mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 06:32:16 +03:00
Compare commits
34 Commits
fix/codeql
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eadbb3f544 | ||
|
|
118840131d | ||
|
|
3fed9e837a | ||
|
|
1f04e73a1c | ||
|
|
f66c986dbd | ||
|
|
3669df2ca5 | ||
|
|
590cbbe7b1 | ||
|
|
c7e264e1a6 | ||
|
|
699be22e1e | ||
|
|
7756aef970 | ||
|
|
050c7c0021 | ||
|
|
c535df9076 | ||
|
|
5d9998eb31 | ||
|
|
19741775ee | ||
|
|
b668d91364 | ||
|
|
4ec080dc19 | ||
|
|
0458c5ac4c | ||
|
|
9603ec1bf1 | ||
|
|
6d043674c2 | ||
|
|
bed4d24049 | ||
|
|
018badc3b3 | ||
|
|
87719f2381 | ||
|
|
0a1f1d42ee | ||
|
|
eb6f319712 | ||
|
|
bc9090ba65 | ||
|
|
d9cb4f5f5d | ||
|
|
ce6249cbb7 | ||
|
|
9935f80971 | ||
|
|
e968d11b1c | ||
|
|
7afafcecc9 | ||
|
|
c79faa45fb | ||
|
|
871832820f | ||
|
|
d87b97a786 | ||
|
|
142ae93498 |
16
.env.example
16
.env.example
@@ -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
|
||||
|
||||
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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 |
|
||||
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -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
|
||||
|
||||
14
README.md
14
README.md
@@ -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 15–95% 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 15–95% 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 & 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 15–95%, ~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 15–95%, ~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'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's docs."/>
|
||||
|
||||
<sub>📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & 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">
|
||||
|
||||
|
||||
2
changelog.d/features/10668-newapi-gateway-protocols.md
Normal file
2
changelog.d/features/10668-newapi-gateway-protocols.md
Normal 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
|
||||
1
changelog.d/features/10896-glm-5.3.md
Normal file
1
changelog.d/features/10896-glm-5.3.md
Normal 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
|
||||
1
changelog.d/features/10897-home-recent-requests.md
Normal file
1
changelog.d/features/10897-home-recent-requests.md
Normal 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
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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))
|
||||
1
changelog.d/fixes/10536-llmlingua-2-2.0.5-drop-tfjs.md
Normal file
1
changelog.d/fixes/10536-llmlingua-2-2.0.5-drop-tfjs.md
Normal 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))
|
||||
@@ -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)
|
||||
1
changelog.d/fixes/10597-combo-log-error-body.md
Normal file
1
changelog.d/fixes/10597-combo-log-error-body.md
Normal 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))
|
||||
1
changelog.d/fixes/10765-rtk-unconditional-stats-cpu.md
Normal file
1
changelog.d/fixes/10765-rtk-unconditional-stats-cpu.md
Normal 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)
|
||||
1
changelog.d/fixes/10788-ollama-cloud-effort-tiers.md
Normal file
1
changelog.d/fixes/10788-ollama-cloud-effort-tiers.md
Normal 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)
|
||||
1
changelog.d/fixes/10815-kiro-oauth-profilearn-dedup.md
Normal file
1
changelog.d/fixes/10815-kiro-oauth-profilearn-dedup.md
Normal 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)
|
||||
1
changelog.d/fixes/10848-image-scan-cookie-bridge.md
Normal file
1
changelog.d/fixes/10848-image-scan-cookie-bridge.md
Normal 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)
|
||||
1
changelog.d/fixes/10849-search-provider-opaque-400.md
Normal file
1
changelog.d/fixes/10849-search-provider-opaque-400.md
Normal 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)
|
||||
@@ -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)
|
||||
1
changelog.d/fixes/10887-memory-mcp-tools.md
Normal file
1
changelog.d/fixes/10887-memory-mcp-tools.md
Normal 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
|
||||
1
changelog.d/fixes/10902-pplx-search-hint-optin.md
Normal file
1
changelog.d/fixes/10902-pplx-search-hint-optin.md
Normal 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
|
||||
1
changelog.d/fixes/claude-to-gemini-consecutive-roles.md
Normal file
1
changelog.d/fixes/claude-to-gemini-consecutive-roles.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(translator):** merge consecutive same-role contents in direct Claude to Gemini request translation to prevent upstream HTTP 400 errors
|
||||
1
changelog.d/fixes/minimax-music-generation-dispatch.md
Normal file
1
changelog.d/fixes/minimax-music-generation-dispatch.md
Normal 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).
|
||||
@@ -20,7 +20,6 @@
|
||||
"@stryker-mutator/tap-runner",
|
||||
"@swc/helpers",
|
||||
"@tailwindcss/postcss",
|
||||
"@tensorflow/tfjs",
|
||||
"@testing-library/jest-dom",
|
||||
"@testing-library/react",
|
||||
"@toon-format/toon",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
350
docs/architecture/ADAPTIVE_ROUTING.md
Normal file
350
docs/architecture/ADAPTIVE_ROUTING.md
Normal 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.
|
||||
@@ -12,6 +12,7 @@
|
||||
"ROUTER_BACKENDS",
|
||||
"admission-lanes",
|
||||
"cluster-decisions",
|
||||
"persistence-backend-boundary"
|
||||
"persistence-backend-boundary",
|
||||
"ADAPTIVE_ROUTING"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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ą runtime’y 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 runtime’y transformers.js + onnxruntime-node oraz model TinyBERT (~57 MB) pobierany
|
||||
przy pierwszym użyciu (nie przez npm).
|
||||
|
||||
Per środowisko:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 權杖路徑,迴環介面)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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). |
|
||||
|
||||
8
llm.txt
8
llm.txt
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
66
open-sse/config/providers/registry/mlx/index.ts
Normal file
66
open-sse/config/providers/registry/mlx/index.ts
Normal 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
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
59
open-sse/config/providers/registry/tabitoken/index.ts
Normal file
59
open-sse/config/providers/registry/tabitoken/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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" },
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 || ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
358
open-sse/handlers/mediaGeneration/minimaxMusic.ts
Normal file
358
open-sse/handlers/mediaGeneration/minimaxMusic.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
220
open-sse/services/routing/events.ts
Normal file
220
open-sse/services/routing/events.ts
Normal 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";
|
||||
}
|
||||
133
open-sse/services/routing/index.ts
Normal file
133
open-sse/services/routing/index.ts
Normal 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";
|
||||
227
open-sse/services/routing/otel.ts
Normal file
227
open-sse/services/routing/otel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
313
open-sse/services/routing/quality.ts
Normal file
313
open-sse/services/routing/quality.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
77
open-sse/utils/directResponseStartTimeout.ts
Normal file
77
open-sse/utils/directResponseStartTimeout.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
83
open-sse/utils/streamTiming.ts
Normal file
83
open-sse/utils/streamTiming.ts
Normal 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
367
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>");
|
||||
|
||||
175
scripts/perf/routing-events-bench.ts
Normal file
175
scripts/perf/routing-events-bench.ts
Normal 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");
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
207
src/app/(dashboard)/home/HomeRecentRequests.tsx
Normal file
207
src/app/(dashboard)/home/HomeRecentRequests.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
72
src/app/api/v1/explain/routing/route.ts
Normal file
72
src/app/api/v1/explain/routing/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user