Compare commits

...

34 Commits

Author SHA1 Message Date
dependabot[bot]
eadbb3f544 chore(deps): bump github/codeql-action/analyze from 4.37.6 to 4.37.7
Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.37.6 to 4.37.7.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](5595ccaf91...ff2f1c621b)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-21 01:06:14 +00:00
Webman
118840131d fix(deps): upgrade @atjsh/llmlingua-2 to 2.0.5 and drop @tensorflow/tfjs (#10610)
Implements #10536: upgrade @atjsh/llmlingua-2 2.0.3 → 2.0.5 and drop @tensorflow/tfjs from the LLMLingua SLM optional stack.

Validated in an isolated worktree boarded onto origin/release/v3.8.50 (0 conflicts, 20 files):
- 48/48 focused llmlingua/colocate/docker unit tests pass (author-reported, reproduced).
- check-file-size, check-changelog-integrity: OK.
- grep confirms no remaining source imports of @tensorflow/tfjs.
- typecheck:core: clean.
- check-complexity / check-cognitive-complexity: OK, both under baseline.

Co-authored-by: jonlwheat2-gif <jonlwheat2-gif@users.noreply.github.com>
2026-08-20 22:02:14 -03:00
Octopus
3fed9e837a fix(sse): add the missing minimax-music dispatch to music generation (#10650)
Obrigado — bug real: MUSIC_PROVIDERS.minimax declara format "minimax-music" e seus modelos são publicados pelo catálogo, mas handleMusicGeneration nunca teve um branch para esse format — todo request minimax/* caía no guard final com "Unsupported music format", modelos anunciados mas inalcançáveis. Handler completo cobrindo os dois output formats (url/hex), envelope base_resp, endpoint regional, e guarda local de credencial ausente.

Validação (worktree própria a partir de origin/release/v3.8.50, merge limpo, 0 conflitos):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- tests/unit/minimax-music-generation.test.ts — 9/9 passando
2026-08-20 21:37:54 -03:00
Diego Rodrigues de Sa e Souza
1f04e73a1c Merge pull request #10922 from diegosouzapw/fix/10877-quota-alias-lookup
fix(sse): canonicalize alias provider ids before quota fetcher lookup (#10877)
2026-08-20 21:20:00 -03:00
Diego Rodrigues de Sa e Souza
f66c986dbd Merge pull request #10919 from diegosouzapw/fix/10849-search-provider-400
fix(api): POST /v1/search names unknown providers instead of opaque 400 (#10849)
2026-08-20 21:19:31 -03:00
Diego Rodrigues de Sa e Souza
3669df2ca5 Merge pull request #10914 from diegosouzapw/fix/10848-image-scan-cookie-bridge
fix(config): exclude cookie-auth image bridges from unprefixed model scan (#10848)
2026-08-20 21:19:17 -03:00
Diego Rodrigues de Sa e Souza
590cbbe7b1 Merge pull request #10918 from diegosouzapw/fix/10815-kiro-oauth-dedup
fix(db): disambiguate Kiro OAuth dedup by profileArn (#10815)
2026-08-20 21:19:10 -03:00
Diego Rodrigues de Sa e Souza
c7e264e1a6 Merge pull request #10911 from diegosouzapw/fix/10788-opencode-effort-tiers
fix(open-sse): declare Ollama Cloud reasoning models' supportedThinkingEfforts (#10788)
2026-08-20 21:19:03 -03:00
Diego Rodrigues de Sa e Souza
699be22e1e Merge pull request #10915 from diegosouzapw/fix/10765-rtk-unconditional-stats
fix: skip expensive RTK compression stats computation on no-op runs (#10765)
2026-08-20 21:18:55 -03:00
Diego Rodrigues de Sa e Souza
7756aef970 Merge pull request #10917 from diegosouzapw/fix/10597-combo-log-error-body
fix: log upstream error body in COMBO per-target failure warnings (#10597)
2026-08-20 21:18:48 -03:00
Diego Rodrigues de Sa e Souza
050c7c0021 Merge pull request #10913 from diegosouzapw/fix/10592-playground-endpoint-selector
fix: route Playground ChatTab Send to the selected endpoint (#10592)
2026-08-20 21:18:41 -03:00
Diego Rodrigues de Sa e Souza
c535df9076 Merge pull request #10923 from diegosouzapw/fix/10156-responses-commentary-sse
fix(sse): strip commentary items from Responses response.completed snapshot (#10156)
2026-08-20 21:18:28 -03:00
Diego Rodrigues de Sa e Souza
5d9998eb31 Merge pull request #10912 from diegosouzapw/fix/10095-antigravity-multiaccount-quota
fix(domain): treat unreported Antigravity quota fraction as unknown, not exhausted (#10095)
2026-08-20 21:18:16 -03:00
Markus Hartung
19741775ee fix(sse): strip commentary items from Responses response.completed snapshot (#10156)
Live SSE frames for a phase:"commentary" message were already dropped
per #6199, but the terminal response.completed.response.output array was
forwarded verbatim whenever the upstream echoed the same item back
non-empty, since backfillResponsesCompletedOutput only fills an empty
array. Reuse the existing isResponsesCommentaryMessageItem predicate to
filter the terminal snapshot's output array (and, defensively, the
backfill buffer it can be seeded from) so both representations agree.

Regression test added to tests/unit/responses-commentary-passthrough-6199.test.ts
reproducing the exact upstream shape from the issue.
2026-08-20 20:42:02 -03:00
Markus Hartung
b668d91364 fix(sse): canonicalize alias provider ids before quota fetcher lookup (#10877) 2026-08-20 20:41:59 -03:00
Markus Hartung
4ec080dc19 fix(api): POST /v1/search names unknown providers instead of opaque 400 (#10849)
v1SearchSchema.provider was a hard-coded z.enum that rejected any id outside
its list before the route's own resolveSearchProvider() check ever ran,
so unknown/short-alias provider ids (grok, brave, serper, ...) always
surfaced a generic "Invalid request" instead of the informative
"Unknown search provider: <id>" message. Relax the schema to a free-form
string and let resolveSearchProvider() own runtime validation (as it
already did for ids that passed the enum). Also extend
SEARCH_PROVIDER_ALIASES with short-form aliases mirroring the existing
jina/jina-ai pattern (brave, serper, perplexity, exa, tavily, google-pse,
linkup, ollama, searchapi, youcom, searxng, zai, duckduckgo), and surface
the first Zod validation issue's field name instead of the generic
message for other still-invalid fields (e.g. search_type).
2026-08-20 20:37:22 -03:00
Markus Hartung
0458c5ac4c fix(db): disambiguate Kiro OAuth dedup by profileArn (#10815) 2026-08-20 20:34:59 -03:00
Markus Hartung
9603ec1bf1 fix(sse): log upstream error body in COMBO per-target failure warnings (#10597) 2026-08-20 20:34:38 -03:00
Markus Hartung
6d043674c2 fix: skip expensive RTK compression stats computation on no-op runs (#10765) 2026-08-20 20:34:19 -03:00
Markus Hartung
bed4d24049 fix(config): exclude cookie-auth image bridges from unprefixed model scan (#10848) 2026-08-20 20:33:19 -03:00
Markus Hartung
018badc3b3 fix: route Playground ChatTab Send to the selected endpoint, not just chat.completions (#10592) 2026-08-20 20:30:40 -03:00
Markus Hartung
87719f2381 fix(domain): treat unreported Antigravity quota fraction as unknown, not exhausted (#10095) 2026-08-20 20:29:38 -03:00
Markus Hartung
0a1f1d42ee fix(open-sse): declare Ollama Cloud reasoning models' supportedThinkingEfforts (#10788)
glm-5.1, glm-5.2, deepseek-v4-pro and deepseek-v4-flash declared supportsReasoning:true but no supportedThinkingEfforts, so the catalog's appendSyncedEffortVariants() pass (which only synthesizes -low/-high/-max ids from an already-populated capabilities.effort_tiers) never exposed a selectable effort tier for them, unlike gpt-oss:20b/120b. Add the documented low/medium/high/max vocabulary (see supportsMaxEffortForProvider's isOllamaCloud comment in reasoningEffort.ts).
2026-08-20 20:27:26 -03:00
Yawar
eb6f319712 feat(providers): add tabitoken gateway and serve hcnsec's four protocols (#10668)
Obrigado — PR muito bem documentado e verificado. Adiciona o gateway TabiToken (Anthropic-first, /v1/messages, x-api-key) e estende hcnsec de 1 para 4 protocolos (Chat, Responses, Anthropic Messages, Gemini). AlternateFormat ganha o hook urlBuilder opcional (necessário para o path model-scoped do Gemini), compartilhado com o provider gemini nativo em vez de duplicado.

Reconciliado nesta sessão contra o release tip atualizado (base drift real: 343→345 canônicos entre quando o PR foi criado e o merge, mais os PRs #10673/#10658 mergeados nesse meio-tempo). Conflitos em contagens de providers (docs, file-size baseline, teste de partição) resolvidos additivamente.

Validação (reconciliação a partir de origin/release/v3.8.50):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- npm run check:provider-consistency — OK (266 REGISTRY entries, 346 providers canônicos, 0 exceções)
- 40/40 testes passando (newapi-gateway-providers, hcnsec-provider, providers-constants-split, alternate-formats)
2026-08-20 20:24:42 -03:00
MSiva
bc9090ba65 fix(translator): merge consecutive same-role contents in direct claudeToGeminiRequest (#10658)
Obrigado — bug real: a tradução direta claudeToGeminiRequest emitia mensagens consecutivas do mesmo role em contents[], o que a API do Gemini rejeita com HTTP 400 (turnos alternados user/model são obrigatórios). Traz claudeToGeminiRequest à paridade com openaiToGeminiRequest reutilizando mergeConsecutiveSameRoleContents.

Validação (worktree combinado a partir de origin/release/v3.8.50, merge limpo, 0 conflitos):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- tests/unit/claude-to-gemini-consecutive-roles.test.ts — 7/7 passando
- tests/unit/claude-to-gemini-budget-tokens-zero-6813.test.ts — 2/2 passando (sem regressão)
2026-08-20 20:04:20 -03:00
Paco Cartones
d9cb4f5f5d fix(files): validate the list limit query parameter (#10673)
Obrigado — bug real: GET /v1/files aceitava limit negativo sem validação (`-5 || 20` avalia truthy em -5, então Math.min(-5, 10000) = -5 passava direto). Agora valida integer/positivo/tamanho e retorna 400 estruturado para valores inválidos, preservando o default 20 e o máximo 10.000.

Validação (worktree combinado a partir de origin/release/v3.8.50, 0 conflitos):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- tests/integration/files-api-limit-validation.test.ts — 5/5 passando
- tests/integration/files-api.test.ts — 12/12 passando (sem regressão)
- tests/unit/batch_api.test.ts teve 1 falha, confirmada DRIFT pré-existente idêntica no tip puro do release (não relacionada, timing de cancelamento de batch)
2026-08-20 19:56:34 -03:00
Diego Rodrigues de Sa e Souza
ce6249cbb7 Merge pull request #10528 from excessivechaos/fix/direct-dispatcher-timeout-10214
fix(network): bound direct-path response-start timeout and retry on fresh socket (#10214)
2026-08-20 19:55:57 -03:00
Diego Rodrigues de Sa e Souza
9935f80971 fix(perplexity-web): make the built-in-search hint opt-in (#10904)
Merged — extraction of the one still-uncovered fix from #8634 (the other two items — mode "search"→CONCISE downgrade, pplx-opus generation — were already applied on this release tip). typecheck/file-size/changelog/complexity/cognitive-complexity gates all clean, 32/32 tests passing.
2026-08-20 19:34:18 -03:00
Diego Rodrigues de Sa e Souza
e968d11b1c feat(home): add Recent Requests panel + excludeTests allowlist fix (#10900)
Merged — reimplementation extracting the non-conflicting Recent Requests panel + excludeTests allowlist fix from #8450 (see PR body for the full scoping rationale, including why the topology UX rework was deliberately excluded — it contradicts the already-shipped #8428). typecheck/file-size/changelog/complexity/cognitive-complexity/i18n-coverage gates all clean, 2/2 unit + 1/1 vitest passing.
2026-08-20 18:26:02 -03:00
Diego Rodrigues de Sa e Souza
7afafcecc9 feat(sse): add GLM-5.3 models and effort tiers (#10896)
Merged — clean extraction from #10358's genuinely new content (see PR body for the rationale: an unrelated .planning/codebase/ scaffolding dump was dropped). typecheck/file-size/changelog/provider-consistency gates clean, 18/18 tests passing.
2026-08-20 18:06:41 -03:00
Tiangao
c79faa45fb fix(image): support OpenRouter reference-image edits (#10197) (#10363)
Obrigado — feature real e bem verificada: POST /v1/images/edits rejeitava o provider built-in openrouter mesmo ele suportando edição por imagem de referência via sua Image API unificada. Traduz a imagem de entrada para o formato input_references documentado do OpenRouter e despacha para /api/v1/images, removendo o prefixo do provider do model id antes de encaminhar.

Nota: o contribuidor não conseguiu rodar o teste localmente (better-sqlite3 ausente no ambiente dele) — rodei aqui.

Validação (worktree própria a partir de origin/release/v3.8.50, merge limpo, 0 conflitos):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- tests/unit/10197-openrouter-image-edits-route.test.ts — 3/3 passando (forward bem-sucedido, credenciais ausentes 401, rate-limit)
2026-08-20 18:04:22 -03:00
Diego Rodrigues de Sa e Souza
871832820f fix(memory): enable agent memory save via MCP tools + builtin stream guard (#10887)
Merged — clean single-commit extraction from #9115's genuinely new content (see PR body for the full extraction rationale: 66-commit branch, only 1 commit matched the stated scope). typecheck/file-size/changelog gates clean, 19/19 unit + 14/14 integration tests passing.
2026-08-20 17:28:37 -03:00
3g0r1ch
d87b97a786 feat(routing): adaptive feedback loop v2 — operational/semantic quality, confidence, TTFT/ITL, end-to-end test (#10881)
Obrigado — feature substancial e bem estruturada: separa qualidade operacional (comportamento de wire: 4xx/5xx, 429, respostas malformadas, stream interrompido) de qualidade semântica (só setada por avaliadores externos, nunca inferida do sucesso HTTP), com confidence/sample-awareness para não deixar poucos sucessos de sorte dominarem o ranking. Instrumentação de streaming (TTFT/ITL) threaded até RoutingEvent, endpoint de explicabilidade, e teste E2E determinístico cobrindo degradação→recuperação→blip.

Validação (worktree própria a partir de origin/release/v3.8.50, merge limpo, 0 conflitos):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- 59/59 testes passando (mlx-provider, routing-adaptive-e2e, routing-events(-concurrency), routing-otel, routing-quality, routing-scoring-quality, stream-timing, auto-combo-scoring-clamp)
2026-08-20 17:28:30 -03:00
excessivechaos
142ae93498 fix(network): bound direct-path response-start timeout 2026-08-17 08:26:57 -07:00
152 changed files with 7897 additions and 754 deletions

View File

@@ -1361,6 +1361,14 @@ CURSOR_USER_AGENT="Cursor/3.4"
# FETCH_BODY_TIMEOUT_MS=600000 # Time to receive full response body
# FETCH_CONNECT_TIMEOUT_MS=30000 # TCP connection establishment (default: 30s)
# FETCH_KEEPALIVE_TIMEOUT_MS=4000 # Keep-alive socket idle timeout (default: 4s)
# OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS=30000 # Bounded response-start window per direct
# # (no-proxy) attempt (#10214). A silently-dropped
# # pooled keep-alive socket surfaces no transport
# # error, so without this bound a direct request can
# # stall until undici's headersTimeout (600s) or the
# # caller's deadline; on expiry the request retries
# # once on a fresh no-keep-alive socket. 0 disables
# # the bound (default: 30000 = 30s).
# Default timeout (ms) for src/shared/utils/fetchTimeout.ts. Acts as the
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
@@ -1415,6 +1423,14 @@ CURSOR_USER_AGENT="Cursor/3.4"
# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000
# OMNIROUTE_PPLX_TLS_GRACE_MS=10000
# ── Perplexity web: built-in-search hint ──
# Used by: open-sse/executors/perplexity-web/protocol.ts — appends "You have
# built-in web search. Answer questions directly using search results." to the
# caller's system message. Off by default: Perplexity's answer engine searches
# anyway, and for coding clients the sentence leaks into replies as
# meta-commentary. Set to 1/true/yes/on to restore the old behavior.
# OMNIROUTE_PPLX_SEARCH_HINT=0
# ── Grok web TLS sidecar (Chrome-fingerprinted client) ──
# Used by: open-sse/services/grokTlsClient.ts — wire-level timeout for the
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on

View File

@@ -50,13 +50,13 @@ updates:
# bumps; majors here need their own PR and a deliberate migration review.
- dependency-name: "ioredis"
update-types: ["version-update:semver-major"]
# @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN.
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)
# and for local memory embeddings (src/lib/memory/embedding/transformersLocal.ts),
# and was VPS-validated at 3.5.2 (#4014). 4.x breaks both, and even 3.x minors must
# be re-validated on the VPS — so freeze ALL auto-bumps (no update-types = ignore
# every version). Migrate it intentionally, not via dependabot (#4050).
# @huggingface/transformers is VPS-validated at ^4.2.0 (migrated intentionally in
# #9962). It is load-bearing for the LLMLingua ONNX compression engine (open-sse/
# services/compression/engines/llmlingua/ — @atjsh/llmlingua-2@2.0.5 peers on
# "@huggingface/transformers": "^3.5.2 || ^4.0.0") and for local memory embeddings
# (src/lib/memory/embedding/transformersLocal.ts). Further majors must be re-validated
# on the VPS — so keep auto-bumps frozen (no update-types = ignore every version).
# Migrate it intentionally, not via dependabot (#4050).
- dependency-name: "@huggingface/transformers"
- package-ecosystem: "github-actions"

View File

@@ -26,6 +26,6 @@ jobs:
with:
languages: javascript-typescript
queries: security-extended
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
- uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:javascript-typescript"

View File

@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 343 LLM providers, auto-fallback.
**OmniRoute** — unified AI proxy/router. One endpoint, 346 LLM providers, auto-fallback.
| Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -173,7 +173,7 @@ COPY . ./
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \
mkdir -p /app/data \
&& npm run build \
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', '@tensorflow/tfjs', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
# ── Runner base ────────────────────────────────────────────────────────────
FROM base AS runner-base

View File

@@ -7,7 +7,7 @@
# 🚀 OmniRoute — The Free AI Gateway
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 343 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 343 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 346 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 346 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
</div>
@@ -101,7 +101,7 @@
<tr>
<td align="right"><b>⚙️ Features</b></td>
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
<td align="center"><a href="#-343-ai-providers--90-free">🌐 Providers</a></td>
<td align="center"><a href="#-346-ai-providers--90-free">🌐 Providers</a></td>
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI &amp; MCP</a></td>
</tr>
<tr>
@@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 343 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 343 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 1595%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 346 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 346 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 1595%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
<br/>
<br/>
@@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step:
</div>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 343 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project&apos;s docs."/>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 346 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project&apos;s docs."/>
<sub>📊 Full methodology &amp; per-feature detail vs 9router, OpenRouter, CLIProxyAPI &amp; LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
@@ -559,7 +559,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md)
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **343-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **346-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
- **⚡ Local performance & infra** — one-click local Redis, Cloudflare Workers / Deno Deploy relay deployers, Bifrost & Mux as supervised embedded services. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md)
@@ -642,11 +642,11 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
<div align="center">
## 🌐 343 AI Providers — 90+ Free
## 🌐 346 AI Providers — 90+ Free
</div>
> The most complete catalog of any open-source router: **343 providers**, **90+ with a free tier**, **56 free forever**.
> The most complete catalog of any open-source router: **346 providers**, **90+ with a free tier**, **56 free forever**.
<div align="center">

View File

@@ -0,0 +1,2 @@
- **feat(providers):** add the TabiToken NewAPI gateway (`tabitoken`) and teach the existing HCNSec entry (`hcnsec`) the three further protocols it actually serves. TabiToken leaves the NewAPI pricing endpoint public, so its catalog is read from the host rather than guessed: four Claude models, each reporting the Anthropic and OpenAI protocols. HCNSec shipped OpenAI-only; probing the host showed `/v1/messages`, `/v1/responses` and the Gemini `/v1beta` path all reach its token layer, so each is now declared as an alternate format — with its default format, base URL, auth scheme and regional catalog classification untouched. ([#10668](https://github.com/diegosouzapw/OmniRoute/pull/10668)) — thanks @yawar-aquil
- **feat(sse):** allow an alternate protocol to build its own upstream URL. `AlternateFormat` gained an optional `urlBuilder`, because the Gemini protocol carries the model inside the path (`{base}/{model}:generateContent`) and the existing `chatPath`/`urlSuffix` fields are constants that cannot express it. The route builder is extracted as `buildGeminiGenerateContentUrl` and shared with the native `gemini` provider so the two consumers cannot drift on the `?alt=sse` streaming suffix. ([#10668](https://github.com/diegosouzapw/OmniRoute/pull/10668)) — thanks @yawar-aquil

View File

@@ -0,0 +1 @@
- **feat(sse):** add GLM-5.3 support (`glm-5.3`, `glm-5.3-high`, `glm-5.3-low`) across the z.ai first-party providers, mapping the upstream `reasoning_effort` request parameter to the existing 5.2 tier UX ([#10896](https://github.com/diegosouzapw/OmniRoute/pull/10896)) — thanks @phuongddx

View File

@@ -0,0 +1 @@
- **feat(home):** add a live **Recent Requests** panel beside the home Provider Topology (polls `GET /api/usage/call-logs?excludeTests=1` every ~3s, gated by the topology appearance toggle + page visibility). `excludeTests` is now an allowlist of real provider inference (`/v1/%` or `/api/v1/%`), applied before `LIMIT`, so connection-test/model-sync/management rows can never leak into the feed ([#10897](https://github.com/diegosouzapw/OmniRoute/pull/10897), extracted from [#8450](https://github.com/diegosouzapw/OmniRoute/pull/8450)) — thanks @nguyenha935

View File

@@ -0,0 +1 @@
- fix(domain): stop treating an unreported Antigravity quota fraction (`fractionReported:false`) as 0% remaining in `quotaCache.ts`, which was falsely marking every fresh/newly-connected account as exhausted and blocking multi-account rotation (#10095)

View File

@@ -0,0 +1 @@
- **fix(sse):** Responses-passthrough `response.completed` snapshots now drop `phase:"commentary"` items the same way live SSE frames already do, so the terminal `response.output` array no longer echoes internal commentary text that was already suppressed from the stream (#10156).

View File

@@ -0,0 +1 @@
- **fix(network):** direct (no-proxy) egress now bounds each attempt's response-start window (default 30s, `OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS`) and retries once on a fresh no-keep-alive socket, so a silently-dropped pooled keep-alive connection can no longer stall direct providers (opencode-go, command-code) until a service restart ([#10214](https://github.com/diegosouzapw/OmniRoute/issues/10214))

View File

@@ -0,0 +1 @@
- **fix(deps):** upgrade `@atjsh/llmlingua-2` from 2.0.3 to 2.0.5 and remove `@tensorflow/tfjs` from the LLMLingua SLM stack — 2.0.5 adds official Transformers.js v4 support (peers `@huggingface/transformers` at `^3.5.2 || ^4.0.0`) and 2.0.4+ no longer requires TensorFlow.js, restoring compatibility with OmniRoute's Transformers.js v4 while dropping the largest single contributor to the optional runtime footprint ([#10536](https://github.com/diegosouzapw/OmniRoute/issues/10536))

View File

@@ -0,0 +1 @@
- fix(dashboard): route the Playground's ChatTab "Send" through the endpoint actually selected in StudioConfigPane (`search`, `web.fetch`, etc.) instead of always POSTing to `/api/v1/chat/completions`, fixing the false "No active credentials for provider" 404 when testing search-only providers (#10592)

View File

@@ -0,0 +1 @@
- **fix(sse):** Include the redacted upstream error body in the per-target COMBO failure log (`Model X failed, trying next`) so operators can triage a 400/500 without reproducing the request ([#10597](https://github.com/diegosouzapw/OmniRoute/issues/10597))

View File

@@ -0,0 +1 @@
- fix(compression): skip the expensive `createCompressionStats()` pass in RTK when no message was actually compressed, matching every sibling stacked engine (#10765)

View File

@@ -0,0 +1 @@
- **fix(open-sse):** declare `supportedThinkingEfforts` (`low`/`medium`/`high`/`max`) on Ollama Cloud's `glm-5.1`, `glm-5.2`, `deepseek-v4-pro` and `deepseek-v4-flash` registry entries so the catalog's `appendSyncedEffortVariants()` pass — which only synthesizes selectable `-low`/`-high`/`-max` model ids from an already-populated `capabilities.effort_tiers` — can expose an effort selector for these reasoning-capable models, matching what `gpt-oss:20b`/`gpt-oss:120b` already had (#10788)

View File

@@ -0,0 +1 @@
- fix(db): disambiguate `createProviderConnection()`'s OAuth email dedup by `providerSpecificData.profileArn` in addition to `username`, so adding a second Kiro/AWS profile with the same email creates a new connection instead of silently merging into the first (#10815)

View File

@@ -0,0 +1 @@
- fix(config): exclude cookie-auth image bridges (chatgpt-web, gemini-web) from the unprefixed model scan so a bare id never silently binds to an unofficial web bridge (#10848)

View File

@@ -0,0 +1 @@
- fix(api): POST /v1/search now replies with a named `Unknown search provider: <id>` error (and field-named validation messages) instead of an opaque `Invalid request` for unrecognized or short-alias provider ids like `brave`/`serper` (#10849)

View File

@@ -0,0 +1 @@
- **fix(sse):** `getResetAwareProvider()` and the auto-combo quota lookup in `combo.ts` now canonicalize the provider id via `resolveProviderId()` before calling `getQuotaFetcher()`, so a fetcher registered under a provider's canonical id (e.g. `ollama-cloud`, `codex`) is found for combo targets stored under an alias spelling (e.g. `ollamacloud`, `cx`) instead of silently degrading reset-aware/reset-window/auto quota-aware routing to plain priority ordering (#10877)

View File

@@ -0,0 +1 @@
- **fix(memory):** enable agent memory save/update via MCP tools (`memory_save`/`update`/`search`/`delete` builtins with per-provider schemas, `apiKeyId` optional with caller-principal fallback) and gate server-side memory builtin injection to non-stream requests only ([#10887](https://github.com/diegosouzapw/OmniRoute/pull/10887)) — thanks @Egorich-print

View File

@@ -0,0 +1 @@
- **fix(perplexity-web):** make the built-in-search hint appended to every system message opt-in via `OMNIROUTE_PPLX_SEARCH_HINT` (off by default) — Perplexity's answer engine searches anyway, and the hint leaked into replies as meta-commentary for coding clients ([#10902](https://github.com/diegosouzapw/OmniRoute/pull/10902), extracted from [#8634](https://github.com/diegosouzapw/OmniRoute/pull/8634)) — thanks @danscMax

View File

@@ -0,0 +1 @@
- **fix(translator):** merge consecutive same-role contents in direct Claude to Gemini request translation to prevent upstream HTTP 400 errors

View File

@@ -0,0 +1 @@
- **fix(sse):** MiniMax music models now generate audio instead of failing with `Unsupported music format: minimax-music` — the provider entry was registered in the music registry (and advertised by `/v1/models`), but `handleMusicGeneration` had no branch for its format, so every `minimax/*` music request fell through the dispatch chain to a 400. Adds the missing dispatch: a single synchronous POST with the `base_resp` envelope check (a non-zero `status_code` arrives on HTTP 200 too), `data.status` handling (an unfinished generation is reported instead of polled — the operation has no task id and no query endpoint), `url` and `hex` output formats (hex normalized to base64), `mp3`/`wav`/`pcm` containers via `audio_setting`, and the regional endpoint through the per-connection base-URL override, which is also the only host that accepts `aigc_watermark`. The registry entry gains the generation and cover model ids it was missing and drops a query URL that does not exist for this operation. Regression guard: `tests/unit/minimax-music-generation.test.ts` (9 tests).

View File

@@ -20,7 +20,6 @@
"@stryker-mutator/tap-runner",
"@swc/helpers",
"@tailwindcss/postcss",
"@tensorflow/tfjs",
"@testing-library/jest-dom",
"@testing-library/react",
"@toon-format/toon",

View File

@@ -444,14 +444,15 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1062,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051,
"src/shared/components/ModelSelectModal.tsx": 1138,
"src/shared/constants/providers/apikey/gateways.ts": 1268,
"src/shared/constants/providers/apikey/gateways.ts": 1283,
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387,
"_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).",
"src/lib/modelCapabilities.ts": 1006,
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014,
"open-sse/config/imageRegistry.ts": 1034,
"src/sse/handlers/chatHelpers.ts": 1017,
"src/shared/middleware/chatBodyAdmission.ts": 1005
"src/shared/middleware/chatBodyAdmission.ts": 1005,
"_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file)."
},
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",

View File

@@ -0,0 +1,350 @@
---
title: "Adaptive Routing: Routing Events, Quality Feedback & Explainability"
version: 3.8.50
lastUpdated: 2026-08-20
---
# Adaptive Routing: Routing Events, Quality Feedback & Explainability
This document describes the feedback-driven adaptive routing foundation added to
OmniRoute. It is deliberately small: it introduces a typed routing-outcome
channel, an online quality signal that feeds the existing auto-combo scorer, an
optional OpenTelemetry exporter, and an explainability endpoint. It does **not**
replace the existing resilience stack (circuit breaker, connection cooldown,
model lockout, health matrix, autopilot) — it complements it.
## 1. Architectural context
OmniRoute is a data plane with a **request hot path** and a **control/intelligence
plane**. The hot path must stay fast, memory-efficient, asynchronous, resilient and
predictable. Evaluation, quality scoring, experiments and historical analysis belong
to the control plane.
```
AI Agent / IDE
┌─────────────────────┐
│ OmniRoute │ data plane (fast, sync, in-memory)
│ routing / failover │
│ health / guardrail │
│ cache / streaming │
└──────────┬──────────┘
│ RoutingEvent (fire-and-forget, ~0.2µs)
┌─────────────────────┐
│ Feedback sinks │ control plane (async, best-effort)
│ quality tracker │
│ OTel exporter │
│ explain store │
└──────────┬──────────┘
▼ quality score
auto-combo scorer
```
### What was already there (audited, not duplicated)
| Concept | Existing implementation |
| ----------------------------------- | -------------------------------------------------------------------------------------------------- |
| Availability (can we send traffic?) | Circuit breaker (CLOSED/DEGRADED/OPEN/HALF_OPEN, DB-persisted), connection cooldown, model lockout |
| Health reporting | `providerHealthMatrix.ts`, `providerHealthAutopilot.ts` |
| Shadow traffic | `open-sse/services/combo/shadowRouting.ts` |
| Guardrails | `src/lib/guardrails/` (pre/post hooks) |
| Exact cache | `src/lib/semanticCache.ts` (signature-based) |
| Evaluators / eval-driven routing | `src/lib/evals/`, `open-sse/services/evalRouting.ts` |
| Combo decision explainability | `open-sse/services/combo/decisionTrace.ts` |
| Dashboard real-time events | `src/lib/events/eventBus.ts` (UI notification channel, `unknown` payloads, 100-entry history) |
The routing-event layer is **not** a re-implementation of `eventBus`: that bus is
the dashboard's real-time notification channel (typed _event names_, opaque
payloads, UI consumers). `RoutingEvent` is a typed _outcome_ struct
(latency/tokens/cost/outcome/finish-reason) consumed by the control plane's
feedback sinks (quality tracker, OTel exporter, explain store).
### What was missing (added here)
1. A **typed routing-outcome event + sink abstraction** (`RoutingEvent` /
`RoutingEventSink`). `decisionTrace` is combo-scoped and in-memory-only;
`comboMetrics` are cumulative counters; `call_logs` is raw async persistence.
None is a typed, sink-based outcome channel that a quality tracker, an OTel
exporter, or a Future-AGI-style evaluator can subscribe to.
2. An **online quality signal** (EWMA) for output quality — the scorer previously
proxied "quality" only through static task fitness and opt-in eval pass-rates.
3. An **optional, dependency-free OTel exporter** using GenAI semantic conventions.
4. An **explainability endpoint** returning the real routing decisions + quality state.
## 2. Routing Events (feedback foundation)
Files: `open-sse/services/routing/events.ts`, `.../index.ts`
A `RoutingEvent` carries only routing metadata:
```ts
interface RoutingEvent {
requestId: string;
provider: string;
model: string;
strategy: string; // "auto" | "priority" | "direct" | ...
latencyMs: number;
ttftMs: number | null;
inputTokens: number | null;
outputTokens: number | null;
cost: number | null;
retries: number;
fallbackUsed: boolean;
outcome: RoutingOutcome; // allowlisted union
status: number | null;
finishReason: string | null;
connectionId: string | null;
ts: number;
}
```
`RoutingEventSink` is a `Send+Sync`-style trait in TypeScript:
```ts
interface RoutingEventSink {
readonly name: string;
record(event: RoutingEvent): void; // must be O(1), no sync I/O
}
```
The hot path calls `emitRoutingEvent(event)` once per completed request
(the streaming-completion callback, the non-streaming success path, and the
malformed-200 failure path in `handleChatCore`). Dispatch is synchronous fan-out
to registered sinks, but each sink only enqueues/updates in-memory state. **No
synchronous database writes, no network I/O on the hot path.**
Default sinks:
- `MemoryRoutingEventStore` — bounded (500) ring buffer, newest-first, for the
explain endpoint.
- `QualityTracker` consumer — updates the EWMA quality estimate.
- `OtlpHttpsEventSink` — optional, enabled only when `OMNIROUTE_OTEL_ENDPOINT`
(or `OTEL_EXPORTER_OTLP_ENDPOINT`) is set.
### Measured overhead (honest comparison)
`npm run bench:routing-events` on this workstation (100k iterations; sub-µs ops
measured as aggregate µs/op because per-op percentiles are below
`performance.now()` timer resolution):
| Scenario | µs/op | ops/s |
| --------------------------------- | ------ | ------ |
| baseline (scoring only) | ~0.045 | ~22 M |
| baseline + RoutingEvent (2 sinks) | ~0.168 | ~5.9 M |
| baseline + event + OTel enqueue | ~0.163 | ~6.1 M |
| concurrent (8 interleaved bursts) | ~0.18 | — |
The event-dispatch delta over baseline scoring is ~0.12 µs/request; the OTel sink
only enqueues (O(1) buffer push), adding nothing measurable. These numbers are
machine-specific and relative — not a production guarantee. The v1 "~0.2 µs"
figure was an aggregate estimate; this methodology separates the scoring baseline
from the event-dispatch cost.
## 3. Quality Signal (feedback-driven provider state)
Files: `open-sse/services/routing/quality.ts`
v2 separates **operational** from **semantic** quality:
- **Operational** — derived from the routing hot path (HTTP 4xx/5xx, connection
failures, 429s, malformed responses, stream interruptions, `finish_reason=length`,
zero-output successes, latency/TTFT EWMA). A 200 is NOT treated as semantic
quality.
- **Semantic** — the actual value of the generated output. ONLY ever produced by
an evaluator via `setSemanticQuality()`. It is `null` until one provides it and
never leaks into the operational score.
Per-(provider, model) state (EWMA + bounded counters):
- `successEwma` — EWMA (α=0.2) of outcome success.
- `latencyEwma` / `ttftEwma` — EWMA of latency (α=0.1).
- `samples`, `anomalies`, `rateLimited`, `semantic`, `semanticConfidence`.
- `recencyMs` — how recently the model was last observed.
### Confidence / sample awareness
`confidence = clamp01(samples / 50)`, and the score returned to the scorer is
blended toward the neutral midpoint:
```
score = 0.5 + confidence * (operational - 0.5)
```
Consequences (verified by tests):
- A cold provider (0 samples) scores **0.5** — not unfairly penalized, but
unable to dominate a provider with thousands of solid observations.
- A provider with 7 lucky successes is pulled toward 0.5 (never dominates from
optimistic initialization).
- A provider with 50+ samples converges to its true operational score.
- Degradation and recovery are gradual (EWMA), and one isolated failure does
not destroy a healthy provider.
`ProviderQuality` exposes `{ operational, semantic, confidence, samples, anomalies,
rateLimited, successEwma, latencyEwmaMs, ttftEwmaMs, recencyMs }`.
This feeds the auto-combo scorer as the `quality` scoring factor:
- `ScoringFactors.quality` / `ScoringWeights.quality` in
`open-sse/services/autoCombo/scoring.ts`.
- `DEFAULT_WEIGHTS`: `health` 0.1905 → 0.1605, `quality` 0.03. Sum stays 1.0.
- `buildAutoCandidates` populates `candidate.quality` from the tracker; candidates
without data default to neutral **0.5** (a cold candidate is neither boosted nor
penalized).
The closed loop:
```
RoutingEvent → QualityTracker → getQualityScore → auto-combo quality factor
↑ │
└────── request outcome (handleChatCore) ←────────────┘
```
### Hard exclusion vs soft penalty
The quality signal is a **soft adaptive preference** only. Hard exclusion stays
with the existing resilience stack: circuit breaker OPEN, quota exhausted,
auth failure, model lockout — none of these are affected by the quality score.
A provider whose quality score dips temporarily is de-preferenced, never
hard-disabled.
## 3b. Canonical stream timing (TTFT / ITL)
Files: `open-sse/utils/streamTiming.ts`
`createStreamTiming()` is the single instrumentation seam for the streaming path,
wired into `createSSEStream` (open-sse/utils/stream.ts):
- `markByte()` — first upstream chunk received.
- `markForward()` — first chunk forwarded to the client (used for TTFT).
- `markInterrupted()` — stream timeout/abort/error before a clean finish.
- `ttft()` = first-forwarded-SSE-chunk latency. **This is NOT token-level TTFT**
a single SSE chunk may carry zero/one/many tokens. Documented precisely.
- `avgItlMs()` = mean inter-chunk gap (a chunk-latency proxy for ITL).
TTFT/ITL/interrupted flow into the `RoutingEvent` (`ttftMs`, `itlMs`) and are
exported as GenAI/OmniRoute span attributes by the OTel sink.
## 4. OpenTelemetry / GenAI observability
Files: `open-sse/services/routing/otel.ts`
- Dependency-free OTLP/HTTP JSON exporter (uses global `fetch`, no
`@opentelemetry/*` SDK).
- Spans follow GenAI semantic conventions (`gen_ai.provider.name`,
`gen_ai.request.model`, `gen_ai.usage.input_tokens/output_tokens`,
`gen_ai.completion.finish_reason`, `gen_ai.system`) plus OmniRoute routing
attributes (outcome, status, ttft, retries, fallback).
- `record()` only enqueues into a bounded buffer (O(1)); a background timer
flushes via `POST {endpoint}/v1/traces` asynchronously. Under overload the
oldest events are dropped (`dropped` counter) — never backpressure the data
plane.
- **Disabled unless configured.** `OMNIROUTE_OTEL_ENDPOINT` (or
`OTEL_EXPORTER_OTLP_ENDPOINT`) must be set; otherwise the sink is not
registered and zero OTel code runs.
## 5. Explainability
- `GET /v1/explain/routing` returns the recent `RoutingEvent`s (the real
decisions, newest first) and the per-provider/model quality snapshot.
- Auth mirrors `/v1/combos` (Bearer API key or dashboard session; anonymous on
single-user local deployments with `REQUIRE_API_KEY=false`).
- Combo-level per-invocation traces remain available via the existing
`decisionTrace.ts` (header `X-OmniRoute-Combo-Trace`).
- Safety: events carry only routing metadata, never prompts/bodies/credentials.
## 6. Evaluation-plane integration (Future AGI readiness)
OmniRoute treats Future AGI (or any evaluator) as a **potential
intelligence/evaluation backend, not a dependency**. The seams:
- A `RoutingEventSink` can forward events to an evaluator asynchronously.
- The `MemoryRoutingEventStore` + quality snapshot give an evaluator the raw
decision stream.
- A future `Evaluator` (deterministic, local judge, HTTP, WASM) would consume
events/traces and return a `QualityScore` that feeds the same
`getQualityScore`/quality-factor path.
- Existing eval-driven routing (`open-sse/services/evalRouting.ts`) already
re-orders combo targets by `eval_runs` pass-rates when enabled.
No evaluation runs synchronously on the request path, and the gateway operates
fully with the evaluator absent.
## 7. Final architectural review
1. **What remains on the synchronous hot path?** Routing/scoring, guardrail
pre-checks, cache lookup, and one `emitRoutingEvent` fan-out (~0.12 µs over
baseline scoring) to in-memory sinks.
2. **What moved to asynchronous processing?** OTel export (timer + fetch),
`call_logs`/usage persistence, semantic-cache writes, quality is in-memory
and O(1) (no async needed).
3. **How does a routing outcome become feedback?** `handleChatCore` emits a
`RoutingEvent``QualityTracker` updates EWMA state → `getQualityScore`
feeds the auto-combo `quality` factor.
4. **How does quality influence future routing?** A low quality score reduces
the weighted score of that provider/model in `scoreAutoTargets`, so degraded
models are gradually de-preferenced and recover as their EWMA improves.
5. **How can Future AGI integrate without becoming a dependency?** Via the
`RoutingEventSink` interface / a future `Evaluator` adapter — no hardcoded
dependency.
6. **What happens when the evaluator is unavailable?** Routing is unaffected;
quality falls back to neutral (1.0) for models with no observed signal.
7. **What happens when telemetry is unavailable?** The OTel sink simply isn't
registered; the rest of the routing layer runs unchanged.
8. **What happens under overload?** The OTel buffer drops oldest events; quality
and the ring buffer are bounded by construction; no backpressure.
9. **How does provider state recover after degradation?** EWMA re-converges as
successes accumulate; warmup keeps cold models neutral; the circuit breaker
independently recovers via HALF_OPEN probes.
10. **Which proposed features were intentionally NOT implemented, and why?**
- Shadow traffic / experiments — already implemented
(`combo/shadowRouting.ts`); not re-built.
- Guardrails — already implemented (`src/lib/guardrails/`); not duplicated.
- Semantic cache — already implemented (`src/lib/semanticCache.ts`); not
duplicated.
- A full experiment-management platform, dataset tooling, prompt-optimization
platform, vector DB, or mandatory external OTel infrastructure — out of
scope for a lean data plane.
- A Rust `RoutingEvent` struct — the data plane is TypeScript; the TS type
is the adapted equivalent.
## 8. Configuration reference
| Variable | Default | Effect |
| ----------------------------- | ----------- | ------------------------------------------------------------------------------- |
| `OMNIROUTE_OTEL_ENDPOINT` | unset | When set, enables the OTLP/HTTP traces exporter (e.g. `http://collector:4318`). |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Fallback alias for the OTLP endpoint. |
| `OTEL_SERVICE_NAME` | `omniroute` | `service.name` resource attribute. |
## 9. Tests
- `tests/unit/routing-events.test.ts` — event normalization, status
classification, bounded ring buffer, sink fan-out + isolation.
- `tests/unit/routing-quality.test.ts` — EWMA warmup, failure/success recovery,
anomaly penalties, 429 transient handling, snapshot, reset.
- `tests/unit/routing-scoring-quality.test.ts` — weight integrity, neutral
default, quality factor ranking.
- `tests/unit/routing-otel.test.ts` — enable gating, GenAI span payload, async
flush, drop-under-overload.
- `tests/unit/routing-events-concurrency.test.ts` — thousands of events, ring
buffer boundedness, throwing-sink isolation, interleaved async bursts,
reset-during-inserts.
- `tests/unit/routing-adaptive-e2e.test.ts` — deterministic end-to-end loop via
the real `scoreAutoTargets` scorer: healthy → degrade → recover → blip, plus
cold-start and lucky-cold-provider scenarios.
- `tests/unit/stream-timing.test.ts` — TTFT (first-forwarded-chunk), ITL,
first-byte vs first-forward, interruption, malformed/empty chunk safety.
## 10. Pre-existing issues status (Phase 18)
| Issue | Status | Notes |
| ----------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `omniglyph` export mismatch | **FIXED (environmental)** | `node_modules` was out of sync with `package-lock.json` (installed 1.3.1 vs locked 1.4.0). Running `npm install omniglyph@1.4.0` restored the locked version; type errors dropped to 0. Manifests unchanged. |
| Stale `getKnownContextOverflow` tests | **KNOWN — not fixed** | `combo-context-overflow-compression-probe.test.ts` imports a function that no longer exists in `open-sse/services/combo.ts` (only comments reference it). Fixing requires re-implementing or re-writing those tests — unrelated architectural churn. |
| `combo-runtime-unit-concurrency.test.ts` DB isolation | **KNOWN — not fixed** | Test-harness SQLite-isolation assertion fails when run directly; fails identically on the base branch. |
| i18n `llm.txt` drift | **KNOWN — not fixed** | `docs/i18n/*/llm.txt` differ from root; pre-existing, blocks the docs-sync pre-commit gate. |
Environmental vs code issues are kept distinct; no unrelated failures are hidden
behind changed test filters.

View File

@@ -12,6 +12,7 @@
"ROUTER_BACKENDS",
"admission-lanes",
"cluster-decisions",
"persistence-backend-boundary"
"persistence-backend-boundary",
"ADAPTIVE_ROUTING"
]
}

View File

@@ -28,12 +28,12 @@ The `omniglyph` engine (package `omniglyph`, 1.4.0+) accepts a named semantic pr
globally through `omniglyph.profile` in the compression settings or per step through the
stacked pipeline's step config:
| Profile | Boundary |
| -------------- | --------------------------------------------------------------------------- |
| `aggressive` | Default. The policy the published receipts measured — images system, tool docs and dense history |
| `balanced` | Keeps live state native, protects the last 8 turns, collapses older closed history |
| `coding-safe` | Keeps authority, tool schemas and live tool output native, protects the last 12 turns |
| `passthrough` | Routes without transforming; the engine is skipped |
| Profile | Boundary |
| ------------- | ------------------------------------------------------------------------------------------------ |
| `aggressive` | Default. The policy the published receipts measured — images system, tool docs and dense history |
| `balanced` | Keeps live state native, protects the last 8 turns, collapses older closed history |
| `coding-safe` | Keeps authority, tool schemas and live tool output native, protects the last 12 turns |
| `passthrough` | Routes without transforming; the engine is skipped |
The profile is a **ceiling, not a floor**: `mergeCompressionProfileOptions` in the package
refuses to let a caller override reopen a lossy lane the profile closed, so a per-step
@@ -170,22 +170,22 @@ override points it at a local copy instead (offline / air-gapped installs).
### Optional dependencies & on-demand install
The prunable LLMLingua runtime peer stack is **optional**. Three packages are declared as
The prunable LLMLingua runtime peer stack is **optional**. Two packages are declared as
`optionalDependencies` in `package.json` and kept **external** by the production build
(`scripts/build/prepublish.ts` does not bundle them):
| Package | Version (pin) | Notes |
| -------------------- | ------------- | ---------------------------------------------- |
| `@atjsh/llmlingua-2` | `2.0.3` | Entry package; declares the others as peers |
| `@tensorflow/tfjs` | `4.22.0` | Heaviest dep — dominates the ~800 MB footprint |
| `js-tiktoken` | `^1.0.20` | Tokenizer |
| Package | Version (pin) | Notes |
| -------------------- | ------------- | ------------------------------------------- |
| `@atjsh/llmlingua-2` | `2.0.5` | Entry package; declares the others as peers |
| `js-tiktoken` | `^1.0.20` | Tokenizer |
`@huggingface/transformers` is pinned at `3.5.2` as an **optional** dependency (shared with
the local embeddings path and also traced into the standalone bundle). Keeping it optional prevents
`onnxruntime-node` CUDA provider postinstall failures on CUDA 11 hosts from aborting the whole
OmniRoute install; when the optional stack is absent, LLMLingua still fail-opens. Only the three
packages above are prunable SLM peers. A standard `npm install` (dev) installs the optional stack
automatically unless optional dependencies are omitted.
`@huggingface/transformers` is pinned at `^4.2.0` (shared with the local embeddings path and
also traced into the standalone bundle); `@atjsh/llmlingua-2@2.0.5` peers on it with
`"^3.5.2 || ^4.0.0"`, so both Transformers.js v3 and v4 are supported. Since 2.0.4,
`@atjsh/llmlingua-2` no longer requires `@tensorflow/tfjs`, which removed the largest single
contributor (TensorFlow.js) from the SLM stack. Only the two packages above are prunable SLM
peers. A standard `npm install` (dev) installs the optional stack automatically unless optional
dependencies are omitted.
**Why on-demand:** the npm-published package, the standalone bundle, and the Docker image
ship **without** these deps to stay slim. When they are absent, the worker's dependency
@@ -195,11 +195,12 @@ error logged). To activate it in a pruned environment, install the optional stac
```bash
# pin to the versions declared in package.json optionalDependencies
npm install @atjsh/llmlingua-2@2.0.3 @tensorflow/tfjs@4.22.0 js-tiktoken
npm install @atjsh/llmlingua-2@2.0.5 js-tiktoken
```
Roughly **~800 MB** total: the TensorFlow.js + transformers runtimes dominate; the
TinyBERT model adds ~57 MB downloaded at first use (not via npm).
The `@tensorflow/tfjs` removal (2.0.4+) eliminates the previously dominant ~800 MB
contributor — the remaining footprint is the transformers.js + onnxruntime-node runtimes,
plus the TinyBERT model (~57 MB) downloaded at first use (not via npm).
Per environment:

View File

@@ -52,6 +52,8 @@ safely retry only the failures after a partial result.
- **Pollinations** — Free GPT-5, Claude, Gemini (no key needed)
- **LongCat** — 10M tokens free (one-time grant, requires account + KYC)
- **Cloudflare AI** — 50+ models, 10K neurons/day
- **MLX Gemma 26B** — Local Apple Silicon model (~38.5 tok/s, ~15.9GB RAM)
- **MLX Qwen 3.8 27B** — Local Apple Silicon model (~9.1 tok/s, ~13.1GB RAM)
4. Click **Connect**
5. Done! You now have free AI access.
@@ -79,6 +81,94 @@ safely retry only the failures after a partial result.
5. Login with your account
6. Done! You now have access to your subscription models.
### Option D: Local MLX Models (Apple Silicon)
For Apple Silicon Macs with unified memory, OmniRoute supports connecting to local MLX models running via `mlx-lm.server` as regular OpenAI-compatible local providers.
#### Prerequisites
- **Apple Silicon Mac** (M1/M2/M3/M4) with 24GB+ unified memory recommended
- **uv** package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
- **mlx-lm**: `uv pip install mlx-lm`
#### Quick Start
1. **Install dependencies**:
```bash
# Install uv if not already installed
curl -LsSf https://astral.sh/uv/install.sh | sh
# Install mlx-lm
uv pip install mlx-lm
```
2. **Start MLX servers manually** (in separate terminals):
```bash
# Terminal 1: Gemma 4 26B A4B IT-QAT (port 11435)
uv run mlx_lm.server --model mlx-community/gemma-4-26B-A4B-it-qat-q4_0-mlx-aligned --port 11435 --host 127.0.0.1
# Terminal 2: Qwen 3.8 27B MLX Mixed (port 11436)
uv run mlx_lm.server --model maglun/Qwen3.8-27B-MLX-Mixed-3.80bpw --port 11436 --host 127.0.0.1
```
3. **Connect in OmniRoute Dashboard**:
- Go to **Providers** → **Add Provider**
- Select **MLX Gemma 26B** or **MLX Qwen 3.8 27B**
- Click **Connect** (no API key needed)
4. **Use with OpenCode**:
```bash
# Configure OpenCode to use OmniRoute
opencode config set api.base_url http://localhost:20128/v1
opencode config set api.key <your-omniroute-api-key>
# Use MLX models
opencode run --model mlx-gemma/gemma-4-26b
opencode run --model mlx-qwen/qwen3.8-27b
```
#### Memory Management
**Important**: With 24GB unified memory, only **one large MLX model can run at a time**.
- Gemma 26B: ~15.9GB peak memory
- Qwen 3.8 27B: ~13.1GB peak memory
You must manage this manually:
- Run only one MLX server at a time, or
- Run both on separate machines, or
- Stop one before starting the other
OmniRoute does not automatically manage MLX server processes — it only routes requests to the OpenAI-compatible endpoints you configure.
#### Tool Calling Support
Both models support OpenAI-compatible tool calling. Test with:
```bash
curl -X POST http://localhost:20128/v1/chat/completions \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-d '{
"model": "mlx-gemma/gemma-4-26b",
"messages": [{"role": "user", "content": "What is 2+2? Use the calculator tool."}],
"tools": [{"type": "function", "function": {"name": "calculator", "description": "Calculate", "parameters": {"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]}}}]
}'
```
#### Troubleshooting
| Issue | Solution |
| ------------------ | ----------------------------------------------------------------------------- |
| Server won't start | Check `uv run mlx_lm.server --help` and verify model IDs |
| Out of memory | Ensure only one model runs; close other apps; check Activity Monitor |
| Connection refused | Verify server is running on correct port (11435/11436) |
| Slow responses | First request loads model into memory (~30-60s); subsequent requests are fast |
| Tool calling fails | Ensure model supports tools; check OmniRoute logs for translation errors |
---
## Best Free Providers

View File

@@ -142,22 +142,22 @@ wskazuje zamiast tego lokalną kopię (instalacje offline / air-gapped).
### Opcjonalne zależności i instalacja on-demand
Przycinany stos peerów runtime LLMLingua jest **opcjonalny**. Trzy pakiety są zadeklarowane jako
Przycinany stos peerów runtime LLMLingua jest **opcjonalny**. Dwa pakiety są zadeklarowane jako
`optionalDependencies` w `package.json` i utrzymywane jako **external** przez build produkcyjny
(`scripts/build/prepublish.ts` ich nie bundluje):
| Package | Version (pin) | Notes |
| -------------------- | ------------- | ------------------------------------------------- |
| `@atjsh/llmlingua-2` | `2.0.3` | Pakiet wejściowy; deklaruje pozostałe jako peery |
| `@tensorflow/tfjs` | `4.22.0` | Najcięższa zależność — dominuje footprint ~800 MB |
| `js-tiktoken` | `^1.0.20` | Tokenizer |
| Package | Version (pin) | Notes |
| -------------------- | ------------- | ------------------------------------------- |
| `@atjsh/llmlingua-2` | `2.0.5` | Pakiet wejściowy; deklaruje pozostałe jako peery |
| `js-tiktoken` | `^1.0.20` | Tokenizer |
`@huggingface/transformers` jest pinowany na `3.5.2` jako **opcjonalna** zależność (współdzielona ze
ścieżką lokalnych embeddings i również śledzona do standalone bundle). Utrzymanie jej jako optional
zapobiega awariom postinstall providera CUDA `onnxruntime-node` na hostach CUDA 11, które przerywałyby
całą instalację OmniRoute; gdy opcjonalny stos jest nieobecny, LLMLingua nadal fail-openuje. Tylko trzy
powyższe pakiety to przycinane peery SLM. Standardowe `npm install` (dev) instaluje opcjonalny stos
automatycznie, o ile opcjonalne zależności nie zostaną pominięte.
`@huggingface/transformers` jest pinowany na `^4.2.0` (współdzielony ze ścieżką lokalnych embeddings
i również śledzony do standalone bundle); `@atjsh/llmlingua-2@2.0.5` peeruje na nim przez
`"^3.5.2 || ^4.0.0"`, więc obsługiwane są zarówno Transformers.js v3, jak i v4. Od 2.0.4
`@atjsh/llmlingua-2` nie wymaga już `@tensorflow/tfjs`, co usunęło największy pojedynczy wkład
(TensorFlow.js) ze stosu SLM. Tylko dwa powyższe pakiety to przycinane peery SLM. Standardowe
`npm install` (dev) instaluje opcjonalny stos automatycznie, o ile opcjonalne zależności nie zostaną
pominięte.
**Dlaczego on-demand:** pakiet publikowany w npm, standalone bundle i obraz Docker
dostarczane są **bez** tych zależności, aby pozostać lekkie. Gdy ich brakuje, bramka zależności
@@ -167,11 +167,12 @@ logowanego błędu). Aby aktywować go w przyciętym środowisku, zainstaluj opc
```bash
# pin to the versions declared in package.json optionalDependencies
npm install @atjsh/llmlingua-2@2.0.3 @tensorflow/tfjs@4.22.0 js-tiktoken
npm install @atjsh/llmlingua-2@2.0.5 js-tiktoken
```
Łącznie mniej więcej **~800 MB**: dominują runtimey TensorFlow.js + transformers; model
TinyBERT dodaje ~57 MB pobierane przy pierwszym użyciu (nie przez npm).
Usunięcie `@tensorflow/tfjs` (2.0.4+) eliminuje wcześniej dominujący wkład ~800 MB — pozostały
footprint to runtimey transformers.js + onnxruntime-node oraz model TinyBERT (~57 MB) pobierany
przy pierwszym użyciu (nie przez npm).
Per środowisko:

View File

@@ -326,13 +326,11 @@ Przed wypuszczeniem dowolnego wydania v3.8.x zweryfikuj te dodatkowe pozycje:
- [ ] `npm install -g omniroute@<this-version>` uruchamia postinstall bez fatalnego wyjścia
- [ ] Ścieżka update zachowuje optional deps: `omniroute update --apply` i auto-updater
uruchamiają `npm install -g … --include=optional`, żeby `optionalDependencies` (better-sqlite3,
keytar, tls-client oraz stack SLM llmlingua: `@atjsh/llmlingua-2`,
`@huggingface/transformers@3.5.2`, `@tensorflow/tfjs`, `js-tiktoken`) przeżyły update.
`@huggingface/transformers` zostaje optional, żeby jego postinstall providera CUDA `onnxruntime-node`
nie mógł przerwać instalacji na hostach CUDA 11. Tier ultra `modelPath` SLM potrzebuje też
keytar, tls-client oraz stack SLM llmlingua: `@atjsh/llmlingua-2@2.0.5`,
`js-tiktoken`) przeżyły update. Tier ultra `modelPath` SLM potrzebuje też
modelu tinybert, auto-pobieranego do `${DATA_DIR}/models/llmlingua` przy pierwszym użyciu. Postinstall
(`scripts/build/colocateOptionals.mjs`) następnie ko-lokuje opcjonalne zamknięcie SLM do
`dist/node_modules`, żeby worker rozwiązywał JEDNĄ opcjonalną instancję `@huggingface/transformers` 3.5.2
`dist/node_modules`, żeby worker rozwiązywał JEDNĄ instancję `@huggingface/transformers` ^4.2.0
— standalone trace bundluje tylko transformers, nie dynamicznie importowane
optionals, więc bez tego worker załadowałby llmlingua-2 przeciw transformers z roota
i tier SLM cicho fail-openowałby.

View File

@@ -275,14 +275,12 @@ npm run build:release
- [ ] `npm install -g omniroute@<this-version>` 运行 postinstall 无致命退出
- [ ] 更新路径保留可选依赖:`omniroute update --apply` 以及自动更新器
运行 `npm install -g … --include=optional` 以确保 `optionalDependencies`better-sqlite3、
keytar、tls-client 以及 llmlingua SLM 栈:`@atjsh/llmlingua-2`、
`@huggingface/transformers@3.5.2`、`@tensorflow/tfjs`、`js-tiktoken`)在更新后仍然存在。
`@huggingface/transformers` 保持为可选依赖,这样其 `onnxruntime-node` CUDA provider postinstall
不会在 CUDA 11 主机上中断安装。Ultra 模式的 `modelPath` SLM 层还需要
keytar、tls-client 以及 llmlingua SLM 栈:`@atjsh/llmlingua-2@2.0.5`、
`js-tiktoken`)在更新后仍然存在。Ultra 模式的 `modelPath` SLM 层还需要
tinybert 模型,首次使用时自动下载到 `${DATA_DIR}/models/llmlingua`。postinstall
`scripts/build/colocateOptionals.mjs`)随后将 SLM 可选依赖闭包共置到
`dist/node_modules`,使 Worker 解析单一的 `@huggingface/transformers` 3.5.2
可选实例 — standalone trace 仅打包 transformers不包含动态导入的
`dist/node_modules`,使 Worker 解析单一的 `@huggingface/transformers` ^4.2.0
实例 — standalone trace 仅打包 transformers不包含动态导入的
可选依赖,否则 Worker 会基于根目录的 transformers 加载 llmlingua-2
SLM 层将静默失效。
- [ ] `omniroute status` 在无 `.env` 的情况下正常工作CLI Token 路径,仅 loopback

View File

@@ -322,14 +322,12 @@ npm run build:release
- [ ] `npm install -g omniroute@<此版本>` 執行 postinstall 而不會致命退出
- [ ] 更新路徑保留選擇性依賴:`omniroute update --apply` 和自動更新器
執行 `npm install -g … --include=optional`,因此 `optionalDependencies`better-sqlite3、
keytar、tls-client以及 llmlingua SLM 堆疊:`@atjsh/llmlingua-2`、
`@huggingface/transformers@3.5.2`、`@tensorflow/tfjs`、`js-tiktoken`)在更新後仍會保留。
`@huggingface/transformers` 維持選擇性,因此其 `onnxruntime-node` CUDA 提供者的 postinstall
不會在 CUDA 11 主機上中斷安裝。Ultra `modelPath` SLM 層還需要
keytar、tls-client以及 llmlingua SLM 堆疊:`@atjsh/llmlingua-2@2.0.5`、
`js-tiktoken`)在更新後仍會保留。Ultra `modelPath` SLM 層還需要
tinybert 模型,會在首次使用時自動下載到 `${DATA_DIR}/models/llmlingua`。Postinstall
`scripts/build/colocateOptionals.mjs`)接著將 SLM 選擇性閉包複製到
`dist/node_modules`,使工作者解析到**單一** `@huggingface/transformers` 3.5.2
選擇性實例——獨立追蹤僅捆綁 transformers而非動態匯入的
`dist/node_modules`,使工作者解析到**單一** `@huggingface/transformers` ^4.2.0
實例——獨立追蹤僅捆綁 transformers而非動態匯入的
選擇性套件,因此若無此步驟,工作者會載入 llmlingua-2 並使用根目錄的 transformers
導致 SLM 層靜默地失敗但仍保持運作。
- [ ] `omniroute status` 在無 `.env` 的情況下正常運作(僅限 CLI 權杖路徑,迴環介面)

View File

@@ -351,14 +351,12 @@ Before shipping any v3.8.x release, verify these additional items:
- [ ] `npm install -g omniroute@<this-version>` runs postinstall without fatal exit
- [ ] Update path keeps optional deps: `omniroute update --apply` and the auto-updater
run `npm install -g … --include=optional` so `optionalDependencies` (better-sqlite3,
keytar, tls-client, and the llmlingua SLM stack: `@atjsh/llmlingua-2`,
`@huggingface/transformers@3.5.2`, `@tensorflow/tfjs`, `js-tiktoken`) survive an update.
`@huggingface/transformers` stays optional so its `onnxruntime-node` CUDA provider postinstall
cannot abort installation on CUDA 11 hosts. The ultra `modelPath` SLM tier also needs the
keytar, tls-client, and the llmlingua SLM stack: `@atjsh/llmlingua-2@2.0.5`,
`js-tiktoken`) survive an update. The ultra `modelPath` SLM tier also needs the
tinybert model, auto-downloaded to `${DATA_DIR}/models/llmlingua` on first use. Postinstall
(`scripts/build/colocateOptionals.mjs`) then co-locates the SLM optional closure into
`dist/node_modules` so the worker resolves a SINGLE `@huggingface/transformers` 3.5.2
optional instance — the standalone trace bundles only transformers, not the dynamically-imported
`dist/node_modules` so the worker resolves a SINGLE `@huggingface/transformers` ^4.2.0
instance — the standalone trace bundles only transformers, not the dynamically-imported
optionals, so without this the worker would load llmlingua-2 against the root's transformers
and the SLM tier would silently fail-open.
- [ ] `omniroute status` works with no `.env` (CLI token path, loopback only)

View File

@@ -732,6 +732,7 @@ REQUEST_TIMEOUT_MS (global override)
| `OMNIROUTE_AGENT_GOAL_STREAM_RECOVERY` | `true` | Enable early stream recovery automatically for detected `/goal` agent runs. Set `false`/`0`/`off` to disable the goal-specific opt-in. This can only ADD recovery on top of the operator default — it never overrides an explicit `STREAM_RECOVERY_ENABLED`/DB settings opt-out. |
| `OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS` | _(off)_ | Strip non-standard `codex.*` SSE events (e.g. `codex.rate_limits`) that break the OpenAI SDK's `responses.stream()` with a 502. Set `true`/`1`/`yes` to enable. |
| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive response headers. |
| `OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS` | `30000` (30s) | Maximum response-start wait (ms) for each direct no-proxy attempt. A timeout retries once on a fresh socket; set `0` to disable the bound and retain the previous behavior. |
| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive the full response body. |
| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP connection establishment timeout. |
| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. |
@@ -755,6 +756,7 @@ REQUEST_TIMEOUT_MS (global override)
| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). |
| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_PPLX_SEARCH_HINT` | `0` (off) | Appends "You have built-in web search. Answer questions directly using search results." to the caller's system message (`perplexity-web/protocol.ts`). Off by default — Perplexity searches anyway, and the sentence leaks into replies as meta-commentary for coding clients. Set `1`/`true`/`yes`/`on` to restore. |
| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`grokTlsClient.ts`). |
| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_NOTION_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`notionTlsClient.ts`); the `notion-web` executor raises it per-request to `180000` for long generations. |

View File

@@ -10,7 +10,7 @@ lastUpdated: 2026-08-20
> Regenerate with: `npm run gen:provider-reference`
> **Last generated:** 2026-08-20
Total providers: **343**. See category breakdown below.
Total providers: **346**. See category breakdown below.
## Categories
@@ -120,7 +120,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `zai-web` | `zw` | Z.ai Web | Web cookie | [link](https://chat.z.ai) | Copy the "token" value from chat.z.ai → DevTools → Application → Local Storage. Do not copy cookies; OmniRoute handles the per-request CAPTCHA through its browser transport. | — |
| `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | — |
## API Key Providers (paid / paid-with-free-credits) (230)
## API Key Providers (paid / paid-with-free-credits) (231)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
@@ -319,6 +319,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `sumopod` | `sumopod` | SumoPod | API key | [link](https://ai.sumopod.com) | Use your SumoPod API key (sk-...) in Authorization: Bearer <key>. Fully OpenAI-compatible. API base URL: https://ai.sumopod.com/v1. |
| `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) |
| `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — |
| `tabitoken` | `tabitoken` | TabiToken | API key, aggregator | [link](https://tabitoken.com) | — |
| `tencent` | `tencent` | Tencent Hunyuan | API key | [link](https://hunyuan.tencent.com) | Get API key at console.cloud.tencent.com |
| `thebai` | `thebai` | TheB.AI | API key, aggregator | [link](https://theb.ai) | Bearer API key for the TheB.AI OpenAI-compatible gateway. |
| `tinyfish` | `tf` | TinyFish Fetch | API key | [link](https://docs.tinyfish.ai/fetch-api) | X-API-Key from agent.tinyfish.ai/api-keys |
@@ -355,7 +356,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `zerolimitai` | `zerolimitai` | ZeroLimitAI | API key, aggregator | [link](https://www.zerolimitai.com) | Temporary free trial is advertised, but official pages conflict between 3 and 7 days; a 100-calls/day claim is not treated as permanent. |
| `zylo-api` | `zylo` | Zylo API | API key, aggregator | [link](https://zyloai.net) | Basic plan: 10 RPM, 7,200 requests/day and 200,000 tokens/day; limited to Basic text models. |
## Local Providers (12)
## Local Providers (14)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
@@ -365,6 +366,8 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `llama-cpp` | `llamacpp` | llama.cpp | Local, self-hosted | [link](https://github.com/ggml-org/llama.cpp) | API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port. |
| `llamafile` | `llamafile` | Llamafile | Local, self-hosted | [link](https://github.com/Mozilla-Ocho/llamafile) | API key optional. Configure the local Llamafile OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). |
| `lm-studio` | `lmstudio` | LM Studio | Local, self-hosted | [link](https://lmstudio.ai) | API key optional. Configure the local LM Studio OpenAI-compatible base URL (default: http://localhost:1234/v1). |
| `mlx-gemma` | `mlx-gemma` | MLX Gemma 26B | Local, self-hosted | [link](https://github.com/ml-explore/mlx) | No API key required. Runs mlx-lm server locally on port 11435. Requires `uv` and `mlx-lm` installed. Model: `mlx-community/gemma-4-26B-A4B-it-qat-q4_0-mlx-aligned` (~15.9GB peak memory). |
| `mlx-qwen` | `mlx-qwen` | MLX Qwen 3.8 27B | Local, self-hosted | [link](https://github.com/ml-explore/mlx) | No API key required. Runs mlx-lm server locally on port 11436. Requires `uv` and `mlx-lm` installed. Model: `maglun/Qwen3.8-27B-MLX-Mixed-3.80bpw` (~13.1GB peak memory). |
| `ollama-local` | `ollama` | Ollama | Local, self-hosted | [link](https://ollama.com) | No API key required. Ollama runs locally — configure its OpenAI-compatible base URL (default: http://localhost:11434/v1) and make sure Ollama is running before connecting. |
| `oobabooga` | `ooba` | oobabooga | Local, self-hosted | [link](https://github.com/oobabooga/text-generation-webui) | API key optional. Configure the local oobabooga OpenAI-compatible base URL (default: http://localhost:5000/v1). |
| `sdwebui` | `sdwebui` | SD WebUI | Local | [link](https://github.com/AUTOMATIC1111/stable-diffusion-webui) | No API key required. Configure the local WebUI base URL (default: http://localhost:7860). |

View File

@@ -1,6 +1,6 @@
# OmniRoute
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 343 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
## Overview
@@ -165,7 +165,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ └── manager.ts # MITM proxy manager
│ ├── shared/ # Shared utilities, components, and constants
│ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.)
│ │ ├── constants/ # Provider definitions (343), model lists, pricing, routing strategies, MCP scopes
│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes
│ │ ├── contracts/ # Shared API contracts
│ │ ├── hooks/ # React hooks
│ │ ├── middleware/ # Shared middleware utilities
@@ -277,7 +277,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
## Key Features (v3.8.50)
### Core Proxy
- **343 AI providers** with automatic format translation
- **346 AI providers** with automatic format translation
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
- **4-tier fallback**: Subscription → API Key → Cheap → Free
@@ -475,7 +475,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
## v3.8.x Highlights
- **343-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
- **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay`
- **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown
- **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules)

View File

@@ -18,6 +18,35 @@ export const GLM_ANTHROPIC_DEFAULT_BASE_URLS = Object.freeze({
});
export const GLM_SHARED_MODELS = Object.freeze([
{
// GLM-5.3 (2026-08-14): one upstream id; effort is the reasoning_effort
// param (low|high|max, default max) — the -high/-low entries below are
// OmniRoute aliases resolved by GlmExecutor::parseGlmEffortTier.
// Default context window not yet published by Z.ai; 1M mirrored from
// GLM-5.2 (same base model). https://z.ai/blog/glm-5.3
id: "glm-5.3",
name: "GLM 5.3",
contextLength: 1000000,
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
},
{
id: "glm-5.3-high",
name: "GLM 5.3 High",
contextLength: 1000000,
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
},
{
id: "glm-5.3-low",
name: "GLM 5.3 Low",
contextLength: 1000000,
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
},
{
id: "glm-5.2",
name: "GLM 5.2",

View File

@@ -918,9 +918,9 @@ export function parseImageModel(modelStr) {
}
}
// No provider prefix — try to find the model in every provider
// No provider prefix — try to find the model in every provider, excluding cookie-auth (web) bridges
for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) {
if (config.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr)) {
if (config.authHeader !== "cookie" && (config.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr))) {
return { provider: providerId, model: modelStr };
}
}

View File

@@ -17,6 +17,8 @@ interface MusicProvider {
id: string;
baseUrl: string;
statusUrl?: string;
/** Regional deployment of the same contract, reachable via a base-URL override. */
regionalBaseUrl?: string;
authType: string;
authHeader: string;
format: string;
@@ -79,14 +81,21 @@ export const MUSIC_PROVIDERS: Record<string, MusicProvider> = {
minimax: {
id: "minimax",
baseUrl: "https://api.minimax.io/v1/music_generation",
statusUrl: "https://api.minimax.io/v1/query/music_generation",
// The music operation answers with the finished audio in the POST response —
// there is no task id and no query endpoint, hence no statusUrl. The regional
// deployment serves the same contract and is the only host that accepts the
// `aigc_watermark` request field.
regionalBaseUrl: "https://api.minimaxi.com/v1/music_generation",
authType: "apikey",
authHeader: "bearer",
format: "minimax-music",
models: [
{ id: "music-3.0", name: "Music 3.0" },
{ id: "music-2.6", name: "Music 2.6" },
{ id: "music-3.0-free", name: "Music 3.0 Free" },
{ id: "music-2.6-free", name: "Music 2.6 Free" },
{ id: "music-cover", name: "Music Cover" },
{ id: "music-cover-free", name: "Music Cover Free" },
],
},
comfyui: {

View File

@@ -19,6 +19,19 @@ export interface AlternateFormat {
authHeader?: string;
headers?: Record<string, string>;
urlSuffix?: string;
/**
* Monta a URL final quando o protocolo alternativo embute o modelo no path, e
* nao apenas um sufixo fixo. O caso concreto e o protocolo Gemini, cuja rota e
* `{base}/{model}:generateContent` (ou `:streamGenerateContent?alt=sse`) — algo
* que `chatPath`/`urlSuffix` nao expressam, porque ambos sao constantes.
*
* Mesma assinatura do `urlBuilder` de RegistryEntry (base ja sem "/" final,
* modelo e stream), de proposito: um gateway que fala Gemini como alternativa
* reaproveita `buildGeminiGenerateContentUrl` de shared.ts — o mesmo builder que
* o provedor Gemini nativo usa — em vez de reimplementar a rota.
* Quando ausente, a URL continua sendo `baseUrl + chatPath + urlSuffix`.
*/
urlBuilder?: (base: string, model: string, stream: boolean) => string;
label: string;
}

View File

@@ -3,6 +3,8 @@ import { unorouterProvider } from "./registry/unorouter/index.ts";
import { aimlapiProvider } from "./registry/aimlapi/index.ts";
import { byteplusProvider } from "./registry/byteplus/index.ts";
import { mlxGemmaProvider } from "./registry/mlx/index.ts";
import { mlxQwenProvider } from "./registry/mlx/index.ts";
import { ollama_cloudProvider } from "./registry/ollama-cloud/index.ts";
import { syntheticProvider } from "./registry/synthetic/index.ts";
import { ideogramProvider } from "./registry/ideogram/index.ts";
@@ -261,9 +263,12 @@ import { freeinferenceProvider } from "./registry/freeinference/index.ts";
import { freeAiProvider } from "./registry/free-ai/index.ts";
import { voidAiProvider } from "./registry/void-ai/index.ts";
import { helixmindProvider } from "./registry/helixmind/index.ts";
import { tabitokenProvider } from "./registry/tabitoken/index.ts";
export const REGISTRY: Record<string, RegistryEntry> = {
aimlapi: aimlapiProvider,
"mlx-gemma": mlxGemmaProvider,
"mlx-qwen": mlxQwenProvider,
"ollama-cloud": ollama_cloudProvider,
synthetic: syntheticProvider,
ideogram: ideogramProvider,
@@ -526,4 +531,5 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"free-ai": freeAiProvider,
"void-ai": voidAiProvider,
helixmind: helixmindProvider,
tabitoken: tabitokenProvider,
};

View File

@@ -1,5 +1,5 @@
import type { RegistryEntry } from "../../shared.ts";
import { resolvePublicCred } from "../../shared.ts";
import { buildGeminiGenerateContentUrl, resolvePublicCred } from "../../shared.ts";
export const geminiProvider: RegistryEntry = {
id: "gemini",
@@ -7,10 +7,7 @@ export const geminiProvider: RegistryEntry = {
format: "gemini",
executor: "default",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
urlBuilder: (base, model, stream) => {
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
return `${base}/${model}:${action}`;
},
urlBuilder: buildGeminiGenerateContentUrl,
authType: "apikey",
authHeader: "x-goog-api-key",
defaultContextLength: 1048576,

View File

@@ -1,11 +1,62 @@
import type { RegistryEntry } from "../../shared.ts";
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
import {
buildGeminiGenerateContentUrl,
buildOpenAiCompatibleRegistryEntry,
getAnthropicCompatHeaders,
} from "../../shared.ts";
/**
* HCNSec — NewAPI-based host (https://api.hcnsec.cn), announced by its own `/api/status` as
* 新疆幻城网安科技公益大模型安全网关. Catalogued as an API-key **regional** provider
* (`APIKEY_PROVIDERS_REGIONAL.hcnsec`); this entry only describes how to reach it.
*
* It shipped OpenAI-only. The three alternates below were added after probing the host live:
* every one of them reaches the NewAPI token layer (`{"error":{"type":"new_api_error"}}` on an
* invalid key) rather than a router 404, so each is a route this host actually serves —
* including the Gemini path in both its unary and `:streamGenerateContent?alt=sse` forms.
* The default format, base URL and auth scheme are deliberately untouched.
*
* `models: []` is unchanged and deliberate. Unlike TabiToken, this host gates every discovery
* endpoint behind auth (`/api/status` reports `pricing.requireAuth: true`; `/api/pricing`,
* `/api/models`, `/api/models/display` and `/api/user/models` all answer "Unauthorized, not
* logged in and no access token provided"). Rather than ship a guessed catalog, the model list
* is left to live discovery through `modelsUrl` with the operator's own key — the same
* arrangement `anyapi` and `helixmind` use.
*/
export const hcnsecProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "hcnsec",
alias: "hcnsec",
baseUrl: "https://api.hcnsec.cn/v1/chat/completions",
modelsUrl: "https://api.hcnsec.cn/v1/models",
responsesBaseUrl: "https://api.hcnsec.cn/v1/responses",
models: [],
passthroughModels: true,
alternateFormats: [
{
// `Anthropic-Version` is scoped to this alternate (deepseek's arrangement) because
// it is only meaningful on `/v1/messages`, and because `default.ts` supplies that
// default solely for `anthropic-compatible-*` provider ids — not for a gateway that
// reaches the Claude protocol through an alternate.
format: "claude",
baseUrl: "https://api.hcnsec.cn/v1/messages",
authHeader: "x-api-key",
headers: getAnthropicCompatHeaders(),
label: "Anthropic-compatible",
},
{
format: "openai-responses",
baseUrl: "https://api.hcnsec.cn/v1/responses",
authHeader: "bearer",
label: "OpenAI Responses",
},
{
// The Gemini protocol carries the model in the path, so this alternate needs the
// same builder the native `gemini` provider uses instead of a constant chatPath.
format: "gemini",
baseUrl: "https://api.hcnsec.cn/v1beta/models",
authHeader: "x-goog-api-key",
urlBuilder: buildGeminiGenerateContentUrl,
label: "Gemini-compatible",
},
],
});

View File

@@ -0,0 +1,66 @@
import type { RegistryEntry } from "../../shared.ts";
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
// MLX ports (deterministic, documented)
const MLX_GEMMA_PORT = 11435;
const MLX_QWEN_PORT = 11436;
// ─────────────────────────────────────────────────────────────────────────────
// Memory-aware context windows for MLX models on 24GB unified memory.
// Based on verified peak memory: Gemma 26B ~15.9GB, Qwen 27B ~13.1GB.
// KV cache estimate: 2 * 2 * layers * kv_heads * head_dim * num_ctx bytes.
// Conservative context windows to leave headroom for OS/other processes.
export const MLX_DEFAULT_CONTEXT_LIMIT = 32768;
const CONTEXT_GEMMA_26B = 8192; // 15.9GB weights + ~3.5GB KV @ 8k = ~19.4GB (safe for 24GB)
const CONTEXT_QWEN_27B = 8192; // 13.1GB weights + ~3.5GB KV @ 8k = ~16.6GB (safe for 24GB)
// ─────────────────────────────────────────────────────────────────────────────
// MLX Gemma 26B Provider
// Model: mlx-community/gemma-4-26B-A4B-it-qat-q4_0-mlx-aligned
// Verified speed: ~38.5 tok/s, peak memory: ~15.9 GB
export const mlxGemmaProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "mlx-gemma",
alias: "mlx-gemma",
baseUrl: `http://localhost:${MLX_GEMMA_PORT}/v1`,
modelsUrl: `http://localhost:${MLX_GEMMA_PORT}/v1/models`,
passthroughModels: false,
defaultContextLength: MLX_DEFAULT_CONTEXT_LIMIT,
models: [
{
id: "mlx-community/gemma-4-26B-A4B-it-qat-q4_0-mlx-aligned",
name: "Gemma 4 26B A4B IT-QAT (MLX)",
toolCalling: true,
supportsVision: false,
supportsReasoning: false,
contextLength: CONTEXT_GEMMA_26B,
maxOutputTokens: 8192,
},
],
timeoutMs: 120000, // Longer timeout for model loading
});
// ─────────────────────────────────────────────────────────────────────────────
// MLX Qwen3.8 27B Provider
// Model: maglun/Qwen3.8-27B-MLX-Mixed-3.80bpw
// Verified speed: ~9.1 tok/s, peak memory: ~13.1 GB
export const mlxQwenProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "mlx-qwen",
alias: "mlx-qwen",
baseUrl: `http://localhost:${MLX_QWEN_PORT}/v1`,
modelsUrl: `http://localhost:${MLX_QWEN_PORT}/v1/models`,
passthroughModels: false,
defaultContextLength: MLX_DEFAULT_CONTEXT_LIMIT,
models: [
{
id: "maglun/Qwen3.8-27B-MLX-Mixed-3.80bpw",
name: "Qwen 3.8 27B MLX Mixed 3.80bpw",
toolCalling: true,
supportsVision: false,
supportsReasoning: false,
contextLength: CONTEXT_QWEN_27B,
maxOutputTokens: 8192,
},
],
timeoutMs: 120000, // Longer timeout for model loading
});

View File

@@ -24,8 +24,24 @@ export const ollama_cloudProvider: RegistryEntry = {
supportsReasoning: true,
supportedThinkingEfforts: ["low", "medium", "high"],
},
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true },
// #10788: Ollama Cloud accepts low|medium|high|max|none uniformly across
// its reasoning-capable models (see supportsMaxEffortForProvider's
// isOllamaCloud comment in open-sse/executors/base/reasoningEffort.ts) —
// declare supportedThinkingEfforts so appendSyncedEffortVariants() (which
// runs before static-model capability enrichment) can synthesize the
// catalog's selectable -low/-high/-max variant ids for these models.
{
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
supportsReasoning: true,
supportedThinkingEfforts: ["low", "medium", "high", "max"],
},
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
supportsReasoning: true,
supportedThinkingEfforts: ["low", "medium", "high", "max"],
},
{ id: "kimi-k2.6", name: "Kimi K2.6" },
// Ollama Cloud accepts low|medium|high|max|none and rejects xhigh, so the
// explicit supportsXHighEffort:false makes the sanitizer map xhigh → max.
@@ -34,12 +50,14 @@ export const ollama_cloudProvider: RegistryEntry = {
name: "GLM 5.1",
supportsReasoning: true,
supportsXHighEffort: false,
supportedThinkingEfforts: ["low", "medium", "high", "max"],
},
{
id: "glm-5.2",
name: "GLM 5.2",
supportsReasoning: true,
supportsXHighEffort: false,
supportedThinkingEfforts: ["low", "medium", "high", "max"],
},
// #3110: MiniMax M3 via Ollama
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },

