Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44e4d55a66 | |||
| 095c84ac16 | |||
| e063eae727 | |||
| f02c5b5c69 | |||
| 838f1d645c |
@@ -32,6 +32,27 @@ Version format: `2.x.y` — examples:
|
||||
npm version patch --no-git-tag-version
|
||||
```
|
||||
|
||||
> **⚠️ ATOMIC COMMIT RULE — Version bump MUST happen before committing feature files.**
|
||||
>
|
||||
> **CORRECT order:**
|
||||
>
|
||||
> 1. `npm version patch --no-git-tag-version` ← bump first
|
||||
> 2. implement features / fix bugs
|
||||
> 3. `git add -A && git commit -m "chore(release): v2.x.y — all changes in ONE commit"`
|
||||
>
|
||||
> **OR if features are already staged:**
|
||||
>
|
||||
> 1. implement features (do NOT commit yet)
|
||||
> 2. `npm version patch --no-git-tag-version` ← bump before committing
|
||||
> 3. `git add -A && git commit -m "chore(release): v2.x.y — all changes in ONE commit"`
|
||||
>
|
||||
> **NEVER do this (creates version mismatch in git history):**
|
||||
>
|
||||
> - ~~commit features → then bump version → commit package.json separately~~
|
||||
>
|
||||
> This ensures that `git show v2.x.y` always contains both code changes and the version bump together.
|
||||
> The GitHub release tag will point to a commit that includes ALL changes for that version.
|
||||
|
||||
### 2. Regenerate lock file (REQUIRED after version bump)
|
||||
|
||||
**Mandatory** — skipping causes `@swc/helpers` lock mismatch and CI failures:
|
||||
|
||||
@@ -4,6 +4,75 @@
|
||||
|
||||
---
|
||||
|
||||
## [2.7.0] — 2026-03-17
|
||||
|
||||
> Sprint: ClawRouter-inspired features — toolCalling flag, multilingual intent detection, benchmark-driven fallback, request deduplication, pluggable RouterStrategy, Grok-4 Fast + GLM-5 + MiniMax M2.5 + Kimi K2.5 pricing.
|
||||
|
||||
### ✨ New Models & Pricing
|
||||
|
||||
- **feat(pricing)**: xAI Grok-4 Fast — `$0.20/$0.50 per 1M tokens`, 1143ms p50 latency, tool calling supported
|
||||
- **feat(pricing)**: xAI Grok-4 (standard) — `$0.20/$1.50 per 1M tokens`, reasoning flagship
|
||||
- **feat(pricing)**: GLM-5 via Z.AI — `$0.5/1M`, 128K output context
|
||||
- **feat(pricing)**: MiniMax M2.5 — `$0.30/1M input`, reasoning + agentic tasks
|
||||
- **feat(pricing)**: DeepSeek V3.2 — updated pricing `$0.27/$1.10 per 1M`
|
||||
- **feat(pricing)**: Kimi K2.5 via Moonshot API — direct Moonshot API access
|
||||
- **feat(providers)**: Z.AI provider added (`zai` alias) — GLM-5 family with 128K output
|
||||
|
||||
### 🧠 Routing Intelligence
|
||||
|
||||
- **feat(registry)**: `toolCalling` flag per model in provider registry — combos can now prefer/require tool-calling capable models
|
||||
- **feat(scoring)**: Multilingual intent detection for AutoCombo scoring — PT/ZH/ES/AR script/language patterns influence model selection per request context
|
||||
- **feat(fallback)**: Benchmark-driven fallback chains — real latency data (p50 from `comboMetrics`) used to re-order fallback priority dynamically
|
||||
- **feat(dedup)**: Request deduplication via content-hash — 5-second idempotency window prevents duplicate provider calls from retrying clients
|
||||
- **feat(router)**: Pluggable `RouterStrategy` interface in `autoCombo/routerStrategy.ts` — custom routing logic can be injected without modifying core
|
||||
|
||||
### 🔧 MCP Server Improvements
|
||||
|
||||
- **feat(mcp)**: 2 new advanced tool schemas: `omniroute_get_provider_metrics` (p50/p95/p99 per provider) and `omniroute_explain_route` (routing decision explanation)
|
||||
- **feat(mcp)**: MCP tool auth scopes updated — `metrics:read` scope added for provider metrics tools
|
||||
- **feat(mcp)**: `omniroute_best_combo_for_task` now accepts `languageHint` parameter for multilingual routing
|
||||
|
||||
### 📊 Observability
|
||||
|
||||
- **feat(metrics)**: `comboMetrics.ts` extended with real-time latency percentile tracking per provider/account
|
||||
- **feat(health)**: Health API (`/api/monitoring/health`) now returns per-provider `p50Latency` and `errorRate` fields
|
||||
- **feat(usage)**: Usage history migration for per-model latency tracking
|
||||
|
||||
### 🗄️ DB Migrations
|
||||
|
||||
- **feat(migrations)**: New column `latency_p50` in `combo_metrics` table — zero-breaking, safe for existing users
|
||||
|
||||
### 🐛 Bug Fixes / Closures
|
||||
|
||||
- **close(#411)**: better-sqlite3 hashed module resolution on Windows — fixed in v2.6.10 (f02c5b5)
|
||||
- **close(#409)**: GitHub Copilot chat completions fail with Claude models when files attached — fixed in v2.6.9 (838f1d6)
|
||||
- **close(#405)**: Duplicate of #411 — resolved
|
||||
|
||||
## [2.6.10] — 2026-03-17
|
||||
|
||||
> Windows fix: better-sqlite3 prebuilt download without node-gyp/Python/MSVC (#426).
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(install/#426)**: On Windows, `npm install -g omniroute` used to fail with `better_sqlite3.node is not a valid Win32 application` because the bundled native binary was compiled for Linux. Adds **Strategy 1.5** to `scripts/postinstall.mjs`: uses `@mapbox/node-pre-gyp install --fallback-to-build=false` (bundled within `better-sqlite3`) to download the correct prebuilt binary for the current OS/arch without requiring any build tools (no node-gyp, no Python, no MSVC). Falls back to `npm rebuild` only if the download fails. Adds platform-specific error messages with clear manual fix instructions.
|
||||
|
||||
---
|
||||
|
||||
## [2.6.9] — 2026-03-17
|
||||
|
||||
> CI fixes (t11 any-budget), bug fix #409 (file attachments via Copilot+Claude), release workflow correction.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(ci)**: Remove word "any" from comments in `openai-responses.ts` and `chatCore.ts` that were failing the t11 `\bany\b` budget check (false positive from regex counting comments)
|
||||
- **fix(chatCore)**: Normalize unsupported content part types before forwarding to providers (#409 — Cursor sends `{type:"file"}` when `.md` files are attached; Copilot and other OpenAI-compat providers reject with "type has to be either 'image_url' or 'text'"; fix converts `file`/`document` blocks to `text` and drops unknown types)
|
||||
|
||||
### 🔧 Workflow
|
||||
|
||||
- **chore(generate-release)**: Add ATOMIC COMMIT RULE — version bump (`npm version patch`) MUST happen before committing feature files to ensure tag always points to a commit containing all version changes together
|
||||
|
||||
---
|
||||
|
||||
## [2.6.8] — 2026-03-17
|
||||
|
||||
> Sprint: Combo as Agent (system prompt + tool filter), Context Caching Protection, Auto-Update, Detailed Logs, MITM Kiro IDE.
|
||||
|
||||
@@ -898,27 +898,44 @@ When minimized, OmniRoute lives in your system tray with quick actions:
|
||||
|
||||
## 💰 Pricing at a Glance
|
||||
|
||||
| Tier | Provider | Cost | Quota Reset | Best For |
|
||||
| ------------------- | ----------------- | ---------------------- | ---------------- | ----------------------- |
|
||||
| **💳 SUBSCRIPTION** | Claude Code (Pro) | $20/mo | 5h + weekly | Already subscribed |
|
||||
| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users |
|
||||
| | Gemini CLI | **FREE** | 180K/mo + 1K/day | Everyone! |
|
||||
| | GitHub Copilot | $10-19/mo | Monthly | GitHub users |
|
||||
| **🔑 API KEY** | NVIDIA NIM | **FREE** (dev forever) | ~40 RPM | 70+ open models |
|
||||
| | Cerebras | **FREE** (1M tok/day) | 60K TPM / 30 RPM | World's fastest |
|
||||
| | Groq | **FREE** (30 RPM) | 14.4K RPD | Ultra-fast Llama/Gemma |
|
||||
| | DeepSeek | Pay-per-use | None | Best price/quality |
|
||||
| | xAI (Grok) | Pay-per-use | None | Grok models |
|
||||
| | Mistral | Free trial + paid | Rate limited | European AI |
|
||||
| | OpenRouter | Pay-per-use | None | 100+ models aggr. |
|
||||
| **💰 CHEAP** | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
|
||||
| | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option |
|
||||
| | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost |
|
||||
| **🆓 FREE** | iFlow | **$0** | Unlimited | 5 models unlimited |
|
||||
| | Qwen | **$0** | Unlimited | 4 models unlimited |
|
||||
| | Kiro | **$0** | Unlimited | Claude (AWS Builder ID) |
|
||||
| Tier | Provider | Cost | Quota Reset | Best For |
|
||||
| ------------------- | --------------------------- | ------------------------- | ---------------- | --------------------------------- |
|
||||
| **💳 SUBSCRIPTION** | Claude Code (Pro) | $20/mo | 5h + weekly | Already subscribed |
|
||||
| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users |
|
||||
| | Gemini CLI | **FREE** | 180K/mo + 1K/day | Everyone! |
|
||||
| | GitHub Copilot | $10-19/mo | Monthly | GitHub users |
|
||||
| **🔑 API KEY** | NVIDIA NIM | **FREE** (dev forever) | ~40 RPM | 70+ open models |
|
||||
| | Cerebras | **FREE** (1M tok/day) | 60K TPM / 30 RPM | World's fastest |
|
||||
| | Groq | **FREE** (30 RPM) | 14.4K RPD | Ultra-fast Llama/Gemma |
|
||||
| | DeepSeek V3.2 | $0.27/$1.10 per 1M | None | Best price/quality reasoning |
|
||||
| | xAI Grok-4 Fast | **$0.20/$0.50 per 1M** 🆕 | None | Fastest + tool calling, ultralow |
|
||||
| | xAI Grok-4 (standard) | $0.20/$1.50 per 1M 🆕 | None | Reasoning flagship from xAI |
|
||||
| | Mistral | Free trial + paid | Rate limited | European AI |
|
||||
| | OpenRouter | Pay-per-use | None | 100+ models aggr. |
|
||||
| **💰 CHEAP** | GLM-5 (via Z.AI) 🆕 | $0.5/1M | Daily 10AM | 128K output, newest flagship |
|
||||
| | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
|
||||
| | MiniMax M2.5 🆕 | $0.3/1M input | 5-hour rolling | Reasoning + agentic tasks |
|
||||
| | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option |
|
||||
| | Kimi K2.5 (Moonshot API) 🆕 | Pay-per-use | None | Direct Moonshot API access |
|
||||
| | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost |
|
||||
| **🆓 FREE** | iFlow | **$0** | Unlimited | 5 models unlimited |
|
||||
| | Qwen | **$0** | Unlimited | 4 models unlimited |
|
||||
| | Kiro | **$0** | Unlimited | Claude Sonnet/Haiku (AWS Builder) |
|
||||
|
||||
**💡 $0 Combo Stack:** Gemini CLI (180K/mo) → iFlow (unlimited: kimi-k2-thinking, qwen3-coder-plus, deepseek-r1) → Kiro (Claude for free) → Qwen (4 models, unlimited) — **Zero cost, never stops coding.** When Gemini quota runs out, OmniRoute auto-falls back to iFlow or Kiro with zero config.
|
||||
> 🆕 **New models added (Mar 2026):** Grok-4 Fast family at $0.20/$0.50/M (benchmarked at 1143ms — 30% faster than Gemini 2.5 Flash), GLM-5 via Z.AI with 128K output, MiniMax M2.5 reasoning, DeepSeek V3.2 updated pricing, Kimi K2.5 via Moonshot direct API.
|
||||
|
||||
**💡 $0 Combo Stack — The Complete Free Setup:**
|
||||
|
||||
```
|
||||
Gemini CLI (180K/mo free)
|
||||
→ iFlow (unlimited: kimi-k2-thinking, qwen3-coder-plus, deepseek-r1)
|
||||
→ Kiro (Claude Sonnet 4.5 + Haiku — unlimited, via AWS Builder ID)
|
||||
→ Qwen (4 models — unlimited)
|
||||
→ Groq (14.4K req/day — ultra-fast)
|
||||
→ NVIDIA NIM (70+ models — 40 RPM forever)
|
||||
```
|
||||
|
||||
**Zero cost. Never stops coding.** Configure this as one OmniRoute combo and all fallbacks happen automatically — no manual switching ever.
|
||||
|
||||
---
|
||||
|
||||
@@ -1027,7 +1044,20 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video
|
||||
|
||||
OmniRoute v2.0 is built as an operational platform, not just a relay proxy.
|
||||
|
||||
### 🚀 New in v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
### 🆕 New — ClawRouter-Inspired Improvements (Mar 2026)
|
||||
|
||||
| Feature | What It Does |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| ⚡ **Grok-4 Fast Family** | xAI models at $0.20/$0.50/M — benchmarked 1143ms (30% faster than Gemini 2.5 Flash) |
|
||||
| 🧠 **GLM-5 via Z.AI** | 128K output context, $0.5/1M — newest flagship from the GLM family |
|
||||
| 🔮 **MiniMax M2.5** | Reasoning + agentic tasks at $0.30/1M — significant upgrade from M2.1 |
|
||||
| 🎯 **toolCalling Flag per Model** | Per-model `toolCalling: true/false` in registry — AutoCombo skips non-tool-capable models |
|
||||
| 🌍 **Multilingual Intent Detection** | PT/ZH/ES/AR keywords in AutoCombo scoring — better model selection for non-English content |
|
||||
| 📊 **Benchmark-Driven Fallbacks** | Real p95 latency from live requests feeds combo scoring — AutoCombo learns from actual data |
|
||||
| 🔁 **Request Deduplication** | Content-hash based dedup window — multi-agent safe, prevents duplicate charges |
|
||||
| 🔌 **Pluggable RouterStrategy** | Extensible `RouterStrategy` interface — add custom routing logic as plugins |
|
||||
|
||||
### 🚀 Previous v2.0.9+ — Playground, CLI Fingerprints & ACP
|
||||
|
||||
| Feature | What It Does |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.6.8
|
||||
version: 2.7.0
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
export interface RegistryModel {
|
||||
id: string;
|
||||
name: string;
|
||||
toolCalling?: boolean;
|
||||
targetFormat?: string;
|
||||
unsupportedParams?: readonly string[];
|
||||
}
|
||||
@@ -139,6 +140,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
clientSecretDefault: "",
|
||||
},
|
||||
models: [
|
||||
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
|
||||
{ id: "gemini-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" },
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
|
||||
@@ -168,6 +172,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
clientSecretDefault: "",
|
||||
},
|
||||
models: [
|
||||
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
|
||||
{ id: "gemini-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" },
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
|
||||
@@ -460,8 +467,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
},
|
||||
models: [
|
||||
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
|
||||
{ id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
|
||||
{ id: "claude-sonnet-4-6-20251031", name: "Claude Sonnet 4.6 (Dated)" },
|
||||
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-opus-4-20250514", name: "Claude Opus 4" },
|
||||
{ id: "claude-opus-4-6-20251031", name: "Claude Opus 4.6 (Dated)" },
|
||||
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
{ id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet" },
|
||||
],
|
||||
},
|
||||
@@ -495,6 +507,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
models: [
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "glm-5-turbo", name: "GLM 5 Turbo" },
|
||||
{ id: "glm-4.7-flash", name: "GLM 4.7 Flash" },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
{ id: "glm-4.6v", name: "GLM 4.6V (Vision)" },
|
||||
@@ -506,6 +520,25 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
zai: {
|
||||
id: "zai",
|
||||
alias: "zai",
|
||||
format: "claude",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
urlSuffix: "?beta=true",
|
||||
authType: "apikey",
|
||||
authHeader: "x-api-key",
|
||||
headers: {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
models: [
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "glm-5-turbo", name: "GLM 5 Turbo" },
|
||||
],
|
||||
},
|
||||
|
||||
kimi: {
|
||||
id: "kimi",
|
||||
alias: "kimi",
|
||||
@@ -637,7 +670,11 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }],
|
||||
models: [
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5 (Legacy Alias)" },
|
||||
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
|
||||
],
|
||||
},
|
||||
|
||||
"minimax-cn": {
|
||||
@@ -655,6 +692,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
},
|
||||
models: [
|
||||
// Keep parity with minimax to ensure model discovery works for minimax-cn connections.
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5 (Legacy Alias)" },
|
||||
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
|
||||
],
|
||||
},
|
||||
@@ -717,10 +756,14 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "grok-4", name: "Grok 4" },
|
||||
{ id: "grok-4-fast-non-reasoning", name: "Grok 4 Fast" },
|
||||
{ id: "grok-4-fast-reasoning", name: "Grok 4 Fast Reasoning" },
|
||||
{ id: "grok-code-fast-1", name: "Grok Code Fast" },
|
||||
{ id: "grok-4-1-fast-non-reasoning", name: "Grok 4.1 Fast" },
|
||||
{ id: "grok-4-1-fast-reasoning", name: "Grok 4.1 Fast Reasoning" },
|
||||
{ id: "grok-4-0709", name: "Grok 4 (0709)" },
|
||||
{ id: "grok-4", name: "Grok 4" },
|
||||
{ id: "grok-3", name: "Grok 3" },
|
||||
{ id: "grok-3-mini", name: "Grok 3 Mini" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -849,7 +892,10 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "gpt-oss-120b", name: "GPT OSS 120B", toolCalling: false },
|
||||
{ id: "openai/gpt-oss-120b", name: "GPT OSS 120B (OpenAI Prefix)", toolCalling: false },
|
||||
{ id: "meta/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
|
||||
{ id: "nvidia/llama-3.3-70b-instruct", name: "Llama 3.3 70B (NVIDIA Prefix)" },
|
||||
{ id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" },
|
||||
{ id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "z-ai/glm4.7", name: "GLM 4.7" },
|
||||
|
||||
+121
-28
@@ -42,6 +42,7 @@ import {
|
||||
import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idempotencyLayer";
|
||||
import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts";
|
||||
import { isModelUnavailableError, getNextFamilyFallback } from "../services/modelFamilyFallback.ts";
|
||||
import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts";
|
||||
|
||||
export function shouldUseNativeCodexPassthrough({
|
||||
provider,
|
||||
@@ -89,6 +90,22 @@ export async function handleChatCore({
|
||||
}) {
|
||||
const { provider, model, extendedContext } = modelInfo;
|
||||
const startTime = Date.now();
|
||||
const persistFailureUsage = (statusCode: number, errorCode?: string | null) => {
|
||||
saveRequestUsage({
|
||||
provider: provider || "unknown",
|
||||
model: model || "unknown",
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, reasoning: 0 },
|
||||
status: String(statusCode),
|
||||
success: false,
|
||||
latencyMs: Date.now() - startTime,
|
||||
timeToFirstTokenMs: 0,
|
||||
errorCode: errorCode || String(statusCode),
|
||||
timestamp: new Date().toISOString(),
|
||||
connectionId: connectionId || undefined,
|
||||
apiKeyId: apiKeyInfo?.id || undefined,
|
||||
apiKeyName: apiKeyInfo?.name || undefined,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
// ── Phase 9.2: Idempotency check ──
|
||||
const idempotencyKey = getIdempotencyKey(clientRawRequest?.headers);
|
||||
@@ -193,7 +210,7 @@ export async function handleChatCore({
|
||||
} else if (isClaudePassthrough) {
|
||||
// Claude-to-Claude passthrough: forward body completely untouched.
|
||||
// No translation, no field stripping, no thinking normalization.
|
||||
// We are just a gateway -- do not interfere with the request in any way.
|
||||
// We are just a gateway -- do not interfere with the request in the slightest.
|
||||
translatedBody = { ...body };
|
||||
log?.debug?.("FORMAT", "claude->claude passthrough -- forwarding untouched");
|
||||
} else {
|
||||
@@ -246,8 +263,44 @@ export async function handleChatCore({
|
||||
if (Array.isArray(translatedBody.messages)) {
|
||||
for (const msg of translatedBody.messages) {
|
||||
if (Array.isArray(msg.content)) {
|
||||
msg.content = msg.content.filter((block: Record<string, unknown>) =>
|
||||
block.type !== "text" || (typeof block.text === "string" && block.text.length > 0)
|
||||
msg.content = msg.content.filter(
|
||||
(block: Record<string, unknown>) =>
|
||||
block.type !== "text" || (typeof block.text === "string" && block.text.length > 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── #409: Normalize unsupported content part types ──
|
||||
// Cursor and other clients send {type:"file"} when attaching .md or other files.
|
||||
// Providers (Copilot, OpenAI) only accept "text" and "image_url" in content arrays.
|
||||
// Convert: file → text (extract content), drop unrecognized types with a warning.
|
||||
if (Array.isArray(translatedBody.messages)) {
|
||||
for (const msg of translatedBody.messages) {
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
msg.content = (msg.content as Record<string, unknown>[]).flatMap(
|
||||
(block: Record<string, unknown>) => {
|
||||
if (block.type === "text" || block.type === "image_url" || block.type === "image") {
|
||||
return [block];
|
||||
}
|
||||
// file / document → extract text content
|
||||
if (block.type === "file" || block.type === "document") {
|
||||
const fileContent =
|
||||
(block.file as Record<string, unknown>)?.content ??
|
||||
(block.file as Record<string, unknown>)?.text ??
|
||||
block.content ??
|
||||
block.text;
|
||||
const fileName =
|
||||
(block.file as Record<string, unknown>)?.name ?? block.name ?? "attachment";
|
||||
if (typeof fileContent === "string" && fileContent.length > 0) {
|
||||
return [{ type: "text", text: `[${fileName}]\n${fileContent}` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
// Unknown types: drop silently
|
||||
log?.debug?.("CONTENT", `Dropped unsupported content part type="${block.type}"`);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -328,6 +381,57 @@ export async function handleChatCore({
|
||||
// Get executor for this provider
|
||||
const executor = getExecutor(provider);
|
||||
|
||||
// Create stream controller for disconnect detection
|
||||
const streamController = createStreamController({ onDisconnect, log, provider, model });
|
||||
|
||||
const dedupRequestBody = { ...translatedBody, model: `${provider}/${model}` };
|
||||
const dedupEnabled = shouldDeduplicate(dedupRequestBody);
|
||||
const dedupHash = dedupEnabled ? computeRequestHash(dedupRequestBody) : null;
|
||||
|
||||
const executeProviderRequest = async (modelToCall = model, allowDedup = false) => {
|
||||
const execute = async () => {
|
||||
const bodyToSend =
|
||||
translatedBody.model === modelToCall
|
||||
? translatedBody
|
||||
: { ...translatedBody, model: modelToCall };
|
||||
|
||||
const rawResult = await withRateLimit(provider, connectionId, modelToCall, () =>
|
||||
executor.execute({
|
||||
model: modelToCall,
|
||||
body: bodyToSend,
|
||||
stream,
|
||||
credentials,
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
extendedContext,
|
||||
})
|
||||
);
|
||||
|
||||
if (stream) return rawResult;
|
||||
|
||||
// Non-stream responses need cloning for shared dedup consumers.
|
||||
const status = rawResult.response.status;
|
||||
const statusText = rawResult.response.statusText;
|
||||
const headers = Array.from(rawResult.response.headers.entries());
|
||||
const payload = await rawResult.response.text();
|
||||
|
||||
return {
|
||||
...rawResult,
|
||||
response: new Response(payload, { status, statusText, headers }),
|
||||
};
|
||||
};
|
||||
|
||||
if (allowDedup && dedupEnabled && dedupHash) {
|
||||
const dedupResult = await deduplicate(dedupHash, execute);
|
||||
if (dedupResult.wasDeduplicated) {
|
||||
log?.debug?.("DEDUP", `Joined in-flight request hash=${dedupHash}`);
|
||||
}
|
||||
return dedupResult.result;
|
||||
}
|
||||
|
||||
return execute();
|
||||
};
|
||||
|
||||
// Track pending request
|
||||
trackPendingRequest(model, provider, connectionId, true);
|
||||
|
||||
@@ -345,9 +449,6 @@ export async function handleChatCore({
|
||||
0;
|
||||
log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`);
|
||||
|
||||
// Create stream controller for disconnect detection
|
||||
const streamController = createStreamController({ onDisconnect, log, provider, model });
|
||||
|
||||
// Execute request using executor (handles URL building, headers, fallback, transform)
|
||||
let providerResponse;
|
||||
let providerUrl;
|
||||
@@ -355,17 +456,7 @@ export async function handleChatCore({
|
||||
let finalBody;
|
||||
|
||||
try {
|
||||
const result = await withRateLimit(provider, connectionId, model, () =>
|
||||
executor.execute({
|
||||
model,
|
||||
body: translatedBody,
|
||||
stream,
|
||||
credentials,
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
extendedContext,
|
||||
})
|
||||
);
|
||||
const result = await executeProviderRequest(model, true);
|
||||
|
||||
providerResponse = result.response;
|
||||
providerUrl = result.url;
|
||||
@@ -412,6 +503,7 @@ export async function handleChatCore({
|
||||
streamController.handleError(error);
|
||||
return createErrorResult(499, "Request aborted");
|
||||
}
|
||||
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, error?.name || "upstream_error");
|
||||
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
|
||||
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
|
||||
@@ -521,17 +613,7 @@ export async function handleChatCore({
|
||||
log?.info?.("MODEL_FALLBACK", `${model} unavailable (${statusCode}) → trying ${nextModel}`);
|
||||
// Re-execute with the fallback model
|
||||
try {
|
||||
const fallbackResult = await withRateLimit(provider, connectionId, nextModel, () =>
|
||||
executor.execute({
|
||||
model: nextModel,
|
||||
body: translatedBody,
|
||||
stream,
|
||||
credentials,
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
extendedContext,
|
||||
})
|
||||
);
|
||||
const fallbackResult = await executeProviderRequest(nextModel, false);
|
||||
if (fallbackResult.response.ok) {
|
||||
providerResponse = fallbackResult.response;
|
||||
providerUrl = fallbackResult.url;
|
||||
@@ -543,15 +625,19 @@ export async function handleChatCore({
|
||||
// We fall through by NOT returning here
|
||||
} else {
|
||||
// Fallback also failed — return original error
|
||||
persistFailureUsage(statusCode, "model_unavailable");
|
||||
return createErrorResult(statusCode, errMsg, retryAfterMs);
|
||||
}
|
||||
} catch {
|
||||
persistFailureUsage(statusCode, "model_unavailable");
|
||||
return createErrorResult(statusCode, errMsg, retryAfterMs);
|
||||
}
|
||||
} else {
|
||||
persistFailureUsage(statusCode, "model_unavailable");
|
||||
return createErrorResult(statusCode, errMsg, retryAfterMs);
|
||||
}
|
||||
} else {
|
||||
persistFailureUsage(statusCode, `upstream_${statusCode}`);
|
||||
return createErrorResult(statusCode, errMsg, retryAfterMs);
|
||||
}
|
||||
// ── End T5 ───────────────────────────────────────────────────────────────
|
||||
@@ -580,6 +666,7 @@ export async function handleChatCore({
|
||||
connectionId,
|
||||
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
|
||||
}).catch(() => {});
|
||||
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_sse_payload");
|
||||
return createErrorResult(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
"Invalid SSE response for non-streaming request"
|
||||
@@ -597,6 +684,7 @@ export async function handleChatCore({
|
||||
connectionId,
|
||||
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
|
||||
}).catch(() => {});
|
||||
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_json_payload");
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid JSON response from provider");
|
||||
}
|
||||
}
|
||||
@@ -639,6 +727,11 @@ export async function handleChatCore({
|
||||
provider: provider || "unknown",
|
||||
model: model || "unknown",
|
||||
tokens: usage,
|
||||
status: "200",
|
||||
success: true,
|
||||
latencyMs: Date.now() - startTime,
|
||||
timeToFirstTokenMs: Date.now() - startTime,
|
||||
errorCode: null,
|
||||
timestamp: new Date().toISOString(),
|
||||
connectionId: connectionId || undefined,
|
||||
apiKeyId: apiKeyInfo?.id || undefined,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
MCP_TOOLS,
|
||||
MCP_TOOL_MAP,
|
||||
setRoutingStrategyInput,
|
||||
setRoutingStrategyTool,
|
||||
} from "../schemas/tools.ts";
|
||||
|
||||
describe("omniroute_set_routing_strategy MCP tool schema", () => {
|
||||
it("should be registered in MCP_TOOLS", () => {
|
||||
const tool = MCP_TOOLS.find((t) => t.name === "omniroute_set_routing_strategy");
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool?.phase).toBe(2);
|
||||
});
|
||||
|
||||
it("should be available in MCP_TOOL_MAP", () => {
|
||||
expect(MCP_TOOL_MAP["omniroute_set_routing_strategy"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should require write:combos scope", () => {
|
||||
expect(setRoutingStrategyTool.scopes).toContain("write:combos");
|
||||
});
|
||||
|
||||
it("should validate a standard strategy payload", () => {
|
||||
const result = setRoutingStrategyInput.safeParse({
|
||||
comboId: "my-combo",
|
||||
strategy: "cost-optimized",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate auto strategy with autoRoutingStrategy", () => {
|
||||
const result = setRoutingStrategyInput.safeParse({
|
||||
comboId: "my-combo",
|
||||
strategy: "auto",
|
||||
autoRoutingStrategy: "latency",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject unknown strategy", () => {
|
||||
const result = setRoutingStrategyInput.safeParse({
|
||||
comboId: "my-combo",
|
||||
strategy: "unknown-strategy",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -107,6 +107,7 @@ export const listCombosOutput = z.object({
|
||||
"priority",
|
||||
"weighted",
|
||||
"round-robin",
|
||||
"strict-random",
|
||||
"random",
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
@@ -470,7 +471,53 @@ export const setBudgetGuardTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/usage/budget"],
|
||||
};
|
||||
|
||||
// --- Tool 11: omniroute_set_resilience_profile ---
|
||||
// --- Tool 11: omniroute_set_routing_strategy ---
|
||||
export const setRoutingStrategyInput = z.object({
|
||||
comboId: z.string().describe("Combo ID or name to update"),
|
||||
strategy: z
|
||||
.enum([
|
||||
"priority",
|
||||
"weighted",
|
||||
"round-robin",
|
||||
"strict-random",
|
||||
"random",
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
"auto",
|
||||
])
|
||||
.describe("Routing strategy to apply"),
|
||||
autoRoutingStrategy: z
|
||||
.enum(["rules", "cost", "eco", "latency", "fast"])
|
||||
.optional()
|
||||
.describe("Optional strategy used by auto mode (only used when strategy='auto')"),
|
||||
});
|
||||
|
||||
export const setRoutingStrategyOutput = z.object({
|
||||
success: z.boolean(),
|
||||
combo: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
strategy: z.string(),
|
||||
autoRoutingStrategy: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const setRoutingStrategyTool: McpToolDefinition<
|
||||
typeof setRoutingStrategyInput,
|
||||
typeof setRoutingStrategyOutput
|
||||
> = {
|
||||
name: "omniroute_set_routing_strategy",
|
||||
description:
|
||||
"Updates a combo routing strategy (priority/weighted/auto/etc.) at runtime. Supports selecting the sub-strategy used by auto mode (rules/cost/latency).",
|
||||
inputSchema: setRoutingStrategyInput,
|
||||
outputSchema: setRoutingStrategyOutput,
|
||||
scopes: ["write:combos"],
|
||||
auditLevel: "full",
|
||||
phase: 2,
|
||||
sourceEndpoints: ["/api/combos", "/api/combos/{id}"],
|
||||
};
|
||||
|
||||
// --- Tool 12: omniroute_set_resilience_profile ---
|
||||
export const setResilienceProfileInput = z.object({
|
||||
profile: z
|
||||
.enum(["aggressive", "balanced", "conservative"])
|
||||
@@ -502,7 +549,7 @@ export const setResilienceProfileTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/resilience"],
|
||||
};
|
||||
|
||||
// --- Tool 12: omniroute_test_combo ---
|
||||
// --- Tool 13: omniroute_test_combo ---
|
||||
export const testComboInput = z.object({
|
||||
comboId: z.string().describe("ID of the combo to test"),
|
||||
testPrompt: z.string().max(500).describe("Short test prompt (max 500 chars)"),
|
||||
@@ -540,7 +587,7 @@ export const testComboTool: McpToolDefinition<typeof testComboInput, typeof test
|
||||
sourceEndpoints: ["/api/combos/test", "/v1/chat/completions"],
|
||||
};
|
||||
|
||||
// --- Tool 13: omniroute_get_provider_metrics ---
|
||||
// --- Tool 14: omniroute_get_provider_metrics ---
|
||||
export const getProviderMetricsInput = z.object({
|
||||
provider: z.string().describe("Provider name (e.g., 'claude', 'gemini-cli', 'codex')"),
|
||||
});
|
||||
@@ -583,7 +630,7 @@ export const getProviderMetricsTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/provider-metrics", "/api/resilience"],
|
||||
};
|
||||
|
||||
// --- Tool 14: omniroute_best_combo_for_task ---
|
||||
// --- Tool 15: omniroute_best_combo_for_task ---
|
||||
export const bestComboForTaskInput = z.object({
|
||||
taskType: z
|
||||
.enum(["coding", "review", "planning", "analysis", "debugging", "documentation"])
|
||||
@@ -628,7 +675,7 @@ export const bestComboForTaskTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/combos", "/api/combos/metrics", "/api/monitoring/health"],
|
||||
};
|
||||
|
||||
// --- Tool 15: omniroute_explain_route ---
|
||||
// --- Tool 16: omniroute_explain_route ---
|
||||
export const explainRouteInput = z.object({
|
||||
requestId: z.string().describe("Request ID from the X-Request-Id header"),
|
||||
});
|
||||
@@ -674,7 +721,7 @@ export const explainRouteTool: McpToolDefinition<
|
||||
sourceEndpoints: [],
|
||||
};
|
||||
|
||||
// --- Tool 16: omniroute_get_session_snapshot ---
|
||||
// --- Tool 17: omniroute_get_session_snapshot ---
|
||||
export const getSessionSnapshotInput = z.object({}).describe("No parameters required");
|
||||
|
||||
export const getSessionSnapshotOutput = z.object({
|
||||
@@ -723,7 +770,7 @@ export const getSessionSnapshotTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/usage/analytics", "/api/telemetry/summary"],
|
||||
};
|
||||
|
||||
// --- Tool 17: omniroute_sync_pricing ---
|
||||
// --- Tool 18: omniroute_sync_pricing ---
|
||||
export const syncPricingInput = z.object({
|
||||
sources: z
|
||||
.array(z.string())
|
||||
@@ -775,6 +822,7 @@ export const MCP_TOOLS = [
|
||||
// Phase 2: Advanced
|
||||
simulateRouteTool,
|
||||
setBudgetGuardTool,
|
||||
setRoutingStrategyTool,
|
||||
setResilienceProfileTool,
|
||||
testComboTool,
|
||||
getProviderMetricsTool,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
listModelsCatalogInput,
|
||||
simulateRouteInput,
|
||||
setBudgetGuardInput,
|
||||
setRoutingStrategyInput,
|
||||
setResilienceProfileInput,
|
||||
testComboInput,
|
||||
getProviderMetricsInput,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
import {
|
||||
handleSimulateRoute,
|
||||
handleSetBudgetGuard,
|
||||
handleSetRoutingStrategy,
|
||||
handleSetResilienceProfile,
|
||||
handleTestCombo,
|
||||
handleGetProviderMetrics,
|
||||
@@ -593,6 +595,18 @@ export function createMcpServer(): McpServer {
|
||||
)
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_set_routing_strategy",
|
||||
{
|
||||
description:
|
||||
"Updates combo routing strategy at runtime (priority/weighted/round-robin/auto/etc.)",
|
||||
inputSchema: setRoutingStrategyInput,
|
||||
},
|
||||
withScopeEnforcement("omniroute_set_routing_strategy", (args) =>
|
||||
handleSetRoutingStrategy(setRoutingStrategyInput.parse(args))
|
||||
)
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_set_resilience_profile",
|
||||
{
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* OmniRoute MCP Advanced Tools — 8 intelligence tools that differentiate
|
||||
* OmniRoute MCP Advanced Tools — 10 intelligence tools that differentiate
|
||||
* OmniRoute from all other AI gateways.
|
||||
*
|
||||
* Tools:
|
||||
* 1. omniroute_simulate_route — Dry-run routing simulation
|
||||
* 2. omniroute_set_budget_guard — Session budget with degrade/block/alert
|
||||
* 3. omniroute_set_resilience_profile — Circuit breaker/retry profiles
|
||||
* 4. omniroute_test_combo — Live test each provider in a combo
|
||||
* 5. omniroute_get_provider_metrics — Detailed per-provider metrics
|
||||
* 6. omniroute_best_combo_for_task — AI-powered combo recommendation
|
||||
* 7. omniroute_explain_route — Post-hoc routing decision explainer
|
||||
* 8. omniroute_get_session_snapshot — Full session state snapshot
|
||||
* 3. omniroute_set_routing_strategy — Runtime strategy switch for combos
|
||||
* 4. omniroute_set_resilience_profile — Circuit breaker/retry profiles
|
||||
* 5. omniroute_test_combo — Live test each provider in a combo
|
||||
* 6. omniroute_get_provider_metrics — Detailed per-provider metrics
|
||||
* 7. omniroute_best_combo_for_task — AI-powered combo recommendation
|
||||
* 8. omniroute_explain_route — Post-hoc routing decision explainer
|
||||
* 9. omniroute_get_session_snapshot — Full session state snapshot
|
||||
* 10. omniroute_sync_pricing — Sync provider pricing from external source
|
||||
*/
|
||||
|
||||
import { logToolCall } from "../audit.ts";
|
||||
@@ -335,6 +337,108 @@ export async function handleSetBudgetGuard(args: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSetRoutingStrategy(args: {
|
||||
comboId: string;
|
||||
strategy:
|
||||
| "priority"
|
||||
| "weighted"
|
||||
| "round-robin"
|
||||
| "strict-random"
|
||||
| "random"
|
||||
| "least-used"
|
||||
| "cost-optimized"
|
||||
| "auto";
|
||||
autoRoutingStrategy?: "rules" | "cost" | "eco" | "latency" | "fast";
|
||||
}) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const combos = normalizeCombosResponse(await apiFetch("/api/combos"));
|
||||
const combo = combos.find(
|
||||
(comboEntry) =>
|
||||
toString(comboEntry.id) === args.comboId || toString(comboEntry.name) === args.comboId
|
||||
);
|
||||
|
||||
if (!combo) {
|
||||
const msg = `Combo '${args.comboId}' not found`;
|
||||
await logToolCall(
|
||||
"omniroute_set_routing_strategy",
|
||||
args,
|
||||
null,
|
||||
Date.now() - start,
|
||||
false,
|
||||
msg
|
||||
);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
|
||||
const comboId = toString(combo.id);
|
||||
if (!comboId) {
|
||||
const msg = "Matched combo has no id";
|
||||
await logToolCall(
|
||||
"omniroute_set_routing_strategy",
|
||||
args,
|
||||
null,
|
||||
Date.now() - start,
|
||||
false,
|
||||
msg
|
||||
);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
|
||||
const comboData = toRecord(combo.data);
|
||||
const currentConfig = toRecord(
|
||||
Object.keys(toRecord(combo.config)).length > 0 ? combo.config : comboData.config
|
||||
);
|
||||
|
||||
let nextConfig: JsonRecord | undefined = undefined;
|
||||
if (args.strategy === "auto" && args.autoRoutingStrategy) {
|
||||
const currentAutoConfig = toRecord(currentConfig.auto);
|
||||
nextConfig = {
|
||||
...currentConfig,
|
||||
auto: {
|
||||
...currentAutoConfig,
|
||||
routingStrategy: args.autoRoutingStrategy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const payload: JsonRecord = { strategy: args.strategy };
|
||||
if (nextConfig && Object.keys(nextConfig).length > 0) {
|
||||
payload.config = nextConfig;
|
||||
}
|
||||
|
||||
const updatedCombo = toRecord(
|
||||
await apiFetch(`/api/combos/${encodeURIComponent(comboId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
);
|
||||
|
||||
const updatedConfig = toRecord(updatedCombo.config);
|
||||
const resolvedAutoStrategy =
|
||||
toString(toRecord(updatedConfig.auto).routingStrategy) ||
|
||||
(args.strategy === "auto" ? (args.autoRoutingStrategy ?? "rules") : "");
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
combo: {
|
||||
id: toString(updatedCombo.id, comboId),
|
||||
name: toString(updatedCombo.name, toString(combo.name, comboId)),
|
||||
strategy: toString(updatedCombo.strategy, args.strategy),
|
||||
autoRoutingStrategy:
|
||||
toString(updatedCombo.strategy, args.strategy) === "auto" ? resolvedAutoStrategy : null,
|
||||
},
|
||||
};
|
||||
|
||||
await logToolCall("omniroute_set_routing_strategy", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await logToolCall("omniroute_set_routing_strategy", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSetResilienceProfile(args: {
|
||||
profile: "aggressive" | "balanced" | "conservative";
|
||||
}) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* RouterStrategy — Pluggable Routing Strategy System
|
||||
*
|
||||
* Inspired by ClawRouter commit 14c83c258 "refactor: extract routing into pluggable RouterStrategy system".
|
||||
* Provides a RouterStrategy interface and two built-in implementations:
|
||||
* - RulesStrategy (default): wraps the existing 6-factor scoring engine
|
||||
* - CostStrategy: always picks cheapest available model
|
||||
*/
|
||||
|
||||
import type { ProviderCandidate, ScoredProvider } from "./scoring.js";
|
||||
import { scorePool } from "./scoring.js";
|
||||
import { getTaskFitness } from "./taskFitness.js";
|
||||
|
||||
export interface RoutingContext {
|
||||
taskType: string;
|
||||
requestHasTools?: boolean;
|
||||
requestHasVision?: boolean;
|
||||
estimatedInputTokens?: number;
|
||||
}
|
||||
|
||||
export interface RoutingDecision {
|
||||
provider: string;
|
||||
model: string;
|
||||
strategy: string;
|
||||
reason: string;
|
||||
candidatesConsidered: number;
|
||||
finalScore: number;
|
||||
}
|
||||
|
||||
export interface RouterStrategy {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
select(pool: ProviderCandidate[], context: RoutingContext): RoutingDecision;
|
||||
}
|
||||
|
||||
// ── RulesStrategy: wraps 6-factor scoring engine ────────────────────────────
|
||||
|
||||
class RulesStrategyImpl implements RouterStrategy {
|
||||
readonly name = "rules";
|
||||
readonly description =
|
||||
"6-factor weighted scoring: quota, health, cost, latency, taskFit, stability";
|
||||
|
||||
select(pool: ProviderCandidate[], context: RoutingContext): RoutingDecision {
|
||||
const eligible = pool.filter((c) => c.circuitBreakerState !== "OPEN");
|
||||
const ranked: ScoredProvider[] = scorePool(
|
||||
eligible.length > 0 ? eligible : pool,
|
||||
context.taskType,
|
||||
undefined,
|
||||
getTaskFitness
|
||||
);
|
||||
const best = ranked[0];
|
||||
if (!best) throw new Error("[RulesStrategy] No candidates to score");
|
||||
return {
|
||||
provider: best.provider,
|
||||
model: best.model,
|
||||
strategy: this.name,
|
||||
reason: `RulesStrategy: score=${best.score.toFixed(3)} (quota=${best.factors.quota.toFixed(2)}, health=${best.factors.health.toFixed(2)}, cost=${best.factors.costInv.toFixed(2)}, taskFit=${best.factors.taskFit.toFixed(2)})`,
|
||||
candidatesConsidered: ranked.length,
|
||||
finalScore: best.score,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── CostStrategy: always picks cheapest healthy provider ─────────────────────
|
||||
|
||||
class CostStrategyImpl implements RouterStrategy {
|
||||
readonly name = "cost";
|
||||
readonly description = "Always selects cheapest available provider (by costPer1MTokens)";
|
||||
|
||||
select(pool: ProviderCandidate[], context: RoutingContext): RoutingDecision {
|
||||
const healthy = pool.filter((c) => c.circuitBreakerState !== "OPEN");
|
||||
const candidates = healthy.length > 0 ? healthy : pool;
|
||||
const sorted = [...candidates].sort((a, b) => a.costPer1MTokens - b.costPer1MTokens);
|
||||
const best = sorted[0];
|
||||
if (!best) throw new Error("[CostStrategy] No candidates available");
|
||||
return {
|
||||
provider: best.provider,
|
||||
model: best.model,
|
||||
strategy: this.name,
|
||||
reason: `CostStrategy: cheapest at $${best.costPer1MTokens.toFixed(3)}/1M tokens`,
|
||||
candidatesConsidered: candidates.length,
|
||||
finalScore: best.costPer1MTokens === 0 ? 1.0 : 1 / best.costPer1MTokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── LatencyStrategy: prioritize low latency + reliability ───────────────────
|
||||
|
||||
class LatencyStrategyImpl implements RouterStrategy {
|
||||
readonly name = "latency";
|
||||
readonly description = "Prioritizes lowest p95 latency with reliability weighting";
|
||||
|
||||
select(pool: ProviderCandidate[], context: RoutingContext): RoutingDecision {
|
||||
const healthy = pool.filter((c) => c.circuitBreakerState !== "OPEN");
|
||||
const candidates = healthy.length > 0 ? healthy : pool;
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
const aPenalty = a.errorRate * 1000;
|
||||
const bPenalty = b.errorRate * 1000;
|
||||
return a.p95LatencyMs + aPenalty - (b.p95LatencyMs + bPenalty);
|
||||
});
|
||||
const best = sorted[0];
|
||||
if (!best) throw new Error("[LatencyStrategy] No candidates available");
|
||||
|
||||
const latencyScore = best.p95LatencyMs > 0 ? Math.max(0.001, 10_000 / best.p95LatencyMs) : 1;
|
||||
const reliability = Math.max(0, 1 - best.errorRate);
|
||||
const finalScore = latencyScore * 0.7 + reliability * 0.3;
|
||||
|
||||
return {
|
||||
provider: best.provider,
|
||||
model: best.model,
|
||||
strategy: this.name,
|
||||
reason: `LatencyStrategy: p95=${best.p95LatencyMs}ms, errorRate=${(best.errorRate * 100).toFixed(2)}%`,
|
||||
candidatesConsidered: candidates.length,
|
||||
finalScore,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const strategyRegistry = new Map<string, RouterStrategy>();
|
||||
|
||||
const rulesStrategy = new RulesStrategyImpl();
|
||||
const costStrategy = new CostStrategyImpl();
|
||||
const latencyStrategy = new LatencyStrategyImpl();
|
||||
|
||||
strategyRegistry.set("rules", rulesStrategy);
|
||||
strategyRegistry.set("cost", costStrategy);
|
||||
strategyRegistry.set("eco", costStrategy); // alias
|
||||
strategyRegistry.set("latency", latencyStrategy);
|
||||
strategyRegistry.set("fast", latencyStrategy); // alias
|
||||
|
||||
export function getStrategy(name: string): RouterStrategy {
|
||||
const strategy = strategyRegistry.get(name);
|
||||
if (!strategy) {
|
||||
console.warn(`[RouterStrategy] Strategy '${name}' not found, falling back to 'rules'`);
|
||||
return rulesStrategy;
|
||||
}
|
||||
return strategy;
|
||||
}
|
||||
|
||||
export function registerStrategy(name: string, strategy: RouterStrategy): void {
|
||||
if (strategyRegistry.has(name)) {
|
||||
console.warn(`[RouterStrategy] Overwriting strategy '${name}'`);
|
||||
}
|
||||
strategyRegistry.set(name, strategy);
|
||||
}
|
||||
|
||||
export function listStrategies(): Array<{ name: string; description: string }> {
|
||||
return [...strategyRegistry.entries()].map(([name, s]) => ({ name, description: s.description }));
|
||||
}
|
||||
|
||||
export function selectWithStrategy(
|
||||
pool: ProviderCandidate[],
|
||||
context: RoutingContext,
|
||||
strategyName = "rules"
|
||||
): RoutingDecision {
|
||||
return getStrategy(strategyName).select(pool, context);
|
||||
}
|
||||
@@ -74,7 +74,8 @@ export function calculateScore(factors: ScoringFactors, weights: ScoringWeights)
|
||||
weights.costInv * factors.costInv +
|
||||
weights.latencyInv * factors.latencyInv +
|
||||
weights.taskFit * factors.taskFit +
|
||||
weights.stability * factors.stability
|
||||
weights.stability * factors.stability +
|
||||
weights.tierPriority * factors.tierPriority
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,23 @@ const FITNESS_TABLE: Record<string, Record<string, number>> = {
|
||||
"deepseek-coder": 0.9,
|
||||
"deepseek-v3": 0.85,
|
||||
"deepseek-r1": 0.88,
|
||||
"deepseek-chat": 0.84, // DeepSeek V3.2 Chat — strong code performance
|
||||
"deepseek-v3.2": 0.86, // Explicit V3.2 alias
|
||||
qwen: 0.78,
|
||||
llama: 0.72,
|
||||
mistral: 0.75,
|
||||
mixtral: 0.77,
|
||||
// Grok-4 fast — good code, ultra-low latency (1143ms P50)
|
||||
"grok-4-fast": 0.8,
|
||||
"grok-4": 0.82,
|
||||
"grok-3": 0.8,
|
||||
// Kimi K2.5 — agentic with tool calling, good at code tasks
|
||||
"kimi-k2": 0.82,
|
||||
// GLM-5 — Z.AI model with 128k output
|
||||
"glm-5": 0.78,
|
||||
// MiniMax M2.5 — reasoning support helps complex code
|
||||
"minimax-m2.5": 0.75,
|
||||
"minimax-m2": 0.72,
|
||||
},
|
||||
review: {
|
||||
"claude-sonnet": 0.92,
|
||||
@@ -58,10 +71,15 @@ const FITNESS_TABLE: Record<string, Record<string, number>> = {
|
||||
"claude-sonnet": 0.92,
|
||||
"gemini-2.5-pro": 0.95,
|
||||
"gemini-pro": 0.88,
|
||||
"gemini-3.1-pro": 0.95, // Gemini 3.1 Pro — 1M context, ideal for long analysis
|
||||
"gpt-4o": 0.85,
|
||||
o1: 0.9,
|
||||
o3: 0.93,
|
||||
"deepseek-r1": 0.88,
|
||||
"deepseek-chat": 0.8,
|
||||
"kimi-k2": 0.82, // Kimi K2.5 agentic — good for analysis
|
||||
"glm-5": 0.78, // GLM-5 with 128k output for long analysis
|
||||
"minimax-m2.5": 0.76,
|
||||
},
|
||||
debugging: {
|
||||
"claude-sonnet": 0.93,
|
||||
@@ -87,8 +105,17 @@ const FITNESS_TABLE: Record<string, Record<string, number>> = {
|
||||
"claude-opus": 0.85,
|
||||
"gpt-4o": 0.85,
|
||||
"gemini-pro": 0.8,
|
||||
"gemini-3.1-pro": 0.85,
|
||||
"deepseek-v3": 0.75,
|
||||
"deepseek-chat": 0.74,
|
||||
"gemini-flash": 0.72,
|
||||
// New models from ClawRouter analysis (2026-03-17):
|
||||
"grok-4-fast": 0.72, // ultra-fast, suitable for all tasks
|
||||
"grok-4": 0.74,
|
||||
"grok-3": 0.73,
|
||||
"kimi-k2": 0.76, // agentic multi-step tasks
|
||||
"glm-5": 0.7,
|
||||
"minimax-m2.5": 0.7,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+295
-2
@@ -5,19 +5,36 @@
|
||||
|
||||
import { checkFallbackError, formatRetryAfter, getProviderProfile } from "./accountFallback.ts";
|
||||
import { unavailableResponse } from "../utils/error.ts";
|
||||
import { recordComboRequest, getComboMetrics } from "./comboMetrics.ts";
|
||||
import { recordComboIntent, recordComboRequest, getComboMetrics } from "./comboMetrics.ts";
|
||||
import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.ts";
|
||||
import * as semaphore from "./rateLimitSemaphore.ts";
|
||||
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker";
|
||||
import { fisherYatesShuffle, getNextFromDeck } from "../../src/shared/utils/shuffleDeck";
|
||||
import { parseModel } from "./model.ts";
|
||||
import { applyComboAgentMiddleware, injectModelTag } from "./comboAgentMiddleware.ts";
|
||||
import { classifyWithConfig, DEFAULT_INTENT_CONFIG } from "./intentClassifier.ts";
|
||||
import { selectProvider as selectAutoProvider } from "./autoCombo/engine.ts";
|
||||
import { selectWithStrategy } from "./autoCombo/routerStrategy.ts";
|
||||
import { DEFAULT_WEIGHTS, scorePool } from "./autoCombo/scoring.ts";
|
||||
import { supportsToolCalling } from "./modelCapabilities.ts";
|
||||
|
||||
// Status codes that should mark semaphore + record circuit breaker failures
|
||||
const TRANSIENT_FOR_BREAKER = [429, 502, 503, 504];
|
||||
|
||||
const MAX_COMBO_DEPTH = 3;
|
||||
|
||||
// Bootstrap defaults from ClawRouter benchmark (used when no local latency history exists yet)
|
||||
const DEFAULT_MODEL_P95_MS = {
|
||||
"grok-4-fast-non-reasoning": 1143,
|
||||
"grok-4-1-fast-non-reasoning": 1244,
|
||||
"gemini-2.5-flash": 1238,
|
||||
"kimi-k2.5": 1646,
|
||||
"gpt-4o-mini": 2764,
|
||||
"claude-sonnet-4.6": 4000,
|
||||
"claude-opus-4.6": 6000,
|
||||
"deepseek-chat": 2000,
|
||||
};
|
||||
|
||||
// In-memory atomic counter per combo for round-robin distribution
|
||||
// Resets on server restart (by design — no stale state)
|
||||
const rrCounters = new Map();
|
||||
@@ -202,6 +219,158 @@ function sortModelsByUsage(models, comboName) {
|
||||
return withUsage.map((e) => e.modelStr);
|
||||
}
|
||||
|
||||
function toTextContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== "object") return "";
|
||||
if (typeof part.text === "string") return part.text;
|
||||
return "";
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function extractPromptForIntent(body) {
|
||||
if (!body || typeof body !== "object") return "";
|
||||
|
||||
const fromMessages = Array.isArray(body.messages)
|
||||
? [...body.messages].reverse().find((m) => m && typeof m === "object" && m.role === "user")
|
||||
: null;
|
||||
if (fromMessages) return toTextContent(fromMessages.content);
|
||||
|
||||
if (typeof body.input === "string") return body.input;
|
||||
if (Array.isArray(body.input)) {
|
||||
const text = body.input
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return "";
|
||||
if (typeof item.content === "string") return item.content;
|
||||
if (typeof item.text === "string") return item.text;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (text) return text;
|
||||
}
|
||||
|
||||
if (typeof body.prompt === "string") return body.prompt;
|
||||
return "";
|
||||
}
|
||||
|
||||
function mapIntentToTaskType(intent) {
|
||||
switch (intent) {
|
||||
case "code":
|
||||
return "coding";
|
||||
case "reasoning":
|
||||
return "analysis";
|
||||
case "simple":
|
||||
return "default";
|
||||
case "medium":
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function toStringArray(input) {
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((v) => (typeof v === "string" ? v.trim() : "")).filter(Boolean);
|
||||
}
|
||||
if (typeof input === "string") {
|
||||
return input
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getIntentConfig(settings, combo) {
|
||||
const comboIntentConfig =
|
||||
combo?.autoConfig?.intentConfig ||
|
||||
combo?.config?.auto?.intentConfig ||
|
||||
combo?.config?.intentConfig ||
|
||||
{};
|
||||
|
||||
return {
|
||||
...DEFAULT_INTENT_CONFIG,
|
||||
...comboIntentConfig,
|
||||
...(typeof settings?.intentDetectionEnabled === "boolean"
|
||||
? { enabled: settings.intentDetectionEnabled }
|
||||
: {}),
|
||||
...(Number.isFinite(Number(settings?.intentSimpleMaxWords))
|
||||
? { simpleMaxWords: Number(settings.intentSimpleMaxWords) }
|
||||
: {}),
|
||||
...(toStringArray(settings?.intentExtraCodeKeywords).length > 0
|
||||
? { extraCodeKeywords: toStringArray(settings.intentExtraCodeKeywords) }
|
||||
: {}),
|
||||
...(toStringArray(settings?.intentExtraReasoningKeywords).length > 0
|
||||
? { extraReasoningKeywords: toStringArray(settings.intentExtraReasoningKeywords) }
|
||||
: {}),
|
||||
...(toStringArray(settings?.intentExtraSimpleKeywords).length > 0
|
||||
? { extraSimpleKeywords: toStringArray(settings.intentExtraSimpleKeywords) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getBootstrapLatencyMs(modelId) {
|
||||
const normalized = String(modelId || "").toLowerCase();
|
||||
return DEFAULT_MODEL_P95_MS[normalized] ?? 1500;
|
||||
}
|
||||
|
||||
async function buildAutoCandidates(modelStrings, comboName) {
|
||||
const metrics = getComboMetrics(comboName);
|
||||
const { getPricingForModel } = await import("../../src/lib/localDb");
|
||||
|
||||
const candidates = await Promise.all(
|
||||
modelStrings.map(async (modelStr) => {
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "unknown";
|
||||
const model = parsed.model || modelStr;
|
||||
|
||||
let costPer1MTokens = 1;
|
||||
try {
|
||||
const pricing = await getPricingForModel(provider, model);
|
||||
const inputPrice = Number(pricing?.input);
|
||||
if (Number.isFinite(inputPrice) && inputPrice >= 0) {
|
||||
costPer1MTokens = inputPrice;
|
||||
}
|
||||
} catch {
|
||||
// keep default cost
|
||||
}
|
||||
|
||||
const modelMetric = metrics?.byModel?.[modelStr] || null;
|
||||
const avgLatency = Number(modelMetric?.avgLatencyMs);
|
||||
const successRate = Number(modelMetric?.successRate);
|
||||
const p95LatencyMs =
|
||||
Number.isFinite(avgLatency) && avgLatency > 0 ? avgLatency : getBootstrapLatencyMs(model);
|
||||
const errorRate =
|
||||
Number.isFinite(successRate) && successRate >= 0 && successRate <= 100
|
||||
? 1 - successRate / 100
|
||||
: 0.05;
|
||||
|
||||
const breakerStateRaw = getCircuitBreaker(`combo:${modelStr}`)?.getStatus?.()?.state;
|
||||
const circuitBreakerState =
|
||||
breakerStateRaw === "OPEN" || breakerStateRaw === "HALF_OPEN" ? breakerStateRaw : "CLOSED";
|
||||
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
quotaRemaining: 100,
|
||||
quotaTotal: 100,
|
||||
circuitBreakerState,
|
||||
costPer1MTokens,
|
||||
p95LatencyMs,
|
||||
latencyStdDev: Math.max(10, p95LatencyMs * 0.1),
|
||||
errorRate,
|
||||
accountTier: "standard",
|
||||
quotaResetIntervalSecs: 86400,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle combo chat with fallback
|
||||
* Supports all 6 strategies: priority, weighted, round-robin, random, least-used, cost-optimized
|
||||
@@ -316,7 +485,131 @@ export async function handleComboChat({
|
||||
}
|
||||
|
||||
// Apply strategy-specific ordering
|
||||
if (strategy === "strict-random") {
|
||||
if (strategy === "auto") {
|
||||
const requestHasTools = Array.isArray(body?.tools) && body.tools.length > 0;
|
||||
let eligibleModels = [...orderedModels];
|
||||
|
||||
if (requestHasTools) {
|
||||
const filtered = eligibleModels.filter((m) => supportsToolCalling(m));
|
||||
if (filtered.length > 0) {
|
||||
eligibleModels = filtered;
|
||||
} else {
|
||||
log.warn(
|
||||
"COMBO",
|
||||
"Auto strategy: all candidates filtered by tool-calling policy, falling back to full pool"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = extractPromptForIntent(body);
|
||||
const systemPrompt =
|
||||
typeof combo?.system_message === "string" ? combo.system_message : undefined;
|
||||
const intentConfig = getIntentConfig(settings, combo);
|
||||
const intent = classifyWithConfig(prompt, intentConfig, systemPrompt);
|
||||
recordComboIntent(combo.name, intent);
|
||||
const taskType = mapIntentToTaskType(intent);
|
||||
|
||||
const autoConfigSource = combo?.autoConfig || combo?.config?.auto || combo?.config || {};
|
||||
const routingStrategy =
|
||||
typeof autoConfigSource.routingStrategy === "string"
|
||||
? autoConfigSource.routingStrategy
|
||||
: typeof autoConfigSource.strategyName === "string"
|
||||
? autoConfigSource.strategyName
|
||||
: "rules";
|
||||
|
||||
const candidatePool = Array.isArray(autoConfigSource.candidatePool)
|
||||
? autoConfigSource.candidatePool
|
||||
: [
|
||||
...new Set(
|
||||
eligibleModels.map((m) => {
|
||||
const parsed = parseModel(m);
|
||||
return parsed.provider || parsed.providerAlias || "unknown";
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
const weights =
|
||||
autoConfigSource.weights && typeof autoConfigSource.weights === "object"
|
||||
? autoConfigSource.weights
|
||||
: DEFAULT_WEIGHTS;
|
||||
const explorationRate = Number.isFinite(Number(autoConfigSource.explorationRate))
|
||||
? Number(autoConfigSource.explorationRate)
|
||||
: 0.05;
|
||||
const budgetCap = Number.isFinite(Number(autoConfigSource.budgetCap))
|
||||
? Number(autoConfigSource.budgetCap)
|
||||
: undefined;
|
||||
const modePack =
|
||||
typeof autoConfigSource.modePack === "string" ? autoConfigSource.modePack : undefined;
|
||||
|
||||
const candidates = await buildAutoCandidates(eligibleModels, combo.name);
|
||||
if (candidates.length > 0) {
|
||||
let selectedProvider = null;
|
||||
let selectedModel = null;
|
||||
let selectionReason = "";
|
||||
|
||||
if (routingStrategy !== "rules") {
|
||||
try {
|
||||
const decision = selectWithStrategy(
|
||||
candidates,
|
||||
{ taskType, requestHasTools },
|
||||
routingStrategy
|
||||
);
|
||||
selectedProvider = decision.provider;
|
||||
selectedModel = decision.model;
|
||||
selectionReason = decision.reason;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
"COMBO",
|
||||
`Auto strategy '${routingStrategy}' failed (${err?.message || "unknown"}), falling back to rules`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedProvider || !selectedModel) {
|
||||
const selection = selectAutoProvider(
|
||||
{
|
||||
id: combo.id || combo.name,
|
||||
name: combo.name,
|
||||
type: "auto",
|
||||
candidatePool,
|
||||
weights,
|
||||
modePack,
|
||||
budgetCap,
|
||||
explorationRate,
|
||||
},
|
||||
candidates,
|
||||
taskType
|
||||
);
|
||||
selectedProvider = selection.provider;
|
||||
selectedModel = selection.model;
|
||||
selectionReason = `score=${selection.score.toFixed(3)}${selection.isExploration ? " (exploration)" : ""}`;
|
||||
}
|
||||
|
||||
const modelLookup = new Map();
|
||||
for (const modelStr of eligibleModels) {
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "unknown";
|
||||
const modelId = parsed.model || modelStr;
|
||||
modelLookup.set(`${provider}/${modelId}`, modelStr);
|
||||
}
|
||||
|
||||
const ranked = scorePool(candidates, taskType, weights)
|
||||
.map((r) => modelLookup.get(`${r.provider}/${r.model}`) || `${r.provider}/${r.model}`)
|
||||
.filter(Boolean);
|
||||
|
||||
const selectedModelStr =
|
||||
modelLookup.get(`${selectedProvider}/${selectedModel}`) ||
|
||||
`${selectedProvider}/${selectedModel}`;
|
||||
orderedModels = [...new Set([selectedModelStr, ...ranked, ...eligibleModels])];
|
||||
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Auto selection: ${selectedModelStr} | intent=${intent} task=${taskType} | strategy=${routingStrategy} | ${selectionReason}`
|
||||
);
|
||||
} else {
|
||||
log.warn("COMBO", "Auto strategy has no candidates, keeping default ordering");
|
||||
}
|
||||
} else if (strategy === "strict-random") {
|
||||
const selectedId = await getNextFromDeck(`combo:${combo.name}`, orderedModels);
|
||||
// Put selected model first so the fallback loop tries it first
|
||||
const rest = orderedModels.filter((m) => m !== selectedId);
|
||||
|
||||
@@ -21,6 +21,7 @@ interface ComboMetricsEntry {
|
||||
totalLatencyMs: number;
|
||||
strategy: string;
|
||||
lastUsedAt: string | null;
|
||||
intentCounts: Record<string, number>;
|
||||
byModel: Record<string, ModelMetrics>;
|
||||
}
|
||||
|
||||
@@ -69,6 +70,7 @@ export function recordComboRequest(
|
||||
totalLatencyMs: 0,
|
||||
strategy,
|
||||
lastUsedAt: null,
|
||||
intentCounts: {},
|
||||
byModel: {},
|
||||
});
|
||||
}
|
||||
@@ -131,6 +133,7 @@ export function getComboMetrics(comboName: string): ComboMetricsView | null {
|
||||
combo.totalRequests > 0 ? Math.round((combo.totalSuccesses / combo.totalRequests) * 100) : 0,
|
||||
fallbackRate:
|
||||
combo.totalRequests > 0 ? Math.round((combo.totalFallbacks / combo.totalRequests) * 100) : 0,
|
||||
intentCounts: { ...combo.intentCounts },
|
||||
byModel: Object.fromEntries(
|
||||
Object.entries(combo.byModel).map(([model, m]) => [
|
||||
model,
|
||||
@@ -156,6 +159,30 @@ export function getAllComboMetrics(): Record<string, ComboMetricsView | null> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record detected prompt intent for a combo (used by multilingual routing analytics).
|
||||
*/
|
||||
export function recordComboIntent(comboName: string, intent: string): void {
|
||||
if (!metrics.has(comboName)) {
|
||||
metrics.set(comboName, {
|
||||
totalRequests: 0,
|
||||
totalSuccesses: 0,
|
||||
totalFailures: 0,
|
||||
totalFallbacks: 0,
|
||||
totalLatencyMs: 0,
|
||||
strategy: "priority",
|
||||
lastUsedAt: null,
|
||||
intentCounts: {},
|
||||
byModel: {},
|
||||
});
|
||||
}
|
||||
|
||||
const combo = metrics.get(comboName);
|
||||
if (!combo) return;
|
||||
const key = String(intent || "unknown");
|
||||
combo.intentCounts[key] = (combo.intentCounts[key] || 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset metrics for a specific combo
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Emergency Fallback — Budget Exhaustion Redirect
|
||||
*
|
||||
* When a request fails due to budget exhaustion (HTTP 402 or budget keywords
|
||||
* in the error body), optionally redirect to a free-tier model
|
||||
* (default provider/model: nvidia + openai/gpt-oss-120b at $0.00/M tokens).
|
||||
*
|
||||
* Inspired by ClawRouter: "gpt-oss-120b costs nothing and serves as
|
||||
* automatic fallback when wallet is empty."
|
||||
*/
|
||||
|
||||
export interface EmergencyFallbackConfig {
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
triggerOn402: boolean;
|
||||
triggerOnBudgetKeywords: boolean;
|
||||
budgetKeywords: string[];
|
||||
/** Skip fallback for tool requests (gpt-oss-120b may not support structured tool calling) */
|
||||
skipForToolRequests: boolean;
|
||||
maxOutputTokens: number;
|
||||
}
|
||||
|
||||
export const EMERGENCY_FALLBACK_CONFIG: EmergencyFallbackConfig = {
|
||||
enabled: true,
|
||||
provider: "nvidia",
|
||||
model: "openai/gpt-oss-120b",
|
||||
triggerOn402: true,
|
||||
triggerOnBudgetKeywords: true,
|
||||
budgetKeywords: [
|
||||
"insufficient funds",
|
||||
"insufficient_funds",
|
||||
"budget exceeded",
|
||||
"budget_exceeded",
|
||||
"quota exceeded",
|
||||
"quota_exceeded",
|
||||
"billing",
|
||||
"payment required",
|
||||
"out of credits",
|
||||
"no credits",
|
||||
"credit limit",
|
||||
"spending limit",
|
||||
"saldo insuficiente",
|
||||
"limite de gastos",
|
||||
"cota excedida",
|
||||
],
|
||||
skipForToolRequests: true,
|
||||
maxOutputTokens: 4096,
|
||||
};
|
||||
|
||||
export interface FallbackDecision {
|
||||
shouldFallback: true;
|
||||
reason: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
maxOutputTokens: number;
|
||||
}
|
||||
|
||||
export interface NoFallbackDecision {
|
||||
shouldFallback: false;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export type FallbackResult = FallbackDecision | NoFallbackDecision;
|
||||
|
||||
export function shouldUseFallback(
|
||||
status: number,
|
||||
errorBody: string,
|
||||
requestHasTools: boolean,
|
||||
config: EmergencyFallbackConfig = EMERGENCY_FALLBACK_CONFIG
|
||||
): FallbackResult {
|
||||
if (!config.enabled) return { shouldFallback: false, reason: "emergency fallback disabled" };
|
||||
if (config.skipForToolRequests && requestHasTools) {
|
||||
return { shouldFallback: false, reason: "skipped: request has tools" };
|
||||
}
|
||||
if (config.triggerOn402 && status === 402) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
reason: `HTTP 402 → emergency fallback to ${config.provider}/${config.model}`,
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
maxOutputTokens: config.maxOutputTokens,
|
||||
};
|
||||
}
|
||||
if (config.triggerOnBudgetKeywords && errorBody) {
|
||||
const lowerBody = errorBody.toLowerCase();
|
||||
const matched = config.budgetKeywords.find((kw) => lowerBody.includes(kw.toLowerCase()));
|
||||
if (matched) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
reason: `Budget error detected ('${matched}') → emergency fallback to ${config.provider}/${config.model}`,
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
maxOutputTokens: config.maxOutputTokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { shouldFallback: false, reason: "no budget error detected" };
|
||||
}
|
||||
|
||||
export function isFallbackDecision(result: FallbackResult): result is FallbackDecision {
|
||||
return result.shouldFallback === true;
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Multilingual Intent Detection for AutoCombo
|
||||
*
|
||||
* Classifies prompts as: code | reasoning | simple | medium
|
||||
* using keywords in 9 languages (EN, PT-BR, ES, ZH, JA, RU, DE, KO, AR).
|
||||
*
|
||||
* Inspired by ClawRouter (BlockRunAI) multilingual routing system.
|
||||
* Execution: purely synchronous, <1ms, no I/O.
|
||||
*/
|
||||
|
||||
export type IntentType = "code" | "reasoning" | "simple" | "medium";
|
||||
|
||||
export const CODE_KEYWORDS: readonly string[] = [
|
||||
// English
|
||||
"function",
|
||||
"class",
|
||||
"import",
|
||||
"def",
|
||||
"SELECT",
|
||||
"async",
|
||||
"await",
|
||||
"const",
|
||||
"let",
|
||||
"var",
|
||||
"return",
|
||||
"```",
|
||||
"algorithm",
|
||||
"compile",
|
||||
"debug",
|
||||
"refactor",
|
||||
"typescript",
|
||||
"python",
|
||||
"javascript",
|
||||
"code",
|
||||
"implement",
|
||||
"write a",
|
||||
"create a component",
|
||||
"endpoint",
|
||||
"repository",
|
||||
"deploy",
|
||||
"install",
|
||||
"script",
|
||||
"api",
|
||||
"database",
|
||||
"query",
|
||||
"schema",
|
||||
"interface",
|
||||
"generic",
|
||||
"enum",
|
||||
"module",
|
||||
"package",
|
||||
"dependency",
|
||||
// Português (PT-BR)
|
||||
"função",
|
||||
"classe",
|
||||
"importar",
|
||||
"definir",
|
||||
"consulta",
|
||||
"assíncrono",
|
||||
"aguardar",
|
||||
"constante",
|
||||
"variável",
|
||||
"retornar",
|
||||
"algoritmo",
|
||||
"compilar",
|
||||
"depurar",
|
||||
"refatorar",
|
||||
"código",
|
||||
"implementar",
|
||||
"criar um",
|
||||
"componente",
|
||||
"como fazer",
|
||||
"repositório",
|
||||
"configurar",
|
||||
"instalar",
|
||||
"banco de dados",
|
||||
"escrever uma função",
|
||||
"criar uma classe",
|
||||
// Español
|
||||
"función",
|
||||
"clase",
|
||||
"importar",
|
||||
"definir",
|
||||
"consulta",
|
||||
"asíncrono",
|
||||
"esperar",
|
||||
"constante",
|
||||
"variable",
|
||||
"retornar",
|
||||
"algoritmo",
|
||||
"compilar",
|
||||
"depurar",
|
||||
"refactorizar",
|
||||
"código",
|
||||
"implementar",
|
||||
// 中文
|
||||
"函数",
|
||||
"类",
|
||||
"导入",
|
||||
"定义",
|
||||
"查询",
|
||||
"异步",
|
||||
"等待",
|
||||
"常量",
|
||||
"变量",
|
||||
"返回",
|
||||
"算法",
|
||||
"编译",
|
||||
"调试",
|
||||
"代码",
|
||||
// 日本語
|
||||
"関数",
|
||||
"クラス",
|
||||
"インポート",
|
||||
"非同期",
|
||||
"定数",
|
||||
"変数",
|
||||
"コード",
|
||||
"アルゴリズム",
|
||||
// Русский
|
||||
"функция",
|
||||
"класс",
|
||||
"импорт",
|
||||
"запрос",
|
||||
"асинхронный",
|
||||
"константа",
|
||||
"переменная",
|
||||
"алгоритм",
|
||||
"код",
|
||||
// Deutsch
|
||||
"funktion",
|
||||
"klasse",
|
||||
"importieren",
|
||||
"abfrage",
|
||||
"asynchron",
|
||||
"konstante",
|
||||
"variable",
|
||||
"algorithmus",
|
||||
"code",
|
||||
// 한국어
|
||||
"함수",
|
||||
"클래스",
|
||||
"가져오기",
|
||||
"정의",
|
||||
"쿼리",
|
||||
"비동기",
|
||||
"대기",
|
||||
"상수",
|
||||
"변수",
|
||||
"반환",
|
||||
"코드",
|
||||
// العربية
|
||||
"دالة",
|
||||
"فئة",
|
||||
"استيراد",
|
||||
"استعلام",
|
||||
"غير متزامن",
|
||||
"ثابت",
|
||||
"متغير",
|
||||
"كود",
|
||||
"خوارزمية",
|
||||
];
|
||||
|
||||
export const REASONING_KEYWORDS: readonly string[] = [
|
||||
// English
|
||||
"prove",
|
||||
"theorem",
|
||||
"derive",
|
||||
"step by step",
|
||||
"chain of thought",
|
||||
"formally",
|
||||
"mathematical",
|
||||
"proof",
|
||||
"logically",
|
||||
"analyze",
|
||||
"reasoning",
|
||||
"deduce",
|
||||
"infer",
|
||||
"hypothesis",
|
||||
"convergence",
|
||||
// Português (PT-BR)
|
||||
"provar",
|
||||
"teorema",
|
||||
"derivar",
|
||||
"passo a passo",
|
||||
"cadeia de pensamento",
|
||||
"formalmente",
|
||||
"matemático",
|
||||
"prova",
|
||||
"logicamente",
|
||||
"analisar",
|
||||
"raciocínio",
|
||||
"deduzir",
|
||||
"inferir",
|
||||
"hipótese",
|
||||
"demonstrar",
|
||||
"cálculo",
|
||||
"equação diferencial",
|
||||
"integral",
|
||||
"otimização",
|
||||
// Español
|
||||
"demostrar",
|
||||
"teorema",
|
||||
"derivar",
|
||||
"paso a paso",
|
||||
"formalmente",
|
||||
"matemático",
|
||||
"lógicamente",
|
||||
// 中文
|
||||
"证明",
|
||||
"定理",
|
||||
"推导",
|
||||
"逐步",
|
||||
"思维链",
|
||||
"数学",
|
||||
"逻辑",
|
||||
"分析",
|
||||
// 日本語
|
||||
"証明",
|
||||
"定理",
|
||||
"導出",
|
||||
"論理的",
|
||||
"分析",
|
||||
// Русский
|
||||
"доказать",
|
||||
"теорема",
|
||||
"шаг за шагом",
|
||||
"математически",
|
||||
"логически",
|
||||
// Deutsch
|
||||
"beweisen",
|
||||
"theorem",
|
||||
"schritt für schritt",
|
||||
"mathematisch",
|
||||
"logisch",
|
||||
// 한국어
|
||||
"증명",
|
||||
"정리",
|
||||
"단계별",
|
||||
"수학적",
|
||||
"논리적",
|
||||
// العربية
|
||||
"إثبات",
|
||||
"نظرية",
|
||||
"خطوة بخطوة",
|
||||
"رياضي",
|
||||
"منطقياً",
|
||||
];
|
||||
|
||||
export const SIMPLE_KEYWORDS: readonly string[] = [
|
||||
// English
|
||||
"what is",
|
||||
"define",
|
||||
"translate",
|
||||
"hello",
|
||||
"yes or no",
|
||||
"summarize",
|
||||
"list",
|
||||
"tell me",
|
||||
"who is",
|
||||
// Português (PT-BR)
|
||||
"o que é",
|
||||
"definir",
|
||||
"traduzir",
|
||||
"olá",
|
||||
"oi",
|
||||
"sim ou não",
|
||||
"resumir",
|
||||
"listar",
|
||||
"me diga",
|
||||
"quem é",
|
||||
"quando foi",
|
||||
"onde fica",
|
||||
"explique brevemente",
|
||||
"de forma simples",
|
||||
// Español
|
||||
"qué es",
|
||||
"definir",
|
||||
"traducir",
|
||||
"hola",
|
||||
"resumir",
|
||||
"listar",
|
||||
// 中文
|
||||
"什么是",
|
||||
"定义",
|
||||
"翻译",
|
||||
"你好",
|
||||
"总结",
|
||||
"列出",
|
||||
// Русский
|
||||
"что такое",
|
||||
"определить",
|
||||
"перевести",
|
||||
"привет",
|
||||
"резюмировать",
|
||||
// Deutsch
|
||||
"was ist",
|
||||
"definieren",
|
||||
"übersetzen",
|
||||
"hallo",
|
||||
"zusammenfassen",
|
||||
// 한국어
|
||||
"이란",
|
||||
"정의",
|
||||
"번역",
|
||||
"안녕",
|
||||
"요약",
|
||||
// العربية
|
||||
"ما هو",
|
||||
"تعريف",
|
||||
"ترجمة",
|
||||
"مرحبا",
|
||||
"ملخص",
|
||||
];
|
||||
|
||||
/**
|
||||
* Classify a prompt's intent using multilingual keyword matching.
|
||||
* Priority: code > reasoning > simple > medium (default)
|
||||
*/
|
||||
export function classifyPromptIntent(prompt: string, systemPrompt?: string): IntentType {
|
||||
const fullText = `${systemPrompt ?? ""} ${prompt}`.toLowerCase();
|
||||
const wordCount = prompt.trim().split(/\s+/).length;
|
||||
|
||||
for (const kw of CODE_KEYWORDS) {
|
||||
if (fullText.includes(kw.toLowerCase())) return "code";
|
||||
}
|
||||
for (const kw of REASONING_KEYWORDS) {
|
||||
if (fullText.includes(kw.toLowerCase())) return "reasoning";
|
||||
}
|
||||
if (wordCount < 60) {
|
||||
for (const kw of SIMPLE_KEYWORDS) {
|
||||
if (fullText.includes(kw.toLowerCase())) return "simple";
|
||||
}
|
||||
}
|
||||
return "medium";
|
||||
}
|
||||
|
||||
export interface IntentClassifierConfig {
|
||||
enabled: boolean;
|
||||
extraCodeKeywords?: string[];
|
||||
extraReasoningKeywords?: string[];
|
||||
extraSimpleKeywords?: string[];
|
||||
simpleMaxWords?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_INTENT_CONFIG: IntentClassifierConfig = {
|
||||
enabled: true,
|
||||
simpleMaxWords: 60,
|
||||
};
|
||||
|
||||
export function classifyWithConfig(
|
||||
prompt: string,
|
||||
config: IntentClassifierConfig,
|
||||
systemPrompt?: string
|
||||
): IntentType {
|
||||
if (!config.enabled) return "medium";
|
||||
const fullText = `${systemPrompt ?? ""} ${prompt}`.toLowerCase();
|
||||
const wordCount = prompt.trim().split(/\s+/).length;
|
||||
const maxSimpleWords = config.simpleMaxWords ?? 60;
|
||||
const codeKws = [...CODE_KEYWORDS, ...(config.extraCodeKeywords ?? [])];
|
||||
const reasoningKws = [...REASONING_KEYWORDS, ...(config.extraReasoningKeywords ?? [])];
|
||||
const simpleKws = [...SIMPLE_KEYWORDS, ...(config.extraSimpleKeywords ?? [])];
|
||||
for (const kw of codeKws) {
|
||||
if (fullText.includes(kw.toLowerCase())) return "code";
|
||||
}
|
||||
for (const kw of reasoningKws) {
|
||||
if (fullText.includes(kw.toLowerCase())) return "reasoning";
|
||||
}
|
||||
if (wordCount < maxSimpleWords) {
|
||||
for (const kw of simpleKws) {
|
||||
if (fullText.includes(kw.toLowerCase())) return "simple";
|
||||
}
|
||||
}
|
||||
return "medium";
|
||||
}
|
||||
@@ -23,6 +23,18 @@ const PROVIDER_MODEL_ALIASES = {
|
||||
"gemini-3-flash": "gemini-3-flash-preview",
|
||||
"raptor-mini": "oswe-vscode-prime",
|
||||
},
|
||||
gemini: {
|
||||
"gemini-3.1-pro-preview": "gemini-3.1-pro",
|
||||
"gemini-3-1-pro": "gemini-3.1-pro",
|
||||
},
|
||||
"gemini-cli": {
|
||||
"gemini-3.1-pro-preview": "gemini-3.1-pro",
|
||||
"gemini-3-1-pro": "gemini-3.1-pro",
|
||||
},
|
||||
nvidia: {
|
||||
"gpt-oss-120b": "openai/gpt-oss-120b",
|
||||
"nvidia/gpt-oss-120b": "openai/gpt-oss-120b",
|
||||
},
|
||||
antigravity: {},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
|
||||
import { parseModel } from "./model.ts";
|
||||
|
||||
// Conservative denylist fallback used when registry metadata is absent.
|
||||
// Keep small and explicit to avoid false negatives.
|
||||
const TOOL_CALLING_UNSUPPORTED_PATTERNS = [
|
||||
"gpt-oss-120b",
|
||||
"deepseek-reasoner",
|
||||
"glm-4.7",
|
||||
"glm4.7",
|
||||
];
|
||||
|
||||
function getRegistryToolCallingFlag(providerIdOrAlias: string, modelId: string): boolean | null {
|
||||
const providerAlias = PROVIDER_ID_TO_ALIAS[providerIdOrAlias] || providerIdOrAlias;
|
||||
const models = PROVIDER_MODELS[providerAlias];
|
||||
if (!Array.isArray(models)) return null;
|
||||
const found = models.find((m) => m?.id === modelId);
|
||||
if (!found) return null;
|
||||
return typeof found.toolCalling === "boolean" ? found.toolCalling : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a model should be considered safe for structured function/tool calling.
|
||||
*
|
||||
* Decision order:
|
||||
* 1) Provider registry metadata (toolCalling flag) when available.
|
||||
* 2) Conservative denylist fallback for known problematic model families.
|
||||
* 3) Default true.
|
||||
*/
|
||||
export function supportsToolCalling(modelStr: string): boolean {
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "";
|
||||
const model = parsed.model || modelStr;
|
||||
|
||||
if (provider) {
|
||||
const fromRegistry = getRegistryToolCallingFlag(provider, model);
|
||||
if (fromRegistry !== null) return fromRegistry;
|
||||
}
|
||||
|
||||
const normalized = String(modelStr || "").toLowerCase();
|
||||
if (!normalized) return false;
|
||||
|
||||
const blocked = TOOL_CALLING_UNSUPPORTED_PATTERNS.some((pattern) => {
|
||||
if (normalized === pattern) return true;
|
||||
if (normalized.endsWith(`/${pattern}`)) return true;
|
||||
return normalized.includes(pattern);
|
||||
});
|
||||
|
||||
return !blocked;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Request Deduplication Service
|
||||
*
|
||||
* Deduplicates **concurrent** identical requests to the same upstream.
|
||||
* Inspired by ClawRouter's dedup.ts (BlockRunAI / github.com/BlockRunAI/ClawRouter).
|
||||
*
|
||||
* IMPORTANT: In-memory only — does NOT persist across restarts and does NOT
|
||||
* work across multiple process instances (no cross-instance dedup).
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export interface DedupConfig {
|
||||
enabled: boolean;
|
||||
maxTemperatureForDedup: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_DEDUP_CONFIG: DedupConfig = {
|
||||
enabled: true,
|
||||
maxTemperatureForDedup: 0.1,
|
||||
timeoutMs: 60_000,
|
||||
};
|
||||
|
||||
export interface DedupResult<T> {
|
||||
result: T;
|
||||
wasDeduplicated: boolean;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
const inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* Compute a deterministic hash for a request body.
|
||||
* Includes: model, messages, temperature, tools, tool_choice, max_tokens, response_format
|
||||
* Excludes: stream, user, metadata (don't affect LLM output)
|
||||
*/
|
||||
export function computeRequestHash(requestBody: unknown): string {
|
||||
const body = requestBody as Record<string, unknown>;
|
||||
const canonical = {
|
||||
model: body.model ?? null,
|
||||
messages: body.messages ?? null,
|
||||
temperature: typeof body.temperature === "number" ? body.temperature : 1.0,
|
||||
tools: body.tools ?? null,
|
||||
tool_choice: body.tool_choice ?? null,
|
||||
max_tokens: body.max_tokens ?? null,
|
||||
response_format: body.response_format ?? null,
|
||||
top_p: body.top_p ?? null,
|
||||
frequency_penalty: body.frequency_penalty ?? null,
|
||||
presence_penalty: body.presence_penalty ?? null,
|
||||
};
|
||||
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/** Determine whether a request should be deduplicated */
|
||||
export function shouldDeduplicate(
|
||||
requestBody: unknown,
|
||||
config: DedupConfig = DEFAULT_DEDUP_CONFIG
|
||||
): boolean {
|
||||
if (!config.enabled) return false;
|
||||
const body = requestBody as Record<string, unknown>;
|
||||
if (body.stream === true) return false;
|
||||
const temperature = typeof body.temperature === "number" ? body.temperature : 1.0;
|
||||
if (temperature > config.maxTemperatureForDedup) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a request with deduplication.
|
||||
* Concurrent identical requests share one upstream call.
|
||||
*/
|
||||
export async function deduplicate<T>(
|
||||
hash: string,
|
||||
fn: () => Promise<T>,
|
||||
config: DedupConfig = DEFAULT_DEDUP_CONFIG
|
||||
): Promise<DedupResult<T>> {
|
||||
if (!config.enabled) {
|
||||
return { result: await fn(), wasDeduplicated: false, hash };
|
||||
}
|
||||
|
||||
const existing = inflight.get(hash);
|
||||
if (existing) {
|
||||
const result = (await existing) as T;
|
||||
return { result, wasDeduplicated: true, hash };
|
||||
}
|
||||
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const sharedPromise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
inflight.set(hash, sharedPromise as Promise<unknown>);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (inflight.get(hash) === sharedPromise) inflight.delete(hash);
|
||||
}, config.timeoutMs);
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
resolve(result);
|
||||
return { result, wasDeduplicated: false, hash };
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (inflight.get(hash) === sharedPromise) inflight.delete(hash);
|
||||
}
|
||||
}
|
||||
|
||||
export function getInflightCount(): number {
|
||||
return inflight.size;
|
||||
}
|
||||
export function getInflightHashes(): string[] {
|
||||
return [...inflight.keys()];
|
||||
}
|
||||
export function clearInflight(): void {
|
||||
inflight.clear();
|
||||
}
|
||||
@@ -208,7 +208,7 @@ export function openaiResponsesToOpenAIRequest(
|
||||
});
|
||||
}
|
||||
|
||||
// Filter orphaned tool results (no matching tool_call in any assistant message)
|
||||
// Filter orphaned tool results (no matching tool_call in assistant messages)
|
||||
const allToolCallIds = new Set<string>();
|
||||
for (const m of messages) {
|
||||
const rec = toRecord(m);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.6.8",
|
||||
"version": "2.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.6.8",
|
||||
"version": "2.6.10",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.6.8",
|
||||
"version": "2.7.0",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
+63
-7
@@ -14,6 +14,7 @@
|
||||
*
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/321
|
||||
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/426
|
||||
*/
|
||||
|
||||
import { existsSync, copyFileSync, mkdirSync } from "node:fs";
|
||||
@@ -80,8 +81,54 @@ if (existsSync(rootBinary)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 1.5: Use node-pre-gyp to download the correct prebuilt binary
|
||||
// This works on Windows without requiring node-gyp, Python, or MSVC.
|
||||
// better-sqlite3 ships prebuilts for win32-x64, win32-arm64, darwin-x64/arm64.
|
||||
console.log(" 📥 Attempting to download prebuilt binary via node-pre-gyp...");
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
// better-sqlite3 bundles @mapbox/node-pre-gyp — use it directly
|
||||
const preGypBin = join(
|
||||
ROOT,
|
||||
"app",
|
||||
"node_modules",
|
||||
".bin",
|
||||
process.platform === "win32" ? "node-pre-gyp.cmd" : "node-pre-gyp"
|
||||
);
|
||||
const preGypFallback = join(
|
||||
ROOT,
|
||||
"app",
|
||||
"node_modules",
|
||||
"@mapbox",
|
||||
"node-pre-gyp",
|
||||
"bin",
|
||||
"node-pre-gyp"
|
||||
);
|
||||
const preGypCmd = existsSync(preGypBin) ? preGypBin : preGypFallback;
|
||||
|
||||
if (existsSync(preGypCmd)) {
|
||||
execSync(`"${process.execPath}" "${preGypCmd}" install --fallback-to-build=false`, {
|
||||
cwd: join(ROOT, "app", "node_modules", "better-sqlite3"),
|
||||
stdio: "inherit",
|
||||
timeout: 60_000,
|
||||
});
|
||||
mkdirSync(dirname(appBinary), { recursive: true });
|
||||
try {
|
||||
process.dlopen({ exports: {} }, appBinary);
|
||||
console.log(" ✅ Prebuilt binary downloaded and loaded successfully!\n");
|
||||
process.exit(0);
|
||||
} catch (loadErr) {
|
||||
console.warn(` ⚠️ Downloaded binary failed to load: ${loadErr.message}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(" ⚠️ node-pre-gyp not found, skipping prebuilt download.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ node-pre-gyp download failed: ${err.message.split("\n")[0]}`);
|
||||
}
|
||||
|
||||
// Strategy 2: Fall back to npm rebuild (may work if build tools are available)
|
||||
console.log(" ⚠️ Root binary not available or incompatible, attempting npm rebuild...");
|
||||
console.log(" ⚠️ Attempting npm rebuild (requires build tools)...");
|
||||
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
@@ -103,14 +150,23 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing worked, warn but don't fail the install — let the package stay
|
||||
// installed so users can fix manually or use the pre-flight check in the CLI
|
||||
console.warn(" ⚠️ Could not fix better-sqlite3 native module automatically.");
|
||||
// If nothing worked, warn but don't fail the install
|
||||
console.warn("\n ⚠️ Could not fix better-sqlite3 native module automatically.");
|
||||
console.warn(" The server may not start correctly.");
|
||||
console.warn(" Try manually:");
|
||||
console.warn(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
|
||||
if (process.platform === "darwin") {
|
||||
console.warn(" Manual fix options:");
|
||||
if (process.platform === "win32") {
|
||||
console.warn(" Option A (easiest — no build tools needed):");
|
||||
console.warn(` cd "${join(ROOT, "app", "node_modules", "better-sqlite3")}"`);
|
||||
console.warn(" npx @mapbox/node-pre-gyp install --fallback-to-build=false");
|
||||
console.warn(" Option B (requires Build Tools for Visual Studio):");
|
||||
console.warn(` cd "${join(ROOT, "app")}" && npm rebuild better-sqlite3`);
|
||||
console.warn(" Install from: https://visualstudio.microsoft.com/visual-cpp-build-tools/");
|
||||
console.warn(" Also ensure Python is installed: https://python.org");
|
||||
} else if (process.platform === "darwin") {
|
||||
console.warn(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
|
||||
console.warn(" If build tools are missing: xcode-select --install");
|
||||
} else {
|
||||
console.warn(` cd ${join(ROOT, "app")} && npm rebuild better-sqlite3`);
|
||||
}
|
||||
console.warn("");
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export async function GET() {
|
||||
const { getAllCircuitBreakerStatuses } = await import("@/shared/utils/circuitBreaker");
|
||||
const { getAllRateLimitStatus } = await import("@omniroute/open-sse/services/rateLimitManager");
|
||||
const { getAllModelLockouts } = await import("@omniroute/open-sse/services/accountFallback");
|
||||
const { getInflightCount } = await import("@omniroute/open-sse/services/requestDedup.ts");
|
||||
|
||||
const settings = await getSettings();
|
||||
const circuitBreakers = getAllCircuitBreakerStatuses();
|
||||
@@ -50,6 +51,9 @@ export async function GET() {
|
||||
localProviders: getAllHealthStatuses(),
|
||||
rateLimitStatus,
|
||||
lockouts,
|
||||
dedup: {
|
||||
inflightRequests: getInflightCount(),
|
||||
},
|
||||
setupComplete: settings?.setupComplete || false,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -143,6 +143,10 @@ const SCHEMA_SQL = `
|
||||
tokens_cache_creation INTEGER DEFAULT 0,
|
||||
tokens_reasoning INTEGER DEFAULT 0,
|
||||
status TEXT,
|
||||
success INTEGER DEFAULT 1,
|
||||
latency_ms INTEGER DEFAULT 0,
|
||||
ttft_ms INTEGER DEFAULT 0,
|
||||
error_code TEXT,
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uh_timestamp ON usage_history(timestamp);
|
||||
@@ -327,6 +331,35 @@ function ensureProviderConnectionsColumns(db: SqliteDatabase) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureUsageHistoryColumns(db: SqliteDatabase) {
|
||||
try {
|
||||
const columns = db.prepare("PRAGMA table_info(usage_history)").all() as Array<{
|
||||
name?: string;
|
||||
}>;
|
||||
const columnNames = new Set(columns.map((column) => String(column.name ?? "")));
|
||||
|
||||
if (!columnNames.has("success")) {
|
||||
db.exec("ALTER TABLE usage_history ADD COLUMN success INTEGER DEFAULT 1");
|
||||
console.log("[DB] Added usage_history.success column");
|
||||
}
|
||||
if (!columnNames.has("latency_ms")) {
|
||||
db.exec("ALTER TABLE usage_history ADD COLUMN latency_ms INTEGER DEFAULT 0");
|
||||
console.log("[DB] Added usage_history.latency_ms column");
|
||||
}
|
||||
if (!columnNames.has("ttft_ms")) {
|
||||
db.exec("ALTER TABLE usage_history ADD COLUMN ttft_ms INTEGER DEFAULT 0");
|
||||
console.log("[DB] Added usage_history.ttft_ms column");
|
||||
}
|
||||
if (!columnNames.has("error_code")) {
|
||||
db.exec("ALTER TABLE usage_history ADD COLUMN error_code TEXT");
|
||||
console.log("[DB] Added usage_history.error_code column");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn("[DB] Failed to verify usage_history schema:", message);
|
||||
}
|
||||
}
|
||||
|
||||
export function getDbInstance(): SqliteDatabase {
|
||||
if (_db) return _db;
|
||||
|
||||
@@ -337,6 +370,7 @@ export function getDbInstance(): SqliteDatabase {
|
||||
const memoryDb = new Database(":memory:");
|
||||
memoryDb.pragma("journal_mode = WAL");
|
||||
memoryDb.exec(SCHEMA_SQL);
|
||||
ensureUsageHistoryColumns(memoryDb);
|
||||
_db = memoryDb;
|
||||
return memoryDb;
|
||||
}
|
||||
@@ -420,6 +454,7 @@ export function getDbInstance(): SqliteDatabase {
|
||||
db.pragma("synchronous = NORMAL");
|
||||
db.exec(SCHEMA_SQL);
|
||||
ensureProviderConnectionsColumns(db);
|
||||
ensureUsageHistoryColumns(db);
|
||||
|
||||
// ── Versioned Migrations ──
|
||||
// Auto-seed 001 as applied (the inline SCHEMA_SQL already created these tables)
|
||||
|
||||
@@ -98,6 +98,10 @@ CREATE TABLE IF NOT EXISTS usage_history (
|
||||
tokens_cache_creation INTEGER DEFAULT 0,
|
||||
tokens_reasoning INTEGER DEFAULT 0,
|
||||
status TEXT,
|
||||
success INTEGER DEFAULT 1,
|
||||
latency_ms INTEGER DEFAULT 0,
|
||||
ttft_ms INTEGER DEFAULT 0,
|
||||
error_code TEXT,
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uh_timestamp ON usage_history(timestamp);
|
||||
|
||||
@@ -24,8 +24,7 @@ export const CALL_LOGS_DIR = isCloud ? null : path.join(DATA_DIR, "call_logs");
|
||||
// Legacy paths
|
||||
const LEGACY_DB_FILE =
|
||||
isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "usage.json");
|
||||
const LEGACY_LOG_FILE =
|
||||
isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "log.txt");
|
||||
const LEGACY_LOG_FILE = isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "log.txt");
|
||||
const LEGACY_CALL_LOGS_DB_FILE =
|
||||
isCloud || !LEGACY_DATA_DIR ? null : path.join(LEGACY_DATA_DIR, "call_logs.json");
|
||||
const LEGACY_CALL_LOGS_DIR =
|
||||
@@ -82,10 +81,10 @@ export function migrateUsageJsonToSqlite() {
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name,
|
||||
tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning,
|
||||
status, timestamp)
|
||||
status, success, latency_ms, ttft_ms, error_code, timestamp)
|
||||
VALUES (@provider, @model, @connectionId, @apiKeyId, @apiKeyName,
|
||||
@tokensInput, @tokensOutput, @tokensCacheRead, @tokensCacheCreation, @tokensReasoning,
|
||||
@status, @timestamp)
|
||||
@status, @success, @latencyMs, @ttftMs, @errorCode, @timestamp)
|
||||
`);
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
@@ -103,6 +102,14 @@ export function migrateUsageJsonToSqlite() {
|
||||
entry.tokens?.cacheCreation ?? entry.tokens?.cache_creation_input_tokens ?? 0,
|
||||
tokensReasoning: entry.tokens?.reasoning ?? entry.tokens?.reasoning_tokens ?? 0,
|
||||
status: entry.status || null,
|
||||
success: entry.success === false ? 0 : 1,
|
||||
latencyMs: Number.isFinite(Number(entry.latencyMs)) ? Number(entry.latencyMs) : 0,
|
||||
ttftMs: Number.isFinite(Number(entry.timeToFirstTokenMs))
|
||||
? Number(entry.timeToFirstTokenMs)
|
||||
: Number.isFinite(Number(entry.latencyMs))
|
||||
? Number(entry.latencyMs)
|
||||
: 0,
|
||||
errorCode: entry.errorCode || null,
|
||||
timestamp: entry.timestamp || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,6 +107,10 @@ export async function getUsageDb() {
|
||||
reasoning: toNumber(r.tokens_reasoning),
|
||||
},
|
||||
status: toStringOrNull(r.status),
|
||||
success: toNumber(r.success) === 1,
|
||||
latencyMs: toNumber(r.latency_ms),
|
||||
timeToFirstTokenMs: toNumber(r.ttft_ms),
|
||||
errorCode: toStringOrNull(r.error_code),
|
||||
timestamp: toStringOrNull(r.timestamp),
|
||||
};
|
||||
});
|
||||
@@ -130,8 +134,8 @@ export async function saveRequestUsage(entry: any) {
|
||||
`
|
||||
INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name,
|
||||
tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning,
|
||||
status, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
status, success, latency_ms, ttft_ms, error_code, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
).run(
|
||||
entry.provider || null,
|
||||
@@ -145,6 +149,14 @@ export async function saveRequestUsage(entry: any) {
|
||||
entry.tokens?.cacheCreation ?? entry.tokens?.cache_creation_input_tokens ?? 0,
|
||||
entry.tokens?.reasoning ?? entry.tokens?.reasoning_tokens ?? 0,
|
||||
entry.status || null,
|
||||
entry.success === false ? 0 : 1,
|
||||
Number.isFinite(Number(entry.latencyMs)) ? Number(entry.latencyMs) : 0,
|
||||
Number.isFinite(Number(entry.timeToFirstTokenMs))
|
||||
? Number(entry.timeToFirstTokenMs)
|
||||
: Number.isFinite(Number(entry.latencyMs))
|
||||
? Number(entry.latencyMs)
|
||||
: 0,
|
||||
entry.errorCode || null,
|
||||
timestamp
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -202,6 +214,10 @@ export async function getUsageHistory(filter: any = {}) {
|
||||
reasoning: toNumber(r.tokens_reasoning),
|
||||
},
|
||||
status: toStringOrNull(r.status),
|
||||
success: toNumber(r.success) === 1,
|
||||
latencyMs: toNumber(r.latency_ms),
|
||||
timeToFirstTokenMs: toNumber(r.ttft_ms),
|
||||
errorCode: toStringOrNull(r.error_code),
|
||||
timestamp: toStringOrNull(r.timestamp),
|
||||
};
|
||||
});
|
||||
|
||||
+268
-12
@@ -115,6 +115,13 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 18.0,
|
||||
cache_creation: 2.0,
|
||||
},
|
||||
"gemini-3.1-pro-preview": {
|
||||
input: 2.0,
|
||||
output: 12.0,
|
||||
cached: 0.25,
|
||||
reasoning: 18.0,
|
||||
cache_creation: 2.0,
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
input: 2.0,
|
||||
output: 12.0,
|
||||
@@ -129,12 +136,13 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 3.75,
|
||||
cache_creation: 0.3,
|
||||
},
|
||||
// Gemini 2.5 Flash Lite — preco corrigido via ClawRouter: $0.10/$0.40 (era $0.15/$1.25)
|
||||
"gemini-2.5-flash-lite": {
|
||||
input: 0.15,
|
||||
output: 1.25,
|
||||
cached: 0.015,
|
||||
reasoning: 1.875,
|
||||
cache_creation: 0.15,
|
||||
input: 0.1,
|
||||
output: 0.4,
|
||||
cached: 0.025,
|
||||
reasoning: 0.6,
|
||||
cache_creation: 0.1,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -208,6 +216,13 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 3.0,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
"deepseek-v3.2": {
|
||||
input: 0.5,
|
||||
output: 2.0,
|
||||
cached: 0.25,
|
||||
reasoning: 3.0,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
"deepseek-v3.2-reasoner": {
|
||||
input: 0.75,
|
||||
output: 3.0,
|
||||
@@ -451,10 +466,71 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 15.0,
|
||||
cache_creation: 3.0,
|
||||
},
|
||||
// Claude 4.5 Haiku — modelo eco mais recente da Anthropic (2025-10)
|
||||
"claude-haiku-4-5-20251001": {
|
||||
input: 1.0,
|
||||
output: 5.0,
|
||||
cached: 0.5,
|
||||
reasoning: 7.5,
|
||||
cache_creation: 1.0,
|
||||
},
|
||||
"claude-haiku-4.5": {
|
||||
input: 1.0,
|
||||
output: 5.0,
|
||||
cached: 0.5,
|
||||
reasoning: 7.5,
|
||||
cache_creation: 1.0,
|
||||
},
|
||||
// Claude Sonnet 4.6 — maxOutput 64k tokens, $3/$15/M
|
||||
"claude-sonnet-4-6-20251031": {
|
||||
input: 3.0,
|
||||
output: 15.0,
|
||||
cached: 1.5,
|
||||
reasoning: 22.5,
|
||||
cache_creation: 3.0,
|
||||
},
|
||||
"claude-sonnet-4.6": {
|
||||
input: 3.0,
|
||||
output: 15.0,
|
||||
cached: 1.5,
|
||||
reasoning: 22.5,
|
||||
cache_creation: 3.0,
|
||||
},
|
||||
// Claude Opus 4.6 — mais barato que Opus 4 ($5/$25 vs $15/$75)
|
||||
"claude-opus-4-6-20251031": {
|
||||
input: 5.0,
|
||||
output: 25.0,
|
||||
cached: 2.5,
|
||||
reasoning: 37.5,
|
||||
cache_creation: 5.0,
|
||||
},
|
||||
"claude-opus-4.6": {
|
||||
input: 5.0,
|
||||
output: 25.0,
|
||||
cached: 2.5,
|
||||
reasoning: 37.5,
|
||||
cache_creation: 5.0,
|
||||
},
|
||||
},
|
||||
|
||||
// Gemini
|
||||
gemini: {
|
||||
// Gemini 3.1 Pro — novo flagship Google (2026-03-17)
|
||||
// Context: 1.050.000 tokens | Max Output: 65.536
|
||||
"gemini-3.1-pro": {
|
||||
input: 2.0,
|
||||
output: 12.0,
|
||||
cached: 0.25,
|
||||
reasoning: 18.0,
|
||||
cache_creation: 2.0,
|
||||
},
|
||||
"gemini-3-1-pro": {
|
||||
input: 2.0,
|
||||
output: 12.0,
|
||||
cached: 0.25,
|
||||
reasoning: 18.0,
|
||||
cache_creation: 2.0,
|
||||
},
|
||||
"gemini-3-pro-preview": {
|
||||
input: 2.0,
|
||||
output: 12.0,
|
||||
@@ -462,6 +538,13 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 18.0,
|
||||
cache_creation: 2.0,
|
||||
},
|
||||
"gemini-3.1-pro-preview": {
|
||||
input: 2.0,
|
||||
output: 12.0,
|
||||
cached: 0.25,
|
||||
reasoning: 18.0,
|
||||
cache_creation: 2.0,
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
input: 2.0,
|
||||
output: 12.0,
|
||||
@@ -476,12 +559,53 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 3.75,
|
||||
cache_creation: 0.3,
|
||||
},
|
||||
// Gemini 2.5 Flash Lite — preco corrigido: $0.10/$0.40 (ClawRouter)
|
||||
"gemini-2.5-flash-lite": {
|
||||
input: 0.15,
|
||||
output: 1.25,
|
||||
cached: 0.015,
|
||||
reasoning: 1.875,
|
||||
cache_creation: 0.15,
|
||||
input: 0.1,
|
||||
output: 0.4,
|
||||
cached: 0.025,
|
||||
reasoning: 0.6,
|
||||
cache_creation: 0.1,
|
||||
},
|
||||
},
|
||||
|
||||
// DeepSeek — API nativa (V3.2 Chat), separada de free providers
|
||||
// Preco: $0.28/$0.42/M tokens (verificado via ClawRouter 2026-03-17)
|
||||
deepseek: {
|
||||
"deepseek-chat": {
|
||||
input: 0.28,
|
||||
output: 0.42,
|
||||
cached: 0.014,
|
||||
reasoning: 0.42,
|
||||
cache_creation: 0.28,
|
||||
},
|
||||
"deepseek-v3": {
|
||||
input: 0.28,
|
||||
output: 0.42,
|
||||
cached: 0.014,
|
||||
reasoning: 0.42,
|
||||
cache_creation: 0.28,
|
||||
},
|
||||
"deepseek-v3.2": {
|
||||
input: 0.28,
|
||||
output: 0.42,
|
||||
cached: 0.014,
|
||||
reasoning: 0.42,
|
||||
cache_creation: 0.28,
|
||||
},
|
||||
"deepseek-reasoner": {
|
||||
input: 0.55,
|
||||
output: 2.19,
|
||||
cached: 0.14,
|
||||
reasoning: 2.19,
|
||||
cache_creation: 0.55,
|
||||
},
|
||||
"deepseek-r1": {
|
||||
input: 0.55,
|
||||
output: 2.19,
|
||||
cached: 0.14,
|
||||
reasoning: 2.19,
|
||||
cache_creation: 0.55,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -498,6 +622,20 @@ export const DEFAULT_PRICING = {
|
||||
|
||||
// GLM
|
||||
glm: {
|
||||
"glm-5": {
|
||||
input: 1.0,
|
||||
output: 3.2,
|
||||
cached: 0.5,
|
||||
reasoning: 4.8,
|
||||
cache_creation: 1.0,
|
||||
},
|
||||
"glm-5-turbo": {
|
||||
input: 1.2,
|
||||
output: 4.0,
|
||||
cached: 0.6,
|
||||
reasoning: 6.0,
|
||||
cache_creation: 1.2,
|
||||
},
|
||||
"glm-4.7": {
|
||||
input: 0.75,
|
||||
output: 3.0,
|
||||
@@ -521,7 +659,7 @@ export const DEFAULT_PRICING = {
|
||||
},
|
||||
},
|
||||
|
||||
// Kimi
|
||||
// Kimi (Moonshot)
|
||||
kimi: {
|
||||
"kimi-latest": {
|
||||
input: 1.0,
|
||||
@@ -530,10 +668,33 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 6.0,
|
||||
cache_creation: 1.0,
|
||||
},
|
||||
// Kimi K2.5 — acesso direto via Moonshot API
|
||||
// Context: 262.144 tokens | Capabilities: reasoning, vision, agentic, tools
|
||||
"kimi-k2.5": {
|
||||
input: 0.6,
|
||||
output: 3.0,
|
||||
cached: 0.3,
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.6,
|
||||
},
|
||||
"moonshot-kimi-k2.5": {
|
||||
input: 0.6,
|
||||
output: 3.0,
|
||||
cached: 0.3,
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.6,
|
||||
},
|
||||
},
|
||||
|
||||
// MiniMax
|
||||
minimax: {
|
||||
"minimax-m2.1": {
|
||||
input: 0.5,
|
||||
output: 2.0,
|
||||
cached: 0.25,
|
||||
reasoning: 3.0,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
"MiniMax-M2.1": {
|
||||
input: 0.5,
|
||||
output: 2.0,
|
||||
@@ -541,6 +702,22 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 3.0,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
// MiniMax M2.5 — mais barato que M2.1, reasoning + tools
|
||||
// Context: 204.800 tokens | Max Output: 16.384 tokens
|
||||
"minimax-m2.5": {
|
||||
input: 0.3,
|
||||
output: 1.2,
|
||||
cached: 0.15,
|
||||
reasoning: 1.8,
|
||||
cache_creation: 0.3,
|
||||
},
|
||||
"MiniMax-M2.5": {
|
||||
input: 0.3,
|
||||
output: 1.2,
|
||||
cached: 0.15,
|
||||
reasoning: 1.8,
|
||||
cache_creation: 0.3,
|
||||
},
|
||||
},
|
||||
|
||||
// ─── Free-tier API Key Providers (nominal $0 pricing) ───
|
||||
@@ -627,6 +804,7 @@ export const DEFAULT_PRICING = {
|
||||
|
||||
// Nvidia
|
||||
nvidia: {
|
||||
"nvidia/gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 },
|
||||
"openai/gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 },
|
||||
"gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 },
|
||||
"moonshotai/kimi-k2.5": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 },
|
||||
@@ -757,7 +935,85 @@ export const DEFAULT_PRICING = {
|
||||
},
|
||||
},
|
||||
|
||||
// Kiro (AWS)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// xAI (Grok) — Grok-3 + Grok-4 Family
|
||||
// Source: ClawRouter benchmarks 2026-03-17
|
||||
// Grok-4-fast-non-reasoning: 1143ms P50 (mais rapido do benchmark)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
xai: {
|
||||
"grok-3": {
|
||||
input: 3.0,
|
||||
output: 15.0,
|
||||
cached: 1.5,
|
||||
reasoning: 22.5,
|
||||
cache_creation: 3.0,
|
||||
},
|
||||
"grok-3-mini": {
|
||||
input: 0.3,
|
||||
output: 0.5,
|
||||
cached: 0.15,
|
||||
reasoning: 0.75,
|
||||
cache_creation: 0.3,
|
||||
},
|
||||
// Grok-4 Fast Family — ultrabaratos ($0.20/$0.50/M)
|
||||
"grok-4-fast-non-reasoning": {
|
||||
input: 0.2,
|
||||
output: 0.5,
|
||||
cached: 0.1,
|
||||
reasoning: 0.0,
|
||||
cache_creation: 0.2,
|
||||
},
|
||||
"grok-4-fast-reasoning": {
|
||||
input: 0.2,
|
||||
output: 0.5,
|
||||
cached: 0.1,
|
||||
reasoning: 0.75,
|
||||
cache_creation: 0.2,
|
||||
},
|
||||
"grok-4-1-fast-non-reasoning": {
|
||||
input: 0.2,
|
||||
output: 0.5,
|
||||
cached: 0.1,
|
||||
reasoning: 0.0,
|
||||
cache_creation: 0.2,
|
||||
},
|
||||
"grok-4-1-fast-reasoning": {
|
||||
input: 0.2,
|
||||
output: 0.5,
|
||||
cached: 0.1,
|
||||
reasoning: 0.75,
|
||||
cache_creation: 0.2,
|
||||
},
|
||||
"grok-4-0709": {
|
||||
input: 0.2,
|
||||
output: 1.5,
|
||||
cached: 0.1,
|
||||
reasoning: 2.25,
|
||||
cache_creation: 0.2,
|
||||
},
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Z.AI / ZhipuAI — GLM-5 Family
|
||||
// Adicionados via ClawRouter 2026-03-17 | maxOutput: 128k tokens!
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
zai: {
|
||||
"glm-5": {
|
||||
input: 1.0,
|
||||
output: 3.2,
|
||||
cached: 0.5,
|
||||
reasoning: 4.8,
|
||||
cache_creation: 1.0,
|
||||
},
|
||||
"glm-5-turbo": {
|
||||
input: 1.2,
|
||||
output: 4.0,
|
||||
cached: 0.6,
|
||||
reasoning: 6.0,
|
||||
cache_creation: 1.2,
|
||||
},
|
||||
},
|
||||
|
||||
kiro: {
|
||||
"claude-sonnet-4.5": {
|
||||
input: 3.0,
|
||||
|
||||
@@ -390,6 +390,18 @@ export const APIKEY_PROVIDERS = {
|
||||
website: "https://cloud.google.com/vertex-ai",
|
||||
authHint: "Provide Service Account JSON or OAuth access_token",
|
||||
},
|
||||
// Z.AI (formerly ZhipuAI) — GLM-5 family with 128k output
|
||||
// Added 2026-03-17 based on ClawRouter feature analysis
|
||||
zai: {
|
||||
id: "zai",
|
||||
alias: "zai",
|
||||
name: "Z.AI (GLM-5)",
|
||||
icon: "psychology",
|
||||
color: "#2563EB",
|
||||
textIcon: "ZA",
|
||||
website: "https://open.bigmodel.cn",
|
||||
apiHint: "API key from https://open.bigmodel.cn/usercenter/apikeys",
|
||||
},
|
||||
};
|
||||
|
||||
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||
|
||||
@@ -52,6 +52,7 @@ const comboStrategySchema = z.enum([
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
"strict-random",
|
||||
"auto",
|
||||
]);
|
||||
|
||||
const comboRuntimeConfigSchema = z
|
||||
@@ -139,6 +140,12 @@ export const updateSettingsSchema = z.object({
|
||||
.optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
|
||||
// Auto intent classifier settings (multilingual routing)
|
||||
intentDetectionEnabled: z.boolean().optional(),
|
||||
intentSimpleMaxWords: z.number().int().min(1).max(500).optional(),
|
||||
intentExtraCodeKeywords: z.array(z.string().max(100)).optional(),
|
||||
intentExtraReasoningKeywords: z.array(z.string().max(100)).optional(),
|
||||
intentExtraSimpleKeywords: z.array(z.string().max(100)).optional(),
|
||||
// Protocol toggles (default: disabled)
|
||||
mcpEnabled: z.boolean().optional(),
|
||||
a2aEnabled: z.boolean().optional(),
|
||||
|
||||
@@ -30,6 +30,12 @@ export const updateSettingsSchema = z.object({
|
||||
.optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
|
||||
// Auto intent classifier settings (multilingual routing)
|
||||
intentDetectionEnabled: z.boolean().optional(),
|
||||
intentSimpleMaxWords: z.number().int().min(1).max(500).optional(),
|
||||
intentExtraCodeKeywords: z.array(z.string().max(100)).optional(),
|
||||
intentExtraReasoningKeywords: z.array(z.string().max(100)).optional(),
|
||||
intentExtraSimpleKeywords: z.array(z.string().max(100)).optional(),
|
||||
// Protocol toggles (default: disabled)
|
||||
mcpEnabled: z.boolean().optional(),
|
||||
mcpTransport: z.enum(["stdio", "sse", "streamable-http"]).optional(),
|
||||
|
||||
@@ -46,6 +46,10 @@ import {
|
||||
applyTaskAwareRouting,
|
||||
getTaskRoutingConfig,
|
||||
} from "@omniroute/open-sse/services/taskAwareRouter.ts";
|
||||
import {
|
||||
isFallbackDecision,
|
||||
shouldUseFallback,
|
||||
} from "@omniroute/open-sse/services/emergencyFallback.ts";
|
||||
|
||||
/**
|
||||
* Handle chat completion request
|
||||
@@ -270,7 +274,8 @@ async function handleSingleModelChat(
|
||||
request: any = null,
|
||||
comboName: string | null = null,
|
||||
apiKeyInfo: any = null,
|
||||
telemetry: any = null
|
||||
telemetry: any = null,
|
||||
runtimeOptions: { emergencyFallbackTried?: boolean } = {}
|
||||
) {
|
||||
// 1. Resolve model → provider/model
|
||||
const resolved = await resolveModelOrError(modelStr, body);
|
||||
@@ -372,6 +377,53 @@ async function handleSingleModelChat(
|
||||
return result.response;
|
||||
}
|
||||
|
||||
// Emergency fallback for budget exhaustion (402 / billing / quota keywords):
|
||||
// reroute to a free model (default provider/model: nvidia + openai/gpt-oss-120b) exactly once.
|
||||
if (!runtimeOptions.emergencyFallbackTried) {
|
||||
const fallbackDecision = shouldUseFallback(
|
||||
Number(result.status || 0),
|
||||
String(result.error || ""),
|
||||
Array.isArray(body?.tools) && body.tools.length > 0
|
||||
);
|
||||
|
||||
if (isFallbackDecision(fallbackDecision)) {
|
||||
const fallbackModelStr = `${fallbackDecision.provider}/${fallbackDecision.model}`;
|
||||
const currentModelStr = `${provider}/${model}`;
|
||||
|
||||
if (fallbackModelStr !== currentModelStr) {
|
||||
const fallbackBody = { ...body, model: fallbackModelStr };
|
||||
|
||||
// Cap output on emergency fallback to avoid unexpected long responses.
|
||||
const maxTokens = Math.min(
|
||||
Number(
|
||||
fallbackBody.max_tokens ??
|
||||
fallbackBody.max_completion_tokens ??
|
||||
fallbackDecision.maxOutputTokens
|
||||
) || fallbackDecision.maxOutputTokens,
|
||||
fallbackDecision.maxOutputTokens
|
||||
);
|
||||
fallbackBody.max_tokens = maxTokens;
|
||||
fallbackBody.max_completion_tokens = maxTokens;
|
||||
|
||||
log.warn(
|
||||
"EMERGENCY_FALLBACK",
|
||||
`${currentModelStr} -> ${fallbackModelStr} | reason=${fallbackDecision.reason}`
|
||||
);
|
||||
|
||||
return handleSingleModelChat(
|
||||
fallbackBody,
|
||||
fallbackModelStr,
|
||||
clientRawRequest,
|
||||
request,
|
||||
comboName,
|
||||
apiKeyInfo,
|
||||
telemetry,
|
||||
{ ...runtimeOptions, emergencyFallbackTried: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Mark account as quota-exhausted on 429 response
|
||||
if (result.status === 429) {
|
||||
markAccountExhaustedFrom429(credentials.connectionId, provider);
|
||||
|
||||
Reference in New Issue
Block a user