View File

@@ -0,0 +1,59 @@
import type { RegistryEntry } from "../../shared.ts";
import { getAnthropicCompatHeaders } from "../../shared.ts";
/**
* TabiToken — NewAPI-based Claude gateway (https://tabitoken.com).
*
* The catalog below is not hand-written: TabiToken leaves the NewAPI pricing endpoint
* public (`/api/status` reports `pricing.requireAuth: false`), so `GET /api/pricing`
* lists every model together with the protocols it accepts. All four entries report
* `supported_endpoint_types: ["anthropic","openai"]`, which is why only those two
* protocols are declared here — the host also routes `/v1/responses` and the Gemini
* `/v1beta` path, but no model on this gateway is reachable through them.
*
* Claude-first (`/v1/messages` + `x-api-key`) because the whole catalog is Claude and
* that avoids a translation hop for Claude-native clients; `passthroughModels` keeps
* models added upstream usable before this list catches up.
*
* No static fingerprint headers. TabiToken fronts Cloudflare, and the only User-Agent
* it rejects is the literal `curl/*` default — a browser UA is answered with
* "Access denied: abusive or non-compliant use is prohibited", while sending no UA
* (the fetch default) reaches the token layer normally. So, unlike agentrouter, this
* entry needs neither a static nor a dynamic wire image.
*
* `headers` carries only `Anthropic-Version`, and it has to live on the entry rather
* than come from the executor: `default.ts` defaults that header solely for provider
* ids prefixed `anthropic-compatible-` (buildHeaders, the `startsWith` branch), so a
* plain `format: "claude"` entry would POST `/v1/messages` without it. Six sibling
* third-party Claude entries (wafer, zai, xiaomi-mimo, xiaomi-mimo-token-plan,
* bailian-coding-plan, deepseek) set it for exactly this reason. Entry-level headers
* are merged for every format (base.ts::buildHeadersPreamble), so the OpenAI alternate
* below also sends it — a documented no-op on `/chat/completions` (see the same note in
* executors/github.ts).
*/
export const tabitokenProvider: RegistryEntry = {
id: "tabitoken",
alias: "tabitoken",
format: "claude",
executor: "default",
baseUrl: "https://tabitoken.com/v1/messages",
modelsUrl: "https://tabitoken.com/v1/models",
authType: "apikey",
authHeader: "x-api-key",
headers: getAnthropicCompatHeaders(),
alternateFormats: [
{
format: "openai",
baseUrl: "https://tabitoken.com/v1/chat/completions",
authHeader: "bearer",
label: "OpenAI-compatible",
},
],
models: [
{ id: "claude-opus-5", name: "Claude Opus 5" },
{ id: "claude-opus-5-thinking", name: "Claude Opus 5 (Thinking)" },
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
{ id: "claude-opus-4-8-thinking", name: "Claude Opus 4.8 (Thinking)" },
],
passthroughModels: true,
};

View File

@@ -11,13 +11,15 @@ export const zaiProvider: RegistryEntry = {
authType: "apikey",
authHeader: "x-api-key",
headers: getAnthropicCompatHeaders(),
// Real upstream model IDs only. The effort tiers (glm-5.2-high / glm-5.2-max)
// are intentionally NOT listed here: they are OmniRoute aliases resolved by the
// GlmExecutor (parseGlm52Effort → base "glm-5.2" + effort field). This provider
// uses the DefaultExecutor, which sends the model ID verbatim, so the aliases
// would reach z.ai's Anthropic endpoint as unknown IDs. Use the `glm` provider
// for effort tiers. Vision models are likewise omitted (handled elsewhere).
// Real upstream model IDs only. The effort tiers (glm-5.2-high/-max,
// glm-5.3-high/-low) are intentionally NOT listed here: they are OmniRoute
// aliases resolved by the GlmExecutor (parseGlmEffortTier → base model +
// effort selector). This provider uses the DefaultExecutor, which sends the
// model ID verbatim, so the aliases would reach z.ai's Anthropic endpoint as
// unknown IDs. Use the `glm` provider for effort tiers. Vision models are
// likewise omitted (handled elsewhere).
models: [
{ id: "glm-5.3", name: "GLM 5.3" },
{ id: "glm-5.2", name: "GLM 5.2" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "glm-5", name: "GLM 5" },

View File

@@ -758,3 +758,20 @@ export function buildAntigravityUrl(base: string, model: string, stream: boolean
const path = stream ? "/v1internal:streamGenerateContent?alt=sse" : "/v1internal:generateContent";
return `${base}${path}`;
}
/**
* Gemini protocol `generateContent` route: the model goes in the path, not the body.
*
* Shared because the format has two consumers: the native `gemini` provider
* (RegistryEntry.urlBuilder) and gateways that expose Gemini as an alternate
* protocol (AlternateFormat.urlBuilder, see alternateFormats.ts). One copy per
* consumer would leave the streaming `?alt=sse` suffix free to diverge.
*/
export function buildGeminiGenerateContentUrl(
base: string,
model: string,
stream: boolean
): string {
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
return `${base}/${model}:${action}`;
}

View File

@@ -303,6 +303,19 @@ export const SEARCH_CREDENTIAL_FALLBACKS: Record<string, string> = {
export const SEARCH_PROVIDER_ALIASES: Record<string, string> = {
"jina-ai": "jina-search",
jina: "jina-search",
brave: "brave-search",
serper: "serper-search",
perplexity: "perplexity-search",
exa: "exa-search",
tavily: "tavily-search",
"google-pse": "google-pse-search",
linkup: "linkup-search",
ollama: "ollama-search",
searchapi: "searchapi-search",
youcom: "youcom-search",
searxng: "searxng-search",
zai: "zai-search",
duckduckgo: "duckduckgo-free",
};
export function resolveSearchProviderId(providerId: string): string {

View File

@@ -191,6 +191,9 @@ export class DefaultExecutor extends BaseExecutor {
// Operator's manual override (#6147) keeps its own semantics and falls
// through to the provider-specific handling below.
const normalized = alternate.baseUrl.replace(/\/$/, "");
// A model-scoped alternate (the Gemini protocol: `{base}/{model}:generateContent`)
// builds its own URL — chatPath/urlSuffix are constants and cannot carry the model.
if (alternate.urlBuilder) return alternate.urlBuilder(normalized, model, stream);
return `${normalized}${alternate.chatPath || ""}${alternate.urlSuffix || ""}`;
}
}

View File

@@ -52,17 +52,41 @@ function getEffectiveKey(credentials: ProviderCredentials): string {
return credentials.apiKey || credentials.accessToken || "";
}
export type GlmEffortLevel = "low" | "high" | "max";
type GlmEffortTier = {
baseModel: string;
effort: GlmEffortLevel;
/** Transport where the upstream honors the effort selector for this family. */
transport: GlmTransport;
};
/**
* GLM-5.2 effort tiers route exclusively through the Anthropic transport,
* where Zhipu maps Claude Code effort selectors (high/max) to reasoning
* intensity. The base model ID sent upstream is always "glm-5.2".
* GLM-5.2 effort tiers (glm-5.2-high/-max) route exclusively through the
* Anthropic transport, where Zhipu maps Claude Code effort selectors (high/max)
* to reasoning intensity. The base model ID sent upstream is always "glm-5.2".
*
* GLM-5.3 replaced tier endpoints with a documented `reasoning_effort` request
* parameter (low|high|max, default max) on the coding chat/completions endpoint,
* so its tiers stay on the OpenAI transport and inject `reasoning_effort` +
* `thinking.type=enabled` (5.3 no longer accepts thinking disabled).
*
* https://docs.z.ai/devpack/latest-model
* https://z.ai/blog/glm-5.3
*/
function parseGlm52Effort(model: string): { baseModel: string; effort: "high" | "max" } | null {
if (model === "glm-5.2-high") return { baseModel: "glm-5.2", effort: "high" };
if (model === "glm-5.2-max") return { baseModel: "glm-5.2", effort: "max" };
return null;
function parseGlmEffortTier(model: string): GlmEffortTier | null {
switch (model) {
case "glm-5.2-high":
return { baseModel: "glm-5.2", effort: "high", transport: "anthropic" };
case "glm-5.2-max":
return { baseModel: "glm-5.2", effort: "max", transport: "anthropic" };
case "glm-5.3-high":
return { baseModel: "glm-5.3", effort: "high", transport: "openai" };
case "glm-5.3-low":
return { baseModel: "glm-5.3", effort: "low", transport: "openai" };
default:
return null;
}
}
/**
@@ -278,7 +302,7 @@ export class GlmExecutor extends DefaultExecutor {
credentials: ProviderCredentials,
transport: GlmTransport
) {
const effortTier = parseGlm52Effort(model);
const effortTier = parseGlmEffortTier(model);
const effectiveModel = effortTier ? effortTier.baseModel : model;
const transformed = this.transformRequest(effectiveModel, body, stream, credentials);
@@ -313,6 +337,14 @@ export class GlmExecutor extends DefaultExecutor {
}
if (transport === "openai") {
// GLM-5.3 effort tiers: inject the documented `reasoning_effort` param and
// force thinking on — 5.3 rejects thinking.type "disabled", and an effort
// tier without thinking would silently drop the selector upstream.
if (record && effortTier && effortTier.transport === "openai") {
const existingThinking = asRecord(record.thinking);
record.thinking = { ...existingThinking, type: "enabled" };
record.reasoning_effort = effortTier.effort;
}
if (record && stream && hasTools(record) && record.tool_stream === undefined) {
return { ...record, tool_stream: true };
}
@@ -446,7 +478,12 @@ export class GlmExecutor extends DefaultExecutor {
*/
private async finalizeAnthropicTransportResult(
input: ExecuteInput,
result: { response: Response; url: string; headers: Record<string, string>; transformedBody: unknown }
result: {
response: Response;
url: string;
headers: Record<string, string>;
transformedBody: unknown;
}
): Promise<GlmExecuteResult> {
const { response: rawResponse, url, headers, transformedBody } = result;
const clientHeaders = input.clientHeaders ?? {};
@@ -475,13 +512,14 @@ export class GlmExecutor extends DefaultExecutor {
}
async execute(input: ExecuteInput): Promise<GlmExecuteResult> {
const effortTier = parseGlm52Effort(input.model);
const effortTier = parseGlmEffortTier(input.model);
// GLM-5.2 effort tiers route directly through Anthropic transport (no fallback).
// Zhipu only graduates effort on the Anthropic endpoint via the
// effort-2025-11-24 beta header included in GLM_ANTHROPIC_BETA.
// Effort tiers route directly through their family's transport (no fallback):
// GLM-5.2 → Anthropic (Zhipu only graduates effort there, via the
// effort-2025-11-24 beta header in GLM_ANTHROPIC_BETA); GLM-5.3 → OpenAI
// coding endpoint (`reasoning_effort` param). See parseGlmEffortTier.
if (effortTier) {
return this.executeTransport(input, "anthropic");
return this.executeTransport(input, effortTier.transport);
}
const primaryTransport = getGlmTransport(

View File

@@ -370,15 +370,29 @@ export function buildPplxRequestBody(
};
}
const SEARCH_HINT = "You have built-in web search. Answer questions directly using search results.";
/**
* Whether to append {@link SEARCH_HINT} to the caller's system message.
*
* It used to be unconditional. Perplexity's answer engine is search-first anyway, and
* for coding clients the sentence leaks into replies as meta-commentary ("I need to
* search before responding per my instructions"), so it is now opt-in via
* `OMNIROUTE_PPLX_SEARCH_HINT`. Read per call rather than at module load so the flag
* can be flipped without restarting the server (and so tests can toggle it).
*/
function searchHintEnabled(): boolean {
return /^(1|true|yes|on)$/i.test(process.env.OMNIROUTE_PPLX_SEARCH_HINT ?? "");
}
export function buildQuery(parsed: ParsedMessages, followUpUuid: string | null): string {
if (followUpUuid) return parsed.currentMsg;
const obj: Record<string, unknown> = {};
if (parsed.systemMsg.trim()) {
obj.instructions = [
parsed.systemMsg.trim(),
"You have built-in web search. Answer questions directly using search results.",
];
obj.instructions = searchHintEnabled()
? [parsed.systemMsg.trim(), SEARCH_HINT]
: [parsed.systemMsg.trim()];
}
if (parsed.history.length > 0) {
obj.history = parsed.history;

View File

@@ -34,6 +34,38 @@ import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanti
import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts";
import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts";
import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts";
import {
createRoutingEvent,
emitRoutingEvent,
outcomeFromStatus,
} from "../services/routing/index.ts";
/**
* Best-effort finish_reason extraction from a (possibly translated) response
* body for routing-event telemetry. Returns null when the shape is unknown.
*/
function routingFinishReason(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const record = body as Record<string, unknown>;
const choices = record.choices;
if (Array.isArray(choices)) {
const first = choices[0];
if (first && typeof first === "object") {
const fr = (first as Record<string, unknown>).finish_reason;
if (typeof fr === "string") return fr;
}
}
const output = record.output;
if (Array.isArray(output)) {
for (const item of output) {
if (item && typeof item === "object") {
const fr = (item as Record<string, unknown>).finish_reason;
if (typeof fr === "string") return fr;
}
}
}
return null;
}
import {
getHeaderValueCaseInsensitive,
isNoMemoryRequested,
@@ -400,6 +432,7 @@ import { isLocalStreamLifecycleError } from "@/shared/utils/circuitBreaker";
import { shouldIsolateProbeFailures } from "@/shared/utils/probeOrigin";
import { extractFacts } from "@/lib/memory/extraction";
import { handleToolCallExecution } from "@/lib/skills/interception";
import { MEMORY_BUILTIN_TOOL_NAMES } from "@/lib/skills/memoryBuiltins";
import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers";
import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults";
import {
@@ -4920,9 +4953,11 @@ export async function handleChatCore({
const customSkillExecutionEnabled =
Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true;
const builtinToolNames = [webSearchFallbackPlan.toolName, webFetchFallbackPlan.toolName].filter(
(name): name is string => Boolean(name)
);
const builtinToolNames = [
webSearchFallbackPlan.toolName,
webFetchFallbackPlan.toolName,
...(memoryOwnerId && memorySettings?.enabled ? MEMORY_BUILTIN_TOOL_NAMES : []),
].filter((name): name is string => Boolean(name));
if (customSkillExecutionEnabled || builtinToolNames.length > 0) {
const skillSessionId = pipelineSessionId;
@@ -5054,6 +5089,27 @@ export async function handleChatCore({
});
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "malformed_translated_response");
trackPendingRequest(model, provider, pendingConnId, false);
// Routing event (feedback foundation) — record the malformed outcome so
// the quality tracker de-prioritizes this model over time.
void emitRoutingEvent(
createRoutingEvent({
requestId: traceId || pendingRequestId || "unknown",
provider: provider || "unknown",
model: model || "unknown",
strategy: isCombo ? (comboStrategy ?? "combo") : "direct",
latencyMs: Date.now() - startTime,
ttftMs: null,
inputTokens: null,
outputTokens: null,
cost: null,
retries: 0,
fallbackUsed: false, // combo-level fallback tracked by decisionTrace
outcome: "malformed",
status: HTTP_STATUS.BAD_GATEWAY,
finishReason: routingFinishReason(translatedResponse),
connectionId: credentials?.connectionId ?? null,
})
);
return createErrorResult(
HTTP_STATUS.BAD_GATEWAY,
malformedMessage,
@@ -5154,6 +5210,43 @@ export async function handleChatCore({
response: { status: 200, data: translatedResponse },
});
// Routing event (feedback foundation) — fire-and-forget, cheap.
void emitRoutingEvent(
createRoutingEvent({
requestId: traceId || pendingRequestId || "unknown",
provider: provider || "unknown",
model: model || "unknown",
strategy: isCombo ? (comboStrategy ?? "combo") : "direct",
latencyMs: Date.now() - startTime,
ttftMs: null,
inputTokens:
usage && typeof usage === "object"
? (() => {
const promptTokens = (usage as Record<string, unknown>).prompt_tokens;
return typeof promptTokens === "number" && Number.isFinite(promptTokens)
? promptTokens
: null;
})()
: null,
outputTokens:
usage && typeof usage === "object"
? (() => {
const completionTokens = (usage as Record<string, unknown>).completion_tokens;
return typeof completionTokens === "number" && Number.isFinite(completionTokens)
? completionTokens
: null;
})()
: null,
cost: Number.isFinite(estimatedCost) ? estimatedCost : null,
retries: 0,
fallbackUsed: false, // combo-level fallback tracked by decisionTrace
outcome: "success",
status: 200,
finishReason: routingFinishReason(translatedResponse),
connectionId: credentials?.connectionId ?? null,
})
);
return {
success: true,
response: buildNonStreamingJsonResponse(translatedResponse, responseHeaders),
@@ -5274,6 +5367,8 @@ export async function handleChatCore({
error: streamError,
errorCode: streamErrorCode,
ttft,
itlMs: streamItlMs,
interrupted: streamInterrupted,
}) => {
const normalizedStreamStatus = streamStatus || 200;
if (streamCompletionRecorded) return;
@@ -5377,6 +5472,53 @@ export async function handleChatCore({
endpoint: endpointPath,
});
// Routing event (feedback foundation) — fire-and-forget, cheap, never blocks
// the stream. Feeds the quality tracker + optional OTel exporter.
void emitRoutingEvent(
createRoutingEvent({
requestId: traceId || pendingRequestId || "unknown",
provider: provider || "unknown",
model: model || "unknown",
strategy: isCombo ? (comboStrategy ?? "combo") : "direct",
latencyMs: Date.now() - startTime,
ttftMs: typeof ttft === "number" && Number.isFinite(ttft) && ttft >= 0 ? ttft : null,
itlMs:
typeof streamItlMs === "number" && Number.isFinite(streamItlMs) && streamItlMs >= 0
? streamItlMs
: null,
inputTokens:
streamUsage && typeof streamUsage === "object"
? (() => {
const promptTokens = (streamUsage as Record<string, unknown>).prompt_tokens;
return typeof promptTokens === "number" && Number.isFinite(promptTokens)
? promptTokens
: null;
})()
: null,
outputTokens:
streamUsage && typeof streamUsage === "object"
? (() => {
const completionTokens = (streamUsage as Record<string, unknown>).completion_tokens;
return typeof completionTokens === "number" && Number.isFinite(completionTokens)
? completionTokens
: null;
})()
: null,
cost: null,
retries: 0,
fallbackUsed: false, // combo-level fallback tracked by decisionTrace
outcome:
normalizedStreamStatus === 200
? "success"
: streamErrorCode === "stream_interrupted" || streamErrorCode === "aborted"
? "stream_interrupted"
: outcomeFromStatus(normalizedStreamStatus),
status: normalizedStreamStatus,
finishReason: routingFinishReason(streamResponseBody),
connectionId: streamConnectionId ?? credentials?.connectionId ?? null,
})
);
persistAttemptLogs({
status: normalizedStreamStatus,
error: streamError || undefined,

View File

@@ -2,6 +2,7 @@ import { retrieveMemories } from "@/lib/memory/retrieval";
import { getMemorySettings, DEFAULT_MEMORY_SETTINGS, toMemoryRetrievalConfig } from "@/lib/memory/settings";
import { injectMemory, shouldInjectMemory } from "@/lib/memory/injection";
import { injectSkills } from "@/lib/skills/injection";
import { buildMemoryToolsForProvider } from "@/lib/skills/memoryBuiltins";
import { skillRegistry } from "@/lib/skills/registry";
import { FORMATS } from "../../translator/formats.ts";
import { detectCachingContext } from "../../services/compression/cachingAware.ts";
@@ -138,6 +139,43 @@ export async function injectMemoryAndSkills({
}
}
if (memoryOwnerId && memorySettings?.enabled && body.stream !== true) {
// Server-side builtin memory tools (memory_save/update/search/delete) are
// executed by the gateway's tool-call interception, which runs only on the
// non-stream path. Stream clients (opencode etc.) execute tools client-side,
// so for them these tools would be announced but never executed; they should
// use the MCP memory tools (omniroute_memory_*) instead.
const existingTools = Array.isArray(body.tools) ? body.tools : [];
const existingToolNames = new Set(
existingTools.flatMap((tool) => {
const record = tool as Record<string, unknown> | null;
if (!record || typeof record !== "object") return [];
const fn = record.function as Record<string, unknown> | undefined;
if (typeof fn?.name === "string") return [fn.name];
if (typeof record.name === "string") return [record.name];
return [];
})
);
const memoryTools = buildMemoryToolsForProvider(
getSkillsProviderForFormat(sourceFormat)
).filter((tool) => {
const record = tool as Record<string, unknown>;
const name =
(record.function as Record<string, unknown> | undefined)?.name ?? record.name;
return typeof name === "string" && !existingToolNames.has(name);
});
if (memoryTools.length > 0) {
body = {
...body,
tools: [...existingTools, ...memoryTools],
};
log?.debug?.(
"MEMORY",
`Injected ${memoryTools.length} memory tool(s) for key=${memoryOwnerId}`
);
}
}
if (memoryOwnerId && memorySettings?.skillsEnabled) {
// Ensure the registry cache is warm before listing: on a cold/fresh
// process skills that exist only in the DB would be missed (false

View File

@@ -1346,6 +1346,107 @@ export async function handleOpenAIImageEdit({
return result;
}
/**
* Handle OpenRouter's unified Image API reference-image flow.
*
* OpenRouter does not expose `/images/edits`; image-to-image requests use
* `POST /api/v1/images` with `input_references` containing data-URL images.
* Keep this separate from the generic multipart `/images/edits` forwarder,
* whose contract is used by custom OpenAI-compatible nodes (#10197).
*/
export async function handleOpenRouterImageEdit({
model,
provider,
baseUrl,
credentials,
prompt,
imageBytes,
imageMime,
size,
n = 1,
log,
}: {
model: string;
provider: string;
baseUrl: string;
credentials:
| {
apiKey?: string;
accessToken?: string;
}
| null
| undefined;
prompt: string;
imageBytes: Buffer;
imageMime?: string | null;
size?: string | null;
n?: number;
log?: { info: (tag: string, message: string) => void } | null;
}) {
const startTime = Date.now();
let url = baseUrl.trim();
while (url.endsWith("/")) url = url.slice(0, -1);
if (url.endsWith("/images/generations")) {
url = url.slice(0, -"/images/generations".length) + "/images";
} else if (!url.endsWith("/images")) {
url += "/images";
}
const mime = imageMime || "image/png";
const upstreamBody: Record<string, unknown> = {
model,
prompt,
input_references: [
{
type: "image_url",
image_url: {
url: `data:${mime};base64,${imageBytes.toString("base64")}`,
},
},
],
n: n || 1,
};
if (size) upstreamBody.size = size;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
const token = credentials?.apiKey || credentials?.accessToken;
if (token) headers.Authorization = `Bearer ${token}`;
log?.info(
"IMAGE",
`${provider}/${model} (reference edit) | prompt: "${prompt.slice(0, 60)}..." -> ${url}`
);
const result = await fetchImageEndpoint(
url,
headers,
JSON.stringify(upstreamBody),
provider,
log
);
saveCallLog({
method: "POST",
path: "/v1/images/edits",
status: result.status || (result.success ? 200 : 502),
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
tokens: { prompt_tokens: 0, completion_tokens: 0 },
error: result.success
? null
: typeof result.error === "string"
? result.error.slice(0, 500)
: null,
requestBody: { model, prompt: prompt.slice(0, 200), size: size || "default", n: n || 1 },
responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null,
}).catch(() => {});
return result;
}
export async function handleImageEdit({
provider,
model,

View File

@@ -0,0 +1,358 @@
/**
* MiniMax music generation handler (format: "minimax-music").
*
* The provider entry has been in musicRegistry since the media registries were
* introduced, but handleMusicGeneration never grew a branch for its format — so
* every registered `minimax/*` music model fell through the dispatch chain to
* `Unsupported music format: minimax-music` (400) and the models were
* advertised by /v1/models while being impossible to call.
*
* The upstream contract is a single synchronous POST — unlike the vendor's
* task-based media endpoints there is no task id and no query endpoint, so a
* request is either finished (`data.status` 2, audio in `data.audio`) or still
* generating (`data.status` 1), which can only be reported back, never awaited.
* Failures are carried in the `base_resp` envelope (`status_code` 0 = success)
* even on HTTP 200.
*
* `output_format` selects how the audio comes back: `url` (a short-lived link,
* valid for 24h — callers must download it before it expires) or `hex` (the raw
* container inline, normalized here to base64 so the response matches the
* OpenAI-shaped payload the other music branches return).
*/
import { saveCallLog } from "@/lib/usageDb";
import { sanitizeErrorMessage } from "../../utils/error.ts";
type MinimaxMusicBody = Record<string, unknown>;
interface MinimaxMusicProviderConfig {
baseUrl: string;
/** Regional deployment of the same contract — see resolveEndpoint below. */
regionalBaseUrl?: string;
}
interface MinimaxMusicCredentials {
apiKey?: unknown;
accessToken?: unknown;
providerSpecificData?: { baseUrl?: unknown } | null;
}
interface MinimaxMusicLog {
info?: (scope: string, message: string) => void;
error?: (scope: string, message: string) => void;
}
interface MinimaxMusicArgs {
model: string;
provider: string;
providerConfig: MinimaxMusicProviderConfig;
body: MinimaxMusicBody;
credentials?: MinimaxMusicCredentials | null;
log?: MinimaxMusicLog | null;
}
/** Containers accepted by `audio_setting.format`. */
const AUDIO_FORMATS = new Set(["mp3", "wav", "pcm"]);
/** Accepted `output_format` values. */
const OUTPUT_FORMATS = new Set(["url", "hex"]);
/** Container assumed when the request does not pin `audio_setting.format`. */
const DEFAULT_AUDIO_FORMAT = "mp3";
/** `data.status`: 1 = still generating, 2 = finished. */
const STATUS_IN_PROGRESS = 1;
/** String request fields forwarded verbatim when the caller provides them. */
const STRING_REQUEST_FIELDS = [
"prompt",
"lyrics",
"audio_url",
"audio_base64",
"cover_feature_id",
] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function numberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function booleanValue(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
/** Fire-and-forget usage log for a MiniMax music-generation call. */
function logMinimaxMusicCall(params: {
status: number;
model: string;
provider: string;
duration: number;
error?: string;
requestBody?: unknown;
responseBody?: unknown;
}): void {
saveCallLog({
method: "POST",
path: "/v1/music/generations",
...params,
}).catch(() => {});
}
/**
* Endpoint for this call: the per-connection `providerSpecificData.baseUrl`
* override (the same storage every configurable-base-URL provider uses) wins
* over the registry default. That override is how a connection targets the
* regional deployment declared as `regionalBaseUrl`.
*/
function resolveEndpoint(
providerConfig: MinimaxMusicProviderConfig,
credentials?: MinimaxMusicCredentials | null
): string {
const psd = credentials?.providerSpecificData;
const override = isRecord(psd) ? stringValue(psd.baseUrl) : undefined;
return override || providerConfig.baseUrl;
}
/** True when `endpoint` is the regional deployment declared by the registry. */
function isRegionalEndpoint(endpoint: string, regionalBaseUrl?: string): boolean {
if (!regionalBaseUrl) return false;
try {
return new URL(endpoint).host === new URL(regionalBaseUrl).host;
} catch {
return false;
}
}
/** Forwards only the recognized `audio_setting` members, dropping unknown containers. */
function buildAudioSetting(body: MinimaxMusicBody): Record<string, unknown> | undefined {
const provided: Record<string, unknown> = isRecord(body.audio_setting) ? body.audio_setting : {};
const setting: Record<string, unknown> = {};
const sampleRate = numberValue(provided.sample_rate);
if (sampleRate !== undefined) setting.sample_rate = sampleRate;
const bitrate = numberValue(provided.bitrate);
if (bitrate !== undefined) setting.bitrate = bitrate;
const format = stringValue(provided.format)?.toLowerCase();
if (format && AUDIO_FORMATS.has(format)) setting.format = format;
return Object.keys(setting).length > 0 ? setting : undefined;
}
/** Container reported back to the caller — mirrors what was asked upstream. */
function resolveAudioFormat(body: MinimaxMusicBody): string {
const provided: Record<string, unknown> = isRecord(body.audio_setting) ? body.audio_setting : {};
const format = stringValue(provided.format)?.toLowerCase();
return format && AUDIO_FORMATS.has(format) ? format : DEFAULT_AUDIO_FORMAT;
}
function resolveOutputFormat(body: MinimaxMusicBody): string {
const requested = stringValue(body.output_format)?.toLowerCase();
return requested && OUTPUT_FORMATS.has(requested) ? requested : "url";
}
/**
* Upstream request body. `stream` is pinned false: this route answers with a
* single JSON payload, and streaming responses would also be restricted to the
* hex output format.
*/
function buildUpstreamBody(
model: string,
body: MinimaxMusicBody,
regional: boolean
): Record<string, unknown> {
const request: Record<string, unknown> = {
model,
stream: false,
output_format: resolveOutputFormat(body),
};
for (const field of STRING_REQUEST_FIELDS) {
const value = stringValue(body[field]);
if (value !== undefined) request[field] = value;
}
const audioSetting = buildAudioSetting(body);
if (audioSetting) request.audio_setting = audioSetting;
const lyricsOptimizer = booleanValue(body.lyrics_optimizer);
if (lyricsOptimizer !== undefined) request.lyrics_optimizer = lyricsOptimizer;
// `instrumental` is the spelling the other music branches already accept.
const isInstrumental = booleanValue(body.is_instrumental) ?? booleanValue(body.instrumental);
if (isInstrumental !== undefined) request.is_instrumental = isInstrumental;
// Only the regional endpoint accepts a watermark flag.
if (regional) {
const watermark = booleanValue(body.aigc_watermark);
if (watermark !== undefined) request.aigc_watermark = watermark;
}
return request;
}
async function readPayload(response: Response): Promise<Record<string, unknown>> {
const rawText = await response.text();
if (!rawText) return {};
try {
const parsed: unknown = JSON.parse(rawText);
return isRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
/** Hex payloads are normalized to base64; Buffer would silently drop bad nibbles. */
function hexAudioToBase64(audioHex: string): string {
if (audioHex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(audioHex)) {
throw new Error("MiniMax music generation returned invalid hex audio");
}
return Buffer.from(audioHex, "hex").toString("base64");
}
/** `base_resp.status_code` is non-zero on failures that still answer HTTP 200. */
function readEnvelopeError(payload: Record<string, unknown>): string | undefined {
const baseResp: Record<string, unknown> = isRecord(payload.base_resp) ? payload.base_resp : {};
const statusCode = numberValue(baseResp.status_code);
if (statusCode === undefined || statusCode === 0) return undefined;
return stringValue(baseResp.status_msg) || `upstream status code ${statusCode}`;
}
export async function handleMinimaxMusicGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
}: MinimaxMusicArgs) {
const startTime = Date.now();
const token = stringValue(credentials?.apiKey) || stringValue(credentials?.accessToken);
if (!token) {
return { success: false as const, status: 401, error: "MiniMax API key is required" };
}
const modelId = stringValue(model);
if (!modelId) {
return { success: false as const, status: 400, error: "MiniMax music model is required" };
}
const endpoint = resolveEndpoint(providerConfig, credentials);
const upstreamBody = buildUpstreamBody(
modelId,
body,
isRegionalEndpoint(endpoint, providerConfig.regionalBaseUrl)
);
const audioFormat = resolveAudioFormat(body);
const modelLabel = `${provider}/${modelId}`;
log?.info?.(
"MUSIC",
`${modelLabel} (minimax-music) | prompt: "${String(body.prompt ?? "").slice(0, 60)}..." | ` +
`output_format: ${upstreamBody.output_format} | audio_format: ${audioFormat}`
);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(upstreamBody),
});
const payload = await readPayload(response);
if (!response.ok) {
const errorMessage =
readEnvelopeError(payload) || `MiniMax music generation failed (${response.status})`;
log?.error?.("MUSIC", `${provider} minimax-music error ${response.status}: ${errorMessage}`);
logMinimaxMusicCall({
status: response.status,
model: modelLabel,
provider,
duration: Date.now() - startTime,
error: errorMessage,
requestBody: upstreamBody,
});
return { success: false as const, status: response.status, error: errorMessage };
}
const envelopeError = readEnvelopeError(payload);
if (envelopeError) {
log?.error?.("MUSIC", `${provider} minimax-music rejected the request: ${envelopeError}`);
logMinimaxMusicCall({
status: 502,
model: modelLabel,
provider,
duration: Date.now() - startTime,
error: envelopeError,
requestBody: upstreamBody,
});
return { success: false as const, status: 502, error: envelopeError };
}
const data: Record<string, unknown> = isRecord(payload.data) ? payload.data : {};
// No task id and no query endpoint exist for this operation, so an
// unfinished generation cannot be polled — surface it instead of hanging.
if (numberValue(data.status) === STATUS_IN_PROGRESS) {
const pending = "MiniMax music generation is still in progress; retry the request";
logMinimaxMusicCall({
status: 502,
model: modelLabel,
provider,
duration: Date.now() - startTime,
error: pending,
});
return { success: false as const, status: 502, error: pending };
}
const audio = stringValue(data.audio);
if (!audio) {
const errorMessage = "MiniMax music generation returned no audio";
logMinimaxMusicCall({
status: 502,
model: modelLabel,
provider,
duration: Date.now() - startTime,
error: errorMessage,
});
return { success: false as const, status: 502, error: errorMessage };
}
const track =
upstreamBody.output_format === "hex"
? { b64_json: hexAudioToBase64(audio), format: audioFormat }
: { url: audio, format: audioFormat };
logMinimaxMusicCall({
status: 200,
model: modelLabel,
provider,
duration: Date.now() - startTime,
responseBody: { audio_count: 1 },
});
return {
success: true as const,
data: { created: Math.floor(Date.now() / 1000), data: [track] },
};
} catch (err: unknown) {
const errorMessage = sanitizeErrorMessage(err) || "Music provider error";
log?.error?.("MUSIC", `${provider} minimax-music error: ${errorMessage}`);
logMinimaxMusicCall({
status: 502,
model: modelLabel,
provider,
duration: Date.now() - startTime,
error: errorMessage,
});
return { success: false as const, status: 502, error: errorMessage };
}
}

View File

@@ -33,6 +33,7 @@ import {
} from "../utils/kieTask.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { handleFalMusicGeneration } from "./mediaGeneration/fal.ts";
import { handleMinimaxMusicGeneration } from "./mediaGeneration/minimaxMusic.ts";
function normalizeKieSunoModel(model: string): string {
const map: Record<string, string> = {
@@ -153,6 +154,17 @@ export async function handleMusicGeneration({ body, credentials, log }) {
return handleUdioMusicGeneration({ model, provider, providerConfig, body, credentials, log });
}
if (providerConfig.format === "minimax-music") {
return handleMinimaxMusicGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
});
}
return {
success: false,
status: 400,

View File

@@ -7,9 +7,23 @@ import {
toMemoryRetrievalConfig,
DEFAULT_MEMORY_SETTINGS,
} from "@/lib/memory/settings";
import { resolveMcpCallerApiKeyId } from "../mcpCallerIdentity.ts";
/**
* Resolve the memory owner id for an MCP tool call:
* explicit arg wins, otherwise fall back to the authenticated caller's
* principal id (HTTP auth headers on SSE/Streamable HTTP transports,
* OMNIROUTE_API_KEY env var on stdio). Keeps MCP-stored memories under
* the same owner id that chat-context memory uses, so retrieval in the
* chat pipeline finds entries written via MCP.
*/
async function resolveMemoryOwnerId(explicit?: string): Promise<string> {
if (explicit && explicit.trim() !== "") return explicit.trim();
return (await resolveMcpCallerApiKeyId().catch(() => undefined)) || "mcp";
}
export const MemorySearchSchema = z.object({
apiKeyId: z.string(),
apiKeyId: z.string().optional(),
query: z.string().optional(),
type: z.enum(["factual", "episodic", "procedural", "semantic"]).optional(),
maxTokens: z.number().int().positive().max(8000).optional(),
@@ -17,7 +31,7 @@ export const MemorySearchSchema = z.object({
});
export const MemoryAddSchema = z.object({
apiKeyId: z.string(),
apiKeyId: z.string().optional(),
sessionId: z.string().optional(),
type: z.enum(["factual", "episodic", "procedural", "semantic"]),
key: z.string().min(1),
@@ -26,7 +40,7 @@ export const MemoryAddSchema = z.object({
});
export const MemoryClearSchema = z.object({
apiKeyId: z.string(),
apiKeyId: z.string().optional(),
type: z.enum(["factual", "episodic", "procedural", "semantic"]).optional(),
olderThan: z.string().optional(),
});
@@ -38,6 +52,7 @@ export const memoryTools = {
scopes: ["read:memory"],
inputSchema: MemorySearchSchema,
handler: async (args: z.infer<typeof MemorySearchSchema>) => {
const apiKeyId = await resolveMemoryOwnerId(args.apiKeyId);
// Plan 21 D16/Bug#7 fix: even on the error path the fallback must
// respect DEFAULT_MEMORY_SETTINGS.strategy instead of hardcoding "exact".
const memorySettings =
@@ -54,7 +69,7 @@ export const memoryTools = {
(memorySettings.enabled ? memorySettings.maxTokens : DEFAULT_MEMORY_SETTINGS.maxTokens),
};
const memories = await retrieveMemories(args.apiKeyId, config);
const memories = await retrieveMemories(apiKeyId, config);
const filtered = args.type ? memories.filter((m) => m.type === args.type) : memories;
@@ -77,8 +92,9 @@ export const memoryTools = {
scopes: ["write:memory"],
inputSchema: MemoryAddSchema,
handler: async (args: z.infer<typeof MemoryAddSchema>) => {
const apiKeyId = await resolveMemoryOwnerId(args.apiKeyId);
const memory = await createMemory({
apiKeyId: args.apiKeyId,
apiKeyId,
sessionId: args.sessionId || "",
type: args.type as MemoryType,
key: args.key,
@@ -103,8 +119,9 @@ export const memoryTools = {
scopes: ["write:memory"],
inputSchema: MemoryClearSchema,
handler: async (args: z.infer<typeof MemoryClearSchema>) => {
const apiKeyId = await resolveMemoryOwnerId(args.apiKeyId);
const result = await listMemories({
apiKeyId: args.apiKeyId,
apiKeyId,
type: args.type as MemoryType | undefined,
});
const existingMemories = Array.isArray(result)

View File

@@ -23,6 +23,12 @@ export interface ScoringFactors {
sessionAvailability?: number;
resetWindowAffinity: number;
connectionDensity: number;
/**
* Feedback-driven quality signal [0,1] from the routing-event quality tracker
* (open-sse/services/routing/quality.ts). Optional so cold candidates with no
* observed events default to neutral (1.0) and are never penalized.
*/
quality?: number;
}
export interface ScoringWeights {
@@ -40,11 +46,13 @@ export interface ScoringWeights {
sessionAvailability?: number;
resetWindowAffinity: number;
connectionDensity: number;
/** Weight for the feedback-driven quality factor (#feedback-foundation). */
quality?: number;
}
export const DEFAULT_WEIGHTS: ScoringWeights = {
quota: 0.1429,
health: 0.1905,
health: 0.1605,
costInv: 0.1429,
latencyInv: 0.1143,
taskFit: 0.0762,
@@ -57,6 +65,10 @@ export const DEFAULT_WEIGHTS: ScoringWeights = {
sessionAvailability: 0.0476,
resetWindowAffinity: 0,
connectionDensity: 0.0476,
// Shifted from `health` (0.1905 → 0.1605): availability stays dominant, and
// the new quality signal (observed output quality over time) gets a real,
// if smaller, vote. Sum remains exactly 1.0.
quality: 0.03,
};
/** Normalize independently configured UI weights into a scoring distribution. */
@@ -107,6 +119,12 @@ export interface ProviderCandidate {
sessionAvailability?: number;
/** Score [0..1] for quota reset-window preference; sooner selected reset windows score higher. */
resetWindowAffinity?: number;
/**
* Feedback-driven quality score [0..1] for this provider/model from the
* routing-event quality tracker (open-sse/services/routing). Omitted/undefined
* candidates default to a neutral 1.0 in calculateFactors.
*/
quality?: number;
connectionPoolSize?: number;
connectionId?: string;
}
@@ -141,7 +159,10 @@ export function calculateScore(factors: ScoringFactors, weights: ScoringWeights)
(weights.cacheAffinity ?? 0) * (factors.cacheAffinity ?? 0) +
(weights.sessionAvailability ?? 0) * (factors.sessionAvailability ?? 1) +
(weights.resetWindowAffinity ?? 0) * factors.resetWindowAffinity +
(weights.connectionDensity ?? 0) * factors.connectionDensity
(weights.connectionDensity ?? 0) * factors.connectionDensity +
// Missing quality factor → neutral 0.5: a cold candidate is neither boosted
// (which would let optimistic initialization dominate) nor penalized.
(weights.quality ?? 0) * (factors.quality ?? 0.5)
);
}
@@ -268,6 +289,9 @@ export function calculateFactors(
sessionAvailability: clamp01(candidate.sessionAvailability ?? 1),
resetWindowAffinity: clamp01(candidate.resetWindowAffinity ?? 0.5),
connectionDensity: clamp01(((candidate.connectionPoolSize ?? 1) - 1) / 10),
// Feedback quality signal; neutral 0.5 when the tracker has no data yet
// (cold providers are neither boosted nor unfairly penalized).
quality: clamp01(candidate.quality ?? 0.5),
};
}

View File

@@ -36,6 +36,7 @@ import {
import { buildNoUpstreamResponseDiagnostics, buildRecoveryHint } from "./combo/pinRecovery.ts";
import { buildTargetTimeoutRunner } from "./combo/targetTimeoutRunner.ts";
import { recordComboRequest, recordComboShadowRequest, getComboMetrics } from "./comboMetrics.ts";
import { qualityScoreFor } from "./routing/index.ts";
import {
expandComboSystemPromptIfPresent,
resolveTargetFingerprint,
@@ -63,6 +64,7 @@ import { getHiddenModelsByProvider } from "@/models";
import { resolveModelLockoutSettings } from "../../src/lib/resilience/modelLockoutSettings";
import { fetchCodexQuota } from "./codexQuotaFetcher.ts";
import { evaluateQuotaCutoff, getQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts";
import { resolveProviderId } from "../../src/shared/constants/providers.ts";
import * as semaphore from "./rateLimitSemaphore.ts";
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker";
import { parseModel } from "./model.ts";
@@ -490,7 +492,10 @@ export async function buildAutoCandidates(
let quotaRemaining = 100;
let quotaCutoffBlocked = false;
let quotaCutoffReason: string | undefined;
const fetcher = getQuotaFetcher(provider);
// #10877: `provider` here may be a legacy/user-facing alias spelling
// (target.provider/parseModel output); canonicalize before the fetcher
// registry lookup so aliased combo members still hit quota-aware scoring.
const fetcher = getQuotaFetcher(resolveProviderId(provider));
const connection = target.connectionId ? connectionById.get(target.connectionId) : undefined;
const authType = typeof connection?.authType === "string" ? connection.authType : null;
const sessionAvailability =
@@ -578,6 +583,9 @@ export async function buildAutoCandidates(
connectionPoolSize: connectionPoolCounts.get(provider) ?? 1,
connectionId: target.connectionId ?? undefined,
authType,
// Feedback-driven quality signal (routing quality tracker). Neutral 1.0
// before enough samples accumulate — a cold model is never penalized.
quality: qualityScoreFor(provider, model),
};
})
);
@@ -2271,7 +2279,10 @@ async function handleComboChatInner({
);
}
}
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
log.warn("COMBO", `Model ${modelStr} failed, trying next`, {
status: result.status,
errorBody: redactConnectionLabel(errorText),
});
// #5976: per-model-quota providers (Gemini, GitHub, etc.) multiplex models
// behind one connection. A model-level 500 or 429 (RPM) must NOT cool down
@@ -3456,7 +3467,10 @@ async function handleRoundRobinCombo({
kind: classifyComboOutcome(result.status, errorText),
});
if (offset > 0) fallbackCount++;
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status });
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, {
status: result.status,
errorBody: redactConnectionLabel(errorText),
});
if (
resilienceSettings.providerCooldown.enabled &&

View File

@@ -14,6 +14,7 @@ import { isRecord } from "./comboData.ts";
import type { SlaRoutingPolicy } from "../autoCombo/routerStrategy.ts";
import { RESET_WINDOW_NAMES } from "./types.ts";
import type { ResolvedComboTarget } from "./types.ts";
import { resolveProviderId } from "../../../src/shared/constants/providers.ts";
const RESET_AWARE_SESSION_WINDOW_MS = 5 * 60 * 60 * 1000;
const RESET_AWARE_WEEKLY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
@@ -138,7 +139,11 @@ export function resolveSlaRoutingPolicy(
export function getResetAwareProvider(target: ResolvedComboTarget): string | null {
const provider = (target.providerId || target.provider || "").toLowerCase();
return provider || null;
// #10877: combo targets can carry a legacy/user-facing alias spelling
// (e.g. "ollamacloud", "cx") while quota fetchers register under the
// canonical provider id (e.g. "ollama-cloud", "codex"). Canonicalize here
// so getQuotaFetcher() lookups downstream (quotaStrategies.ts) find them.
return provider ? resolveProviderId(provider) : null;
}
function normalizeResetAt(value: unknown): string | null {

View File

@@ -181,6 +181,7 @@ function applyDrr(targets: ResolvedComboTarget[], comboName: string): ResolvedCo
const deficits = getDrrDeficits(comboName);
const totalWeight = targets.reduce((sum, t) => sum + normalizeWeight(t.weight), 0);
if (totalWeight <= 0) return targets.slice();
// Add each target's quantum (weight share) to its deficit.
for (const target of targets) {
@@ -206,8 +207,9 @@ function applyDrr(targets: ResolvedComboTarget[], comboName: string): ResolvedCo
return [winner, ...rest];
}
/** Weights default to 1 and are floored at 1 to keep quantum math well-defined. */
/** Weights default to 1. Explicit 0 stays 0 so the operator can disable a target. */
function normalizeWeight(weight: number | undefined): number {
if (weight === 0) return 0;
return Number.isFinite(weight) && (weight as number) > 0 ? (weight as number) : 1;
}

View File

@@ -8,7 +8,7 @@
*
* ## Fail-open paths
* 1. Optional-deps gate: if any of `@atjsh/llmlingua-2`, `@huggingface/transformers`,
* `@tensorflow/tfjs`, `js-tiktoken` does not resolve, return `text` immediately —
* `js-tiktoken` does not resolve, return `text` immediately —
* NO worker spawn. This is the default in CI / most installs (deps are OPTIONAL).
* 2. Per-call timeout: first call for a model gets `FIRST_CALL_TIMEOUT_MS` (one-time
* model load); warm calls get `LLMLINGUA_WORKER_TIMEOUT_MS`. On timeout → original
@@ -16,7 +16,7 @@
* 3. Worker error/exit → resolve all pending with their original text + respawn next.
*
* ## Serialization
* ONNX/tfjs are not reentrant — calls are queued FIFO and only one message is
* ONNX inference is not reentrant — calls are queued FIFO and only one message is
* in-flight at a time (the next is posted after the previous reply or its timeout).
*
* ## Idle eviction
@@ -45,7 +45,7 @@ const FIRST_CALL_TIMEOUT_MS = 60000;
/**
* Gate probe: `@atjsh/llmlingua-2` is the entry package that declares the others
* (`@huggingface/transformers`, `@tensorflow/tfjs`, `js-tiktoken`) as peers. We probe
* (`@huggingface/transformers`, `js-tiktoken`) as peers. We probe
* ONLY it (by manifest existence) because the peers are ESM-only and `require.resolve`
* throws for them even when installed; the worker still fail-opens if a peer is
* genuinely missing at `import()` time.

View File

@@ -656,6 +656,18 @@ export function applyRtkCompression(
};
});
// Mirror the sibling stacked engines (headroom, session-dedup, ccr, relevance,
// ionizer, readLifecycle): skip the expensive createCompressionStats() pass
// (full JSON.stringify + tokenizer over the whole body, twice) when nothing
// actually changed. Untouched messages keep their original reference above,
// so a reference-identity scan is enough to detect the no-op case (#10765).
const anyMessageChanged = compressedMessages.some(
(message, index) => message !== messages[index]
);
if (!anyMessageChanged) {
return { body, compressed: false, stats: null };
}
const compressedBody = { ...adapter.body, messages: compressedMessages };
const stats = createCompressionStats(
adapter.body,

View File

@@ -0,0 +1,220 @@
/**
* Routing Events — first-class representation of routing outcomes.
*
* Every request that reaches a provider emits one `RoutingEvent` describing what
* happened: which provider/model was used, under which strategy, with what
* latency/tokens/cost, and whether the outcome was a success, an error, a
* malformed response, a timeout, a rate-limit, or a blocked request.
*
* This is the "feedback foundation": the event is cheap to produce (no I/O in
* the emitting call) and is fanned out synchronously to registered sinks, each
* of which must be O(1)-ish and must never perform synchronous I/O. Sinks can
* then do whatever they need asynchronously — buffer to an OTLP exporter,
* update in-memory quality statistics, keep a bounded ring buffer for
* explainability, etc.
*
* DESIGN NOTE (adapted from the Future-AGI-inspired mission, kept deliberately
* lean): the original proposal was a Rust `RoutingEvent` struct + a
* `RoutingEventSink` trait. This module is the TypeScript equivalent, sized to
* the existing codebase: we already persist rich per-request detail in
* `call_logs` (async) and keep per-combo counters in `comboMetrics.ts`. This
* module adds the *typed, structured, sink-based* outcome channel those systems
* lacked, without duplicating either of them.
*
* SAFETY CONTRACT: an event carries ONLY routing metadata — provider, model,
* strategy, timing, token/cost numbers, an allowlisted outcome, finish reason,
* HTTP status, connection id. Never prompts, request/response bodies, headers,
* credentials, or account ids.
*/
/**
* Allowlisted routing outcomes. Keeping this an enum-like union prevents freeform
* strings from leaking into telemetry/quality logic and keeps sinks exhaustive.
*/
export const ROUTING_OUTCOMES = [
"success",
"error",
"malformed",
"timeout",
"rate_limited",
"stream_interrupted",
"guardrail_blocked",
"cancelled",
] as const;
export type RoutingOutcome = (typeof ROUTING_OUTCOMES)[number];
export interface RoutingEvent {
/** Correlation/request id — never a prompt or body. */
requestId: string;
provider: string;
model: string;
/** Combo strategy (e.g. "auto") or "direct" when not routed through a combo. */
strategy: string;
latencyMs: number;
/**
* Time-to-first-forwarded-SSE-chunk in ms (NOT token-level TTFT), or null
* for non-streaming requests / when nothing was forwarded.
*/
ttftMs: number | null;
/**
* Mean inter-chunk gap in ms — a chunk-latency proxy for inter-token latency,
* only meaningful for streaming requests. Null otherwise.
*/
itlMs: number | null;
inputTokens: number | null;
outputTokens: number | null;
cost: number | null;
retries: number;
fallbackUsed: boolean;
outcome: RoutingOutcome;
/** Upstream HTTP status; null when the request never reached a provider. */
status: number | null;
/** finish_reason from the provider response (stop / length / tool_calls / ...). */
finishReason: string | null;
connectionId: string | null;
ts: number;
}
/** A sink consumes routing events. Implementations must never do sync I/O. */
export interface RoutingEventSink {
readonly name: string;
record(event: RoutingEvent): void;
}
const sinks = new Set<RoutingEventSink>();
/**
* Register a sink. Returns an unsubscribe function. Registering the same sink
* instance twice is a no-op (Set semantics).
*/
export function registerRoutingEventSink(sink: RoutingEventSink): () => void {
sinks.add(sink);
return () => {
sinks.delete(sink);
};
}
/** Test/ops hook: list currently registered sink names. */
export function listRoutingEventSinks(): string[] {
return Array.from(sinks, (s) => s.name);
}
/** Test/ops hook: remove every registered sink. */
export function clearRoutingEventSinks(): void {
sinks.clear();
}
/**
* Emit a routing event to every registered sink. Synchronous and allocation-
* friendly so callers can invoke it at the end of the request hot path without
* measurable impact; each sink's `record()` must be cheap (enqueue/buffer only).
* A throwing sink is isolated so one misbehaving sink cannot break the router.
*/
export function dispatchRoutingEvent(event: RoutingEvent): void {
for (const sink of sinks) {
try {
sink.record(event);
} catch {
// Sinks are observability/best-effort — never let one break the data plane.
}
}
}
/**
* Bounded in-memory ring-buffer sink. Holds the most recent N events for
* explainability/debugging (see GET /api/v1/explain/routing). Insert is O(1);
* no TTL sweep needed because the buffer is size-bounded by construction.
*/
export class MemoryRoutingEventStore implements RoutingEventSink {
readonly name = "memory";
private buffer: RoutingEvent[] = [];
private cursor = 0;
constructor(private readonly capacity = 500) {}
record(event: RoutingEvent): void {
if (this.buffer.length < this.capacity) {
this.buffer.push(event);
} else {
this.buffer[this.cursor] = event;
}
this.cursor = (this.cursor + 1) % this.capacity;
}
/** Most recent events, newest first, up to `limit`. */
recent(limit = 50): RoutingEvent[] {
if (this.buffer.length < this.capacity) {
return this.buffer.slice(-limit).reverse();
}
// Ring is full — walk backwards from the cursor.
const out: RoutingEvent[] = [];
for (let i = 0; i < Math.min(limit, this.buffer.length); i++) {
const idx = (this.cursor - 1 - i + this.buffer.length) % this.buffer.length;
out.push(this.buffer[idx]);
}
return out;
}
clear(): void {
this.buffer = [];
this.cursor = 0;
}
get size(): number {
return this.buffer.length;
}
}
/** Create a well-formed event with defaults for unset observability fields. */
export function createRoutingEvent(input: {
requestId: string;
provider: string;
model: string;
strategy?: string | null;
latencyMs: number;
ttftMs?: number | null;
itlMs?: number | null;
inputTokens?: number | null;
outputTokens?: number | null;
cost?: number | null;
retries?: number;
fallbackUsed?: boolean;
outcome: RoutingOutcome;
status?: number | null;
finishReason?: string | null;
connectionId?: string | null;
ts?: number;
}): RoutingEvent {
return {
requestId: input.requestId,
provider: input.provider || "unknown",
model: input.model || "unknown",
strategy: input.strategy ?? "direct",
latencyMs: Math.max(0, input.latencyMs || 0),
ttftMs: input.ttftMs ?? null,
itlMs: input.itlMs ?? null,
inputTokens: input.inputTokens ?? null,
outputTokens: input.outputTokens ?? null,
cost: input.cost ?? null,
retries: input.retries ?? 0,
fallbackUsed: input.fallbackUsed ?? false,
outcome: input.outcome,
status: input.status ?? null,
finishReason: input.finishReason ?? null,
connectionId: input.connectionId ?? null,
ts: input.ts ?? Date.now(),
};
}
/**
* Classify an upstream HTTP status into a RoutingOutcome. Status 200/201 → success;
* 429 → rate_limited; 408/504 → timeout; 4xx/5xx → error; anything else → error.
*/
export function outcomeFromStatus(status: number | null | undefined): RoutingOutcome {
if (status == null) return "error";
if (status === 200 || status === 201) return "success";
if (status === 429) return "rate_limited";
if (status === 408 || status === 504) return "timeout";
return "error";
}

View File

@@ -0,0 +1,133 @@
/**
* Routing feedback foundation — default wiring.
*
* Bootstraps the default routing-event sinks:
* 1. `MemoryRoutingEventStore` — bounded ring buffer for explainability.
* 2. `QualityTracker` consumer — feeds the auto-combo `quality` scoring factor.
* 3. Optional OTel/HTTP exporter — enabled only when an OTLP endpoint is set.
*
* The hot path only calls `emitRoutingEvent()`, which fans out synchronously to
* these cheap in-memory sinks. No synchronous I/O, no external dependencies.
*
* This is the adapter seam Future AGI (or any evaluation backend) can plug into
* later without becoming a dependency: an evaluator would be another
* `RoutingEventSink` (or a consumer of the ring buffer / quality snapshot).
*/
import {
clearRoutingEventSinks,
dispatchRoutingEvent,
listRoutingEventSinks,
MemoryRoutingEventStore,
registerRoutingEventSink,
type RoutingEvent,
type RoutingEventSink,
} from "./events.ts";
import {
getProviderQuality,
getQualityScore,
getQualitySnapshot,
recordQualityEvent,
resetQualityTracker,
setSemanticQuality,
type ProviderQuality,
} from "./quality.ts";
import { isRoutingOtelEnabled, OtlpHttpsEventSink } from "./otel.ts";
const memoryStore = new MemoryRoutingEventStore(500);
// The quality tracker is registered as a sink so it updates inline with the
// event (O(1) math) and the OTel exporter only ever enqueues.
const qualitySink: RoutingEventSink = {
name: "quality",
record(event: RoutingEvent): void {
recordQualityEvent(event);
},
};
let otelSink: OtlpHttpsEventSink | null = null;
let initialized = false;
/** Register the default sinks. Idempotent; safe to call multiple times. */
export function initRoutingObservability(env: NodeJS.ProcessEnv = process.env): {
sinks: string[];
otelEnabled: boolean;
} {
if (initialized) {
return { sinks: listRoutingSinkNames(), otelEnabled: isRoutingOtelEnabled(env) };
}
initialized = true;
registerRoutingEventSink(memoryStore);
registerRoutingEventSink(qualitySink);
if (isRoutingOtelEnabled(env)) {
const endpoint = (env.OMNIROUTE_OTEL_ENDPOINT ?? env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "").trim();
otelSink = new OtlpHttpsEventSink({
endpoint,
serviceName: env.OTEL_SERVICE_NAME ?? "omniroute",
maxBatchSize: 64,
flushIntervalMs: 10_000,
});
registerRoutingEventSink(otelSink);
}
return { sinks: listRoutingSinkNames(), otelEnabled: otelSink != null };
}
/** Emit a routing event to all registered sinks (fire-and-forget, cheap). */
export function emitRoutingEvent(event: RoutingEvent): void {
if (!initialized) initRoutingObservability();
dispatchRoutingEvent(event);
}
/** Neutral default quality used when a model has no observed events. */
export function qualityScoreFor(provider: string, model: string): number {
return getQualityScore(provider, model);
}
/** Full per-provider/model quality view (operational + semantic + confidence). */
export function providerQualityFor(provider: string, model: string): ProviderQuality {
return getProviderQuality(provider, model);
}
/**
* Evaluator seam: record a semantic quality score. NEVER call this from the
* request hot path with HTTP-derived signals — semantic quality is reserved for
* actual evaluation (task success, tool-use correctness, groundedness).
*/
export { setSemanticQuality } from "./quality.ts";
export function routingQualitySnapshot(limit = 200): ReturnType<typeof getQualitySnapshot> {
return getQualitySnapshot(limit);
}
export { classifyQuality, type QualityClassification } from "./quality.ts";
export function recentRoutingEvents(limit = 50): RoutingEvent[] {
return memoryStore.recent(limit);
}
export function routingOtelStats(): { buffered: number; dropped: number } | null {
return otelSink ? otelSink.getStats() : null;
}
function listRoutingSinkNames(): string[] {
return listRoutingEventSinks();
}
/** Test/ops hook: full reset of the routing observability layer. */
export function resetRoutingObservability(): void {
clearRoutingEventSinks();
memoryStore.clear();
resetQualityTracker();
if (otelSink) {
otelSink.stop();
otelSink = null;
}
initialized = false;
}
export type { RoutingEvent, RoutingOutcome, RoutingEventSink } from "./events.ts";
export { createRoutingEvent, outcomeFromStatus } from "./events.ts";

View File

@@ -0,0 +1,227 @@
/**
* Optional OpenTelemetry / GenAI observability sink.
*
* A `RoutingEventSink` that forwards routing events to an OTLP/HTTP collector as
* GenAI semantic-convention spans (semconvgenai: `gen_ai.provider.name`,
* `gen_ai.request.model`, `gen_ai.operation.name`, `gen_ai.usage.input_tokens`,
* `gen_ai.usage.output_tokens`, etc.).
*
* Deliberately lightweight:
* - No `@opentelemetry/*` SDK dependency. Uses the collector's OTLP/HTTP JSON
* (traces) endpoint via global `fetch`, which is already available and async.
* - `record()` only enqueues into a bounded buffer (O(1), never I/O). A single
* background flush timer drains the buffer asynchronously. Under overload the
* oldest events are dropped (never backpressure the data plane).
* - Disabled unless `OMNIROUTE_OTEL_ENDPOINT` (or `OTEL_EXPORTER_OTLP_ENDPOINT`)
* is set — normal lightweight deployments run with zero OTel code executing.
* - No secrets/prompts are ever serialized; only RoutingEvent metadata.
*/
export interface OtlpHttpsExporterConfig {
/** Collector base URL, e.g. https://collector:4318 — spans go to /v1/traces. */
endpoint: string;
/** Export batch size / flush interval. */
maxBatchSize?: number;
flushIntervalMs?: number;
serviceName?: string;
}
/** Resolve whether OTLP export is configured. */
export function isRoutingOtelEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const endpoint = (env.OMNIROUTE_OTEL_ENDPOINT ?? env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "").trim();
return endpoint.length > 0;
}
interface OtelSpan {
traceId: string;
spanId: string;
name: string;
kind: number;
startTimeUnixNano: string;
endTimeUnixNano: string;
attributes: Array<{
key: string;
value: { stringValue?: string; intValue?: string; doubleValue?: number };
}>;
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
function randomId(bytes: number): string {
const arr = new Uint8Array(bytes);
// Use crypto.getRandomValues when available (Node ≥ 19 global), else Math.random.
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
crypto.getRandomValues(arr);
} else {
for (let i = 0; i < bytes; i++) arr[i] = Math.floor(Math.random() * 256);
}
return toHex(arr);
}
export class OtlpHttpsEventSink {
readonly name = "otel";
private readonly endpoint: string;
private readonly maxBatchSize: number;
private readonly serviceName: string;
private buffer: RoutingEventLike[] = [];
private dropped = 0;
private consecutiveFailures = 0;
private flushedBatches = 0;
private timer: ReturnType<typeof setInterval> | null = null;
private flushing = false;
constructor(private readonly config: OtlpHttpsExporterConfig) {
this.endpoint = config.endpoint.replace(/\/+$/, "") + "/v1/traces";
this.maxBatchSize = config.maxBatchSize ?? 64;
this.serviceName = config.serviceName ?? "omniroute";
this.start();
}
/** O(1) enqueue; drops oldest when the buffer is full. Never performs I/O. */
record(event: RoutingEventLike): void {
if (this.buffer.length >= this.maxBatchSize * 4) {
this.buffer.shift();
this.dropped += 1;
}
this.buffer.push(event);
}
getStats(): {
buffered: number;
dropped: number;
consecutiveFailures: number;
flushedBatches: number;
} {
return {
buffered: this.buffer.length,
dropped: this.dropped,
consecutiveFailures: this.consecutiveFailures,
flushedBatches: this.flushedBatches,
};
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
void this.flush();
}
private start(): void {
const intervalMs = this.config.flushIntervalMs ?? 10_000;
this.timer = setInterval(() => void this.flush(), intervalMs);
// Do not keep the process alive just for telemetry.
this.timer.unref?.();
}
private async flush(): Promise<void> {
if (this.flushing) return;
if (this.buffer.length === 0) return;
this.flushing = true;
const batch = this.buffer.splice(0, this.maxBatchSize);
try {
const res = await fetch(this.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildOtlpTracesPayload(batch, this.serviceName)),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) throw new Error(`OTLP collector returned ${res.status}`);
this.consecutiveFailures = 0;
this.flushedBatches += 1;
} catch {
// Telemetry delivery is best-effort. Re-buffer for a retry, but stop after
// MAX_CONSECUTIVE_FAILURES so a permanently-unavailable collector cannot
// grow the buffer without bound. The dropped counter reflects the loss.
this.consecutiveFailures += 1;
if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
this.dropped += batch.length;
} else {
this.buffer.unshift(...batch);
}
} finally {
this.flushing = false;
}
}
}
/** Drop a batch (and count it) after this many consecutive collector failures. */
const MAX_CONSECUTIVE_FAILURES = 5;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type RoutingEventLike = any;
/**
* Build an OTLP/HTTP traces JSON payload with one span per routing event,
* mapped to GenAI semantic conventions.
*/
export function buildOtlpTracesPayload(events: RoutingEventLike[], serviceName: string): unknown {
const resourceSpans = [
{
resource: {
attributes: [
{ key: "service.name", value: { stringValue: serviceName } },
{ key: "telemetry.sdk.name", value: { stringValue: "omniroute-routing" } },
],
},
scopeSpans: [
{
scope: { name: "omniroute.routing" },
spans: events.map(toSpan),
},
],
},
];
return { resourceSpans };
}
function attr(
key: string,
value: string | number
): { key: string; value: { stringValue?: string; intValue?: string; doubleValue?: number } } {
if (typeof value === "number") {
return Number.isInteger(value)
? { key, value: { intValue: String(value) } }
: { key, value: { doubleValue: value } };
}
return { key, value: { stringValue: String(value) } };
}
function toSpan(event: RoutingEventLike): OtelSpan {
const traceId = randomId(16);
const spanId = randomId(8);
const startNs = BigInt(event.ts) * 1_000_000n;
const endNs = startNs + BigInt(Math.max(0, event.latencyMs || 0)) * 1_000_000n;
const attributes = [
attr("gen_ai.provider.name", event.provider),
attr("gen_ai.request.model", event.model),
attr("gen_ai.operation.name", "chat"),
attr("gen_ai.system", event.strategy || "direct"),
attr("gen_ai.usage.input_tokens", event.inputTokens ?? 0),
attr("gen_ai.usage.output_tokens", event.outputTokens ?? 0),
attr("gen_ai.completion.finish_reason", event.finishReason ?? "unknown"),
attr("gen_ai.request.temperature", 0),
attr("omniroute.routing.outcome", event.outcome),
attr("omniroute.routing.status", event.status ?? 0),
attr("omniroute.routing.ttft_ms", event.ttftMs ?? -1),
attr("omniroute.routing.itl_ms", event.itlMs ?? -1),
attr("omniroute.routing.retries", event.retries ?? 0),
attr("omniroute.routing.fallback_used", event.fallbackUsed ? 1 : 0),
attr("gen_ai.client.token.usage.input_tokens", event.inputTokens ?? 0),
attr("gen_ai.client.token.usage.output_tokens", event.outputTokens ?? 0),
];
if (event.connectionId) attributes.push(attr("omniroute.connection_id", event.connectionId));
return {
traceId,
spanId,
name: `chat ${event.provider}/${event.model}`,
kind: 3, // CLIENT
startTimeUnixNano: startNs.toString(),
endTimeUnixNano: endNs.toString(),
attributes,
};
}

View File

@@ -0,0 +1,313 @@
/**
* Provider/Model Quality Signal — feedback-driven adaptive routing (v2).
*
* v2 separates two distinct concepts that v1 conflated:
*
* - **Operational quality** — derived from the routing hot path (HTTP status,
* connection failures, 429s, malformed responses, stream interruptions,
* finish_reason anomalies, zero-output successes, latency/TTFT). A request
* returning HTTP 200 is NOT necessarily high quality; operational quality
* only says "the wire behaved."
* - **Semantic quality** — the actual value of the generated output
* (evaluator score, task success, tool-use correctness, factual accuracy).
* This is ONLY ever produced by an external evaluator via
* `setSemanticQuality()`. It is never manufactured from HTTP success. It is
* `null` until an evaluator provides a value.
*
* Confidence / sample awareness (v2):
* - `confidence = clamp01(samples / CONFIDENCE_FULL_SAMPLES)`.
* - The score returned to the scorer is blended toward the neutral midpoint
* (0.5): `score = NEUTRAL + confidence * (operational - NEUTRAL)`.
* - Consequences: a cold provider (0 samples) scores neutral 0.5 — it is not
* unfairly penalized, but it also cannot dominate a provider with thousands
* of solid observations. A provider with 7 lucky successes is pulled toward
* 0.5, so it never dominates purely from optimistic initialization.
*
* This complements the existing resilience stack (circuit breaker, connection
* cooldown, model lockout, health matrix): those handle *availability* (hard
* exclusion); this signal handles *soft adaptive preference*.
*
* Statistics are plain arithmetic (EWMA + small counters), O(1) per event, safe
* under the Node event loop's single thread — no lock-free/atomic trickery.
*/
/** EWMA smoothing factor (alpha). Lower = slower adaptation. */
const OPERATIONAL_ALPHA = 0.2;
/** Latency EWMA alpha — slower so transient spikes don't tank quality instantly. */
const LATENCY_ALPHA = 0.1;
/** Samples at which confidence reaches 1.0 (full confidence). */
const CONFIDENCE_FULL_SAMPLES = 50;
/** Neutral score used for cold/unknown providers (midpoint, neither boosted nor penalized). */
const NEUTRAL_SCORE = 0.5;
interface QualityState {
/** EWMA of the success indicator (1 = good, 0 = bad). */
successEwma: number;
/** EWMA of latency in ms. */
latencyEwma: number;
/** EWMA of TTFT in ms (streaming only). */
ttftEwma: number | null;
/** Total events observed for this (provider, model). */
samples: number;
/** Count of operational-anomaly events (malformed / empty / length / interrupted). */
anomalies: number;
/** Rate-limit (429) count — tracked separately for observability. */
rateLimited: number;
/** Semantic quality [0,1] from an external evaluator, if one has provided it. */
semantic: number | null;
/** Confidence [0,1] of the semantic score as reported by the evaluator. */
semanticConfidence: number | null;
lastTs: number;
}
const states = new Map<string, QualityState>();
function keyOf(provider: string, model: string): string {
return `${provider}/${model}`;
}
function getOrCreate(key: string): QualityState {
let state = states.get(key);
if (!state) {
state = {
successEwma: 1,
latencyEwma: 0,
ttftEwma: null,
samples: 0,
anomalies: 0,
rateLimited: 0,
semantic: null,
semanticConfidence: null,
lastTs: 0,
};
states.set(key, state);
}
return state;
}
function isOperationalAnomaly(event: {
outcome: string;
finishReason: string | null;
outputTokens: number | null | undefined;
}): boolean {
if (event.outcome === "malformed" || event.outcome === "stream_interrupted") return true;
// finish_reason=length → the model ran out of output budget (truncated answer).
if (event.outcome === "success" && event.finishReason === "length") return true;
// A "successful" 200 that produced zero output tokens is an empty/invalid output.
// NOTE: we deliberately do NOT treat a missing finish_reason as an anomaly —
// streaming passthrough frequently has no reconstructed finish_reason, so that
// signal would penalize every legitimately streamed request (pure noise).
if (event.outcome === "success" && event.outputTokens === 0) return true;
return false;
}
function successIndicator(event: { outcome: string; status: number | null }): number {
if (event.outcome === "success") return 1;
// 429 is a transient signal, not a quality failure — treat as neutral-positive.
if (event.outcome === "rate_limited" || event.status === 429) return 0.5;
return 0;
}
/** Record one operational routing event into the quality estimate. O(1). */
export function recordQualityEvent(event: {
provider: string;
model: string;
outcome: string;
status: number | null;
latencyMs: number;
ttftMs?: number | null;
finishReason?: string | null;
outputTokens?: number | null;
ts?: number;
}): void {
const key = keyOf(event.provider || "unknown", event.model || "unknown");
const state = getOrCreate(key);
state.samples += 1;
if (
isOperationalAnomaly({
outcome: event.outcome,
finishReason: event.finishReason ?? null,
outputTokens: event.outputTokens ?? undefined,
})
) {
state.anomalies += 1;
}
if (event.outcome === "rate_limited" || event.status === 429) state.rateLimited += 1;
const indicator = successIndicator({ outcome: event.outcome, status: event.status });
// First sample seeds the EWMA directly (no lag toward a default).
state.successEwma =
state.samples === 1
? indicator
: state.successEwma + OPERATIONAL_ALPHA * (indicator - state.successEwma);
const latency = Number.isFinite(event.latencyMs) && event.latencyMs >= 0 ? event.latencyMs : 0;
state.latencyEwma =
state.samples === 1
? latency
: state.latencyEwma + LATENCY_ALPHA * (latency - state.latencyEwma);
const ttft = event.ttftMs;
if (typeof ttft === "number" && Number.isFinite(ttft) && ttft >= 0) {
state.ttftEwma =
state.ttftEwma == null ? ttft : state.ttftEwma + LATENCY_ALPHA * (ttft - state.ttftEwma);
}
state.lastTs = event.ts ?? Date.now();
}
/**
* Evaluator seam: record a semantic quality score for a (provider, model).
* Semantic quality is ONLY ever produced by an evaluator (deterministic scorer,
* local LLM judge, HTTP/Future-AGI adapter, WASM). It is never manufactured from
* operational/HTP success. `confidence` should reflect the evaluator's certainty
* (e.g. number of eval cases backing the score).
*/
export function setSemanticQuality(
provider: string,
model: string,
score: number,
confidence: number
): void {
const state = getOrCreate(keyOf(provider || "unknown", model || "unknown"));
state.semantic = Math.max(0, Math.min(1, Number.isFinite(score) ? score : 0.5));
state.semanticConfidence = Math.max(0, Math.min(1, Number.isFinite(confidence) ? confidence : 0));
}
export interface ProviderQuality {
provider: string;
model: string;
/** Operational score [0,1] (wire behavior) — confidence-adjusted, neutral 0.5 cold. */
operational: number;
/** Semantic score [0,1] from an evaluator, or null when none has been provided. */
semantic: number | null;
/** Confidence [0,1] of the operational score (sample-count based). */
confidence: number;
/** Confidence [0,1] of the semantic score, when an evaluator reported one. */
semanticConfidence: number | null;
samples: number;
anomalies: number;
rateLimited: number;
successEwma: number;
latencyEwmaMs: number;
ttftEwmaMs: number | null;
/** Milliseconds since the last observed event; null when never observed. */
recencyMs: number | null;
lastTs: number;
}
/** Raw operational score before the confidence blend (pure EWMA + penalties). */
function rawOperationalScore(state: QualityState): number {
let score = state.successEwma;
// Latency degradation: soft penalty capped at 0.2 so slow models are discounted, not zeroed.
const latencyPenalty = Math.min(0.2, state.latencyEwma / 60_000);
score -= latencyPenalty;
// Anomaly penalty: capped so a few bad apples don't nuke a provider entirely.
const anomalyRate = state.anomalies / Math.max(1, state.samples);
score -= Math.min(0.25, anomalyRate * 0.5);
return Math.max(0, Math.min(1, score));
}
function confidenceOf(samples: number): number {
return Math.max(0, Math.min(1, samples / CONFIDENCE_FULL_SAMPLES));
}
/**
* Operational quality for a (provider, model), confidence-adjusted and blended
* toward the neutral midpoint. See module docs for the cold-start guarantee.
*/
export function getProviderQuality(provider: string, model: string): ProviderQuality {
const state = states.get(keyOf(provider, model));
const now = Date.now();
if (!state || state.samples === 0) {
return {
provider,
model,
operational: NEUTRAL_SCORE,
semantic: null,
confidence: 0,
semanticConfidence: null,
samples: 0,
anomalies: 0,
rateLimited: 0,
successEwma: 1,
latencyEwmaMs: 0,
ttftEwmaMs: null,
recencyMs: null,
lastTs: 0,
};
}
const confidence = confidenceOf(state.samples);
const raw = rawOperationalScore(state);
const operational = NEUTRAL_SCORE + confidence * (raw - NEUTRAL_SCORE);
return {
provider,
model,
operational,
semantic: state.semantic,
confidence,
semanticConfidence: state.semanticConfidence,
samples: state.samples,
anomalies: state.anomalies,
rateLimited: state.rateLimited,
successEwma: state.successEwma,
latencyEwmaMs: state.latencyEwma,
ttftEwmaMs: state.ttftEwma,
recencyMs: state.samples > 0 ? Math.max(0, now - state.lastTs) : null,
lastTs: state.lastTs,
};
}
/**
* Backward-compatible scalar used by the auto-combo scorer's `quality` factor.
* Returns the confidence-adjusted operational score (neutral 0.5 when cold).
*/
export function getQualityScore(provider: string, model: string): number {
return getProviderQuality(provider, model).operational;
}
/** Full snapshot of the tracker for explainability / dashboard. */
export function getQualitySnapshot(limit = 200): ProviderQuality[] {
const views: ProviderQuality[] = [];
for (const [key] of states) {
const slash = key.indexOf("/");
const provider = slash >= 0 ? key.slice(0, slash) : key;
const model = slash >= 0 ? key.slice(slash + 1) : key;
views.push(getProviderQuality(provider, model));
}
views.sort((a, b) => b.lastTs - a.lastTs);
return views.slice(0, limit);
}
/**
* Classify a provider/model quality state for explainability / dashboard.
* This reflects the SOFT adaptive signal — it says nothing about hard exclusion
* (circuit open / quota / auth), which is owned by the resilience stack.
*
* - "healthy": high confidence + operational quality well above neutral
* - "degraded": operational quality at or below neutral (soft penalty active)
* - "warming": low confidence (few samples) — treated neutrally
* - "cold": never observed — neutral, cannot dominate
*/
export type QualityClassification = "healthy" | "degraded" | "warming" | "cold";
export function classifyQuality(q: ProviderQuality): QualityClassification {
if (q.samples === 0) return "cold";
if (q.confidence < 0.5) return "warming";
if (q.operational < 0.5) return "degraded";
return "healthy";
}
/** Test/ops hook: reset all quality state. */
export function resetQualityTracker(): void {
states.clear();
}
export const QUALITY_WELL_KNOWN = {
CONFIDENCE_FULL_SAMPLES,
NEUTRAL_SCORE,
} as const;

View File

@@ -15,6 +15,8 @@ import { getModelSpec } from "../../../src/shared/constants/modelSpecs.ts";
import {
buildChangedToolNameMap,
buildHistoricalToolResultContext,
mergeConsecutiveSameRoleContents,
type GeminiContent,
} from "./openai-to-gemini/helpers.ts";
/**
@@ -45,7 +47,7 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
: null;
const result: {
model: string;
contents: Array<Record<string, unknown>>;
contents: GeminiContent[];
generationConfig: Record<string, unknown>;
safetySettings: unknown;
systemInstruction?: { role: string; parts: Array<{ text: string }> };
@@ -314,6 +316,11 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) {
result._toolNameMap = changedToolNameMap;
}
// Gemini strictly rejects requests containing consecutive messages with the same role
// (400 INVALID_ARGUMENT: "Request contains consecutive messages with the same role").
// Normalize adjacent same-role messages by concatenating their parts.
result.contents = mergeConsecutiveSameRoleContents(result.contents);
return result;
}

View File

@@ -39,8 +39,13 @@ import {
escapeHistoricalContextAttribute,
escapeHistoricalContextContent,
buildHistoricalToolResultContext,
type GeminiPart,
type GeminiContent,
mergeConsecutiveSameRoleContents,
} from "./openai-to-gemini/helpers.ts";
export { mergeConsecutiveSameRoleContents, type GeminiContent, type GeminiPart };
// Observed Antigravity wrapper output cap, not an underlying model capability.
// Keep this bridge-local: Antigravity currently caps visible output around 16K.
// See: https://github.com/keisksw/antigravity-output-analysis
@@ -56,9 +61,6 @@ const GEMINI_BUILTIN_TOOL_NAMES = new Set<string>([
"googleSearch",
]);
type GeminiPart = Record<string, unknown>;
type GeminiContent = { role: string; parts: GeminiPart[] };
type GeminiFunctionDeclaration = {
name: string;
description: string;
@@ -158,29 +160,6 @@ type GeminiToolNameOptions = {
supportsSignatureBypass?: boolean;
};
// Gemini-family APIs (incl. Antigravity / Vertex) reject a `contents[]` array that
// has two adjacent entries with the same role:
// 400 INVALID_ARGUMENT "Request contains consecutive messages with the same role".
// Client history that carries consecutive user turns — or a tool-result turn (mapped
// to role:"user") immediately followed by a plain user turn — would otherwise leak
// that invalid alternation through. Merge adjacent same-role entries by concatenating
// their parts, the same normalization the Kiro and Claude request paths already apply
// (9router#2191).
export function mergeConsecutiveSameRoleContents(contents: GeminiContent[]): GeminiContent[] {
const merged: GeminiContent[] = [];
for (const entry of contents) {
const last = merged[merged.length - 1];
if (last && last.role === entry.role) {
last.parts.push(...entry.parts);
} else {
// Shallow-copy the entry and its `parts` array so a later same-role merge
// (`last.parts.push(...)`) never mutates the caller's input objects.
merged.push({ ...entry, parts: [...entry.parts] });
}
}
return merged;
}
// Core: Convert OpenAI request to Gemini format (base for all variants)
function openaiToGeminiBase(
model: string,

View File

@@ -152,3 +152,29 @@ export function buildHistoricalToolResultContext(name: string, response: unknown
"</previous_tool_result_context>",
].join("\n");
}
export type GeminiPart = Record<string, unknown>;
export type GeminiContent = { role: string; parts: GeminiPart[] };
// Gemini-family APIs (incl. Antigravity / Vertex) reject a `contents[]` array that
// has two adjacent entries with the same role:
// 400 INVALID_ARGUMENT "Request contains consecutive messages with the same role".
// Client history that carries consecutive user turns — or a tool-result turn (mapped
// to role:"user") immediately followed by a plain user turn — would otherwise leak
// that invalid alternation through. Merge adjacent same-role entries by concatenating
// their parts, the same normalization the Kiro and Claude request paths already apply
// (9router#2191).
export function mergeConsecutiveSameRoleContents(contents: GeminiContent[]): GeminiContent[] {
const merged: GeminiContent[] = [];
for (const entry of contents) {
const last = merged[merged.length - 1];
if (last && last.role === entry.role) {
last.parts.push(...entry.parts);
} else {
// Shallow-copy the entry and its `parts` array so a later same-role merge
// (`last.parts.push(...)`) never mutates the caller's input objects.
merged.push({ ...entry, parts: [...entry.parts] });
}
}
return merged;
}

View File

@@ -0,0 +1,77 @@
type DirectFetchOptions = RequestInit & { dispatcher?: unknown };
type DirectFetch = (
input: RequestInfo | URL,
options: DirectFetchOptions
) => Promise<Response>;
const DEFAULT_DIRECT_HEADERS_TIMEOUT_MS = 30_000;
const DIRECT_RESPONSE_START_TIMEOUT_CODE = "DIRECT_RESPONSE_START_TIMEOUT";
export function resolveDirectHeadersTimeoutMs(
env: Record<string, string | undefined> = process.env
): number {
const raw = env.OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS;
if (raw == null || raw.trim() === "") return DEFAULT_DIRECT_HEADERS_TIMEOUT_MS;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
}
function createDirectResponseStartTimeout(timeoutMs: number): Error & { code: string } {
const err = new Error(
`Direct response did not start within ${timeoutMs}ms — retrying on a fresh socket`
) as Error & { code: string };
err.name = "TimeoutError";
err.code = DIRECT_RESPONSE_START_TIMEOUT_CODE;
return err;
}
export function isDirectResponseStartTimeout(err: unknown): boolean {
return (
!!err &&
typeof err === "object" &&
"code" in err &&
err.code === DIRECT_RESPONSE_START_TIMEOUT_CODE
);
}
function mergeAbortSignals(
primary: AbortSignal | null | undefined,
secondary: AbortSignal
): AbortSignal {
if (!primary) return secondary;
if (primary.aborted) return primary;
const controller = new AbortController();
const onPrimaryAbort = () => controller.abort(primary.reason);
const onSecondaryAbort = () => controller.abort(secondary.reason);
const cleanup = () => {
primary.removeEventListener("abort", onPrimaryAbort);
secondary.removeEventListener("abort", onSecondaryAbort);
};
primary.addEventListener("abort", onPrimaryAbort, { once: true });
secondary.addEventListener("abort", onSecondaryAbort, { once: true });
controller.signal.addEventListener("abort", cleanup, { once: true });
return controller.signal;
}
export async function directFetchWithBoundedResponseStart(
input: RequestInfo | URL,
options: DirectFetchOptions,
fetchImpl: DirectFetch,
timeoutMs: number
): Promise<Response> {
if (!timeoutMs || timeoutMs <= 0) return fetchImpl(input, options);
const attemptController = new AbortController();
const timer = setTimeout(
() => attemptController.abort(createDirectResponseStartTimeout(timeoutMs)),
timeoutMs
);
timer.unref?.();
try {
return await fetchImpl(input, {
...options,
signal: mergeAbortSignals(options.signal, attemptController.signal),
});
} finally {
clearTimeout(timer);
}
}

View File

@@ -19,6 +19,11 @@ import {
isControlPlaneProxyDirectFallbackEnabled,
isFeatureFlagEnabled,
} from "@/shared/utils/featureFlags";
import {
directFetchWithBoundedResponseStart,
isDirectResponseStartTimeout,
resolveDirectHeadersTimeoutMs,
} from "./directResponseStartTimeout.ts";
// #9100: relay egress (Vercel / Deno / Cloudflare edge functions) used to go
// through bare `originalFetch` — NO connection pooling, NO timeout, NO retry.
@@ -154,7 +159,6 @@ type TlsFingerprintStore = {
provider?: string | null;
sessionScope?: string;
};
/**
* #5217 (Gap-secondary): a mutable sink that records the proxy actually applied
* by `runWithProxyContext` for the in-flight request. Executors that pin their
@@ -802,15 +806,7 @@ async function patchedFetch(
(deps.nativeFetch as FetchWithDispatcher | undefined) ?? originalFetchWithDispatcher;
return _nativeFetch(input, options);
}
// Direct connection (no proxy) — use undici with custom dispatcher for timeout control.
// Falls back to original native fetch if dispatcher initialization fails (#1054).
// Retries once on transient dispatcher errors before falling back (fix: proxyfetch-undici-retry).
//
// Non-replayable body guard: if the body is stream-like (ReadableStream/Blob)
// or the input is a Request that carries a body, the first dispatcher attempt
// owns that body. Retrying or falling back to native fetch would replay a
// consumed/locked body and can mask the original transport error with
// "Response body object should not be disturbed or locked".
// Direct undici path: bound response-start, fresh-socket retry, and body guard.
const hasNonReplayableBody = requestHasNonReplayableBody(input, options);
const maxAttempts = hasNonReplayableBody ? 1 : 2;
const _undiciDirect =
@@ -818,32 +814,44 @@ async function patchedFetch(
const _nativeFallback =
(deps.nativeFetch as FetchWithDispatcher | undefined) ?? originalFetchWithDispatcher;
let lastDispatcherError: unknown = null;
const directHeadersTimeoutMs = resolveDirectHeadersTimeoutMs();
let targetHostForLogs = "";
try {
targetHostForLogs = new URL(targetUrl).host;
} catch {
// ignore — logging is best-effort
}
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await _undiciDirect(input, {
...options,
// #4252: first attempt uses the pooled keep-alive dispatcher; a retry
// (after a transient socket error) uses the no-keep-alive dispatcher so
// it opens a FRESH socket instead of grabbing another stale pooled one
// — the burst pattern was the retry re-hitting a dead pooled socket and
// then falling through to native fetch (which also pools) → 502.
dispatcher: attempt === 0 ? getDefaultDispatcher() : getRetryDispatcher(),
});
return await directFetchWithBoundedResponseStart(
input,
{
...options,
dispatcher: attempt === 0 ? getDefaultDispatcher() : getRetryDispatcher(),
},
_undiciDirect,
directHeadersTimeoutMs
);
} catch (dispatcherError) {
if (isDirectResponseStartTimeout(dispatcherError)) {
if (attempt === 0 && maxAttempts > 1) {
console.warn(
`[ProxyFetch] Direct response-start timeout (${directHeadersTimeoutMs}ms) on pooled dispatcher — retrying on fresh no-keep-alive dispatcher: ${targetHostForLogs}`
);
lastDispatcherError = dispatcherError;
continue;
}
throw dispatcherError;
}
const msg =
dispatcherError instanceof Error ? dispatcherError.message : String(dispatcherError);
// CAUTION: Do NOT fallback to native fetch if the error is a version mismatch (invalid onRequestStart)
// because the native fetch will definitely fail with the undici v8 dispatcher.
if (msg.includes("onRequestStart")) {
console.error(
`[ProxyFetch] Fatal version mismatch: Dispatcher (v8) vs Fetch (v6/native). Hardware upgrade or SOCKS5 config isolation required. Error: ${msg}`
);
throw dispatcherError;
}
// Only retry/fallback for connection/dispatcher errors, not HTTP errors.
// Prefer the .code property when available (more stable across undici
// versions than message-string matching); fall back to substring match
// for errors that lack a structured code.
// Retry/fallback only for connection errors, never HTTP errors.
tagProxyUnreachable(dispatcherError);
const errCode = (dispatcherError as { code?: unknown })?.code;
if (
@@ -854,10 +862,7 @@ async function patchedFetch(
msg.includes("UND_ERR")
) {
if (attempt === 0 && maxAttempts > 1) {
// First failure — retry once after a short backoff before giving up.
// Delay is OMNIROUTE_RETRY_BACKOFF_MS (default 10ms): a fixed backoff
// beats random jitter here because the retry opens a fresh socket, so
// jitter was pure added latency with no herd benefit.
// Retry after a short fixed backoff on a fresh socket.
lastDispatcherError = dispatcherError;
await new Promise((r) => setTimeout(r, RETRY_BACKOFF_MS));
continue;
@@ -873,7 +878,7 @@ async function patchedFetch(
throw tagProxyUnreachable(dispatcherError);
}
// All attempts exhausted — try proxy fallback before native fetch
// Exhausted attempts: try proxy fallback before native fetch.
if (
!tlsDirectFallback &&
source === "direct" &&
@@ -899,20 +904,14 @@ async function patchedFetch(
}
}
}
// Preserve original phrase intact for monitoring: "Undici dispatcher failed, falling back to native fetch"
// #4252: append the flattened err.cause (code/syscall/errno/address) — the bare
// "fetch failed" message hides what actually broke, making bursts undiagnosable.
// Preserve the original monitoring phrase and append the transport cause.
console.warn(
`[ProxyFetch] Undici dispatcher failed, falling back to native fetch (after retry): ${describeFetchCause(dispatcherError)}`
);
try {
return await _nativeFallback(input, options);
} catch (nativeError) {
// #4252: both the undici dispatcher AND native fetch failed. Surface BOTH
// causes (server log) and tag the propagated error so the combo executor sees
// a diagnosable failure IMMEDIATELY instead of a bare "fetch failed" — the
// latter left jobs sitting until the 30s semaphore queue timeout, which then
// tripped the circuit breaker.
// Surface both dispatcher and native causes immediately.
const detail = `dispatcher=[${describeFetchCause(dispatcherError)}] native=[${describeFetchCause(nativeError)}]`;
console.warn(`[ProxyFetch] native fetch fallback ALSO failed: ${detail}`);
if (nativeError instanceof Error) {

View File

@@ -121,6 +121,29 @@ export function pushUniqueResponsesOutputItems(target: unknown[], items: readonl
}
}
/**
* #10156 — strip items matched by `isCommentaryItem` (the same predicate used
* to drop live commentary-phase SSE frames, #6199) from a `response.completed`
* output array before it is forwarded or buffered for backfill. Upstreams may
* echo an already-dropped commentary item back inside a non-empty terminal
* `output` array; without this, the live stream and the terminal snapshot
* silently disagree about what the client actually saw.
*/
export function filterResponsesCommentaryFromItems(
items: readonly unknown[],
isCommentaryItem: (item: unknown) => boolean
): { items: unknown[]; changed: boolean } {
let changed = false;
const filtered = items.filter((item) => {
if (isCommentaryItem(item)) {
changed = true;
return false;
}
return true;
});
return { items: filtered, changed };
}
export function backfillResponsesCompletedOutput(
parsed: unknown,
collectedItems: readonly unknown[]

View File

@@ -36,6 +36,7 @@ import {
import { STREAM_IDLE_TIMEOUT_MS, FETCH_BODY_TIMEOUT_MS, HTTP_STATUS } from "../config/constants.ts";
import {
OMIT_STREAMING_CHUNK_MARKER,
isResponsesCommentaryMessageItem,
sanitizeStreamingChunk,
} from "../handlers/responseSanitizer.ts";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
@@ -59,6 +60,7 @@ import {
} from "../services/sessionManager.ts";
import {
backfillResponsesCompletedOutput,
filterResponsesCommentaryFromItems,
normalizeResponsesCompletedUsage as normalizeUsage,
normalizeResponsesSseIds,
pushUniqueResponsesOutputItems,
@@ -81,6 +83,7 @@ import {
import { restoreClaudeToolName } from "../services/claudeCodeToolRemapper.ts";
import { normalizeFinalOpenAIStreamChunk } from "./openAIStreamChunk.ts";
import { collectClaudeDelta } from "./streamClaudeDelta.ts";
import { createStreamTiming, type StreamTiming } from "./streamTiming.ts";
/**
* Race a response body read against a timeout.
@@ -129,7 +132,15 @@ type StreamCompletePayload = {
clientPayload?: unknown;
error?: string | null;
errorCode?: string | null;
/**
* Time-to-first-forwarded-SSE-chunk in ms, or null when nothing was forwarded.
* NOT token-level TTFT — see open-sse/utils/streamTiming.ts for what is measured.
*/
ttft?: number | null;
/** Mean inter-chunk gap in ms (chunk-latency proxy for ITL), or null. */
itlMs?: number | null;
/** True when the stream was interrupted (timeout/abort/error) before a clean finish. */
interrupted?: boolean;
};
type StreamOptions = {
@@ -577,7 +588,10 @@ function getOpenAIIntermediateChunks(value: unknown): unknown[] {
return Array.isArray(candidate) ? candidate : [];
}
export function restoreClaudePassthroughToolUseName(parsed: JsonRecord, toolNameMap: unknown): boolean {
export function restoreClaudePassthroughToolUseName(
parsed: JsonRecord,
toolNameMap: unknown
): boolean {
const block =
parsed.content_block && typeof parsed.content_block === "object"
? (parsed.content_block as JsonRecord)
@@ -660,6 +674,16 @@ export function createSSEStream(options: StreamOptions = {}) {
performance.clearMarks("omni-request-body-size");
}
// Canonical streaming timing (TTFT / ITL / interruption). One instance per
// stream, marked from the transform below. ttft() = first-forwarded-SSE-chunk
// latency (NOT token-level) — see streamTiming.ts.
const timing: StreamTiming = createStreamTiming();
/** Forward a pre-encoded SSE chunk, marking TTFT/ITL on the way. */
const forward = (controller: TransformStreamDefaultController<Uint8Array>, bytes: Uint8Array) => {
timing.markForward();
controller.enqueue(bytes);
};
// Drop internal commentary-phase Responses output before forwarding (#6199).
// Explicit option wins; otherwise read the feature flag (default on) — resolved once per stream.
const shouldDropResponsesCommentary =
@@ -948,7 +972,7 @@ export function createSSEStream(options: StreamOptions = {}) {
clientPayloadCollector.push(event);
const output = formatSSE(event, FORMATS.CLAUDE);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
forward(controller, encoder.encode(output));
}
};
@@ -973,7 +997,8 @@ export function createSSEStream(options: StreamOptions = {}) {
const errOutput = formatSSE(errorEvent, FORMATS.CLAUDE);
reqLogger?.appendConvertedChunk?.(errOutput);
clientPayloadCollector.push(errorEvent);
controller.enqueue(encoder.encode(errOutput));
forward(controller, encoder.encode(errOutput));
timing.markInterrupted();
let failureHandled = false;
if (onFailure) {
try {
@@ -1034,7 +1059,7 @@ export function createSSEStream(options: StreamOptions = {}) {
clientPayloadCollector.push(itemSanitized);
reqLogger?.appendConvertedChunk?.(output);
forwardedValuableChunk = true;
controller.enqueue(encoder.encode(output));
forward(controller, encoder.encode(output));
};
const emitFinalSseMetadata = async (
@@ -1059,7 +1084,7 @@ export function createSSEStream(options: StreamOptions = {}) {
});
if (!comment) return;
reqLogger?.appendConvertedChunk?.(comment);
controller.enqueue(encoder.encode(comment));
forward(controller, encoder.encode(comment));
};
const getResponsesReasoningKey = (payload: Record<string, unknown>): string | null => {
@@ -1146,7 +1171,7 @@ export function createSSEStream(options: StreamOptions = {}) {
clientPayloadCollector.push(syntheticEvent.body);
const output = `event: ${syntheticEvent.event}\ndata: ${JSON.stringify(syntheticEvent.body)}\n\n`;
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
forward(controller, encoder.encode(output));
}
};
@@ -1164,6 +1189,7 @@ export function createSSEStream(options: StreamOptions = {}) {
let failureHandled = false;
if (onFailure) {
try {
timing.markInterrupted();
failureHandled =
onFailure({
status: HTTP_STATUS.GATEWAY_TIMEOUT,
@@ -1195,6 +1221,7 @@ export function createSSEStream(options: StreamOptions = {}) {
transform(chunk, controller) {
if (streamTimedOut) return;
const now = Date.now();
timing.markByte();
lastChunkTime = now;
const text = decoder.decode(chunk, { stream: true });
buffer += text;
@@ -1253,7 +1280,7 @@ export function createSSEStream(options: StreamOptions = {}) {
const pendingOutput = passthroughEventPrefix.flush();
if (pendingOutput) {
reqLogger?.appendConvertedChunk?.(pendingOutput);
controller.enqueue(encoder.encode(pendingOutput));
forward(controller, encoder.encode(pendingOutput));
}
clearPendingPassthroughEvent();
continue;
@@ -1420,7 +1447,7 @@ export function createSSEStream(options: StreamOptions = {}) {
clientPayloadCollector.push(event);
}
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
forward(controller, encoder.encode(output));
injectedUsage = true;
} else {
output = `data: ${JSON.stringify(parsed)}\n\n`;
@@ -1540,11 +1567,26 @@ export function createSSEStream(options: StreamOptions = {}) {
}
}
}
let responsesCommentaryStrippedFromCompleted = false;
if (
parsed.type === "response.completed" &&
Array.isArray(parsed.response?.output) &&
parsed.response.output.length > 0
) {
// #10156 — an upstream may echo a `phase:"commentary"` item back
// inside a non-empty terminal `output` array even though its live
// SSE frames were already dropped above. Keep both representations
// consistent by applying the same drop here.
if (shouldDropResponsesCommentary) {
const { items, changed } = filterResponsesCommentaryFromItems(
parsed.response.output,
isResponsesCommentaryMessageItem
);
if (changed) {
parsed.response.output = items;
responsesCommentaryStrippedFromCompleted = true;
}
}
pushUniqueResponsesOutputItems(
passthroughResponsesOutputItems,
parsed.response.output
@@ -1588,9 +1630,19 @@ export function createSSEStream(options: StreamOptions = {}) {
]) as typeof parsed;
}
const stripped = stripResponsesLifecycleEcho(parsed);
// Belt-and-suspenders for #10156: filter the backfill buffer itself
// before it can seed an empty `response.completed.response.output`,
// in case a future code path pushes a commentary item into it
// without going through the response.completed branch above.
const backfillCandidates = shouldDropResponsesCommentary
? filterResponsesCommentaryFromItems(
passthroughResponsesOutputItems,
isResponsesCommentaryMessageItem
).items
: passthroughResponsesOutputItems;
const backfilled = backfillResponsesCompletedOutput(
parsed,
passthroughResponsesOutputItems
backfillCandidates
);
const usageNormalized = normalizeUsage(parsed);
if (
@@ -1598,7 +1650,8 @@ export function createSSEStream(options: StreamOptions = {}) {
backfilled ||
textualToolCallBackfilled ||
responsesIdsNormalized ||
usageNormalized
usageNormalized ||
responsesCommentaryStrippedFromCompleted
) {
output = `data: ${JSON.stringify(parsed)}\n\n`;
injectedUsage = true;
@@ -1709,7 +1762,7 @@ export function createSSEStream(options: StreamOptions = {}) {
clientPayload = parsed;
clientPayloadCollector.push(clientPayload);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
forward(controller, encoder.encode(output));
continue;
}
@@ -1785,7 +1838,7 @@ export function createSSEStream(options: StreamOptions = {}) {
totalContentLength += delta.reasoning_content.length;
clientPayloadCollector.push(reasoningChunk);
reqLogger?.appendConvertedChunk?.(rOutput);
controller.enqueue(encoder.encode(rOutput));
forward(controller, encoder.encode(rOutput));
delete delta.reasoning_content;
splitMixedReasoningContent = true;
}
@@ -1964,7 +2017,7 @@ export function createSSEStream(options: StreamOptions = {}) {
}
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
forward(controller, encoder.encode(output));
if (failurePayload) {
let failureHandled = false;
if (onFailure) {
@@ -2004,7 +2057,7 @@ export function createSSEStream(options: StreamOptions = {}) {
if (parsed.error) {
const output = formatTranslatedStreamError(parsed, sourceFormat);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
forward(controller, encoder.encode(output));
upstreamErrorForwarded = true;
doneSent = true;
continue;
@@ -2223,7 +2276,7 @@ export function createSSEStream(options: StreamOptions = {}) {
passthroughEventPrefix,
emitConvertedOutput: (output: string) => {
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
forward(controller, encoder.encode(output));
},
pushProviderPayload: (payload: unknown) => providerPayloadCollector.push(payload),
pushClientPayload: (payload: unknown) => clientPayloadCollector.push(payload),
@@ -2336,7 +2389,7 @@ export function createSSEStream(options: StreamOptions = {}) {
output = output.endsWith("\n") ? `${output}\n` : `${output}\n\n`;
}
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
forward(controller, encoder.encode(output));
}
if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) {
@@ -2380,7 +2433,7 @@ export function createSSEStream(options: StreamOptions = {}) {
flushOutput = `data: ${JSON.stringify(syntheticChunk)}\n\n`;
}
reqLogger?.appendConvertedChunk?.(flushOutput);
controller.enqueue(encoder.encode(flushOutput));
forward(controller, encoder.encode(flushOutput));
passthroughAccumulatedContent = appendBoundedText(
passthroughAccumulatedContent,
passthroughBufferedTextualToolCallContent
@@ -2397,7 +2450,7 @@ export function createSSEStream(options: StreamOptions = {}) {
totalContentLength += thinkFlush.addedLength;
clientPayloadCollector.push(thinkFlush.syntheticChunk);
reqLogger?.appendConvertedChunk?.(thinkFlush.flushOutput);
controller.enqueue(encoder.encode(thinkFlush.flushOutput));
forward(controller, encoder.encode(thinkFlush.flushOutput));
}
// Estimate usage if provider didn't return valid usage
@@ -2431,7 +2484,7 @@ export function createSSEStream(options: StreamOptions = {}) {
);
const finishOutput = `data: ${JSON.stringify(syntheticFinishChunk)}\n\n`;
reqLogger?.appendConvertedChunk?.(finishOutput);
controller.enqueue(encoder.encode(finishOutput));
forward(controller, encoder.encode(finishOutput));
clientPayloadCollector.push(syntheticFinishChunk);
}
await emitFinalSseMetadata(controller, usage);
@@ -2440,7 +2493,7 @@ export function createSSEStream(options: StreamOptions = {}) {
clientPayloadCollector.push({ done: true });
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(encoder.encode(doneOutput));
forward(controller, encoder.encode(doneOutput));
}
}
// Notify caller for call log persistence (include full response body with accumulated content)
@@ -2514,6 +2567,9 @@ export function createSSEStream(options: StreamOptions = {}) {
status: 200,
usage,
responseBody,
ttft: timing.ttftMs(),
itlMs: timing.avgItlMs(),
interrupted: timing.interrupted,
// #9315 switched the summary to the accumulated responseBody to avoid
// stale/truncated event data — but responseBody here is synthesized in
// chat-completion shape, which loses the Responses API `response` object.
@@ -2616,6 +2672,7 @@ export function createSSEStream(options: StreamOptions = {}) {
let failureHandled = false;
if (onFailure) {
try {
timing.markInterrupted();
failureHandled =
onFailure({
status: err.status,
@@ -2635,6 +2692,9 @@ export function createSSEStream(options: StreamOptions = {}) {
status: err.status,
usage: state?.usage,
responseBody: errorBody,
ttft: timing.ttftMs(),
itlMs: timing.avgItlMs(),
interrupted: timing.interrupted,
error: err.message,
errorCode: err.code,
providerPayload: providerPayloadCollector.build(
@@ -2731,7 +2791,7 @@ export function createSSEStream(options: StreamOptions = {}) {
clientPayloadCollector.push({ done: true });
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(encoder.encode(doneOutput));
forward(controller, encoder.encode(doneOutput));
}
}

View File

@@ -0,0 +1,83 @@
/**
* Canonical streaming timing instrumentation (TTFT / ITL / interruption).
*
* One reusable seam for measuring the streaming path. It is created once per
* stream and marked from the SSE transform:
*
* markByte() — first upstream chunk received (bytes arrived from provider)
* markForward() — first chunk forwarded to the client (first SSE chunk enqueued)
*
* `ttft()` is therefore **first-forwarded-SSE-chunk latency**, NOT token-level
* TTFT. We document that distinction explicitly: a single SSE chunk can carry
* zero, one, or many tokens, and chunk boundaries do not map to token
* boundaries. If a future implementation can measure actual token timing it
* should extend this seam, not bypass it.
*
* ITL (inter-token latency) is approximated by the mean gap between forwarded
* SSE chunks (bounded sample window). It is a chunk-latency proxy, again not
* true token timing — callers must label it as such.
*
* The object is cheap to construct, plain mutable state, and safe under the
* event loop's single thread (each stream owns its own instance).
*/
export interface StreamTiming {
startedAt: number;
firstByteAt: number | null;
firstForwardAt: number | null;
lastForwardAt: number | null;
/** Mean gap between forwarded chunks (ms), bounded window. */
interChunkGaps: number[];
forwardedChunks: number;
interrupted: boolean;
markByte(): void;
markForward(): void;
markInterrupted(): void;
/** First-forwarded-SSE-chunk latency in ms, or null if nothing was forwarded. */
ttftMs(): number | null;
/** Mean inter-chunk gap in ms, or null when fewer than 2 chunks were forwarded. */
avgItlMs(): number | null;
/** Time from stream start to completion (ms). */
totalMs(): number;
}
/** Max number of inter-chunk samples kept (bounds memory). */
const MAX_INTER_CHUNK_GAPS = 32;
export function createStreamTiming(): StreamTiming {
const timing: StreamTiming = {
startedAt: Date.now(),
firstByteAt: null,
firstForwardAt: null,
lastForwardAt: null,
interChunkGaps: [],
forwardedChunks: 0,
interrupted: false,
markByte() {
if (this.firstByteAt === null) this.firstByteAt = Date.now();
},
markForward() {
const now = Date.now();
if (this.firstForwardAt === null) this.firstForwardAt = now;
if (this.lastForwardAt !== null && this.interChunkGaps.length < MAX_INTER_CHUNK_GAPS) {
this.interChunkGaps.push(now - this.lastForwardAt);
}
this.lastForwardAt = now;
this.forwardedChunks += 1;
},
markInterrupted() {
this.interrupted = true;
},
ttftMs() {
return this.firstForwardAt === null ? null : this.firstForwardAt - this.startedAt;
},
avgItlMs() {
if (this.interChunkGaps.length === 0) return null;
const sum = this.interChunkGaps.reduce((a, b) => a + b, 0);
return sum / this.interChunkGaps.length;
},
totalMs() {
return Date.now() - this.startedAt;
},
};
return timing;
}

367
package-lock.json generated
View File

@@ -155,8 +155,7 @@
"node": ">=22.22.2 <23 || >=24.0.0 <27"
},
"optionalDependencies": {
"@atjsh/llmlingua-2": "2.0.3",
"@tensorflow/tfjs": "4.22.0",
"@atjsh/llmlingua-2": "2.0.5",
"better-sqlite3": "^13.0.2",
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",
@@ -556,17 +555,16 @@
}
},
"node_modules/@atjsh/llmlingua-2": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@atjsh/llmlingua-2/-/llmlingua-2-2.0.3.tgz",
"integrity": "sha512-UJJFMbzYldkZ4qX5CrSZtmytOnXf6aXhmr1sBhbpVMHdmQG+7GCnrx5rIwPSOmozXD9KiPv5nnV6pvzxdtHdYQ==",
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@atjsh/llmlingua-2/-/llmlingua-2-2.0.5.tgz",
"integrity": "sha512-cXdGUJgx0e2Sui5gYC8kapOhw1HAxwzh9IuYPdqyB+VlP6SL9imIfyB7I4GTCl/iG+BUxaOqSrLqWsWYvDZuVQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"es-toolkit": "^1.38.0"
},
"peerDependencies": {
"@huggingface/transformers": "*",
"@tensorflow/tfjs": "*",
"@huggingface/transformers": "^3.5.2 || ^4.0.0",
"js-tiktoken": "*"
}
},
@@ -12241,241 +12239,6 @@
"tailwindcss": "4.3.3"
}
},
"node_modules/@tensorflow/tfjs": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs/-/tfjs-4.22.0.tgz",
"integrity": "sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@tensorflow/tfjs-backend-cpu": "4.22.0",
"@tensorflow/tfjs-backend-webgl": "4.22.0",
"@tensorflow/tfjs-converter": "4.22.0",
"@tensorflow/tfjs-core": "4.22.0",
"@tensorflow/tfjs-data": "4.22.0",
"@tensorflow/tfjs-layers": "4.22.0",
"argparse": "^1.0.10",
"chalk": "^4.1.0",
"core-js": "3.29.1",
"regenerator-runtime": "^0.13.5",
"yargs": "^16.0.3"
},
"bin": {
"tfjs-custom-module": "dist/tools/custom_module/cli.js"
}
},
"node_modules/@tensorflow/tfjs-backend-cpu": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-4.22.0.tgz",
"integrity": "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@types/seedrandom": "^2.4.28",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-backend-webgl": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-4.22.0.tgz",
"integrity": "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@tensorflow/tfjs-backend-cpu": "4.22.0",
"@types/offscreencanvas": "~2019.3.0",
"@types/seedrandom": "^2.4.28",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-converter": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-4.22.0.tgz",
"integrity": "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ==",
"license": "Apache-2.0",
"optional": true,
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-core": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-4.22.0.tgz",
"integrity": "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@types/long": "^4.0.1",
"@types/offscreencanvas": "~2019.7.0",
"@types/seedrandom": "^2.4.28",
"@webgpu/types": "0.1.38",
"long": "4.0.0",
"node-fetch": "~2.6.1",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
}
},
"node_modules/@tensorflow/tfjs-core/node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT",
"optional": true
},
"node_modules/@tensorflow/tfjs-data": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-data/-/tfjs-data-4.22.0.tgz",
"integrity": "sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@types/node-fetch": "^2.1.2",
"node-fetch": "~2.6.1",
"string_decoder": "^1.3.0"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0",
"seedrandom": "^3.0.5"
}
},
"node_modules/@tensorflow/tfjs-layers": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-layers/-/tfjs-layers-4.22.0.tgz",
"integrity": "sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA==",
"license": "Apache-2.0 AND MIT",
"optional": true,
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"optional": true,
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@tensorflow/tfjs/node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"license": "ISC",
"optional": true,
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^7.0.0"
}
},
"node_modules/@tensorflow/tfjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT",
"optional": true
},
"node_modules/@tensorflow/tfjs/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/@tensorflow/tfjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@tensorflow/tfjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@tensorflow/tfjs/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@tensorflow/tfjs/node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"license": "MIT",
"optional": true,
"dependencies": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^20.2.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@tensorflow/tfjs/node_modules/yargs-parser": {
"version": "20.2.9",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz",
@@ -13022,13 +12785,6 @@
"@types/node": "*"
}
},
"node_modules/@types/long": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
"license": "MIT",
"optional": true
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -13059,24 +12815,6 @@
"undici-types": "~8.3.0"
}
},
"node_modules/@types/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.4"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.3.0",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz",
"integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==",
"license": "MIT",
"optional": true
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -13153,13 +12891,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/seedrandom": {
"version": "2.4.34",
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.34.tgz",
"integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==",
"license": "MIT",
"optional": true
},
"node_modules/@types/tough-cookie": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz",
@@ -13978,13 +13709,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@webgpu/types": {
"version": "0.1.38",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.38.tgz",
"integrity": "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/@xmldom/xmldom": {
"version": "0.9.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
@@ -17208,18 +16932,6 @@
"node": ">=6.6.0"
}
},
"node_modules/core-js": {
"version": "3.29.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.29.1.tgz",
"integrity": "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -26774,13 +26486,6 @@
"node": ">=0.1.90"
}
},
"node_modules/long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
"license": "Apache-2.0",
"optional": true
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -29266,52 +28971,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==",
"license": "MIT",
"optional": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT",
"optional": true
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause",
"optional": true
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
@@ -33071,13 +32730,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
@@ -33916,7 +33568,7 @@
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/selfsigned": {
@@ -34761,13 +34413,6 @@
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/sql.js": {
"version": "1.14.2",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "omniroute",
"version": "3.8.50",
"description": "Unified AI router with 343 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
"description": "Unified AI router with 346 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
"type": "module",
"bin": {
"omniroute": "bin/omniroute.mjs",
@@ -89,6 +89,7 @@
"gen:provider-reference": "bun scripts/docs/gen-provider-reference.ts",
"bench:compression": "bun scripts/compression/benchmark.ts",
"bench:heap-body": "node --expose-gc --import tsx/esm scripts/perf/request-body-heap.ts",
"bench:routing-events": "node --import tsx/esm scripts/perf/routing-events-bench.ts",
"eval:compression": "node --import tsx scripts/compression-eval/index.ts",
"eval:router": "node --import tsx scripts/router-eval/index.ts",
"eval:router:compare": "node --import tsx scripts/router-eval/compare.ts",
@@ -341,8 +342,7 @@
"onnxruntime-node": "~1.24.3"
},
"optionalDependencies": {
"@atjsh/llmlingua-2": "2.0.3",
"@tensorflow/tfjs": "4.22.0",
"@atjsh/llmlingua-2": "2.0.5",
"better-sqlite3": "^13.0.2",
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",

View File

@@ -6,7 +6,10 @@
* deployment runs `server.js` from that directory directly (not the assembled
* `dist/` bundle). The standalone trace cannot see worker_threads entrypoints
* resolved at runtime, including the required call-log artifact worker and the
* optional LLMLingua-2 worker. It also omits LLMLingua's optional dependencies.
* optional LLMLingua-2 worker (`open-sse/services/compression/engines/llmlingua/onnxWorker.js`,
* dynamically spawned via worker_threads — untraceable by webpack). It also omits
* LLMLingua's optional SLM deps (`@atjsh/llmlingua-2`, `js-tiktoken`) — they are
* optionalDependencies and are only installed at the ROOT `node_modules`.
*
* The call-log worker is required, so a bundle failure must fail the build.
* LLMLingua remains fail-soft when its optional dependencies are absent.

View File

@@ -4,31 +4,32 @@
* OmniRoute — Co-locate the LLMLingua-2 optional dependency closure into the standalone bundle.
*
* The compression "ultra" SLM tier (PR #4257) runs `@atjsh/llmlingua-2` +
* `@huggingface/transformers` + `@tensorflow/tfjs` + `js-tiktoken` inside a worker thread
* `@huggingface/transformers` + `js-tiktoken` inside a worker thread
* (`open-sse/services/compression/engines/llmlingua/onnxWorker.js`, shipped under `dist/`). These
* are `optionalDependencies`: npm installs them into the ROOT `node_modules` on
* `--include=optional`, but the Next.js standalone trace bundles ONLY `@huggingface/transformers`
* (3.5.2, pinned) into `dist/node_modules` — it does NOT trace the optional, dynamically-imported
* (4.2.0, pinned) into `dist/node_modules` — it does NOT trace the optional, dynamically-imported
* SLM packages.
*
* ## Why this matters (the instance-split bug)
*
* The worker lives under `dist/`, so its `import("@huggingface/transformers")` resolves
* `dist/node_modules/@huggingface/transformers` (3.5.2) and the worker sets the model `cacheDir`
* `dist/node_modules/@huggingface/transformers` (4.2.0) and the worker sets the model `cacheDir`
* on THAT instance's `env`. But its `import("@atjsh/llmlingua-2")` walks past `dist/node_modules`
* (no `@atjsh` there) up to the ROOT `node_modules`, and llmlingua-2's own
* `import("@huggingface/transformers")` then resolves the ROOT transformers — a DIFFERENT instance.
* The `cacheDir`/`localModelPath` config the worker set never reaches the instance llmlingua-2
* actually uses, so the local model under `DATA_DIR/models/llmlingua` is never found and the SLM
* tier silently fails-open (no compression). Worse, if the root transformers is a 4.x line,
* llmlingua-2 throws on a tokenizer-API change (`decoder.decode` is undefined).
* tier silently fails-open (no compression). (Before `@atjsh/llmlingua-2@2.0.5` a root
* transformers on the 4.x line also made llmlingua-2 throw on a tokenizer-API change
* — `decoder.decode` is undefined; 2.0.5+ supports both v3 and v4.)
*
* ## The fix
*
* Co-locate the SLM optional dependency CLOSURE from the root `node_modules` into
* `dist/node_modules` (NO-CLOBBER, so the pinned `dist` transformers 3.5.2 / onnxruntime / sharp
* `dist/node_modules` (NO-CLOBBER, so the pinned `dist` transformers 4.2.0 / onnxruntime / sharp
* stay). Then the worker resolves `@atjsh/llmlingua-2` AND `@huggingface/transformers` from the
* SAME `dist/node_modules` — a single 3.5.2 instance — so the env config applies and the local
* SAME `dist/node_modules` — a single 4.2.0 instance — so the env config applies and the local
* model loads.
*
* `@huggingface/transformers` is intentionally NOT a closure seed: it is a PEER of
@@ -54,7 +55,7 @@ import { dirname, join, sep } from "node:path";
* Entry packages of the SLM optional stack (the closure roots). `@huggingface/transformers` is
* deliberately absent — it is the pinned instance already present in `dist/node_modules`.
*/
export const SEED_PACKAGES = ["@atjsh/llmlingua-2", "@tensorflow/tfjs", "js-tiktoken"];
export const SEED_PACKAGES = ["@atjsh/llmlingua-2", "js-tiktoken"];
/**
* Compute the transitive dependency closure of `seeds` by walking each package's `dependencies` +

View File

@@ -402,7 +402,7 @@ runBuildTool(
// The worker is spawned via worker_threads at a path the Next.js bundler cannot
// statically trace, so it must ship as a standalone .js (mirrors the MCP-server
// bundling above). Heavy deps (@atjsh/llmlingua-2 / @huggingface/transformers /
// @tensorflow/tfjs / js-tiktoken) stay EXTERNAL — they are optionalDependencies,
// js-tiktoken) stay EXTERNAL — they are optionalDependencies,
// dynamically imported at runtime, and the worker fail-opens if any is absent.
const llmWorkerSrc = join(
ROOT,

View File

@@ -50,7 +50,6 @@ export const OPTIONAL_PACKS = [
{ name: "@huggingface/transformers" },
{ name: "onnxruntime-node" },
{ name: "@atjsh/llmlingua-2" },
{ name: "@tensorflow/tfjs" },
{ name: "js-tiktoken" },
],
},
@@ -156,7 +155,7 @@ export async function dirChecksum(dir) {
hash.update(String(size));
hash.update("\0");
try {
// Stream to keep memory bounded on multi-hundred-MB packages (tfjs).
// Stream to keep memory bounded on multi-hundred-MB packages (onnxruntime-node).
for await (const chunk of createReadStream(absolute)) hash.update(chunk);
} catch {
hash.update("<unreadable>");

View File

@@ -0,0 +1,175 @@
/**
* Routing feedback foundation benchmark (v2 — honest comparison).
*
* v1 reported a single "~0.2µs/request" figure. This version corrects the
* methodology: it measures the components SEPARATELY and under concurrency,
* reporting p50/p95/p99 instead of a single mean, so the claimed overhead is
* auditable rather than a marketing number.
*
* Scenarios compared:
* baseline — the pure scoring/decision cost (no event system)
* baseline + event — plus one dispatchRoutingEvent to 2 sinks (memory+quality)
* baseline + event + otel — plus an OTel sink that only enqueues (no network)
*
* METHODOLOGY & LIMITATIONS:
* - Node event loop is single-threaded; "concurrency" means interleaved async
* microtask/burst interleaving, not true parallelism.
* - p95/p99 are measured per-op over a big N with high-resolution timers.
* - No network I/O is performed (OTel flush is deliberately not fired).
* - Numbers are machine-specific; treat them as relative, not absolute.
*
* Usage:
* npm run bench:routing-events
* npm run bench:routing-events -- --events 200000
*/
import { performance } from "node:perf_hooks";
import {
dispatchRoutingEvent,
MemoryRoutingEventStore,
registerRoutingEventSink,
type RoutingEvent,
type RoutingEventSink,
} from "../../open-sse/services/routing/events.ts";
import { recordQualityEvent } from "../../open-sse/services/routing/quality.ts";
import { OtlpHttpsEventSink } from "../../open-sse/services/routing/otel.ts";
import {
calculateFactors,
calculateScore,
DEFAULT_WEIGHTS,
type ProviderCandidate,
} from "../../open-sse/services/autoCombo/scoring.ts";
const N = Number(process.argv[2] === "--events" ? (process.argv[3] ?? 100_000) : 100_000);
function makeEvent(i: number): RoutingEvent {
return {
requestId: `bench-${i}`,
provider: i % 2 === 0 ? "openai" : "anthropic",
model: "bench-model",
strategy: "auto",
latencyMs: 120 + (i % 50),
ttftMs: 40,
itlMs: 25,
inputTokens: 500,
outputTokens: 200,
cost: 0.01,
retries: 0,
fallbackUsed: false,
outcome: i % 100 === 0 ? "malformed" : "success",
status: 200,
finishReason: "stop",
connectionId: null,
ts: Date.now(),
};
}
function bench(name: string, iterations: number, fn: (i: number) => number): void {
// Warmup
for (let i = 0; i < Math.min(10_000, iterations); i++) fn(i);
const start = performance.now();
for (let i = 0; i < iterations; i++) fn(i);
const elapsedMs = performance.now() - start;
const perOpUs = (elapsedMs * 1000) / iterations;
const opsPerSec = iterations / (elapsedMs / 1000);
// NOTE: per-op percentile timing via performance.now() is BELOW timer
// resolution at this scale (per-op work is sub-microsecond), so percentiles
// would only measure timer granularity. Aggregate µs/op + throughput are the
// honest metrics here.
console.log(
`${name.padEnd(46)} ${iterations.toLocaleString()} ops in ${elapsedMs.toFixed(1)}ms | ` +
`${perOpUs.toFixed(3)}µs/op | ${Math.round(opsPerSec).toLocaleString()} ops/s`
);
}
// Shared sink set for the "event" and "otel" scenarios.
const store = new MemoryRoutingEventStore(500);
registerRoutingEventSink(store);
const qualitySink: RoutingEventSink = {
name: "quality",
record: (e) => recordQualityEvent(e),
};
registerRoutingEventSink(qualitySink);
// OTel sink that only enqueues (flush interval set absurdly high; never fires in-run).
const otelSink = new OtlpHttpsEventSink({
endpoint: "http://127.0.0.1:1", // unreachable; record() never touches the network
flushIntervalMs: 1_000_000,
});
registerRoutingEventSink(otelSink);
const candidate = (quality: number): ProviderCandidate => ({
provider: "p",
model: "m",
quotaRemaining: 100,
quotaTotal: 100,
circuitBreakerState: "CLOSED",
costPer1MTokens: 1,
p95LatencyMs: 100,
latencyStdDev: 10,
errorRate: 0,
quality,
});
const pool = [candidate(0.9), candidate(0.5), candidate(0.2)];
console.log(
`\nRouting events benchmark (${N.toLocaleString()} iterations, 2 sinks + otel-enqueue)\n`
);
// baseline: the scoring/decision cost the router already pays WITHOUT the event system.
bench("baseline: calculateFactors+Score", N, (i) => {
const c = pool[i % pool.length];
const f = calculateFactors(c, pool, "general", () => 0.5);
return calculateScore(f, DEFAULT_WEIGHTS);
});
// baseline + event: the production hot-path cost (dispatch to memory+quality sinks).
bench("baseline + RoutingEvent (2 sinks)", N, (i) => {
const c = pool[i % pool.length];
const f = calculateFactors(c, pool, "general", () => 0.5);
const score = calculateScore(f, DEFAULT_WEIGHTS);
dispatchRoutingEvent(makeEvent(i));
return score;
});
// baseline + event + OTel-enqueue: adds the third sink (still no network I/O).
bench("baseline + event + OTel enqueue", N, (i) => {
const c = pool[i % pool.length];
const f = calculateFactors(c, pool, "general", () => 0.5);
const score = calculateScore(f, DEFAULT_WEIGHTS);
dispatchRoutingEvent(makeEvent(i));
return score;
});
// Concurrency: bursts interleaved on the event loop.
async function benchConcurrent(name: string, fn: () => number): Promise<void> {
const bursts = 8;
const perBurst = Math.ceil(N / bursts);
const start = performance.now();
await Promise.all(
Array.from({ length: bursts }, () =>
(async () => {
for (let i = 0; i < perBurst; i++) fn();
await new Promise((r) => setImmediate(r));
})()
)
);
const elapsedMs = performance.now() - start;
const totalOps = bursts * perBurst;
console.log(
`${name.padEnd(46)} ${totalOps.toLocaleString()} ops in ${elapsedMs.toFixed(1)}ms ` +
`(${(elapsedMs * 1000) / totalOps}µs/op aggregate)`
);
}
console.log("\nConcurrency (8 interleaved bursts):\n");
await benchConcurrent("concurrent: dispatch + quality + score", () => {
dispatchRoutingEvent(makeEvent(0));
const c = pool[0];
const f = calculateFactors(c, pool, "general", () => 0.5);
return calculateScore(f, DEFAULT_WEIGHTS);
});
console.log(`\nOTel sink stats: ${JSON.stringify(otelSink.getStats())}`);
otelSink.stop();
console.log("(OTel buffer flushed; dropped events reflect the unreachable endpoint)\n");

View File

@@ -19,6 +19,7 @@ import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel";
import { useIsElectron, useOpenExternal } from "@/shared/hooks/useElectron";
import { HomeProviderTopologySection } from "./HomeProviderTopologySection";
import { shouldShowProviderTopologyOnHome } from "./homeAppearance";
import HomeRecentRequests from "../home/HomeRecentRequests";
type UpdateStep = {
step: string;
@@ -1126,12 +1127,15 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
)}
{showProviderTopologyOnHome && (
<HomeProviderTopologySection
providers={topologyProviders}
lastProvider={lastProvider}
errorProvider={errorProvider}
enabled={showProviderTopologyOnHome}
/>
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-3">
<HomeProviderTopologySection
providers={topologyProviders}
lastProvider={lastProvider}
errorProvider={errorProvider}
enabled={showProviderTopologyOnHome}
/>
<HomeRecentRequests enabled={showProviderTopologyOnHome} />
</div>
)}
{/* Provider Models Modal */}

View File

@@ -11,6 +11,13 @@ import { getModelPricing } from "@/lib/playground/types";
import type { ConfigState } from "../StudioConfigPane";
import type { StreamMetrics } from "@/shared/schemas/playground";
import { buildReasoningRequestFields } from "../reasoningControlUtils";
import {
buildNonChatRequestBody,
formatNonChatResponse,
isChatCompletionsEndpoint,
lastUserContent,
resolveChatTabRequestPath,
} from "./chatTabEndpointRequest";
interface Message {
role: "system" | "user" | "assistant";
@@ -127,11 +134,19 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
try {
const fetchHeaders: Record<string, string> = { "Content-Type": "application/json" };
const chatEndpoint = isChatCompletionsEndpoint(configState.endpoint);
const requestBody = chatEndpoint
? buildRequestBody(chatMessages)
: buildNonChatRequestBody(
configState.endpoint,
lastUserContent(chatMessages),
configState.model
);
const res = await fetch("/api/v1/chat/completions", {
const res = await fetch(resolveChatTabRequestPath(configState.endpoint), {
method: "POST",
headers: fetchHeaders,
body: JSON.stringify(buildRequestBody(chatMessages)),
body: JSON.stringify(requestBody),
signal: controller.signal,
});
@@ -150,6 +165,20 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
return;
}
if (!chatEndpoint) {
const rawText = await res.text();
setMessages((prev) => {
const next = [...prev];
const idx = appendIndex !== undefined ? appendIndex : next.length - 1;
next[idx] = { ...next[idx], content: formatNonChatResponse(rawText) };
return next;
});
setResponseDuration(Date.now() - startTime);
setLoading(false);
streamMetrics.reset();
return;
}
let firstChunk = true;
const reader = res.body?.getReader();
const decoder = new TextDecoder();

View File

@@ -0,0 +1,59 @@
// src/app/(dashboard)/dashboard/playground/components/tabs/chatTabEndpointRequest.ts
//
// #10592 — ChatTab.tsx hardcoded every "Send" click to POST /api/v1/chat/completions,
// ignoring configState.endpoint entirely. Selecting a search-only provider (exa-search,
// tavily-search, serper-search) in the Endpoint selector still sent a chat.completions
// request, which has no notion of search-provider credentials and 404s.
//
// This module gives ChatTab a small, testable seam for routing non-chat endpoints
// (currently "search" and "web.fetch") to their real path with a query-shaped body,
// instead of the chat.completions messages/SSE shape.
import { endpointToPath, type PlaygroundEndpoint } from "@/lib/playground/codeExport";
/** Chat-shaped endpoints keep the existing messages[] + SSE-delta request/response flow. */
export function isChatCompletionsEndpoint(endpoint: PlaygroundEndpoint | undefined): boolean {
return !endpoint || endpoint === "chat.completions";
}
/** Resolves the fetch path (mounted under `/api`) for the selected Playground endpoint. */
export function resolveChatTabRequestPath(endpoint: PlaygroundEndpoint | undefined): string {
return `/api${endpointToPath(endpoint ?? "chat.completions")}`;
}
/**
* Builds the request body for a non-chat endpoint from the user's free-text query.
* "search" and "web.fetch" both take a single string field instead of a messages array.
*/
export function buildNonChatRequestBody(
endpoint: PlaygroundEndpoint | undefined,
query: string,
model: string
): Record<string, unknown> {
if (endpoint === "web.fetch") {
return { url: query };
}
const body: Record<string, unknown> = { query };
if (model) body.model = model;
return body;
}
/** Renders a non-chat endpoint's raw response text as a chat-bubble-friendly string. */
export function formatNonChatResponse(rawText: string): string {
try {
const parsed = JSON.parse(rawText) as unknown;
return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```";
} catch {
return rawText;
}
}
/** Finds the most recent user-authored message content to use as a non-chat query. */
export function lastUserContent(
chatMessages: Array<{ role: string; content: string }>
): string {
for (let i = chatMessages.length - 1; i >= 0; i--) {
if (chatMessages[i].role === "user") return chatMessages[i].content;
}
return "";
}

View File

@@ -0,0 +1,207 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { fmtCompact } from "@/shared/utils/formatting";
/**
* Home-page "Recent Requests" panel — the live request feed that sits beside the
* Provider Topology graph (parity with 9Router's Usage view).
*
* ## Data source
* Fed by POLLING `GET /api/usage/call-logs?limit=N` every ~3s, NOT by the live
* WebSocket. The WS is used elsewhere for the in-flight beam (it emits
* `request.started` and, since the stuck-latch fix, `request.completed`/`request.failed`),
* but its payload carries no tokens/latency/status and no persisted history — so it
* can't back a "recent requests" table on its own. The call-logs endpoint merges the
* in-memory active/completed entries with persisted rows and returns them newest-first
* (active on top), which is exactly this feed.
*
* The poll is gated by `enabled` (the same `showProviderTopologyOnHome` flag that
* gates the topology section) AND page visibility, so a backgrounded tab pauses.
*/
const POLL_INTERVAL_MS = 3000;
// Rows shown after client-side filtering of connection-test rows.
const RECENT_LIMIT = 20;
// Fetch a wider window than we display so filtering out connection-test rows
// (a burst of "Test connection" clicks) can't starve the feed below RECENT_LIMIT.
const FETCH_LIMIT = 60;
type CallLogRow = {
id?: string;
timestamp?: string;
status?: number;
model?: string;
provider?: string;
providerDisplay?: string | null;
path?: string;
sourceFormat?: string | null;
targetFormat?: string | null;
tokens?: { in?: number; out?: number };
error?: string | null;
active?: boolean;
completed?: boolean;
};
/**
* Connection tests write real `call_logs` rows (provider "Test connection" button →
* `/api/providers/[id]/test`) with fixed markers: model `connection-test`, path
* `/api/providers/test`, sourceFormat/targetFormat `test`. Those are health probes,
* not user traffic, so they must not clutter the Recent Requests feed (matching how
* 9Router keeps its Usage list to real calls). Drop any row carrying a test marker.
*/
function isConnectionTestRow(row: CallLogRow): boolean {
return (
row.model === "connection-test" ||
row.sourceFormat === "test" ||
row.targetFormat === "test" ||
row.path === "/api/providers/test"
);
}
type RequestState = "active" | "error" | "ok";
function requestState(row: CallLogRow): RequestState {
if (row.active || row.status === 0) return "active";
if (row.error || (typeof row.status === "number" && row.status >= 400)) return "error";
return "ok";
}
function timeAgo(timestamp: string | undefined, nowMs: number): string {
if (!timestamp) return "";
const then = Date.parse(timestamp);
if (!Number.isFinite(then)) return "";
const diff = Math.max(0, Math.floor((nowMs - then) / 1000));
if (diff < 60) return `${diff}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
const STATE_DOT: Record<RequestState, string> = {
active: "bg-primary animate-pulse",
error: "bg-red-500",
ok: "bg-green-500",
};
export default function HomeRecentRequests({ enabled = true }: { enabled?: boolean }) {
const t = useTranslations("home");
const [rows, setRows] = useState<CallLogRow[]>([]);
const [loaded, setLoaded] = useState(false);
// A ticking clock so the relative "When" column updates without re-fetching.
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
if (!enabled) return;
const id = setInterval(() => setNowMs(Date.now()), 1000);
return () => clearInterval(id);
}, [enabled]);
const load = useCallback(async (signal: AbortSignal) => {
try {
const res = await fetch(`/api/usage/call-logs?limit=${FETCH_LIMIT}&excludeTests=1`, {
cache: "no-store",
signal,
});
if (!res.ok) return;
const data = await res.json();
if (signal.aborted) return;
const filtered = Array.isArray(data)
? (data as CallLogRow[]).filter((row) => !isConnectionTestRow(row)).slice(0, RECENT_LIMIT)
: [];
setRows(filtered);
setLoaded(true);
} catch (error) {
const isAbort = error instanceof DOMException && error.name === "AbortError";
if (!isAbort) console.error("Failed to load recent requests:", error);
}
}, []);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let controller: AbortController | null = null;
const tick = async () => {
// Pause polling while the tab is backgrounded; resume on next tick.
if (document.visibilityState === "visible") {
const currentController = new AbortController();
controller = currentController;
await load(currentController.signal);
if (controller === currentController) controller = null;
}
if (!cancelled) timeoutId = setTimeout(tick, POLL_INTERVAL_MS);
};
tick();
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
controller?.abort();
};
}, [enabled, load]);
return (
<Card padding="sm" className="flex min-w-0 flex-col overflow-hidden h-[300px] sm:h-[420px]">
<div className="pb-2 mb-1 border-b border-border shrink-0">
<span className="text-xs font-semibold uppercase tracking-wide text-text-muted">
{t("recentRequests")}
</span>
</div>
{loaded && rows.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-sm text-text-muted">
{t("recentRequestsEmpty")}
</div>
) : (
<div className="flex-1 overflow-y-auto -mx-1 px-1">
<table className="w-full min-w-0 border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-surface">
<tr className="border-b border-border text-text-muted">
<th className="w-2 py-1.5" />
<th className="py-1.5 text-left font-semibold">{t("recentRequestsModel")}</th>
<th className="py-1.5 text-right font-semibold whitespace-nowrap">
{t("recentRequestsTokens")}
</th>
<th className="py-1.5 text-right font-semibold">{t("recentRequestsWhen")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{rows.map((row, i) => {
const state = requestState(row);
return (
<tr key={row.id || i} className="hover:bg-bg-subtle transition-colors">
<td className="py-1.5">
<span className={`block size-1.5 rounded-full ${STATE_DOT[state]}`} />
</td>
<td
className="py-1.5 font-mono truncate max-w-[140px]"
title={row.model || ""}
>
{row.model || "—"}
</td>
<td className="py-1.5 text-right whitespace-nowrap">
<span className="text-primary">{fmtCompact(row.tokens?.in)}</span>{" "}
<span className="text-green-500">{fmtCompact(row.tokens?.out)}</span>
</td>
<td className="py-1.5 text-right whitespace-nowrap text-text-muted">
{state === "active" ? (
<span className="text-primary"></span>
) : (
timeAgo(row.timestamp, nowMs)
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
);
}

View File

@@ -143,6 +143,9 @@ export async function GET(request: Request) {
if (searchParams.get("correlationId")) filter.correlationId = searchParams.get("correlationId");
if (searchParams.get("limit")) filter.limit = parseInt(searchParams.get("limit"));
if (searchParams.get("offset")) filter.offset = parseInt(searchParams.get("offset"));
// Home Recent Requests feed sets excludeTests=1 so connection-test probe rows
// are dropped at the SQL layer (before LIMIT), not client-side after slicing.
if (searchParams.get("excludeTests") === "1") filter.excludeTests = true;
const [logs, connections, providerNodes] = await Promise.all([
getCallLogs(filter),

View File

@@ -0,0 +1,72 @@
/**
* GET /v1/explain/routing — routing explainability + feedback state.
*
* Returns the most recent routing events (bounded in-memory ring buffer) and
* the per-provider/model quality snapshot produced by the feedback foundation
* (open-sse/services/routing). This is REAL decision data — the events were
* emitted by the request hot path, not recomputed after the fact.
*
* Safety: only routing metadata (provider/model/strategy/timing/tokens/outcome/
* status/finish_reason). Never prompts, bodies, headers, credentials, accounts.
*
* Auth mirrors /v1/combos: valid Bearer API key or dashboard session. With
* REQUIRE_API_KEY=false (single-user local deployments) anonymous read is
* allowed, matching /v1/models behavior.
*/
import { NextResponse } from "next/server";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
import {
recentRoutingEvents,
routingQualitySnapshot,
routingOtelStats,
initRoutingObservability,
classifyQuality,
} from "@omniroute/open-sse/services/routing/index.ts";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
});
}
export async function GET(request: Request) {
const apiKeyRaw = extractApiKey(request);
const apiKeyOk = apiKeyRaw ? await isValidApiKey(apiKeyRaw) : false;
const dashboardOk = !apiKeyOk ? await isDashboardSessionAuthenticated(request) : false;
if (!apiKeyOk && !dashboardOk && isRequireApiKeyEnabled()) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
}
try {
const limit = Math.min(
500,
Math.max(1, Number(new URL(request.url).searchParams.get("limit")) || 50)
);
const { sinks, otelEnabled } = initRoutingObservability();
const quality = routingQualitySnapshot(limit).map((q) => ({
...q,
classification: classifyQuality(q),
}));
return NextResponse.json(
{
object: "routing_explain",
sinks,
otelEnabled,
events: recentRoutingEvents(limit),
quality,
otel: routingOtelStats(),
},
{ headers: { "Cache-Control": "no-store" } }
);
} catch {
return errorResponse(HTTP_STATUS.SERVER_ERROR, "Failed to build routing explain payload");
}
}

View File

@@ -7,6 +7,61 @@ export async function OPTIONS() {
return handleCorsOptions();
}
const DEFAULT_LIST_LIMIT = 20;
const MAX_LIST_LIMIT = 10000;
export function parseFilesListQuery(searchParams: URLSearchParams):
| {
ok: true;
limit: number;
after: string | undefined;
order: "asc" | "desc";
purpose: string | undefined;
}
| { ok: false; response: Response } {
const rawLimit = searchParams.get("limit");
let limit = DEFAULT_LIST_LIMIT;
if (rawLimit !== null) {
if (!/^\d+$/.test(rawLimit)) {
return {
ok: false,
response: NextResponse.json(
{ error: { message: "limit must be a positive integer", type: "invalid_request_error" } },
{ status: 400, headers: CORS_HEADERS }
),
};
}
limit = Number.parseInt(rawLimit, 10);
if (limit < 1 || limit > MAX_LIST_LIMIT) {
return {
ok: false,
response: NextResponse.json(
{
error: {
message: `limit must be between 1 and ${MAX_LIST_LIMIT}`,
type: "invalid_request_error",
},
},
{ status: 400, headers: CORS_HEADERS }
),
};
}
}
const orderParam = searchParams.get("order");
const order = orderParam === "asc" ? "asc" : "desc";
return {
ok: true,
limit,
after: searchParams.get("after") || undefined,
order,
purpose: searchParams.get("purpose") || undefined,
};
}
export async function POST(request: Request) {
const scope = await getApiKeyRequestScope(request);
if (scope.rejection) return scope.rejection;
@@ -78,10 +133,9 @@ export async function GET(request: Request) {
const apiKeyId = scope.apiKeyId;
const { searchParams } = new URL(request.url);
const limit = Math.min(Number.parseInt(searchParams.get("limit") || "20") || 20, 10000);
const after = searchParams.get("after") || undefined;
const order = (searchParams.get("order") as "asc" | "desc") || "desc";
const purpose = searchParams.get("purpose") || undefined;
const parsed = parseFilesListQuery(searchParams);
if (!parsed.ok) return parsed.response;
const { limit, after, order, purpose } = parsed;
// We fetch limit + 1 to check if there are more items
const files = listFiles({

View File

@@ -3,6 +3,7 @@ import {
handleCodexImageEdit,
handleImageEdit,
handleOpenAIImageEdit,
handleOpenRouterImageEdit,
} from "@omniroute/open-sse/handlers/imageGeneration.ts";
import {
handleFalAIImageEdit,
@@ -585,6 +586,55 @@ async function postHandler(request: Request, _context?: unknown) {
});
}
// Built-in OpenRouter uses its unified Image API for reference-image
// edits: POST /api/v1/images with input_references. Forward through the
// provider-specific adapter (#10197), rather than the multipart
// /images/edits path used by custom OpenAI-compatible nodes.
if (providerConfig?.id === "openrouter") {
const credentials = await getProviderCredentialsWithQuotaPreflight(
parsed.provider,
null,
allowedConnections,
resolvedModel
);
if (!credentials) {
return errorResponse(
HTTP_STATUS.UNAUTHORIZED,
`No credentials for provider: ${parsed.provider}`
);
}
if (credentials.allRateLimited) {
return unavailableResponse(
HTTP_STATUS.RATE_LIMITED,
`[${parsed.provider}] All accounts rate limited`,
credentials.retryAfter,
credentials.retryAfterHuman
);
}
const result = await handleOpenRouterImageEdit({
provider: parsed.provider,
model: parsed.model,
baseUrl: providerConfig.baseUrl,
credentials,
prompt,
imageBytes,
imageMime,
size: size ?? undefined,
n: 1,
log,
});
if (result.success) {
await clearRecoveredProviderState(credentials);
return jsonResponse(result.data);
}
return jsonResponse(
toJsonErrorPayload(result.error, "Image edit provider error"),
result.status
);
}
// Other built-in providers do not expose an OpenAI-compatible edit endpoint.
if (providerConfig) {
return errorResponse(

View File

@@ -19,7 +19,11 @@ import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { v1SearchSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
formatValidationMessage,
isValidationFailure,
validateBody,
} from "@/shared/validation/helpers";
import { recordCost } from "@/domain/costRules";
import {
computeCacheKey,
@@ -120,7 +124,7 @@ async function postHandler(request: Request, context: unknown) {
const validation = validateBody(v1SearchSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
return errorResponse(HTTP_STATUS.BAD_REQUEST, formatValidationMessage(validation.error));
}
const body = validation.data;

View File

@@ -44,6 +44,13 @@ import { getAntigravityQuotaFamily } from "@omniroute/open-sse/services/antigrav
interface QuotaInfo {
remainingPercentage: number;
resetAt: string | null;
// #10095 — upstream explicitly told us it did NOT report this window's
// fraction (e.g. a fresh Antigravity account or a newly-launched
// -tiered model id Google hasn't wired quota telemetry for yet).
// `undefined`/`true` means the value is a real, upstream-reported
// percentage; `false` means "unknown", so callers must not treat the
// defaulted-to-0 `remainingPercentage` as genuine exhaustion.
fractionReported?: boolean;
}
interface QuotaCacheEntry {
@@ -113,7 +120,10 @@ const MAX_CONCURRENT_REFRESHES = 5;
function isExhausted(quotas: Record<string, QuotaInfo>): boolean {
const entries = Object.values(quotas);
if (entries.length === 0) return false;
return entries.every((q) => q.remainingPercentage <= 0);
// #10095 — a window whose fraction was never reported by upstream must
// never single-handedly flip the whole connection to exhausted; treat it
// as available (mirrors the guard in genericQuotaFetcher.ts).
return entries.every((q) => q.fractionReported !== false && q.remainingPercentage <= 0);
}
/**
@@ -237,6 +247,9 @@ function normalizeQuotas(rawQuotas: Record<string, any>): Record<string, QuotaIn
safePercentage(q.remainingPercentage) ??
(q.total > 0 ? Math.round(((q.total - (q.used || 0)) / q.total) * 100) : 0),
resetAt: q.resetAt || null,
// #10095 — thread through the "did upstream actually report this
// window's fraction" signal (see UsageQuota in usage/quota.ts).
fractionReported: q.fractionReported === false ? false : undefined,
};
}
}
@@ -641,11 +654,14 @@ export function getQuotaWindowStatus(
usedPercentage,
resetAt,
// If reset time has already passed, avoid stale cached percentages blocking selection.
reachedThreshold: windowExpired
? false
: remainingPercentage <= 0
? true
: usedPercentage >= thresholdPercent,
// #10095 — a window whose fraction upstream never reported is "unknown",
// not "0% remaining"; never let it reach the exhaustion threshold.
reachedThreshold:
windowExpired || window.fractionReported === false
? false
: remainingPercentage <= 0
? true
: usedPercentage >= thresholdPercent,
};
}

View File

@@ -1800,6 +1800,11 @@
"updateStarted": "Update started...",
"reloadingPageAutomatically": "Reloading page automatically...",
"providerTopology": "Provider Topology",
"recentRequests": "Recent Requests",
"recentRequestsEmpty": "No requests yet.",
"recentRequestsModel": "Model",
"recentRequestsTokens": "In / Out",
"recentRequestsWhen": "When",
"downloadDmg": "Download DMG (macOS)",
"downloadDmgDescription": "A new version of the OmniRoute desktop app is available. Please download and install the macOS DMG installer to update (current: v{version}).",
"downloadExe": "Download EXE (Windows)",

View File

@@ -26,7 +26,11 @@ import {
isBcryptHash,
verifyManagementPassword,
} from "@/lib/auth/managementPassword";
import { webSessionCredentialKey, parseProviderSpecificData } from "./webSessionDedup";
import {
webSessionCredentialKey,
parseProviderSpecificData,
isMatchingOauthIdentity,
} from "./webSessionDedup";
import { pickCodexConnectionForUser } from "@/lib/oauth/utils/codexConnectionSelection";
import { reconcileCodexUsageHistory } from "./providers/usageIdentityReconciliation";
@@ -435,30 +439,25 @@ export async function createProviderConnection(data: JsonRecord) {
}
} else {
// For other providers (or Codex without workspaceId), match on email —
// disambiguated by providerSpecificData.username when present on both
// sides. Two different IdPs can share the same email address (e.g. a
// Google account and a HuggingFace account); matching on email alone
// would silently overwrite the other account's connection on the
// second login. Only fall back to the bare email-only match when
// neither side carries a username (legacy rows created before this
// disambiguation existed).
// disambiguated by providerSpecificData.username and/or
// providerSpecificData.profileArn when present on both sides. Two
// different IdPs (or two distinct Kiro/AWS profiles authenticated via
// the same email-carrying IdP) can share the same email address;
// matching on email alone would silently overwrite the other
// account's connection on the second login. Only fall back to the
// bare email-only match when neither side carries a username/profileArn
// (legacy rows created before this disambiguation existed).
const incomingUsername = toStringOrNull(providerSpecificData.username);
const incomingProfileArn = toStringOrNull(providerSpecificData.profileArn);
const emailMatches = db
.prepare(
"SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND email = ?"
)
.all(data.provider, data.email) as JsonRecord[];
existing =
emailMatches.find((row) => {
const existingUsername = toStringOrNull(
parseProviderSpecificData(row.provider_specific_data)?.username
);
if (incomingUsername && existingUsername) {
return incomingUsername === existingUsername;
}
if (incomingUsername || existingUsername) return false;
return true;
}) || null;
emailMatches.find((row) =>
isMatchingOauthIdentity(row, incomingUsername, incomingProfileArn)
) || null;
}
} else if (data.authType === "apikey") {
// Name-based upsert (existing behavior): same provider + same name → update.

View File

@@ -55,3 +55,43 @@ export function parseProviderSpecificData(raw: unknown): Record<string, unknown>
}
return null;
}
/** Trimmed non-empty string, else null — local to avoid a cross-module import for one coercion. */
function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
/**
* Two-sided disambiguator match: `true` when both sides agree, `false` when
* both carry a value and it differs, `undefined` when the field can't decide
* (at most one side carries it) — the caller then defers to other fields.
*/
function fieldMatch(incoming: string | null, existing: string | null): boolean | undefined {
if (incoming && existing) return incoming === existing;
if (incoming || existing) return false;
return undefined;
}
/**
* Decide whether `row` (an existing `provider_connections` record) is the
* same OAuth identity as an incoming connection carrying `incomingUsername`
* and `incomingProfileArn` (#10815).
*
* Two independent disambiguators, either of which can prove "different
* account": `providerSpecificData.username` (Raycast-style IdP dedup) and
* `providerSpecificData.profileArn` (Kiro/AWS profile dedup — Kiro never
* sets `username`). A field only rules a match IN/OUT when both the
* incoming and existing record carry it; when neither carries either field
* the legacy bare-email match still applies unchanged.
*/
export function isMatchingOauthIdentity(
row: { provider_specific_data?: unknown },
incomingUsername: string | null,
incomingProfileArn: string | null
): boolean {
const existingPsd = parseProviderSpecificData(row.provider_specific_data);
const usernameMatch = fieldMatch(incomingUsername, nonEmptyString(existingPsd?.username));
const profileArnMatch = fieldMatch(incomingProfileArn, nonEmptyString(existingPsd?.profileArn));
if (usernameMatch === false || profileArnMatch === false) return false;
return true;
}

View File

@@ -1,6 +1,7 @@
import { skillExecutor } from "./executor";
import { skillRegistry } from "./registry";
import { builtinSkills } from "./builtins";
import { memoryBuiltinHandlers, MEMORY_BUILTIN_TOOL_NAMES } from "./memoryBuiltins";
import { detectProvider, decodeSkillToolName } from "./injection";
import { OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webSearchFallback.ts";
import { OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webFetchInterception.ts";
@@ -32,10 +33,12 @@ const BUILTIN_TOOL_ALIASES: Record<string, string> = {
[OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME]: "web_fetch",
};
const MEMORY_TOOL_NAMES = new Set<string>(MEMORY_BUILTIN_TOOL_NAMES);
function resolveBuiltinHandlerName(
toolName: string,
context: ExecutionContext
): keyof typeof builtinSkills | null {
): keyof typeof builtinSkills | keyof typeof memoryBuiltinHandlers | null {
const [rawName] = toolName.includes("@") ? toolName.split("@") : [toolName];
const canonicalName = BUILTIN_TOOL_ALIASES[rawName] || rawName;
const allowed = new Set(
@@ -46,7 +49,13 @@ function resolveBuiltinHandlerName(
return null;
}
return canonicalName in builtinSkills ? (canonicalName as keyof typeof builtinSkills) : null;
if (canonicalName in builtinSkills) {
return canonicalName as keyof typeof builtinSkills;
}
if (MEMORY_TOOL_NAMES.has(canonicalName)) {
return canonicalName as keyof typeof memoryBuiltinHandlers;
}
return null;
}
function getResponsesOutputContainer(response: Record<string, unknown> | null | undefined): {
@@ -95,12 +104,23 @@ export async function interceptToolCalls(
callId: call.id,
});
const result = await builtinSkills[builtinHandlerName](call.arguments, {
apiKeyId: context.apiKeyId,
sessionId: context.sessionId,
provider: context.provider,
model: context.model,
});
const isMemoryHandler = MEMORY_TOOL_NAMES.has(builtinHandlerName);
const result = isMemoryHandler
? await memoryBuiltinHandlers[
builtinHandlerName as keyof typeof memoryBuiltinHandlers
](call.arguments, {
apiKeyId: context.apiKeyId,
sessionId: context.sessionId,
})
: await builtinSkills[builtinHandlerName as keyof typeof builtinSkills](
call.arguments,
{
apiKeyId: context.apiKeyId,
sessionId: context.sessionId,
provider: context.provider,
model: context.model,
}
);
log.info("skills.interception.execution_complete", {
toolName: call.name,

Some files were not shown because too many files have changed in this diff Show More