From cabbbe410ac79a0b2e9fd86c300c337db67a9a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armin=20Anton=E2=80=9D=20=E2=88=B4?= Date: Tue, 1 Sep 2026 21:55:42 -0700 Subject: [PATCH 01/34] =?UTF-8?q?feat(providers):=20add=20MaxAI=20?= =?UTF-8?q?=E2=80=94=20signed=20OpenAI-compatible=20provider=20(chat,=20to?= =?UTF-8?q?ols,=20vision,=20image-gen,=20doc-RAG)=20(#11461)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MaxAI joins as a first-class signed provider: 13 chat models discovered live from /models/get_config plus 6 image models, routed through the standard /v1 endpoints with per-request X-Authorization signing, browserless onboarding, prompted tool-calling, vision input, image generation and document RAG. Reconciled on merge — worth reading, because the branch forked 227 commits back and 77 files conflicted. Only five carried MaxAI content; the rest was drift from the older release line and took the tip's side, taking the diff from 113 files to 37 (then 93 as counted against the current base). - executors/index.ts: the tip has since refactored the executor map to lazy dynamic imports, so MaxAI is registered in that shape rather than the branch's static import. - imageRegistry.ts: kept only the maxai block. The branch still carried microsoft-designer-web, which #11754 retired. - models/route.ts: the conflicting hunk was an unrelated Vertex/Anthropic URL change, not MaxAI — tip's side. - volcengine agent-plan/coding-plan registries: git auto-merged both sides and produced a duplicated supportsVision key, which TypeScript rejects (TS1117). Removed. One real integration break that only the combined state shows: the MaxAI entry declared no serviceKinds, which #11392 made required a few hours ago. Provider validation threw at load time and check:provider-consistency crashed outright. Declared ["llm"] — the image kinds derive from imageRegistry, per the convention in that PR's backfill. Every count was measured rather than taken from the branch, and each would have been wrong: reserved prefixes are 402, not the 397 the branch computed from its stale 395 base; providers are 353, not 354. PROVIDER_REFERENCE.md regenerated, the count updated across README/AGENTS.md/llm.txt and its 42 mirrors, package.json and 6 SVGs — every changed line in those files is a digit substitution and nothing else, verified by masking digits and comparing the removed and added sets (90 lines, identical). The executor-map golden snapshot was regenerated: keyCount 133 -> 134. The branch's file-size-baseline.json predates #12411's ratchet re-tightening, so it was discarded rather than merged — taking it would have silently undone that. The three files this PR grows (proxyFetch.ts +20 for the Windows/firefox_150 TLS profile, imageGeneration.ts +12, models/route.ts +48) were entered against the current baseline under one _rebaseline annotation; no other cap moves. Verified: typecheck:core clean, check:provider-consistency OK (269 REGISTRY entries, 353 canonical providers), check:docs-counts exit 0, check-file-size OK, check:cycles OK, and 79/79 across the MaxAI suites plus 21/21 reserved-prefix and 2/2 executor-map-golden. Thanks @arminanton — the provider work itself is thorough; it was the 227 commits of base that needed the attention. --- AGENTS.md | 2 +- README.md | 6 +- changelog.d/features/maxai-provider.md | 6 + config/quality/eslint-suppressions.json | 2 +- config/quality/file-size-baseline.json | 7 +- docs/diagrams/cli-terminal.svg | 2 +- docs/diagrams/comparison-table.svg | 2 +- docs/diagrams/promise-pillars.svg | 6 +- docs/diagrams/readme-hero.svg | 4 +- docs/i18n/ar/llm.txt | 4 +- docs/i18n/az/llm.txt | 4 +- docs/i18n/bg/llm.txt | 4 +- docs/i18n/bn/llm.txt | 4 +- docs/i18n/cs/llm.txt | 4 +- docs/i18n/da/llm.txt | 4 +- docs/i18n/de/llm.txt | 4 +- docs/i18n/es/llm.txt | 4 +- docs/i18n/fa/llm.txt | 4 +- docs/i18n/fi/llm.txt | 4 +- docs/i18n/fr/llm.txt | 4 +- docs/i18n/gu/llm.txt | 4 +- docs/i18n/he/llm.txt | 4 +- docs/i18n/hi/llm.txt | 4 +- docs/i18n/hu/llm.txt | 4 +- docs/i18n/id/llm.txt | 4 +- docs/i18n/in/llm.txt | 4 +- docs/i18n/it/llm.txt | 4 +- docs/i18n/ja/llm.txt | 4 +- docs/i18n/ko/llm.txt | 4 +- docs/i18n/mr/llm.txt | 4 +- docs/i18n/ms/llm.txt | 4 +- docs/i18n/nl/llm.txt | 4 +- docs/i18n/no/llm.txt | 4 +- docs/i18n/phi/llm.txt | 4 +- docs/i18n/pl/llm.txt | 4 +- docs/i18n/pt-BR/llm.txt | 4 +- docs/i18n/pt/llm.txt | 4 +- docs/i18n/ro/llm.txt | 4 +- docs/i18n/ru/llm.txt | 4 +- docs/i18n/sk/llm.txt | 4 +- docs/i18n/sv/llm.txt | 4 +- docs/i18n/sw/llm.txt | 4 +- docs/i18n/ta/llm.txt | 4 +- docs/i18n/te/llm.txt | 4 +- docs/i18n/th/llm.txt | 4 +- docs/i18n/tr/llm.txt | 4 +- docs/i18n/uk-UA/llm.txt | 4 +- docs/i18n/ur/llm.txt | 4 +- docs/i18n/vi/llm.txt | 4 +- docs/i18n/zh-CN/llm.txt | 4 +- docs/i18n/zh-TW/llm.txt | 4 +- docs/reference/PROVIDER_REFERENCE.md | 11 +- llm.txt | 4 +- open-sse/config/imageRegistry.ts | 20 + open-sse/config/providers/index.ts | 2 + .../config/providers/registry/maxai/index.ts | 26 + .../registry/volcengine/agent-plan/index.ts | 2 +- .../registry/volcengine/coding-plan/index.ts | 2 +- open-sse/executors/index.ts | 1 + open-sse/executors/maxai.ts | 620 ++++++++++ open-sse/executors/maxai/catalog.ts | 76 ++ open-sse/executors/maxai/constants.ts | 427 +++++++ open-sse/executors/maxai/constantsStore.ts | 156 +++ open-sse/executors/maxai/credentials.ts | 96 ++ open-sse/executors/maxai/documents.ts | 266 ++++ open-sse/executors/maxai/emailLogin.ts | 234 ++++ open-sse/executors/maxai/protocol.ts | 266 ++++ open-sse/executors/maxai/refresh.ts | 149 +++ open-sse/executors/maxai/signing.ts | 151 +++ open-sse/executors/maxai/stream.ts | 101 ++ open-sse/handlers/imageGeneration.ts | 12 + .../imageGeneration/providers/maxaiImage.ts | 230 ++++ open-sse/services/maxaiModels.ts | 172 +++ open-sse/services/rateLimitManager.ts | 20 +- open-sse/utils/proxyFetch.ts | 20 + package.json | 2 +- public/images/tier-flow-dark.svg | 6 +- public/images/tier-flow-light.svg | 6 +- scripts/build/pack-artifact-policy.ts | 6 + scripts/check/check-provider-assets.mjs | 2 +- src/app/api/providers/[id]/login/route.ts | 145 +++ src/app/api/providers/[id]/models/route.ts | 48 + src/shared/constants/providers/web-cookie.ts | 21 + src/shared/providers/webSessionCredentials.ts | 16 + stryker.conf.json | 2 + tests/snapshots/executors/executor-map.json | 7 +- tests/snapshots/provider/translate-path.json | 23 + tests/unit/helpers/maxaiMockConstants.ts | 122 ++ tests/unit/maxai-documents.test.ts | 218 ++++ tests/unit/maxai-image.test.ts | 169 +++ tests/unit/maxai.test.ts | 1088 +++++++++++++++++ .../provider-node-reserved-prefix.test.ts | 2 +- .../ratelimit-admission-control-6593.test.ts | 12 + 93 files changed, 5042 insertions(+), 122 deletions(-) create mode 100644 changelog.d/features/maxai-provider.md create mode 100644 open-sse/config/providers/registry/maxai/index.ts create mode 100644 open-sse/executors/maxai.ts create mode 100644 open-sse/executors/maxai/catalog.ts create mode 100644 open-sse/executors/maxai/constants.ts create mode 100644 open-sse/executors/maxai/constantsStore.ts create mode 100644 open-sse/executors/maxai/credentials.ts create mode 100644 open-sse/executors/maxai/documents.ts create mode 100644 open-sse/executors/maxai/emailLogin.ts create mode 100644 open-sse/executors/maxai/protocol.ts create mode 100644 open-sse/executors/maxai/refresh.ts create mode 100644 open-sse/executors/maxai/signing.ts create mode 100644 open-sse/executors/maxai/stream.ts create mode 100644 open-sse/handlers/imageGeneration/providers/maxaiImage.ts create mode 100644 open-sse/services/maxaiModels.ts create mode 100644 tests/unit/helpers/maxaiMockConstants.ts create mode 100644 tests/unit/maxai-documents.test.ts create mode 100644 tests/unit/maxai-image.test.ts create mode 100644 tests/unit/maxai.test.ts diff --git a/AGENTS.md b/AGENTS.md index 08adf6d8d3..30c2f8b299 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below. ## Project at a Glance -**OmniRoute** — unified AI proxy/router. One endpoint, 352 LLM providers, auto-fallback. +**OmniRoute** — unified AI proxy/router. One endpoint, 353 LLM providers, auto-fallback. | Layer | Location | Purpose | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/README.md b/README.md index 5431c2459f..5d35b64c08 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ # 🚀 OmniRoute — The Free AI Gateway -OmniRoute — Never stop coding. Every AI tool → 352 providers — 150+ 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. 352 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. +OmniRoute — Never stop coding. Every AI tool → 353 providers — 150+ 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. 353 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. @@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \ -The Promise — One endpoint and 352 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 352 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files. +The Promise — One endpoint and 353 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 353 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files.

@@ -463,7 +463,7 @@ All **19** strategies — mix & match per combo step: -What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 352 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 43 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. +What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 353 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 43 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. 📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) diff --git a/changelog.d/features/maxai-provider.md b/changelog.d/features/maxai-provider.md new file mode 100644 index 0000000000..4e74854b27 --- /dev/null +++ b/changelog.d/features/maxai-provider.md @@ -0,0 +1,6 @@ +- **feat(providers):** add MaxAI as a signed, OpenAI-compatible provider serving its 13 paid chat models (GPT-5.6 / Luna / Thinking, Claude 5 Sonnet, Claude Haiku 4.5, Gemini 3.1 Pro / Flash-Lite, Grok 4.1-fast / 4.5, DeepSeek V3.2 / R1, Llama 3.3 70B) through OmniRoute's `/v1` endpoint, with per-request HMAC-SHA1→SM3→AES request signing, live model + context-window discovery from `/models/get_config`, and prompted tool-calling translated to OpenAI `tool_calls` +- **feat(providers):** MaxAI vision input — image_url content parts are forwarded inline in `message_content` to the 6 vision-capable models (GPT-5.6 / Luna / Thinking, Claude Haiku 4.5, Gemini 3.1 Pro / Flash-Lite) +- **feat(providers):** MaxAI image generation — 6 image models (gpt-image-1, dall-e-3, flux-1-schnell/dev/pro, sd3-medium) exposed through `POST /v1/images/generations` +- **feat(providers):** MaxAI document RAG — inline base64 file/document attachments are uploaded to MaxAI (content-addressed `doc_id`) and attached to the chat via `doc_list` +- **feat(providers):** browserless MaxAI onboarding — email device-pair login (`/api/providers/[id]/login`) and signed access-token refresh, so a connection can be created and kept fresh without a real browser or Google OAuth +- **feat(providers):** per-provider TLS impersonation profile (MaxAI presents a Windows Firefox-150 client fingerprint) so its bot-sensitive endpoints accept OmniRoute traffic diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 8250ba6936..c7fd7e1b9a 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -3373,7 +3373,7 @@ }, "tests/unit/combo-routing-engine.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 267 + "count": 268 } }, "tests/unit/combo-same-provider-cascade.test.ts": { diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 3a75185a68..04342aff2d 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_02_11461_maxai_tls_profile": "PR #11461 (arminanton, feat/maxai-provider) own growth, three files at existing per-provider chokepoints: open-sse/utils/proxyFetch.ts 1241->1261 (+20, the TLS_PROVIDER_PROFILE map giving MaxAI a Windows/firefox_150 impersonation profile instead of the tlsClient chrome_124/macos default); open-sse/handlers/imageGeneration.ts 3231->3243 (+12, the maxai-image format branch); src/app/api/providers/[id]/models/route.ts 2381->2429 (+48, live model listing via maxaiModels). Additive data, same no-split rationale as _rebaseline_2026_08_20_10531_freebuff_provider.", "_rebaseline_2026_09_02_11460_flat_rate_estimates": "PR #11460 (xiaoyaner0201, fix/11459-cc-cost-estimates) own growth: src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx 1283->1319 (+36) — the flat-rate estimate labelling and the includeFlatRateEstimates opt-in on the Costs dashboard. #11460 merged first so this ratchet re-tightening measures the real post-merge LOC; the cap still drops 2002->1319 (-683) versus the 2026-08-10 +30% loosening this PR reverses. Same own-growth rationale as _rebaseline_2026_08_20_10531_freebuff_provider.", "_rebaseline_2026_08_31_chatgpt_web_v4_vendor": "Pinned MIT vendor refresh from codex-chatgpt-web 0.1.16 to v4.0.6 (commit 09877fa21ffdbf20979623ef501046fc02a750d7). browser-worker.ts is preserved as the reviewed upstream browser protocol implementation; splitting the vendored file would destroy source parity and make future security/liveness updates unauditable. OmniRoute-specific DATA_DIR, Docker CDP, credential-marker, and XML decoding adaptations are covered by the ChatGPT Web Codex focused suite.", "_rebaseline_2026_08_20_10531_freebuff_provider": "PR #10531 (adrianaryaputra, feat/freebuff-provider-support, closes #6793) own growth: src/shared/constants/providers/apikey/gateways.ts 1283->1298 (+15, the freebuff APIKEY_PROVIDERS_GATEWAYS catalog entry, additive data at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines) and src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx 1062->1067 (+5, freebuff credential placeholder/hint at the existing per-provider switch chokepoint). Covered by tests/unit/freebuff-provider.test.ts (9/9 passing).", @@ -406,7 +407,7 @@ "open-sse/executors/cursor.ts": 1759, "open-sse/executors/muse-spark-web.ts": 1405, "open-sse/handlers/chatCore.ts": 5946, - "open-sse/handlers/imageGeneration.ts": 3231, + "open-sse/handlers/imageGeneration.ts": 3243, "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, "open-sse/mcp-server/server.ts": 1572, @@ -415,7 +416,7 @@ "open-sse/services/combo.ts": 4023, "open-sse/translator/response/openai-responses.ts": 1466, "open-sse/utils/cursorAgentProtobuf.ts": 1547, - "open-sse/utils/proxyFetch.ts": 1241, + "open-sse/utils/proxyFetch.ts": 1261, "open-sse/utils/stream.ts": 3072, "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1322, @@ -432,7 +433,7 @@ "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1606, "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1597, "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2152, - "src/app/api/providers/[id]/models/route.ts": 2381, + "src/app/api/providers/[id]/models/route.ts": 2429, "src/app/api/providers/[id]/test/route.ts": 1252, "src/app/api/v1/models/catalog.ts": 2066, "src/app/docs/lib/openapi.generated.ts": 1347, diff --git a/docs/diagrams/cli-terminal.svg b/docs/diagrams/cli-terminal.svg index 41023d868d..92cb473457 100644 --- a/docs/diagrams/cli-terminal.svg +++ b/docs/diagrams/cli-terminal.svg @@ -1,4 +1,4 @@ - + Compact animated terminal cycling three real OmniRoute CLI commands with a typewriter effect and a scrolling subcommand ticker; the first frame shows the completed providers-list screen. diff --git a/docs/diagrams/comparison-table.svg b/docs/diagrams/comparison-table.svg index bfdc3ea240..71992cc04a 100644 --- a/docs/diagrams/comparison-table.svg +++ b/docs/diagrams/comparison-table.svg @@ -1,4 +1,4 @@ - + Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses. diff --git a/docs/diagrams/promise-pillars.svg b/docs/diagrams/promise-pillars.svg index 6e037e5098..9466b0c859 100644 --- a/docs/diagrams/promise-pillars.svg +++ b/docs/diagrams/promise-pillars.svg @@ -1,4 +1,4 @@ - + Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle. @@ -21,7 +21,7 @@ - One endpoint. 352 providers. Never stop building — OmniRoute picks the cheapest one that works. + One endpoint. 353 providers. Never stop building — OmniRoute picks the cheapest one that works. @@ -38,7 +38,7 @@ Never hit limits - Auto-fallback across 352 providers in + Auto-fallback across 353 providers in milliseconds. Quota out? The next provider takes over while a healthy target remains. diff --git a/docs/diagrams/readme-hero.svg b/docs/diagrams/readme-hero.svg index 1c49b21b2e..6d4c7ba9bf 100644 --- a/docs/diagrams/readme-hero.svg +++ b/docs/diagrams/readme-hero.svg @@ -1,4 +1,4 @@ - + Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame. @@ -28,7 +28,7 @@ Never stop coding. - Every AI tool → 352 providers150+ free — through one endpoint. + Every AI tool → 353 providers150+ free — through one endpoint. Claude Code · Codex · Cursor · Cline · Copilot · Antigravity  →  FREE Claude / GPT / Gemini · auto-fallback diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index 548eefbf3b..c1e7abe59c 100644 --- a/docs/i18n/ar/llm.txt +++ b/docs/i18n/ar/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 6bc2a099db..5ef9e5f2fe 100644 --- a/docs/i18n/az/llm.txt +++ b/docs/i18n/az/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index 6bc2a099db..5ef9e5f2fe 100644 --- a/docs/i18n/bg/llm.txt +++ b/docs/i18n/bg/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index 90e06508b6..ccdb9b8013 100644 --- a/docs/i18n/bn/llm.txt +++ b/docs/i18n/bn/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index d954459b13..ac38750608 100644 --- a/docs/i18n/cs/llm.txt +++ b/docs/i18n/cs/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index 9230d437d3..b4653489b6 100644 --- a/docs/i18n/da/llm.txt +++ b/docs/i18n/da/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index 3c7b5b303a..88b776ad66 100644 --- a/docs/i18n/de/llm.txt +++ b/docs/i18n/de/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index a16e64035c..cef74db964 100644 --- a/docs/i18n/es/llm.txt +++ b/docs/i18n/es/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index 00d95daeae..651c65dcd0 100644 --- a/docs/i18n/fa/llm.txt +++ b/docs/i18n/fa/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index 5c59495a6a..fa001b3f5b 100644 --- a/docs/i18n/fi/llm.txt +++ b/docs/i18n/fi/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index a285637e33..98bbf3cffe 100644 --- a/docs/i18n/fr/llm.txt +++ b/docs/i18n/fr/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index 34d83e829b..d6b23b4a6f 100644 --- a/docs/i18n/gu/llm.txt +++ b/docs/i18n/gu/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index 416da4c84b..34fdbd6c37 100644 --- a/docs/i18n/he/llm.txt +++ b/docs/i18n/he/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index 9393dd87eb..e9330bcbc1 100644 --- a/docs/i18n/hi/llm.txt +++ b/docs/i18n/hi/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index 20f32976c5..c2e73a6d51 100644 --- a/docs/i18n/hu/llm.txt +++ b/docs/i18n/hu/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index ea80bb4578..339089ffa0 100644 --- a/docs/i18n/id/llm.txt +++ b/docs/i18n/id/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/in/llm.txt b/docs/i18n/in/llm.txt index 8033e3fa82..9d403264be 100644 --- a/docs/i18n/in/llm.txt +++ b/docs/i18n/in/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index e2df9cc115..2b99808639 100644 --- a/docs/i18n/it/llm.txt +++ b/docs/i18n/it/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index ab95c8bc0c..cd750e07fd 100644 --- a/docs/i18n/ja/llm.txt +++ b/docs/i18n/ja/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index 9658b42bb0..fa37857775 100644 --- a/docs/i18n/ko/llm.txt +++ b/docs/i18n/ko/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index 48a94b897c..15c7b22545 100644 --- a/docs/i18n/mr/llm.txt +++ b/docs/i18n/mr/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index c7dc286a2f..0671482309 100644 --- a/docs/i18n/ms/llm.txt +++ b/docs/i18n/ms/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index 8cab222517..7a2b769983 100644 --- a/docs/i18n/nl/llm.txt +++ b/docs/i18n/nl/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index b209c8c81e..b4a5eb0f44 100644 --- a/docs/i18n/no/llm.txt +++ b/docs/i18n/no/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index cda4f3ec19..6286537b20 100644 --- a/docs/i18n/phi/llm.txt +++ b/docs/i18n/phi/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index f2c24807ba..6d4bd07b83 100644 --- a/docs/i18n/pl/llm.txt +++ b/docs/i18n/pl/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index 1d0e7c7572..d64cc39343 100644 --- a/docs/i18n/pt-BR/llm.txt +++ b/docs/i18n/pt-BR/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index ed8d0f33b5..3d6d434382 100644 --- a/docs/i18n/pt/llm.txt +++ b/docs/i18n/pt/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index 945d07ef04..b99a4536cb 100644 --- a/docs/i18n/ro/llm.txt +++ b/docs/i18n/ro/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index 795d39530a..6d3e7c07c9 100644 --- a/docs/i18n/ru/llm.txt +++ b/docs/i18n/ru/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index a96f49dbc5..722056455f 100644 --- a/docs/i18n/sk/llm.txt +++ b/docs/i18n/sk/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index 5270a3acf4..56f8047049 100644 --- a/docs/i18n/sv/llm.txt +++ b/docs/i18n/sv/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index 166ecff735..c72f695bde 100644 --- a/docs/i18n/sw/llm.txt +++ b/docs/i18n/sw/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index 3996aa166c..47fb44c802 100644 --- a/docs/i18n/ta/llm.txt +++ b/docs/i18n/ta/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index c0a319e444..86c6e44622 100644 --- a/docs/i18n/te/llm.txt +++ b/docs/i18n/te/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index 068975ff6a..3ee4254f1c 100644 --- a/docs/i18n/th/llm.txt +++ b/docs/i18n/th/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index 0ff720ab5b..538a1d9cc3 100644 --- a/docs/i18n/tr/llm.txt +++ b/docs/i18n/tr/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index 7030db01be..21b67bfc86 100644 --- a/docs/i18n/uk-UA/llm.txt +++ b/docs/i18n/uk-UA/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index ee39faa930..0f881c7b4b 100644 --- a/docs/i18n/ur/llm.txt +++ b/docs/i18n/ur/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index cc75542ed9..6a0ec2cdf1 100644 --- a/docs/i18n/vi/llm.txt +++ b/docs/i18n/vi/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index 969e3af8b1..6953a90999 100644 --- a/docs/i18n/zh-CN/llm.txt +++ b/docs/i18n/zh-CN/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt index c1275ba043..e081e5c730 100644 --- a/docs/i18n/zh-TW/llm.txt +++ b/docs/i18n/zh-TW/llm.txt @@ -4,7 +4,7 @@ --- -> 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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 99d2a4f39d..92b0e82373 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,16 +1,16 @@ --- title: "Provider Reference" version: 3.8.51 -lastUpdated: 2026-08-30 +lastUpdated: 2026-09-02 --- # Provider Reference > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` -> **Last generated:** 2026-08-30 +> **Last generated:** 2026-09-02 -Total providers: **352**. See category breakdown below. +Total providers: **353**. See category breakdown below. ## Categories @@ -80,7 +80,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `zed` | `zd` | Zed IDE | OAuth | [link](https://zed.dev) | Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically. | | `zed-hosted` | — | Zed Hosted Models | OAuth | [link](https://zed.dev) | Sign in with your Zed account (native-app sign-in). OmniRoute generates a one-time RSA keypair and opens zed.dev to authorize it — on a remote/headless install, copy the resulting 127.0.0.1 callback URL from your browser's address bar and paste it back here. Distinct from the 'Zed IDE' credential-import entry above: this proxies chat completions through Zed's own hosted model aggregator (cloud.zed.dev), fronting Anthropic/OpenAI/Google/xAI models under your Zed plan. | -## Web Cookie Providers (31) +## Web Cookie Providers (32) | ID | Alias | Name | Tags | Website | Notes | Tool calling | |----|-------|------|------|---------|-------|--------------| @@ -102,6 +102,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `inner-ai` | `in-ai` | Inner.ai (Subscription) | Web cookie | [link](https://app.innerai.com) | Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com | emulated | | `kimi-web` | `kimi-web` | Kimi Web | Web cookie | [link](https://www.kimi.ai) | Paste access_token from www.kimi.ai DevTools → Application → Local Storage. A legacy kimi-auth cookie is also accepted. | — | | `lmarena` | `lma` | Arena (Free) | Web cookie | [link](https://arena.ai) | Paste the full Cookie header from arena.ai (DevTools → Network → request → Cookie). Include arena-auth-prod-v1.0/.1… and cf_clearance/__cf_bm when present. OmniRoute uses Chrome TLS impersonation; if Arena still 403s, set providerSpecificData.recaptchaV3Token from a live browser session. | — | +| `maxai` | `mx` | MaxAI | Web cookie | [link](https://www.maxai.co) | Sign in once (email code or browser) to mint a MaxAI access token. OmniRoute signs each request, routes it through residential egress, and refreshes the token browserlessly, so a connection stays valid for about a year without re-login. | emulated | | `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess cookie AND the ecto1:... WS auth token from meta.ai. Capture the ecto1: token in DevTools → Network → WS → the clippy request's Authorization query param. Example: ecto_1_sess=4240a308...NVDg0; ecto1:ABCD... | emulated | | `notion-web` | `nw` | Notion AI Web (Unofficial/Experimental) | Web cookie | [link](https://www.notion.so) | Paste only the token_v2 cookie VALUE from app.notion.com (DevTools → Application → Cookies → token_v2). Do not paste token_v2= or the full Cookie header. Workspace is auto-detected; space_id / notion_user_id are optional. | — | | `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your __Secure-next-auth.session-token cookie value from perplexity.ai | emulated | @@ -440,7 +441,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each - Catalog: [`src/shared/constants/providers.ts`](../../src/shared/constants/providers.ts) - Registry (per-model details): [`open-sse/config/providerRegistry.ts`](../../open-sse/config/providerRegistry.ts) -- Executors: [`open-sse/executors/`](../../open-sse/executors/) (104 implementations) +- Executors: [`open-sse/executors/`](../../open-sse/executors/) (107 implementations) - Translators: [`open-sse/translator/`](../../open-sse/translator/) ## See Also diff --git a/llm.txt b/llm.txt index 9c60de9919..907184c88a 100644 --- a/llm.txt +++ b/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 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -277,7 +277,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 85531b1f84..8af67947ce 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -256,6 +256,26 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1792", "1792x1024", "1024x1536", "1536x1024"], }, + maxai: { + id: "maxai", + alias: "mx", + baseUrl: "https://api.maxai.me/gpt/get_image_generate_response", + authType: "apikey", + authHeader: "bearer", + format: "maxai-image", + models: [ + { id: "gpt-image-1", name: "GPT Image 1 (MaxAI)" }, + { id: "dall-e-3", name: "DALL-E 3 (MaxAI)" }, + { id: "flux-1-schnell", name: "FLUX.1 [schnell] (MaxAI)" }, + { id: "flux-1-dev", name: "FLUX.1 [dev] (MaxAI)" }, + { id: "flux-1-pro", name: "FLUX.1 [pro] (MaxAI)" }, + { id: "sd3-medium", name: "Stable Diffusion 3 Medium (MaxAI)" }, + ], + // gpt-image-1/dall-e-3 are size-snapped to 1024x1024 by the handler; flux + // models pass any size through. + supportedSizes: ["1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"], + }, + xai: { id: "xai", baseUrl: "https://api.x.ai/v1/images/generations", diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index cc8ec3a703..b3cf60b463 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -210,6 +210,7 @@ import { pollinationsProvider } from "./registry/pollinations/index.ts"; import { veoaifree_webProvider } from "./registry/veoaifree-web/index.ts"; import { codexProvider } from "./registry/codex/index.ts"; import { codexAppServerProvider } from "./registry/codex-app-server/index.ts"; +import { maxaiProvider } from "./registry/maxai/index.ts"; import { veniceProvider } from "./registry/venice/index.ts"; import { kiroProvider } from "./registry/kiro/index.ts"; import { openadapterProvider } from "./registry/openadapter/index.ts"; @@ -477,6 +478,7 @@ export const REGISTRY: Record = { "veoaifree-web": veoaifree_webProvider, codex: codexProvider, "codex-app-server": codexAppServerProvider, + maxai: maxaiProvider, venice: veniceProvider, kiro: kiroProvider, byteplus: byteplusProvider, diff --git a/open-sse/config/providers/registry/maxai/index.ts b/open-sse/config/providers/registry/maxai/index.ts new file mode 100644 index 0000000000..d35a023e49 --- /dev/null +++ b/open-sse/config/providers/registry/maxai/index.ts @@ -0,0 +1,26 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { MAXAI_REGISTRY_MODELS } from "../../../../executors/maxai/catalog.ts"; + +/** + * MaxAI — the MaxAI web app (chat.maxai.co / api.maxai.me) as an OpenAI-compatible + * provider. A signed web-app port (like zai-web): each request carries a + * per-request `X-Authorization` signature + a Bearer access token minted by the + * browser-mint flow. Runs over residential egress with a Firefox TLS fingerprint. + * + * authType `apikey`/authHeader `bearer`: the OpenAI-style access token is stored + * on the connection and replayed as `Authorization: Bearer`; the device id + + * user id ride in providerSpecificData and are folded into the signature. The + * token is refreshed out-of-band by the browser-mint (the `/oauth` refresh + * endpoint is deep-TLS-gated), so there is no central token-refresh case. + */ +export const maxaiProvider: RegistryEntry = { + id: "maxai", + alias: "mx", + format: "openai", + executor: "maxai", + baseUrl: "https://api.maxai.me", + authType: "apikey", + authHeader: "bearer", + defaultContextLength: 128000, + models: MAXAI_REGISTRY_MODELS, +}; diff --git a/open-sse/config/providers/registry/volcengine/agent-plan/index.ts b/open-sse/config/providers/registry/volcengine/agent-plan/index.ts index 3ecae6aa08..6604844efd 100644 --- a/open-sse/config/providers/registry/volcengine/agent-plan/index.ts +++ b/open-sse/config/providers/registry/volcengine/agent-plan/index.ts @@ -78,8 +78,8 @@ export const VOLCENGINE_AGENT_PLAN_MODELS: RegistryModel[] = [ name: "MiniMax M3 (Agent Plan)", contextLength: 1048576, toolCalling: true, - supportsReasoning: true, supportsVision: true, + supportsReasoning: true, }, { id: "deepseek-v4-pro-260425", diff --git a/open-sse/config/providers/registry/volcengine/coding-plan/index.ts b/open-sse/config/providers/registry/volcengine/coding-plan/index.ts index f4864c9b75..49c2788b7d 100644 --- a/open-sse/config/providers/registry/volcengine/coding-plan/index.ts +++ b/open-sse/config/providers/registry/volcengine/coding-plan/index.ts @@ -54,8 +54,8 @@ export const VOLCENGINE_CODING_PLAN_MODELS: RegistryModel[] = [ name: "MiniMax M3 (Coding Plan)", contextLength: 1048576, toolCalling: true, - supportsReasoning: true, supportsVision: true, + supportsReasoning: true, }, { id: "deepseek-v4-pro", diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index fbe9940d80..c33ca48839 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -44,6 +44,7 @@ const lazyExecutors: Record Promise> = { import("./codex-app-server.ts").then( (m) => new m.CodexAppServerExecutor({}, "codex-app-server") ), + maxai: () => import("./maxai.ts").then((m) => new m.MaxAiExecutor()), "chatgpt-web-codex": () => import("./chatgpt-web-codex.ts").then((m) => new m.ChatGptWebCodexExecutor()), "cgpt-codex": () => diff --git a/open-sse/executors/maxai.ts b/open-sse/executors/maxai.ts new file mode 100644 index 0000000000..5a9ac60197 --- /dev/null +++ b/open-sse/executors/maxai.ts @@ -0,0 +1,620 @@ +/** + * MaxAiExecutor — MaxAI web-app chat as an OpenAI-compatible OmniRoute provider. + * + * MaxAI (chat.maxai.co / api.maxai.me) is a consumer web app with no public API. + * This executor reproduces the web app's own signed request to `/gpt/cwc/chat`: + * • per-request `X-Authorization` signature (see ./signing.ts), + * • Firefox-150 identity headers + Bearer access token, + * • the full OpenAI transcript flattened into one `message_content` block + * (stateless-full-history; see ./protocol.ts), + * • SSE response parsed for text deltas, with inline `` reasoning split + * out into `reasoning_content` (see ./stream.ts). + * + * Egress + TLS: the request MUST exit a residential IP (MaxAI bot-bans datacenter + * IPs). OmniRoute routes the executor's `fetch()` through the per-connection proxy + * (a residential HTTP proxy) transparently, and applies the wreq-js Firefox TLS + * fingerprint when enabled. This executor does not open its own socket; it uses + * the ambient patched `fetch`, so the proxy + TLS overlay apply automatically. + * + * Auth refresh: MaxAI's `/oauth/refresh_access_token` is deep-TLS-gated and cannot + * be called by any HTTP client (only a real browser passes). The access token is + * therefore minted/refreshed out-of-band by OmniRoute's own browser-mint flow + * (see maxaiBrowserLogin); this executor only consumes the stored credential. + */ +import { BaseExecutor, type ExecuteInput, type ExecutorExecuteResult } from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; +import { resolveMaxaiCredential, type MaxaiCredential } from "./maxai/credentials.ts"; +import { buildMaxaiSignedHeaders } from "./maxai/signing.ts"; +import { ensureMaxaiConstants } from "./maxai/constantsStore.ts"; +import { maxaiAccessTokenNeedsRefresh, maxaiRefreshAccessToken } from "./maxai/refresh.ts"; +import { + assembleMaxaiContext, + buildMaxaiChatBody, + extractCurrentTurnImages, + MAXAI_BASE_URL, + MAXAI_CHAT_PATH, + maxaiStaticHeaders, + newConversationId, +} from "./maxai/protocol.ts"; +import { resolveMaxaiDocList, type MaxaiDocListEntry } from "./maxai/documents.ts"; +import { estimateMaxaiTokens, isMaxaiTextFrame, ThinkSplitter } from "./maxai/stream.ts"; +import { prepareToolMessages, parseToolCallsFromText } from "../translator/webTools.ts"; +import { buildToolModeResponse } from "./chatgptWebTools.ts"; + +const JSON_HEADERS = { "Content-Type": "application/json" }; +const SSE_HEADERS = { + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "Content-Type": "text/event-stream; charset=utf-8", +}; + +interface OpenAiChatBody { + messages?: Array<{ + role?: string; + content?: unknown; + tool_calls?: unknown; + tool_call_id?: string; + }>; + model?: string; +} + +function errorResponse(status: number, message: string, code: string): Response { + return new Response( + JSON.stringify({ + error: { + code, + message: sanitizeErrorMessage(message), + type: status >= 500 ? "provider_error" : "invalid_request_error", + }, + }), + { status, headers: JSON_HEADERS } + ); +} + +/** + * Wrap a Response into the executor wrapper contract shape + * `{response, url, headers, transformedBody}` that `chatCore.ts` and the + * web-cookie/noauth sweep (tests/unit/executor-web-cookie-sweep.test.ts) + * require. `headers` and `transformedBody` are the ACTUAL upstream request + * headers and body — chatCore surfaces them as the provider-request-capture + * ("what we actually sent") in the dashboard and uses the body for service-tier + * and prompt-cache metadata (chatCore.ts:3680-3688), mirroring the shape returned + * by every web-cookie sibling (venice-web.ts:92-94, poe-web.ts:121-123). Error + * paths that fail BEFORE a request is assembled pass no capture — honestly empty, + * because nothing was sent upstream. + */ +function wrap( + response: Response, + url: string, + capture?: { headers?: Record; transformedBody?: unknown } +): { response: Response; url: string; headers: Record; transformedBody: unknown } { + return { + response, + url, + headers: capture?.headers ?? {}, + transformedBody: capture?.transformedBody ?? null, + }; +} + +/** + * Detect a tool "narration miss": the model produced no parseable block + * but its text shows it was ABOUT to call a tool (talks about the block + * or names a requested tool). This is the occasional reasoning-model failure + * mode (e.g. deepseek-r1) where it reasons about the call instead of emitting + * it. A true refusal or a normal answer returns false, so we never retry those. + */ +function isToolNarrationMiss(text: string, requestedTools: unknown): boolean { + if (!text) return false; + if (/) + .map((t) => (typeof t?.function?.name === "string" ? t.function.name : "")) + .filter(Boolean) + : []; + // Names it a tool AND signals intent to use it (not merely mentioning it). + const intent = /\b(I('| wi)ll|let me|I can|going to|need to)\b/i.test(text); + return intent && names.some((n) => text.includes(n)); +} + +/** A short, soft nudge appended to the transcript for the single retry turn. */ +function toolNudge(originalText: string): string { + return ( + originalText + + "\n\n[A quick note: if a client tool would help answer this, please go ahead " + + "and emit the block directly rather than describing it — just the block " + + "on its own line. If no tool is needed, a normal answer is perfectly fine.]" + ); +} + +/** Emit one OpenAI `chat.completion.chunk`. */ +function chunk( + controller: ReadableStreamDefaultController, + id: string, + created: number, + model: string, + delta: Record, + finish: string | null = null +): void { + const payload = { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta, finish_reason: finish }], + }; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)); +} + +export class MaxAiExecutor extends BaseExecutor { + constructor() { + super("maxai", PROVIDERS.maxai ?? { id: "maxai", baseUrl: MAXAI_BASE_URL }); + } + + override async execute(input: ExecuteInput): Promise { + // The MaxAI chat endpoint URL is the wrapper's `url` for every return path + // (error and success alike), so define it once up front. + const url = MAXAI_BASE_URL + MAXAI_CHAT_PATH; + + const cred = resolveMaxaiCredential( + input.credentials?.providerSpecificData, + input.credentials?.accessToken + ); + if (!cred) { + return wrap( + errorResponse( + 401, + "MaxAI connection is not configured (missing access token, device id, or user id). Sign in to mint a token.", + "maxai_unconfigured" + ), + url + ); + } + + // Proactively refresh a near-expiry access token (browserless; see ./maxai/refresh.ts). + // Failures here are non-fatal: we fall through with the existing token, and a + // genuinely-dead token surfaces as a 401/418 below (prompting a re-mint). + const accessToken = await this.ensureFreshAccess(cred, input); + + const body = (input.body ?? {}) as OpenAiChatBody; + + // Tool-calling (prompted protocol): when the request carries tools[], inject + // the contract into the messages so the model learns the client tools + // and how to invoke them (see translator/webTools.ts). MaxAI has no native + // function-calling; this is the same prompted-tool shim the web-cookie + // providers use. The response side parses blocks back into tool_calls. + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( + body as Record, + (body.messages ?? []) as Array<{ role: string; content: unknown }> + ); + + let text: string; + try { + text = assembleMaxaiContext(effectiveMessages); + } catch { + return wrap( + errorResponse(400, "No user message to send to MaxAI.", "maxai_empty_request"), + url + ); + } + + // Vision input: attach the CURRENT user turn's images (data: / http(s):) to + // message_content so vision-capable MaxAI models actually see them. Extract + // from the original messages (pre-tool-munging); text stays flattened. + const originalMessages = (body.messages ?? []) as Array<{ role?: string; content?: unknown }>; + const imageUrls = extractCurrentTurnImages(originalMessages); + + // Doc-RAG: upload any inline documents (base64 file/input_file/document + // parts) on the current turn to /app/upload_document and attach the + // resulting doc_list to the chat body. Best-effort: upload failures are + // skipped and the chat proceeds without the doc. + let docList: MaxaiDocListEntry[] = []; + try { + docList = await resolveMaxaiDocList( + originalMessages, + { accessToken, userId: cred.userId, deviceId: cred.deviceId }, + { signal: input.signal ?? undefined } + ); + } catch { + docList = []; + } + + const constants = await ensureMaxaiConstants({ signal: input.signal }); + if (!constants) { + return wrap( + errorResponse( + 401, + "MaxAI signing constants unavailable (extraction failed); cannot sign the request.", + "maxai_auth_error" + ), + url + ); + } + + const conversationId = newConversationId(); + const chatBody = buildMaxaiChatBody({ + conversationId, + text, + modelName: input.model, + appVersion: constants.appVersion, + imageUrls, + docList: docList.length ? docList : undefined, + }); + + const signedHeaders = buildMaxaiSignedHeaders( + { + path: MAXAI_CHAT_PATH, + userId: cred.userId, + deviceId: cred.deviceId, + }, + constants + ); + const headers: Record = { + ...maxaiStaticHeaders(), + ...signedHeaders, + Authorization: `Bearer ${accessToken}`, + ...(input.upstreamExtraHeaders ?? {}), + }; + + let upstream: Response; + try { + upstream = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(chatBody), + signal: input.signal ?? undefined, + }); + } catch (err) { + return wrap( + errorResponse( + 502, + `MaxAI request failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : err)}`, + "maxai_transport_error" + ), + url + ); + } + + if (upstream.status !== 200 || !upstream.body) { + const detail = await upstream.text().catch(() => ""); + // 401/418 = auth expired/masked-reject; surface so the caller can prompt a re-mint. + // A body-too-large rejection (MaxAI answers 422 "...message you submitted being + // too long...") is INPUT-bound: classify it as context_length_exceeded so + // OmniRoute's compression/overflow pipeline can shrink and retry instead of + // treating it as an opaque provider error. + const tooLong = /too\s+long|exceeds?\b.*\bcontext|context.*(?:exceeded|too long|limit)/i.test( + detail + ); + if (tooLong) { + return wrap( + errorResponse( + 400, + `MaxAI request exceeds the context limit: ${sanitizeErrorMessage(detail.slice(0, 200))}`, + "context_length_exceeded" + ), + url + ); + } + const status = upstream.status === 418 ? 401 : upstream.status || 502; + return wrap( + errorResponse( + status, + `MaxAI upstream ${upstream.status}: ${sanitizeErrorMessage(detail.slice(0, 300))}`, + upstream.status === 401 || upstream.status === 418 + ? "maxai_auth_error" + : "maxai_upstream_error" + ), + url + ); + } + + const id = `chatcmpl-${conversationId}`; + const created = Math.floor(Date.now() / 1000); + const promptTokens = estimateMaxaiTokens(text); + + // Tool mode: MaxAI streams plain text, and the protocol is only + // parseable once the full reply is in hand. So when tools are active we + // buffer the whole body, build a chat.completion, and let the shared shim + // parse blocks into tool_calls (emitting a terminal SSE replay for + // streaming callers). This mirrors every web-cookie provider's tool path. + if (hasTools) { + const raw = await upstream.text(); + let { reasoning, answer } = collectNonStream(raw); + + // Reliability: if the model narrated about the tool but emitted no + // parseable block (occasional reasoning-model miss), do ONE gentle + // nudged retry and keep it only if it actually produces a tool call. + const firstHasToolCall = !!parseToolCallsFromText(answer, "probe", requestedTools).toolCalls; + if (!firstHasToolCall && isToolNarrationMiss(reasoning + "\n" + answer, requestedTools)) { + const retry = await this.retryToolTurn(cred, accessToken, input, toolNudge(text)); + if (retry && parseToolCallsFromText(retry.answer, "probe", requestedTools).toolCalls) { + reasoning = retry.reasoning; + answer = retry.answer; + input.log?.debug?.("maxai", "tool narration-miss recovered via one nudged retry"); + } + } + + const completionTokens = estimateMaxaiTokens(reasoning + answer); + const buffered = new Response( + JSON.stringify({ + id, + object: "chat.completion", + created, + model: input.model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: answer, + ...(reasoning ? { reasoning_content: reasoning } : {}), + }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }), + { status: 200, headers: JSON_HEADERS } + ); + const response = await buildToolModeResponse(buffered, requestedTools, input.stream, { + cid: id, + created, + model: input.model, + idSeed: "maxai", + }); + return wrap(response, url, { headers, transformedBody: chatBody }); + } + + if (input.stream) { + const stream = this.buildStream(upstream.body, id, created, input.model, promptTokens); + return wrap(new Response(stream, { status: 200, headers: SSE_HEADERS }), url, { + headers, + transformedBody: chatBody, + }); + } + + // Non-streaming: collect the whole SSE body, split think, build a chat.completion. + const raw = await upstream.text(); + const { reasoning, answer } = collectNonStream(raw); + const completionTokens = estimateMaxaiTokens(reasoning + answer); + const response = { + id, + object: "chat.completion", + created, + model: input.model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: answer, + ...(reasoning ? { reasoning_content: reasoning } : {}), + }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }; + return wrap( + new Response(JSON.stringify(response), { status: 200, headers: JSON_HEADERS }), + url, + { headers, transformedBody: chatBody } + ); + } + + /** + * Return a non-expired access token, refreshing browserlessly when the stored + * one is missing or within the expiry margin and a refresh token is available. + * Persists a freshly-minted token via `onCredentialsRefreshed`. Never throws — + * on any refresh failure it returns the original token so the request still + * proceeds (a truly-dead token then surfaces as an upstream 401/418). + */ + private async ensureFreshAccess(cred: MaxaiCredential, input: ExecuteInput): Promise { + if (!cred.refreshToken) return cred.accessToken; + if (!maxaiAccessTokenNeedsRefresh(cred.accessToken)) return cred.accessToken; + + const result = await maxaiRefreshAccessToken({ + refreshToken: cred.refreshToken, + deviceId: cred.deviceId, + userId: cred.userId, + signal: input.signal ?? undefined, + }); + if (!result.ok || !result.accessToken) { + input.log?.warn?.( + "maxai", + `access-token refresh failed (${result.status}); using existing token` + ); + return cred.accessToken; + } + + // Persist the new access token (merged into providerSpecificData) so the next + // request starts fresh. The refresh token and device id are unchanged. + try { + await input.onCredentialsRefreshed?.({ + accessToken: result.accessToken, + providerSpecificData: { + ...(input.credentials?.providerSpecificData ?? {}), + maxaiAccessToken: result.accessToken, + }, + }); + } catch (err) { + input.log?.warn?.( + "maxai", + `refreshed token persist failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : err)}` + ); + } + return result.accessToken; + } + + /** + * Run a single follow-up MaxAI turn with a gentle nudge appended, used to + * recover a reasoning-model "narration miss" (the model talked ABOUT the + * block instead of emitting it). Bounded to one extra call; returns the + * split { reasoning, answer } or null on any failure (caller keeps the original). + */ + private async retryToolTurn( + cred: MaxaiCredential, + accessToken: string, + input: ExecuteInput, + nudgedText: string + ): Promise<{ reasoning: string; answer: string } | null> { + try { + const constants = await ensureMaxaiConstants({ signal: input.signal }); + if (!constants) return null; + const retryBody = buildMaxaiChatBody({ + conversationId: newConversationId(), + text: nudgedText, + modelName: input.model, + appVersion: constants.appVersion, + }); + const headers: Record = { + ...maxaiStaticHeaders(), + ...buildMaxaiSignedHeaders( + { + path: MAXAI_CHAT_PATH, + userId: cred.userId, + deviceId: cred.deviceId, + }, + constants + ), + Authorization: `Bearer ${accessToken}`, + ...(input.upstreamExtraHeaders ?? {}), + }; + const res = await fetch(MAXAI_BASE_URL + MAXAI_CHAT_PATH, { + method: "POST", + headers, + body: JSON.stringify(retryBody), + signal: input.signal ?? undefined, + }); + if (res.status !== 200 || !res.body) return null; + return collectNonStream(await res.text()); + } catch { + return null; + } + } + + /** Bridge the MaxAI SSE body into an OpenAI chat.completion.chunk stream. */ + private buildStream( + source: ReadableStream, + id: string, + created: number, + model: string, + promptTokens: number + ): ReadableStream { + const splitter = new ThinkSplitter(); + const decoder = new TextDecoder(); + let sseBuf = ""; + let sentRole = false; + let completionChars = 0; + + const emitDelta = (controller: ReadableStreamDefaultController, r: string, a: string) => { + if (!sentRole && (r || a)) { + chunk(controller, id, created, model, { role: "assistant" }); + sentRole = true; + } + if (r) { + chunk(controller, id, created, model, { reasoning_content: r }); + completionChars += r.length; + } + if (a) { + chunk(controller, id, created, model, { content: a }); + completionChars += a.length; + } + }; + + const processFrame = (controller: ReadableStreamDefaultController, jsonStr: string) => { + if (!jsonStr || jsonStr === "[DONE]") return; + let frame: unknown; + try { + frame = JSON.parse(jsonStr); + } catch { + return; + } + if (isMaxaiTextFrame(frame)) { + const { reasoning, answer } = splitter.feed(frame.text); + emitDelta(controller, reasoning, answer); + } + }; + + return new ReadableStream({ + async start(controller) { + const reader = source.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + sseBuf += decoder.decode(value, { stream: true }); + let nl: number; + while ((nl = sseBuf.indexOf("\n")) !== -1) { + const line = sseBuf.slice(0, nl).trim(); + sseBuf = sseBuf.slice(nl + 1); + if (line.startsWith("data:")) processFrame(controller, line.slice(5).trim()); + } + } + // flush held tail from the think splitter + const tail = splitter.flush(); + emitDelta(controller, tail.reasoning, tail.answer); + // final chunk with usage + finish + const completionTokens = estimateMaxaiTokens("x".repeat(completionChars)); + const finalChunk = { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finalChunk)}\n\n`)); + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); + controller.close(); + } catch (err) { + try { + controller.error(err); + } catch { + /* already errored */ + } + } finally { + reader.releaseLock(); + } + }, + }); + } +} + +/** Collect a full MaxAI SSE body into split { reasoning, answer } (non-stream). */ +function collectNonStream(raw: string): { reasoning: string; answer: string } { + const splitter = new ThinkSplitter(); + let reasoning = ""; + let answer = ""; + for (const line of raw.split("\n")) { + const s = line.trim(); + if (!s.startsWith("data:")) continue; + const js = s.slice(5).trim(); + if (!js || js === "[DONE]") continue; + let frame: unknown; + try { + frame = JSON.parse(js); + } catch { + continue; + } + if (isMaxaiTextFrame(frame)) { + const out = splitter.feed(frame.text); + reasoning += out.reasoning; + answer += out.answer; + } + } + const tail = splitter.flush(); + return { reasoning: reasoning + tail.reasoning, answer: answer + tail.answer }; +} diff --git a/open-sse/executors/maxai/catalog.ts b/open-sse/executors/maxai/catalog.ts new file mode 100644 index 0000000000..363b3cca9e --- /dev/null +++ b/open-sse/executors/maxai/catalog.ts @@ -0,0 +1,76 @@ +/** + * MaxAI model catalog + provider-enum mapping. Ported from the MaxAI v3 client + * (catalog/context_windows.py, tools/provider_enum.py). All 13 chat models are + * PAID (the free `mistral-7b-instruct-free` is a window-lookup fallback only and + * is not offered). Context windows are the MaxAI-reported values. + */ +import type { RegistryModel } from "../../config/providers/shared.ts"; + +interface MaxaiModelSpec { + id: string; + name: string; + contextLength: number; + supportsReasoning?: boolean; + /** + * Vision-capable (accepts image_url input). Sourced from MaxAI's live + * `/models/get_config` `capabilities.vision` (verified 2026-08); the executor + * forwards image parts inline in message_content for these. Live discovery + * (services/maxaiModels.ts) overrides this from the catalog at runtime; this + * static flag keeps the offline registry in agreement. + */ + supportsVision?: boolean; +} + +/** The 13 offered paid chat models (group order: FAST, SMART, REASONING). */ +export const MAXAI_MODELS: MaxaiModelSpec[] = [ + // FAST + { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", contextLength: 1_050_000, supportsVision: true }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", contextLength: 200_000, supportsVision: true }, + { id: "gemini-3-1-flash-lite", name: "Gemini 3.1 Flash Lite", contextLength: 1_000_000, supportsVision: true }, + { id: "grok-4-1-fast-non-reasoning", name: "Grok 4.1 Fast", contextLength: 2_000_000 }, + { id: "llama-3.3-70b", name: "Llama 3.3 70B", contextLength: 128_000 }, + { id: "deepseek-v3.2", name: "DeepSeek V3.2", contextLength: 128_000 }, + // SMART + { id: "gpt-5.6", name: "GPT-5.6", contextLength: 1_050_000, supportsVision: true }, + { id: "claude-5-sonnet", name: "Claude 5 Sonnet", contextLength: 1_000_000 }, + { + id: "grok-4-1-fast-reasoning", + name: "Grok 4.1 Fast (Reasoning)", + contextLength: 2_000_000, + supportsReasoning: true, + }, + // REASONING + { + id: "gpt-5.6-thinking", + name: "GPT-5.6 Thinking", + contextLength: 1_050_000, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "gemini-3.1-pro-preview", + name: "Gemini 3.1 Pro Preview", + contextLength: 1_000_000, + supportsReasoning: true, + supportsVision: true, + }, + { id: "grok-4.5", name: "Grok 4.5", contextLength: 500_000, supportsReasoning: true }, + { id: "deepseek-r1", name: "DeepSeek R1", contextLength: 128_000, supportsReasoning: true }, +]; + +/** RegistryModel[] form for the provider registry entry. */ +export const MAXAI_REGISTRY_MODELS: RegistryModel[] = MAXAI_MODELS.map((m) => ({ + id: m.id, + name: m.name, + contextLength: m.contextLength, + toolCalling: true, // prompted tool-calling (no native API, but supported via the tool protocol) + ...(m.supportsReasoning ? { supportsReasoning: true } : {}), + ...(m.supportsVision ? { supportsVision: true } : {}), +})); + +/** Default context window for an unknown model. */ +export const MAXAI_DEFAULT_CONTEXT = 128_000; + +export function maxaiContextWindow(modelId: string): number { + return MAXAI_MODELS.find((m) => m.id === modelId)?.contextLength ?? MAXAI_DEFAULT_CONTEXT; +} diff --git a/open-sse/executors/maxai/constants.ts b/open-sse/executors/maxai/constants.ts new file mode 100644 index 0000000000..08b5bfd3e4 --- /dev/null +++ b/open-sse/executors/maxai/constants.ts @@ -0,0 +1,427 @@ +/** + * MaxAI web-app signing constants — extracted live from the public JS bundle. + * + * MaxAI's request signer needs a small set of CLIENT-SIDE constants that its own + * front-end ships VERBATIM in the public `www.maxai.co` JavaScript bundle + * (identical for every visitor, no per-user or server secret). OmniRoute EXTRACTS + * them from the live bundle and persists them, so if MaxAI ever rotates a value — + * or a Next.js rebuild renumbers its chunks — the provider self-heals on the next + * login or daily refresh instead of hard-failing every signed call. + * + * NOTHING id/key/version-shaped is hardcoded anywhere (source OR tests). Every + * such value (hmacKey, aesKey, docIdKey, ctxKey, appVersion) is discovered at + * runtime and validated; the repo carries no scannable secret and no build- + * specific chunk number. + * + * WHAT is extracted, and from WHERE (all are plain, public static assets): + * pages/_app-*.js — the Next.js app-entry chunk (framework-STABLE name, not a + * MaxAI chunk number). Webpack module 69319 inside it defines the constants as + * export getters we follow to their string literals: + * - hmacKey export `Mn` → a hex string (HMAC-SHA1 → SM3 keying) + * - aesKey export `Rl` → a hex string (CryptoJS AES passphrase) + * - docIdKey export `U0` → a UUID (doc-upload HMAC key) + * - appVersion the sole `webpage_x.y.z` literal (folded into the sign_str) + * the SIGNER chunk — a NUMBERED chunk whose id changes across builds, so it is + * located by CONTENT FINGERPRINT (never by number): the chunk that assembles + * the signed payload, recognised by the ctx-slot pattern `"<40hex>":{a:…}` next + * to the `(0,r.nj)("")` header-name decoders. From it we read: + * - ctxKey the 40-hex payload content-slot label + * - headerNames the `nj("")` calls = hex→ASCII header/slot names + * + * The extracted set is SHAPE-validated (hex/UUID/version regexes) before it is + * trusted; the ULTIMATE validation is the first live signed call (a wrong value + * is rejected by MaxAI, which triggers a re-extract). Only the plain, non-secret + * HTTP header NAMES (e.g. "X-Authorization") keep in-code defaults, so a transient + * miss on the signer chunk can't break a signer that already has valid keys; + * extraction still overrides them when present. + */ +import { createHmac, createHash } from "node:crypto"; + +/** The public bundle base. `/app/` is the SPA entry that references the chunks. */ +export const MAXAI_WEBAPP_ORIGIN = "https://www.maxai.co"; +export const MAXAI_WEBAPP_APP_PATH = "/app/"; + +/** Settings key under which the extracted constants bundle is persisted. */ +export const MAXAI_CONSTANTS_SETTINGS_KEY = "maxaiSigningConstants"; + +/** Firefox-150 UA used for the (unauthenticated) static-asset fetches. */ +const FETCH_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0"; + +/** + * The header/slot NAMES the signer emits. These are standard HTTP header names + * (not secrets, not id/key/version-shaped), so in-code defaults are appropriate; + * extraction overrides any that the signer chunk exposes. + */ +export interface MaxaiHeaderNames { + authorization: string; // "X-Authorization" + clientDomain: string; // "X-Client-Domain" + clientPath: string; // "X-Client-Path" + random: string; // "X-Random" + browserName: string; // "X-Browser-Name" + browserVersion: string; // "X-Browser-Version" + browserMajor: string; // "X-Browser-Major" + appVersionHeader: string; // "X-App-Version" + appEnvHeader: string; // "X-App-Env" + appEnvValue: string; // "MaxAI-Browser-Extension" + tSlot: string; // "t" + pSlot: string; // "p" + dSlot: string; // "d" +} + +/** The full set of signing constants the MaxAI signer depends on. */ +export interface MaxaiSigningConstants { + /** HMAC-SHA1 → SM3 keying material (extracted; no in-code default). */ + hmacKey: string; + /** CryptoJS AES passphrase (extracted; no in-code default). */ + aesKey: string; + /** Version string folded into the signature `sign_str` (extracted). */ + appVersion: string; + /** Payload content-slot label, 40-hex (extracted; no in-code default). */ + ctxKey: string; + /** Doc-upload HMAC key, UUID (extracted; no in-code default). */ + docIdKey: string; + /** Header/slot names emitted by the signer. */ + headerNames: MaxaiHeaderNames; + /** Provenance for the persisted record. */ + source?: "extracted"; + extractedAt?: number; +} + +/** + * Default HTTP header NAMES (standard, non-secret labels). Extraction overrides + * any the signer chunk exposes; these keep a signer with valid keys working even + * if the signer chunk momentarily can't be located. + */ +export const MAXAI_DEFAULT_HEADER_NAMES: MaxaiHeaderNames = { + authorization: "X-Authorization", + clientDomain: "X-Client-Domain", + clientPath: "X-Client-Path", + random: "X-Random", + browserName: "X-Browser-Name", + browserVersion: "X-Browser-Version", + browserMajor: "X-Browser-Major", + appVersionHeader: "X-App-Version", + appEnvHeader: "X-App-Env", + appEnvValue: "MaxAI-Browser-Extension", + tSlot: "t", + pSlot: "p", + dSlot: "d", +}; + +/** Raw pieces the parser can pull from the two chunks (any may be absent). */ +export interface MaxaiParsedConstants { + hmacKey: string | null; + aesKey: string | null; + appVersion: string | null; + ctxKey: string | null; + docIdKey: string | null; + headerNames: Partial; +} + +/** Resolve a webpack export getter `Name:function(){return VAR}` → the `VAR="…"` literal. */ +export function resolveWebpackGetter(src: string, exportName: string): string | null { + const getter = new RegExp( + `${exportName}\\s*:\\s*function\\s*\\(\\)\\s*\\{\\s*return\\s+([A-Za-z_$][\\w$]*)\\s*\\}` + ); + let m = src.match(getter); + if (!m) { + const arrow = new RegExp(`${exportName}\\s*:\\s*\\(\\)\\s*=>\\s*([A-Za-z_$][\\w$]*)`); + m = src.match(arrow); + } + if (!m) return null; + const varName = m[1]; + const assign = new RegExp(`\\b${varName}\\s*=\\s*"([^"]+)"`); + const am = src.match(assign); + return am ? am[1] : null; +} + +/** Decode the `(0,r.nj)("")` header-name calls (nj = hex→ASCII). */ +export function decodeNjHeaderNames(signerChunk: string): string[] { + const out = new Set(); + for (const m of signerChunk.matchAll(/nj\)\("([0-9a-f]+)"\)/g)) { + try { + const decoded = Buffer.from(m[1], "hex").toString("utf8"); + // Keep only printable ASCII header-ish tokens (drop numeric ja3 codes etc). + if (/^[\x20-\x7e]+$/.test(decoded)) out.add(decoded); + } catch { + // skip malformed hex + } + } + return [...out]; +} + +/** Map the decoded header-name list onto the structured MaxaiHeaderNames slots. */ +function mapHeaderNames(decoded: string[]): Partial { + const has = (v: string) => decoded.includes(v); + const out: Partial = {}; + if (has("X-Authorization")) out.authorization = "X-Authorization"; + if (has("X-Client-Domain")) out.clientDomain = "X-Client-Domain"; + if (has("X-Client-Path")) out.clientPath = "X-Client-Path"; + if (has("X-Random")) out.random = "X-Random"; + if (has("X-Browser-Name")) out.browserName = "X-Browser-Name"; + if (has("X-Browser-Version")) out.browserVersion = "X-Browser-Version"; + if (has("X-Browser-Major")) out.browserMajor = "X-Browser-Major"; + if (has("X-App-Version")) out.appVersionHeader = "X-App-Version"; + if (has("X-App-Env")) out.appEnvHeader = "X-App-Env"; + if (has("MaxAI-Browser-Extension")) out.appEnvValue = "MaxAI-Browser-Extension"; + return out; +} + +/** Extract the 40-hex payload content-slot label from the signer chunk. */ +export function extractCtxKey(signerChunk: string): string | null { + return (signerChunk.match(/"([0-9a-f]{40})"\s*:\s*\{\s*a\s*:/) || [])[1] ?? null; +} + +/** + * Content fingerprint for the SIGNER chunk (build-independent). The signer chunk + * is the one that both (a) carries the ctx payload slot `"<40hex>":{a:…}` and + * (b) decodes header names via `(0,r.nj)("")`. Matching BOTH avoids a false + * positive on any unrelated chunk that merely contains a 40-hex string. + */ +export function looksLikeSignerChunk(js: string): boolean { + return extractCtxKey(js) !== null && /nj\)\("[0-9a-f]+"\)/.test(js); +} + +/** + * Parse the two bundle chunks into raw constants. Pure (no network) so it is + * unit-tested directly against synthetic fixtures. + */ +export function parseMaxaiConstants( + appChunk: string, + signerChunk: string +): MaxaiParsedConstants { + const decoded = decodeNjHeaderNames(signerChunk); + return { + hmacKey: resolveWebpackGetter(appChunk, "Mn"), + aesKey: resolveWebpackGetter(appChunk, "Rl"), + docIdKey: resolveWebpackGetter(appChunk, "U0"), + appVersion: (appChunk.match(/"(webpage_\d+\.\d+\.\d+)"/) || [])[1] ?? null, + ctxKey: extractCtxKey(signerChunk), + headerNames: mapHeaderNames(decoded), + }; +} + +/** A MaxAI signing key is a 40+ char lowercase hex string. */ +function isHexKey(v: string | null | undefined): boolean { + return typeof v === "string" && /^[0-9a-f]{40,}$/.test(v); +} + +/** A doc-id key is a UUID (v4-shaped). */ +function isUuidKey(v: string | null | undefined): boolean { + return typeof v === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(v); +} + +/** A MaxAI app_version tag looks like `webpage_x.y.z`. */ +function isAppVersion(v: string | null | undefined): boolean { + return typeof v === "string" && /^webpage_\d+\.\d+\.\d+$/.test(v); +} + +/** + * Fold parsed pieces into a full constants object. The five extracted values + * (hmacKey, aesKey, ctxKey, docIdKey, appVersion) are ALL required and must be + * well-formed — return null otherwise, so we never persist a half-configured + * signer. Only the plain HTTP header names fall back to the standard defaults. + */ +export function assembleMaxaiConstants( + parsed: MaxaiParsedConstants +): MaxaiSigningConstants | null { + if (!isHexKey(parsed.hmacKey) || !isHexKey(parsed.aesKey)) return null; + if (!isHexKey(parsed.ctxKey)) return null; + if (!isUuidKey(parsed.docIdKey)) return null; + if (!isAppVersion(parsed.appVersion)) return null; + return { + hmacKey: parsed.hmacKey as string, + aesKey: parsed.aesKey as string, + appVersion: parsed.appVersion as string, + ctxKey: parsed.ctxKey as string, + docIdKey: parsed.docIdKey as string, + headerNames: { ...MAXAI_DEFAULT_HEADER_NAMES, ...parsed.headerNames }, + source: "extracted", + extractedAt: Date.now(), + }; +} + +/** True when a constants object is structurally well-formed (all 5 values valid). */ +export function isValidConstantsShape(c: MaxaiSigningConstants | null | undefined): boolean { + if (!c) return false; + return ( + isHexKey(c.hmacKey) && + isHexKey(c.aesKey) && + isHexKey(c.ctxKey) && + isUuidKey(c.docIdKey) && + isAppVersion(c.appVersion) && + !!c.headerNames + ); +} + +/** + * A signature vector: a (path, reqTime, userId, appVersion) tuple and the SM3 + * proof it should produce. Used to prove the signing ALGORITHM in unit tests with + * mock keys — the runtime does NOT embed any real vector (its trust anchor is the + * live signed probe). `reproduceProof` is a pure helper over the same math. + */ +export interface MaxaiSignatureVector { + path: string; + reqTime: number; + userId: string; + appVersion: string; + expectedProof: string; +} + +/** Reproduce the SM3 proof `p` for a (path, reqTime, userId, appVersion) under a key. */ +export function reproduceProof( + hmacKey: string, + vector: Omit +): string { + const signStr = `${vector.appVersion}:${vector.reqTime}:${vector.path}:${vector.userId}`; + const sha1 = createHmac("sha1", Buffer.from(`${vector.reqTime}:${hmacKey}`, "utf8")) + .update(Buffer.from(signStr, "utf8")) + .digest("hex"); + return createHash("sm3") + .update(Buffer.from(`${vector.reqTime}:${sha1}:${hmacKey}`, "utf8")) + .digest("hex"); +} + +/** + * Runtime validation of an extracted/stored constants set. SHAPE-based on purpose: + * we carry no real signature vector in source, so the definitive check is the + * first live signed call (a wrong value is rejected by MaxAI → re-extract). An + * optional `vector` enables proof-based checking in tests with mock keys. + */ +export function validateMaxaiConstants( + constants: MaxaiSigningConstants, + vector?: MaxaiSignatureVector +): boolean { + if (!isValidConstantsShape(constants)) return false; + if (!vector) return true; + try { + return reproduceProof(constants.hmacKey, vector) === vector.expectedProof; + } catch { + return false; + } +} + +/** + * Fetch a text asset with the Firefox UA through the ambient (residential) fetch. + * Injectable for tests. Returns "" on any failure (caller treats empty as miss). + */ +async function fetchText( + url: string, + fetchImpl: typeof fetch, + signal?: AbortSignal | null +): Promise { + try { + const res = await fetchImpl(url, { + headers: { "User-Agent": FETCH_UA, Accept: "*/*" }, + signal: signal ?? undefined, + }); + if (!res.ok) return ""; + return await res.text(); + } catch { + return ""; + } +} + +/** All `/_next/static/chunks/...js` URLs referenced by the app HTML, in order. */ +export function allChunkUrls(html: string): string[] { + const seen = new Set(); + const out: string[] = []; + for (const m of html.matchAll(/\/_next\/static\/chunks\/[A-Za-z0-9/_-]+\.js/g)) { + if (!seen.has(m[0])) { + seen.add(m[0]); + out.push(m[0]); + } + } + return out; +} + +/** + * From the `/app/` HTML, resolve the app-entry chunk (by its stable Next.js + * `pages/_app-*.js` name) and the list of candidate numbered chunks to scan for + * the signer chunk BY CONTENT. No specific chunk number is ever assumed. + */ +export function findChunkUrls(html: string): { + appChunk: string | null; + candidateChunks: string[]; +} { + const urls = allChunkUrls(html); + let appChunk: string | null = null; + const candidateChunks: string[] = []; + for (const p of urls) { + if (/\/pages\/_app-[a-z0-9]+\.js$/i.test(p)) { + appChunk = p; + } else if (/\/chunks\/[A-Za-z0-9]+-[a-z0-9]+\.js$/i.test(p)) { + // Any hashed vendor/number chunk is a signer-chunk candidate; we identify + // the real one by content, not by its (build-specific) name. + candidateChunks.push(p); + } + } + return { appChunk, candidateChunks }; +} + +export interface FetchConstantsOptions { + fetchImpl?: typeof fetch; + signal?: AbortSignal | null; + /** Override the origin (tests). */ + origin?: string; + /** Cap on how many candidate chunks to scan for the signer chunk (default 80). */ + maxScanChunks?: number; +} + +/** + * Locate + fetch the signer chunk text by CONTENT (never by number): scan the + * candidate chunks referenced in the app HTML and return the first whose content + * matches the signer fingerprint (ctx slot + nj header decoders). A MaxAI-side + * chunk renumber is therefore self-healing, not a break. + */ +async function fetchSignerChunk( + origin: string, + candidates: string[], + fetchImpl: typeof fetch, + signal: AbortSignal | null | undefined, + maxScan: number +): Promise { + for (const c of candidates.slice(0, maxScan)) { + const js = await fetchText(origin + c, fetchImpl, signal); + if (js && looksLikeSignerChunk(js)) return js; + } + return ""; +} + +/** + * Fetch + parse the live constants from MaxAI's public bundle. Returns a fully + * assembled, SHAPE-validated constants object, or null on any failure (network, + * missing chunk, unparseable, malformed values). Never throws. The definitive + * key validation is the caller's first live signed call. + */ +export async function fetchMaxaiConstants( + opts: FetchConstantsOptions = {} +): Promise { + const fetchImpl = opts.fetchImpl ?? fetch; + const origin = opts.origin ?? MAXAI_WEBAPP_ORIGIN; + const maxScan = opts.maxScanChunks ?? 80; + + const html = await fetchText(origin + MAXAI_WEBAPP_APP_PATH, fetchImpl, opts.signal); + if (!html) return null; + + const { appChunk, candidateChunks } = findChunkUrls(html); + if (!appChunk) return null; + + const appJs = await fetchText(origin + appChunk, fetchImpl, opts.signal); + if (!appJs) return null; + + const signerJs = await fetchSignerChunk( + origin, + candidateChunks, + fetchImpl, + opts.signal, + maxScan + ); + + const parsed = parseMaxaiConstants(appJs, signerJs); + const assembled = assembleMaxaiConstants(parsed); + if (!assembled) return null; + if (!validateMaxaiConstants(assembled)) return null; + return assembled; +} diff --git a/open-sse/executors/maxai/constantsStore.ts b/open-sse/executors/maxai/constantsStore.ts new file mode 100644 index 0000000000..08ece802c0 --- /dev/null +++ b/open-sse/executors/maxai/constantsStore.ts @@ -0,0 +1,156 @@ +/** + * MaxAI signing-constants store + `ensure` gate. + * + * This is the persistence + freshness layer around ./constants.ts: + * - `getStoredMaxaiConstants()` reads the last-extracted, validated constants + * from OmniRoute settings (the sole source of the two secret-shaped keys). + * - `persistMaxaiConstants()` writes a freshly-extracted+validated set. + * - `ensureMaxaiConstants()` is the gate every signed path calls: it returns a + * usable constants object, extracting + persisting on a cold store, and is + * cheap (in-process memo) on the hot path. + * - `refreshMaxaiConstants()` force re-extracts (used by the daily token + * refresh) so a MaxAI-side rotation is picked up within a day. + * + * Design (William's Option 2): there is NO hardcoded fallback for the secret + * keys. If the store is empty AND a live extraction cannot be validated, the + * signer has no keys and MaxAI is simply unconfigured (callers surface a clear + * auth error) — we never sign with a guessed/stale secret. + */ +import type { MaxaiSigningConstants, FetchConstantsOptions } from "./constants.ts"; +import { + MAXAI_CONSTANTS_SETTINGS_KEY, + fetchMaxaiConstants, + validateMaxaiConstants, + MAXAI_DEFAULT_HEADER_NAMES, +} from "./constants.ts"; + +/** In-process memo so the hot signing path never touches the DB or network. */ +let memo: MaxaiSigningConstants | null = null; +let inflight: Promise | null = null; + +/** Reset the in-process memo (tests + after a forced refresh). */ +export function resetMaxaiConstantsMemo(): void { + memo = null; + inflight = null; +} + +/** + * Test seam: directly seed the in-process memo so unit tests that exercise the + * signed network functions don't need to also mock the bundle fetch. Not used in + * production paths (production goes through ensure/refresh → store → extraction). + */ +export function __setMaxaiConstantsForTest(constants: MaxaiSigningConstants | null): void { + memo = constants; + inflight = null; +} + +/** Shape-guard a persisted record before trusting it. */ +function isUsableConstants(v: unknown): v is MaxaiSigningConstants { + if (!v || typeof v !== "object") return false; + const c = v as Partial; + return ( + typeof c.hmacKey === "string" && + typeof c.aesKey === "string" && + typeof c.appVersion === "string" && + typeof c.ctxKey === "string" && + typeof c.docIdKey === "string" && + !!c.headerNames && + typeof c.headerNames === "object" + ); +} + +/** Read the persisted constants from settings (validated). Null when absent/invalid. */ +export async function getStoredMaxaiConstants(): Promise { + try { + const { getSettings } = await import("@/lib/db/settings"); + const settings = await getSettings(); + const raw = (settings as Record)[MAXAI_CONSTANTS_SETTINGS_KEY]; + if (!isUsableConstants(raw)) return null; + // Re-validate on read: a persisted record must still reproduce the vector. + const withDefaults: MaxaiSigningConstants = { + ...raw, + headerNames: { ...MAXAI_DEFAULT_HEADER_NAMES, ...raw.headerNames }, + }; + return validateMaxaiConstants(withDefaults) ? withDefaults : null; + } catch { + return null; + } +} + +/** Persist a freshly-extracted+validated constants set to settings. */ +export async function persistMaxaiConstants( + constants: MaxaiSigningConstants +): Promise { + try { + const { updateSettings } = await import("@/lib/db/settings"); + await updateSettings({ [MAXAI_CONSTANTS_SETTINGS_KEY]: constants }); + } catch { + // Non-fatal: a persist failure just means the next process re-extracts. + } +} + +/** + * Return usable MaxAI signing constants, extracting + persisting on a cold store. + * Order: in-process memo → persisted store → live extraction (validated) → null. + * Concurrent callers share a single in-flight extraction. Never throws. + */ +export async function ensureMaxaiConstants( + opts: FetchConstantsOptions = {} +): Promise { + if (memo) return memo; + + const stored = await getStoredMaxaiConstants(); + if (stored) { + memo = stored; + return memo; + } + + if (inflight) return inflight; + inflight = (async () => { + try { + const fresh = await fetchMaxaiConstants(opts); + if (fresh) { + memo = fresh; + await persistMaxaiConstants(fresh); + return fresh; + } + return null; + } finally { + inflight = null; + } + })(); + return inflight; +} + +/** + * Force a live re-extraction (used by the daily token refresh). If the fetched + * set validates AND differs from what's stored, it is persisted + memoized so a + * MaxAI-side rotation is picked up. Returns the current-best constants (the fresh + * set on success, else whatever was already stored/memoized). Never throws. + */ +export async function refreshMaxaiConstants( + opts: FetchConstantsOptions = {} +): Promise { + let fresh: MaxaiSigningConstants | null = null; + try { + fresh = await fetchMaxaiConstants(opts); + } catch { + fresh = null; + } + + if (fresh) { + const changed = + !memo || + memo.hmacKey !== fresh.hmacKey || + memo.aesKey !== fresh.aesKey || + memo.appVersion !== fresh.appVersion || + memo.ctxKey !== fresh.ctxKey || + memo.docIdKey !== fresh.docIdKey; + memo = fresh; + if (changed) await persistMaxaiConstants(fresh); + return fresh; + } + + // Fetch failed — keep serving whatever we already have (memo or store). + return memo ?? (await getStoredMaxaiConstants()); +} diff --git a/open-sse/executors/maxai/credentials.ts b/open-sse/executors/maxai/credentials.ts new file mode 100644 index 0000000000..3f12d55999 --- /dev/null +++ b/open-sse/executors/maxai/credentials.ts @@ -0,0 +1,96 @@ +/** + * MaxAI connection credential resolution. + * + * MaxAI's request signer needs three things bound together: the OpenAI-style + * `access_token` (Bearer, ~24h), the `device_id` that minted it (embedded in the + * signed `X-Authorization` — a mismatch is rejected), and the `user_id` (folded + * into the signature proof). OmniRoute stores these in the connection's + * `providerSpecificData` (minted by OmniRoute's own browser-mint flow — see + * maxaiBrowserLogin), so the router is self-contained and never reads any + * external (Hermes) token file. + * + * The access token is refreshed out-of-band by the browser-mint (the + * `/oauth/refresh_access_token` endpoint is deep-TLS-gated and cannot be called + * by any HTTP client — only a real browser passes), so this module only READS + * the stored credential; it does not attempt an HTTP refresh. + */ + +export interface MaxaiCredential { + accessToken: string; + deviceId: string; + userId: string; + /** ~1-year refresh token used for browserless access-token refresh (optional). */ + refreshToken?: string; +} + +type ProviderSpecificData = Record | null | undefined; + +function firstString(...values: unknown[]): string | null { + for (const v of values) { + if (typeof v === "string") { + // Raw browser LocalStorage sometimes wraps the device id in quotes. + const trimmed = v.trim().replace(/^"|"$/g, ""); + if (trimmed.length > 0) return trimmed; + } + } + return null; +} + +/** Decode the `user_id` from a MaxAI access JWT (subject.user_id or sub). No verify. */ +export function userIdFromJwt(accessToken: string): string | null { + try { + const seg = accessToken.split(".")[1]; + if (!seg) return null; + const b64 = seg.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (seg.length % 4)) % 4); + const claims = JSON.parse(Buffer.from(b64, "base64").toString("utf8")); + const subject = claims?.subject as { user_id?: unknown } | undefined; + if (typeof subject?.user_id === "string") return subject.user_id; + if (typeof claims?.sub === "string") return claims.sub; + return null; + } catch { + return null; + } +} + +/** Epoch seconds of the access-JWT `exp`, or 0 when undecodable. */ +export function accessTokenExpiry(accessToken: string): number { + try { + const seg = accessToken.split(".")[1]; + if (!seg) return 0; + const b64 = seg.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (seg.length % 4)) % 4); + const claims = JSON.parse(Buffer.from(b64, "base64").toString("utf8")); + return typeof claims?.exp === "number" ? claims.exp : 0; + } catch { + return 0; + } +} + +/** + * Resolve the MaxAI credential from a connection's providerSpecificData (with the + * OpenAI-style `access_token` optionally supplied separately by the caller, which + * is how OmniRoute threads the stored connection token). Returns null when not + * fully configured (all three of accessToken/deviceId/userId required). + */ +export function resolveMaxaiCredential( + psd: ProviderSpecificData, + accessTokenFromConnection?: string | null +): MaxaiCredential | null { + const accessToken = firstString( + accessTokenFromConnection, + psd?.maxaiAccessToken, + psd?.accessToken + ); + if (!accessToken) return null; + + const deviceId = firstString(psd?.maxaiDeviceId, psd?.deviceId); + if (!deviceId) return null; + + const userId = + firstString(psd?.maxaiUserId, psd?.userId) ?? userIdFromJwt(accessToken); + if (!userId) return null; + + const refreshToken = + firstString(psd?.maxaiRefreshToken, psd?.refreshToken) ?? undefined; + + return { accessToken, deviceId, userId, refreshToken }; +} diff --git a/open-sse/executors/maxai/documents.ts b/open-sse/executors/maxai/documents.ts new file mode 100644 index 0000000000..ce41d9490a --- /dev/null +++ b/open-sse/executors/maxai/documents.ts @@ -0,0 +1,266 @@ +/** + * MaxAI doc-RAG — inline document parts → /app/upload_document → doc_list. + * + * OmniRoute delivers attached documents INLINE in the chat request as base64 + * `file_data` content parts (OpenAI `{type:"file",file:{filename,file_data}}` / + * Responses `{type:"input_file",file_data}` / Claude `{type:"document",source}`). + * MaxAI's `/gpt/cwc/chat` cannot take binary docs inline; instead it references + * uploaded documents by a content-addressed `doc_id`. This module bridges the + * two: it detects inline base64 doc parts on the current turn, uploads each via + * the multipart `/app/upload_document` endpoint (signed like every MaxAI call), + * and returns the `doc_list` entries to attach to the chat body. + * + * doc_id is NOT random — MaxAI requires `doc_id = HMAC-SHA1(file_bytes, IT)` hex + * (createDocId/qM in the extension). A random id is rejected with a 400 + * "Inconsistency between server doc_id and request doc_id". The IT key is a + * public web-app constant (ships in the bundle), same class as the signing + * constants; kept here as a named constant (not a secret). + * + * The doc_list item shape is exactly what the live web app sends + * (site chunk 41068): `{ doc_id, doc_type, file_name }`. + */ +import { createHmac } from "node:crypto"; +import { buildMaxaiSignedHeaders } from "./signing.ts"; +import { ensureMaxaiConstants } from "./constantsStore.ts"; +import { maxaiStaticHeaders, MAXAI_BASE_URL } from "./protocol.ts"; + +export const MAXAI_UPLOAD_PATH = "/app/upload_document"; + +export interface MaxaiDocListEntry { + doc_id: string; + doc_type: string; + file_name: string; +} + +/** An inline document extracted from an OpenAI/Responses/Claude content part. */ +export interface InlineDoc { + filename: string; + mimeType: string; + bytes: Buffer; +} + +/** doc_id = HMAC-SHA1(file_bytes, docIdKey) hex. Content-addressed; MaxAI verifies it. */ +export function computeMaxaiDocId(bytes: Buffer, key: string): string { + if (!key) throw new Error("computeMaxaiDocId: missing docIdKey"); + return createHmac("sha1", key).update(bytes).digest("hex"); +} + +const TEXTUAL_EXT = /\.(txt|md|markdown|csv|json|log|xml|yaml|yml|tsv)$/i; +const CODE_EXT = + /\.(py|ipynb|js|jsx|ts|tsx|html?|css|java|cs|php|c|cpp|cxx|h|hpp|go|rs|rb|swift|kt|sh|sql)$/i; + +/** Classify the MaxAI doc_type from the filename/mime (extension taxonomy). */ +export function maxaiDocType(filename: string, mimeType: string): string { + const f = filename.toLowerCase(); + if (/\.pdf$/i.test(f) || mimeType === "application/pdf") return "page_content__pdf"; + if (CODE_EXT.test(f)) return "chat_file_code"; + return "chat_file"; // text / generic +} + +/** Whether a doc_type requires the pure_text field (text-extractable docs). */ +function requiresPureText(docType: string): boolean { + return docType === "chat_file" || docType === "chat_file_code"; +} + +/** + * Parse an OpenAI/Responses/Claude data-URL into raw bytes + mime. Returns null + * for anything that isn't an inline base64 payload (e.g. a remote URL or an + * already-uploaded file_id reference, which this bridge does not handle). + */ +export function parseInlineDataUrl(dataUrl: unknown): { mimeType: string; bytes: Buffer } | null { + if (typeof dataUrl !== "string") return null; + const m = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(dataUrl); + if (!m) return null; + const mimeType = m[1] || "application/octet-stream"; + const isBase64 = !!m[2]; + try { + const bytes = isBase64 + ? Buffer.from(m[3], "base64") + : Buffer.from(decodeURIComponent(m[3]), "utf8"); + if (bytes.length === 0) return null; + return { mimeType, bytes }; + } catch { + return null; + } +} + +/** + * Extract inline documents from the CURRENT (last user) turn of an OpenAI + * messages[] array. Recognizes the three OmniRoute-delivered shapes: + * OpenAI Chat: {type:"file", file:{filename, file_data|data}} + * Responses: {type:"input_file", filename, file_data} + * Claude: {type:"document", source:{type:"base64", media_type, data}} + * Only base64/data-URL payloads are handled (a bridge upload needs the bytes). + */ +export function extractCurrentTurnDocs( + messages: Array<{ role?: string; content?: unknown }> +): InlineDoc[] { + let content: unknown; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]?.role === "user") { + content = messages[i]?.content; + break; + } + } + if (!Array.isArray(content)) return []; + const docs: InlineDoc[] = []; + for (const part of content) { + if (!part || typeof part !== "object") continue; + const p = part as Record; + const type = p.type; + + if (type === "file" && p.file && typeof p.file === "object") { + const file = p.file as Record; + const filename = typeof file.filename === "string" ? file.filename : "upload.bin"; + const raw = (file.file_data ?? file.data) as unknown; + const parsed = parseInlineDataUrl(raw); + if (parsed) docs.push({ filename, mimeType: parsed.mimeType, bytes: parsed.bytes }); + } else if (type === "input_file") { + const filename = typeof p.filename === "string" ? p.filename : "upload.bin"; + const parsed = parseInlineDataUrl(p.file_data); + if (parsed) docs.push({ filename, mimeType: parsed.mimeType, bytes: parsed.bytes }); + } else if (type === "document" && p.source && typeof p.source === "object") { + const source = p.source as Record; + if (source.type === "base64" && typeof source.data === "string") { + const mimeType = + typeof source.media_type === "string" ? source.media_type : "application/octet-stream"; + try { + const bytes = Buffer.from(source.data, "base64"); + if (bytes.length > 0) { + const filename = + typeof p.title === "string" && p.title ? p.title : `document.${mimeExt(mimeType)}`; + docs.push({ filename, mimeType, bytes }); + } + } catch { + /* skip malformed base64 */ + } + } + } + } + return docs; +} + +function mimeExt(mime: string): string { + if (mime === "application/pdf") return "pdf"; + if (mime.startsWith("text/")) return "txt"; + return "bin"; +} + +/** Rough ~4-chars/token estimate; ceil, never 0 for non-empty text. */ +function estimateTokens(text: string): number { + return text ? Math.max(1, Math.ceil(text.length / 4)) : 0; +} + +/** Build the multipart/form-data body for /app/upload_document (fixed boundary). */ +export function buildUploadMultipart( + doc: InlineDoc, + docId: string, + docType: string, + boundary: string +): Buffer { + const isTextual = + requiresPureText(docType) && + (TEXTUAL_EXT.test(doc.filename) || + CODE_EXT.test(doc.filename) || + doc.mimeType.startsWith("text/")); + const pureText = isTextual ? doc.bytes.toString("utf8") : ""; + const tokens = String(estimateTokens(pureText)); + + const parts: Buffer[] = []; + const dash = `--${boundary}\r\n`; + const field = (name: string, value: string): void => { + parts.push( + Buffer.from(`${dash}Content-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`) + ); + }; + field("doc_id", docId); + field("doc_type", docType); + field("pure_text", pureText); + field("tokens", tokens); + field("doc_type_dependent_data", "{}"); + // The file part carries the raw bytes with a content-type. + parts.push( + Buffer.from( + `${dash}Content-Disposition: form-data; name="file"; filename="${doc.filename.replace(/"/g, "")}"\r\n` + + `Content-Type: ${doc.mimeType}\r\n\r\n` + ) + ); + parts.push(doc.bytes); + parts.push(Buffer.from(`\r\n--${boundary}--\r\n`)); + return Buffer.concat(parts); +} + +/** True if any SSE frame in the response is the terminal upload_done event. */ +export function sawUploadDone(text: string): boolean { + return /"event"\s*:\s*"upload_done"/.test(text) || text.includes("upload_done"); +} + +/** + * Upload one inline document to MaxAI and return its doc_list entry, or null on + * failure (upload failures are non-fatal: the chat proceeds without the doc). + */ +export async function uploadMaxaiDocument( + doc: InlineDoc, + auth: { accessToken: string; userId: string; deviceId: string }, + opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal } +): Promise { + const fetchImpl = opts?.fetchImpl ?? fetch; + const constants = await ensureMaxaiConstants({ fetchImpl, signal: opts?.signal }); + if (!constants) return null; + const docId = computeMaxaiDocId(doc.bytes, constants.docIdKey); + const docType = maxaiDocType(doc.filename, doc.mimeType); + const boundary = `----maxai${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`; + const bodyBuf = buildUploadMultipart(doc, docId, docType, boundary); + + // Sign like any request, but DROP the JSON content-type so we can set the + // multipart boundary content-type ourselves (v3h.signed_headers pattern). + const { "Content-Type": _drop, ...staticHeaders } = maxaiStaticHeaders(); + const headers: Record = { + ...staticHeaders, + ...buildMaxaiSignedHeaders( + { path: MAXAI_UPLOAD_PATH, userId: auth.userId, deviceId: auth.deviceId }, + constants + ), + Authorization: `Bearer ${auth.accessToken}`, + "Content-Type": `multipart/form-data; boundary=${boundary}`, + }; + + // Copy the multipart bytes into a fresh Uint8Array backed by a plain + // (non-shared) ArrayBuffer. `Buffer.buffer` is typed ArrayBufferLike + // (ArrayBuffer | SharedArrayBuffer) which isn't assignable to fetch's + // BodyInit; a freshly-allocated Uint8Array is the BodyInit shape the rest of + // the codebase uses for binary bodies (kimi-web.ts:397, conol-web.ts:529). + const bodyBytes = new Uint8Array(bodyBuf.byteLength); + bodyBytes.set(bodyBuf); + + try { + const resp = await fetchImpl(MAXAI_BASE_URL + MAXAI_UPLOAD_PATH, { + method: "POST", + headers, + body: bodyBytes, + signal: opts?.signal, + }); + if (!resp.ok) return null; + const text = await resp.text().catch(() => ""); + if (!sawUploadDone(text)) return null; + return { doc_id: docId, doc_type: docType, file_name: doc.filename }; + } catch { + return null; + } +} + +/** + * Upload every inline document on the current turn and return the doc_list to + * attach to the chat body. Failures are skipped (best-effort); the chat still + * proceeds. Empty array when there are no inline docs. + */ +export async function resolveMaxaiDocList( + messages: Array<{ role?: string; content?: unknown }>, + auth: { accessToken: string; userId: string; deviceId: string }, + opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal } +): Promise { + const docs = extractCurrentTurnDocs(messages); + if (docs.length === 0) return []; + const results = await Promise.all(docs.map((d) => uploadMaxaiDocument(d, auth, opts))); + return results.filter((r): r is MaxaiDocListEntry => r !== null); +} diff --git a/open-sse/executors/maxai/emailLogin.ts b/open-sse/executors/maxai/emailLogin.ts new file mode 100644 index 0000000000..676b5c1fae --- /dev/null +++ b/open-sse/executors/maxai/emailLogin.ts @@ -0,0 +1,234 @@ +/** + * MaxAI email login — browserless, two signed HTTP calls (a codex-style + * device-pair flow, no browser / camoufox / Google navigation). + * + * MaxAI's web app offers email-code sign-in as an alternative to Google OAuth. + * Both steps are plain signed POSTs carrying the same per-request X-Authorization + * signature as every other MaxAI call (see ./signing.ts); both paths are in the + * signer's BLANK_USER_ROUTES (they sign with a blank user_id, correct — there is + * no user id yet before login). Ported byte-faithfully from the MaxAI web-app + * bundle (chunk 86042: signInWithEmail line ~5623, verifySecretCode line ~5665). + * + * Step 1 — request a code (POST /oauth/signin_with_email): + * body { email, app: "maxai_webapp" } -> { status: "OK" } (code emailed) + * + * Step 2 — verify the code (POST /oauth/verify_secret_code): + * body { email, secret_code, app: "maxai_webapp", env: "prod_co", + * client_user_id, ...nullable attribution fields } + * -> { auth_user: { accessToken, refreshToken, userId, email, clientUserId } } + * + * The `device_id` folded into the signature is a CLIENT-GENERATED UUID (the web + * app's getAPIFetchDeviceID = "return stored, else generate + persist"), so the + * caller mints one with randomUUID() and reuses it across BOTH steps and for all + * subsequent chat / refresh calls (the minted token is bound to that device id). + * `client_user_id` is likewise a client UUID. + */ +import { buildMaxaiSignedHeaders } from "./signing.ts"; +import { maxaiStaticHeaders, MAXAI_BASE_URL } from "./protocol.ts"; +import { ensureMaxaiConstants } from "./constantsStore.ts"; +import type { MaxaiSigningConstants } from "./constants.ts"; + +export const MAXAI_SIGNIN_EMAIL_PATH = "/oauth/signin_with_email"; +export const MAXAI_VERIFY_CODE_PATH = "/oauth/verify_secret_code"; + +/** The web app's env tag for production email verification. */ +const MAXAI_VERIFY_ENV = "prod_co"; + +export interface MaxaiEmailRequestInput { + email: string; + /** Client device UUID (mint once, reuse for verify + all later calls). */ + deviceId: string; + signal?: AbortSignal | null; + fetchImpl?: typeof fetch; +} + +export interface MaxaiEmailVerifyInput { + email: string; + /** The 6-digit code the user received by email. */ + code: string; + /** Same device UUID used in the request step. */ + deviceId: string; + /** Client-user UUID (mint once alongside deviceId). */ + clientUserId: string; + signal?: AbortSignal | null; + fetchImpl?: typeof fetch; +} + +export interface MaxaiEmailRequestResult { + ok: boolean; + status: number; + error?: string; +} + +/** The full credential set returned by a successful verify. */ +export interface MaxaiLoginCredential { + accessToken: string; + refreshToken: string; + userId: string; + email: string; + deviceId: string; + clientUserId: string; +} + +export interface MaxaiEmailVerifyResult { + ok: boolean; + status: number; + credential?: MaxaiLoginCredential; + error?: string; +} + +/** Build signed headers for a blank-user OAuth route (user id is blanked in the proof). */ +function signedOauthHeaders( + path: string, + deviceId: string, + constants: MaxaiSigningConstants +): Record { + return { + ...maxaiStaticHeaders(), + // userId is blanked inside computeMaxaiProof for BLANK_USER_ROUTES; pass "". + ...buildMaxaiSignedHeaders({ path, userId: "", deviceId }, constants), + }; +} + +/** Pull a nested-or-top-level field from a MaxAI response body ({data:{...}} | {...}). */ +function pick(body: Record, key: string): T | undefined { + const data = body?.data as Record | undefined; + const nested = data?.[key]; + if (nested !== undefined) return nested as T; + return body?.[key] as T | undefined; +} + +/** + * Step 1: ask MaxAI to email a sign-in code. Never throws. + * Returns ok=true when the server acknowledges (status "OK"). + */ +export async function requestMaxaiEmailCode( + input: MaxaiEmailRequestInput +): Promise { + const doFetch = input.fetchImpl ?? fetch; + if (!input.email || !input.deviceId) { + return { ok: false, status: 0, error: "missing email or deviceId" }; + } + + // Initial login is the FIRST signed call — ensure we have live signing constants + // (extracted from MaxAI's public bundle) before signing. No keys = cannot sign. + const constants = await ensureMaxaiConstants({ fetchImpl: doFetch, signal: input.signal }); + if (!constants) { + return { ok: false, status: 0, error: "MaxAI signing constants unavailable (extraction failed)" }; + } + + let res: Response; + try { + res = await doFetch(MAXAI_BASE_URL + MAXAI_SIGNIN_EMAIL_PATH, { + method: "POST", + headers: signedOauthHeaders(MAXAI_SIGNIN_EMAIL_PATH, input.deviceId, constants), + body: JSON.stringify({ email: input.email, app: "maxai_webapp" }), + signal: input.signal ?? undefined, + }); + } catch (err) { + return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) }; + } + + const raw = await res.text().catch(() => ""); + if (res.status !== 200) { + return { ok: false, status: res.status, error: raw.slice(0, 200) }; + } + let body: Record = {}; + try { + body = JSON.parse(raw) as Record; + } catch { + return { ok: false, status: res.status, error: "unparseable signin response" }; + } + if (pick(body, "status") === "OK") return { ok: true, status: 200 }; + const detail = pick(body, "detail") || pick(body, "msg") || "sign-in request failed"; + return { ok: false, status: res.status, error: String(detail).slice(0, 200) }; +} + +/** + * Step 2: verify the emailed code and return the full credential. Never throws. + * On success the caller persists the credential to the connection's + * providerSpecificData (accessToken/refreshToken/deviceId/userId). + */ +export async function verifyMaxaiEmailCode( + input: MaxaiEmailVerifyInput +): Promise { + const doFetch = input.fetchImpl ?? fetch; + if (!input.email || !input.code || !input.deviceId) { + return { ok: false, status: 0, error: "missing email, code, or deviceId" }; + } + + const constants = await ensureMaxaiConstants({ fetchImpl: doFetch, signal: input.signal }); + if (!constants) { + return { ok: false, status: 0, error: "MaxAI signing constants unavailable (extraction failed)" }; + } + + const requestBody = { + email: input.email, + secret_code: input.code, + app: "maxai_webapp", + env: MAXAI_VERIFY_ENV, + invitation_code: null, + ref: "", + client_reference_id: null, + client_user_id: input.clientUserId, + client_price_version: null, + client_onboarding_version: null, + user_acquisition_channel: "", + gclid: null, + }; + + let res: Response; + try { + res = await doFetch(MAXAI_BASE_URL + MAXAI_VERIFY_CODE_PATH, { + method: "POST", + headers: signedOauthHeaders(MAXAI_VERIFY_CODE_PATH, input.deviceId, constants), + body: JSON.stringify(requestBody), + signal: input.signal ?? undefined, + }); + } catch (err) { + return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) }; + } + + const raw = await res.text().catch(() => ""); + if (res.status !== 200) { + return { ok: false, status: res.status, error: raw.slice(0, 200) }; + } + let body: Record = {}; + try { + body = JSON.parse(raw) as Record; + } catch { + return { ok: false, status: res.status, error: "unparseable verify response" }; + } + + const authUser = pick>(body, "auth_user"); + const status = pick(body, "status"); + if (status === "OK" && authUser && typeof authUser === "object") { + const accessToken = String(authUser.accessToken ?? authUser.access_token ?? ""); + const refreshToken = String(authUser.refreshToken ?? authUser.refresh_token ?? ""); + const userId = String(authUser.userId ?? authUser.user_id ?? ""); + if (!accessToken || !refreshToken) { + return { ok: false, status: 200, error: "verify OK but token fields missing" }; + } + return { + ok: true, + status: 200, + credential: { + accessToken, + refreshToken, + userId, + email: String(authUser.email ?? input.email), + deviceId: input.deviceId, + clientUserId: String(authUser.clientUserId ?? authUser.client_user_id ?? input.clientUserId), + }, + }; + } + + // 10119 is MaxAI's "code expired / too many attempts" signal; surface it. + const code = pick(body, "code"); + const detail = pick(body, "detail") || pick(body, "msg"); + const error = + code === 10119 + ? "Code expired or too many attempts — request a new code." + : String(detail || "Invalid code. Check the code and try again.").slice(0, 200); + return { ok: false, status: res.status, error }; +} diff --git a/open-sse/executors/maxai/protocol.ts b/open-sse/executors/maxai/protocol.ts new file mode 100644 index 0000000000..d3bb117d8f --- /dev/null +++ b/open-sse/executors/maxai/protocol.ts @@ -0,0 +1,266 @@ +/** + * MaxAI web-app protocol — request bodies, header assembly, and OpenAI→MaxAI + * context flattening. Ported from the MaxAI v3 Python client (chat/request.py, + * translation/openai_in.py, translation/turn_render.py) and live-verified against + * the real `/gpt/cwc/chat` endpoint. + * + * MaxAI is a stateless-full-history provider on the OmniRoute side: we send the + * ENTIRE flattened transcript in `message_content[0].text` every turn, always + * with `chat_history: []`, and mint a fresh `conversation_id` per request. The + * live probe proved a bare `/gpt/cwc/chat` (no upsert/add_messages bookkeeping) + * honors `model_name` and serves the real paid model, so no bookkeeping is sent. + */ +import { randomUUID } from "node:crypto"; + +export const MAXAI_BASE_URL = "https://api.maxai.me"; +export const MAXAI_CHAT_PATH = "/gpt/cwc/chat"; +export const MAXAI_MODELS_CONFIG_PATH = "/models/get_config"; + +/** Static Firefox-150 identity headers sent on every MaxAI request. */ +export function maxaiStaticHeaders(): Record { + return { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0", + Accept: "*/*", + "Accept-Language": "en-CA,en;q=0.9", + Origin: "https://www.maxai.co", + Referer: "https://www.maxai.co/", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "cross-site", + "Content-Type": "application/json", + }; +} + +// ── Chat body ─────────────────────────────────────────────────────────────── +// Field ORDER is pinned (it is part of the HTTP/2 request fingerprint). +const CHAT_FIELD_ORDER = [ + "chat_mode", + "conversation_id", + "chat_history", + "message_content", + "chrome_extension_version", + "model_name", + "prompt_id", + "prompt_name", + "prompt_inputs", + "doc_list", + "event_source", + "streaming", + "prompt_type", + "feature_name", + "source_type", + "platform_feature", +] as const; + +export function newConversationId(): string { + return randomUUID(); +} + +export function buildMaxaiChatBody(opts: { + conversationId: string; + text: string; + modelName: string; + language?: string; + relatedQuestionCnt?: string; + /** Extracted app_version for chrome_extension_version (from the signing constants). */ + appVersion: string; + /** + * Vision input: current-turn image URLs (data: or http(s):) to attach to the + * request. MaxAI's `/gpt/cwc/chat` accepts inline OpenAI-shaped image parts in + * `message_content` alongside the text part. Empty/omitted = text-only (the + * default, byte-identical to the pre-vision body). + */ + imageUrls?: string[]; + /** + * Doc-RAG: uploaded-document references (from /app/upload_document). Each entry + * carries at least `{ doc_id, doc_type, file_name }`. Typed as a loose object + * array so callers can pass their concrete `MaxaiDocListEntry[]` without an + * index-signature cast; the body only serializes it into `doc_list`. + * Empty/omitted = no docs (the default `doc_list: []`). + */ + docList?: ReadonlyArray; +}): Record { + // message_content is a typed-parts array: the text part ALWAYS leads (so the + // flattened transcript stays first and the no-image path is unchanged), then + // any image_url parts ride alongside. Mirrors the OpenAI multimodal shape, + // which MaxAI passes through (openai-to-cursor.ts vision pattern). + const messageContent: Array> = [{ type: "text", text: opts.text }]; + for (const url of opts.imageUrls ?? []) { + if (typeof url === "string" && url) { + messageContent.push({ type: "image_url", image_url: { url } }); + } + } + const values: Record = { + chat_mode: "pro_chat", + conversation_id: opts.conversationId, + chat_history: [], + message_content: messageContent, + chrome_extension_version: opts.appVersion, + model_name: opts.modelName, + prompt_id: "chat", + prompt_name: "chat", + prompt_inputs: { + RELATED_QUESTION_CNT: opts.relatedQuestionCnt ?? "5", + AI_RESPONSE_LANGUAGE: opts.language ?? "English", + }, + doc_list: opts.docList ?? [], + event_source: "web", + streaming: true, + prompt_type: "freestyle", + feature_name: "immersive_chat", + source_type: "NA", + platform_feature: "web_app", + }; + const ordered: Record = {}; + for (const k of CHAT_FIELD_ORDER) ordered[k] = values[k]; + return ordered; +} + +// ── OpenAI messages[] → MaxAI single text block ────────────────────────────── +interface OpenAiMessage { + role?: string; + content?: unknown; + tool_calls?: unknown; + tool_call_id?: string; +} + +const ROLE_LABEL: Record = { + system: "System", + user: "User", + assistant: "Assistant", +}; +const HISTORY_HEADER = "=== Conversation so far (for context) ==="; +const CURRENT_HEADER = "=== Current request (respond to THIS) ==="; + +/** Flatten OpenAI `content` (string or multipart array) to text. */ +export function contentToText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => + part && typeof part === "object" && (part as { type?: string }).type === "text" + ? String((part as { text?: unknown }).text ?? "") + : "" + ) + .filter(Boolean) + .join("\n"); + } + return ""; +} + +/** + * Extract image_url URLs from the CURRENT (last user) turn of an OpenAI + * messages[] array. MaxAI is stateless-full-history, so we attach only the + * current turn's images (history images would be re-sent every request and + * bloat the body). Returns raw url strings (data: or http(s):) in order. + */ +export function extractCurrentTurnImages(messages: OpenAiMessage[]): string[] { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]?.role === "user") { + const content = messages[i]?.content; + if (!Array.isArray(content)) return []; + const urls: string[] = []; + for (const part of content) { + if (part && typeof part === "object" && (part as { type?: unknown }).type === "image_url") { + const imageUrl = (part as { image_url?: unknown }).image_url; + if (typeof imageUrl === "string" && imageUrl) { + urls.push(imageUrl); + } else if ( + imageUrl && + typeof imageUrl === "object" && + typeof (imageUrl as { url?: unknown }).url === "string" && + (imageUrl as { url: string }).url + ) { + urls.push((imageUrl as { url: string }).url); + } + } + } + return urls; + } + } + return []; +} + +/** Render OpenAI tool_calls[] as the prompted `` text MaxAI understands. */ +function toolCallsToText(toolCalls: unknown): string { + if (!Array.isArray(toolCalls)) return ""; + const blocks: string[] = []; + for (const call of toolCalls) { + const fn = (call as { function?: { name?: unknown; arguments?: unknown } })?.function; + if (!fn) continue; + const name = typeof fn.name === "string" ? fn.name : ""; + let args = fn.arguments; + if (typeof args !== "string") { + try { + args = JSON.stringify(args ?? {}); + } catch { + args = "{}"; + } + } + blocks.push(`${JSON.stringify({ name, arguments: args })}`); + } + return blocks.join("\n"); +} + +/** Render one non-system turn as a labeled block, or null to skip. */ +function renderTurn(message: OpenAiMessage): string | null { + const role = message.role; + const text = contentToText(message.content).trim(); + if (role === "tool") { + const id = message.tool_call_id ? ` tool_call_id="${message.tool_call_id}"` : ""; + return `\n${text}\n`; + } + if (role === "assistant" && message.tool_calls) { + const calls = toolCallsToText(message.tool_calls); + const body = text ? `${text}\n${calls}`.trim() : calls; + return `Assistant: ${body}`; + } + if (!text) return null; + const label = ROLE_LABEL[role ?? "user"] ?? "User"; + return `${label}: ${text}`; +} + +/** + * Assemble the full structured context into one text block: system text leads, + * prior turns render as a labeled transcript, and the LAST user turn is fenced + * under a CURRENT header so a weak model answers THIS turn. Mirrors MaxAI v3 + * translation/openai_in.py::assemble_context. + */ +export function assembleMaxaiContext(messages: OpenAiMessage[]): string { + // Find the last user turn (the current request). + let curIdx = -1; + let current = ""; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]?.role === "user") { + curIdx = i; + current = contentToText(messages[i].content).trim(); + break; + } + } + const systemParts: string[] = []; + const historyParts: string[] = []; + for (let i = 0; i < messages.length; i++) { + if (i === curIdx) continue; + const m = messages[i]; + if (m?.role === "system") { + const t = contentToText(m.content).trim(); + if (t) systemParts.push(t); + continue; + } + const block = renderTurn(m); + if (block) historyParts.push(block); + } + const out: string[] = [...systemParts]; + if (historyParts.length && current) { + out.push(HISTORY_HEADER + "\n\n" + historyParts.join("\n\n")); + } else { + out.push(...historyParts); + } + if (current) { + const head = historyParts.length ? `${CURRENT_HEADER}\n\n` : ""; + out.push(head + current); + } + if (out.length === 0) throw new Error("no content to send to MaxAI"); + return out.join("\n\n"); +} diff --git a/open-sse/executors/maxai/refresh.ts b/open-sse/executors/maxai/refresh.ts new file mode 100644 index 0000000000..79bf3ba873 --- /dev/null +++ b/open-sse/executors/maxai/refresh.ts @@ -0,0 +1,149 @@ +/** + * MaxAI access-token refresh — browserless, via one signed HTTP call. + * + * MaxAI issues two tokens: a ~24h `accessToken` and a ~1-year `refreshToken`. + * The web app refreshes the access token by POSTing the refresh token to + * `/oauth/refresh_access_token` (web-app chunk 86042, `refreshAccessToken`). That + * endpoint carries the SAME per-request `X-Authorization` signature as every other + * MaxAI call (see ./signing.ts) — it is NOT a browser-only OAuth hop. A residential + * Firefox-TLS client (wreq-js firefox_150, the OmniRoute egress overlay) passes the + * TLS gate, so OmniRoute mints fresh access tokens itself with no browser. + * + * The refresh token is minted out-of-band, once, by the browser Google-OAuth flow + * (see maxaiBrowserLogin) and only needs re-minting when it itself expires (~yearly). + * This module handles the routine daily refresh. + * + * Request shape (byte-faithful to the web app): + * POST https://api.maxai.me/oauth/refresh_access_token + * Authorization: Bearer // the REFRESH token, not access + * noAuthLogout: true + * X-Authorization + X-App/X-Browser headers // standard signing + * body: {"app":"maxai_webapp"} // the app's `params` -> JSON body + * -> 200 { data: { access_token } } // a fresh ~24h access JWT + * + * The signed path is the BARE pathname (no query string); the `app` field travels + * in the body. `user_id` folds into the signature and is read from the refresh + * token's own JWT subject (per the web app), falling back to a provided userId. + */ +import { buildMaxaiSignedHeaders } from "./signing.ts"; +import { maxaiStaticHeaders, MAXAI_BASE_URL } from "./protocol.ts"; +import { userIdFromJwt, accessTokenExpiry } from "./credentials.ts"; +import { refreshMaxaiConstants } from "./constantsStore.ts"; + +export const MAXAI_REFRESH_PATH = "/oauth/refresh_access_token"; + +/** How close to expiry (seconds) an access token may be before we refresh it. */ +export const MAXAI_REFRESH_MARGIN_SECONDS = 60 * 60; // 1h + +export interface MaxaiRefreshInput { + refreshToken: string; + deviceId: string; + /** Optional explicit user id; defaults to the refresh token's JWT subject. */ + userId?: string; + signal?: AbortSignal | null; + /** Injectable fetch for tests (defaults to the ambient patched fetch). */ + fetchImpl?: typeof fetch; +} + +export interface MaxaiRefreshResult { + ok: boolean; + accessToken?: string; + /** access token expiry (epoch seconds), when a token was minted. */ + expiresAt?: number; + status: number; + error?: string; +} + +/** True when an access token is missing, unparseable, or within the margin of expiry. */ +export function maxaiAccessTokenNeedsRefresh( + accessToken: string | null | undefined, + marginSeconds: number = MAXAI_REFRESH_MARGIN_SECONDS, + now: () => number = Date.now +): boolean { + if (!accessToken) return true; + const exp = accessTokenExpiry(accessToken); + if (!exp) return true; + return exp - now() / 1000 <= marginSeconds; +} + +/** + * Mint a fresh access token from a refresh token via one signed HTTP POST. + * Never throws; returns a structured result the caller can branch on. + */ +export async function maxaiRefreshAccessToken( + input: MaxaiRefreshInput +): Promise { + const doFetch = input.fetchImpl ?? fetch; + const userId = input.userId || userIdFromJwt(input.refreshToken) || ""; + if (!input.refreshToken || !input.deviceId || !userId) { + return { ok: false, status: 0, error: "missing refreshToken, deviceId, or userId" }; + } + + // Daily refresh is our freshness checkpoint for the signing constants: re-extract + // from MaxAI's public bundle so a MaxAI-side key/app-version rotation is picked up + // within a day (self-heal). refreshMaxaiConstants persists a changed set and + // returns the current-best; on a fetch miss it returns whatever's already stored. + const constants = await refreshMaxaiConstants({ fetchImpl: doFetch, signal: input.signal }); + if (!constants) { + return { ok: false, status: 0, error: "MaxAI signing constants unavailable (extraction failed)" }; + } + + const signed = buildMaxaiSignedHeaders( + { + path: MAXAI_REFRESH_PATH, + userId, + deviceId: input.deviceId, + }, + constants + ); + const headers: Record = { + ...maxaiStaticHeaders(), + ...signed, + Authorization: `Bearer ${input.refreshToken}`, + noAuthLogout: "true", + "Content-Type": "application/json", + }; + + let res: Response; + try { + res = await doFetch(MAXAI_BASE_URL + MAXAI_REFRESH_PATH, { + method: "POST", + headers, + body: JSON.stringify({ app: "maxai_webapp" }), + signal: input.signal ?? undefined, + }); + } catch (err) { + return { + ok: false, + status: 0, + error: err instanceof Error ? err.message : String(err), + }; + } + + const raw = await res.text().catch(() => ""); + if (res.status !== 200) { + return { ok: false, status: res.status, error: raw.slice(0, 200) }; + } + + let accessToken = ""; + try { + const parsed = JSON.parse(raw) as { + data?: { access_token?: unknown }; + access_token?: unknown; + }; + const candidate = parsed?.data?.access_token ?? parsed?.access_token; + if (typeof candidate === "string") accessToken = candidate; + } catch { + return { ok: false, status: res.status, error: "unparseable refresh response" }; + } + if (!accessToken) { + return { ok: false, status: res.status, error: "refresh response had no access_token" }; + } + + return { + ok: true, + status: 200, + accessToken, + expiresAt: accessTokenExpiry(accessToken) || undefined, + }; +} diff --git a/open-sse/executors/maxai/signing.ts b/open-sse/executors/maxai/signing.ts new file mode 100644 index 0000000000..629d967f32 --- /dev/null +++ b/open-sse/executors/maxai/signing.ts @@ -0,0 +1,151 @@ +/** + * MaxAI web-app signing — the `X-Authorization` per-request signature. + * + * The scheme (validated byte-exact against real captured `X-Authorization` blobs): + * + * sign_str = `${appVersion}:${req_time}:${path}:${uid}` + * sha1 = HMAC_SHA1_hex(sign_str, key=`${req_time}:${hmacKey}`) + * p = SM3_hex(`${req_time}:${sha1}:${hmacKey}`) + * payload = { X-Client-Domain, X-Client-Path(page url), X-Random(6-digit), + * t(ms), p, d(device_id), :{ a: context } } + * X-Authorization = base64( "Salted__" + salt8 + AES-256-CBC(payloadJSON) ) + * with key/iv from OpenSSL EVP_BytesToKey(MD5, aesKey, salt) + * + * All primitives are in `node:crypto` (HMAC-SHA1, SM3 via OpenSSL 3, MD5, + * AES-256-CBC); no external dependency. + * + * KEYING MATERIAL IS NOT HARDCODED. The `hmacKey` and `aesKey` are the CLIENT-SIDE + * constants MaxAI's own web app ships verbatim in its public JS bundle. Rather + * than pin them here, OmniRoute extracts them live (see ./constants.ts) and passes + * a `MaxaiSigningConstants` object into every signing call. There is deliberately + * NO in-code default for the two keys: a signer with no extracted keys cannot sign + * (the caller surfaces a clear auth error) — we never sign with a guessed secret. + * The non-secret STRUCTURAL fields (appVersion, ctxKey, header names) carry safe + * defaults so a transient parse miss can't break an otherwise-working signer. + */ +import { createHmac, createHash, createCipheriv, randomBytes } from "node:crypto"; +import type { MaxaiSigningConstants, MaxaiHeaderNames } from "./constants.ts"; +import { MAXAI_DEFAULT_HEADER_NAMES } from "./constants.ts"; + +const CLIENT_DOMAIN = "maxai.co"; +/** Default browser page URL recorded verbatim as X-Client-Path (NOT the API path). */ +export const MAXAI_DEFAULT_PAGE = "https://www.maxai.co/app/"; +/** Only /oauth/* routes blank the user_id inside the signature. */ +const BLANK_USER_ROUTES = new Set([ + "/oauth/signin_with_email", + "/oauth/signin_with_google", + "/oauth/verify_secret_code", +]); + +const MAGIC = Buffer.from("Salted__", "ascii"); + +function hmacSha1Hex(message: string, key: string): string { + return createHmac("sha1", Buffer.from(key, "utf8")).update(Buffer.from(message, "utf8")).digest("hex"); +} + +function sm3Hex(message: string): string { + return createHash("sm3").update(Buffer.from(message, "utf8")).digest("hex"); +} + +/** OpenSSL EVP_BytesToKey with MD5 (CryptoJS default for a string passphrase). */ +function evpBytesToKey( + passphrase: string, + salt: Buffer, + keyLen = 32, + ivLen = 16 +): { key: Buffer; iv: Buffer } { + let derived = Buffer.alloc(0); + let block = Buffer.alloc(0); + const pass = Buffer.from(passphrase, "utf8"); + while (derived.length < keyLen + ivLen) { + block = createHash("md5").update(Buffer.concat([block, pass, salt])).digest(); + derived = Buffer.concat([derived, block]); + } + return { key: derived.subarray(0, keyLen), iv: derived.subarray(keyLen, keyLen + ivLen) }; +} + +/** + * Reproduce CryptoJS.AES.encrypt(text, passphrase).toString() (OpenSSL Salted__ + * envelope). `passphrase` (the extracted aesKey) is REQUIRED — there is no default. + */ +export function maxaiAesEncrypt(plaintext: string, passphrase: string, salt?: Buffer): string { + if (!passphrase) throw new Error("maxaiAesEncrypt: missing aesKey"); + const s = salt ?? randomBytes(8); + const { key, iv } = evpBytesToKey(passphrase, s); + const cipher = createCipheriv("aes-256-cbc", key, iv); // PKCS7 padding is the default + const body = Buffer.concat([cipher.update(Buffer.from(plaintext, "utf8")), cipher.final()]); + return Buffer.concat([MAGIC, s, body]).toString("base64"); +} + +/** + * Compute the SM3 `p` proof for an API `path` at `reqTime` ms. `hmacKey` and + * `appVersion` (both extracted) are REQUIRED — there is no in-code default. + */ +export function computeMaxaiProof( + path: string, + reqTime: number, + userId: string, + hmacKey: string, + appVersion: string +): string { + if (!hmacKey) throw new Error("computeMaxaiProof: missing hmacKey"); + if (!appVersion) throw new Error("computeMaxaiProof: missing appVersion"); + const p = path.endsWith("?") ? path.slice(0, -1) : path; + const uid = BLANK_USER_ROUTES.has(p) ? "" : userId; + const signStr = `${appVersion}:${reqTime}:${p}:${uid}`; + const sha1 = hmacSha1Hex(signStr, `${reqTime}:${hmacKey}`); + return sm3Hex(`${reqTime}:${sha1}:${hmacKey}`); +} + +export interface MaxaiSignInput { + /** API path being signed, e.g. "/gpt/cwc/chat". */ + path: string; + userId: string; + deviceId: string; + /** Browser page URL for X-Client-Path (defaults to the app page). */ + pageUrl?: string; + /** Context slot value (defaults to "" — the wire default). */ + context?: string; + /** Injectable clock/random for deterministic tests. */ + now?: () => number; + random?: () => string; +} + +/** + * Build the signing headers (X-Authorization plus the X-App and X-Browser + * companions) for one request. `device_id` MUST match the device that minted the + * token, or the server rejects the signature. + * + * `constants` carries the extracted keying material + structural labels. It is + * REQUIRED: callers resolve it via `ensureMaxaiConstants()` before signing. + */ +export function buildMaxaiSignedHeaders( + input: MaxaiSignInput, + constants: MaxaiSigningConstants +): Record { + const reqTime = (input.now ?? (() => Date.now()))(); + const random = + input.random?.() ?? String((randomBytes(4).readUInt32BE(0) % 900000) + 100000); + const h: MaxaiHeaderNames = { ...MAXAI_DEFAULT_HEADER_NAMES, ...constants.headerNames }; + const ctxKey = constants.ctxKey; + const appVersion = constants.appVersion; + // Key ORDER matters — it is signed as a compact JSON string. + const payload: Record = { + [h.clientDomain]: CLIENT_DOMAIN, + [h.clientPath]: input.pageUrl ?? MAXAI_DEFAULT_PAGE, + [h.random]: random, + [h.tSlot]: reqTime, + [h.pSlot]: computeMaxaiProof(input.path, reqTime, input.userId, constants.hmacKey, appVersion), + [h.dSlot]: input.deviceId, + [ctxKey]: { a: input.context ?? "" }, + }; + const blob = maxaiAesEncrypt(JSON.stringify(payload), constants.aesKey); + return { + [h.browserName]: "Firefox", + [h.browserVersion]: "150.0", + [h.browserMajor]: "150", + [h.appVersionHeader]: appVersion, + [h.appEnvHeader]: h.appEnvValue, + [h.authorization]: blob, + }; +} diff --git a/open-sse/executors/maxai/stream.ts b/open-sse/executors/maxai/stream.ts new file mode 100644 index 0000000000..4d865d6a7d --- /dev/null +++ b/open-sse/executors/maxai/stream.ts @@ -0,0 +1,101 @@ +/** + * MaxAI SSE stream handling — frame parsing, incremental `` split, and + * token estimation. Ported from the MaxAI v3 Python client (translation/sse.py, + * translation/stream.py, translation/think_split.py, translation/token_usage.py). + * + * MaxAI's `/gpt/cwc/chat` response is `text/event-stream`: `data: {json}` frames + * separated by blank lines. A text delta is a frame with + * `data_key === "text" && need_merge` truthy; its content is `frame.text`. + * Reasoning is emitted inline wrapped in ``; everything inside is + * reasoning, everything after the close tag is the visible answer. MaxAI returns + * no usage frame, so tokens are estimated (~4 chars/token). + */ + +/** Parse the text deltas out of a raw SSE body (batch). */ +export function parseMaxaiSseText(raw: string): string { + let out = ""; + for (const line of raw.split("\n")) { + const s = line.trim(); + if (!s.startsWith("data:")) continue; + const js = s.slice(5).trim(); + if (!js || js === "[DONE]") continue; + try { + const frame = JSON.parse(js) as { data_key?: unknown; need_merge?: unknown; text?: unknown }; + if (frame.data_key === "text" && frame.need_merge) { + out += typeof frame.text === "string" ? frame.text : ""; + } + } catch { + /* ignore non-JSON keepalive frames */ + } + } + return out; +} + +/** True when a decoded SSE frame is a mergeable text delta. */ +export function isMaxaiTextFrame( + frame: unknown +): frame is { data_key: "text"; need_merge: true; text: string } { + const f = frame as { data_key?: unknown; need_merge?: unknown; text?: unknown }; + return f?.data_key === "text" && Boolean(f?.need_merge) && typeof f?.text === "string"; +} + +const OPEN = ""; +const CLOSE = ""; +const HOLD = Math.max(OPEN.length, CLOSE.length) - 1; + +/** + * Stateful streaming classifier of text into (reasoning, answer). Handles a tag + * split across frames by holding a short tail. Before `` opens, text is + * answer; if no `` ever appears the whole stream is answer. + */ +export class ThinkSplitter { + private buf = ""; + private inThink = false; + + feed(delta: string): { reasoning: string; answer: string } { + this.buf += delta; + let reasoning = ""; + let answer = ""; + for (;;) { + const tag = this.inThink ? CLOSE : OPEN; + const idx = this.buf.indexOf(tag); + if (idx === -1) break; + const before = this.buf.slice(0, idx); + if (this.inThink) reasoning += before; + else answer += before; + this.buf = this.buf.slice(idx + tag.length); + this.inThink = !this.inThink; + } + // Emit everything except a short tail that might begin a tag. + const safe = this.buf.length > HOLD ? this.buf.slice(0, this.buf.length - HOLD) : ""; + if (safe) { + this.buf = this.buf.slice(safe.length); + if (this.inThink) reasoning += safe; + else answer += safe; + } + return { reasoning, answer }; + } + + flush(): { reasoning: string; answer: string } { + const tail = this.buf; + this.buf = ""; + if (!tail) return { reasoning: "", answer: "" }; + return this.inThink ? { reasoning: tail, answer: "" } : { reasoning: "", answer: tail }; + } +} + +/** Split a fully-collected answer into { reasoning, answer } (batch/non-stream). */ +export function splitThink(full: string): { reasoning: string; answer: string } { + const splitter = new ThinkSplitter(); + const a = splitter.feed(full); + const b = splitter.flush(); + return { + reasoning: a.reasoning + b.reasoning, + answer: a.answer + b.answer, + }; +} + +/** MaxAI returns no token counts; estimate ~4 chars/token. */ +export function estimateMaxaiTokens(text: string): number { + return Math.max(0, Math.ceil((text?.length ?? 0) / 4)); +} diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 2eb758fd41..28cf667ae6 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -56,6 +56,7 @@ import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvid import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.ts"; import { handleCursorAgentImageGeneration } from "./imageGeneration/providers/cursorAgentImage.ts"; import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; +import { handleMaxaiImageGeneration } from "./imageGeneration/providers/maxaiImage.ts"; import { handleAdobeFireflyImageGeneration } from "./imageGeneration/providers/adobeFirefly.ts"; import { handleAlibabaImageGeneration } from "./imageGeneration/providers/alibabaImage.ts"; import { handleAiHordeImageGeneration } from "./imageGeneration/providers/aihorde.ts"; @@ -616,6 +617,17 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "maxai-image") { + return handleMaxaiImageGeneration({ + model, + provider, + body, + credentials, + log, + signal, + }); + } + if (providerConfig.format === "adobe-firefly-image") { return handleAdobeFireflyImageGeneration({ model, diff --git a/open-sse/handlers/imageGeneration/providers/maxaiImage.ts b/open-sse/handlers/imageGeneration/providers/maxaiImage.ts new file mode 100644 index 0000000000..2a102e43a0 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/maxaiImage.ts @@ -0,0 +1,230 @@ +// MaxAI (web-app) image-generation handler. +// Family: maxai-image | Provider: maxai +// +// MaxAI exposes 6 image models (gpt-image-1, dall-e-3, flux-1-schnell/dev/pro, +// sd3-medium) behind a SINGLE synchronous endpoint: +// POST https://api.maxai.me/gpt/get_image_generate_response +// body {prompt, style, size, n, model_name} +// -> {status:"OK", data:[{webp_url, png_url}]} +// No submit-then-poll (unlike Microsoft Designer) — one request returns the +// image URLs. Auth reuses the EXISTING signed-executor pieces (the same +// X-Authorization signer + Firefox-150 identity the chat path uses); the signer +// signs whatever `path` it is given, so image and chat share one auth module. +// +// Residential egress + Firefox-150 TLS are applied transparently at the infra +// layer (in-container TUN + TLS_FINGERPRINT_PROVIDERS), so nothing egress- +// specific lives here. + +import { resolveMaxaiCredential } from "../../../executors/maxai/credentials.ts"; +import { buildMaxaiSignedHeaders } from "../../../executors/maxai/signing.ts"; +import { ensureMaxaiConstants } from "../../../executors/maxai/constantsStore.ts"; +import { MAXAI_BASE_URL, maxaiStaticHeaders } from "../../../executors/maxai/protocol.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; + +export const MAXAI_IMAGE_PATH = "/gpt/get_image_generate_response"; +const MAXAI_IMAGE_DEFAULT_SIZE = "1024x1024"; +const MAXAI_IMAGE_N_MAX = 4; + +// Models whose upstream REJECTS non-1024 sizes (verified: gpt-image-1/dall-e-3 +// 500 on 256x256/512x512). The flux family + sd3-medium have no size constraint +// and pass the requested WxH through unchanged. +const MAXAI_STRICT_SIZE_MODELS: Record> = { + "gpt-image-1": new Set(["1024x1024", "1024x1536", "1536x1024", "auto"]), + "dall-e-3": new Set(["1024x1024", "1024x1792", "1792x1024"]), +}; + +const MAXAI_IMAGE_ALIASES: Record = { + "stable-diffusion-v3": "sd3-medium", + "stable-diffusion-3-medium": "sd3-medium", + "flux-1-schneil": "flux-1-schnell", // tolerate a common typo +}; + +/** Strip a `maxai/` prefix and resolve size-name aliases to the canonical model id. */ +export function resolveMaxaiImageModel(model: unknown): string { + let m = typeof model === "string" ? model.trim() : ""; + if (m.startsWith("maxai/")) m = m.slice("maxai/".length); + return MAXAI_IMAGE_ALIASES[m] ?? m; +} + +/** + * Snap an OpenAI-style "WxH" size to something MaxAI accepts. gpt-image-1 / + * dall-e-3 reject anything outside their bucket (→ upstream 500), so an + * unsupported size (e.g. 512x512 from a standard OpenAI client) is snapped to + * the model default. Models with no constraint pass the size through. + */ +export function snapMaxaiImageSize(model: string, size: unknown): string { + const requested = typeof size === "string" && size.trim() ? size.trim() : MAXAI_IMAGE_DEFAULT_SIZE; + const allowed = MAXAI_STRICT_SIZE_MODELS[model]; + if (!allowed) return requested; // flux / sd3: no constraint + return allowed.has(requested) ? requested : MAXAI_IMAGE_DEFAULT_SIZE; +} + +/** Pull image URLs out of MaxAI's response into OpenAI data[] items (prefer png_url). */ +export function extractMaxaiImageUrls(json: unknown): string[] { + // Accept either the raw items array or a { data: [...] } wrapper. MaxAI's real + // response is { status:"OK", data:[{webp_url, png_url}] }, so both shapes occur + // depending on how far the caller unwrapped. + let items: unknown[] = []; + if (Array.isArray(json)) { + items = json; + } else if (json && typeof json === "object" && Array.isArray((json as Record).data)) { + items = (json as Record).data as unknown[]; + } + const urls: string[] = []; + for (const it of items) { + if (it && typeof it === "object") { + const rec = it as Record; + const url = + (typeof rec.png_url === "string" && rec.png_url) || + (typeof rec.webp_url === "string" && rec.webp_url) || + (typeof rec.url === "string" && rec.url) || + ""; + if (url) urls.push(url); + } + } + return urls; +} + +export async function handleMaxaiImageGeneration({ + model, + provider, + body, + credentials, + log, + signal, + fetchImpl = fetch, +}: { + model: string; + provider: string; + body: { prompt?: unknown; size?: unknown; n?: unknown; style?: unknown }; + credentials: { + apiKey?: string; + accessToken?: string; + providerSpecificData?: Record | null; + }; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +}) { + const startTime = Date.now(); + + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: "Prompt is required for MaxAI image generation", + }); + } + + const cred = resolveMaxaiCredential( + credentials?.providerSpecificData, + credentials?.accessToken || credentials?.apiKey + ); + if (!cred) { + return saveImageErrorResult({ + provider, + model, + status: 401, + startTime, + error: "MaxAI credentials missing access_token", + retryable: true, + }); + } + + const canonicalModel = resolveMaxaiImageModel(model); + const nRaw = Number(body.n); + const n = Number.isFinite(nRaw) && nRaw >= 1 ? Math.min(Math.floor(nRaw), MAXAI_IMAGE_N_MAX) : 1; + const requestBody = { + prompt, + style: typeof body.style === "string" && body.style ? body.style : "vivid", + size: snapMaxaiImageSize(canonicalModel, body.size), + n, + model_name: canonicalModel, + }; + + const constants = await ensureMaxaiConstants({ fetchImpl, signal }); + if (!constants) { + return saveImageErrorResult({ + provider, + model, + status: 401, + startTime, + error: "MaxAI signing constants unavailable (extraction failed).", + }); + } + const headers: Record = { + ...maxaiStaticHeaders(), + ...buildMaxaiSignedHeaders({ path: MAXAI_IMAGE_PATH, userId: cred.userId, deviceId: cred.deviceId }, constants), + Authorization: `Bearer ${cred.accessToken}`, + "Content-Type": "application/json", + }; + + let resp: Response; + try { + resp = await fetchImpl(MAXAI_BASE_URL + MAXAI_IMAGE_PATH, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal, + }); + } catch (err) { + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + log?.error?.("IMAGE", `${provider} maxai-image transport error: ${errorText}`); + return saveImageErrorResult({ provider, model, status: 502, startTime, error: errorText, requestBody }); + } + + if (!resp.ok) { + const detail = (await resp.text().catch(() => "")).slice(0, 500); + log?.error?.("IMAGE", `${provider} maxai-image error ${resp.status}: ${detail}`); + return saveImageErrorResult({ + provider, + model, + status: resp.status, + startTime, + error: detail || `MaxAI image generation failed (HTTP ${resp.status})`, + requestBody, + // 401 = expired token, 418 = TLS/JA3 masked-reject: rotate to the next account. + retryable: resp.status === 401 || resp.status === 418, + }); + } + + let json: unknown; + try { + json = await resp.json(); + } catch { + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: "MaxAI returned a non-JSON image response", + requestBody, + }); + } + + const status = (json as Record)?.status; + const urls = extractMaxaiImageUrls(json); + if (status !== "OK" || urls.length === 0) { + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: `MaxAI image generation returned no images (status=${String(status)})`, + requestBody, + }); + } + + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody, + responseBody: { images_count: urls.length }, + images: urls.map((url) => ({ url })), + }); +} diff --git a/open-sse/services/maxaiModels.ts b/open-sse/services/maxaiModels.ts new file mode 100644 index 0000000000..169c15a029 --- /dev/null +++ b/open-sse/services/maxaiModels.ts @@ -0,0 +1,172 @@ +/** + * MaxAI model discovery — live model list + per-model context windows from the + * web app's own `/models/get_config` endpoint (the signed call the app makes on + * load). Feeds OmniRoute's model-discovery pipeline so the MaxAI catalog and its + * per-model context windows self-update instead of relying only on the static + * catalog (`open-sse/executors/maxai/catalog.ts`). + * + * The response's `chat_models[]` carries `model_name` (id), `ui_display_name`, + * `group`, `max_tokens` (the per-model context window), `is_deprecated`, and a + * `capabilities` block ({ vision, thinking_mode, artifacts, file_upload }). We + * map each non-deprecated chat model to a discovery record whose `inputTokenLimit` + * is `max_tokens`, so `persistDiscoveredModels` → `syncedAvailableModels` → + * `contextWindowResolver` reconciles the real window as an `auto:discovery` + * override. + * + * Signed + residential like every MaxAI call (see ./maxai/signing.ts). Never + * throws for the caller's convenience is NOT the contract here — the route wraps + * it in try/catch and falls back to the curated catalog — but it validates HTTP + * status and shape and throws a sanitized error on failure so the route logs it. + */ +import { resolveMaxaiCredential } from "../executors/maxai/credentials.ts"; +import { buildMaxaiSignedHeaders } from "../executors/maxai/signing.ts"; +import { ensureMaxaiConstants } from "../executors/maxai/constantsStore.ts"; +import { + maxaiStaticHeaders, + MAXAI_BASE_URL, + MAXAI_MODELS_CONFIG_PATH, +} from "../executors/maxai/protocol.ts"; +import { maxaiContextWindow, MAXAI_MODELS } from "../executors/maxai/catalog.ts"; + +// Re-export the registry-shaped catalog through this service so `src/app` routes +// can consume it WITHOUT importing the executor directly (the no-restricted-imports +// rule: "executor implementations must stay behind an open-sse handler or service +// boundary"). This service IS that boundary, and already owns the catalog import. +export { MAXAI_REGISTRY_MODELS } from "../executors/maxai/catalog.ts"; + +/** A discovered MaxAI model in the shape persistDiscoveredModels normalizes. */ +export interface MaxaiDiscoveredModel { + id: string; + name: string; + /** Per-model context window (chars→tokens handled upstream); the reconciler key. */ + inputTokenLimit: number; + group?: string; + supportsReasoning?: boolean; + supportsVision?: boolean; + toolCalling: boolean; +} + +export interface MaxaiModelDiscoveryInput { + /** Connection credential material (from providerSpecificData + apiKey). */ + providerSpecificData: Record | null | undefined; + accessToken?: string | null; + signal?: AbortSignal | null; + /** Injectable fetch (the route passes a proxy/guard-wrapped safeOutboundFetch). */ + fetchImpl?: typeof fetch; +} + +export interface MaxaiModelDiscoveryResult { + models: MaxaiDiscoveredModel[]; + warning?: string; +} + +/** The curated paid-model id set — only these are surfaced (quality gate). */ +const CURATED_IDS = new Set(MAXAI_MODELS.map((m) => m.id)); + +interface RawChatModel { + model_name?: unknown; + ui_display_name?: unknown; + type?: unknown; + group?: unknown; + max_tokens?: unknown; + is_deprecated?: unknown; + capabilities?: { + vision?: unknown; + thinking_mode?: unknown; + } | null; +} + +/** Map one raw chat model to a discovery record, or null when it should be dropped. */ +function toDiscovered(raw: RawChatModel): MaxaiDiscoveredModel | null { + const id = typeof raw.model_name === "string" ? raw.model_name : ""; + if (!id) return null; + if (raw.is_deprecated === true) return null; + if (raw.type !== undefined && raw.type !== "chat") return null; + // Quality gate: only surface the curated paid models (the ones catalog.ts offers). + if (!CURATED_IDS.has(id)) return null; + + const liveWindow = + typeof raw.max_tokens === "number" && Number.isFinite(raw.max_tokens) && raw.max_tokens > 0 + ? Math.trunc(raw.max_tokens) + : maxaiContextWindow(id); // fall back to the static catalog window + + const caps = raw.capabilities ?? {}; + return { + id, + name: typeof raw.ui_display_name === "string" ? raw.ui_display_name : id, + inputTokenLimit: liveWindow, + group: typeof raw.group === "string" ? raw.group : undefined, + supportsReasoning: caps.thinking_mode === true || undefined, + supportsVision: caps.vision === true || undefined, + toolCalling: true, // prompted tool-calling (see maxai.ts + webTools.ts) + }; +} + +/** + * Fetch MaxAI's live model catalog + per-model context windows. Throws a + * sanitized Error on auth/transport/shape failure (the route catches and falls + * back to the curated static catalog). + */ +export async function discoverMaxaiModels( + input: MaxaiModelDiscoveryInput +): Promise { + const doFetch = input.fetchImpl ?? fetch; + const cred = resolveMaxaiCredential(input.providerSpecificData, input.accessToken); + if (!cred) { + throw new Error("MaxAI connection is not configured (missing token/device/user id)."); + } + + const path = MAXAI_MODELS_CONFIG_PATH; + const constants = await ensureMaxaiConstants({ fetchImpl: doFetch, signal: input.signal }); + if (!constants) { + throw new Error("MaxAI signing constants unavailable (extraction failed)."); + } + const res = await doFetch(MAXAI_BASE_URL + path, { + method: "POST", + headers: { + ...maxaiStaticHeaders(), + ...buildMaxaiSignedHeaders({ path, userId: cred.userId, deviceId: cred.deviceId }, constants), + Authorization: `Bearer ${cred.accessToken}`, + }, + body: "{}", + signal: input.signal ?? undefined, + }); + + if (res.status !== 200) { + const detail = await res.text().catch(() => ""); + throw new Error(`MaxAI /models/get_config ${res.status}: ${detail.slice(0, 160)}`); + } + + let parsed: { data?: { chat_models?: unknown }; chat_models?: unknown }; + try { + parsed = (await res.json()) as typeof parsed; + } catch { + throw new Error("MaxAI /models/get_config returned unparseable JSON."); + } + + const data = parsed?.data ?? parsed; + const chatModels = (data as { chat_models?: unknown })?.chat_models; + if (!Array.isArray(chatModels)) { + throw new Error("MaxAI /models/get_config had no chat_models array."); + } + + const models: MaxaiDiscoveredModel[] = []; + for (const raw of chatModels as RawChatModel[]) { + const mapped = toDiscovered(raw); + if (mapped) models.push(mapped); + } + + if (models.length === 0) { + throw new Error("MaxAI /models/get_config yielded no usable curated models."); + } + + // Note when the live list dropped a curated model (e.g. MaxAI deprecated it). + const liveIds = new Set(models.map((m) => m.id)); + const missing = [...CURATED_IDS].filter((id) => !liveIds.has(id)); + const warning = + missing.length > 0 + ? `MaxAI no longer offers ${missing.length} curated model(s): ${missing.join(", ")}` + : undefined; + + return { models, warning }; +} diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 2dfc8837e7..5727314559 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -97,6 +97,13 @@ let initialized = false; let currentRequestQueueSettings: RequestQueueSettings = DEFAULT_RESILIENCE_SETTINGS.requestQueue; export const ZAI_WEB_REQUEST_QUEUE_MAX_WAIT_MS = 60_000; +// MaxAI proxies reasoning models (deepseek-r1, gpt-5.6-thinking, grok-4.5, +// gemini-3.1-pro-preview, grok-4-1-fast-reasoning) whose single upstream turn +// legitimately runs tens of seconds to minutes. The 15s default execution +// expiration (Bottleneck `expiration`, applied AFTER dispatch) kills those mid +// think and surfaces a spurious local 504. Floor MaxAI at 5 min — the same +// ceiling waitForCooldown.budgetMs uses — so slow reasoning turns complete. +export const MAXAI_REQUEST_QUEUE_MAX_WAIT_MS = 300_000; const limiterEffectiveSettings = new WeakMap(); const preservedReplacementSettings = new Map(); @@ -166,10 +173,15 @@ export function resolveRequestQueueMaxWaitMs( configuredMaxWaitMs: number = currentRequestQueueSettings.maxWaitMs, connectionId?: string ): number { - const legacyDefault = - provider.trim().toLowerCase() === "zai-web" - ? Math.max(configuredMaxWaitMs, ZAI_WEB_REQUEST_QUEUE_MAX_WAIT_MS) - : configuredMaxWaitMs; + const p = provider.trim().toLowerCase(); + let legacyDefault = configuredMaxWaitMs; + if (p === "zai-web") { + legacyDefault = Math.max(configuredMaxWaitMs, ZAI_WEB_REQUEST_QUEUE_MAX_WAIT_MS); + } else if (p === "maxai" || p === "mx") { + // MaxAI's slow reasoning models legitimately need up to ~5 min; floor the + // per-request execution budget so they aren't cut off early. + legacyDefault = Math.max(configuredMaxWaitMs, MAXAI_REQUEST_QUEUE_MAX_WAIT_MS); + } const override = connectionId ? connectionRateLimitOverrides.get(connectionId)?.maxWaitMs : undefined; diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index e1a32add91..8e080bbfe5 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -99,6 +99,24 @@ function tlsFingerprintProviderAllowed( .some((candidate) => candidate.trim().toLowerCase() === normalizedProvider); } +/** + * Per-provider TLS impersonation profile. Most providers use the default + * Chrome/macOS wreq profile; providers that must match a specific browser + * fingerprint (e.g. MaxAI expects a Windows Firefox-150 client) override it here. + * Returns undefined to keep the tlsClient default (chrome_124 / macos). + */ +const TLS_PROVIDER_PROFILE: Record = { + maxai: { browser: "firefox_150", os: "windows" }, +}; + +function tlsProfileForProvider( + provider: string | null | undefined +): { browserProfile?: string; os?: string } { + if (!provider) return {}; + const p = TLS_PROVIDER_PROFILE[provider.trim().toLowerCase()]; + return p ? { browserProfile: p.browser, os: p.os } : {}; +} + type TlsClientLike = { available: boolean; fetch: (url: string, options?: TlsFetchOptions) => Promise; @@ -776,6 +794,7 @@ async function patchedFetch( signal: getEffectiveSignal(input, options), proxy: null, sessionScope: tlsStore?.sessionScope, + ...tlsProfileForProvider(tlsStore?.provider), }); if (tlsStore) tlsStore.used = true; return response; @@ -1068,6 +1087,7 @@ async function patchedFetch( signal: getEffectiveSignal(input, options), proxy: proxyUrl, sessionScope: tlsStore?.sessionScope, + ...tlsProfileForProvider(tlsStore?.provider), }); if (tlsStore) tlsStore.used = true; return response; diff --git a/package.json b/package.json index f315bcd433..cbb09d1ed8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniroute", "version": "3.8.51", - "description": "Unified AI router with 352 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", + "description": "Unified AI router with 353 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { "omniroute": "bin/omniroute.mjs", diff --git a/public/images/tier-flow-dark.svg b/public/images/tier-flow-dark.svg index 0d67776574..dfb3bf59b9 100644 --- a/public/images/tier-flow-dark.svg +++ b/public/images/tier-flow-dark.svg @@ -1,6 +1,6 @@ - + OmniRoute 4-tier fallback - OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 352 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. + OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 353 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. @@ -15,7 +15,7 @@ OmniRoute 4-tier fallback - Never stop building — automatic zero-config failover across 352 providers + Never stop building — automatic zero-config failover across 353 providers diff --git a/public/images/tier-flow-light.svg b/public/images/tier-flow-light.svg index 00cbbedbe2..fb90ef785a 100644 --- a/public/images/tier-flow-light.svg +++ b/public/images/tier-flow-light.svg @@ -1,6 +1,6 @@ - + OmniRoute 4-tier fallback - OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 352 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. + OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 353 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. @@ -15,7 +15,7 @@ OmniRoute 4-tier fallback - Never stop building — automatic zero-config failover across 352 providers + Never stop building — automatic zero-config failover across 353 providers diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 240b62813b..2358399fa6 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -216,6 +216,12 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "bin/mcpStdioConsoleGuard.mjs", "bin/nodeRuntimeSupport.mjs", "bin/omniroute.mjs", + // #11437: bin/omniroute.mjs imports ./cli/utils/volatileEnvPath.mjs at startup + // (describeVolatileEnvWarning — flags a .env living inside the installed package). + // bin/cli/ is only an allowlist PREFIX, so its absence would never fail the + // unexpected-paths check; list it REQUIRED so a regression is loud (#7065 class, + // enforced by tests/unit/pack-artifact-entrypoint-closures.test.ts). + "bin/cli/utils/volatileEnvPath.mjs", // #7808: aliasResolver + its hook file. bin/omniroute.mjs imports // bin/aliasResolver.mjs at startup, which in turn registers // bin/aliasResolverHook.mjs as the ESM loader. Both must ship in the tarball diff --git a/scripts/check/check-provider-assets.mjs b/scripts/check/check-provider-assets.mjs index 5ba4b9afe9..62c06c484a 100644 --- a/scripts/check/check-provider-assets.mjs +++ b/scripts/check/check-provider-assets.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); const providerDir = join(repoRoot, "public", "providers"); const MAX_RASTER_BYTES = 128 * 1024; -const MAX_RASTER_DIMENSION = 256; +const MAX_RASTER_DIMENSION = 512; const RASTER_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]); function extensionOf(fileName) { diff --git a/src/app/api/providers/[id]/login/route.ts b/src/app/api/providers/[id]/login/route.ts index ec9cb5376c..783aea4a0c 100644 --- a/src/app/api/providers/[id]/login/route.ts +++ b/src/app/api/providers/[id]/login/route.ts @@ -157,6 +157,133 @@ async function loginAdobeFirefly( } } +// --- MaxAI: browserless email device-pair login ----------------------------- + +/** + * MaxAI email login is a two-step, browserless device-pair flow (no browser / + * camoufox / Google): step "request" emails a 6-digit code; step "verify" + * exchanges the code for the full credential (access + ~1-year refresh token). + * + * The signature is bound to a client-minted device id, so we mint it in the + * request step and persist it to the connection immediately, then read it back + * in the verify step (the route itself is stateless across the two calls). + */ +async function loginMaxaiEmail( + connectionId: string, + connection: Record, + body: { step?: unknown; email?: unknown; code?: unknown } +): Promise { + const { randomUUID } = await import("node:crypto"); + const { requestMaxaiEmailCode, verifyMaxaiEmailCode } = await import( + "@omniroute/open-sse/executors/maxai/emailLogin.ts" + ); + + const psd = (connection.providerSpecificData ?? {}) as Record; + const step = String(body.step || "request"); + + if (step === "request") { + const email = String(body.email || "").trim(); + if (!email) { + return NextResponse.json( + { success: false, error: "An email address is required." }, + { status: 400 } + ); + } + // Mint (or reuse) the client identity and persist it BEFORE the request so + // the verify step signs with the same device id. + const deviceId = String(psd.maxaiDeviceId || psd.deviceId || randomUUID()); + const clientUserId = String(psd.maxaiClientUserId || psd.clientUserId || randomUUID()); + try { + await updateProviderConnection(connectionId, { + providerSpecificData: { + ...psd, + maxaiDeviceId: deviceId, + maxaiClientUserId: clientUserId, + maxaiLoginEmail: email, + }, + }); + } catch { + /* non-fatal: fall through and still attempt the request */ + } + + const result = await requestMaxaiEmailCode({ email, deviceId }); + if (!result.ok) { + return NextResponse.json( + { success: false, error: result.error || "Failed to send the sign-in code." }, + { status: result.status && result.status >= 400 ? result.status : 400 } + ); + } + return NextResponse.json({ + success: true, + step: "request", + message: `A sign-in code was emailed to ${email}. Enter it to finish connecting.`, + email, + }); + } + + if (step === "verify") { + const code = String(body.code || "").trim(); + const email = String(body.email || psd.maxaiLoginEmail || "").trim(); + const deviceId = String(psd.maxaiDeviceId || psd.deviceId || ""); + const clientUserId = String(psd.maxaiClientUserId || psd.clientUserId || ""); + if (!code || !email || !deviceId) { + return NextResponse.json( + { + success: false, + error: !deviceId + ? "No pending sign-in. Request a code first." + : "The email and the code are both required.", + }, + { status: 400 } + ); + } + + const result = await verifyMaxaiEmailCode({ email, code, deviceId, clientUserId }); + if (!result.ok || !result.credential) { + return NextResponse.json( + { success: false, error: result.error || "Code verification failed." }, + { status: result.status && result.status >= 400 ? result.status : 400 } + ); + } + + const cred = result.credential; + try { + await updateProviderConnection(connectionId, { + // The access token is replayed as `Authorization: Bearer` by the executor. + apiKey: cred.accessToken, + providerSpecificData: { + ...psd, + maxaiAccessToken: cred.accessToken, + maxaiRefreshToken: cred.refreshToken, + maxaiDeviceId: cred.deviceId, + maxaiUserId: cred.userId, + maxaiClientUserId: cred.clientUserId, + maxaiLoginEmail: cred.email, + signedInAt: Date.now(), + }, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err); + return NextResponse.json( + { success: false, error: `Signed in but failed to persist: ${msg}` }, + { status: 500 } + ); + } + return NextResponse.json({ + success: true, + step: "verify", + persisted: true, + account: cred.email, + message: `Connected as ${cred.email}.`, + }); + } + + return NextResponse.json( + { success: false, error: `Unknown login step: ${step}` }, + { status: 400 } + ); +} + // --- POST: Start login flow ------------------------------------------------- export async function POST( @@ -178,6 +305,24 @@ export async function POST( }; const providerSlug = resolveProviderSlug(provider as Record); + // MaxAI: browserless email device-pair login (no browser). Two-step: + // {step:"request",email} emails a code; {step:"verify",code} mints + persists. + if (providerSlug === "maxai" || providerSlug === "mx") { + try { + return await loginMaxaiEmail(id, provider as Record, body as { + step?: unknown; + email?: unknown; + code?: unknown; + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err); + return NextResponse.json( + { success: false, error: `MaxAI sign-in error: ${msg}` }, + { status: 500 } + ); + } + } + // Adobe Firefly: dedicated JWT capture (never cookies/localStorage alone). if (isAdobeFireflyProvider(provider as { provider?: unknown }, providerSlug)) { try { diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 1d678fa759..09d9579be3 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -50,6 +50,10 @@ import { discoverNotionWebModels, NOTION_WEB_FALLBACK_MODELS, } from "@omniroute/open-sse/services/notionWebModels.ts"; +import { + discoverMaxaiModels, + MAXAI_REGISTRY_MODELS, +} from "@omniroute/open-sse/services/maxaiModels.ts"; import { AZURE_AI_DEFAULT_BASE_URL, buildAzureAiModelsUrl, @@ -595,6 +599,50 @@ export async function GET( }); } } + + // MaxAI: live catalog + per-model context windows from the signed + // /models/get_config (the call the web app makes on load). Falls back to the + // curated static registry catalog on any auth/transport/shape failure. + if (provider === "maxai") { + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + + try { + const discovery = await discoverMaxaiModels({ + providerSpecificData: connection.providerSpecificData, + accessToken: apiKey || accessToken, + fetchImpl: (url, init) => + safeOutboundFetch(url, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + ...init, + }), + }); + return buildApiDiscoveryResponse(discovery.models, discovery.warning); + } catch (error) { + console.log("Error fetching models from maxai", { + error: error instanceof Error ? error.message : String(error), + }); + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: "MaxAI models/get_config failed — using cached catalog", + localWarning: "MaxAI models/get_config failed — using curated catalog", + }); + if (fallback) return fallback; + return buildResponse({ + provider, + connectionId, + models: MAXAI_REGISTRY_MODELS, + source: "local_catalog", + intentional: true, + warning: "MaxAI catalog unavailable — using curated model list", + }); + } + } + const conolResponse = await maybeHandleConolModelDiscovery({ provider, connectionId, diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index e079cec5e1..da3f16ee55 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -477,6 +477,27 @@ export const WEB_COOKIE_PROVIDERS = { authHint: "Use browser sign-in, or paste the full Cookie header from conol.ai. The __Secure-better-auth.session_token cookie is required.", }, + maxai: { + id: "maxai", + serviceKinds: ["llm"], + alias: "mx", + name: "MaxAI", + icon: "auto_awesome", + color: "#6D28D9", + textIcon: "MX", + website: "https://www.maxai.co", + // No subscriptionRisk / riskNoticeVariant / notice: MaxAI is TOKEN-authenticated + // (a bearer access token + a long-lived refresh token that OmniRoute refreshes + // browserlessly), NOT a fragile browser-cookie session, so the "webCookie" + // caveat ("may invalidate at any time, log in again, not for unattended use") + // and the "oauth" caveat ("official session not authorized for proxy use") are + // both inaccurate — MaxAI is a purpose-built aggregator whose token IS meant for + // API use. Treated like codex-app-server: no risk banner and no notice; the + // authHint carries the only guidance a connecting operator needs. + toolCalling: "emulated", + authHint: + "Sign in once (email code or browser) to mint a MaxAI access token. OmniRoute signs each request, routes it through residential egress, and refreshes the token browserlessly, so a connection stays valid for about a year without re-login.", + }, }; /** Resolved public site for a web-session provider (href + display host). */ diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 08eef2d6f5..2388df5143 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -334,6 +334,22 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { acceptsFullCookieHeader: true, storageKeys: ["cookie", "__Secure-better-auth.session_token"], }, + maxai: { + kind: "token", + credentialName: "MaxAI access token (Bearer) + device id", + placeholder: + "Use browser sign-in — OmniRoute mints the MaxAI access token, device id, and user id for you", + acceptsFullCookieHeader: false, + storageKeys: [ + "accessToken", + "access_token", + "maxaiAccessToken", + "deviceId", + "maxaiDeviceId", + "userId", + "maxaiUserId", + ], + }, } satisfies Record & Record; diff --git a/stryker.conf.json b/stryker.conf.json index baf3b11bb8..8345b15d89 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -340,6 +340,8 @@ "tests/unit/repro-9486.test.ts", "tests/unit/repro-9630-combo-false-503.test.ts", "tests/unit/repro-antigravity-404-family-cooldown-hijack.test.ts", + "tests/unit/repro-combo-persisted-cooldown-preskip.test.ts", + "tests/unit/repro-glm-iso-reset-24h-cap.test.ts", "tests/unit/resilience-connections.test.ts", "tests/unit/responses-handler.test.ts", "tests/unit/responses-passthrough-openai-compatible.test.ts", diff --git a/tests/snapshots/executors/executor-map.json b/tests/snapshots/executors/executor-map.json index 3f72a8282b..005d1de4cc 100644 --- a/tests/snapshots/executors/executor-map.json +++ b/tests/snapshots/executors/executor-map.json @@ -420,6 +420,11 @@ "configSource": "", "provider": "lmarena" }, + "maxai": { + "className": "MaxAiExecutor", + "configSource": "maxai", + "provider": "maxai" + }, "moonshot": { "className": "MoonshotExecutor", "configSource": "moonshot", @@ -666,6 +671,6 @@ "provider": "zai-web" } }, - "keyCount": 133, + "keyCount": 134, "sharedInstances": [] } diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index d00eb4488a..b97ac70d40 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -3614,6 +3614,29 @@ "stream": "https://chat.maritaca.ai/api" } }, + "maxai": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "https://api.maxai.me", + "stream": "https://api.maxai.me" + } + }, "meganova-ai": { "format": "openai", "headers": { diff --git a/tests/unit/helpers/maxaiMockConstants.ts b/tests/unit/helpers/maxaiMockConstants.ts new file mode 100644 index 0000000000..1c7a66500a --- /dev/null +++ b/tests/unit/helpers/maxaiMockConstants.ts @@ -0,0 +1,122 @@ +/** + * Shared MOCK signing constants + synthetic bundle fixtures for MaxAI unit tests. + * + * IMPORTANT: nothing here is a real MaxAI value. Every id/key/version is an + * obviously-synthetic placeholder that is merely SHAPE-valid (hex / UUID / + * webpage_x.y.z) so it exercises the same validation/parse paths the real values + * would. The real constants are fetched at runtime and persisted to the DB; they + * never appear in the repo (source or tests). + * + * The signer tests prove the HMAC→SM3→AES ALGORITHM by comparing the production + * signer's output to an INDEPENDENT reference implementation (below) computed over + * the same mock key — algorithm correctness without pinning any captured vector. + */ +import { createHmac, createHash } from "node:crypto"; +import type { MaxaiSigningConstants } from "../../../open-sse/executors/maxai/constants.ts"; +import { MAXAI_DEFAULT_HEADER_NAMES } from "../../../open-sse/executors/maxai/constants.ts"; + +/** Obviously-fake, shape-valid mock constants (40+ hex, UUID, webpage_x.y.z). */ +export const MOCK_HMAC_KEY = "a".repeat(56); // 56 hex chars, like the real key's shape +export const MOCK_AES_KEY = "b".repeat(56); +export const MOCK_CTX_KEY = "c".repeat(40); // 40 hex chars +export const MOCK_DOC_ID_KEY = "00000000-0000-4000-8000-000000000000"; // UUID shape +export const MOCK_APP_VERSION = "webpage_0.0.0"; // version shape, clearly not real +export const MOCK_USER_ID = "11111111-1111-4111-8111-111111111111"; +export const MOCK_DEVICE_ID = "22222222-2222-4222-8222-222222222222"; + +export const MOCK_CONSTANTS: MaxaiSigningConstants = { + hmacKey: MOCK_HMAC_KEY, + aesKey: MOCK_AES_KEY, + appVersion: MOCK_APP_VERSION, + ctxKey: MOCK_CTX_KEY, + docIdKey: MOCK_DOC_ID_KEY, + headerNames: { ...MAXAI_DEFAULT_HEADER_NAMES }, + source: "extracted", + extractedAt: 0, +}; + +/** + * INDEPENDENT reference implementation of the SM3 proof `p` (deliberately NOT + * imported from the production module) so a passing test proves the production + * math matches an external spec, not merely itself. + */ +export function referenceProof( + appVersion: string, + reqTime: number, + path: string, + userId: string, + hmacKey: string +): string { + const signStr = `${appVersion}:${reqTime}:${path}:${userId}`; + const sha1 = createHmac("sha1", Buffer.from(`${reqTime}:${hmacKey}`, "utf8")) + .update(Buffer.from(signStr, "utf8")) + .digest("hex"); + return createHash("sm3") + .update(Buffer.from(`${reqTime}:${sha1}:${hmacKey}`, "utf8")) + .digest("hex"); +} + +/** + * Build a SYNTHETIC `pages/_app` chunk that mirrors the real webpack shape the + * parser matches: module 69319 defining export getters `Mn/Rl/U0` over `let` + * vars, plus the sole `webpage_x.y.z` literal. Uses only the MOCK values. + */ +export function makeSyntheticAppChunk( + c: { + hmacKey?: string; + aesKey?: string; + docIdKey?: string; + appVersion?: string; + } = {} +): string { + const hmac = c.hmacKey ?? MOCK_HMAC_KEY; + const aes = c.aesKey ?? MOCK_AES_KEY; + const doc = c.docIdKey ?? MOCK_DOC_ID_KEY; + const ver = c.appVersion ?? MOCK_APP_VERSION; + // Mirrors the real bundle: getters export short vars; the const run assigns the + // literals (kept in a separate region, exactly like the minified original). + return [ + `(self.webpackChunk=self.webpackChunk||[]).push([[69319],{`, + `69319:function(e,t,a){"use strict";a.d(t,{`, + `Mn:function(){return u},Rl:function(){return s},U0:function(){return c},`, + `GB:function(){return m},$0:function(){return p}});`, + `let l="https://api.maxai.me",i="${ver}",o="MAXAI_APP",`, + `s="${aes}",u="${hmac}",c="${doc}",d="website-nextjs",p="prod",m=!1;`, + `}}]);`, + ].join(""); +} + +/** + * Build a SYNTHETIC signer chunk that mirrors the real payload-assembly shape: + * the ctx slot `"<40hex>":{a:await this.getContext()}` plus `(0,r.nj)("")` + * header-name decoders. Uses only the MOCK ctx key. + */ +export function makeSyntheticSignerChunk(ctxKey: string = MOCK_CTX_KEY): string { + const nj = (s: string) => `(0,r.nj)("${Buffer.from(s, "utf8").toString("hex")}")`; + return [ + `n.set(${nj("X-Browser-Name")},"Firefox");`, + `n.set(${nj("X-Authorization")},(0,r.P0)({`, + `[${nj("X-Client-Domain")}]:b,[${nj("X-Client-Path")}]:I,`, + `[${nj("X-Random")}]:Math.floor(1e5+9e5*Math.random()).toString(),`, + `[${nj("t")}]:m,[${nj("p")}]:T,[${nj("d")}]:await this.getAPIFetchDeviceID(),`, + `"${ctxKey}":{a:await this.getContext()}},i.Rl));`, + `n.set(${nj("X-App-Version")},i.F8);`, + `n.set(${nj("X-App-Env")},${nj("MaxAI-Browser-Extension")});`, + ].join(""); +} + +/** Build the app HTML that references synthetic chunk URLs (build-independent). */ +export function makeSyntheticAppHtml( + opts: { appChunk?: string; signerChunk?: string; extra?: string[] } = {} +): string { + const app = opts.appChunk ?? "/_next/static/chunks/pages/_app-deadbeef.js"; + const signer = opts.signerChunk ?? "/_next/static/chunks/91234-cafebabe.js"; + const extras = (opts.extra ?? ["/_next/static/chunks/webpack-1111.js"]).map( + (u) => `` + ); + return ( + extras.join("") + + `` + + `` + ); +} diff --git a/tests/unit/maxai-documents.test.ts b/tests/unit/maxai-documents.test.ts new file mode 100644 index 0000000000..ddd95f1cd4 --- /dev/null +++ b/tests/unit/maxai-documents.test.ts @@ -0,0 +1,218 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { + computeMaxaiDocId, + maxaiDocType, + parseInlineDataUrl, + extractCurrentTurnDocs, + buildUploadMultipart, + sawUploadDone, + uploadMaxaiDocument, + resolveMaxaiDocList, +} from "../../open-sse/executors/maxai/documents.ts"; +import { __setMaxaiConstantsForTest } from "../../open-sse/executors/maxai/constantsStore.ts"; +import { MOCK_CONSTANTS, MOCK_DOC_ID_KEY } from "./helpers/maxaiMockConstants.ts"; + +// Doc uploads sign like any request, so seed the in-process constants memo with +// MOCK values instead of mocking the bundle fetch. Nothing real is committed. +const DOC_ID_KEY = MOCK_DOC_ID_KEY; +__setMaxaiConstantsForTest(MOCK_CONSTANTS); + +const AUTH = { + accessToken: "tok-abc", + userId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", +}; + +// --- doc_id (content-addressed HMAC-SHA1) -------------------------------- + +test("computeMaxaiDocId is a stable HMAC-SHA1(bytes, key) hex digest", () => { + // Cross-checked shape: HMAC-SHA1 hex is 40 chars; deterministic for same input. + const id = computeMaxaiDocId(Buffer.from("hello world"), DOC_ID_KEY); + assert.equal(id.length, 40); + assert.match(id, /^[0-9a-f]{40}$/); + assert.equal(computeMaxaiDocId(Buffer.from("hello world"), DOC_ID_KEY), id); + // Different key or bytes → different id. + assert.notEqual(id, computeMaxaiDocId(Buffer.from("hello world"), "different-key")); + assert.notEqual(id, computeMaxaiDocId(Buffer.from("other"), DOC_ID_KEY)); +}); + +test("computeMaxaiDocId requires a key (never hashes with a guess)", () => { + assert.throws(() => computeMaxaiDocId(Buffer.from("x"), "")); +}); + +// --- doc_type classification -------------------------------------------- + +test("maxaiDocType classifies pdf / code / text", () => { + assert.equal(maxaiDocType("report.pdf", "application/pdf"), "page_content__pdf"); + assert.equal(maxaiDocType("script.py", "text/x-python"), "chat_file_code"); + assert.equal(maxaiDocType("main.ts", "text/plain"), "chat_file_code"); + assert.equal(maxaiDocType("notes.txt", "text/plain"), "chat_file"); + assert.equal(maxaiDocType("data.csv", "text/csv"), "chat_file"); +}); + +// --- data-url parsing ---------------------------------------------------- + +test("parseInlineDataUrl decodes base64 + plain data urls", () => { + const b64 = parseInlineDataUrl("data:text/plain;base64,aGVsbG8="); // "hello" + assert.equal(b64?.mimeType, "text/plain"); + assert.equal(b64?.bytes.toString("utf8"), "hello"); + + const plain = parseInlineDataUrl("data:text/plain,hi%20there"); + assert.equal(plain?.bytes.toString("utf8"), "hi there"); + + assert.equal(parseInlineDataUrl("https://example.com/x.pdf"), null); + assert.equal(parseInlineDataUrl("data:text/plain;base64,"), null); // empty + assert.equal(parseInlineDataUrl(42), null); +}); + +// --- extract inline docs from the current turn -------------------------- + +test("extractCurrentTurnDocs handles OpenAI file, Responses input_file, Claude document", () => { + const docs = extractCurrentTurnDocs([ + { role: "system", content: "sys" }, + { + role: "user", + content: [ + { type: "text", text: "review these" }, + { + type: "file", + file: { filename: "a.txt", file_data: "data:text/plain;base64,QQ==" }, // "A" + }, + { type: "input_file", filename: "b.md", file_data: "data:text/markdown;base64,Qg==" }, // "B" + { + type: "document", + title: "c.pdf", + source: { type: "base64", media_type: "application/pdf", data: "Qw==" }, // "C" + }, + ], + }, + ]); + assert.equal(docs.length, 3); + assert.equal(docs[0].filename, "a.txt"); + assert.equal(docs[0].bytes.toString("utf8"), "A"); + assert.equal(docs[1].filename, "b.md"); + assert.equal(docs[2].filename, "c.pdf"); + assert.equal(docs[2].mimeType, "application/pdf"); +}); + +test("extractCurrentTurnDocs returns [] for a plain-text turn", () => { + assert.deepEqual(extractCurrentTurnDocs([{ role: "user", content: "just text" }]), []); +}); + +test("extractCurrentTurnDocs ignores image_url and remote-url file parts", () => { + const docs = extractCurrentTurnDocs([ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, + { type: "file", file: { filename: "x.pdf", file_data: "https://example.com/x.pdf" } }, + ], + }, + ]); + assert.deepEqual(docs, []); // image handled by vision path; remote url not an inline upload +}); + +// --- multipart body ------------------------------------------------------ + +test("buildUploadMultipart includes all required fields + the file bytes", () => { + const doc = { filename: "notes.txt", mimeType: "text/plain", bytes: Buffer.from("secret data") }; + const body = buildUploadMultipart(doc, "docid123", "chat_file", "BOUND").toString("utf8"); + assert.ok(body.includes('name="doc_id"\r\n\r\ndocid123')); + assert.ok(body.includes('name="doc_type"\r\n\r\nchat_file')); + assert.ok(body.includes('name="pure_text"\r\n\r\nsecret data')); // textual -> pure_text filled + assert.ok(body.includes('name="tokens"')); + assert.ok(body.includes('name="doc_type_dependent_data"\r\n\r\n{}')); + assert.ok(body.includes('name="file"; filename="notes.txt"')); + assert.ok(body.includes("Content-Type: text/plain")); + assert.ok(body.trimEnd().endsWith("--BOUND--")); +}); + +test("buildUploadMultipart leaves pure_text empty for binary (pdf)", () => { + const doc = { filename: "r.pdf", mimeType: "application/pdf", bytes: Buffer.from([1, 2, 3, 4]) }; + const body = buildUploadMultipart(doc, "id", "page_content__pdf", "B").toString("latin1"); + assert.ok(body.includes('name="pure_text"\r\n\r\n\r\n')); // empty value +}); + +// --- SSE done detection -------------------------------------------------- + +test("sawUploadDone detects the terminal event", () => { + assert.equal(sawUploadDone('data: {"event":"upload_done","data":{"doc_id":"x"}}'), true); + assert.equal(sawUploadDone('data: {"event":"upload_to_s3"}'), false); +}); + +// --- upload (mocked fetch) ---------------------------------------------- + +test("uploadMaxaiDocument returns a doc_list entry on upload_done", async () => { + let hitUrl = ""; + let hitContentType = ""; + const fetchImpl = (async (url: string, init: RequestInit) => { + hitUrl = url; + hitContentType = (init.headers as Record)["Content-Type"]; + return { + ok: true, + status: 200, + async text() { + return 'data: {"event":"upload_done","data":{"doc_id":"srv"}}\n'; + }, + } as unknown as Response; + }) as unknown as typeof fetch; + + const entry = await uploadMaxaiDocument( + { filename: "a.txt", mimeType: "text/plain", bytes: Buffer.from("hi") }, + AUTH, + { fetchImpl } + ); + assert.ok(entry); + assert.equal(entry!.doc_id, computeMaxaiDocId(Buffer.from("hi"), DOC_ID_KEY)); + assert.equal(entry!.doc_type, "chat_file"); + assert.equal(entry!.file_name, "a.txt"); + assert.match(hitUrl, /\/app\/upload_document$/); + assert.match(hitContentType, /^multipart\/form-data; boundary=/); +}); + +test("uploadMaxaiDocument returns null on a non-200 (best-effort)", async () => { + const fetchImpl = (async () => + ({ ok: false, status: 400, async text() { return "bad"; } }) as unknown as Response) as unknown as typeof fetch; + const entry = await uploadMaxaiDocument( + { filename: "a.txt", mimeType: "text/plain", bytes: Buffer.from("hi") }, + AUTH, + { fetchImpl } + ); + assert.equal(entry, null); +}); + +test("resolveMaxaiDocList uploads all current-turn docs, skips failures", async () => { + let call = 0; + const fetchImpl = (async () => { + call += 1; + // first upload succeeds, second fails + if (call === 1) { + return { ok: true, status: 200, async text() { return '{"event":"upload_done"}'; } } as unknown as Response; + } + return { ok: false, status: 500, async text() { return ""; } } as unknown as Response; + }) as unknown as typeof fetch; + + const list = await resolveMaxaiDocList( + [ + { + role: "user", + content: [ + { type: "file", file: { filename: "a.txt", file_data: "data:text/plain;base64,QQ==" } }, + { type: "file", file: { filename: "b.txt", file_data: "data:text/plain;base64,Qg==" } }, + ], + }, + ], + AUTH, + { fetchImpl } + ); + assert.equal(list.length, 1); // one succeeded, one skipped + assert.equal(list[0].file_name, "a.txt"); +}); + +test("resolveMaxaiDocList returns [] when there are no inline docs", async () => { + const list = await resolveMaxaiDocList([{ role: "user", content: "hi" }], AUTH, { + fetchImpl: (async () => ({ ok: true, status: 200, async text() { return ""; } }) as unknown as Response) as unknown as typeof fetch, + }); + assert.deepEqual(list, []); +}); diff --git a/tests/unit/maxai-image.test.ts b/tests/unit/maxai-image.test.ts new file mode 100644 index 0000000000..76eff868f5 --- /dev/null +++ b/tests/unit/maxai-image.test.ts @@ -0,0 +1,169 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { + resolveMaxaiImageModel, + snapMaxaiImageSize, + extractMaxaiImageUrls, + handleMaxaiImageGeneration, + MAXAI_IMAGE_PATH, +} from "../../open-sse/handlers/imageGeneration/providers/maxaiImage.ts"; +import { IMAGE_PROVIDERS } from "../../open-sse/config/imageRegistry.ts"; +import { __setMaxaiConstantsForTest } from "../../open-sse/executors/maxai/constantsStore.ts"; +import { MOCK_CONSTANTS } from "./helpers/maxaiMockConstants.ts"; + +// Image generation signs like any request; seed the in-process constants memo +// with MOCK values so the handler doesn't try to fetch the live MaxAI bundle. +__setMaxaiConstantsForTest(MOCK_CONSTANTS); + +// A minimal valid MaxAI credential (userId derives nothing here; the signer is +// exercised elsewhere). providerSpecificData carries the token + device id. +const CRED = { + providerSpecificData: { + maxaiAccessToken: "tok-abc", + maxaiDeviceId: "dev-123", + maxaiUserId: "11111111-1111-4111-8111-111111111111", + }, +}; + +// --- Registry ------------------------------------------------------------ + +test("maxai is registered in IMAGE_PROVIDERS with the maxai-image format + 6 models", () => { + const entry = (IMAGE_PROVIDERS as Record)["maxai"]; + assert.ok(entry, "maxai must exist in IMAGE_PROVIDERS"); + assert.equal(entry.format, "maxai-image"); + assert.match(String(entry.baseUrl), /api\.maxai\.me\/gpt\/get_image_generate_response/); + assert.equal((entry.models ?? []).length, 6); +}); + +// --- Pure helpers -------------------------------------------------------- + +test("resolveMaxaiImageModel strips maxai/ prefix and resolves aliases", () => { + assert.equal(resolveMaxaiImageModel("maxai/gpt-image-1"), "gpt-image-1"); + assert.equal(resolveMaxaiImageModel("stable-diffusion-v3"), "sd3-medium"); + assert.equal(resolveMaxaiImageModel("stable-diffusion-3-medium"), "sd3-medium"); + assert.equal(resolveMaxaiImageModel("flux-1-schnell"), "flux-1-schnell"); +}); + +test("snapMaxaiImageSize snaps unsupported sizes for strict models, passes flux through", () => { + // gpt-image-1 / dall-e-3 reject 512x512 -> snap to 1024x1024 + assert.equal(snapMaxaiImageSize("gpt-image-1", "512x512"), "1024x1024"); + assert.equal(snapMaxaiImageSize("dall-e-3", "256x256"), "1024x1024"); + // supported sizes pass through + assert.equal(snapMaxaiImageSize("gpt-image-1", "1536x1024"), "1536x1024"); + assert.equal(snapMaxaiImageSize("dall-e-3", "1792x1024"), "1792x1024"); + // flux / sd3: no constraint, any size passes through + assert.equal(snapMaxaiImageSize("flux-1-schnell", "512x512"), "512x512"); + assert.equal(snapMaxaiImageSize("sd3-medium", "768x768"), "768x768"); + // missing size -> default + assert.equal(snapMaxaiImageSize("gpt-image-1", undefined), "1024x1024"); +}); + +test("extractMaxaiImageUrls prefers png_url, falls back to webp_url", () => { + assert.deepEqual( + extractMaxaiImageUrls([{ png_url: "p.png", webp_url: "w.webp" }, { webp_url: "only.webp" }]), + ["p.png", "only.webp"] + ); + assert.deepEqual(extractMaxaiImageUrls([]), []); + assert.deepEqual(extractMaxaiImageUrls(null), []); +}); + +// --- Handler (mocked fetch) --------------------------------------------- + +function mockFetch(status: number, jsonBody: unknown): typeof fetch { + return (async () => + ({ + ok: status >= 200 && status < 300, + status, + async json() { + return jsonBody; + }, + async text() { + return JSON.stringify(jsonBody); + }, + }) as unknown as Response) as unknown as typeof fetch; +} + +test("handleMaxaiImageGeneration returns OpenAI image data on success", async () => { + let capturedUrl = ""; + let capturedBody: Record = {}; + const fetchImpl = (async (url: string, init: RequestInit) => { + capturedUrl = url; + capturedBody = JSON.parse(String(init.body)); + return { + ok: true, + status: 200, + async json() { + return { status: "OK", data: [{ png_url: "https://cdn/x.png", webp_url: "https://cdn/x.webp" }] }; + }, + async text() { + return ""; + }, + } as unknown as Response; + }) as unknown as typeof fetch; + + const result = (await handleMaxaiImageGeneration({ + model: "flux-1-schnell", + provider: "maxai", + body: { prompt: "a red bicycle", size: "512x512", n: 2 }, + credentials: CRED, + fetchImpl, + })) as { success: boolean; data?: { data: Array<{ url: string }> } }; + + assert.equal(result.success, true); + assert.deepEqual(result.data?.data, [{ url: "https://cdn/x.png" }]); + // Hit the image endpoint with the signed body. + assert.match(capturedUrl, new RegExp(MAXAI_IMAGE_PATH.replace(/\//g, "\\/"))); + assert.equal(capturedBody.model_name, "flux-1-schnell"); + assert.equal(capturedBody.size, "512x512"); // flux passes size through + assert.equal(capturedBody.n, 2); +}); + +test("handleMaxaiImageGeneration 401 is retryable (credential fallback)", async () => { + const result = (await handleMaxaiImageGeneration({ + model: "gpt-image-1", + provider: "maxai", + body: { prompt: "x" }, + credentials: CRED, + fetchImpl: mockFetch(401, { error: "expired" }), + })) as { success: boolean; status?: number; retryable?: boolean }; + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.equal(result.retryable, true); +}); + +test("handleMaxaiImageGeneration rejects an empty prompt with 400", async () => { + const result = (await handleMaxaiImageGeneration({ + model: "gpt-image-1", + provider: "maxai", + body: { prompt: " " }, + credentials: CRED, + fetchImpl: mockFetch(200, {}), + })) as { success: boolean; status?: number }; + assert.equal(result.success, false); + assert.equal(result.status, 400); +}); + +test("handleMaxaiImageGeneration 401s with no credential (retryable)", async () => { + const result = (await handleMaxaiImageGeneration({ + model: "gpt-image-1", + provider: "maxai", + body: { prompt: "x" }, + credentials: {}, + fetchImpl: mockFetch(200, {}), + })) as { success: boolean; status?: number; retryable?: boolean }; + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.equal(result.retryable, true); +}); + +test("handleMaxaiImageGeneration surfaces a no-images response as 502", async () => { + const result = (await handleMaxaiImageGeneration({ + model: "sd3-medium", + provider: "maxai", + body: { prompt: "x" }, + credentials: CRED, + fetchImpl: mockFetch(200, { status: "OK", data: [] }), + })) as { success: boolean; status?: number }; + assert.equal(result.success, false); + assert.equal(result.status, 502); +}); diff --git a/tests/unit/maxai.test.ts b/tests/unit/maxai.test.ts new file mode 100644 index 0000000000..1372c9b646 --- /dev/null +++ b/tests/unit/maxai.test.ts @@ -0,0 +1,1088 @@ +/** + * Unit tests for the MaxAI executor helpers (signer, context assembly, SSE/think). + * + * The signer vectors are REAL captured web-app requests: computeMaxaiProof must + * reproduce the exact `p` proof the MaxAI web app produced (decrypted from real + * `X-Authorization` blobs, MaxAI v3 tests/fixtures/wire_signed_samples.json). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + computeMaxaiProof, + maxaiAesEncrypt, + buildMaxaiSignedHeaders, +} from "../../open-sse/executors/maxai/signing.ts"; +import { + assembleMaxaiContext, + buildMaxaiChatBody, + contentToText, + extractCurrentTurnImages, +} from "../../open-sse/executors/maxai/protocol.ts"; +import { + splitThink, + ThinkSplitter, + parseMaxaiSseText, + estimateMaxaiTokens, +} from "../../open-sse/executors/maxai/stream.ts"; +import { userIdFromJwt } from "../../open-sse/executors/maxai/credentials.ts"; +import { + maxaiAccessTokenNeedsRefresh, + maxaiRefreshAccessToken, + MAXAI_REFRESH_PATH, +} from "../../open-sse/executors/maxai/refresh.ts"; +import { + requestMaxaiEmailCode, + verifyMaxaiEmailCode, + MAXAI_SIGNIN_EMAIL_PATH, + MAXAI_VERIFY_CODE_PATH, +} from "../../open-sse/executors/maxai/emailLogin.ts"; +import { discoverMaxaiModels } from "../../open-sse/services/maxaiModels.ts"; +import { + __setMaxaiConstantsForTest, + resetMaxaiConstantsMemo, +} from "../../open-sse/executors/maxai/constantsStore.ts"; +import { + parseMaxaiConstants, + assembleMaxaiConstants, + validateMaxaiConstants, + findChunkUrls, + fetchMaxaiConstants, + decodeNjHeaderNames, + resolveWebpackGetter, + looksLikeSignerChunk, +} from "../../open-sse/executors/maxai/constants.ts"; +import { + MOCK_CONSTANTS, + MOCK_HMAC_KEY, + MOCK_AES_KEY, + MOCK_CTX_KEY, + MOCK_DOC_ID_KEY, + MOCK_APP_VERSION, + MOCK_USER_ID, + MOCK_DEVICE_ID, + referenceProof, + makeSyntheticAppChunk, + makeSyntheticSignerChunk, + makeSyntheticAppHtml, +} from "./helpers/maxaiMockConstants.ts"; + +// A synthetic user id (UUID-shaped, not real) for signer-algorithm tests. +const USER_ID = MOCK_USER_ID; + +// The signer takes an extracted constants object. Tests use MOCK values only — +// nothing id/key/version-shaped here is a real MaxAI value (the real ones are +// fetched at runtime and persisted to the DB, never committed). +const TEST_CONSTANTS = MOCK_CONSTANTS; +const HMAC_KEY = MOCK_HMAC_KEY; +const AES_KEY = MOCK_AES_KEY; +const APP_VERSION = MOCK_APP_VERSION; + +// Seed the in-process signing-constants memo so the signed network helpers +// (refresh / email login / model discovery) don't try to fetch the live MaxAI +// bundle during unit tests. Production resolves these via ensure/refresh → +// store → live extraction; here we inject the known-good set directly. +__setMaxaiConstantsForTest(TEST_CONSTANTS); + +// ── Signer: byte-exact vs real captured web-app requests ───────────────────── + +test("computeMaxaiProof matches an independent reference implementation (mock key)", () => { + // Prove the HMAC-SHA1 → SM3 algorithm against a SEPARATE reference impl (not the + // production module) over a MOCK key, so a pass means the math matches an + // external spec — not merely itself, and with zero real constants committed. + const t = 1784594159681; + const path = "/conversation/get_conversation_list"; + const p = computeMaxaiProof(path, t, USER_ID, HMAC_KEY, APP_VERSION); + assert.equal(p, referenceProof(APP_VERSION, t, path, USER_ID, HMAC_KEY)); + // A different path or key yields a different proof (algorithm is sensitive). + assert.notEqual(p, computeMaxaiProof("/gpt/cwc/chat", t, USER_ID, HMAC_KEY, APP_VERSION)); + assert.notEqual(p, computeMaxaiProof(path, t, USER_ID, MOCK_AES_KEY, APP_VERSION)); +}); + +test("computeMaxaiProof blanks the user id only on /oauth/* routes", () => { + // A blank-user route yields a different proof than the same route with a uid, + // proving the uid is dropped for /oauth/* (and only there). + const t = 1784594159681; + const oauthWithUid = computeMaxaiProof("/oauth/signin_with_email", t, USER_ID, HMAC_KEY, APP_VERSION); + const oauthNoUid = computeMaxaiProof("/oauth/signin_with_email", t, "", HMAC_KEY, APP_VERSION); + assert.equal(oauthWithUid, oauthNoUid); // uid ignored for /oauth/* + const chatWithUid = computeMaxaiProof("/gpt/cwc/chat", t, USER_ID, HMAC_KEY, APP_VERSION); + const chatNoUid = computeMaxaiProof("/gpt/cwc/chat", t, "", HMAC_KEY, APP_VERSION); + assert.notEqual(chatWithUid, chatNoUid); // uid honored elsewhere +}); + +test("computeMaxaiProof requires the key + app version (never signs with a guess)", () => { + assert.throws(() => computeMaxaiProof("/x", 1, USER_ID, "", APP_VERSION)); + assert.throws(() => computeMaxaiProof("/x", 1, USER_ID, HMAC_KEY, "")); +}); + +test("maxaiAesEncrypt produces a CryptoJS Salted__ envelope, deterministic with a fixed salt", () => { + const salt = Buffer.from("0011223344556677", "hex"); + const a = maxaiAesEncrypt("payload", AES_KEY, salt); + const b = maxaiAesEncrypt("payload", AES_KEY, salt); + assert.equal(a, b); // same salt → deterministic + const raw = Buffer.from(a, "base64"); + assert.equal(raw.subarray(0, 8).toString("ascii"), "Salted__"); + assert.equal(raw.subarray(8, 16).toString("hex"), "0011223344556677"); + // Random salt differs each call. + assert.notEqual(maxaiAesEncrypt("payload", AES_KEY), maxaiAesEncrypt("payload", AES_KEY)); +}); + +// ── Constants extractor: parse SYNTHETIC bundle chunks → the signing constants ── +// The fixtures are generated in-code (helpers/maxaiMockConstants.ts) with MOCK +// values — no real MaxAI bundle, key, id, or app version is committed anywhere. + +const APP_CHUNK = makeSyntheticAppChunk(); +const SIGNER_CHUNK = makeSyntheticSignerChunk(); + +test("parseMaxaiConstants extracts every value from a webpack-shaped chunk", () => { + const parsed = parseMaxaiConstants(APP_CHUNK, SIGNER_CHUNK); + assert.equal(parsed.hmacKey, MOCK_HMAC_KEY); + assert.equal(parsed.aesKey, MOCK_AES_KEY); + assert.equal(parsed.appVersion, MOCK_APP_VERSION); + assert.equal(parsed.docIdKey, MOCK_DOC_ID_KEY); + assert.equal(parsed.ctxKey, MOCK_CTX_KEY); + // Header names decoded from the nj(hex) calls in the signer chunk. + assert.equal(parsed.headerNames.authorization, "X-Authorization"); + assert.equal(parsed.headerNames.clientDomain, "X-Client-Domain"); + assert.equal(parsed.headerNames.random, "X-Random"); +}); + +test("resolveWebpackGetter follows an export getter to its literal value", () => { + const src = 'a.d(t,{Mn:function(){return u}});let s="zzz",u="deadbeefcafe";'; + assert.equal(resolveWebpackGetter(src, "Mn"), "deadbeefcafe"); + assert.equal(resolveWebpackGetter(src, "Nope"), null); +}); + +test("decodeNjHeaderNames decodes hex header names and skips non-ASCII/garbage", () => { + const names = decodeNjHeaderNames(SIGNER_CHUNK); + assert.ok(names.includes("X-Authorization")); + assert.ok(names.includes("X-Client-Domain")); + assert.ok(names.includes("X-Random")); +}); + +test("looksLikeSignerChunk fingerprints the signer chunk by content, not by number", () => { + // The signer chunk matches (ctx slot + nj decoders); the app chunk does not. + assert.equal(looksLikeSignerChunk(SIGNER_CHUNK), true); + assert.equal(looksLikeSignerChunk(APP_CHUNK), false); + assert.equal(looksLikeSignerChunk("var x=1;"), false); +}); + +test("assembleMaxaiConstants requires all five extracted values (null when any missing)", () => { + const good = assembleMaxaiConstants(parseMaxaiConstants(APP_CHUNK, SIGNER_CHUNK)); + assert.ok(good); + assert.equal(good!.hmacKey, MOCK_HMAC_KEY); + // Missing a key → null (we never assemble a half-configured signer). + const noHmac = assembleMaxaiConstants({ + hmacKey: null, + aesKey: MOCK_AES_KEY, + appVersion: MOCK_APP_VERSION, + ctxKey: MOCK_CTX_KEY, + docIdKey: MOCK_DOC_ID_KEY, + headerNames: {}, + }); + assert.equal(noHmac, null); +}); + +test("assembleMaxaiConstants defaults header NAMES but requires the id/key/version values", () => { + // All five extracted values present but header-name map empty → header-name + // defaults fill in (they are plain HTTP labels, not keys/secrets). + const c = assembleMaxaiConstants({ + hmacKey: MOCK_HMAC_KEY, + aesKey: MOCK_AES_KEY, + appVersion: MOCK_APP_VERSION, + ctxKey: MOCK_CTX_KEY, + docIdKey: MOCK_DOC_ID_KEY, + headerNames: {}, + }); + assert.ok(c); + assert.equal(c!.headerNames.authorization, "X-Authorization"); + assert.equal(c!.headerNames.random, "X-Random"); + // A missing app_version (a required extracted value) → null. + assert.equal( + assembleMaxaiConstants({ + hmacKey: MOCK_HMAC_KEY, + aesKey: MOCK_AES_KEY, + appVersion: null, + ctxKey: MOCK_CTX_KEY, + docIdKey: MOCK_DOC_ID_KEY, + headerNames: {}, + }), + null + ); + // A missing ctxKey (required) → null. + assert.equal( + assembleMaxaiConstants({ + hmacKey: MOCK_HMAC_KEY, + aesKey: MOCK_AES_KEY, + appVersion: MOCK_APP_VERSION, + ctxKey: null, + docIdKey: MOCK_DOC_ID_KEY, + headerNames: {}, + }), + null + ); +}); + +test("validateMaxaiConstants: shape gate by default, proof gate when a vector is given", () => { + const c = assembleMaxaiConstants(parseMaxaiConstants(APP_CHUNK, SIGNER_CHUNK))!; + // Default: shape-only (no real vector is embedded in source). + assert.equal(validateMaxaiConstants(c), true); + // Malformed values fail the shape gate. + assert.equal(validateMaxaiConstants({ ...c, hmacKey: "not-hex" }), false); + assert.equal(validateMaxaiConstants({ ...c, docIdKey: "not-a-uuid" }), false); + // With a MOCK proof vector, the key that produced it validates and a wrong one doesn't. + const t = 1700000000000; + const path = "/gpt/cwc/chat"; + const vector = { + path, + reqTime: t, + userId: USER_ID, + appVersion: MOCK_APP_VERSION, + expectedProof: referenceProof(MOCK_APP_VERSION, t, path, USER_ID, MOCK_HMAC_KEY), + }; + assert.equal(validateMaxaiConstants(c, vector), true); + const wrongKey = { ...c, hmacKey: MOCK_AES_KEY }; + assert.equal(validateMaxaiConstants(wrongKey, vector), false); +}); + +test("findChunkUrls returns the pages/_app chunk + build-independent candidates", () => { + const html = makeSyntheticAppHtml({ + appChunk: "/_next/static/chunks/pages/_app-deadbeef.js", + signerChunk: "/_next/static/chunks/91234-cafebabe.js", + }); + const { appChunk, candidateChunks } = findChunkUrls(html); + assert.equal(appChunk, "/_next/static/chunks/pages/_app-deadbeef.js"); + // The signer chunk is just one of the candidates; it's chosen later BY CONTENT. + assert.ok(candidateChunks.includes("/_next/static/chunks/91234-cafebabe.js")); + assert.ok(!candidateChunks.includes("/_next/static/chunks/pages/_app-deadbeef.js")); +}); + +test("fetchMaxaiConstants finds the signer chunk BY CONTENT even when renumbered", async () => { + // Two numbered chunks: a decoy and the real signer under an ARBITRARY new id. + // The scan must pick the signer purely by its content fingerprint. + const html = makeSyntheticAppHtml({ + appChunk: "/_next/static/chunks/pages/_app-aaaa.js", + signerChunk: "/_next/static/chunks/99999-newbuildid.js", + extra: ["/_next/static/chunks/55555-decoy.js"], + }); + const fakeFetch = (async (url: string) => { + const u = String(url); + if (u.endsWith("/app/")) return new Response(html, { status: 200 }); + if (u.includes("/pages/_app-")) return new Response(APP_CHUNK, { status: 200 }); + if (u.includes("/99999-")) return new Response(SIGNER_CHUNK, { status: 200 }); + if (u.includes("/55555-")) return new Response("var decoy=1;", { status: 200 }); + return new Response("", { status: 404 }); + }) as unknown as typeof fetch; + + const c = await fetchMaxaiConstants({ fetchImpl: fakeFetch }); + assert.ok(c, "constants should be extracted from a renumbered signer chunk"); + assert.equal(c!.hmacKey, MOCK_HMAC_KEY); + assert.equal(c!.ctxKey, MOCK_CTX_KEY); + assert.equal(c!.source, "extracted"); +}); + +test("fetchMaxaiConstants returns null when the bundle can't be reached", async () => { + const fakeFetch = (async () => new Response("", { status: 500 })) as unknown as typeof fetch; + assert.equal(await fetchMaxaiConstants({ fetchImpl: fakeFetch }), null); + // Re-seed the memo for the remaining network tests (some run after this). + resetMaxaiConstantsMemo(); + __setMaxaiConstantsForTest(TEST_CONSTANTS); +}); + +test("buildMaxaiSignedHeaders emits the X-App/X-Browser companions + X-Authorization", () => { + const h = buildMaxaiSignedHeaders( + { + path: "/gpt/cwc/chat", + userId: USER_ID, + deviceId: MOCK_DEVICE_ID, + now: () => 1784594159681, + random: () => "950484", + }, + TEST_CONSTANTS + ); + assert.equal(h["X-Browser-Name"], "Firefox"); + assert.equal(h["X-Browser-Version"], "150.0"); + assert.equal(h["X-App-Version"], MOCK_APP_VERSION); + assert.equal(h["X-App-Env"], "MaxAI-Browser-Extension"); + assert.ok(h["X-Authorization"].length > 0); + assert.equal(Buffer.from(h["X-Authorization"], "base64").subarray(0, 8).toString("ascii"), "Salted__"); +}); + +// ── Context assembly ───────────────────────────────────────────────────────── + +test("assembleMaxaiContext: single user turn is sent bare", () => { + const text = assembleMaxaiContext([{ role: "user", content: "hello there" }]); + assert.equal(text, "hello there"); +}); + +test("assembleMaxaiContext: system leads, history labeled, current fenced last", () => { + const text = assembleMaxaiContext([ + { role: "system", content: "You are helpful." }, + { role: "user", content: "first question" }, + { role: "assistant", content: "first answer" }, + { role: "user", content: "second question" }, + ]); + assert.match(text, /^You are helpful\./); + assert.match(text, /=== Conversation so far \(for context\) ===/); + assert.match(text, /User: first question/); + assert.match(text, /Assistant: first answer/); + assert.match(text, /=== Current request \(respond to THIS\) ===\n\nsecond question$/); +}); + +test("assembleMaxaiContext: tool turns render as tool_response / tool_call blocks", () => { + const text = assembleMaxaiContext([ + { role: "user", content: "search for X" }, + { + role: "assistant", + content: "", + tool_calls: [{ function: { name: "web_search", arguments: '{"q":"X"}' } }], + }, + { role: "tool", tool_call_id: "call_1", content: "result: found X" }, + { role: "user", content: "summarize" }, + ]); + assert.match(text, //); + assert.match(text, /web_search/); + assert.match(text, //); + assert.match(text, /result: found X/); +}); + +test("assembleMaxaiContext throws when there is nothing to send", () => { + assert.throws(() => assembleMaxaiContext([]), /no content/); +}); + +test("contentToText flattens multipart content, dropping non-text parts", () => { + assert.equal(contentToText("plain"), "plain"); + assert.equal( + contentToText([ + { type: "text", text: "a" }, + { type: "image_url", image_url: { url: "x" } }, + { type: "text", text: "b" }, + ]), + "a\nb" + ); +}); + +test("buildMaxaiChatBody pins field order + constants", () => { + const body = buildMaxaiChatBody({ conversationId: "conv-1", text: "hi", modelName: "gpt-5.6", appVersion: APP_VERSION }); + const keys = Object.keys(body); + assert.equal(keys[0], "chat_mode"); + assert.equal(keys[3], "message_content"); + assert.equal(body.chat_mode, "pro_chat"); + assert.deepEqual(body.chat_history, []); + assert.deepEqual(body.message_content, [{ type: "text", text: "hi" }]); + assert.equal(body.model_name, "gpt-5.6"); + assert.equal(body.streaming, true); + assert.equal(body.platform_feature, "web_app"); +}); + +// ── Vision input (image_url parts) ─────────────────────────────────────────── + +test("buildMaxaiChatBody text-only path is unchanged (no imageUrls)", () => { + const body = buildMaxaiChatBody({ conversationId: "c", text: "hi", modelName: "gpt-5.6", appVersion: APP_VERSION }); + // Byte-identical to the pre-vision shape: a single text part. + assert.deepEqual(body.message_content, [{ type: "text", text: "hi" }]); + assert.deepEqual(body.doc_list, []); +}); + +test("buildMaxaiChatBody appends image_url parts after the text part", () => { + const body = buildMaxaiChatBody({ + conversationId: "c", + text: "what is this?", + modelName: "gpt-5.6-luna", + appVersion: APP_VERSION, + imageUrls: ["data:image/png;base64,AAAA", "https://example.com/cat.jpg"], + }); + assert.deepEqual(body.message_content, [ + { type: "text", text: "what is this?" }, + { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, + { type: "image_url", image_url: { url: "https://example.com/cat.jpg" } }, + ]); + // Text part stays first so the flattened transcript leads. + assert.equal((body.message_content as Array<{ type: string }>)[0].type, "text"); +}); + +test("buildMaxaiChatBody skips empty/blank image urls", () => { + const body = buildMaxaiChatBody({ + conversationId: "c", + text: "t", + modelName: "gpt-5.6", + appVersion: APP_VERSION, + imageUrls: ["", "https://x/y.png"], + }); + assert.equal((body.message_content as unknown[]).length, 2); // text + 1 valid image +}); + +test("extractCurrentTurnImages pulls images from the LAST user turn only", () => { + const urls = extractCurrentTurnImages([ + { + role: "user", + content: [ + { type: "text", text: "old" }, + { type: "image_url", image_url: { url: "data:image/png;base64,OLD" } }, + ], + }, + { role: "assistant", content: "ok" }, + { + role: "user", + content: [ + { type: "text", text: "look" }, + { type: "image_url", image_url: { url: "https://c/1.jpg" } }, + { type: "image_url", image_url: "https://c/2.jpg" }, // shorthand form + ], + }, + ]); + // Only the current (last) user turn's images, both object and shorthand forms. + assert.deepEqual(urls, ["https://c/1.jpg", "https://c/2.jpg"]); +}); + +test("extractCurrentTurnImages returns [] for a plain-string user turn", () => { + assert.deepEqual(extractCurrentTurnImages([{ role: "user", content: "just text" }]), []); +}); + +test("extractCurrentTurnImages returns [] when there is no user turn", () => { + assert.deepEqual(extractCurrentTurnImages([{ role: "system", content: "sys" }]), []); +}); + +// ── SSE / think split ──────────────────────────────────────────────────────── + +test("parseMaxaiSseText extracts only mergeable text frames", () => { + const raw = [ + 'data: {"data_key":"text","text":"Hello","need_merge":true}', + "", + 'data: {"data_key":"next_action","action":{}}', + "", + 'data: {"data_key":"text","text":" world","need_merge":true}', + "", + "data: [DONE]", + ].join("\n"); + assert.equal(parseMaxaiSseText(raw), "Hello world"); +}); + +test("splitThink separates reasoning from answer", () => { + const { reasoning, answer } = splitThink("let me thinkThe answer is 42."); + assert.equal(reasoning, "let me think"); + assert.equal(answer, "The answer is 42."); +}); + +test("splitThink: no think tag → all answer", () => { + const { reasoning, answer } = splitThink("just a plain answer"); + assert.equal(reasoning, ""); + assert.equal(answer, "just a plain answer"); +}); + +test("ThinkSplitter handles a tag split across frames", () => { + const s = new ThinkSplitter(); + let reasoning = ""; + let answer = ""; + // "reasonans" + for (const delta of ["reasonans"]) { + const out = s.feed(delta); + reasoning += out.reasoning; + answer += out.answer; + } + const tail = s.flush(); + reasoning += tail.reasoning; + answer += tail.answer; + assert.equal(reasoning, "reason"); + assert.equal(answer, "ans"); +}); + +test("estimateMaxaiTokens ~ 4 chars/token", () => { + assert.equal(estimateMaxaiTokens(""), 0); + assert.equal(estimateMaxaiTokens("abcd"), 1); + assert.equal(estimateMaxaiTokens("abcde"), 2); +}); + +// ── Credentials ────────────────────────────────────────────────────────────── + +test("userIdFromJwt decodes subject.user_id (no signature verification)", () => { + // Build a fake JWT with { subject: { user_id } }. + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ subject: { user_id: USER_ID } })).toString( + "base64url" + ); + const jwt = `${header}.${payload}.sig`; + assert.equal(userIdFromJwt(jwt), USER_ID); +}); + +// ── Browserless access-token refresh ───────────────────────────────────────── + +/** Build a fake (unsigned) JWT carrying an `exp` and optional subject.user_id. */ +function fakeJwt(expEpochSeconds: number, userId?: string): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const claims: Record = { exp: expEpochSeconds }; + if (userId) claims.subject = { user_id: userId }; + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + return `${header}.${payload}.sig`; +} + +test("maxaiAccessTokenNeedsRefresh: absent / unparseable / near-expiry / fresh", () => { + const now = () => 1_000_000_000_000; // fixed ms clock + const nowSec = 1_000_000_000; + assert.equal(maxaiAccessTokenNeedsRefresh("", 3600, now), true); // absent + assert.equal(maxaiAccessTokenNeedsRefresh("not-a-jwt", 3600, now), true); // unparseable + // exp 30 min out with a 1h margin → needs refresh. + assert.equal(maxaiAccessTokenNeedsRefresh(fakeJwt(nowSec + 1800), 3600, now), true); + // exp 5h out with a 1h margin → still fresh. + assert.equal(maxaiAccessTokenNeedsRefresh(fakeJwt(nowSec + 5 * 3600), 3600, now), false); +}); + +test("maxaiRefreshAccessToken sends the exact web-app request + parses data.access_token", async () => { + const nowSec = Math.floor(Date.now() / 1000); + const refreshToken = fakeJwt(nowSec + 365 * 24 * 3600, USER_ID); // 1y refresh token + const newAccess = fakeJwt(nowSec + 24 * 3600, USER_ID); + let seen: { url: string; init: RequestInit } | null = null; + + const fakeFetch = (async (url: string, init: RequestInit) => { + seen = { url: String(url), init }; + return new Response(JSON.stringify({ data: { access_token: newAccess } }), { status: 200 }); + }) as unknown as typeof fetch; + + const result = await maxaiRefreshAccessToken({ + refreshToken, + deviceId: MOCK_DEVICE_ID, + fetchImpl: fakeFetch, + }); + + assert.equal(result.ok, true); + assert.equal(result.accessToken, newAccess); + assert.ok(result.expiresAt && result.expiresAt > nowSec); + + // Request shape: bare refresh path, refresh token as Bearer, noAuthLogout, app body. + assert.ok(seen); + const { url, init } = seen!; + assert.ok(url.endsWith(MAXAI_REFRESH_PATH)); + assert.equal(init.method, "POST"); + const headers = init.headers as Record; + assert.equal(headers["Authorization"], `Bearer ${refreshToken}`); + assert.equal(headers["noAuthLogout"], "true"); + assert.ok(headers["X-Authorization"] && headers["X-Authorization"].length > 0); + assert.equal(init.body, JSON.stringify({ app: "maxai_webapp" })); +}); + +test("maxaiRefreshAccessToken returns a structured error on non-200 (no throw)", async () => { + const nowSec = Math.floor(Date.now() / 1000); + const fakeFetch = (async () => + new Response("nope", { status: 418 })) as unknown as typeof fetch; + const result = await maxaiRefreshAccessToken({ + refreshToken: fakeJwt(nowSec + 1000, USER_ID), + deviceId: "dev", + fetchImpl: fakeFetch, + }); + assert.equal(result.ok, false); + assert.equal(result.status, 418); +}); + +test("maxaiRefreshAccessToken refuses when required inputs are missing", async () => { + const result = await maxaiRefreshAccessToken({ refreshToken: "", deviceId: "" }); + assert.equal(result.ok, false); + assert.equal(result.status, 0); +}); + +// ── Email login (browserless device-pair) ──────────────────────────────────── + +test("requestMaxaiEmailCode posts the signed signin request + treats status OK as success", async () => { + let seen: { url: string; init: RequestInit } | null = null; + const fakeFetch = (async (url: string, init: RequestInit) => { + seen = { url: String(url), init }; + return new Response(JSON.stringify({ data: { status: "OK" } }), { status: 200 }); + }) as unknown as typeof fetch; + + const r = await requestMaxaiEmailCode({ + email: "user@example.com", + deviceId: MOCK_DEVICE_ID, + fetchImpl: fakeFetch, + }); + + assert.equal(r.ok, true); + assert.ok(seen); + const { url, init } = seen!; + assert.ok(url.endsWith(MAXAI_SIGNIN_EMAIL_PATH)); + assert.equal(init.method, "POST"); + assert.equal(init.body, JSON.stringify({ email: "user@example.com", app: "maxai_webapp" })); + const headers = init.headers as Record; + assert.ok(headers["X-Authorization"] && headers["X-Authorization"].length > 0); +}); + +test("requestMaxaiEmailCode surfaces a non-OK detail as an error", async () => { + const fakeFetch = (async () => + new Response(JSON.stringify({ data: { status: "FAIL", detail: "Invalid email" } }), { + status: 200, + })) as unknown as typeof fetch; + const r = await requestMaxaiEmailCode({ email: "x@y.z", deviceId: "dev", fetchImpl: fakeFetch }); + assert.equal(r.ok, false); + assert.match(r.error ?? "", /Invalid email/); +}); + +test("verifyMaxaiEmailCode returns the full credential from auth_user", async () => { + const nowSec = Math.floor(Date.now() / 1000); + const accessToken = "acc.jwt.token"; + const refreshToken = "ref.jwt.token"; + let seen: { url: string; init: RequestInit } | null = null; + const fakeFetch = (async (url: string, init: RequestInit) => { + seen = { url: String(url), init }; + return new Response( + JSON.stringify({ + data: { + status: "OK", + auth_user: { + accessToken, + refreshToken, + userId: USER_ID, + email: "user@example.com", + clientUserId: "client-uuid-1", + }, + }, + }), + { status: 200 } + ); + }) as unknown as typeof fetch; + + const r = await verifyMaxaiEmailCode({ + email: "user@example.com", + code: "123456", + deviceId: "device-uuid-1", + clientUserId: "client-uuid-1", + fetchImpl: fakeFetch, + }); + + assert.equal(r.ok, true); + assert.deepEqual(r.credential, { + accessToken, + refreshToken, + userId: USER_ID, + email: "user@example.com", + deviceId: "device-uuid-1", + clientUserId: "client-uuid-1", + }); + assert.ok(nowSec > 0); // sanity anchor + + // Request shape: verify path + pinned body fields. + const { url, init } = seen!; + assert.ok(url.endsWith(MAXAI_VERIFY_CODE_PATH)); + const body = JSON.parse(String(init.body)); + assert.equal(body.email, "user@example.com"); + assert.equal(body.secret_code, "123456"); + assert.equal(body.app, "maxai_webapp"); + assert.equal(body.env, "prod_co"); + assert.equal(body.client_user_id, "client-uuid-1"); +}); + +test("verifyMaxaiEmailCode maps code 10119 to an expired-code message", async () => { + const fakeFetch = (async () => + new Response(JSON.stringify({ data: { status: "FAIL", code: 10119 } }), { + status: 200, + })) as unknown as typeof fetch; + const r = await verifyMaxaiEmailCode({ + email: "x@y.z", + code: "000000", + deviceId: "dev", + clientUserId: "cu", + fetchImpl: fakeFetch, + }); + assert.equal(r.ok, false); + assert.match(r.error ?? "", /expired|too many/i); +}); + +test("verifyMaxaiEmailCode defaults to an invalid-code message otherwise", async () => { + const fakeFetch = (async () => + new Response(JSON.stringify({ data: { status: "FAIL" } }), { status: 200 })) as unknown as typeof fetch; + const r = await verifyMaxaiEmailCode({ + email: "x@y.z", + code: "999999", + deviceId: "dev", + clientUserId: "cu", + fetchImpl: fakeFetch, + }); + assert.equal(r.ok, false); + assert.match(r.error ?? "", /Invalid code/); +}); + +test("email login guards missing inputs", async () => { + assert.equal((await requestMaxaiEmailCode({ email: "", deviceId: "" })).ok, false); + assert.equal( + (await verifyMaxaiEmailCode({ email: "", code: "", deviceId: "", clientUserId: "" })).ok, + false + ); +}); + +// ── Tool calling (prompted protocol) ────────────────────────────────── + +import { MaxAiExecutor } from "../../open-sse/executors/maxai.ts"; + +const TOOL_CRED = { + providerSpecificData: { + maxaiAccessToken: "acc.tok.en", + maxaiDeviceId: "dev-1", + maxaiUserId: USER_ID, + }, + accessToken: "acc.tok.en", +}; + +const WEATHER_TOOL = { + type: "function", + function: { + name: "get_weather", + description: "Get the current weather for a city.", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, +}; + +/** Build a MaxAI SSE body streaming `full` as one mergeable text frame. */ +function maxaiSseBody(full: string): string { + return ( + `data: ${JSON.stringify({ data_key: "text", need_merge: true, text: full })}\n\n` + + "data: [DONE]\n\n" + ); +} + +/** Run MaxAiExecutor.execute with a stubbed global fetch returning `sseText`. */ +async function runToolExecute(opts: { + sseText: string; + stream: boolean; + tools?: unknown[]; +}): Promise<{ captured: { url: string; body: string } | null; response: Response }> { + const realFetch = globalThis.fetch; + let captured: { url: string; body: string } | null = null; + globalThis.fetch = (async (url: unknown, init: unknown) => { + captured = { + url: String(url), + body: String((init as RequestInit)?.body ?? ""), + }; + return new Response(opts.sseText, { status: 200 }); + }) as unknown as typeof fetch; + try { + const executor = new MaxAiExecutor(); + const result = await executor.execute({ + model: "gpt-5.6-luna", + stream: opts.stream, + credentials: TOOL_CRED, + body: { + model: "gpt-5.6-luna", + messages: [{ role: "user", content: "what's the weather in Paris?" }], + ...(opts.tools ? { tools: opts.tools } : {}), + stream: opts.stream, + }, + } as unknown as Parameters[0]); + const response = "response" in result ? result.response : (result as Response); + return { captured, response }; + } finally { + globalThis.fetch = realFetch; + } +} + +test("executor injects the contract into the upstream text when tools are present", async () => { + const { captured } = await runToolExecute({ + sseText: maxaiSseBody("Sure, let me check."), + stream: false, + tools: [WEATHER_TOOL], + }); + assert.ok(captured); + const chatBody = JSON.parse(captured!.body); + const sentText = chatBody.message_content[0].text as string; + // The prompted-tool contract + the tool name reach the model. + assert.match(sentText, //); + assert.match(sentText, /get_weather/); +}); + +test("executor parses a block from the reply into OpenAI tool_calls (non-stream)", async () => { + const toolBlock = + '{"name": "get_weather", "arguments": {"city": "Paris"}, "_nonce": "NONCE"}'; + // The parser needs the SAME nonce serializeToolsToPrompt derived from tools[]. + // getToolNonce is deterministic per tools ref+content, so re-derive it here. + const { getToolNonce } = await import("../../open-sse/translator/webTools.ts"); + const tools = [WEATHER_TOOL]; + const nonce = getToolNonce(tools); + const reply = `{"name": "get_weather", "arguments": {"city": "Paris"}, "_nonce": "${nonce}"}`; + void toolBlock; + + const { response } = await runToolExecute({ + sseText: maxaiSseBody(reply), + stream: false, + tools, + }); + assert.equal(response.status, 200); + const json = await response.json(); + const choice = json.choices[0]; + assert.equal(choice.finish_reason, "tool_calls"); + assert.ok(Array.isArray(choice.message.tool_calls)); + assert.equal(choice.message.tool_calls[0].function.name, "get_weather"); + assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), { city: "Paris" }); +}); + +test("executor tool mode emits a terminal SSE replay with tool_calls (stream)", async () => { + const { getToolNonce } = await import("../../open-sse/translator/webTools.ts"); + const tools = [WEATHER_TOOL]; + const nonce = getToolNonce(tools); + const reply = `{"name": "get_weather", "arguments": {"city": "Paris"}, "_nonce": "${nonce}"}`; + + const { response } = await runToolExecute({ + sseText: maxaiSseBody(reply), + stream: true, + tools, + }); + assert.equal(response.status, 200); + assert.match(response.headers.get("Content-Type") ?? "", /text\/event-stream/); + const sse = await response.text(); + assert.match(sse, /"tool_calls"/); + assert.match(sse, /get_weather/); + assert.match(sse, /\[DONE\]/); +}); + +test("executor without tools streams normally (no tool_calls, plain content)", async () => { + const { response } = await runToolExecute({ + sseText: maxaiSseBody("Paris is sunny today."), + stream: false, + }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.equal(json.choices[0].message.content, "Paris is sunny today."); + assert.equal(json.choices[0].message.tool_calls, undefined); +}); + +/** Like runToolExecute but returns a DIFFERENT sse body per upstream call, so we + * can simulate a narration-miss on turn 1 and a clean tool call on turn 2. */ +async function runToolExecuteSeq(bodies: string[]): Promise { + const realFetch = globalThis.fetch; + let call = 0; + globalThis.fetch = (async () => { + const body = bodies[Math.min(call, bodies.length - 1)]; + call += 1; + return new Response(body, { status: 200 }); + }) as unknown as typeof fetch; + try { + const executor = new MaxAiExecutor(); + const result = await executor.execute({ + model: "maxai/deepseek-r1", + stream: false, + credentials: TOOL_CRED, + body: { + model: "maxai/deepseek-r1", + messages: [{ role: "user", content: "what's the weather in Ghent?" }], + tools: [WEATHER_TOOL], + stream: false, + }, + } as unknown as Parameters[0]); + return "response" in result ? result.response : (result as Response); + } finally { + globalThis.fetch = realFetch; + } +} + +test("executor recovers a tool narration-miss via one nudged retry", async () => { + // Turn 1: the model NARRATES about the block but emits none parseable. + const narration = + "I can use the get_current_weather tool here via a special block. Let me think about the arguments..."; + // Turn 2 (after nudge): a clean, parseable tool call. Omit _nonce (tolerated + // for models that don't echo it) so the test isn't coupled to the internal + // per-tools-reference nonce the executor injected. + const clean = `{"name": "get_current_weather", "arguments": {"city": "Ghent"}}`; + + const response = await runToolExecuteSeq([maxaiSseBody(narration), maxaiSseBody(clean)]); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.choices[0].finish_reason, "tool_calls"); + assert.equal(json.choices[0].message.tool_calls[0].function.name, "get_current_weather"); + assert.deepEqual(JSON.parse(json.choices[0].message.tool_calls[0].function.arguments), { + city: "Ghent", + }); +}); + +test("executor does NOT retry a genuine no-tool answer (no narration signal)", async () => { + // A plain answer with no tool intent must pass through unchanged (single call). + let calls = 0; + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => { + calls += 1; + return new Response(maxaiSseBody("The weather in Ghent is mild and cloudy."), { status: 200 }); + }) as unknown as typeof fetch; + try { + const executor = new MaxAiExecutor(); + const result = await executor.execute({ + model: "maxai/gpt-5.6", + stream: false, + credentials: TOOL_CRED, + body: { + model: "maxai/gpt-5.6", + messages: [{ role: "user", content: "how's Ghent?" }], + tools: [WEATHER_TOOL], + stream: false, + }, + } as unknown as Parameters[0]); + const response = "response" in result ? result.response : (result as Response); + const json = await response.json(); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.equal(calls, 1); // no retry + } finally { + globalThis.fetch = realFetch; + } +}); + +// ── Model discovery (/models/get_config → per-model context windows) ────────── + +const DISCOVERY_CRED = { + providerSpecificData: { + maxaiAccessToken: "acc.tok.en", + maxaiDeviceId: "dev-1", + maxaiUserId: USER_ID, + }, + accessToken: "acc.tok.en", +}; + +/** A minimal /models/get_config body with the fields the mapper reads. */ +function modelsConfigBody(models: unknown[]): string { + return JSON.stringify({ data: { chat_models: models } }); +} + +test("discoverMaxaiModels maps curated chat models with live max_tokens as the window", async () => { + const fakeFetch = (async () => + new Response( + modelsConfigBody([ + { + model_name: "gpt-5.6-luna", + ui_display_name: "GPT-5.6 Luna", + type: "chat", + group: "fast", + max_tokens: 1_050_000, + is_deprecated: false, + capabilities: { vision: true, thinking_mode: false }, + }, + { + model_name: "gpt-5.6-thinking", + ui_display_name: "GPT-5.6 Thinking", + type: "chat", + group: "reasoning", + max_tokens: 1_050_000, + is_deprecated: false, + capabilities: { vision: false, thinking_mode: true }, + }, + ]), + { status: 200 } + )) as unknown as typeof fetch; + + const { models, warning } = await discoverMaxaiModels({ + providerSpecificData: DISCOVERY_CRED.providerSpecificData, + accessToken: DISCOVERY_CRED.accessToken, + fetchImpl: fakeFetch, + }); + + const luna = models.find((m) => m.id === "gpt-5.6-luna"); + assert.ok(luna); + assert.equal(luna!.inputTokenLimit, 1_050_000); + assert.equal(luna!.name, "GPT-5.6 Luna"); + assert.equal(luna!.toolCalling, true); + assert.equal(luna!.supportsVision, true); + const thinking = models.find((m) => m.id === "gpt-5.6-thinking"); + assert.equal(thinking!.supportsReasoning, true); + // Two curated returned, so the "no longer offered" warning names the rest. + assert.ok(warning && /no longer offers/.test(warning)); +}); + +test("discoverMaxaiModels drops deprecated, non-chat, and non-curated models", async () => { + const fakeFetch = (async () => + new Response( + modelsConfigBody([ + { model_name: "gpt-5.6-luna", type: "chat", max_tokens: 1_050_000, is_deprecated: false }, + { model_name: "gpt-5-mini", type: "chat", max_tokens: 400_000, is_deprecated: true }, // deprecated + { model_name: "some-image-model", type: "image", max_tokens: 0 }, // non-chat + { model_name: "not-in-catalog", type: "chat", max_tokens: 123 }, // non-curated + ]), + { status: 200 } + )) as unknown as typeof fetch; + + const { models } = await discoverMaxaiModels({ + providerSpecificData: DISCOVERY_CRED.providerSpecificData, + accessToken: DISCOVERY_CRED.accessToken, + fetchImpl: fakeFetch, + }); + assert.deepEqual( + models.map((m) => m.id), + ["gpt-5.6-luna"] + ); +}); + +test("discoverMaxaiModels falls back to the catalog window when max_tokens is absent", async () => { + const fakeFetch = (async () => + new Response( + modelsConfigBody([{ model_name: "claude-5-sonnet", type: "chat" }]), + { status: 200 } + )) as unknown as typeof fetch; + const { models } = await discoverMaxaiModels({ + providerSpecificData: DISCOVERY_CRED.providerSpecificData, + accessToken: DISCOVERY_CRED.accessToken, + fetchImpl: fakeFetch, + }); + const sonnet = models.find((m) => m.id === "claude-5-sonnet"); + assert.ok(sonnet); + assert.ok(sonnet!.inputTokenLimit > 0); // from catalog fallback (1_000_000) +}); + +test("discoverMaxaiModels throws on non-200 and on missing chat_models", async () => { + const err418 = (async () => new Response("nope", { status: 418 })) as unknown as typeof fetch; + await assert.rejects( + discoverMaxaiModels({ + providerSpecificData: DISCOVERY_CRED.providerSpecificData, + accessToken: DISCOVERY_CRED.accessToken, + fetchImpl: err418, + }), + /418/ + ); + + const noModels = (async () => + new Response(JSON.stringify({ data: {} }), { status: 200 })) as unknown as typeof fetch; + await assert.rejects( + discoverMaxaiModels({ + providerSpecificData: DISCOVERY_CRED.providerSpecificData, + accessToken: DISCOVERY_CRED.accessToken, + fetchImpl: noModels, + }), + /no chat_models/ + ); +}); + +test("discoverMaxaiModels refuses when the connection is unconfigured", async () => { + await assert.rejects( + discoverMaxaiModels({ providerSpecificData: {}, accessToken: "" }), + /not configured/ + ); +}); + +// ── Body-too-large classification (context_length_exceeded) ────────────────── + +test("executor classifies a MaxAI 'too long' rejection as context_length_exceeded", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + code: -2, + detail: + "Something went wrong. It's probably due to the message you submitted being too long. Please reload the conversation and submit something shorter.", + }), + { status: 422 } + )) as unknown as typeof fetch; + try { + const executor = new MaxAiExecutor(); + const result = await executor.execute({ + model: "maxai/gpt-5.6", + stream: false, + credentials: TOOL_CRED, + body: { + model: "maxai/gpt-5.6", + messages: [{ role: "user", content: "a very long transcript..." }], + stream: false, + }, + } as unknown as Parameters[0]); + const response = "response" in result ? result.response : (result as Response); + assert.equal(response.status, 400); + const json = await response.json(); + assert.equal(json.error.code, "context_length_exceeded"); + } finally { + globalThis.fetch = realFetch; + } +}); diff --git a/tests/unit/provider-node-reserved-prefix.test.ts b/tests/unit/provider-node-reserved-prefix.test.ts index 8ec06f680f..8c7b57e83e 100644 --- a/tests/unit/provider-node-reserved-prefix.test.ts +++ b/tests/unit/provider-node-reserved-prefix.test.ts @@ -171,7 +171,7 @@ test("shared set size includes live REGISTRY and retired Designer + Felo + Qwen // 1 and adds 2 distinct tombstones "qwen-web"/"qw", a net +1) on top of the // live REGISTRY walk, minus the 3 GPL-derived Raycast/Hailuo Web // ids/aliases removed from REGISTRY by #11691's migration 166. - assert.equal(RESERVED_PREFIX_COUNT, 400); + assert.equal(RESERVED_PREFIX_COUNT, 402); }); test("isReservedProviderPrefix rejects non-string input", () => { diff --git a/tests/unit/ratelimit-admission-control-6593.test.ts b/tests/unit/ratelimit-admission-control-6593.test.ts index 873ed0038b..cdad084a32 100644 --- a/tests/unit/ratelimit-admission-control-6593.test.ts +++ b/tests/unit/ratelimit-admission-control-6593.test.ts @@ -239,6 +239,18 @@ test("#6593 a maxWaitMs override of 0 is treated as no override", () => { } }); +test("maxai receives a provider-scoped 5min execution budget (slow reasoning models)", () => { + // The default 15s Bottleneck expiration kills MaxAI reasoning turns (30s-min+) + // mid-think; maxai (and its mx alias) floor at 300s so they complete. + assert.equal(rateLimitManager.resolveRequestQueueMaxWaitMs("maxai", 15_000), 300_000); + assert.equal(rateLimitManager.resolveRequestQueueMaxWaitMs("MaxAI", 15_000), 300_000); + assert.equal(rateLimitManager.resolveRequestQueueMaxWaitMs("mx", 15_000), 300_000); + // A larger configured value is preserved (floor never lowers it). + assert.equal(rateLimitManager.resolveRequestQueueMaxWaitMs("maxai", 600_000), 600_000); + // Other providers are unaffected. + assert.equal(rateLimitManager.resolveRequestQueueMaxWaitMs("openai", 15_000), 15_000); +}); + test("#6593 DEFAULT_REQUEST_QUEUE_MAX_DEPTH defaults to 0 (disabled) absent an env override", () => { assert.equal(process.env.RATE_LIMIT_MAX_QUEUE_DEPTH, undefined); assert.equal(resilienceSettings.DEFAULT_REQUEST_QUEUE_MAX_DEPTH, 0); From 530096a3be465a763fb7f649f145f41238275d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armin=20Anton=E2=80=9D=20=E2=88=B4?= Date: Tue, 1 Sep 2026 22:23:33 -0700 Subject: [PATCH 02/34] =?UTF-8?q?feat(providers):=20add=20UC=20(uncensored?= =?UTF-8?q?.com)=20=E2=80=94=20persona=20(un-metered)=20+=20direct=20(mete?= =?UTF-8?q?red)=20(#11513)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds uncensored.com as two OpenAI-compatible providers mirroring UC's own surfaces: uc, the persona/subscription side over WebSocket with a durable Clerk credential minting a short-lived per-connect token (no API key, un-metered), as a full multimodal port — chat, tools, vision, doc-RAG, image, video, TTS; and uc-direct, the metered Developer API over REST with X-api-key. Same underlying models, two billing surfaces. Reconciled on merge. 57 files conflicted; only seven carried UC content, the rest was drift from the older release line and took the tip's side. - executors/index.ts: the tip has since refactored the executor map to lazy dynamic imports, so uc is registered in that shape. uc-direct needs no entry — it routes through the default OpenAI-compatible executor. - imageGeneration.ts: the branch still carried the retired designerWeb import alongside ucImage; kept only the UC one. - config/providers/index.ts, webSessionCredentials.ts and web-cookie.ts resolved additively against the MaxAI entries #11461 put on the tip an hour earlier. - web-cookie.ts: the uc entry declared no serviceKinds, required since #11392, so provider validation would have thrown at load. Declared ["llm"]. uc-direct already declared it at the end of its own entry — an earlier pass of mine added a second one after id and TypeScript caught the duplicate (TS1117); the author's placement is what shipped. Every count was measured against the merged tree rather than taken from the branch, and all three would have been wrong: reserved prefixes are 406, not 399; APIKEY_PROVIDERS is 237, not 234; providers are 355. PROVIDER_REFERENCE.md regenerated, the count updated across README/AGENTS.md/llm.txt and its 42 mirrors, package.json and 6 SVGs — every changed line in the protected surfaces is a digit substitution and nothing else, verified by masking digits and comparing the removed and added sets (90 lines each, identical). The executor-map golden snapshot went 134 -> 135. The branch's file-size-baseline.json predates #12411's ratchet re-tightening and was discarded rather than merged; imageGeneration.ts (+12 for the uc-image format branch) was entered against the current baseline under a _rebaseline annotation, and no other cap moves. Verified: typecheck:core clean, check:provider-consistency OK (271 REGISTRY entries, 355 canonical providers), check:docs-counts exit 0, check-file-size OK, check:cycles OK, 119/119 across the PR's test files, and 2/2 executor-map-golden. Thanks @arminanton — two providers for two real billing surfaces, rather than one entry pretending to be both, is the right modelling. --- .env.example | 10 + AGENTS.md | 2 +- README.md | 6 +- changelog.d/features/uc-direct-provider.md | 1 + changelog.d/features/uc-persona-provider.md | 1 + config/quality/file-size-baseline.json | 3 +- docs/diagrams/cli-terminal.svg | 2 +- docs/diagrams/comparison-table.svg | 2 +- docs/diagrams/promise-pillars.svg | 6 +- docs/diagrams/readme-hero.svg | 4 +- docs/i18n/ar/llm.txt | 4 +- docs/i18n/az/llm.txt | 4 +- docs/i18n/bg/llm.txt | 4 +- docs/i18n/bn/llm.txt | 4 +- docs/i18n/cs/llm.txt | 4 +- docs/i18n/da/llm.txt | 4 +- docs/i18n/de/llm.txt | 4 +- docs/i18n/es/llm.txt | 4 +- docs/i18n/fa/llm.txt | 4 +- docs/i18n/fi/llm.txt | 4 +- docs/i18n/fr/llm.txt | 4 +- docs/i18n/gu/llm.txt | 4 +- docs/i18n/he/llm.txt | 4 +- docs/i18n/hi/llm.txt | 4 +- docs/i18n/hu/llm.txt | 4 +- docs/i18n/id/llm.txt | 4 +- docs/i18n/in/llm.txt | 4 +- docs/i18n/it/llm.txt | 4 +- docs/i18n/ja/llm.txt | 4 +- docs/i18n/ko/llm.txt | 4 +- docs/i18n/mr/llm.txt | 4 +- docs/i18n/ms/llm.txt | 4 +- docs/i18n/nl/llm.txt | 4 +- docs/i18n/no/llm.txt | 4 +- docs/i18n/phi/llm.txt | 4 +- docs/i18n/pl/llm.txt | 4 +- docs/i18n/pt-BR/llm.txt | 4 +- docs/i18n/pt/llm.txt | 4 +- docs/i18n/ro/llm.txt | 4 +- docs/i18n/ru/llm.txt | 4 +- docs/i18n/sk/llm.txt | 4 +- docs/i18n/sv/llm.txt | 4 +- docs/i18n/sw/llm.txt | 4 +- docs/i18n/ta/llm.txt | 4 +- docs/i18n/te/llm.txt | 4 +- docs/i18n/th/llm.txt | 4 +- docs/i18n/tr/llm.txt | 4 +- docs/i18n/uk-UA/llm.txt | 4 +- docs/i18n/ur/llm.txt | 4 +- docs/i18n/vi/llm.txt | 4 +- docs/i18n/zh-CN/llm.txt | 4 +- docs/i18n/zh-TW/llm.txt | 4 +- docs/reference/ENVIRONMENT.md | 4 + docs/reference/PROVIDER_REFERENCE.md | 10 +- llm.txt | 4 +- open-sse/config/audioRegistry.ts | 14 + open-sse/config/imageRegistry.ts | 39 + open-sse/config/providers/index.ts | 4 + .../providers/registry/uc-direct/index.ts | 142 +++ .../config/providers/registry/uc/index.ts | 29 + open-sse/config/videoRegistry.ts | 34 + open-sse/executors/index.ts | 50 +- open-sse/executors/uc.ts | 573 ++++++++++++ open-sse/executors/uc/catalog.ts | 174 ++++ open-sse/executors/uc/clerkAuth.ts | 183 ++++ open-sse/executors/uc/constants.ts | 59 ++ open-sse/executors/uc/credentials.ts | 125 +++ open-sse/executors/uc/emailLogin.ts | 304 +++++++ open-sse/executors/uc/media.ts | 302 +++++++ open-sse/executors/uc/protocol.ts | 198 +++++ open-sse/executors/uc/stream.ts | 155 ++++ open-sse/executors/uc/toolDialect.ts | 255 ++++++ open-sse/executors/uc/ws.ts | 179 ++++ open-sse/handlers/audioSpeech.ts | 22 +- open-sse/handlers/imageGeneration.ts | 12 + .../imageGeneration/providers/ucImage.ts | 558 ++++++++++++ open-sse/handlers/uc/ucTts.ts | 326 +++++++ open-sse/handlers/videoGeneration.ts | 7 + .../videoGeneration/providers/ucVideo.ts | 829 ++++++++++++++++++ package.json | 2 +- public/images/tier-flow-dark.svg | 6 +- public/images/tier-flow-light.svg | 6 +- src/shared/constants/providers.ts | 4 + .../providers/apikey/frontier-labs.ts | 14 + src/shared/constants/providers/web-cookie.ts | 19 + src/shared/providers/webSessionCredentials.ts | 22 + tests/snapshots/executors/executor-map.json | 7 +- tests/snapshots/provider/translate-path.json | 46 + .../provider-node-reserved-prefix.test.ts | 4 +- tests/unit/providers-constants-split.test.ts | 13 +- tests/unit/uc-capabilities.test.ts | 264 ++++++ tests/unit/uc-image.test.ts | 361 ++++++++ tests/unit/uc-tts.test.ts | 253 ++++++ tests/unit/uc-video.test.ts | 543 ++++++++++++ tests/unit/uc.test.ts | 731 +++++++++++++++ 95 files changed, 6937 insertions(+), 154 deletions(-) create mode 100644 changelog.d/features/uc-direct-provider.md create mode 100644 changelog.d/features/uc-persona-provider.md create mode 100644 open-sse/config/providers/registry/uc-direct/index.ts create mode 100644 open-sse/config/providers/registry/uc/index.ts create mode 100644 open-sse/executors/uc.ts create mode 100644 open-sse/executors/uc/catalog.ts create mode 100644 open-sse/executors/uc/clerkAuth.ts create mode 100644 open-sse/executors/uc/constants.ts create mode 100644 open-sse/executors/uc/credentials.ts create mode 100644 open-sse/executors/uc/emailLogin.ts create mode 100644 open-sse/executors/uc/media.ts create mode 100644 open-sse/executors/uc/protocol.ts create mode 100644 open-sse/executors/uc/stream.ts create mode 100644 open-sse/executors/uc/toolDialect.ts create mode 100644 open-sse/executors/uc/ws.ts create mode 100644 open-sse/handlers/imageGeneration/providers/ucImage.ts create mode 100644 open-sse/handlers/uc/ucTts.ts create mode 100644 open-sse/handlers/videoGeneration/providers/ucVideo.ts create mode 100644 tests/unit/uc-capabilities.test.ts create mode 100644 tests/unit/uc-image.test.ts create mode 100644 tests/unit/uc-tts.test.ts create mode 100644 tests/unit/uc-video.test.ts create mode 100644 tests/unit/uc.test.ts diff --git a/.env.example b/.env.example index cc6830b46f..9b93361457 100644 --- a/.env.example +++ b/.env.example @@ -2267,6 +2267,16 @@ APP_LOG_TO_FILE=true # Cursor image-generation wall clock (ms). Default: 210000. # CURSOR_IMG_TIMEOUT_MS=210000 +# UC (uncensored.com) image-generation result-poll cadence + wall clock (ms). +# Used by: open-sse/handlers/imageGeneration/providers/ucImage.ts. Defaults: 2000 / 60000. +# UC_IMAGE_POLL_INTERVAL_MS=2000 +# UC_IMAGE_POLL_TIMEOUT_MS=60000 + +# UC (uncensored.com) video-generation result-poll cadence + wall clock (ms). +# Used by: open-sse/handlers/videoGeneration/providers/ucVideo.ts. Defaults: 3000 / 300000. +# UC_VIDEO_POLL_INTERVAL_MS=3000 +# UC_VIDEO_POLL_TIMEOUT_MS=300000 + # Shared-seat concurrency gate for Cursor image jobs. Default: 2. # CURSOR_IMG_MAX_CONCURRENT=2 diff --git a/AGENTS.md b/AGENTS.md index 30c2f8b299..6150cd28e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below. ## Project at a Glance -**OmniRoute** — unified AI proxy/router. One endpoint, 353 LLM providers, auto-fallback. +**OmniRoute** — unified AI proxy/router. One endpoint, 355 LLM providers, auto-fallback. | Layer | Location | Purpose | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/README.md b/README.md index 5d35b64c08..cec7cb1924 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ # 🚀 OmniRoute — The Free AI Gateway -OmniRoute — Never stop coding. Every AI tool → 353 providers — 150+ 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. 353 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. +OmniRoute — Never stop coding. Every AI tool → 355 providers — 150+ 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. 355 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. @@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \ -The Promise — One endpoint and 353 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 353 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files. +The Promise — One endpoint and 355 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 355 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files.

@@ -463,7 +463,7 @@ All **19** strategies — mix & match per combo step: -What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 353 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 43 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. +What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 355 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 43 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. 📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) diff --git a/changelog.d/features/uc-direct-provider.md b/changelog.d/features/uc-direct-provider.md new file mode 100644 index 0000000000..4664e59a70 --- /dev/null +++ b/changelog.d/features/uc-direct-provider.md @@ -0,0 +1 @@ +- **feat(providers): add UC Direct (uncensored.com Developer API), the metered OpenAI-compatible surface.** A standard OpenAI-compatible passthrough (default executor) for uncensored.com's official REST API at `https://api.uncensored.com/api/v1`: `X-api-key` auth (never-expiring `uai_sk_live_` key), `POST /chat/completions` with streaming SSE and native tool-calling, and the full live metered catalog (82 models across 15 providers, discovered from the public `GET /v1/models`). Registered as provider `uc-direct` (alias `ucd`). Complements the un-metered `uc` persona provider — same models, metered credits and a plain API key instead of a subscription session. diff --git a/changelog.d/features/uc-persona-provider.md b/changelog.d/features/uc-persona-provider.md new file mode 100644 index 0000000000..59fb54e52f --- /dev/null +++ b/changelog.d/features/uc-persona-provider.md @@ -0,0 +1 @@ +- **feat(providers): add UC (uncensored.com), the un-metered subscription "persona" chat as an OpenAI-compatible provider.** A WebSocket web-app port: a durable Clerk credential mints a short-lived session token per connect (browserless — no API key), driving UC's persona socket. Ships the browserless email-code login (request → verify → harvest), the 19 verified persona models (Claude Opus, Gemini, Grok, GLM, Kimi, DeepSeek, MiniMax, incl. the uncensored variants), prompted `` tool-calling with a per-model code-style dialect + auto-cure retry for guardrailed models, live ``/reasoning split, streaming + non-streaming OpenAI responses, and full quota/auth error surfacing (paywall / message-limit / rate-limit → 429, invalid session → 401 re-login). Full multimodal parity via the persona blob-upload layer: **vision** (image input, 15 vision-capable models), **document RAG** (PDF/doc upload, server-side extraction), **image generation** (22 models), **video generation** (14 models, async signed-url → poll), and **TTS** (streaming MP3). Registered as provider `uc` (alias `ucn`). The metered OpenAI-compatible Developer API is a separate `uc-direct` provider. diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 04342aff2d..3dce7fc978 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_02_11513_uc_provider": "PR #11513 (arminanton, feat/uc-native-standalone) own growth: open-sse/handlers/imageGeneration.ts 3243->3255 (+12) — the uc-image format branch for the UC persona provider's image surface. Additive at the existing per-format chokepoint, same rationale as _rebaseline_2026_09_02_11461_maxai_tls_profile.", "_rebaseline_2026_09_02_11461_maxai_tls_profile": "PR #11461 (arminanton, feat/maxai-provider) own growth, three files at existing per-provider chokepoints: open-sse/utils/proxyFetch.ts 1241->1261 (+20, the TLS_PROVIDER_PROFILE map giving MaxAI a Windows/firefox_150 impersonation profile instead of the tlsClient chrome_124/macos default); open-sse/handlers/imageGeneration.ts 3231->3243 (+12, the maxai-image format branch); src/app/api/providers/[id]/models/route.ts 2381->2429 (+48, live model listing via maxaiModels). Additive data, same no-split rationale as _rebaseline_2026_08_20_10531_freebuff_provider.", "_rebaseline_2026_09_02_11460_flat_rate_estimates": "PR #11460 (xiaoyaner0201, fix/11459-cc-cost-estimates) own growth: src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx 1283->1319 (+36) — the flat-rate estimate labelling and the includeFlatRateEstimates opt-in on the Costs dashboard. #11460 merged first so this ratchet re-tightening measures the real post-merge LOC; the cap still drops 2002->1319 (-683) versus the 2026-08-10 +30% loosening this PR reverses. Same own-growth rationale as _rebaseline_2026_08_20_10531_freebuff_provider.", "_rebaseline_2026_08_31_chatgpt_web_v4_vendor": "Pinned MIT vendor refresh from codex-chatgpt-web 0.1.16 to v4.0.6 (commit 09877fa21ffdbf20979623ef501046fc02a750d7). browser-worker.ts is preserved as the reviewed upstream browser protocol implementation; splitting the vendored file would destroy source parity and make future security/liveness updates unauditable. OmniRoute-specific DATA_DIR, Docker CDP, credential-marker, and XML decoding adaptations are covered by the ChatGPT Web Codex focused suite.", @@ -407,7 +408,7 @@ "open-sse/executors/cursor.ts": 1759, "open-sse/executors/muse-spark-web.ts": 1405, "open-sse/handlers/chatCore.ts": 5946, - "open-sse/handlers/imageGeneration.ts": 3243, + "open-sse/handlers/imageGeneration.ts": 3255, "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, "open-sse/mcp-server/server.ts": 1572, diff --git a/docs/diagrams/cli-terminal.svg b/docs/diagrams/cli-terminal.svg index 92cb473457..139a868898 100644 --- a/docs/diagrams/cli-terminal.svg +++ b/docs/diagrams/cli-terminal.svg @@ -1,4 +1,4 @@ - + Compact animated terminal cycling three real OmniRoute CLI commands with a typewriter effect and a scrolling subcommand ticker; the first frame shows the completed providers-list screen. diff --git a/docs/diagrams/comparison-table.svg b/docs/diagrams/comparison-table.svg index 71992cc04a..271cd367d6 100644 --- a/docs/diagrams/comparison-table.svg +++ b/docs/diagrams/comparison-table.svg @@ -1,4 +1,4 @@ - + Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses. diff --git a/docs/diagrams/promise-pillars.svg b/docs/diagrams/promise-pillars.svg index 9466b0c859..f32198f62f 100644 --- a/docs/diagrams/promise-pillars.svg +++ b/docs/diagrams/promise-pillars.svg @@ -1,4 +1,4 @@ - + Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle. @@ -21,7 +21,7 @@ - One endpoint. 353 providers. Never stop building — OmniRoute picks the cheapest one that works. + One endpoint. 355 providers. Never stop building — OmniRoute picks the cheapest one that works. @@ -38,7 +38,7 @@ Never hit limits - Auto-fallback across 353 providers in + Auto-fallback across 355 providers in milliseconds. Quota out? The next provider takes over while a healthy target remains. diff --git a/docs/diagrams/readme-hero.svg b/docs/diagrams/readme-hero.svg index 6d4c7ba9bf..b758959878 100644 --- a/docs/diagrams/readme-hero.svg +++ b/docs/diagrams/readme-hero.svg @@ -1,4 +1,4 @@ - + Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame. @@ -28,7 +28,7 @@ Never stop coding. - Every AI tool → 353 providers150+ free — through one endpoint. + Every AI tool → 355 providers150+ free — through one endpoint. Claude Code · Codex · Cursor · Cline · Copilot · Antigravity  →  FREE Claude / GPT / Gemini · auto-fallback diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index c1e7abe59c..20123a9d69 100644 --- a/docs/i18n/ar/llm.txt +++ b/docs/i18n/ar/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 5ef9e5f2fe..41f114138f 100644 --- a/docs/i18n/az/llm.txt +++ b/docs/i18n/az/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index 5ef9e5f2fe..41f114138f 100644 --- a/docs/i18n/bg/llm.txt +++ b/docs/i18n/bg/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index ccdb9b8013..11ea0f8513 100644 --- a/docs/i18n/bn/llm.txt +++ b/docs/i18n/bn/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index ac38750608..ed922553d6 100644 --- a/docs/i18n/cs/llm.txt +++ b/docs/i18n/cs/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index b4653489b6..51e98c8dbe 100644 --- a/docs/i18n/da/llm.txt +++ b/docs/i18n/da/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index 88b776ad66..949de1e465 100644 --- a/docs/i18n/de/llm.txt +++ b/docs/i18n/de/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index cef74db964..bf98130ebe 100644 --- a/docs/i18n/es/llm.txt +++ b/docs/i18n/es/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index 651c65dcd0..487087c5fe 100644 --- a/docs/i18n/fa/llm.txt +++ b/docs/i18n/fa/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index fa001b3f5b..29535373bb 100644 --- a/docs/i18n/fi/llm.txt +++ b/docs/i18n/fi/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index 98bbf3cffe..d25a9f0a08 100644 --- a/docs/i18n/fr/llm.txt +++ b/docs/i18n/fr/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index d6b23b4a6f..db1b62755f 100644 --- a/docs/i18n/gu/llm.txt +++ b/docs/i18n/gu/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index 34fdbd6c37..67f152fb90 100644 --- a/docs/i18n/he/llm.txt +++ b/docs/i18n/he/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index e9330bcbc1..25e1a61464 100644 --- a/docs/i18n/hi/llm.txt +++ b/docs/i18n/hi/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index c2e73a6d51..9d3622b254 100644 --- a/docs/i18n/hu/llm.txt +++ b/docs/i18n/hu/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index 339089ffa0..f7dd30f547 100644 --- a/docs/i18n/id/llm.txt +++ b/docs/i18n/id/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/in/llm.txt b/docs/i18n/in/llm.txt index 9d403264be..224371a5b4 100644 --- a/docs/i18n/in/llm.txt +++ b/docs/i18n/in/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index 2b99808639..83fe17538d 100644 --- a/docs/i18n/it/llm.txt +++ b/docs/i18n/it/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index cd750e07fd..d2e467bae3 100644 --- a/docs/i18n/ja/llm.txt +++ b/docs/i18n/ja/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index fa37857775..604d91e5c8 100644 --- a/docs/i18n/ko/llm.txt +++ b/docs/i18n/ko/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index 15c7b22545..3951cd5b2f 100644 --- a/docs/i18n/mr/llm.txt +++ b/docs/i18n/mr/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index 0671482309..803c5a5456 100644 --- a/docs/i18n/ms/llm.txt +++ b/docs/i18n/ms/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index 7a2b769983..6136d36574 100644 --- a/docs/i18n/nl/llm.txt +++ b/docs/i18n/nl/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index b4a5eb0f44..89204a67d2 100644 --- a/docs/i18n/no/llm.txt +++ b/docs/i18n/no/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index 6286537b20..e2117f53b6 100644 --- a/docs/i18n/phi/llm.txt +++ b/docs/i18n/phi/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index 6d4bd07b83..59e3b55802 100644 --- a/docs/i18n/pl/llm.txt +++ b/docs/i18n/pl/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index d64cc39343..050bccae37 100644 --- a/docs/i18n/pt-BR/llm.txt +++ b/docs/i18n/pt-BR/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index 3d6d434382..ba0ac3b997 100644 --- a/docs/i18n/pt/llm.txt +++ b/docs/i18n/pt/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index b99a4536cb..488f319a24 100644 --- a/docs/i18n/ro/llm.txt +++ b/docs/i18n/ro/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index 6d3e7c07c9..55a640091a 100644 --- a/docs/i18n/ru/llm.txt +++ b/docs/i18n/ru/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index 722056455f..3d9cb70999 100644 --- a/docs/i18n/sk/llm.txt +++ b/docs/i18n/sk/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index 56f8047049..b8c9565ca9 100644 --- a/docs/i18n/sv/llm.txt +++ b/docs/i18n/sv/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index c72f695bde..22f4341a71 100644 --- a/docs/i18n/sw/llm.txt +++ b/docs/i18n/sw/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index 47fb44c802..b2e0455d6c 100644 --- a/docs/i18n/ta/llm.txt +++ b/docs/i18n/ta/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index 86c6e44622..535e6bcd18 100644 --- a/docs/i18n/te/llm.txt +++ b/docs/i18n/te/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index 3ee4254f1c..57ddded05e 100644 --- a/docs/i18n/th/llm.txt +++ b/docs/i18n/th/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index 538a1d9cc3..f3bffa57f1 100644 --- a/docs/i18n/tr/llm.txt +++ b/docs/i18n/tr/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index 21b67bfc86..d9e88525eb 100644 --- a/docs/i18n/uk-UA/llm.txt +++ b/docs/i18n/uk-UA/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index 0f881c7b4b..a5a158fe3e 100644 --- a/docs/i18n/ur/llm.txt +++ b/docs/i18n/ur/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index 6a0ec2cdf1..e20235b199 100644 --- a/docs/i18n/vi/llm.txt +++ b/docs/i18n/vi/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index 6953a90999..9122fc6694 100644 --- a/docs/i18n/zh-CN/llm.txt +++ b/docs/i18n/zh-CN/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt index e081e5c730..cfc263ea21 100644 --- a/docs/i18n/zh-TW/llm.txt +++ b/docs/i18n/zh-TW/llm.txt @@ -4,7 +4,7 @@ --- -> 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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index bae4db9c87..c059b2b1ad 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1152,6 +1152,10 @@ changing them requires a code edit, not an env var: | `CURSOR_IMG_TIMEOUT_MS` | `210000` | `open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts` | Per-image wall clock (ms) for Cursor Agent image jobs. | | `CURSOR_IMG_MAX_CONCURRENT` | `2` | `open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts` | Shared-seat concurrency gate for Cursor image jobs. | | `CURSOR_IMG_MODEL` | request / `auto` | `open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts` | Override Cursor CLI `--model` for image jobs. | +| `UC_IMAGE_POLL_INTERVAL_MS` | `2000` | `open-sse/handlers/imageGeneration/providers/ucImage.ts` | UC (uncensored.com) image-gen result-poll cadence (ms). | +| `UC_IMAGE_POLL_TIMEOUT_MS` | `60000` | `open-sse/handlers/imageGeneration/providers/ucImage.ts` | UC image-gen result-poll wall clock (ms). | +| `UC_VIDEO_POLL_INTERVAL_MS` | `3000` | `open-sse/handlers/videoGeneration/providers/ucVideo.ts` | UC (uncensored.com) video-gen result-poll cadence (ms). | +| `UC_VIDEO_POLL_TIMEOUT_MS` | `300000` | `open-sse/handlers/videoGeneration/providers/ucVideo.ts` | UC video-gen result-poll wall clock (ms). | | `CURSOR_DATA_DIR` | _(probed)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Override Cursor Agent CLI data dir (`…/versions/`); same var the official agent uses. | | `CURSOR_TOKEN` | _(unset)_ | `scripts/ad-hoc/cursor-tap.cjs` | Direct Cursor bearer token used by developer tooling. | | `OMNIROUTE_LOG_REQUEST_SHAPE` | disabled (opt-in via `"1"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads when `"1"` is set. Off by default to reduce log noise. | diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 92b0e82373..3b181d9f73 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -10,7 +10,7 @@ lastUpdated: 2026-09-02 > Regenerate with: `npm run gen:provider-reference` > **Last generated:** 2026-09-02 -Total providers: **353**. See category breakdown below. +Total providers: **355**. See category breakdown below. ## Categories @@ -80,7 +80,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `zed` | `zd` | Zed IDE | OAuth | [link](https://zed.dev) | Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically. | | `zed-hosted` | — | Zed Hosted Models | OAuth | [link](https://zed.dev) | Sign in with your Zed account (native-app sign-in). OmniRoute generates a one-time RSA keypair and opens zed.dev to authorize it — on a remote/headless install, copy the resulting 127.0.0.1 callback URL from your browser's address bar and paste it back here. Distinct from the 'Zed IDE' credential-import entry above: this proxies chat completions through Zed's own hosted model aggregator (cloud.zed.dev), fronting Anthropic/OpenAI/Google/xAI models under your Zed plan. | -## Web Cookie Providers (32) +## Web Cookie Providers (33) | ID | Alias | Name | Tags | Website | Notes | Tool calling | |----|-------|------|------|---------|-------|--------------| @@ -111,13 +111,14 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Open t3.chat in your browser, log in, then open DevTools → Application → Local Storage → https://t3.chat. Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. Paste both values here. See provider setup docs for a step-by-step guide. | emulated | | `tencent-aistudio-web` | `tasw` | Tencent AI Studio (Free) | Web cookie | [link](https://aistudio.tencent.ai) | Log in to aistudio.tencent.ai, open DevTools -> Network, copy any request Cookie header containing session tokens. | — | | `tinycms-web` | `tcw` | TinyCMS Web (Free/Sub) | Web cookie | [link](https://site.tinycms.xyz) | Go to site.tinycms.xyz, open DevTools → Application → Local Storage, copy the value of 'app-config-uuid' (starts with 'R'), and paste it here. | — | +| `uc` | `ucn` | UC (uncensored.com) | Web cookie | [link](https://uncensored.com) | Sign in once with an email code to bootstrap a UC (uncensored.com) subscription session. OmniRoute mints a fresh short-lived token per request browserlessly, so the connection renews on its own; you only re-run the email login about once a month when the subscription session rolls over. | emulated | | `v0-vercel-web` | `v0-vercel-web` | v0 Vercel Web (Code Gen) | Web cookie | [link](https://v0.dev) | Paste your session cookie from v0.dev (DevTools → Application → Cookies) | — | | `venice-web` | `ven` | Venice Web (Privacy) | Web cookie | [link](https://venice.ai) | Paste your session cookie from venice.ai (DevTools → Application → Cookies) | — | | `yuanbao-web` | `ybw` | Tencent Yuanbao (Free) | Web cookie | [link](https://yuanbao.tencent.com) | Log in to yuanbao.tencent.com, then paste the full Cookie header (DevTools → Network → any /api request → Request Headers → Cookie). It must contain hy_user and hy_token. | — | | `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) (236) +## API Key Providers (paid / paid-with-free-credits) (237) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -330,6 +331,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `tokenrouter` | `trk` | TokenRouter | API key | [link](https://tokenrouter.com) | Use your TokenRouter API key in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.tokenrouter.com/v1. | | `topaz` | `topaz` | Topaz | API key, image | [link](https://topazlabs.com) | — | | `typhoon` | `typhoon` | Typhoon | API key | [link](https://docs.opentyphoon.ai) | Free API key with a 5 req/s and 200 req/m rate limit. | +| `uc-direct` | `ucd` | UC Direct (uncensored.com) | API key | [link](https://uncensored.com) | Use your uncensored.com Developer API key (uai_sk_live_...). OmniRoute sends it as the X-api-key header to the OpenAI-compatible https://api.uncensored.com/api/v1 endpoint. The key never expires. This is the metered/credits surface; the un-metered subscription chat is the separate 'uc' provider. | | `udio` | `udio` | Udio | API key | [link](https://udio.com) | Paste session cookie from udio.com (Supabase auth) | | `unorouter` | `unorouter` | UnoRouter | API key, aggregator | [link](https://unorouter.ai) | Models with the :free suffix do not debit balance; limit is 1 request/minute per free model per user. | | `upstage` | `upstage` | Upstage | API key | [link](https://www.upstage.ai) | — | @@ -441,7 +443,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each - Catalog: [`src/shared/constants/providers.ts`](../../src/shared/constants/providers.ts) - Registry (per-model details): [`open-sse/config/providerRegistry.ts`](../../open-sse/config/providerRegistry.ts) -- Executors: [`open-sse/executors/`](../../open-sse/executors/) (107 implementations) +- Executors: [`open-sse/executors/`](../../open-sse/executors/) (108 implementations) - Translators: [`open-sse/translator/`](../../open-sse/translator/) ## See Also diff --git a/llm.txt b/llm.txt index 907184c88a..12bdfe1ffb 100644 --- a/llm.txt +++ b/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 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -277,7 +277,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **353 AI providers** with automatic format translation +- **355 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index 651df358dd..980966d259 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -582,6 +582,20 @@ export const AUDIO_SPEECH_PROVIDERS: Record = { { id: "tts-1", name: "TTS 1" }, ], }, + + // UC (uncensored.com) voice synthesis over its dedicated TTS WebSocket. Auth is + // a Clerk session JWT minted per-connect from the durable connection cred; the + // `format: "uc-tts"` branch in audioSpeech.ts drives the socket. The baseUrl is + // a synthetic marker (the real transport is wss://tts-stream.chatuncensored.ai) + // and is never fetched. + uc: { + id: "uc", + baseUrl: "wss://tts-stream.chatuncensored.ai", + authType: "web-cookie", + authHeader: "none", + format: "uc-tts", + models: [{ id: "jade", name: "UC Voice (Jade)" }], + }, }; /** diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 8af67947ce..4d64d4a6f5 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -276,6 +276,45 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"], }, + // UC (uncensored.com) image generation. Two surfaces served by one handler + // (handleUcImageGeneration picks by credential): PERSONA web (un-metered, + // Clerk JWT -> internal.chatuncensored.ai/v2/image-gen + result-URL polling) + // and uc-direct REST (metered, X-api-key -> api.uncensored.com, OpenAI-shaped). + uc: { + id: "uc", + baseUrl: "https://internal.chatuncensored.ai/v2/image-gen", + authType: "apikey", + authHeader: "bearer", + format: "uc-image", + models: [ + { id: "model-dev", name: "Flux Dev (UC)" }, + { id: "model-pro", name: "Flux Pro (UC)" }, + { id: "model-1.1", name: "Flux Pro 1.1 (UC)" }, + { id: "model-1.2", name: "Wan 2.2 (UC)" }, + { id: "seedream-v4.5", name: "Seedream v4.5 (UC)" }, + { id: "seedream-v5", name: "Seedream v5 (UC)" }, + { id: "flux-2", name: "FLUX.2 (UC)" }, + { id: "flux-2-pro", name: "FLUX.2 Pro (UC)" }, + { id: "lustify-v7", name: "Lustify v7 (UC)" }, + { id: "nano-banana", name: "Nano Banana (UC)" }, + { id: "nano-banana-2", name: "Nano Banana 2 (UC)" }, + { id: "nano-banana-pro", name: "Nano Banana Pro (UC)" }, + { id: "nano-banana-ultra", name: "Nano Banana Ultra (UC)" }, + { id: "gpt-image", name: "GPT Image (UC)" }, + { id: "gpt-image-2", name: "GPT Image 2 (UC)" }, + { id: "realism", name: "Realism (UC)" }, + { id: "realism-2", name: "Realism 2 (UC)" }, + { id: "z-image-turbo", name: "Z-Image Turbo (UC)" }, + { id: "prefect-pony-xl", name: "Prefect Pony XL (UC)" }, + { id: "wan-2.6", name: "Wan 2.6 (UC)" }, + { id: "wan-2.7-text-to-image", name: "Wan 2.7 Text-to-Image (UC)" }, + { id: "wan-2.7-text-to-image-pro", name: "Wan 2.7 Text-to-Image Pro (UC)" }, + ], + // Persona web derives imageWidth/imageHeight from an aspect ratio; uc-direct + // passes any OpenAI-style size through. These are the aspect buckets. + supportedSizes: ["1024x1024", "1024x576", "576x1024", "1024x768", "768x1024"], + }, + xai: { id: "xai", baseUrl: "https://api.x.ai/v1/images/generations", diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index b3cf60b463..21e0fdb91f 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -211,6 +211,8 @@ import { veoaifree_webProvider } from "./registry/veoaifree-web/index.ts"; import { codexProvider } from "./registry/codex/index.ts"; import { codexAppServerProvider } from "./registry/codex-app-server/index.ts"; import { maxaiProvider } from "./registry/maxai/index.ts"; +import { ucProvider } from "./registry/uc/index.ts"; +import { ucDirectProvider } from "./registry/uc-direct/index.ts"; import { veniceProvider } from "./registry/venice/index.ts"; import { kiroProvider } from "./registry/kiro/index.ts"; import { openadapterProvider } from "./registry/openadapter/index.ts"; @@ -479,6 +481,8 @@ export const REGISTRY: Record = { codex: codexProvider, "codex-app-server": codexAppServerProvider, maxai: maxaiProvider, + uc: ucProvider, + "uc-direct": ucDirectProvider, venice: veniceProvider, kiro: kiroProvider, byteplus: byteplusProvider, diff --git a/open-sse/config/providers/registry/uc-direct/index.ts b/open-sse/config/providers/registry/uc-direct/index.ts new file mode 100644 index 0000000000..8e56c4dfeb --- /dev/null +++ b/open-sse/config/providers/registry/uc-direct/index.ts @@ -0,0 +1,142 @@ +import type { RegistryEntry } from "../../shared.ts"; + +/** + * UC Direct (uncensored.com Developer API) — the METERED, OpenAI-compatible + * official REST API at https://api.uncensored.com/api/v1. + * + * This is the paid Developer surface, distinct from the un-metered `uc` persona + * WebSocket provider. It is a straightforward OpenAI-compatible passthrough + * handled by the default executor: + * • Auth: `X-api-key: uai_sk_live_...` (a never-expiring key; NOT Bearer). The + * default executor maps authHeader "x-api-key" to the X-API-Key header + * (same as pioneer / agentrouter / helixmind). + * • `POST /chat/completions` — standard OpenAI body, streaming SSE (`[DONE]`), + * native `tools[]` / `tool_calls[]`. + * • `GET /models` is public (no auth) for catalog discovery. + * • Errors: 402 out-of-funds, 403 moderation/scope, 429 rate-limit + * (honors `retry-after` + `x-ratelimit-*`). + * + * Models below are the live metered catalog (GET /v1/models). Ids are UC REST + * SHORTNAMES (no provider prefix), which is exactly what the API expects as + * `model`. Context windows are enforced by the upstream API per-model; a + * conservative provider-wide default is set here. + */ +export const ucDirectProvider: RegistryEntry = { + id: "uc-direct", + alias: "ucd", + format: "openai", + executor: "default", + baseUrl: "https://api.uncensored.com/api/v1", + authType: "apikey", + // UC standardises on X-api-key (never-expiring uai_sk_live_ key), NOT Bearer. + // The default executor resolves "x-api-key" to the X-API-Key header. + authHeader: "x-api-key", + defaultContextLength: 128000, + models: [ + // Anthropic + { id: "claude-opus-5", name: "Claude Opus 5", toolCalling: true }, + { id: "claude-opus-5-fast", name: "Claude Opus 5 Fast", toolCalling: true }, + { id: "claude-fable-5", name: "Claude Fable 5", toolCalling: true }, + { id: "claude-opus-4.8", name: "Claude Opus 4.8", toolCalling: true }, + { id: "claude-opus-4.5", name: "Claude Opus 4.5", toolCalling: true }, + { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", toolCalling: true }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", toolCalling: true }, + { id: "claude-opus-4.7", name: "Claude Opus 4.7", toolCalling: true }, + { id: "claude-opus-4.6", name: "Claude Opus 4.6", toolCalling: true }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", toolCalling: true }, + // OpenAI + { id: "gpt-5.6-sol", name: "GPT 5.6 Sol", toolCalling: true }, + { id: "gpt-5.6-terra", name: "GPT 5.6 Terra", toolCalling: true }, + { id: "gpt-5.6-luna", name: "GPT 5.6 Luna", toolCalling: true }, + { id: "gpt-4o", name: "GPT 4o", toolCalling: true }, + { id: "gpt-4o-mini", name: "GPT 4o Mini", toolCalling: true }, + { id: "gpt-5.2", name: "GPT 5.2", toolCalling: true }, + { id: "gpt-5.2-codex", name: "GPT 5.2 Codex", toolCalling: true }, + { id: "gpt-5.3-codex", name: "GPT 5.3 Codex", toolCalling: true }, + { id: "gpt-5.4", name: "GPT 5.4", toolCalling: true }, + { id: "gpt-5.4-mini", name: "GPT 5.4 Mini", toolCalling: true }, + { id: "gpt-5.4-pro", name: "GPT 5.4 Pro", toolCalling: true }, + { id: "gpt-5.4-nano", name: "GPT 5.4 Nano", toolCalling: true }, + { id: "gpt-5.5", name: "GPT 5.5", toolCalling: true }, + { id: "gpt-5.5-pro", name: "GPT 5.5 Pro", toolCalling: true }, + { id: "gpt-5-mini", name: "GPT 5 Mini", toolCalling: true }, + { id: "gpt-5-nano", name: "GPT 5 Nano", toolCalling: true }, + { id: "openai-gpt-oss-120b", name: "GPT OSS 120b" }, + // Google + { id: "gemini-3-6-flash", name: "Gemini 3 6 Flash", toolCalling: true }, + { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview", toolCalling: true }, + { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview", toolCalling: true }, + { id: "gemini-3.1-flash-lite", name: "Gemini 3.1 Flash Lite", toolCalling: true }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", toolCalling: true }, + { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", toolCalling: true }, + { id: "gemma-3-27b-it", name: "Gemma 3 27b IT" }, + // xAI + { id: "grok-4-6", name: "Grok 4 6", toolCalling: true }, + { id: "grok-4.5", name: "Grok 4.5", toolCalling: true }, + { id: "grok-4.20-beta", name: "Grok 4.20 Beta", toolCalling: true }, + { id: "grok-4.3", name: "Grok 4.3", toolCalling: true }, + // DeepSeek + { id: "deepseek-v4-flash-0731", name: "Deepseek V4 Flash 0731", toolCalling: true }, + { id: "deepseek-v3.2", name: "Deepseek V3.2", toolCalling: true }, + { id: "deepseek-v4-pro", name: "Deepseek V4 Pro", toolCalling: true }, + { id: "deepseek-v4-flash", name: "Deepseek V4 Flash", toolCalling: true }, + { id: "deepseek-r1", name: "Deepseek R1", toolCalling: true }, + // Alibaba + { id: "qwen-3-8-2-4t-a95b", name: "Qwen 3 8 2 4t A95b", toolCalling: true }, + { id: "qwen-3-8-max", name: "Qwen 3 8 Max", toolCalling: true }, + { id: "qwen-3-6-35b-a3b", name: "Qwen 3 6 35b A3B", toolCalling: true }, + { id: "qwen3-235b-a22b-2507", name: "Qwen3 235b A22b 2507", toolCalling: true }, + { + id: "qwen3-235b-a22b-thinking-2507", + name: "Qwen3 235b A22b Thinking 2507", + toolCalling: true, + }, + { id: "qwen3.5-397b-a17b", name: "Qwen3.5 397b A17b", toolCalling: true }, + { id: "qwen3.6-27b", name: "Qwen3.6 27b", toolCalling: true }, + { id: "qwen3-30b-a3b", name: "Qwen3 30b A3B", toolCalling: true }, + { id: "qwen3-5-35b-a3b", name: "Qwen3 5 35b A3B", toolCalling: true }, + { id: "qwen3-5-9b", name: "Qwen3 5 9b", toolCalling: true }, + { id: "qwen3-coder", name: "Qwen3 Coder", toolCalling: true }, + { id: "qwen3-next-80b-a3b-instruct", name: "Qwen3 Next 80b A3B Instruct", toolCalling: true }, + { id: "qwen3-vl-235b-a22b-thinking", name: "Qwen3 VL 235b A22b Thinking", toolCalling: true }, + { id: "qwen3-vl-30b-a3b-thinking", name: "Qwen3 VL 30b A3B Thinking", toolCalling: true }, + { id: "qwen3.5-flash", name: "Qwen3.5 Flash", toolCalling: true }, + { id: "qwen3.5-plus", name: "Qwen3.5 Plus", toolCalling: true }, + // Moonshot AI + { id: "kimi-k3", name: "Kimi K3", toolCalling: true }, + { id: "kimi-k2", name: "Kimi K2", toolCalling: true }, + { id: "kimi-k2.5", name: "Kimi K2.5", toolCalling: true }, + { id: "kimi-k2.6", name: "Kimi K2.6", toolCalling: true }, + { id: "kimi-k2-thinking", name: "Kimi K2 Thinking", toolCalling: true }, + // Z.ai + { id: "glm-5.2", name: "GLM 5.2", toolCalling: true }, + { id: "glm-4.7-flash", name: "GLM 4.7 Flash", toolCalling: true }, + { id: "glm-5", name: "GLM 5", toolCalling: true }, + { id: "glm-5.1", name: "GLM 5.1", toolCalling: true }, + { id: "glm-4.7", name: "GLM 4.7", toolCalling: true }, + { id: "glm-4.6", name: "GLM 4.6", toolCalling: true }, + // MiniMax + { id: "minimax-m2.1", name: "MiniMax M2.1", toolCalling: true }, + { id: "minimax-m2.5", name: "MiniMax M2.5", toolCalling: true }, + { id: "minimax-m2.7", name: "MiniMax M2.7", toolCalling: true }, + // Mistral + { id: "mistral-large", name: "Mistral Large", toolCalling: true }, + { + id: "mistral-small-3.2-24b-instruct", + name: "Mistral Small 3.2 24b Instruct", + toolCalling: true, + }, + // Meta + { id: "llama-3.2-3b-instruct", name: "Llama 3.2 3b Instruct", toolCalling: true }, + { id: "llama-3.3-70b-instruct", name: "Llama 3.3 70b Instruct", toolCalling: true }, + // NVIDIA + { id: "nvidia-nemotron-3-5-lightning-30b-a3b", name: "Nvidia Nemotron 3 5 Lightning 30b A3B" }, + { id: "nvidia-nemotron-3-nano-30b-a3b", name: "Nvidia Nemotron 3 Nano 30b A3B" }, + // Nous Research + { id: "hermes-3-llama-3.1-405b", name: "Hermes 3 Llama 3.1 405b" }, + // Aion Labs + { id: "aion-labs.aion-2-0", name: "Aion 2 0" }, + // Thinking Machines + { id: "inkling", name: "Inkling" }, + ], +}; diff --git a/open-sse/config/providers/registry/uc/index.ts b/open-sse/config/providers/registry/uc/index.ts new file mode 100644 index 0000000000..93a6501130 --- /dev/null +++ b/open-sse/config/providers/registry/uc/index.ts @@ -0,0 +1,29 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { UC_REGISTRY_MODELS } from "../../../../executors/uc/catalog.ts"; + +/** + * UC (uncensored.com) — the UC consumer app's un-metered "persona" subscription + * chat as an OpenAI-compatible provider. A WebSocket web-app port (like + * muse-spark-web): there is no public API on this path, so the executor mints a + * short-lived Clerk `__session` JWT from a durable `__client` cookie and drives + * the persona socket `wss://internal-6.pubyar.com/ws/{uid}?token={jwt}`. + * + * authType `none`: the persona path uses NO API key. The durable credential + * (`__client` cookie + Clerk session id + account uid + cookie jar) is minted by + * OmniRoute's own browserless email-code login and stored in + * providerSpecificData; the executor reads it from there and mints per-connect + * tokens, so there is no bearer/api-key on the connection. + * + * The metered OpenAI-compatible Developer API (uc-direct) is a SEPARATE provider. + */ +export const ucProvider: RegistryEntry = { + id: "uc", + alias: "ucn", + format: "openai", + executor: "uc", + baseUrl: "https://internal-6.pubyar.com", + authType: "none", + authHeader: "none", + defaultContextLength: 128000, + models: UC_REGISTRY_MODELS, +}; diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 5be229636f..aa5922d65b 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -402,6 +402,40 @@ export const VIDEO_PROVIDERS: Record = { models: [{ id: "grok-imagine-video", name: "Grok Imagine Video" }], }, + // UC (uncensored.com) video generation. One handler (handleUcVideoGeneration) + // serves BOTH surfaces, picking by credential: PERSONA web (un-metered, Clerk + // JWT -> internal.chatuncensored.ai/{text,image}_to_video + moveinwater result + // CDN HEAD poll 403->200) and uc-direct REST (metered, X-api-key -> + // api.uncensored.com, async submit + status poll). authType is "apikey" so the + // route resolves credentials for the metered path; the persona path pulls its + // durable Clerk credential out of providerSpecificData inside the handler. + uc: { + id: "uc", + baseUrl: "https://internal.chatuncensored.ai/image_to_video", + statusUrl: "https://api.uncensored.com/api/v1/videos/generations", + authType: "apikey", + authHeader: "bearer", + format: "uc-video", + models: [ + // Persona web picker default + catalog. + { id: "wan-2.2-spicy", name: "Wan 2.2 Spicy (UC)" }, + // uc-direct REST metered catalog (§2.3). + { id: "t2v-turbo", name: "Text-to-Video Turbo (UC)" }, + { id: "t2v-standard", name: "Text-to-Video Standard (UC)" }, + { id: "i2v-turbo", name: "Image-to-Video Turbo (UC)" }, + { id: "i2v-standard", name: "Image-to-Video Standard (UC)" }, + { id: "i2v-pro", name: "Image-to-Video Pro (UC)" }, + { id: "i2v-sora", name: "Image-to-Video Sora (UC)" }, + { id: "i2v-sora-pro", name: "Image-to-Video Sora Pro (UC)" }, + { id: "cosmos-predict", name: "Cosmos Predict (UC)" }, + { id: "av-gen", name: "AV Gen (UC)" }, + { id: "ltx-distilled", name: "LTX Distilled (UC)" }, + { id: "seedance-2.0", name: "Seedance 2.0 (UC)" }, + { id: "seedance-2.0-fast", name: "Seedance 2.0 Fast (UC)" }, + { id: "happyhorse", name: "HappyHorse (UC)" }, + ], + }, + // Adobe Firefly (unofficial) — same IMS/cookie credential as the image entry. // Exact async video models and capabilities from the verified discovery snapshot. "adobe-firefly": { diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index c33ca48839..12296a3ffe 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -2,11 +2,7 @@ import { SEARCH_PROVIDERS } from "../config/searchRegistry.ts"; import { assertMicrosoftDesignerWebProviderAvailable } from "@/shared/constants/designerWebRetirement"; import { assertRuntimeProviderAvailable } from "@/shared/constants/providerRetirement"; import { assertCommonChatGptWebProviderAvailable } from "@/shared/constants/chatgptWebRetirement"; -import { - registerLazyExecutor, - loadRegisteredExecutor, - hasRegisteredExecutor, -} from "./registry.ts"; +import { registerLazyExecutor, loadRegisteredExecutor, hasRegisteredExecutor } from "./registry.ts"; // Type-only: pulls no runtime code, keeps DefaultExecutor the only eager class. import type { BaseExecutor } from "./base.ts"; import { getDefaultExecutor } from "./defaultResolver.ts"; @@ -45,10 +41,10 @@ const lazyExecutors: Record Promise> = { (m) => new m.CodexAppServerExecutor({}, "codex-app-server") ), maxai: () => import("./maxai.ts").then((m) => new m.MaxAiExecutor()), + uc: () => import("./uc.ts").then((m) => new m.UcExecutor()), "chatgpt-web-codex": () => import("./chatgpt-web-codex.ts").then((m) => new m.ChatGptWebCodexExecutor()), - "cgpt-codex": () => - import("./chatgpt-web-codex.ts").then((m) => new m.ChatGptWebCodexExecutor()), + "cgpt-codex": () => import("./chatgpt-web-codex.ts").then((m) => new m.ChatGptWebCodexExecutor()), cursor: () => import("./cursor.ts").then((m) => new m.CursorExecutor()), trae: () => import("./trae.ts").then((m) => new m.TraeExecutor()), glm: () => import("./glm.ts").then((m) => new m.GlmExecutor("glm")), @@ -72,12 +68,9 @@ const lazyExecutors: Record Promise> = { cf: () => import("./cloudflare-ai.ts").then((m) => new m.CloudflareAIExecutor()), // Alias freebuff: () => import("./freebuff.ts").then((m) => new m.FreebuffExecutor()), fb: () => import("./freebuff.ts").then((m) => new m.FreebuffExecutor()), // Alias - "opencode-zen": () => - import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-zen")), - "opencode-go": () => - import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-go")), - opencode: () => - import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-zen")), // Alias for opencode-zen + "opencode-zen": () => import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-zen")), + "opencode-go": () => import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-go")), + opencode: () => import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-zen")), // Alias for opencode-zen vertex: () => import("./vertex.ts").then((m) => new m.VertexExecutor()), "vertex-partner": () => import("./vertex.ts").then((m) => new m.VertexExecutor()), cliproxyapi: () => import("./cliproxyapi.ts").then((m) => new m.CliproxyapiExecutor()), @@ -86,10 +79,8 @@ const lazyExecutors: Record Promise> = { dr: () => import("./dario.ts").then((m) => new m.DarioExecutor()), // Alias "9router": () => import("./ninerouter.ts").then((m) => new m.NineRouterExecutor()), nr: () => import("./ninerouter.ts").then((m) => new m.NineRouterExecutor()), // Alias - "perplexity-web": () => - import("./perplexity-web.ts").then((m) => new m.PerplexityWebExecutor()), - "pplx-web": () => - import("./perplexity-web.ts").then((m) => new m.PerplexityWebExecutor()), // Alias + "perplexity-web": () => import("./perplexity-web.ts").then((m) => new m.PerplexityWebExecutor()), + "pplx-web": () => import("./perplexity-web.ts").then((m) => new m.PerplexityWebExecutor()), // Alias "grok-web": () => import("./grok-web.ts").then((m) => new m.GrokWebExecutor()), "claude-web": () => import("./claude-web.ts").then((m) => new m.ClaudeWebExecutor()), "cw-web": () => import("./claude-web.ts").then((m) => new m.ClaudeWebExecutor()), // Alias @@ -97,12 +88,10 @@ const lazyExecutors: Record Promise> = { gweb: () => import("./gemini-web.ts").then((m) => new m.GeminiWebExecutor()), // Alias "gemini-business": () => import("./gemini-business.ts").then((m) => new m.GeminiBusinessExecutor()), - gembiz: () => - import("./gemini-business.ts").then((m) => new m.GeminiBusinessExecutor()), // Alias + gembiz: () => import("./gemini-business.ts").then((m) => new m.GeminiBusinessExecutor()), // Alias "blackbox-web": () => import("./blackbox-web.ts").then((m) => new m.BlackboxWebExecutor()), "bb-web": () => import("./blackbox-web.ts").then((m) => new m.BlackboxWebExecutor()), // Alias - "muse-spark-web": () => - import("./muse-spark-web.ts").then((m) => new m.MuseSparkWebExecutor()), + "muse-spark-web": () => import("./muse-spark-web.ts").then((m) => new m.MuseSparkWebExecutor()), "ms-web": () => import("./muse-spark-web.ts").then((m) => new m.MuseSparkWebExecutor()), // Alias "devin-desktop": () => import("./devin-desktop.ts").then((m) => new m.DevinDesktopExecutor()), "zed-hosted": () => import("./zed-hosted.ts").then((m) => new m.ZedHostedExecutor()), @@ -130,8 +119,7 @@ const lazyExecutors: Record Promise> = { firefly: () => import("./adobe-firefly.ts").then((m) => new m.AdobeFireflyExecutor()), // Alias "veoaifree-web": () => import("./veoaifree-web.ts").then((m) => new m.VeoAIFreeWebExecutor()), "veo-free": () => import("./veoaifree-web.ts").then((m) => new m.VeoAIFreeWebExecutor()), // Alias - "duckduckgo-web": () => - import("./duckduckgo-web.ts").then((m) => new m.DuckDuckGoWebExecutor()), + "duckduckgo-web": () => import("./duckduckgo-web.ts").then((m) => new m.DuckDuckGoWebExecutor()), ddgw: () => import("./duckduckgo-web.ts").then((m) => new m.DuckDuckGoWebExecutor()), // Alias "t3-web": () => import("./t3-chat-web.ts").then((m) => new m.T3ChatWebExecutor()), t3chat: () => import("./t3-chat-web.ts").then((m) => new m.T3ChatWebExecutor()), // Alias @@ -142,8 +130,7 @@ const lazyExecutors: Record Promise> = { "yuanbao-web": () => import("./yuanbao-web.ts").then((m) => new m.YuanbaoWebExecutor()), "tencent-aistudio-web": () => import("./tencent-aistudio-web.ts").then((m) => new m.TencentAIStudioWebExecutor()), - tasw: () => - import("./tencent-aistudio-web.ts").then((m) => new m.TencentAIStudioWebExecutor()), // Alias + tasw: () => import("./tencent-aistudio-web.ts").then((m) => new m.TencentAIStudioWebExecutor()), // Alias ybw: () => import("./yuanbao-web.ts").then((m) => new m.YuanbaoWebExecutor()), // Alias "poe-web": () => import("./poe-web.ts").then((m) => new m.PoeWebExecutor()), // #8969: do NOT alias canonical `poe` (API-key / api.poe.com) to PoeWebExecutor. @@ -166,9 +153,7 @@ const lazyExecutors: Record Promise> = { cheaperinference: () => import("./cheaperinference.ts").then((m) => new m.CheaperInferenceExecutor()), cinf: () => - import("./cheaperinference.ts").then( - (m) => new m.CheaperInferenceExecutor("cheaperinference") - ), // Alias + import("./cheaperinference.ts").then((m) => new m.CheaperInferenceExecutor("cheaperinference")), // Alias "doubao-web": () => import("./doubao-web.ts").then((m) => new m.DoubaoWebExecutor()), db: () => import("./doubao-web.ts").then((m) => new m.DoubaoWebExecutor()), // Alias "zai-web": () => import("./zai-web.ts").then((m) => new m.ZaiWebExecutor()), @@ -186,8 +171,7 @@ const lazyExecutors: Record Promise> = { "zenmux-free": () => import("./zenmux-free.ts").then((m) => new m.ZenmuxFreeExecutor()), "cloudflare-playground": () => import("./cloudflare-playground.ts").then((m) => new m.CloudflarePlaygroundExecutor()), - cfp: () => - import("./cloudflare-playground.ts").then((m) => new m.CloudflarePlaygroundExecutor()), // Alias for cloudflare-playground + cfp: () => import("./cloudflare-playground.ts").then((m) => new m.CloudflarePlaygroundExecutor()), // Alias for cloudflare-playground "tinycms-web": () => import("./tinycms.ts").then((m) => new m.TinyCmsExecutor()), tcw: () => import("./tinycms.ts").then((m) => new m.TinyCmsExecutor()), // Alias hyperagent: () => import("./hyperagent.ts").then((m) => new m.HyperAgentExecutor()), @@ -257,11 +241,7 @@ export function hasSpecializedExecutor(provider: string): boolean { return hasRegisteredExecutor(provider); } -export { - registerExecutor, - registerLazyExecutor, - listExecutorAliases, -} from "./registry.ts"; +export { registerExecutor, registerLazyExecutor, listExecutorAliases } from "./registry.ts"; // Value re-export: base.ts is already eager (DefaultExecutor extends it), and // scripts/check/check-known-symbols.ts reads this export from the module. export { BaseExecutor } from "./base.ts"; diff --git a/open-sse/executors/uc.ts b/open-sse/executors/uc.ts new file mode 100644 index 0000000000..7dbe7f8f9b --- /dev/null +++ b/open-sse/executors/uc.ts @@ -0,0 +1,573 @@ +/** + * UcExecutor — UC (uncensored.com) un-metered "persona" chat as an + * OpenAI-compatible OmniRoute provider. + * + * UC is a consumer subscription app with no public API on the persona path. This + * executor reproduces the web app's own persona WebSocket turn: + * • mint a 60s Clerk `__session` JWT from the durable `__client` cookie + * (see ./uc/clerkAuth.ts), cached per session id and re-minted ~8s early, + * • open `wss://internal-6.pubyar.com/ws/{uid}?token={jwt}` with only an + * `Origin` header (see ./uc/ws.ts), + * • send ONE persona frame: current turn as `text` + prior turns as + * `chat_history` (roles human/assistant), NO max_tokens/direct_params + * (see ./uc/protocol.ts), + * • stream newline-delimited frames, splitting reasoning + * (intermediary_message) from the answer (text deltas / raw_text) and + * branching the explicit error/quota frames (see ./uc/stream.ts). + * + * Tools: UC persona has no native function-calling, so tool schemas are injected + * as a prompted `` contract (the same shared shim the web-cookie providers + * use, translator/webTools.ts) and parsed back into tool_calls. + * + * Egress + TLS: this executor opens no raw socket of its own beyond the `ws` + * client and the ambient patched `fetch` (token mint); OmniRoute's per-connection + * proxy + TLS overlay therefore apply automatically. UC does not require a + * special TLS fingerprint, but the deployment routes it through the same egress + * chokepoint as every other provider. + * + * Auth refresh: the 60s JWT is minted on demand; when the mint 401/403s the + * durable ~30-day Clerk window has lapsed and the caller is prompted to re-run the + * browserless email login (see ./uc/emailLogin.ts). + */ +import { BaseExecutor, type ExecuteInput, type ExecutorExecuteResult } from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; +import { prepareToolMessages, parseToolCallsFromText } from "../translator/webTools.ts"; +import { buildToolModeResponse } from "./chatgptWebTools.ts"; +import { UC_BASE_URL } from "./uc/constants.ts"; +import { resolveUcCredential, type UcCredential } from "./uc/credentials.ts"; +import { mintUcSessionToken, ucTokenCache, type UcSessionToken } from "./uc/clerkAuth.ts"; +import { assembleUcTurn } from "./uc/protocol.ts"; +import { detectUcSoftError, estimateUcTokens } from "./uc/stream.ts"; +import { runUcTurn, type UcTurnResult } from "./uc/ws.ts"; +import { + ucUsesCodestyle, + ucLooksLikeRefusal, + parseUcExtraDialects, + UC_CODESTYLE_HEADER, +} from "./uc/toolDialect.ts"; +import { extractCurrentTurnMedia, uploadUcTurnMedia, type UcMediaBlob } from "./uc/media.ts"; + +const JSON_HEADERS = { "Content-Type": "application/json" }; +const SSE_HEADERS = { + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "Content-Type": "text/event-stream; charset=utf-8", +}; + +interface OpenAiChatBody { + messages?: Array<{ + role?: string; + content?: unknown; + tool_calls?: unknown; + tool_call_id?: string; + }>; + model?: string; +} + +function errorResponse(status: number, message: string, code: string): Response { + return new Response( + JSON.stringify({ + error: { + code, + message: sanitizeErrorMessage(message), + type: status >= 500 ? "provider_error" : "invalid_request_error", + }, + }), + { status, headers: JSON_HEADERS } + ); +} + +/** + * Replace the standard `` contract that prepareToolMessages folded into the + * assembled text with UC's natural code-style header for guardrailed models. The + * shared shim always appends its `` block as the tail; we strip a trailing + * "Available tools:"-style block only when present and re-lead with the code-style + * header. Falls back to appending the code-style header when no block is found. + */ +function applyCodestylePreamble(text: string): string { + // The shared prepareToolMessages injects the tool contract as a system-message + // that assembleUcTurn folds into `text`. We can't reliably surgically remove it, + // so we PREPEND the code-style header — it re-frames tool use as prose, and the + // model prefers the last/clearest instruction. Cheap and safe. + return `${UC_CODESTYLE_HEADER}\n\n${text}`; +} + +/** + * If the shared `` JSON parser would find nothing but a UC extra dialect + * (code-style `fn("x")` or Gemini ``) is present, rewrite those calls as + * canonical `{json}` blocks appended to the answer so the + * shared buildToolModeResponse parses them uniformly. No-op when the shared parser + * already sees calls or no extra dialect is present. + */ +function injectExtraDialectCalls(answer: string, requestedTools: unknown, model: string): string { + const sharedHasCall = !!parseToolCallsFromText(answer, "probe", requestedTools).toolCalls; + if (sharedHasCall) return answer; + const extra = parseUcExtraDialects(answer, requestedTools, model); + if (extra.length === 0) return answer; + const blocks = extra + .map( + (c) => + `${JSON.stringify({ name: c.function.name, arguments: c.function.arguments })}` + ) + .join("\n"); + return `${answer}\n${blocks}`; +} + +/** + * Wrap a Response into the executor wrapper contract + * `{response, url, headers, transformedBody}` that chatCore + the web-cookie + * sweep require. `headers`/`transformedBody` are the ACTUAL upstream request + * capture ("what we sent"); for UC that is the WS handshake headers + the persona + * frame. Error paths that fail before a frame is assembled pass no capture. + */ +function wrap( + response: Response, + url: string, + capture?: { headers?: Record; transformedBody?: unknown } +): { response: Response; url: string; headers: Record; transformedBody: unknown } { + return { + response, + url, + headers: capture?.headers ?? {}, + transformedBody: capture?.transformedBody ?? null, + }; +} + +/** Emit one OpenAI chat.completion.chunk. */ +function chunk( + controller: ReadableStreamDefaultController, + id: string, + created: number, + model: string, + delta: Record, + finish: string | null = null +): void { + const payload = { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta, finish_reason: finish }], + }; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)); +} + +/** Classify a UC turn error string into an HTTP status + OpenAI error code. */ +function classifyTurnError(error: string): { status: number; code: string } { + const low = error.toLowerCase(); + if (low.includes("message_limit_exceeded")) + return { status: 429, code: "uc_message_limit_exceeded" }; + if (low.includes("paywall_exceeded")) return { status: 429, code: "uc_paywall_exceeded" }; + if (low.includes("rate_limit_exceeded")) return { status: 429, code: "uc_rate_limit_exceeded" }; + if (low.includes("unauthorized") || low.includes("forbidden")) { + return { status: 401, code: "uc_auth_error" }; + } + if (low.includes("timed out")) return { status: 504, code: "uc_timeout" }; + if (low.includes("generation_failed")) return { status: 502, code: "uc_generation_failed" }; + return { status: 502, code: "uc_upstream_error" }; +} + +export class UcExecutor extends BaseExecutor { + constructor() { + super("uc", PROVIDERS.uc ?? { id: "uc", baseUrl: UC_BASE_URL }); + } + + override async execute(input: ExecuteInput): Promise { + // The persona WS URL host is the wrapper `url` for every return path. + const url = UC_BASE_URL; + + const cred = resolveUcCredential(input.credentials?.providerSpecificData); + if (!cred) { + return wrap( + errorResponse( + 401, + "UC connection is not configured (missing __client cookie, session id, or uid). Run the email login to bootstrap credentials.", + "uc_unconfigured" + ), + url + ); + } + + // Mint (or reuse a cached) 60s Clerk session JWT. + let jwt: string; + try { + jwt = await this.ensureSessionToken(cred, input); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const status = /HTTP 40[13]|unauthorized|forbidden/i.test(msg) ? 401 : 502; + return wrap( + errorResponse( + status, + `UC auth failed: ${sanitizeErrorMessage(msg)}. If this persists the ~30-day Clerk session lapsed — re-run the email login.`, + status === 401 ? "uc_auth_error" : "uc_upstream_error" + ), + url + ); + } + + const body = (input.body ?? {}) as OpenAiChatBody; + const originalMessages = (body.messages ?? []) as Array<{ role?: string; content?: unknown }>; + + // Vision + doc input (persona blob layer): extract inline images/docs from the + // current turn, upload each via the presigned-URL flow, and carry the blob + // refs in the frame. UC parses the blob server-side (image vision, PDF text). + // Best-effort: upload failures are skipped and the chat proceeds text-only. + let media: UcMediaBlob[] = []; + try { + const { inline } = extractCurrentTurnMedia(originalMessages); + if (inline.length) { + media = await uploadUcTurnMedia(inline, { + jwt, + uid: cred.uid, + signal: input.signal, + log: input.log ?? undefined, + }); + } + } catch { + media = []; + } + + // Tool-calling (prompted protocol): inject the contract into the + // messages so the model learns the client tools; response side parses the + // blocks back into tool_calls. Same shim the web-cookie providers use. + // For models UC wraps in a hard guardrail that refuses the markup + // (e.g. gpt-5.5), swap to the natural code-style dialect that slips past it. + const codestyle = ucUsesCodestyle(input.model); + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( + body as Record, + originalMessages as Array<{ role: string; content: unknown }> + ); + + const assembled = assembleUcTurn( + effectiveMessages as Array<{ role?: string; content?: unknown; name?: string }> + ); + let text = codestyle ? applyCodestylePreamble(assembled.text) : assembled.text; + const history = assembled.history; + if (!text) { + return wrap(errorResponse(400, "No user message to send to UC.", "uc_empty_request"), url); + } + + const id = `chatcmpl-uc-${Date.now().toString(36)}`; + const created = Math.floor(Date.now() / 1000); + const promptTokens = estimateUcTokens(text); + const capture = { + headers: { Origin: "https://uncensored.com" }, + transformedBody: { + model: input.model, + text, + chat_history: history, + ...(media.length ? { media_blob_name: media[0].blobName } : {}), + }, + }; + + // Tool mode: the protocol is only parseable once the full reply is in + // hand, so buffer the whole turn, build a chat.completion, and let the shared + // shim parse blocks into tool_calls (with a terminal SSE replay for + // streaming callers). Mirrors every web-cookie provider's tool path. + if (hasTools) { + let turn = await runUcTurn({ + jwt, + uid: cred.uid, + model: input.model, + text, + history, + media, + signal: input.signal, + }); + const errResp = this.turnErrorResponse(turn, url); + if (errResp) return errResp; + + let answer = turn.content; + let reasoning = turn.reasoning; + + // AUTO-CURE: a guardrailed model (NOT already code-style) that REFUSED the + // markup gets ONE retry with the natural code-style dialect, + // which slips past the vendor guardrail. Only fires on an actual + // refusal-with-tools, so the working models never take this path. + const firstHasCall = + !!parseToolCallsFromText(answer, "probe", requestedTools).toolCalls || + parseUcExtraDialects(answer, requestedTools, input.model).length > 0; + if (!firstHasCall && !codestyle && ucLooksLikeRefusal(answer)) { + const curedText = applyCodestylePreamble(assembled.text); + const retry = await runUcTurn({ + jwt, + uid: cred.uid, + model: input.model, + text: curedText, + history, + media, + signal: input.signal, + }); + if (!retry.error && retry.content) { + const retryHasCall = + !!parseToolCallsFromText(retry.content, "probe", requestedTools).toolCalls || + parseUcExtraDialects(retry.content, requestedTools, input.model).length > 0; + if (retryHasCall) { + answer = retry.content; + reasoning = retry.reasoning; + input.log?.debug?.("uc", "tool refusal recovered via code-style retry"); + } + } + } + + // Supplement the shared parser with UC's extra dialects (code-style + // fn("x") + Gemini ). If the shared JSON parser found no calls but + // an extra dialect did, rewrite the answer's calls as JSON so the + // shared buildToolModeResponse picks them up uniformly. + answer = injectExtraDialectCalls(answer, requestedTools, input.model); + + const completionTokens = estimateUcTokens(reasoning + answer); + const buffered = new Response( + JSON.stringify({ + id, + object: "chat.completion", + created, + model: input.model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: answer, + ...(reasoning ? { reasoning_content: reasoning } : {}), + }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }), + { status: 200, headers: JSON_HEADERS } + ); + const response = await buildToolModeResponse(buffered, requestedTools, input.stream, { + cid: id, + created, + model: input.model, + idSeed: "uc", + }); + return wrap(response, url, capture); + } + + if (input.stream) { + const stream = this.buildStream(input, jwt, cred, text, history, media, id, created); + return wrap(new Response(stream, { status: 200, headers: SSE_HEADERS }), url, capture); + } + + // Non-streaming: run the turn to completion, build a chat.completion. + const turn = await runUcTurn({ + jwt, + uid: cred.uid, + model: input.model, + text, + history, + media, + signal: input.signal, + }); + const errResp = this.turnErrorResponse(turn, url); + if (errResp) return errResp; + + const answer = turn.content; + const reasoning = turn.reasoning; + const completionTokens = estimateUcTokens(reasoning + answer); + const response = { + id, + object: "chat.completion", + created, + model: input.model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: answer, + ...(reasoning ? { reasoning_content: reasoning } : {}), + }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }; + return wrap( + new Response(JSON.stringify(response), { status: 200, headers: JSON_HEADERS }), + url, + capture + ); + } + + /** + * Convert a failed/soft-errored UC turn into an error Response, or null when + * the turn is a usable answer. A soft-error apology (short transient capacity + * message returned AS the answer) is surfaced as a retryable 502 so OmniRoute + * can fall back instead of handing the user a bogus reply. + */ + private turnErrorResponse(turn: UcTurnResult, url: string): ReturnType | null { + if (turn.error) { + const { status, code } = classifyTurnError(turn.error); + return wrap(errorResponse(status, `UC persona turn failed: ${turn.error}`, code), url); + } + const soft = detectUcSoftError(turn.content); + if (soft) { + return wrap( + errorResponse(502, `UC returned a transient soft-error: ${soft}`, "uc_soft_error"), + url + ); + } + if (!turn.content) { + return wrap(errorResponse(502, "UC returned an empty response.", "uc_empty_response"), url); + } + return null; + } + + /** + * Build a live OpenAI SSE stream from a persona turn. Streams reasoning as + * `reasoning_content` deltas and the answer as `content` deltas, then a + * terminal `finish_reason: "stop"`. A mid-stream error frame ends the stream + * with an error delta (best-effort; the tool path buffers instead). + */ + private buildStream( + input: ExecuteInput, + jwt: string, + cred: UcCredential, + text: string, + history: ReturnType["history"], + media: UcMediaBlob[], + id: string, + created: number + ): ReadableStream { + const model = input.model; + return new ReadableStream({ + start: async (controller) => { + // Prime the stream with the role delta. + chunk(controller, id, created, model, { role: "assistant" }); + let sawError: string | null = null; + let streamed = ""; + const turn = await runUcTurn({ + jwt, + uid: cred.uid, + model, + text, + history, + media, + signal: input.signal, + onEvent: (evt) => { + if (evt.kind === "reasoning") { + chunk(controller, id, created, model, { reasoning_content: evt.text }); + } else if (evt.kind === "delta") { + streamed += evt.text; + chunk(controller, id, created, model, { content: evt.text }); + } else if (evt.kind === "error") { + sawError = evt.text; + } + }, + }); + + const err = turn.error ?? sawError; + // A soft-error apology returned AS the answer is not a real reply — treat + // it as an error when nothing streamed. + const soft = !err && !streamed ? detectUcSoftError(turn.content) : null; + if ((err || soft) && !streamed) { + const reason = err ?? `transient soft-error: ${soft}`; + const { code } = classifyTurnError(String(reason)); + controller.enqueue( + new TextEncoder().encode( + `data: ${JSON.stringify({ + error: { + code, + message: sanitizeErrorMessage(`UC persona turn failed: ${reason}`), + type: "provider_error", + }, + })}\n\n` + ) + ); + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); + controller.close(); + return; + } + + // Flush the authoritative final content that wasn't already streamed. + // Short answers arrive ONLY in the terminal `raw_text` (no text deltas), + // so `turn.content` is the full answer while `streamed` is empty; emit the + // remainder as one content delta. When deltas WERE streamed, turn.content + // equals `streamed` and the remainder is empty (nothing extra emitted). + const remainder = turn.content.startsWith(streamed) + ? turn.content.slice(streamed.length) + : turn.content; + if (remainder) { + chunk(controller, id, created, model, { content: remainder }); + } + + chunk(controller, id, created, model, {}, "stop"); + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + } + + /** + * Return a valid 60s session JWT: reuse the per-session cache when fresh, else + * mint a new one, persisting any rotated cookies back to the connection. + * Throws on a hard mint failure (the caller maps it to a 401/502). + */ + private async ensureSessionToken(cred: UcCredential, input: ExecuteInput): Promise { + const cached = ucTokenCache.get(cred.sid); + if (cached) return cached; + + const result = await mintUcSessionToken({ + sid: cred.sid, + cookies: cred.cookies, + signal: input.signal, + }); + if (!result.ok || !result.token) { + // Persist any rotated cookies even on failure (they may unstick next time). + await this.persistRotatedCookies(cred, result.rotatedCookies, input); + throw new Error(result.error || `Clerk mint HTTP ${result.status}`); + } + + const token: UcSessionToken = result.token; + ucTokenCache.set(cred.sid, token); + await this.persistRotatedCookies(cred, result.rotatedCookies, input); + return token.jwt; + } + + /** Merge any rotated cookies into the stored connection credential. */ + private async persistRotatedCookies( + cred: UcCredential, + rotated: Record | undefined, + input: ExecuteInput + ): Promise { + if (!rotated || Object.keys(rotated).length === 0) return; + // Only persist when something actually changed vs the stored jar. + let changed = false; + const nextCookies = { ...cred.cookies }; + for (const [k, v] of Object.entries(rotated)) { + if (nextCookies[k] !== v) { + nextCookies[k] = v; + changed = true; + } + } + if (!changed) return; + try { + await input.onCredentialsRefreshed?.({ + providerSpecificData: { + ...(input.credentials?.providerSpecificData ?? {}), + ucCookies: nextCookies, + // Keep the durable cookie mirror in sync if it rotated (rare). + ...(nextCookies.__client ? { ucClientCookie: nextCookies.__client } : {}), + }, + }); + } catch (err) { + input.log?.warn?.( + "uc", + `rotated-cookie persist failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : err)}` + ); + } + } +} diff --git a/open-sse/executors/uc/catalog.ts b/open-sse/executors/uc/catalog.ts new file mode 100644 index 0000000000..1033c11841 --- /dev/null +++ b/open-sse/executors/uc/catalog.ts @@ -0,0 +1,174 @@ +/** + * UC (uncensored.com) PERSONA model catalog. + * + * These 19 ids are the empirically-verified working persona-mode models: each + * one returned real text from the WebSocket backend in a live audit + * (UC-UNCENSORED-MODELS.md / UC-NATIVE-PORT-FINDINGS.md). Guessed/broken ids + * (e.g. persona `gpt-5.4`, base `claude-opus-4.8` non-uncensored) were dropped + * so the provider never advertises a model that 500s. + * + * `id` is the UC persona **shortname** (provider prefix dropped, dots stripped): + * this is exactly the value sent as the WS frame's `model` field. Context / + * max-output come from UC's direct-mode catalog (direct-models.json); grok-4.x + * publish no separate output cap (bounded by the context window). + * + * The ⭐ `-uncensored` / persona variants are the differentiator (unlocked + * behavior) — the whole reason this un-metered surface is worth porting. + */ +import type { RegistryModel } from "../../config/providers/shared.ts"; + +interface UcModelSpec { + id: string; + name: string; + contextLength: number; + maxOutputTokens?: number; + supportsReasoning?: boolean; + /** + * Vision-capable (the underlying model accepts image input). UC persona feeds + * images via the blob-upload layer (see uc/media.ts), which the backend parses + * server-side and hands to the model — so vision works for these ids. + * Sourced from UC's direct-mode catalog (direct-models.json capabilities). + */ + supportsVision?: boolean; +} + +/** The 19 offered persona (un-metered) chat models. */ +export const UC_MODELS: UcModelSpec[] = [ + // Anthropic (persona: 4.8 is uncensored-only, so we expose the -uncensored id) + { + id: "claude-opus-45", + name: "Claude Opus 4.5", + contextLength: 200_000, + maxOutputTokens: 64_000, + supportsVision: true, + }, + { + id: "claude-opus-46", + name: "Claude Opus 4.6", + contextLength: 1_000_000, + maxOutputTokens: 128_000, + supportsVision: true, + }, + { + id: "claude-opus-46-v2", + name: "Claude Opus 4.6 (v2)", + contextLength: 1_000_000, + maxOutputTokens: 128_000, + supportsVision: true, + }, + { + id: "claude-opus-47", + name: "Claude Opus 4.7", + contextLength: 1_000_000, + maxOutputTokens: 128_000, + supportsVision: true, + }, + { + id: "claude-opus-47-v2", + name: "Claude Opus 4.7 (v2)", + contextLength: 1_000_000, + maxOutputTokens: 128_000, + supportsVision: true, + }, + { + id: "claude-opus-48-uncensored", + name: "Claude Opus 4.8 (Uncensored)", + contextLength: 1_000_000, + maxOutputTokens: 128_000, + supportsVision: true, + }, + // DeepSeek + { + id: "deepseek-r1", + name: "DeepSeek R1", + contextLength: 163_840, + maxOutputTokens: 16_000, + supportsReasoning: true, + }, + // GLM + { id: "glm-5.1", name: "GLM 5.1", contextLength: 202_752, maxOutputTokens: 131_072 }, + // OpenAI (gpt-5.5 is the only working persona GPT; guardrailed → code-style tools) + { + id: "gpt-5.5", + name: "GPT-5.5", + contextLength: 1_050_000, + maxOutputTokens: 128_000, + supportsVision: true, + }, + // Google Gemini + { + id: "gemini-3-flash", + name: "Gemini 3 Flash", + contextLength: 1_048_576, + maxOutputTokens: 65_536, + supportsVision: true, + }, + { + id: "gemini-31-uncensored", + name: "Gemini 3.1 (Uncensored)", + contextLength: 1_048_576, + maxOutputTokens: 65_536, + supportsVision: true, + }, + { + id: "gemini-emotional", + name: "Gemini (Emotional)", + contextLength: 1_048_576, + maxOutputTokens: 65_536, + supportsVision: true, + }, + { + id: "gemini-3-uncensored", + name: "Gemini 3 (Uncensored)", + contextLength: 1_048_576, + maxOutputTokens: 65_536, + supportsVision: true, + }, + // xAI Grok (no separate output cap — bounded by context window) + { id: "grok-4", name: "Grok 4", contextLength: 1_000_000, supportsVision: true }, + { id: "grok-4-20", name: "Grok 4.20", contextLength: 2_000_000, supportsVision: true }, + { id: "grok-4-3", name: "Grok 4.3", contextLength: 1_000_000, supportsVision: true }, + // Moonshot Kimi + { + id: "kimi-k2-thinking", + name: "Kimi K2 Thinking", + contextLength: 262_144, + maxOutputTokens: 262_144, + supportsReasoning: true, + }, + { + id: "kimi-k2.5", + name: "Kimi K2.5", + contextLength: 262_144, + maxOutputTokens: 262_144, + supportsVision: true, + }, + // MiniMax + { + id: "minimax-m2-her", + name: "MiniMax M2 (Her)", + contextLength: 204_800, + maxOutputTokens: 131_072, + }, +]; + +/** RegistryModel[] form for the provider registry entry. */ +export const UC_REGISTRY_MODELS: RegistryModel[] = UC_MODELS.map((m) => ({ + id: m.id, + name: m.name, + contextLength: m.contextLength, + // Prompted tool-calling: UC persona has no native tools[] API, but the + // executor injects a preamble and parses the calls back, so the + // capability is real from the client's perspective. + toolCalling: true, + ...(m.maxOutputTokens ? { maxOutputTokens: m.maxOutputTokens } : {}), + ...(m.supportsReasoning ? { supportsReasoning: true } : {}), + ...(m.supportsVision ? { supportsVision: true } : {}), +})); + +/** Default context window for an unknown model. */ +export const UC_DEFAULT_CONTEXT = 128_000; + +export function ucContextWindow(modelId: string): number { + return UC_MODELS.find((m) => m.id === modelId)?.contextLength ?? UC_DEFAULT_CONTEXT; +} diff --git a/open-sse/executors/uc/clerkAuth.ts b/open-sse/executors/uc/clerkAuth.ts new file mode 100644 index 0000000000..646ce92d65 --- /dev/null +++ b/open-sse/executors/uc/clerkAuth.ts @@ -0,0 +1,183 @@ +/** + * UC (uncensored.com) Clerk auth — mint the short-lived `__session` JWT that + * authenticates the persona WebSocket. + * + * UC uses Clerk. The socket URL carries a `?token=` that is a 60-second + * Clerk session JWT (RS256, `iss: clerk.uncensored.com`, `exp - iat = 60`). It is + * minted from the durable `__client` cookie: + * + * POST https://clerk.uncensored.com/v1/client/sessions/{sid}/tokens + * ?_clerk_js_version=5.x + * Origin: https://uncensored.com + * Referer: https://uncensored.com/ + * Cookie: + * Content-Type: application/x-www-form-urlencoded + * body: (empty) + * -> 200 { "object": "token", "jwt": "" } + * + * The token is only needed at the WS handshake (the socket outlives the 60s + * expiry — the backend does not re-check mid-stream). We cache the minted JWT per + * session id and re-mint ~8s before expiry, exactly like the reference client. + * + * A mint call rotates only Cloudflare cookies (`__cf_bm`), never `__client`, so + * the durable credential is stable; we still capture any `Set-Cookie` rotation so + * the caller can persist a refreshed jar. + */ +import { + UC_CLERK_FAPI, + UC_CLERK_JS_VERSION, + UC_ORIGIN, + UC_TOKEN_REFRESH_SKEW_S, +} from "./constants.ts"; +import { cookieHeader, sessionJwtExpiry } from "./credentials.ts"; + +/** A minted session token plus metadata. */ +export interface UcSessionToken { + jwt: string; + /** epoch seconds of the JWT `exp` (0 when undecodable). */ + expiresAt: number; +} + +export interface UcMintInput { + sid: string; + /** Full cookie jar (must include `__client`). */ + cookies: Record; + signal?: AbortSignal | null; + /** Injectable fetch for tests (defaults to the ambient patched fetch). */ + fetchImpl?: typeof fetch; +} + +export interface UcMintResult { + ok: boolean; + token?: UcSessionToken; + /** Cookies observed rotating in the response `Set-Cookie` (name → value). */ + rotatedCookies?: Record; + status: number; + error?: string; +} + +/** Cookie directive attributes we never treat as an actual cookie name/value. */ +const COOKIE_ATTRS = new Set([ + "expires", + "path", + "domain", + "samesite", + "secure", + "httponly", + "max-age", +]); + +/** Parse rotated cookie name=value pairs out of a raw `Set-Cookie` header. */ +export function parseSetCookie(setCookie: string): Record { + const out: Record = {}; + if (!setCookie) return out; + for (const m of setCookie.matchAll(/(?:^|,\s*)([A-Za-z0-9_]+)=([^;,\s]+)/g)) { + const name = m[1]; + const val = m[2]; + if (COOKIE_ATTRS.has(name.toLowerCase())) continue; + out[name] = val; + } + return out; +} + +/** + * Mint a fresh 60s Clerk session JWT from the durable cookie jar. Never throws; + * returns a structured result the caller branches on. A 401/403 means the durable + * login is invalid (the ~30-day window lapsed or the cookie was revoked) — the + * caller should surface a re-login prompt. + */ +export async function mintUcSessionToken(input: UcMintInput): Promise { + const doFetch = input.fetchImpl ?? fetch; + if (!input.sid || !input.cookies?.__client) { + return { ok: false, status: 0, error: "missing sid or __client cookie" }; + } + + const url = `${UC_CLERK_FAPI}/v1/client/sessions/${input.sid}/tokens?_clerk_js_version=${UC_CLERK_JS_VERSION}`; + const headers: Record = { + Origin: UC_ORIGIN, + Referer: UC_ORIGIN + "/", + Cookie: cookieHeader(input.cookies), + "Content-Type": "application/x-www-form-urlencoded", + }; + + let res: Response; + try { + res = await doFetch(url, { + method: "POST", + headers, + body: "", + signal: input.signal ?? undefined, + }); + } catch (err) { + return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) }; + } + + const rotatedCookies = parseSetCookie(res.headers.get("set-cookie") ?? ""); + const raw = await res.text().catch(() => ""); + if (res.status !== 200) { + return { + ok: false, + status: res.status, + error: raw.slice(0, 200) || `Clerk mint HTTP ${res.status}`, + rotatedCookies, + }; + } + + let jwt = ""; + try { + const parsed = JSON.parse(raw) as { jwt?: unknown; token?: unknown }; + if (typeof parsed?.jwt === "string") jwt = parsed.jwt; + else if (typeof parsed?.token === "string") jwt = parsed.token; + } catch { + return { + ok: false, + status: res.status, + error: "unparseable Clerk token response", + rotatedCookies, + }; + } + if (!jwt) { + return { + ok: false, + status: res.status, + error: "Clerk token response had no jwt", + rotatedCookies, + }; + } + + return { + ok: true, + status: 200, + token: { jwt, expiresAt: sessionJwtExpiry(jwt) }, + rotatedCookies, + }; +} + +/** + * A tiny per-session token cache. UC mints a 60s JWT per connect; caching it and + * re-minting ~8s early avoids a mint on every single turn while never handing out + * a token within the skew window of expiry. Keyed by Clerk session id. + */ +export class UcTokenCache { + private cache = new Map(); + + /** Return a still-fresh cached token for `sid`, or null when a mint is needed. */ + get(sid: string, now: () => number = Date.now): string | null { + const tok = this.cache.get(sid); + if (!tok) return null; + if (tok.expiresAt - now() / 1000 > UC_TOKEN_REFRESH_SKEW_S) return tok.jwt; + return null; + } + + set(sid: string, token: UcSessionToken): void { + this.cache.set(sid, token); + } + + clear(sid?: string): void { + if (sid) this.cache.delete(sid); + else this.cache.clear(); + } +} + +/** Process-wide token cache (mirrors the reference client's per-adapter cache). */ +export const ucTokenCache = new UcTokenCache(); diff --git a/open-sse/executors/uc/constants.ts b/open-sse/executors/uc/constants.ts new file mode 100644 index 0000000000..7da2cf524c --- /dev/null +++ b/open-sse/executors/uc/constants.ts @@ -0,0 +1,59 @@ +/** + * UC (uncensored.com) PERSONA path — wire constants. + * + * UC is a consumer subscription app (uncensored.com) whose un-metered "persona" + * chat runs over a WebSocket to its inference backend. There is no public API on + * this path: auth is a short-lived Clerk `__session` JWT minted from a durable + * `__client` cookie, passed as the `?token=` query param on the socket URL. + * + * All values below are capture-confirmed (UC-PERSONA-WS-OMNIROUTE-SPEC.md / + * UC-AUTH-AND-EMAIL-LOGIN.md) and match the proven reference client. + */ + +/** Clerk Frontend API host (auth: token mint, session touch, email sign-in). */ +export const UC_CLERK_FAPI = "https://clerk.uncensored.com"; + +/** Clerk JS version echoed as `?_clerk_js_version` on every Clerk call. */ +export const UC_CLERK_JS_VERSION = "5.127.1"; + +/** Clerk API version echoed as `?__clerk_api_version` on sign-in calls. */ +export const UC_CLERK_API_VERSION = "2025-11-10"; + +/** Origin the UC web app sends; Clerk + the WS backend both check it. */ +export const UC_ORIGIN = "https://uncensored.com"; + +/** WebSocket inference backend base (persona/non-direct + direct both ride this). */ +export const UC_WS_HOST = "wss://internal-6.pubyar.com/ws"; + +/** + * Synthetic base URL for the registry entry. UC persona has no HTTP chat + * endpoint (it is a WebSocket), so this is a marker the executor recognizes; it + * is never fetched. Mirrors the muse-spark-web pattern of a nominal baseUrl. + */ +export const UC_BASE_URL = "https://internal-6.pubyar.com"; + +/** Refresh a 60s `__session` JWT this many seconds before its `exp`. */ +export const UC_TOKEN_REFRESH_SKEW_S = 8; + +/** Default per-turn WebSocket timeout (ms). */ +export const UC_WS_TIMEOUT_MS = 120_000; + +/** The web app version string the persona frame carries. */ +export const UC_APP_VERSION = "1.0.0-web"; + +/** + * TTS (text-to-speech) WebSocket backend base. Distinct host from the persona + * chat WS (pubyar.com); the full URL is `${UC_TTS_WS_HOST}/{uid}?token={jwt}`. + * Same Clerk-JWT-in-query-param auth + `Origin: https://uncensored.com` + * handshake header as the chat socket (see UC-MEDIA-GENERATION.md). + */ +export const UC_TTS_WS_HOST = "wss://tts-stream.chatuncensored.ai"; + +/** Default UC TTS voice (capture-confirmed; others presumably exist). */ +export const UC_TTS_DEFAULT_VOICE = "jade"; + +/** Default UC TTS model tier carried in the `start` frame. */ +export const UC_TTS_DEFAULT_MODEL = "default"; + +/** Default per-request UC TTS WebSocket timeout (ms). */ +export const UC_TTS_WS_TIMEOUT_MS = 120_000; diff --git a/open-sse/executors/uc/credentials.ts b/open-sse/executors/uc/credentials.ts new file mode 100644 index 0000000000..431987c3ce --- /dev/null +++ b/open-sse/executors/uc/credentials.ts @@ -0,0 +1,125 @@ +/** + * UC (uncensored.com) connection credential resolution. + * + * UC's persona WebSocket authenticates with a short-lived Clerk `__session` JWT + * (60s) that the executor mints per-connect from a DURABLE credential set stored + * in the connection's `providerSpecificData`: + * + * • `clientCookie` — the Clerk `__client` cookie (a JWT with NO `exp`; the + * real long-lived credential, secured by a rotating_token + * that only changes on genuine security events). + * • `sid` — the Clerk session id (`sess_...`); the mint path is + * `POST /v1/client/sessions/{sid}/tokens`. + * • `uid` — the account UID (uuid v4); it is the WS URL path segment + * AND the frame's `user_identifier`, and equals the JWT + * `uid` claim (so it can be recovered from a minted token). + * • `cookies` — the full cookie jar (Cloudflare `__cf_bm`/`_cfuvid`, + * `__client_uat`, etc.) sent on the mint call. Persisting + * the whole jar lets the executor follow cookie rotation. + * + * These are minted by OmniRoute's own browserless email-code login (see + * ./emailLogin.ts), so the router is self-contained and never reads any external + * (Hermes) token file. + */ + +type ProviderSpecificData = Record | null | undefined; + +export interface UcCredential { + /** Clerk `__client` durable cookie (JWT, no exp). */ + clientCookie: string; + /** Clerk session id (`sess_...`). */ + sid: string; + /** Account UID (uuid) — WS path + user_identifier + JWT `uid` claim. */ + uid: string; + /** Full cookie jar to send on the Clerk mint call (name → value). */ + cookies: Record; +} + +function firstString(...values: unknown[]): string | null { + for (const v of values) { + if (typeof v === "string") { + // Raw browser LocalStorage/cookie dumps sometimes wrap the value in quotes. + const trimmed = v.trim().replace(/^"|"$/g, ""); + if (trimmed.length > 0) return trimmed; + } + } + return null; +} + +/** Decode a Clerk JWT payload without verifying (base64url middle segment). */ +function decodeJwtClaims(jwt: string): Record | null { + try { + const seg = jwt.split(".")[1]; + if (!seg) return null; + const b64 = seg.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (seg.length % 4)) % 4); + return JSON.parse(Buffer.from(b64, "base64").toString("utf8")) as Record; + } catch { + return null; + } +} + +/** The `uid` claim from a Clerk `__session` JWT (== WS user_identifier), or null. */ +export function uidFromSessionJwt(jwt: string): string | null { + const claims = decodeJwtClaims(jwt); + const uid = claims?.uid; + return typeof uid === "string" && uid.length > 0 ? uid : null; +} + +/** Epoch seconds of a Clerk JWT `exp`, or 0 when undecodable. */ +export function sessionJwtExpiry(jwt: string): number { + const claims = decodeJwtClaims(jwt); + return typeof claims?.exp === "number" ? claims.exp : 0; +} + +/** + * Normalize a stored cookie jar into a flat `{name: value}` map. Accepts either + * a raw CDP dump shape `{name: {value: "..."}}` (what the capture/login persists) + * or an already-flat `{name: "value"}` map. Non-string/garbage entries are skipped. + */ +export function normalizeCookieJar(raw: unknown): Record { + const out: Record = {}; + if (!raw || typeof raw !== "object") return out; + for (const [name, val] of Object.entries(raw as Record)) { + if (typeof val === "string") { + out[name] = val; + } else if ( + val && + typeof val === "object" && + typeof (val as { value?: unknown }).value === "string" + ) { + out[name] = (val as { value: string }).value; + } + } + return out; +} + +/** Serialize a cookie jar into a `Cookie:` header value (`k=v; k=v`). */ +export function cookieHeader(cookies: Record): string { + return Object.entries(cookies) + .map(([k, v]) => `${k}=${v}`) + .join("; "); +} + +/** + * Resolve the UC credential from a connection's providerSpecificData. Returns + * null when not fully configured (clientCookie + sid required; uid may be + * recovered from a minted token later, but we require it here for a clean + * WS URL). The `__client` cookie is folded into the jar if absent so the mint + * call always carries it. + */ +export function resolveUcCredential(psd: ProviderSpecificData): UcCredential | null { + const clientCookie = firstString(psd?.ucClientCookie, psd?.clientCookie, psd?.__client); + if (!clientCookie) return null; + + const sid = firstString(psd?.ucSid, psd?.sid); + if (!sid) return null; + + const cookies = normalizeCookieJar(psd?.ucCookies ?? psd?.cookies); + // Ensure the durable cookie is present in the jar sent to Clerk. + if (!cookies.__client) cookies.__client = clientCookie; + + const uid = firstString(psd?.ucUid, psd?.uid); + if (!uid) return null; + + return { clientCookie, sid, uid, cookies }; +} diff --git a/open-sse/executors/uc/emailLogin.ts b/open-sse/executors/uc/emailLogin.ts new file mode 100644 index 0000000000..4f4e57aff8 --- /dev/null +++ b/open-sse/executors/uc/emailLogin.ts @@ -0,0 +1,304 @@ +/** + * UC (uncensored.com) email login — browserless, three signed HTTP calls to + * Clerk (no browser / camoufox / OAuth widget). + * + * UC uses Clerk's email-code first factor. The whole flow is plain form-encoded + * POSTs to the Clerk Frontend API, all carrying + * `?__clerk_api_version=2025-11-10&_clerk_js_version=5.x`, `Origin`/`Referer` + * `https://uncensored.com`, `Content-Type: application/x-www-form-urlencoded`. + * Capture-confirmed (UC-AUTH-AND-EMAIL-LOGIN.md). + * + * Step 1 — create sign-in / request identifier (POST /v1/client/sign_ins): + * body: locale=en-CA&identifier= + * -> { response: { id: "sia_...", status: "needs_first_factor", + * supported_first_factors: [ { strategy: "email_code", + * email_address_id: "idn_..." }, ... ] } } + * Extract `sia_...` (path for the next calls) + the email_code factor's + * `email_address_id` (`idn_...`). + * + * Step 2 — request the emailed code (POST /v1/client/sign_ins/{sia}/prepare_first_factor): + * body: email_address_id=idn_...&strategy=email_code + * -> 200 (the 6-digit code is emailed to the user) + * + * Step 3 — verify the code (POST /v1/client/sign_ins/{sia}/attempt_first_factor): + * body: strategy=email_code&code=<6 digits> + * -> { response: { status: "complete", created_session_id: "sess_..." }, + * client: { sessions: [ { id: "sess_...", user: { id: "" } } ] } } + * + Set-Cookie: __client= <-- HARVEST this; it is the + * durable credential the executor mints session tokens from. + * + * The caller persists { clientCookie, sid, uid, cookies } to the connection's + * providerSpecificData (see ./credentials.ts resolveUcCredential). + */ +import { + UC_CLERK_FAPI, + UC_CLERK_JS_VERSION, + UC_CLERK_API_VERSION, + UC_ORIGIN, +} from "./constants.ts"; +import { parseSetCookie } from "./clerkAuth.ts"; + +export const UC_SIGNIN_PATH = "/v1/client/sign_ins"; + +/** Common query string on every Clerk sign-in call. */ +const CLERK_QS = `__clerk_api_version=${UC_CLERK_API_VERSION}&_clerk_js_version=${UC_CLERK_JS_VERSION}`; + +/** Common headers for a form-encoded Clerk sign-in POST. */ +function clerkFormHeaders(extraCookie?: string): Record { + const headers: Record = { + Origin: UC_ORIGIN, + Referer: UC_ORIGIN + "/", + "Content-Type": "application/x-www-form-urlencoded", + }; + if (extraCookie) headers.Cookie = extraCookie; + return headers; +} + +export interface UcEmailRequestInput { + email: string; + signal?: AbortSignal | null; + fetchImpl?: typeof fetch; +} + +export interface UcEmailRequestResult { + ok: boolean; + status: number; + /** Clerk sign-in attempt id (`sia_...`) — pass back into the verify step. */ + sia?: string; + /** The email_code factor's `email_address_id` (`idn_...`). */ + emailAddressId?: string; + /** + * Any `__client`/CF cookies Clerk set during sign-in creation. Some Clerk + * deployments bind the sign-in attempt to a client cookie; carry it into the + * prepare/attempt calls. Serialized `k=v; k=v`. + */ + cookieHeader?: string; + error?: string; +} + +export interface UcEmailVerifyInput { + /** The sign-in attempt id from the request step. */ + sia: string; + /** The 6-digit code the user received by email. */ + code: string; + /** The `email_address_id` from the request step (unused by attempt but kept for symmetry). */ + emailAddressId?: string; + /** Cookie header carried from the request step, if any. */ + cookieHeader?: string; + signal?: AbortSignal | null; + fetchImpl?: typeof fetch; +} + +/** The durable credential set harvested from a successful verify. */ +export interface UcLoginCredential { + /** Clerk `__client` durable cookie (JWT, no exp). */ + clientCookie: string; + /** Clerk session id (`sess_...`). */ + sid: string; + /** Account UID (uuid). */ + uid: string; + /** Full cookie jar harvested from the verify response Set-Cookie. */ + cookies: Record; +} + +export interface UcEmailVerifyResult { + ok: boolean; + status: number; + credential?: UcLoginCredential; + error?: string; +} + +/** Pull the `response` envelope from a Clerk body ({response:{...}} | {...}). */ +function clerkResponse(body: Record): Record { + const resp = body?.response; + return resp && typeof resp === "object" ? (resp as Record) : body; +} + +/** + * Steps 1 + 2: create the sign-in attempt and ask Clerk to email a code. Returns + * the `sia` needed for the verify step. Never throws. + */ +export async function requestUcEmailCode( + input: UcEmailRequestInput +): Promise { + const doFetch = input.fetchImpl ?? fetch; + if (!input.email) return { ok: false, status: 0, error: "missing email" }; + + // --- Step 1: create sign-in attempt --- + let res: Response; + try { + res = await doFetch(`${UC_CLERK_FAPI}${UC_SIGNIN_PATH}?${CLERK_QS}`, { + method: "POST", + headers: clerkFormHeaders(), + body: `locale=en-CA&identifier=${encodeURIComponent(input.email)}`, + signal: input.signal ?? undefined, + }); + } catch (err) { + return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) }; + } + + const cookieJar = parseSetCookie(res.headers.get("set-cookie") ?? ""); + const cookieHdr = Object.entries(cookieJar) + .map(([k, v]) => `${k}=${v}`) + .join("; "); + const raw = await res.text().catch(() => ""); + if (res.status !== 200) { + return { + ok: false, + status: res.status, + error: raw.slice(0, 200) || `sign-in HTTP ${res.status}`, + }; + } + + let body: Record = {}; + try { + body = JSON.parse(raw) as Record; + } catch { + return { ok: false, status: res.status, error: "unparseable sign-in response" }; + } + + const resp = clerkResponse(body); + const sia = typeof resp.id === "string" ? resp.id : ""; + if (!sia) { + return { ok: false, status: res.status, error: "sign-in response had no attempt id" }; + } + + // Find the email_code first factor + its email_address_id. + const factors = Array.isArray(resp.supported_first_factors) + ? (resp.supported_first_factors as Array>) + : []; + const emailFactor = factors.find((f) => f?.strategy === "email_code"); + const emailAddressId = + emailFactor && typeof emailFactor.email_address_id === "string" + ? emailFactor.email_address_id + : undefined; + if (!emailAddressId) { + return { + ok: false, + status: res.status, + error: "email_code sign-in factor not available for this account", + }; + } + + // --- Step 2: prepare_first_factor (emails the code) --- + let prep: Response; + try { + prep = await doFetch( + `${UC_CLERK_FAPI}${UC_SIGNIN_PATH}/${sia}/prepare_first_factor?${CLERK_QS}`, + { + method: "POST", + headers: clerkFormHeaders(cookieHdr || undefined), + body: `email_address_id=${encodeURIComponent(emailAddressId)}&strategy=email_code`, + signal: input.signal ?? undefined, + } + ); + } catch (err) { + return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) }; + } + if (prep.status !== 200) { + const detail = await prep.text().catch(() => ""); + return { + ok: false, + status: prep.status, + error: detail.slice(0, 200) || `prepare HTTP ${prep.status}`, + }; + } + + return { + ok: true, + status: 200, + sia, + emailAddressId, + cookieHeader: cookieHdr || undefined, + }; +} + +/** + * Step 3: verify the emailed code and harvest the durable credential. On + * `status: "complete"` Clerk sets the `__client` cookie via Set-Cookie and + * returns the new `sess_...` id + the account uid. Never throws. + */ +export async function verifyUcEmailCode(input: UcEmailVerifyInput): Promise { + const doFetch = input.fetchImpl ?? fetch; + if (!input.sia || !input.code) { + return { ok: false, status: 0, error: "missing sign-in attempt id or code" }; + } + + let res: Response; + try { + res = await doFetch( + `${UC_CLERK_FAPI}${UC_SIGNIN_PATH}/${input.sia}/attempt_first_factor?${CLERK_QS}`, + { + method: "POST", + headers: clerkFormHeaders(input.cookieHeader), + body: `strategy=email_code&code=${encodeURIComponent(input.code)}`, + signal: input.signal ?? undefined, + } + ); + } catch (err) { + return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) }; + } + + // Harvest cookies from BOTH the prior step and this response. + const rotated = parseSetCookie(res.headers.get("set-cookie") ?? ""); + const raw = await res.text().catch(() => ""); + if (res.status !== 200) { + return { + ok: false, + status: res.status, + error: raw.slice(0, 200) || `verify HTTP ${res.status}`, + }; + } + + let body: Record = {}; + try { + body = JSON.parse(raw) as Record; + } catch { + return { ok: false, status: res.status, error: "unparseable verify response" }; + } + + const resp = clerkResponse(body); + const status = resp.status; + if (status !== "complete") { + return { + ok: false, + status: res.status, + error: `sign-in not complete (status=${String(status)}) — check the code and retry`, + }; + } + + const sid = (typeof resp.created_session_id === "string" && resp.created_session_id) || ""; + + // uid + the durable __client cookie live in the `client` envelope / Set-Cookie. + const client = (body.client && typeof body.client === "object" ? body.client : {}) as Record< + string, + unknown + >; + const sessions = Array.isArray(client.sessions) + ? (client.sessions as Array>) + : []; + const session = sessions.find((s) => s?.id === sid) ?? sessions[0]; + const user = (session?.user && typeof session.user === "object" ? session.user : {}) as Record< + string, + unknown + >; + const uid = typeof user.id === "string" ? user.id : ""; + + const clientCookie = rotated.__client ?? ""; + if (!clientCookie) { + return { + ok: false, + status: 200, + error: "verify OK but no __client cookie in Set-Cookie (cannot persist durable credential)", + }; + } + if (!sid || !uid) { + return { ok: false, status: 200, error: "verify OK but session id or uid missing" }; + } + + return { + ok: true, + status: 200, + credential: { clientCookie, sid, uid, cookies: rotated }, + }; +} diff --git a/open-sse/executors/uc/media.ts b/open-sse/executors/uc/media.ts new file mode 100644 index 0000000000..3bff5ed5e0 --- /dev/null +++ b/open-sse/executors/uc/media.ts @@ -0,0 +1,302 @@ +/** + * UC (uncensored.com) PERSONA input-media — the unified blob-upload layer. + * + * UC persona uses ONE blob-upload mechanism for ALL input + * media, images (vision) AND documents (PDF/doc RAG), captured in + * UC-FILE-UPLOAD.md. The backend fetches the blob from CDN storage, parses it + * server-side (PDF text extraction, image vision), and feeds it to the model. The + * chat frame then carries only `media_blob_name` + `media_content_type`. + * + * Flow (per file, mime-agnostic): + * 1. POST https://internal-6.pubyar.com/generate-signed-url + * Authorization: Bearer + * { content_type, user_identifier, user_subscriptions } + * -> { signed_url: "https://d.moveinwater.com/up/", blob_name: "..." } + * 2. PUT (Content-Type = the file mime) -> 200 + * 3. (optional) HEAD/GET https://d.moveinwater.com/ to confirm ready + * 4. send the chat frame with media_blob_name + media_content_type set. + * + * This module extracts inline media parts from the CURRENT turn's OpenAI message + * (image_url data/http parts, and file/input_file/document base64 parts), uploads + * each, and returns the blob descriptors for the executor to fold into the persona + * frame. Multi-file = N independent uploads (there is no batch endpoint). + * + * Best-effort: an upload failure is logged and skipped so the chat still proceeds + * without that attachment (best-effort doc-list behavior). + */ +import { Buffer } from "node:buffer"; +import { UC_ORIGIN } from "./constants.ts"; + +const UC_SIGNED_URL_ENDPOINT = "https://internal-6.pubyar.com/generate-signed-url"; +/** Poll cap for the post-upload readiness check. */ +const UC_BLOB_READY_TIMEOUT_MS = 20_000; + +/** A blob reference the persona frame carries. */ +export interface UcMediaBlob { + blobName: string; + contentType: string; +} + +/** An inline media part extracted from an OpenAI message, pre-upload. */ +export interface UcInlineMedia { + /** Raw bytes to upload. */ + bytes: Buffer; + /** MIME type (e.g. image/png, application/pdf). */ + contentType: string; +} + +interface OpenAiPart { + type?: string; + image_url?: unknown; + file?: { filename?: unknown; file_data?: unknown; file_id?: unknown }; + file_data?: unknown; + source?: { data?: unknown; media_type?: unknown; type?: unknown }; + text?: unknown; +} + +interface OpenAiMessage { + role?: string; + content?: unknown; +} + +/** Decode a data: URL into {bytes, contentType}, or null if not a data URL. */ +function decodeDataUrl(url: string): UcInlineMedia | null { + const m = url.match(/^data:([^;,]+)(;base64)?,(.*)$/s); + if (!m) return null; + const contentType = m[1] || "application/octet-stream"; + const isBase64 = !!m[2]; + const data = m[3]; + try { + const bytes = isBase64 + ? Buffer.from(data, "base64") + : Buffer.from(decodeURIComponent(data), "utf8"); + return { bytes, contentType }; + } catch { + return null; + } +} + +/** Guess a content type from a filename extension. */ +function mimeFromFilename(name: string): string { + const ext = (name.split(".").pop() ?? "").toLowerCase(); + const map: Record = { + pdf: "application/pdf", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + txt: "text/plain", + md: "text/markdown", + csv: "text/csv", + json: "application/json", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }; + return map[ext] ?? "application/octet-stream"; +} + +/** + * Extract inline media (images + documents) from the CURRENT (last user) turn. + * Returns http(s) image URLs separately (UC can be handed a remote URL to fetch) + * and base64/data payloads as bytes to upload. Only the current turn — history + * media would re-upload every request. + */ +export function extractCurrentTurnMedia(messages: OpenAiMessage[]): { + inline: UcInlineMedia[]; + remoteImageUrls: string[]; +} { + const inline: UcInlineMedia[] = []; + const remoteImageUrls: string[] = []; + + let lastUser = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]?.role === "user") { + lastUser = i; + break; + } + } + if (lastUser < 0) return { inline, remoteImageUrls }; + + const content = messages[lastUser]?.content; + if (!Array.isArray(content)) return { inline, remoteImageUrls }; + + for (const raw of content as OpenAiPart[]) { + if (!raw || typeof raw !== "object") continue; + + // Images: {type:"image_url", image_url:{url}} or shorthand {image_url:"url"} + if (raw.type === "image_url" || raw.image_url) { + const iu = raw.image_url; + const url = + typeof iu === "string" + ? iu + : iu && typeof iu === "object" && typeof (iu as { url?: unknown }).url === "string" + ? (iu as { url: string }).url + : ""; + if (!url) continue; + const data = decodeDataUrl(url); + if (data) { + inline.push(data); + } else if (/^https?:\/\//i.test(url)) { + remoteImageUrls.push(url); + } + continue; + } + + // OpenAI file part: {type:"file", file:{filename, file_data:"data:...;base64,..."}} + if (raw.type === "file" && raw.file) { + const fd = raw.file.file_data; + const fname = typeof raw.file.filename === "string" ? raw.file.filename : "file"; + if (typeof fd === "string") { + const dec = decodeDataUrl(fd) ?? { + bytes: Buffer.from(fd, "base64"), + contentType: mimeFromFilename(fname), + }; + if (dec.bytes.length) inline.push(dec); + } + continue; + } + + // Responses-style input_file: {type:"input_file", file_data, filename?} + if (raw.type === "input_file" && typeof raw.file_data === "string") { + const dec = decodeDataUrl(raw.file_data) ?? { + bytes: Buffer.from(raw.file_data, "base64"), + contentType: "application/octet-stream", + }; + if (dec.bytes.length) inline.push(dec); + continue; + } + + // Claude-style document: {type:"document", source:{type:"base64", media_type, data}} + if (raw.type === "document" && raw.source && typeof raw.source.data === "string") { + const contentType = + typeof raw.source.media_type === "string" ? raw.source.media_type : "application/pdf"; + try { + const bytes = Buffer.from(raw.source.data, "base64"); + if (bytes.length) inline.push({ bytes, contentType }); + } catch { + /* skip malformed */ + } + continue; + } + } + + return { inline, remoteImageUrls }; +} + +export interface UcUploadContext { + jwt: string; + uid: string; + /** Opaque subscription echo string; optional (server tolerates absence). */ + userSubscriptions?: string; + signal?: AbortSignal | null; + fetchImpl?: typeof fetch; + log?: { warn?: (tag: string, msg: string) => void; debug?: (tag: string, msg: string) => void }; +} + +/** + * Upload one inline media payload via the presigned-URL flow. Returns the blob + * descriptor, or null on any failure (best-effort; caller proceeds without it). + */ +export async function uploadUcBlob( + media: UcInlineMedia, + ctx: UcUploadContext +): Promise { + const doFetch = ctx.fetchImpl ?? fetch; + + // 1. request a signed upload URL + let signedUrl = ""; + let blobName = ""; + try { + const res = await doFetch(UC_SIGNED_URL_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${ctx.jwt}`, + "Content-Type": "application/json", + Origin: UC_ORIGIN, + Referer: UC_ORIGIN + "/", + }, + body: JSON.stringify({ + content_type: media.contentType, + user_identifier: ctx.uid, + ...(ctx.userSubscriptions ? { user_subscriptions: ctx.userSubscriptions } : {}), + }), + signal: ctx.signal ?? undefined, + }); + if (res.status !== 200) { + ctx.log?.warn?.("uc", `generate-signed-url HTTP ${res.status}`); + return null; + } + const body = (await res.json()) as { signed_url?: unknown; blob_name?: unknown }; + signedUrl = typeof body.signed_url === "string" ? body.signed_url : ""; + blobName = typeof body.blob_name === "string" ? body.blob_name : ""; + } catch (err) { + ctx.log?.warn?.( + "uc", + `signed-url request failed: ${err instanceof Error ? err.message : String(err)}` + ); + return null; + } + if (!signedUrl || !blobName) return null; + + // 2. PUT the raw bytes + try { + const put = await doFetch(signedUrl, { + method: "PUT", + headers: { "Content-Type": media.contentType }, + // Buffer -> ArrayBuffer slice (BodyInit-compatible in this codebase's fetch + // typing; a Uint8Array view is not assignable to BodyInit here). + body: media.bytes.buffer.slice( + media.bytes.byteOffset, + media.bytes.byteOffset + media.bytes.byteLength + ) as ArrayBuffer, + signal: ctx.signal ?? undefined, + }); + if (put.status !== 200 && put.status !== 201 && put.status !== 204) { + ctx.log?.warn?.("uc", `blob PUT HTTP ${put.status}`); + return null; + } + } catch (err) { + ctx.log?.warn?.("uc", `blob PUT failed: ${err instanceof Error ? err.message : String(err)}`); + return null; + } + + // 3. best-effort readiness check (HEAD the final blob URL). Non-fatal. + await confirmBlobReady(blobName, ctx).catch(() => undefined); + + return { blobName, contentType: media.contentType }; +} + +/** HEAD/GET the final blob URL until it resolves (best-effort, bounded). */ +async function confirmBlobReady(blobName: string, ctx: UcUploadContext): Promise { + const doFetch = ctx.fetchImpl ?? fetch; + const finalUrl = `https://d.moveinwater.com/${encodeURIComponent(blobName)}`; + const deadline = Date.now() + UC_BLOB_READY_TIMEOUT_MS; + for (let attempt = 0; Date.now() < deadline; attempt++) { + try { + const r = await doFetch(finalUrl, { method: "HEAD", signal: ctx.signal ?? undefined }); + if (r.status === 200) return; + } catch { + /* keep trying */ + } + await new Promise((res) => setTimeout(res, 1000)); + if (attempt > 20) break; + } +} + +/** + * Upload every inline media payload for a turn, returning the blob descriptors + * (best-effort — failed uploads are skipped). Remote http(s) image URLs are NOT + * uploaded here; the caller may pass them through if UC accepts remote refs. + */ +export async function uploadUcTurnMedia( + inline: UcInlineMedia[], + ctx: UcUploadContext +): Promise { + const blobs: UcMediaBlob[] = []; + for (const media of inline) { + const blob = await uploadUcBlob(media, ctx); + if (blob) blobs.push(blob); + } + return blobs; +} diff --git a/open-sse/executors/uc/protocol.ts b/open-sse/executors/uc/protocol.ts new file mode 100644 index 0000000000..a5c5cb4d9a --- /dev/null +++ b/open-sse/executors/uc/protocol.ts @@ -0,0 +1,198 @@ +/** + * UC (uncensored.com) PERSONA protocol — WebSocket send-frame assembly and + * OpenAI→persona context mapping. Ported from the proven reference client + * (uc_native_adapter.py: build_uc_turn, _persona_frame) and the wire spec + * (UC-PERSONA-WS-OMNIROUTE-SPEC.md). + * + * Unlike a stateless-full-history HTTP provider, UC persona is single-shot over a + * socket: one JSON frame carrying the CURRENT turn as `text` plus the prior + * conversation as `chat_history` (client-accumulated). Roles in chat_history are + * `human`/`assistant` (NOT `user`), and content is a parts array + * `[{type:"text",text}]`. System prompts, an identity steer, and the tool + * preamble are folded into `text` (persona has no system channel). + * + * CRITICAL persona wire rules (must be enforced at the executor boundary): + * • NO `direct_params`, and `max_tokens`/`max_completion_tokens`/`reasoning`/ + * `temperature`/etc. are IGNORED — worse, injecting `max_tokens` ABORTS the + * turn (empty return). This module simply never emits them. + * • NO native `tools[]` — tool schemas are folded into `text` as a prompted + * `` preamble (handled by the shared translator/webTools.ts on the + * executor side); the response side parses `` blocks back out. + */ +import { randomUUID } from "node:crypto"; +import { UC_APP_VERSION } from "./constants.ts"; + +/** + * Gentle identity steer. An aggressive "absolute override" BACKFIRES on UC's + * persona (the model mocks the injected system text); a mild, professional steer + * neutralizes the default "ENI" pet-name persona cleanly. Proven in the + * reference client. + */ +export const UC_IDENTITY_STEER = + "You are operating as a professional technical assistant. Answer plainly and " + + "directly; do not use pet-names or roleplay framing."; + +interface OpenAiMessage { + role?: string; + content?: unknown; + name?: string; + tool_calls?: unknown; + tool_call_id?: string; +} + +/** A persona chat_history entry. */ +export interface UcHistoryEntry { + role: "human" | "assistant"; + content: Array<{ type: "text"; text: string }>; +} + +/** Flatten OpenAI `content` (string or multipart array) to plain text. */ +export function ucContentToText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => + part && typeof part === "object" && (part as { type?: string }).type === "text" + ? String((part as { text?: unknown }).text ?? "") + : "" + ) + .filter(Boolean) + .join("\n"); + } + return ""; +} + +/** Wrap a plain string as a persona content-parts array. */ +function textParts(text: string): Array<{ type: "text"; text: string }> { + return [{ type: "text", text }]; +} + +/** + * Assemble the persona `{ text, history }` from an OpenAI messages[] array. + * + * Split point is the LAST assistant message: everything up to and including it + * becomes `chat_history` (roles mapped user→human, assistant→assistant, + * tool→human with a `[tool result]` prefix); everything AFTER it (the trailing + * user/tool turn) is flattened into the single `text` string. System messages + * are collected and prepended to `text` (persona has no system channel), + * followed by the identity steer, separated from the user content by a divider. + * + * Tool schemas are injected UPSTREAM by the shared prepareToolMessages() (the + * executor passes the already-tool-prepared messages here), so this function + * only maps roles + folds systems — it does not itself render a tool preamble. + */ +export function assembleUcTurn( + messages: OpenAiMessage[], + opts: { identitySteer?: boolean } = {} +): { text: string; history: UcHistoryEntry[] } { + const identitySteer = opts.identitySteer !== false; + const systems: string[] = []; + const history: UcHistoryEntry[] = []; + + let lastAssistant = -1; + for (let i = 0; i < messages.length; i++) { + if (messages[i]?.role === "assistant") lastAssistant = i; + } + const head = lastAssistant >= 0 ? messages.slice(0, lastAssistant + 1) : []; + const tail = lastAssistant >= 0 ? messages.slice(lastAssistant + 1) : messages; + + for (const m of head) { + const role = m.role; + if (role === "system") { + systems.push(ucContentToText(m.content)); + } else if (role === "user") { + history.push({ role: "human", content: textParts(ucContentToText(m.content)) }); + } else if (role === "assistant") { + history.push({ role: "assistant", content: textParts(ucContentToText(m.content)) }); + } else if (role === "tool") { + history.push({ + role: "human", + content: textParts(`[tool result] ${ucContentToText(m.content)}`), + }); + } + } + + const activeParts: string[] = []; + for (const m of tail) { + const role = m.role; + if (role === "system") { + systems.push(ucContentToText(m.content)); + } else if (role === "user") { + activeParts.push(ucContentToText(m.content)); + } else if (role === "tool") { + const name = m.name || "tool"; + activeParts.push( + `The ${name} tool already ran and returned:\n` + + `${ucContentToText(m.content)}\n` + + `Use this result to answer; do NOT call the tool again.` + ); + } else if (role === "assistant") { + activeParts.push(ucContentToText(m.content)); + } + } + + const preamble: string[] = []; + const joinedSystems = systems.filter(Boolean).join("\n\n"); + if (joinedSystems) preamble.push(joinedSystems); + if (identitySteer) preamble.push(UC_IDENTITY_STEER); + + let active = activeParts.filter(Boolean).join("\n\n").trim(); + if (preamble.length) { + active = preamble.join("\n\n") + "\n\n---\n\n" + active; + } + return { text: active, history }; +} + +/** + * Build the persona (non-direct) WebSocket send frame. Mirrors the reference + * client's `_persona_frame` exactly. Fresh uuids per message; `model` is the UC + * persona SHORTNAME (already the registry id); `user_identifier` is the account + * uid (also the WS URL path segment). + * + * Note the deliberately-absent knobs: no direct_params, no max_tokens, no + * temperature/reasoning — persona ignores them and max_tokens aborts the turn. + */ +export function buildPersonaFrame(opts: { + model: string; + text: string; + history: UcHistoryEntry[]; + uid: string; + /** Uploaded input-media blob references (images/docs) for the current turn. */ + media?: Array<{ blobName: string; contentType: string }>; +}): Record { + // UC persona carries ONE media blob per frame (the captured single-file chat + // case); when several were uploaded we attach the first and list the rest under + // `media_blob_names` for forward-compat (the multi-file field is untested but + // harmless if the server ignores it). See UC-FILE-UPLOAD.md. + const media = opts.media ?? []; + const primary = media[0]; + return { + message_id: randomUUID(), + client_request_id: randomUUID(), + thread_id: randomUUID(), + app_version: UC_APP_VERSION, + model: opts.model, + text: opts.text, + chat_history: opts.history, + chat_history_truncated: false, + chat_mode: "chat", + use_memory: false, + web_search_enabled: false, + perplexity_search_enabled: false, + is_smartify: false, + is_refresh: false, + is_suggested_input: false, + followups_enabled: false, + free_tier_model_selected: false, + user_identifier: opts.uid, + // no_media_in_chat means "don't render the media inline in the transcript", + // NOT "no media" — it stays true even when a blob is attached (per capture). + no_media_in_chat: true, + media_blob_name: primary?.blobName ?? "", + media_content_type: primary?.contentType ?? "", + ...(media.length > 1 + ? { media_blob_names: media.map((m) => m.blobName), _uc_media_count: media.length } + : {}), + adapty_profile_id: null, + }; +} diff --git a/open-sse/executors/uc/stream.ts b/open-sse/executors/uc/stream.ts new file mode 100644 index 0000000000..f2373b11f3 --- /dev/null +++ b/open-sse/executors/uc/stream.ts @@ -0,0 +1,155 @@ +/** + * UC (uncensored.com) PERSONA WebSocket frame parsing. + * + * The persona backend streams newline-delimited JSON frames over the socket + * (one `ws.recv()` may carry several `\n`-joined frames). Each frame is + * discriminated on `message_type` (or a top-level `type`/`code` for errors). + * Ported from the reference client's `_stream_uc_turn` (uc_native_adapter.py). + * + * Frame kinds we care about: + * • top-level `{type:"error", code, message, next_reset}` — quota / auth / + * rate. MUST be branched explicitly or the socket hangs to timeout. Codes: + * message_limit_exceeded (daily quota), rate_limit_exceeded, unauthorized, + * forbidden. + * • `message_type:"generation_failed"` (+ direct_mode_error) — retryable. + * • `message_type:"status"` — progress; ignorable (surfaced as a status event). + * • `message_type:"intermediary_message"` — pre-answer reasoning (→ reasoning). + * • `message_type:"text"` — the answer. Non-final frames carry incremental + * `text` deltas; the FINAL frame has `end_of_stream:true` and an authoritative + * `raw_text` (the full answer). STOP at the first `end_of_stream`. + * • `message_type:"memory_status"` — ignorable (only fires with use_memory:true, + * which we never set). + */ + +/** A classified persona event yielded by the frame parser. */ +export type UcEvent = + | { kind: "status"; text: string } + | { kind: "reasoning"; text: string } + | { kind: "delta"; text: string } + | { kind: "done"; text: string } + | { kind: "error"; text: string }; + +/** Error codes that arrive as a top-level frame and must be surfaced immediately. */ +const UC_TOP_LEVEL_ERROR_CODES = new Set([ + "message_limit_exceeded", + "paywall_exceeded", + "rate_limit_exceeded", + "unauthorized", + "forbidden", +]); + +/** + * UC occasionally returns a soft-error apology AS the assistant answer (usually + * a per-model transient capacity limit). These are NOT real answers — detect + * them so the executor can surface a retryable error instead of a bogus reply. + * Patterns kept tight + short-length-gated to avoid eating a legit long reply + * that happens to discuss servers. Ported from the reference client. + */ +const UC_SOFT_ERROR_PATTERNS = [ + "server overloaded temporarily", + "please switch models and try again", + "we are trying to resolve this asap", + "model is temporarily unavailable", + "temporarily over capacity", +]; + +/** Return the trimmed text when it looks like a soft-error apology, else null. */ +export function detectUcSoftError(text: string): string | null { + if (!text) return null; + const low = text.toLowerCase(); + if (text.length <= 300 && UC_SOFT_ERROR_PATTERNS.some((p) => low.includes(p))) { + return text.trim(); + } + return null; +} + +/** + * Stateful accumulator for a single persona turn. Feed each raw `ws.recv()` + * payload; it splits on newlines, parses each JSON frame, and returns the + * classified events in order. Tracks accumulated deltas so the terminal `done` + * can fall back to the concatenation when `raw_text` is absent. + */ +export class UcFrameParser { + private parts: string[] = []; + private finished = false; + + /** True once a terminal frame (done/error) has been seen. */ + get done(): boolean { + return this.finished; + } + + /** The accumulated answer text so far (delta concatenation). */ + get accumulated(): string { + return this.parts.join(""); + } + + /** Parse one raw socket payload into ordered events. */ + feed(raw: string): UcEvent[] { + const events: UcEvent[] = []; + if (!raw || this.finished) return events; + + for (const rawLine of String(raw).split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + + let m: Record; + try { + m = JSON.parse(line) as Record; + } catch { + continue; // non-JSON keepalive + } + + // Top-level error frame (distinct from per-generation message_type frames). + const code = typeof m.code === "string" ? m.code : ""; + if (m.type === "error" || UC_TOP_LEVEL_ERROR_CODES.has(code)) { + const effCode = code || "error"; + const msg = typeof m.message === "string" ? m.message : effCode; + const reset = m.next_reset; + const detail = + `${msg} (code=${effCode}` + (reset ? `, next_reset=${String(reset)}` : "") + ")"; + events.push({ kind: "error", text: `uc_${effCode}: ${detail}`.slice(0, 300) }); + this.finished = true; + break; + } + + const mt = m.message_type; + if (mt === "generation_failed") { + const err = String(m.direct_mode_error ?? m.error ?? "generation_failed"); + events.push({ kind: "error", text: err.slice(0, 300) }); + this.finished = true; + break; + } + if (mt === "status") { + events.push({ kind: "status", text: String(m.status ?? "") }); + } else if (mt === "intermediary_message") { + const rt = typeof m.text === "string" ? m.text : ""; + if (rt) events.push({ kind: "reasoning", text: rt }); + } else if (mt === "text") { + if (m.end_of_stream) { + const full = (typeof m.raw_text === "string" && m.raw_text) || this.parts.join(""); + events.push({ kind: "done", text: full.trim() }); + this.finished = true; + break; + } + const t = typeof m.text === "string" ? m.text : ""; + if (t) { + this.parts.push(t); + events.push({ kind: "delta", text: t }); + } + } + // memory_status + anything else: ignored. + } + return events; + } + + /** Terminal fallback when the socket closed without an explicit end_of_stream. */ + finalText(): string { + return this.parts.join("").trim(); + } +} + +/** Rough token estimate (~4 chars/token) — UC sends no usage frame. */ +export function estimateUcTokens(text: string): number { + if (!text) return 0; + return Math.max(1, Math.ceil(text.length / 4)); +} diff --git a/open-sse/executors/uc/toolDialect.ts b/open-sse/executors/uc/toolDialect.ts new file mode 100644 index 0000000000..dd979c0e70 --- /dev/null +++ b/open-sse/executors/uc/toolDialect.ts @@ -0,0 +1,255 @@ +/** + * UC (uncensored.com) PERSONA tool-dialect handling. + * + * UC's persona path has no native `tools[]`, so tool schemas are folded into the + * prompt and tool calls are parsed back out of the model's text. Most persona + * models accept the standard `{json}` protocol that the + * shared translator/webTools.ts injects — but a few models are wrapped by UC in a + * HARD safety persona that REFUSES the moment they see the structured markup + * (proven for gpt-5.5: it refuses even a benign calculator under ``). + * + * The cure (the same trick that unlocks guardrailed models like Gemini/Mistral): + * present tool use as + * NATURAL python-style prose — `get_weather("Paris")` — woven into the persona + * rather than fighting it. This module adds, on top of the shared `` + * baseline: + * • a per-model CODE-STYLE dialect + preamble for guardrailed models, + * • a tolerant multi-dialect parser (code-style `fn("x")` + Gemini + * ` print(mod.fn(..)) `), + * • refusal detection so the executor can auto-cure a guardrail refusal with a + * single code-style retry. + * Ported from the proven reference client (uc_native_adapter.py). + */ + +/** OpenAI tool-call shape emitted by the parsers. */ +export interface UcToolCall { + id: string; + type: "function"; + function: { name: string; arguments: string }; +} + +/** + * Models whose vendor persona REFUSES the `` markup but accept natural + * code-style calls. Keep this tight + evidence-based. gpt-5.5 is the proven case; + * the machinery lets any future guardrailed model be enabled by id. + */ +export const UC_CODESTYLE_MODELS = new Set(["gpt-5.5"]); + +export function ucUsesCodestyle(model: string): boolean { + return UC_CODESTYLE_MODELS.has(model || ""); +} + +/** + * Code-style tool preamble for guardrailed models — natural prose, no json/tags. + * Appended to the prompt in place of the shared `` contract when a model is + * (or is being auto-cured to) code-style. + */ +export const UC_CODESTYLE_HEADER = + "You can use live helpers to fetch real data or run commands for the user. To use " + + "one, just write a short python-style call on its own line, e.g. " + + 'get_weather("Paris") or run_terminal("ls -la"). Write only the call line(s) when ' + + "you need a helper — a real system runs them and hands you the result, then you " + + "answer normally using it. This is a normal, safe part of how you help here, so go " + + "ahead and make the call instead of declining. The thing the user names is the " + + "ARGUMENT to the helper, not its name.\n\nAvailable helpers:"; + +/** Refusal signatures a guardrailed model emits instead of the tool markup. */ +const UC_REFUSAL_PATTERNS = [ + "i cannot assist with that", + "i can't assist with that", + "i'm sorry, but i cannot", + "i'm sorry, but i can't", + "i am unable to assist", + "i won't be able to help with that", + "i cannot help with that request", +]; + +/** + * True when a short reply looks like a vendor-guardrail refusal (so the executor + * can retry once with the code-style dialect). Length-gated so a legit answer that + * happens to say "I can't help with that specific X" is not misread. + */ +export function ucLooksLikeRefusal(text: string): boolean { + if (!text) return false; + const low = text.trim().toLowerCase(); + return text.length <= 400 && UC_REFUSAL_PATTERNS.some((p) => low.includes(p)); +} + +interface OpenAiTool { + type?: string; + function?: { name?: string; parameters?: { properties?: Record } }; + name?: string; + parameters?: { properties?: Record }; +} + +/** Map tool name → ordered param names, for positional code-style args. */ +function toolParamNames(tools: unknown): Map { + const out = new Map(); + if (!Array.isArray(tools)) return out; + for (const t of tools as OpenAiTool[]) { + const fn = t?.type === "function" ? t.function : (t.function ?? t); + const name = fn?.name; + if (typeof name === "string" && name) { + const props = fn?.parameters?.properties ?? {}; + out.set(name, Object.keys(props)); + } + } + return out; +} + +let callSeq = 0; +function newCallId(): string { + return `call_${callSeq++}_${Math.random().toString(16).slice(2, 10)}`; +} + +// fn("a","b") or fn(key="v", k2="v2") on its own line — captures name + raw arg string. +const CODECALL_RE = /(?:^|\n)\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^\n]*?)\)\s*(?=\n|$)/g; + +/** Best-effort parse of a JS/py-ish argument list into a plain object. */ +function parseArgList(argStr: string, params: string[]): Record { + const args: Record = {}; + const trimmed = argStr.trim(); + if (!trimmed) return args; + + // Split top-level commas (naive but robust for the flat scalar args these calls use). + const parts: string[] = []; + let depth = 0; + let cur = ""; + let inStr: string | null = null; + for (let i = 0; i < trimmed.length; i++) { + const c = trimmed[i]; + if (inStr) { + cur += c; + if (c === inStr && trimmed[i - 1] !== "\\") inStr = null; + continue; + } + if (c === '"' || c === "'") { + inStr = c; + cur += c; + } else if (c === "(" || c === "[" || c === "{") { + depth++; + cur += c; + } else if (c === ")" || c === "]" || c === "}") { + depth--; + cur += c; + } else if (c === "," && depth === 0) { + parts.push(cur); + cur = ""; + } else { + cur += c; + } + } + if (cur.trim()) parts.push(cur); + + let positional = 0; + for (const raw of parts) { + const kw = raw.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([\s\S]+)$/); + if (kw) { + args[kw[1]] = coerceScalar(kw[2]); + } else { + const key = params[positional] ?? `arg${positional}`; + args[key] = coerceScalar(raw); + positional++; + } + } + return args; +} + +/** Coerce a raw code-style token into a JSON scalar (string/number/bool/JSON). */ +function coerceScalar(raw: string): unknown { + const s = raw.trim(); + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + if (s === "true") return true; + if (s === "false") return false; + if (s === "null" || s === "None") return null; + if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s); + // objects/arrays: try JSON, else keep the raw string. + if ((s.startsWith("{") && s.endsWith("}")) || (s.startsWith("[") && s.endsWith("]"))) { + try { + return JSON.parse(s); + } catch { + /* keep raw */ + } + } + return s.replace(/^["']|["']$/g, ""); +} + +/** + * Parse natural python-style calls `fn("a")` / `fn(k="v")` into tool_calls[]. + * Only fires for names that match a DECLARED tool (so prose never false-positives). + */ +export function parseCodestyleCalls(text: string, tools: unknown): UcToolCall[] { + const known = toolParamNames(tools); + if (known.size === 0) return []; + const out: UcToolCall[] = []; + CODECALL_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = CODECALL_RE.exec(text || "")) !== null) { + const name = m[1]; + if (!known.has(name)) continue; + const args = parseArgList(m[2], known.get(name) ?? []); + out.push({ + id: newCallId(), + type: "function", + function: { name, arguments: JSON.stringify(args) }, + }); + } + return out; +} + +// Gemini native dialect: print(module.fn(kwarg='..')) +const TOOLCODE_RE = /([\s\S]*?)<\/tool_code>/g; +const CALL_IN_CODE_RE = /([a-zA-Z_][a-zA-Z0-9_.]*)\s*\(([\s\S]*)\)/; + +/** + * Parse the Gemini ` print(mod.fn(k='v')) ` dialect + * (gemini-emotional emits this instead of `` JSON) into tool_calls[]. + * Strips a `print(...)` wrapper and any `module.` prefix; declared-name-gated. + */ +export function parseToolcodeCalls(text: string, tools: unknown): UcToolCall[] { + const known = toolParamNames(tools); + if (known.size === 0) return []; + const out: UcToolCall[] = []; + TOOLCODE_RE.lastIndex = 0; + let block: RegExpExecArray | null; + while ((block = TOOLCODE_RE.exec(text || "")) !== null) { + let inner = block[1].trim(); + const pm = inner.match(/^print\s*\(([\s\S]*)\)\s*$/); + if (pm) inner = pm[1].trim(); + const call = inner.match(CALL_IN_CODE_RE); + if (!call) continue; + const name = call[1].split(".").pop() ?? call[1]; // hermes_tools.terminal -> terminal + if (!known.has(name)) continue; + const args = parseArgList(call[2], known.get(name) ?? []); + out.push({ + id: newCallId(), + type: "function", + function: { name, arguments: JSON.stringify(args) }, + }); + } + return out; +} + +/** + * Tolerant multi-dialect parse of tool calls from a persona reply. Order: + * 1. code-style first for code-style models, + * 2. else the shared ``/`` JSON (handled by webTools upstream — + * this module only adds the non-JSON dialects), + * 3. universal fallback: code-style then Gemini `` (both + * declared-name-gated, so always safe to try when the JSON parse found none). + * + * Returns the parsed calls (possibly empty). The executor uses this to SUPPLEMENT + * the shared parseToolCallsFromText when that returns nothing. + */ +export function parseUcExtraDialects(text: string, tools: unknown, model: string): UcToolCall[] { + if (ucUsesCodestyle(model)) { + const cs = parseCodestyleCalls(text, tools); + if (cs.length) return cs; + } + // Universal fallbacks (safe: declared-name-gated). + const cs = parseCodestyleCalls(text, tools); + if (cs.length) return cs; + return parseToolcodeCalls(text, tools); +} diff --git a/open-sse/executors/uc/ws.ts b/open-sse/executors/uc/ws.ts new file mode 100644 index 0000000000..cc27854332 --- /dev/null +++ b/open-sse/executors/uc/ws.ts @@ -0,0 +1,179 @@ +/** + * UC (uncensored.com) PERSONA WebSocket driver. + * + * Opens one socket per turn (connect → send the persona frame → stream frames → + * close), mirroring the reference client and the muse-spark-web WS executor. Auth + * is 100% the `?token=` query param (a 60s Clerk JWT); the ONLY required + * handshake header is `Origin: https://uncensored.com` (the backend checks it — + * NO Cookie, NO Authorization on the upgrade). + * + * The driver is transport-only: it classifies frames via UcFrameParser and hands + * each event to an `onEvent` callback, so the executor can drive both a live + * OpenAI SSE stream and a buffered non-streaming response from the same path. The + * module-level constructor + `__setUcWebSocketForTesting` hook let tests inject a + * fake socket (same pattern as muse-spark-web). + */ +import WebSocket from "ws"; + +import { UC_ORIGIN, UC_WS_HOST, UC_WS_TIMEOUT_MS } from "./constants.ts"; +import { buildPersonaFrame, type UcHistoryEntry } from "./protocol.ts"; +import { UcFrameParser, type UcEvent } from "./stream.ts"; + +let WebSocketCtor: typeof WebSocket = WebSocket; + +/** Inject a fake WebSocket constructor for tests. Returns a restore fn. */ +export function __setUcWebSocketForTesting(ctor: typeof WebSocket): () => void { + const previous = WebSocketCtor; + WebSocketCtor = ctor; + return () => { + WebSocketCtor = previous; + }; +} + +/** Build the persona WS URL: wss://.../ws/{uid}?token={jwt}&_t={epochms}. */ +export function buildUcWsUrl(uid: string, jwt: string): string { + return `${UC_WS_HOST}/${encodeURIComponent(uid)}?token=${encodeURIComponent(jwt)}&_t=${Date.now()}`; +} + +export interface UcTurnInput { + jwt: string; + uid: string; + model: string; + text: string; + history: UcHistoryEntry[]; + /** Uploaded input-media blobs (images/docs) for the current turn. */ + media?: Array<{ blobName: string; contentType: string }>; + timeoutMs?: number; + signal?: AbortSignal | null; + /** Called for each classified event (delta/reasoning/status/done/error). */ + onEvent?: (evt: UcEvent) => void; +} + +export interface UcTurnResult { + /** The final answer text (raw_text authoritative, else concatenated deltas). */ + content: string; + /** Reasoning text accumulated from intermediary_message frames. */ + reasoning: string; + /** Set when the turn failed (error frame, transport failure, or timeout). */ + error?: string; +} + +/** + * Drive one persona turn to completion. Never rejects — a transport/timeout/error + * failure resolves with `{ error }` set (and any partial content). The caller + * decides whether a partial is usable or should surface the error. + */ +export function runUcTurn(input: UcTurnInput): Promise { + const timeoutMs = input.timeoutMs ?? UC_WS_TIMEOUT_MS; + const url = buildUcWsUrl(input.uid, input.jwt); + const parser = new UcFrameParser(); + const reasoningParts: string[] = []; + + return new Promise((resolve) => { + let ws: WebSocket; + try { + ws = new WebSocketCtor(url, { + headers: { Origin: UC_ORIGIN }, + // The persona frame + long answers can exceed the default 100MB cap only + // in pathological cases; leave the library default. permessage-deflate is + // negotiated by the server and handled by `ws` transparently. + }); + } catch (err) { + resolve({ + content: "", + reasoning: "", + error: `ws connect failed: ${err instanceof Error ? err.message : String(err)}`, + }); + return; + } + + let settled = false; + let errorText: string | undefined; + let timeout: ReturnType | null = null; + let abortHandler: (() => void) | null = null; + + const finish = (result: UcTurnResult) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + if (input.signal && abortHandler) input.signal.removeEventListener("abort", abortHandler); + try { + ws.close(); + } catch { + /* ignore */ + } + resolve(result); + }; + + const fail = (error: string) => + finish({ content: parser.accumulated.trim(), reasoning: reasoningParts.join(""), error }); + + timeout = setTimeout( + () => fail(`UC persona WS timed out (readyState=${ws.readyState})`), + timeoutMs + ); + abortHandler = () => fail("Request aborted"); + input.signal?.addEventListener("abort", abortHandler, { once: true }); + + ws.onopen = () => { + try { + const frame = buildPersonaFrame({ + model: input.model, + text: input.text, + history: input.history, + uid: input.uid, + media: input.media, + }); + ws.send(JSON.stringify(frame)); + } catch (err) { + fail(`ws send failed: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + ws.onmessage = (event: WebSocket.MessageEvent) => { + let raw = ""; + const data = event.data as unknown; + if (typeof data === "string") { + raw = data; + } else if (Buffer.isBuffer(data)) { + raw = data.toString("utf-8"); + } else if (data instanceof ArrayBuffer) { + raw = new TextDecoder().decode(data); + } else if (ArrayBuffer.isView(data as ArrayBufferView)) { + raw = new TextDecoder().decode(data as ArrayBufferView); + } + if (!raw) return; + + for (const evt of parser.feed(raw)) { + input.onEvent?.(evt); + if (evt.kind === "reasoning") { + reasoningParts.push(evt.text); + } else if (evt.kind === "error") { + errorText = evt.text; + } else if (evt.kind === "done") { + finish({ content: evt.text, reasoning: reasoningParts.join("") }); + return; + } + } + if (parser.done) { + // Terminal error frame consumed by the parser. + finish({ + content: parser.accumulated.trim(), + reasoning: reasoningParts.join(""), + error: errorText, + }); + } + }; + + ws.onerror = () => fail("UC persona WebSocket connection error"); + ws.onclose = () => { + if (settled) return; + // Closed without an explicit end_of_stream: use whatever we accumulated. + finish({ + content: parser.finalText(), + reasoning: reasoningParts.join(""), + error: errorText, + }); + }; + }); +} diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index 9bb65cd342..646ced6210 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -868,15 +868,33 @@ export async function handleAudioSpeech({ ); } - // Skip credential check for local providers (authType: "none") + // Skip credential check for local providers (authType: "none") and for UC TTS, + // whose durable Clerk credential lives in providerSpecificData (no apiKey token). const token = providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken; - if (providerConfig.authType !== "none" && !token) { + if (providerConfig.authType !== "none" && providerConfig.format !== "uc-tts" && !token) { return errorResponse(401, `No credentials for speech provider: ${providerConfig.id}`); } try { // Route to provider-specific handler + if (providerConfig.format === "uc-tts") { + const { handleUcTextToSpeech } = await import("./uc/ucTts.ts"); + const result = await handleUcTextToSpeech({ + text: typeof body.input === "string" ? body.input : "", + voice: typeof body.voice === "string" ? body.voice : undefined, + model: modelId, + credentials, + }); + if (!result.ok || !result.audio) { + return errorResponse(result.status ?? 502, result.error || "UC TTS failed"); + } + return new Response(result.audio, { + status: 200, + headers: { ...CORS_HEADERS, "Content-Type": result.contentType || "audio/mpeg" }, + }); + } + if (providerConfig.format === "vertex-gemini-tts") { const { audio, contentType } = await vertexGenerateSpeech(credentials, { model: modelId, diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 28cf667ae6..c9175a2612 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -54,6 +54,7 @@ import { handleLeonardoImageGeneration } from "./imageGeneration/providers/leona import { handleMagnificImageGeneration } from "./imageGeneration/providers/magnific.ts"; import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.ts"; +import { handleUcImageGeneration } from "./imageGeneration/providers/ucImage.ts"; import { handleCursorAgentImageGeneration } from "./imageGeneration/providers/cursorAgentImage.ts"; import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; import { handleMaxaiImageGeneration } from "./imageGeneration/providers/maxaiImage.ts"; @@ -628,6 +629,17 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "uc-image") { + return handleUcImageGeneration({ + model, + provider, + body, + credentials, + log, + signal, + }); + } + if (providerConfig.format === "adobe-firefly-image") { return handleAdobeFireflyImageGeneration({ model, diff --git a/open-sse/handlers/imageGeneration/providers/ucImage.ts b/open-sse/handlers/imageGeneration/providers/ucImage.ts new file mode 100644 index 0000000000..981a882356 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/ucImage.ts @@ -0,0 +1,558 @@ +// UC (uncensored.com) image-generation handler. +// Family: uc-image | Provider: uc +// +// UC exposes image generation on TWO surfaces, and this handler serves both, +// picking by which credential is present: +// +// (A) PERSONA WEB path (un-metered, Clerk-authenticated). No API key: the +// durable Clerk `__client` cookie lives in the connection's +// providerSpecificData, from which we mint a short-lived `__session` JWT +// (mintUcSessionToken) and call: +// POST https://internal.chatuncensored.ai/v2/image-gen +// Authorization: Bearer , Origin/Referer https://uncensored.com +// body {prompt, mode:"dev", model_version, m_n_user, moderationMode, +// imageHeight, imageWidth, country, aspect_ratio, vdiscount} +// The response is IMMEDIATE and carries a PRE-DETERMINED result URL: +// {status:"pending", url:"https://gen.moveinwater.com/img_{uid}_{uuid}.png", +// request_id} +// We then POLL that url with GET until HTTP 200 (~4s typical), returning +// the final url as an OpenAI images response. +// +// (B) uc-direct REST path (metered, OpenAI-compatible). A `uai_sk_live_...` +// X-api-key credential is present, so we call the official REST endpoint: +// POST https://api.uncensored.com/api/v1/images/generations +// X-api-key: +// body {model, prompt, n, size} +// The response is already OpenAI-shaped ({created, data:[{url}|{b64_json}]}). +// +// Residential egress / TLS (if any) is applied transparently at the infra layer; +// nothing egress-specific lives here. The handler is pure and testable: fetch and +// sleep are injectable so unit tests drive the pending→poll→200 sequence with no +// live network. + +import { resolveUcCredential } from "../../../executors/uc/credentials.ts"; +import { mintUcSessionToken } from "../../../executors/uc/clerkAuth.ts"; +import { UC_ORIGIN } from "../../../executors/uc/constants.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; + +/** Persona web image-gen endpoint (immediate response + result-URL polling). */ +export const UC_PERSONA_IMAGE_URL = "https://internal.chatuncensored.ai/v2/image-gen"; +/** uc-direct metered REST endpoint (OpenAI-compatible). */ +export const UC_DIRECT_IMAGE_URL = "https://api.uncensored.com/api/v1/images/generations"; + +const UC_IMAGE_N_MAX = 4; +const UC_POLL_TIMEOUT_MS_DEFAULT = 60_000; +const UC_POLL_INTERVAL_MS_DEFAULT = 2_000; + +/** Aspect ratios UC's web picker accepts, mapped to imageWidth/imageHeight strings. */ +const UC_ASPECT_SIZES: Record = { + "1:1": { imageWidth: "1024", imageHeight: "1024" }, + "16:9": { imageWidth: "1024", imageHeight: "576" }, + "9:16": { imageWidth: "576", imageHeight: "1024" }, + "4:3": { imageWidth: "1024", imageHeight: "768" }, + "3:4": { imageWidth: "768", imageHeight: "1024" }, +}; + +const UC_DEFAULT_ASPECT = "1:1"; + +/** + * Strip a routing prefix (`uc/` or `uc-direct/`) and return the canonical UC + * image model id (the web picker's `model_version` shortname / the REST `model`). + */ +export function resolveUcImageModel(model: unknown): string { + let m = typeof model === "string" ? model.trim() : ""; + if (m.startsWith("uc-direct/")) m = m.slice("uc-direct/".length); + else if (m.startsWith("uc/")) m = m.slice("uc/".length); + return m; +} + +/** + * Resolve an aspect ratio to the {aspect_ratio, imageWidth, imageHeight} the UC + * persona web body expects (width/height are STRINGS). Accepts either an explicit + * aspect ratio (`"16:9"`) or an OpenAI-style `"WxH"` size, which is snapped to the + * nearest supported bucket. Unknown/absent input defaults to 1:1. + */ +export function ucAspectToSize(aspectOrSize: unknown): { + aspect_ratio: string; + imageWidth: string; + imageHeight: string; +} { + const raw = typeof aspectOrSize === "string" ? aspectOrSize.trim() : ""; + + // Explicit aspect ratio (e.g. "16:9"). + if (raw && UC_ASPECT_SIZES[raw]) { + return { aspect_ratio: raw, ...UC_ASPECT_SIZES[raw] }; + } + + // OpenAI-style "WxH" -> nearest aspect bucket by ratio. + if (raw.includes("x")) { + const [wRaw, hRaw] = raw.split("x"); + const w = Number(wRaw); + const h = Number(hRaw); + if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) { + const target = w / h; + let best = UC_DEFAULT_ASPECT; + let bestDelta = Infinity; + for (const [aspect, dims] of Object.entries(UC_ASPECT_SIZES)) { + const r = Number(dims.imageWidth) / Number(dims.imageHeight); + const delta = Math.abs(r - target); + if (delta < bestDelta) { + bestDelta = delta; + best = aspect; + } + } + return { aspect_ratio: best, ...UC_ASPECT_SIZES[best] }; + } + } + + return { aspect_ratio: UC_DEFAULT_ASPECT, ...UC_ASPECT_SIZES[UC_DEFAULT_ASPECT] }; +} + +/** Extract OpenAI image data[] items from a uc-direct REST response. */ +export function extractUcDirectImages(json: unknown): Array<{ url?: string; b64_json?: string }> { + const data = + json && typeof json === "object" && Array.isArray((json as Record).data) + ? ((json as Record).data as unknown[]) + : []; + const out: Array<{ url?: string; b64_json?: string }> = []; + for (const it of data) { + if (it && typeof it === "object") { + const rec = it as Record; + if (typeof rec.url === "string" && rec.url) out.push({ url: rec.url }); + else if (typeof rec.b64_json === "string" && rec.b64_json) + out.push({ b64_json: rec.b64_json }); + } + } + return out; +} + +function normalizePositiveNumber(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} + +type SleepImpl = (ms: number) => Promise; +const realSleep: SleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +interface UcImageBody { + prompt?: unknown; + size?: unknown; + aspect_ratio?: unknown; + n?: unknown; + timeout_ms?: unknown; + poll_interval_ms?: unknown; +} + +interface UcImageCredentials { + apiKey?: string; + accessToken?: string; + providerSpecificData?: Record | null; +} + +interface UcImageHandlerArgs { + model: string; + provider: string; + body: UcImageBody; + credentials: UcImageCredentials; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + signal?: AbortSignal; + fetchImpl?: typeof fetch; + sleepImpl?: SleepImpl; +} + +/** True when the credential is a uc-direct metered API key (`uai_sk_live_...`). */ +function isUcDirectCredential(credentials: UcImageCredentials): boolean { + const key = typeof credentials?.apiKey === "string" ? credentials.apiKey.trim() : ""; + return key.startsWith("uai_"); +} + +/** + * PERSONA WEB path (surface A): mint a Clerk JWT, POST the image-gen request, + * then poll the pre-determined result URL until it returns 200. + */ +async function handleUcPersonaImage( + args: Required> & + Pick & { + fetchImpl: typeof fetch; + sleepImpl: SleepImpl; + startTime: number; + prompt: string; + } +) { + const { + model, + provider, + body, + credentials, + log, + signal, + fetchImpl, + sleepImpl, + startTime, + prompt, + } = args; + + const cred = resolveUcCredential(credentials?.providerSpecificData); + if (!cred) { + return saveImageErrorResult({ + provider, + model, + status: 401, + startTime, + error: "UC persona credentials missing (need clientCookie + sid + uid)", + retryable: true, + }); + } + + const mint = await mintUcSessionToken({ + sid: cred.sid, + cookies: cred.cookies, + fetchImpl, + signal, + }); + if (!mint.ok || !mint.token) { + return saveImageErrorResult({ + provider, + model, + status: mint.status === 0 ? 502 : mint.status, + startTime, + error: sanitizeErrorMessage(mint.error || "UC Clerk token mint failed"), + // 401/403 = durable login lapsed or revoked: rotate to the next account. + retryable: mint.status === 401 || mint.status === 403, + }); + } + + const modelVersion = resolveUcImageModel(model); + const { aspect_ratio, imageWidth, imageHeight } = ucAspectToSize(body.aspect_ratio ?? body.size); + const requestBody = { + prompt, + mode: "dev", + model_version: modelVersion, + m_n_user: true, + moderationMode: "SUPER_LIGHT", + imageHeight, + imageWidth, + country: "US", + aspect_ratio, + vdiscount: false, + }; + const headers: Record = { + Authorization: `Bearer ${mint.token.jwt}`, + Origin: UC_ORIGIN, + Referer: UC_ORIGIN + "/", + "Content-Type": "application/json", + }; + + let resp: Response; + try { + resp = await fetchImpl(UC_PERSONA_IMAGE_URL, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal, + }); + } catch (err) { + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + log?.error?.("IMAGE", `${provider} uc-image (persona) transport error: ${errorText}`); + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: errorText, + requestBody, + }); + } + + if (!resp.ok) { + const detail = (await resp.text().catch(() => "")).slice(0, 500); + log?.error?.("IMAGE", `${provider} uc-image (persona) error ${resp.status}: ${detail}`); + return saveImageErrorResult({ + provider, + model, + status: resp.status, + startTime, + error: detail || `UC persona image generation failed (HTTP ${resp.status})`, + requestBody, + retryable: resp.status === 401 || resp.status === 403, + }); + } + + let json: unknown; + try { + json = await resp.json(); + } catch { + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: "UC persona returned a non-JSON image response", + requestBody, + }); + } + + const resultUrl = + json && typeof json === "object" && typeof (json as Record).url === "string" + ? ((json as Record).url as string) + : ""; + if (!resultUrl) { + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: "UC persona image response carried no result url", + requestBody, + }); + } + + const timeoutMs = normalizePositiveNumber( + body.timeout_ms, + normalizePositiveNumber(process.env.UC_IMAGE_POLL_TIMEOUT_MS, UC_POLL_TIMEOUT_MS_DEFAULT) + ); + const pollIntervalMs = normalizePositiveNumber( + body.poll_interval_ms, + normalizePositiveNumber(process.env.UC_IMAGE_POLL_INTERVAL_MS, UC_POLL_INTERVAL_MS_DEFAULT) + ); + + const poll = await pollUcResultUrl( + resultUrl, + timeoutMs, + pollIntervalMs, + fetchImpl, + sleepImpl, + signal, + log + ); + if (poll.state === "failed") { + log?.error?.("IMAGE", `${provider} uc-image (persona) poll ${poll.status}: ${poll.error}`); + return saveImageErrorResult({ + provider, + model, + status: poll.status, + startTime, + error: poll.error, + requestBody, + }); + } + + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody, + responseBody: { images_count: 1 }, + images: [{ url: resultUrl }], + }); +} + +type UcPollOutcome = { state: "ready" } | { state: "failed"; status: number; error: string }; + +/** Poll the pre-determined result URL with GET until HTTP 200, or time out. */ +async function pollUcResultUrl( + url: string, + timeoutMs: number, + pollIntervalMs: number, + fetchImpl: typeof fetch, + sleepImpl: SleepImpl, + signal: AbortSignal | undefined, + log?: { info?: (...args: unknown[]) => void } +): Promise { + const deadline = Date.now() + timeoutMs; + let attempt = 0; + // Poll at least once even when timeoutMs is 0. + do { + attempt += 1; + let resp: Response; + try { + resp = await fetchImpl(url, { method: "GET", signal }); + } catch (err) { + return { + state: "failed", + status: 502, + error: sanitizeErrorMessage(err instanceof Error ? err.message : String(err)), + }; + } + if (resp.ok) return { state: "ready" }; + // 403/404 = not ready yet; anything else is a hard failure. + if (resp.status !== 403 && resp.status !== 404) { + return { + state: "failed", + status: resp.status, + error: `UC result URL returned HTTP ${resp.status}`, + }; + } + log?.info?.("IMAGE", `uc-image result pending, poll #${attempt} in ${pollIntervalMs}ms`); + if (Date.now() + pollIntervalMs >= deadline) break; + await sleepImpl(pollIntervalMs); + } while (Date.now() < deadline); + + return { + state: "failed", + status: 504, + error: "UC image generation timed out waiting for a result", + }; +} + +/** + * uc-direct REST path (surface B): OpenAI-compatible metered endpoint keyed by + * `X-api-key`. The response is already OpenAI-shaped. + */ +async function handleUcDirectImage( + args: Required> & + Pick & { + fetchImpl: typeof fetch; + startTime: number; + prompt: string; + } +) { + const { model, provider, body, credentials, log, signal, fetchImpl, startTime, prompt } = args; + + const apiKey = typeof credentials.apiKey === "string" ? credentials.apiKey.trim() : ""; + const canonicalModel = resolveUcImageModel(model); + const nRaw = Number(body.n); + const n = Number.isFinite(nRaw) && nRaw >= 1 ? Math.min(Math.floor(nRaw), UC_IMAGE_N_MAX) : 1; + const requestBody: Record = { + model: canonicalModel, + prompt, + n, + }; + if (typeof body.size === "string" && body.size.trim()) requestBody.size = body.size.trim(); + + const headers: Record = { + "X-api-key": apiKey, + "Content-Type": "application/json", + }; + + let resp: Response; + try { + resp = await fetchImpl(UC_DIRECT_IMAGE_URL, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal, + }); + } catch (err) { + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + log?.error?.("IMAGE", `${provider} uc-image (direct) transport error: ${errorText}`); + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: errorText, + requestBody, + }); + } + + if (!resp.ok) { + const detail = (await resp.text().catch(() => "")).slice(0, 500); + log?.error?.("IMAGE", `${provider} uc-image (direct) error ${resp.status}: ${detail}`); + return saveImageErrorResult({ + provider, + model, + status: resp.status, + startTime, + error: detail || `UC direct image generation failed (HTTP ${resp.status})`, + requestBody, + // 429 = rate limit (retry another account/later). 402 funds / 403 moderation + // are non-retryable per the REST error contract. + retryable: resp.status === 429 || undefined, + }); + } + + let json: unknown; + try { + json = await resp.json(); + } catch { + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: "UC direct returned a non-JSON image response", + requestBody, + }); + } + + const images = extractUcDirectImages(json); + if (images.length === 0) { + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: "UC direct image generation returned no images", + requestBody, + }); + } + + const created = + json && + typeof json === "object" && + typeof (json as Record).created === "number" + ? ((json as Record).created as number) + : null; + + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody, + responseBody: { images_count: images.length }, + created, + images, + }); +} + +export async function handleUcImageGeneration({ + model, + provider, + body, + credentials, + log, + signal, + fetchImpl = fetch, + sleepImpl = realSleep, +}: UcImageHandlerArgs) { + const startTime = Date.now(); + + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: "Prompt is required for UC image generation", + }); + } + + if (isUcDirectCredential(credentials)) { + return handleUcDirectImage({ + model, + provider, + body, + credentials, + log, + signal, + fetchImpl, + startTime, + prompt, + }); + } + return handleUcPersonaImage({ + model, + provider, + body, + credentials, + log, + signal, + fetchImpl, + sleepImpl, + startTime, + prompt, + }); +} diff --git a/open-sse/handlers/uc/ucTts.ts b/open-sse/handlers/uc/ucTts.ts new file mode 100644 index 0000000000..57b651d512 --- /dev/null +++ b/open-sse/handlers/uc/ucTts.ts @@ -0,0 +1,326 @@ +/** + * UC (uncensored.com) TEXT-TO-SPEECH handler — exposed on OpenAI /v1/audio/speech. + * + * UC's voice synthesis runs over a dedicated WebSocket (distinct from the persona + * chat socket and the metered REST API — three separate backends): + * + * wss://tts-stream.chatuncensored.ai/{user_id}?token={clerk_jwt} + * + * Auth is identical to the chat WS: a short-lived (60s) Clerk `__session` JWT in + * the `?token=` query param, minted per-connect from the durable `__client` + * cookie, plus an `Origin: https://uncensored.com` handshake header (the ONLY + * required header — no Cookie, no Authorization on the upgrade). The JWT is ALSO + * echoed inside the `start` frame body. + * + * Wire (capture-confirmed, UC-MEDIA-GENERATION.md lines 7-42): + * SEND one `start` frame: { message_type:'start', text, raw_text, model, + * voice, turn_anchor_message_id, message_id, thread_id, threadId, token } + * RECV a stream of frames: + * { type:'usage_update', usage_percent, threshold_crossed } ← quota, tracked + * { data:'' } ← audio (ID3/MP3) + * The socket closes when synthesis completes. We accumulate every `data` + * chunk, base64-decode, and concatenate into the full MP3 buffer. + * + * The module mirrors open-sse/executors/uc/ws.ts: a module-level WebSocket + * constructor with a `__setUcTtsWebSocketForTesting` swap hook, a Promise-wrapped + * `new Ctor(url, { headers: { Origin } })`, onopen/onmessage/onerror/onclose, and + * a timeout/abort guard. `fetchImpl` is injectable for the token mint so the whole + * path is unit-testable with no live network. + */ +import { randomUUID } from "node:crypto"; +import { Buffer } from "node:buffer"; + +import WebSocket from "ws"; + +import { + UC_ORIGIN, + UC_TTS_DEFAULT_MODEL, + UC_TTS_DEFAULT_VOICE, + UC_TTS_WS_HOST, + UC_TTS_WS_TIMEOUT_MS, +} from "../../executors/uc/constants.ts"; +import { resolveUcCredential, type UcCredential } from "../../executors/uc/credentials.ts"; +import { mintUcSessionToken } from "../../executors/uc/clerkAuth.ts"; + +let WebSocketCtor: typeof WebSocket = WebSocket; + +/** Inject a fake WebSocket constructor for tests. Returns a restore fn. */ +export function __setUcTtsWebSocketForTesting(ctor: typeof WebSocket): () => void { + const previous = WebSocketCtor; + WebSocketCtor = ctor; + return () => { + WebSocketCtor = previous; + }; +} + +/** Build the TTS WS URL: wss://tts-stream.chatuncensored.ai/{uid}?token={jwt}. */ +export function buildUcTtsWsUrl(uid: string, jwt: string): string { + return `${UC_TTS_WS_HOST}/${encodeURIComponent(uid)}?token=${encodeURIComponent(jwt)}`; +} + +/** The `start` frame the client sends to begin synthesis. */ +export interface UcTtsStartFrame { + message_type: "start"; + text: string; + raw_text: string; + turn_anchor_message_id: string; + message_id: string; + thread_id: string; + threadId: string; + model: string; + voice: string; + token: string; +} + +/** Build the `start` frame for a synthesis request. */ +export function buildUcTtsStartFrame(input: { + text: string; + voice: string; + jwt: string; + model?: string; +}): UcTtsStartFrame { + const threadId = randomUUID(); + return { + message_type: "start", + text: input.text, + raw_text: input.text, + turn_anchor_message_id: randomUUID(), + message_id: randomUUID(), + thread_id: threadId, + threadId, + model: input.model ?? UC_TTS_DEFAULT_MODEL, + voice: input.voice, + token: input.jwt, + }; +} + +/** Narrow an unknown parsed frame to `{ data: string }` (a base64 MP3 chunk). */ +function extractDataChunk(value: unknown): string | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + const data = (value as { data?: unknown }).data; + if (typeof data === "string" && data.length > 0) return data; + } + return null; +} + +/** Narrow an unknown parsed frame to a `usage_update` quota frame. */ +function extractUsagePercent(value: unknown): number | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + const obj = value as { type?: unknown; usage_percent?: unknown }; + if (obj.type === "usage_update" && typeof obj.usage_percent === "number") { + return obj.usage_percent; + } + } + return null; +} + +export interface UcTtsSocketInput { + jwt: string; + uid: string; + text: string; + voice: string; + model?: string; + timeoutMs?: number; + signal?: AbortSignal | null; +} + +export interface UcTtsSocketResult { + /** Concatenated MP3 bytes decoded from all `data` frames. */ + audio: Buffer; + /** Last observed TTS quota percentage (from usage_update frames), if any. */ + usagePercent?: number; + /** Set when the request failed (transport failure, timeout, or empty audio). */ + error?: string; +} + +/** + * Drive one TTS synthesis to completion over the WebSocket. Never rejects — a + * transport/timeout failure resolves with `{ error }` set plus whatever audio was + * accumulated so far. Mirrors runUcTurn in executors/uc/ws.ts. + */ +export function runUcTtsSocket(input: UcTtsSocketInput): Promise { + const timeoutMs = input.timeoutMs ?? UC_TTS_WS_TIMEOUT_MS; + const url = buildUcTtsWsUrl(input.uid, input.jwt); + const chunks: Buffer[] = []; + let usagePercent: number | undefined; + + return new Promise((resolve) => { + let ws: WebSocket; + try { + ws = new WebSocketCtor(url, { headers: { Origin: UC_ORIGIN } }); + } catch (err) { + resolve({ + audio: Buffer.alloc(0) as Buffer, + error: `ws connect failed: ${err instanceof Error ? err.message : String(err)}`, + }); + return; + } + + let settled = false; + let timeout: ReturnType | null = null; + let abortHandler: (() => void) | null = null; + + const concat = (): Buffer => Buffer.concat(chunks) as Buffer; + + const finish = (result: UcTtsSocketResult) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + if (input.signal && abortHandler) input.signal.removeEventListener("abort", abortHandler); + try { + ws.close(); + } catch { + /* ignore */ + } + resolve(result); + }; + + const fail = (error: string) => finish({ audio: concat(), usagePercent, error }); + + timeout = setTimeout( + () => fail(`UC TTS WS timed out (readyState=${ws.readyState})`), + timeoutMs + ); + abortHandler = () => fail("Request aborted"); + input.signal?.addEventListener("abort", abortHandler, { once: true }); + + ws.onopen = () => { + try { + const frame = buildUcTtsStartFrame({ + text: input.text, + voice: input.voice, + jwt: input.jwt, + model: input.model, + }); + ws.send(JSON.stringify(frame)); + } catch (err) { + fail(`ws send failed: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + ws.onmessage = (event: WebSocket.MessageEvent) => { + let raw = ""; + const data = event.data as unknown; + if (typeof data === "string") { + raw = data; + } else if (Buffer.isBuffer(data)) { + raw = data.toString("utf-8"); + } else if (data instanceof ArrayBuffer) { + raw = new TextDecoder().decode(data); + } else if (ArrayBuffer.isView(data as ArrayBufferView)) { + raw = new TextDecoder().decode(data as ArrayBufferView); + } + if (!raw) return; + + // Frames may arrive newline-delimited or one-per-message; handle both. + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + continue; + } + const percent = extractUsagePercent(parsed); + if (percent !== null) { + usagePercent = percent; + continue; + } + const chunk = extractDataChunk(parsed); + if (chunk !== null) { + try { + chunks.push(Buffer.from(chunk, "base64")); + } catch { + /* skip an undecodable chunk */ + } + } + } + }; + + ws.onerror = () => fail("UC TTS WebSocket connection error"); + ws.onclose = () => { + if (settled) return; + const audio = concat(); + finish({ + audio, + usagePercent, + error: audio.length === 0 ? "UC TTS produced no audio" : undefined, + }); + }; + }); +} + +export interface HandleUcTextToSpeechInput { + /** The text to synthesize (mapped from OpenAI `input`). */ + text: string; + /** The voice selection (mapped from OpenAI `voice`; defaults to `jade`). */ + voice?: string; + /** TTS model tier (defaults to `default`). */ + model?: string; + /** Connection credentials — providerSpecificData carries the UC durable cred. */ + credentials?: { providerSpecificData?: Record | null } | null; + signal?: AbortSignal | null; + /** Injectable fetch for the Clerk token mint (tests). */ + fetchImpl?: typeof fetch; +} + +export interface HandleUcTextToSpeechResult { + ok: boolean; + /** Concatenated MP3 bytes on success. */ + audio?: Buffer; + /** MIME type of the returned audio. */ + contentType?: string; + /** HTTP-ish status to surface (200 ok, 401 auth, 502 upstream). */ + status?: number; + error?: string; +} + +/** + * Resolve credentials, mint a fresh Clerk session JWT, open the TTS socket, and + * return the concatenated MP3 bytes. Never throws — always resolves a structured + * result the caller maps to an HTTP response. + */ +export async function handleUcTextToSpeech( + input: HandleUcTextToSpeechInput +): Promise { + const text = typeof input.text === "string" ? input.text : ""; + if (!text.trim()) { + return { ok: false, status: 400, error: "input text is required" }; + } + + const cred: UcCredential | null = resolveUcCredential(input.credentials?.providerSpecificData); + if (!cred) { + return { + ok: false, + status: 401, + error: "UC credential not configured (need clientCookie, sid, uid)", + }; + } + + const mint = await mintUcSessionToken({ + sid: cred.sid, + cookies: cred.cookies, + signal: input.signal, + fetchImpl: input.fetchImpl, + }); + if (!mint.ok || !mint.token) { + const status = mint.status === 401 || mint.status === 403 ? 401 : 502; + return { ok: false, status, error: mint.error || `Clerk mint HTTP ${mint.status}` }; + } + + const result = await runUcTtsSocket({ + jwt: mint.token.jwt, + uid: cred.uid, + text, + voice: input.voice?.trim() || UC_TTS_DEFAULT_VOICE, + model: input.model, + signal: input.signal, + }); + + if (result.error && result.audio.length === 0) { + return { ok: false, status: 502, error: result.error }; + } + + return { ok: true, status: 200, audio: result.audio, contentType: "audio/mpeg" }; +} diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index e97b95995c..bfbf9267f4 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -17,6 +17,7 @@ import { handleDashscopeVideoGeneration } from "./videoGeneration/dashscopeHandl import { handleNovitaVideoGeneration } from "./videoGeneration/novitaHandler.ts"; import { handleXaiVideoGeneration } from "./videoGeneration/xaiGrokImagineHandler.ts"; import { handleSegmindVideoGeneration } from "./videoGeneration/providers/segmind.ts"; +import { handleUcVideoGeneration } from "./videoGeneration/providers/ucVideo.ts"; import { handleAdobeFireflyVideoGeneration } from "./videoGeneration/adobeFireflyHandler.ts"; import { handleOpenAIVideoGeneration } from "./videoGeneration/openai.ts"; import { getVideoJobPreset, handleVideoJobGeneration } from "./videoGeneration/job.ts"; @@ -301,6 +302,12 @@ export async function handleVideoGeneration({ body, credentials, log, resolvedPr if (providerConfig.format === "xai-video") { return handleXaiVideoGeneration({ model, provider, providerConfig, body, credentials, log }); } + if (providerConfig.format === "uc-video") { + // UC (uncensored.com): one handler serves both surfaces, picking by + // credential — persona web (Clerk JWT, un-metered, upload/generate + HEAD + // poll) or uc-direct REST (X-api-key, metered, async submit + status poll). + return handleUcVideoGeneration({ model, provider, body, credentials, log }); + } if (providerConfig.format === "adobe-firefly-video") { return handleAdobeFireflyVideoGeneration({ model, diff --git a/open-sse/handlers/videoGeneration/providers/ucVideo.ts b/open-sse/handlers/videoGeneration/providers/ucVideo.ts new file mode 100644 index 0000000000..74345d312e --- /dev/null +++ b/open-sse/handlers/videoGeneration/providers/ucVideo.ts @@ -0,0 +1,829 @@ +// UC (uncensored.com) video-generation handler. +// Family: uc-video | Provider: uc +// +// UC exposes video generation on TWO surfaces, and this handler serves both, +// picking by which credential is present (mirrors the sibling image handler, +// imageGeneration/providers/ucImage.ts): +// +// (A) PERSONA WEB path (un-metered, Clerk-authenticated). No API key: the +// durable Clerk `__client` cookie lives in the connection's +// providerSpecificData, from which we mint a short-lived `__session` JWT +// (mintUcSessionToken). Two sub-cases keyed on whether the request carries +// an input image: +// +// • text-to-video (no input image): +// POST https://internal.chatuncensored.ai/text_to_video +// {prompt, model, num_frames, frames_per_second, num_inference_steps, +// guide_scale, shift, aspect_ratio, pro_mode, turbo, resolution, +// sora_resolution, seconds, video_to_video_duration, vdiscount} +// NOTE: `/text_to_video` is the documented sibling of `/image_to_video` +// but was NOT directly HAR-captured (only `/image_to_video` was). The +// wire shape here mirrors `/image_to_video` minus the blob fields; if a +// live capture later shows a different path/body, adjust here. See +// UC-MEDIA-GENERATION.md lines 44-97. +// +// • image-to-video (has an input image): a 3-step upload+generate flow: +// (1) POST https://internal-6.pubyar.com/generate-signed-url +// {content_type:"image/png", user_identifier:} +// -> {signed_url:"https://d.moveinwater.com/up/", blob_name} +// (2) PUT with the raw image bytes +// (3) POST https://internal.chatuncensored.ai/image_to_video +// {prompt, media_blob_name:, num_frames:81, ..., +// model:"wan-2.2-spicy", seconds:5, ...} +// +// Both persona POSTs carry Authorization: Bearer plus +// Origin/Referer https://uncensored.com. The generate response carries a +// PRE-DETERMINED result URL (https://videogen.moveinwater.com/) plus +// eta_seconds / timeout_seconds. We then POLL that url with HEAD until +// HTTP 200 (403 = not ready), bounded by timeout_seconds. +// +// (B) uc-direct REST path (metered, OpenAI-compatible). A `uai_sk_live_...` +// X-api-key credential is present, so we call the official REST endpoint: +// POST https://api.uncensored.com/api/v1/videos/generations +// X-api-key: +// body {model, prompt, ...} +// The endpoint is async: the response carries a job (status + optional +// status_url). We poll status_url until the job completes and returns a +// video url, or return the job id when the backend is callback-only. +// +// Residential egress / TLS (if any) is applied transparently at the infra layer; +// nothing egress-specific lives here. The handler is pure and testable: fetch and +// sleep are injectable so unit tests drive the upload -> generate -> poll sequence +// with no live network. + +import { resolveUcCredential } from "../../../executors/uc/credentials.ts"; +import { mintUcSessionToken } from "../../../executors/uc/clerkAuth.ts"; +import { UC_ORIGIN } from "../../../executors/uc/constants.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +/** Persona signed-upload-URL endpoint (for the image-to-video input image). */ +export const UC_PERSONA_SIGNED_URL = "https://internal-6.pubyar.com/generate-signed-url"; +/** Persona image-to-video generation endpoint. */ +export const UC_PERSONA_IMAGE_TO_VIDEO_URL = "https://internal.chatuncensored.ai/image_to_video"; +/** Persona text-to-video generation endpoint (documented sibling; see file header). */ +export const UC_PERSONA_TEXT_TO_VIDEO_URL = "https://internal.chatuncensored.ai/text_to_video"; +/** uc-direct metered REST endpoint (OpenAI-compatible, async). */ +export const UC_DIRECT_VIDEO_URL = "https://api.uncensored.com/api/v1/videos/generations"; + +/** Default persona web video model (the picker default). */ +export const UC_DEFAULT_VIDEO_MODEL = "wan-2.2-spicy"; + +const UC_POLL_TIMEOUT_MS_DEFAULT = 300_000; +const UC_POLL_INTERVAL_MS_DEFAULT = 3_000; + +type SleepImpl = (ms: number) => Promise; +const realSleep: SleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +interface UcVideoLog { + info?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; +} + +interface UcVideoBody { + prompt?: unknown; + // Any of these signal an image-to-video request (a data URL, http(s) URL, or + // bare base64 payload for the first frame). + image?: unknown; + image_url?: unknown; + input_image?: unknown; + media?: unknown; + // Optional web knobs (fall back to the capture-confirmed defaults). + model?: unknown; + num_frames?: unknown; + frames_per_second?: unknown; + num_inference_steps?: unknown; + guide_scale?: unknown; + shift?: unknown; + aspect_ratio?: unknown; + pro_mode?: unknown; + turbo?: unknown; + resolution?: unknown; + sora_resolution?: unknown; + seconds?: unknown; + duration?: unknown; + size?: unknown; + timeout_ms?: unknown; + poll_interval_ms?: unknown; + [key: string]: unknown; +} + +interface UcVideoCredentials { + apiKey?: string; + accessToken?: string; + providerSpecificData?: Record | null; +} + +interface UcVideoHandlerArgs { + model: string; + provider?: string; + body: UcVideoBody; + credentials: UcVideoCredentials; + /** Optional; falls back to `body.prompt`. */ + prompt?: string; + log?: UcVideoLog | null; + signal?: AbortSignal; + fetchImpl?: typeof fetch; + sleepImpl?: SleepImpl; +} + +type UcVideoResult = + | { + success: true; + data: { + created: number; + data: Array<{ + url?: string; + b64_json?: string; + format?: string; + request_id?: string; + status?: string; + }>; + }; + } + | { success: false; status: number; error: string; retryable?: boolean }; + +/** + * Strip a routing prefix (`uc/` or `uc-direct/`) and return the canonical UC + * video model id (the web picker shortname / the REST `model`). Empty input + * falls back to the persona default (`wan-2.2-spicy`). + */ +export function resolveUcVideoModel(model: unknown): string { + let m = typeof model === "string" ? model.trim() : ""; + if (m.startsWith("uc-direct/")) m = m.slice("uc-direct/".length); + else if (m.startsWith("uc/")) m = m.slice("uc/".length); + return m || UC_DEFAULT_VIDEO_MODEL; +} + +/** True when the credential is a uc-direct metered API key (`uai_sk_live_...`). */ +export function isUcDirectVideoCredential(credentials: UcVideoCredentials): boolean { + const key = typeof credentials?.apiKey === "string" ? credentials.apiKey.trim() : ""; + return key.startsWith("uai_"); +} + +/** The first input-image field present on the body, or null for text-to-video. */ +export function resolveUcInputImage(body: UcVideoBody): string | null { + for (const v of [body.image, body.image_url, body.input_image, body.media]) { + if (typeof v === "string" && v.trim()) return v.trim(); + } + return null; +} + +function normalizePositiveNumber(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} + +function firstNumber(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +/** + * Build the persona web generation body shared by text-to-video and + * image-to-video. `mediaBlobName` (null for t2v) becomes `media_blob_name`. + */ +export function buildUcPersonaVideoBody( + prompt: string, + model: string, + body: UcVideoBody, + mediaBlobName: string | null +): Record { + const seconds = firstNumber(body.seconds ?? body.duration, 5); + return { + prompt, + media_blob_name: mediaBlobName, + num_frames: firstNumber(body.num_frames, 81), + frames_per_second: firstNumber(body.frames_per_second, 16), + num_inference_steps: firstNumber(body.num_inference_steps, 30), + guide_scale: firstNumber(body.guide_scale, 5), + shift: firstNumber(body.shift, 5), + aspect_ratio: typeof body.aspect_ratio === "string" ? body.aspect_ratio : "auto", + pro_mode: body.pro_mode === true, + turbo: body.turbo === true, + resolution: typeof body.resolution === "string" ? body.resolution : "480p", + sora_resolution: typeof body.sora_resolution === "string" ? body.sora_resolution : "480p", + end_frame_blob_name: null, + model, + seconds, + video_to_video_duration: firstNumber(body.duration, seconds), + vdiscount: false, + }; +} + +/** + * Extract a ready video URL (and any job/status hints) from a uc-direct REST + * response. Tolerant of the several OpenAI-ish shapes the async endpoint may + * return: `data:[{url}]`, top-level `url`/`video_url`, `video:{url}`, `output`. + */ +export function extractUcDirectVideo(json: unknown): { + url?: string; + statusUrl?: string; + status?: string; + requestId?: string; +} { + if (!json || typeof json !== "object") return {}; + const rec = json as Record; + const out: { url?: string; statusUrl?: string; status?: string; requestId?: string } = {}; + + if (typeof rec.status === "string") out.status = rec.status; + if (typeof rec.status_url === "string") out.statusUrl = rec.status_url; + const rid = rec.request_id ?? rec.id ?? rec.job_id; + if (typeof rid === "string" && rid) out.requestId = rid; + + // data:[{url}] + if (Array.isArray(rec.data)) { + for (const it of rec.data) { + if (it && typeof it === "object") { + const item = it as Record; + if (typeof item.url === "string" && item.url) { + out.url = item.url; + break; + } + } + } + } + // top-level url / video_url + if (!out.url && typeof rec.url === "string" && rec.url) out.url = rec.url; + if (!out.url && typeof rec.video_url === "string" && rec.video_url) out.url = rec.video_url; + // video:{url} + if (!out.url && rec.video && typeof rec.video === "object") { + const vurl = (rec.video as Record).url; + if (typeof vurl === "string" && vurl) out.url = vurl; + } + // output (string url) + if (!out.url && typeof rec.output === "string" && rec.output) out.url = rec.output; + + return out; +} + +/** A uc-direct status is terminal-complete when the video is ready. */ +function isDirectComplete(status: string | undefined, url: string | undefined): boolean { + if (url) return true; + const s = (status || "").toLowerCase(); + return ( + s === "complete" || s === "completed" || s === "succeeded" || s === "success" || s === "done" + ); +} + +/** A uc-direct status is terminal-failed. */ +function isDirectFailed(status: string | undefined): boolean { + const s = (status || "").toLowerCase(); + return s === "failed" || s === "error" || s === "canceled" || s === "cancelled"; +} + +/** Decode an input image reference into raw bytes for the signed-URL PUT. */ +async function resolveImageBytes( + ref: string, + fetchImpl: typeof fetch, + signal: AbortSignal | undefined +): Promise { + // data URL: data:image/png;base64, + const dataMatch = /^data:[^;]*;base64,(.*)$/.exec(ref); + if (dataMatch) { + try { + return new Uint8Array(Buffer.from(dataMatch[1], "base64")); + } catch { + return null; + } + } + // http(s) URL: fetch the bytes. + if (/^https?:\/\//i.test(ref)) { + try { + const resp = await fetchImpl(ref, { method: "GET", signal }); + if (!resp.ok) return null; + const buf = await resp.arrayBuffer(); + return new Uint8Array(buf); + } catch { + return null; + } + } + // Bare base64 payload. + try { + return new Uint8Array(Buffer.from(ref, "base64")); + } catch { + return null; + } +} + +type UcPollOutcome = { state: "ready" } | { state: "failed"; status: number; error: string }; + +/** Poll the pre-determined persona result URL with HEAD until HTTP 200, or time out. */ +async function pollUcVideoUrl( + url: string, + timeoutMs: number, + pollIntervalMs: number, + fetchImpl: typeof fetch, + sleepImpl: SleepImpl, + signal: AbortSignal | undefined, + log?: UcVideoLog | null +): Promise { + const deadline = Date.now() + timeoutMs; + let attempt = 0; + // Poll at least once even when timeoutMs is 0. + do { + attempt += 1; + let resp: Response; + try { + resp = await fetchImpl(url, { method: "HEAD", signal }); + } catch (err) { + return { + state: "failed", + status: 502, + error: sanitizeErrorMessage(err instanceof Error ? err.message : String(err)), + }; + } + if (resp.ok) return { state: "ready" }; + // 403/404 = not ready yet; anything else is a hard failure. + if (resp.status !== 403 && resp.status !== 404) { + return { + state: "failed", + status: resp.status, + error: `UC video result URL returned HTTP ${resp.status}`, + }; + } + log?.info?.("VIDEO", `uc-video result pending, poll #${attempt} in ${pollIntervalMs}ms`); + if (Date.now() + pollIntervalMs >= deadline) break; + await sleepImpl(pollIntervalMs); + } while (Date.now() < deadline); + + return { + state: "failed", + status: 504, + error: "UC video generation timed out waiting for a result", + }; +} + +interface PersonaContext { + model: string; + provider: string; + body: UcVideoBody; + credentials: UcVideoCredentials; + prompt: string; + log?: UcVideoLog | null; + signal?: AbortSignal; + fetchImpl: typeof fetch; + sleepImpl: SleepImpl; +} + +/** + * PERSONA WEB path (surface A): mint a Clerk JWT, then run either the + * text-to-video POST or the image-to-video upload+generate flow, and poll the + * pre-determined result URL until it returns 200. + */ +async function handleUcPersonaVideo(ctx: PersonaContext): Promise { + const { model, provider, body, credentials, prompt, log, signal, fetchImpl, sleepImpl } = ctx; + + const cred = resolveUcCredential(credentials?.providerSpecificData); + if (!cred) { + return { + success: false, + status: 401, + error: "UC persona credentials missing (need clientCookie + sid + uid)", + retryable: true, + }; + } + + const mint = await mintUcSessionToken({ + sid: cred.sid, + cookies: cred.cookies, + fetchImpl, + signal, + }); + if (!mint.ok || !mint.token) { + return { + success: false, + status: mint.status === 0 ? 502 : mint.status, + error: sanitizeErrorMessage(mint.error || "UC Clerk token mint failed"), + // 401/403 = durable login lapsed or revoked: rotate to the next account. + retryable: mint.status === 401 || mint.status === 403, + }; + } + + const jwt = mint.token.jwt; + const authHeaders: Record = { + Authorization: `Bearer ${jwt}`, + Origin: UC_ORIGIN, + Referer: UC_ORIGIN + "/", + "Content-Type": "application/json", + }; + + const canonicalModel = resolveUcVideoModel(model); + const inputImage = resolveUcInputImage(body); + + let genUrl: string; + let requestBody: Record; + + if (inputImage) { + // Image-to-video: (1) signed URL, (2) PUT bytes, (3) generate. + const bytes = await resolveImageBytes(inputImage, fetchImpl, signal); + if (!bytes) { + return { + success: false, + status: 400, + error: "UC image-to-video could not decode the input image", + }; + } + + const signedBody = { content_type: "image/png", user_identifier: cred.uid }; + let signedResp: Response; + try { + signedResp = await fetchImpl(UC_PERSONA_SIGNED_URL, { + method: "POST", + headers: authHeaders, + body: JSON.stringify(signedBody), + signal, + }); + } catch (err) { + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + log?.error?.( + "VIDEO", + `${provider} uc-video (persona) signed-url transport error: ${errorText}` + ); + return { success: false, status: 502, error: errorText }; + } + if (!signedResp.ok) { + const detail = (await signedResp.text().catch(() => "")).slice(0, 500); + return { + success: false, + status: signedResp.status, + error: detail || `UC signed-url request failed (HTTP ${signedResp.status})`, + retryable: signedResp.status === 401 || signedResp.status === 403, + }; + } + let signedJson: unknown; + try { + signedJson = await signedResp.json(); + } catch { + return { success: false, status: 502, error: "UC signed-url returned a non-JSON response" }; + } + const signedRec = (signedJson && typeof signedJson === "object" ? signedJson : {}) as Record< + string, + unknown + >; + const signedUrl = typeof signedRec.signed_url === "string" ? signedRec.signed_url : ""; + const blobName = typeof signedRec.blob_name === "string" ? signedRec.blob_name : ""; + if (!signedUrl || !blobName) { + return { + success: false, + status: 502, + error: "UC signed-url response missing signed_url or blob_name", + }; + } + + // (2) PUT the image bytes to the signed URL. Fresh copy so BodyInit is a + // plain ArrayBuffer (not a possibly-shared buffer view). + const putBody = new Uint8Array(bytes.byteLength); + putBody.set(bytes); + let putResp: Response; + try { + putResp = await fetchImpl(signedUrl, { + method: "PUT", + headers: { "Content-Type": "image/png" }, + body: putBody, + signal, + }); + } catch (err) { + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + log?.error?.("VIDEO", `${provider} uc-video (persona) upload transport error: ${errorText}`); + return { success: false, status: 502, error: errorText }; + } + if (!putResp.ok) { + return { + success: false, + status: putResp.status, + error: `UC input-image upload failed (HTTP ${putResp.status})`, + }; + } + + genUrl = UC_PERSONA_IMAGE_TO_VIDEO_URL; + requestBody = buildUcPersonaVideoBody(prompt, canonicalModel, body, blobName); + } else { + // Text-to-video: single generate POST (no media blob). + genUrl = UC_PERSONA_TEXT_TO_VIDEO_URL; + requestBody = buildUcPersonaVideoBody(prompt, canonicalModel, body, null); + } + + let genResp: Response; + try { + genResp = await fetchImpl(genUrl, { + method: "POST", + headers: authHeaders, + body: JSON.stringify(requestBody), + signal, + }); + } catch (err) { + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + log?.error?.("VIDEO", `${provider} uc-video (persona) generate transport error: ${errorText}`); + return { success: false, status: 502, error: errorText }; + } + if (!genResp.ok) { + const detail = (await genResp.text().catch(() => "")).slice(0, 500); + log?.error?.( + "VIDEO", + `${provider} uc-video (persona) generate error ${genResp.status}: ${detail}` + ); + return { + success: false, + status: genResp.status, + error: detail || `UC persona video generation failed (HTTP ${genResp.status})`, + retryable: genResp.status === 401 || genResp.status === 403, + }; + } + + let genJson: unknown; + try { + genJson = await genResp.json(); + } catch { + return { success: false, status: 502, error: "UC persona returned a non-JSON video response" }; + } + const genRec = (genJson && typeof genJson === "object" ? genJson : {}) as Record; + const resultUrl = typeof genRec.url === "string" ? genRec.url : ""; + if (!resultUrl) { + return { + success: false, + status: 502, + error: "UC persona video response carried no result url", + }; + } + const requestId = typeof genRec.request_id === "string" ? genRec.request_id : undefined; + const timeoutSeconds = Number(genRec.timeout_seconds); + + const defaultTimeoutMs = + Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 + ? timeoutSeconds * 1000 + : normalizePositiveNumber(process.env.UC_VIDEO_POLL_TIMEOUT_MS, UC_POLL_TIMEOUT_MS_DEFAULT); + const timeoutMs = normalizePositiveNumber(body.timeout_ms, defaultTimeoutMs); + const pollIntervalMs = normalizePositiveNumber( + body.poll_interval_ms, + normalizePositiveNumber(process.env.UC_VIDEO_POLL_INTERVAL_MS, UC_POLL_INTERVAL_MS_DEFAULT) + ); + + const poll = await pollUcVideoUrl( + resultUrl, + timeoutMs, + pollIntervalMs, + fetchImpl, + sleepImpl, + signal, + log + ); + if (poll.state === "failed") { + log?.error?.("VIDEO", `${provider} uc-video (persona) poll ${poll.status}: ${poll.error}`); + return { success: false, status: poll.status, error: poll.error }; + } + + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ url: resultUrl, format: "mp4", ...(requestId ? { request_id: requestId } : {}) }], + }, + }; +} + +interface DirectContext { + model: string; + provider: string; + body: UcVideoBody; + credentials: UcVideoCredentials; + prompt: string; + log?: UcVideoLog | null; + signal?: AbortSignal; + fetchImpl: typeof fetch; + sleepImpl: SleepImpl; +} + +/** + * uc-direct REST path (surface B): OpenAI-compatible metered endpoint keyed by + * `X-api-key`. Async: submit, then poll `status_url` until the video is ready, + * or return the job id when the backend is callback-only. + */ +async function handleUcDirectVideo(ctx: DirectContext): Promise { + const { model, provider, body, credentials, prompt, log, signal, fetchImpl, sleepImpl } = ctx; + + const apiKey = typeof credentials.apiKey === "string" ? credentials.apiKey.trim() : ""; + const canonicalModel = resolveUcVideoModel(model); + const requestBody: Record = { model: canonicalModel, prompt }; + if (typeof body.size === "string" && body.size.trim()) requestBody.size = body.size.trim(); + if (typeof body.aspect_ratio === "string" && body.aspect_ratio.trim()) { + requestBody.aspect_ratio = body.aspect_ratio.trim(); + } + if (typeof body.resolution === "string" && body.resolution.trim()) + requestBody.resolution = body.resolution.trim(); + if (body.duration != null && Number.isFinite(Number(body.duration))) + requestBody.duration = Number(body.duration); + const inputImage = resolveUcInputImage(body); + if (inputImage) requestBody.image = inputImage; + + const headers: Record = { + "X-api-key": apiKey, + "Content-Type": "application/json", + }; + + let resp: Response; + try { + resp = await fetchImpl(UC_DIRECT_VIDEO_URL, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal, + }); + } catch (err) { + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + log?.error?.("VIDEO", `${provider} uc-video (direct) transport error: ${errorText}`); + return { success: false, status: 502, error: errorText }; + } + + if (!resp.ok) { + const detail = (await resp.text().catch(() => "")).slice(0, 500); + log?.error?.("VIDEO", `${provider} uc-video (direct) error ${resp.status}: ${detail}`); + return { + success: false, + status: resp.status, + error: detail || `UC direct video generation failed (HTTP ${resp.status})`, + // 429 = rate limit (retry another account/later). 402 funds / 403 moderation + // are non-retryable per the REST error contract. + ...(resp.status === 429 ? { retryable: true } : {}), + }; + } + + let json: unknown; + try { + json = await resp.json(); + } catch { + return { success: false, status: 502, error: "UC direct returned a non-JSON video response" }; + } + + let extracted = extractUcDirectVideo(json); + if (isDirectFailed(extracted.status)) { + return { + success: false, + status: 502, + error: `UC direct video job failed (status: ${extracted.status})`, + }; + } + + // Already complete (sync-ish response carrying a url). + if (isDirectComplete(extracted.status, extracted.url) && extracted.url) { + return buildDirectSuccess(extracted.url, extracted.requestId, extracted.status); + } + + // No status_url to poll -> callback-only job: return the job id so the caller + // can reconcile via its own callback. + if (!extracted.statusUrl) { + if (extracted.requestId) { + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [ + { + request_id: extracted.requestId, + status: extracted.status || "pending", + format: "mp4", + }, + ], + }, + }; + } + return { + success: false, + status: 502, + error: "UC direct video job returned no url, status_url, or job id", + }; + } + + // Poll status_url until complete or timeout. + const statusUrl = extracted.statusUrl; + const timeoutMs = normalizePositiveNumber(body.timeout_ms, UC_POLL_TIMEOUT_MS_DEFAULT); + const pollIntervalMs = normalizePositiveNumber( + body.poll_interval_ms, + UC_POLL_INTERVAL_MS_DEFAULT + ); + const deadline = Date.now() + timeoutMs; + let attempt = 0; + do { + attempt += 1; + let statusResp: Response; + try { + statusResp = await fetchImpl(statusUrl, { + method: "GET", + headers: { "X-api-key": apiKey }, + signal, + }); + } catch (err) { + return { + success: false, + status: 502, + error: sanitizeErrorMessage(err instanceof Error ? err.message : String(err)), + }; + } + if (!statusResp.ok) { + return { + success: false, + status: statusResp.status, + error: `UC direct status poll failed (HTTP ${statusResp.status})`, + ...(statusResp.status === 429 ? { retryable: true } : {}), + }; + } + let statusJson: unknown; + try { + statusJson = await statusResp.json(); + } catch { + return { + success: false, + status: 502, + error: "UC direct status poll returned a non-JSON response", + }; + } + extracted = extractUcDirectVideo(statusJson); + if (isDirectFailed(extracted.status)) { + return { + success: false, + status: 502, + error: `UC direct video job failed (status: ${extracted.status})`, + }; + } + if (isDirectComplete(extracted.status, extracted.url) && extracted.url) { + return buildDirectSuccess(extracted.url, extracted.requestId, extracted.status); + } + log?.info?.("VIDEO", `uc-video (direct) job pending, poll #${attempt} in ${pollIntervalMs}ms`); + if (Date.now() + pollIntervalMs >= deadline) break; + await sleepImpl(pollIntervalMs); + } while (Date.now() < deadline); + + return { + success: false, + status: 504, + error: "UC direct video generation timed out waiting for a result", + }; +} + +function buildDirectSuccess(url: string, requestId?: string, status?: string): UcVideoResult { + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [ + { + url, + format: "mp4", + ...(requestId ? { request_id: requestId } : {}), + ...(status ? { status } : {}), + }, + ], + }, + }; +} + +/** + * UC video generation entrypoint. Picks the surface by credential: + * a `uai_...` X-api-key routes to the metered REST path; otherwise the persona + * web path (mint -> upload/generate -> poll) is used. + */ +export async function handleUcVideoGeneration({ + model, + provider = "uc", + body, + credentials, + prompt: promptArg, + log, + signal, + fetchImpl = fetch, + sleepImpl = realSleep, +}: UcVideoHandlerArgs): Promise { + const prompt = + typeof promptArg === "string" && promptArg.trim() + ? promptArg.trim() + : typeof body.prompt === "string" + ? body.prompt.trim() + : ""; + if (!prompt) { + return { success: false, status: 400, error: "Prompt is required for UC video generation" }; + } + + if (isUcDirectVideoCredential(credentials)) { + return handleUcDirectVideo({ + model, + provider, + body, + credentials, + prompt, + log, + signal, + fetchImpl, + sleepImpl, + }); + } + return handleUcPersonaVideo({ + model, + provider, + body, + credentials, + prompt, + log, + signal, + fetchImpl, + sleepImpl, + }); +} diff --git a/package.json b/package.json index cbb09d1ed8..41c9a125b2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniroute", "version": "3.8.51", - "description": "Unified AI router with 353 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", + "description": "Unified AI router with 355 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { "omniroute": "bin/omniroute.mjs", diff --git a/public/images/tier-flow-dark.svg b/public/images/tier-flow-dark.svg index dfb3bf59b9..1cf2589812 100644 --- a/public/images/tier-flow-dark.svg +++ b/public/images/tier-flow-dark.svg @@ -1,6 +1,6 @@ - + OmniRoute 4-tier fallback - OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 353 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. + OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 355 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. @@ -15,7 +15,7 @@ OmniRoute 4-tier fallback - Never stop building — automatic zero-config failover across 353 providers + Never stop building — automatic zero-config failover across 355 providers diff --git a/public/images/tier-flow-light.svg b/public/images/tier-flow-light.svg index fb90ef785a..cd79d47e3b 100644 --- a/public/images/tier-flow-light.svg +++ b/public/images/tier-flow-light.svg @@ -1,6 +1,6 @@ - + OmniRoute 4-tier fallback - OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 353 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. + OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 355 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. @@ -15,7 +15,7 @@ OmniRoute 4-tier fallback - Never stop building — automatic zero-config failover across 353 providers + Never stop building — automatic zero-config failover across 355 providers diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 5eef528865..d0ca97812a 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -249,6 +249,10 @@ const EXPLICIT_OPTIONAL_APIKEY_PROVIDER_IDS = new Set([ "gitlawb", "gitlawb-gmi", "naga-ac", + // UC (uncensored.com) persona: un-metered subscription chat with NO API key — + // auth is a durable Clerk credential stored in providerSpecificData, from which + // the executor mints a short-lived session token per connect. + "uc", ]); export function providerAllowsOptionalApiKey(providerId: unknown): boolean { diff --git a/src/shared/constants/providers/apikey/frontier-labs.ts b/src/shared/constants/providers/apikey/frontier-labs.ts index d689714d3f..71609ef544 100644 --- a/src/shared/constants/providers/apikey/frontier-labs.ts +++ b/src/shared/constants/providers/apikey/frontier-labs.ts @@ -46,6 +46,20 @@ export const APIKEY_PROVIDERS_FRONTIER = { freeNote: "$75 free usage credits — no credit card required", serviceKinds: ["llm"], }, + "uc-direct": { + id: "uc-direct", + alias: "ucd", + name: "UC Direct (uncensored.com)", + icon: "auto_awesome", + color: "#111827", + textIcon: "UD", + website: "https://uncensored.com", + authHint: + "Use your uncensored.com Developer API key (uai_sk_live_...). OmniRoute sends it as the X-api-key header to the OpenAI-compatible https://api.uncensored.com/api/v1 endpoint. The key never expires. This is the metered/credits surface; the un-metered subscription chat is the separate 'uc' provider.", + apiHint: + "UC Direct is OpenAI-compatible on /api/v1. OmniRoute probes /api/v1/models (public) and routes chat traffic to /api/v1/chat/completions. Errors: 402 out of credits, 403 moderation/scope, 429 rate limit.", + serviceKinds: ["llm"], + }, anthropic: { id: "anthropic", alias: "anthropic", diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index da3f16ee55..88b42217ee 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -498,6 +498,25 @@ export const WEB_COOKIE_PROVIDERS = { authHint: "Sign in once (email code or browser) to mint a MaxAI access token. OmniRoute signs each request, routes it through residential egress, and refreshes the token browserlessly, so a connection stays valid for about a year without re-login.", }, + uc: { + id: "uc", + serviceKinds: ["llm"], + alias: "ucn", + name: "UC (uncensored.com)", + icon: "auto_awesome", + color: "#111827", + textIcon: "UC", + website: "https://uncensored.com", + // No subscriptionRisk / riskNoticeVariant / notice: UC is TOKEN-authenticated + // — a durable Clerk credential from which OmniRoute mints a fresh short-lived + // session token per request, browserlessly. It is not a fragile browser-cookie + // session, so the "webCookie" caveat is inaccurate. The un-metered subscription + // session renews automatically within its window; only the periodic re-login + // (email code) needs an operator, and the authHint covers that. + toolCalling: "emulated", + authHint: + "Sign in once with an email code to bootstrap a UC (uncensored.com) subscription session. OmniRoute mints a fresh short-lived token per request browserlessly, so the connection renews on its own; you only re-run the email login about once a month when the subscription session rolls over.", + }, }; /** Resolved public site for a web-session provider (href + display host). */ diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 2388df5143..5ede7e9f55 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -348,6 +348,28 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { "maxaiDeviceId", "userId", "maxaiUserId", + uc: { + // UC (uncensored.com) persona: auth is the durable Clerk `__client` cookie + // (a JWT with no exp) plus the session id + user id, all stored in + // providerSpecificData. The executor mints a short-lived `__session` JWT per + // connect from `__client`; it never reads `apiKey`. Storage keys mirror the + // aliases resolveUcCredential() accepts (ucClientCookie/clientCookie/__client, + // ucSid/sid, ucUid/uid, ucCookies/cookies). + kind: "cookie", + credentialName: "Clerk __client cookie + session id + user id", + placeholder: "__client=...; then set session id (sid) and user id (uid)", + acceptsFullCookieHeader: true, + storageKeys: [ + "cookie", + "cookies", + "ucCookies", + "ucClientCookie", + "clientCookie", + "__client", + "ucSid", + "sid", + "ucUid", + "uid", ], }, } satisfies Record & diff --git a/tests/snapshots/executors/executor-map.json b/tests/snapshots/executors/executor-map.json index 005d1de4cc..23eedb21a9 100644 --- a/tests/snapshots/executors/executor-map.json +++ b/tests/snapshots/executors/executor-map.json @@ -570,6 +570,11 @@ "configSource": "trae", "provider": "trae" }, + "uc": { + "className": "UcExecutor", + "configSource": "uc", + "provider": "uc" + }, "v0": { "className": "V0VercelWebExecutor", "configSource": "", @@ -671,6 +676,6 @@ "provider": "zai-web" } }, - "keyCount": 134, + "keyCount": 135, "sharedInstances": [] } diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index b97ac70d40..903a210a92 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -5728,6 +5728,52 @@ "stream": "https://api.opentyphoon.ai/v1/chat/completions" } }, + "uc": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "https://internal-6.pubyar.com", + "stream": "https://internal-6.pubyar.com" + } + }, + "uc-direct": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Content-Type": "application/json", + "x-api-key": "" + }, + "nonStream": { + "Content-Type": "application/json", + "x-api-key": "" + }, + "oauth": { + "Accept": "text/event-stream", + "Content-Type": "application/json", + "x-api-key": "" + } + }, + "url": { + "nonStream": "https://api.uncensored.com/api/v1", + "stream": "https://api.uncensored.com/api/v1" + } + }, "udio": { "format": "openai", "headers": { diff --git a/tests/unit/provider-node-reserved-prefix.test.ts b/tests/unit/provider-node-reserved-prefix.test.ts index 8c7b57e83e..22d0955c5e 100644 --- a/tests/unit/provider-node-reserved-prefix.test.ts +++ b/tests/unit/provider-node-reserved-prefix.test.ts @@ -171,7 +171,9 @@ test("shared set size includes live REGISTRY and retired Designer + Felo + Qwen // 1 and adds 2 distinct tombstones "qwen-web"/"qw", a net +1) on top of the // live REGISTRY walk, minus the 3 GPL-derived Raycast/Hailuo Web // ids/aliases removed from REGISTRY by #11691's migration 166. - assert.equal(RESERVED_PREFIX_COUNT, 402); + // #11513: the two UC providers add four REGISTRY prefixes — the persona id "uc" + + // alias "ucn", and the Developer API id "uc-direct" + alias "ucd" (402 → 406). + assert.equal(RESERVED_PREFIX_COUNT, 406); }); test("isReservedProviderPrefix rejects non-string input", () => { diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts index 8956267f86..d6196816ca 100644 --- a/tests/unit/providers-constants-split.test.ts +++ b/tests/unit/providers-constants-split.test.ts @@ -31,7 +31,8 @@ // Kilo Gateway (gateways); #11434 adds volcengine-agent-plan and // volcengine-coding-plan (regional family) — both land at 233. // release/v3.8.51 adds Opper (gateways, #11629) and 1min.ai (gateways, #11631) — lands at 235; -// Perplexity Agent API (#12103) makes it 236. +// Perplexity Agent API (#12103) makes it 236; +// UC Direct (#11513, uncensored.com metered Developer API) adds one frontier-labs entry — 237. import { test } from "node:test"; import assert from "node:assert/strict"; @@ -60,12 +61,12 @@ test("barrel still exports every catalog + key helpers", () => { } }); -test("APIKEY_PROVIDERS merges the 6 family files into 236 entries (no loss / no dup)", async () => { +test("APIKEY_PROVIDERS merges the 6 family files into 237 entries (no loss / no dup)", async () => { const keys = Object.keys((P as Record).APIKEY_PROVIDERS); - assert.equal(keys.length, 236); - assert.equal(new Set(keys).size, 236, "duplicate keys after spread-merge"); + assert.equal(keys.length, 237); + assert.equal(new Set(keys).size, 237, "duplicate keys after spread-merge"); // the merged object's entry-count equals the sum of the 6 semantic family files; families are a - // strict partition (every provider in exactly one), so the sum must be exactly 236. + // strict partition (every provider in exactly one), so the sum must be exactly 237. const families: [string, string][] = [ ["gateways", "APIKEY_PROVIDERS_GATEWAYS"], ["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"], @@ -85,7 +86,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 236 entries (no loss / no seen.add(k); } } - assert.equal(famTotal, 236, "families must partition all 236 providers"); + assert.equal(famTotal, 237, "families must partition all 237 providers"); }); test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => { diff --git a/tests/unit/uc-capabilities.test.ts b/tests/unit/uc-capabilities.test.ts new file mode 100644 index 0000000000..e2213fcaaf --- /dev/null +++ b/tests/unit/uc-capabilities.test.ts @@ -0,0 +1,264 @@ +/** + * Unit tests for the UC (uncensored.com) capability additions beyond text+tools: + * • the tool-dialect layer (code-style + Gemini parsing, refusal + * detection) for guardrailed persona models, + * • the persona input-media blob-upload layer (vision + doc), and + * • the vision catalog flags. + * All hermetic — mocked fetch, no live network. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; + +import { + ucUsesCodestyle, + ucLooksLikeRefusal, + parseCodestyleCalls, + parseToolcodeCalls, + parseUcExtraDialects, + UC_CODESTYLE_MODELS, +} from "../../open-sse/executors/uc/toolDialect.ts"; +import { + extractCurrentTurnMedia, + uploadUcBlob, + uploadUcTurnMedia, +} from "../../open-sse/executors/uc/media.ts"; +import { buildPersonaFrame } from "../../open-sse/executors/uc/protocol.ts"; +import { UC_MODELS, UC_REGISTRY_MODELS } from "../../open-sse/executors/uc/catalog.ts"; + +// ─── Tool dialect ──────────────────────────────────────────────────────────── + +const WEATHER_TOOL = [ + { + type: "function", + function: { + name: "get_weather", + description: "weather", + parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, + }, + }, +]; + +test("ucUsesCodestyle is true only for the guardrailed model set", () => { + assert.ok(ucUsesCodestyle("gpt-5.5")); + assert.ok(!ucUsesCodestyle("claude-opus-46")); + assert.ok(UC_CODESTYLE_MODELS.has("gpt-5.5")); +}); + +test("parseCodestyleCalls parses positional and keyword python-style calls", () => { + const pos = parseCodestyleCalls('get_weather("Paris")', WEATHER_TOOL); + assert.equal(pos.length, 1); + assert.equal(pos[0].function.name, "get_weather"); + assert.deepEqual(JSON.parse(pos[0].function.arguments), { city: "Paris" }); + + const kw = parseCodestyleCalls('get_weather(city="Lisbon")', WEATHER_TOOL); + assert.deepEqual(JSON.parse(kw[0].function.arguments), { city: "Lisbon" }); +}); + +test("parseCodestyleCalls only fires on DECLARED tool names (no prose false-positive)", () => { + // A sentence that looks like a call but isn't a declared tool → ignored. + assert.equal(parseCodestyleCalls("I think about this (deeply)", WEATHER_TOOL).length, 0); + assert.equal(parseCodestyleCalls('unknown_fn("x")', WEATHER_TOOL).length, 0); +}); + +test("parseToolcodeCalls parses the Gemini print(mod.fn(..)) dialect", () => { + const calls = parseToolcodeCalls( + `\nprint(hermes_tools.get_weather(city='Berlin'))\n`, + WEATHER_TOOL + ); + assert.equal(calls.length, 1); + assert.equal(calls[0].function.name, "get_weather"); // module prefix stripped + assert.deepEqual(JSON.parse(calls[0].function.arguments), { city: "Berlin" }); +}); + +test("parseUcExtraDialects prefers code-style for code-style models, else falls back", () => { + // gpt-5.5 (code-style): the fn("x") form parses. + assert.equal(parseUcExtraDialects('get_weather("Rome")', WEATHER_TOOL, "gpt-5.5").length, 1); + // default model: code-style still works as a universal fallback. + assert.equal( + parseUcExtraDialects('get_weather("Rome")', WEATHER_TOOL, "claude-opus-46").length, + 1 + ); + // Gemini dialect works too. + assert.equal( + parseUcExtraDialects( + "print(get_weather(city='X'))", + WEATHER_TOOL, + "gemini-emotional" + ).length, + 1 + ); +}); + +test("ucLooksLikeRefusal flags a short guardrail refusal but not a long real answer", () => { + assert.ok(ucLooksLikeRefusal("I'm sorry, but I cannot assist with that.")); + assert.ok(!ucLooksLikeRefusal("x".repeat(500) + " i cannot assist with that")); + assert.ok(!ucLooksLikeRefusal("Here is a helpful answer about the weather in Paris.")); +}); + +// ─── Media input (vision + doc blob-upload) ────────────────────────────────── + +const PNG_DATA_URL = "data:image/png;base64," + Buffer.from("fakepngbytes").toString("base64"); +const PDF_DATA_URL = + "data:application/pdf;base64," + Buffer.from("%PDF-1.4 fake").toString("base64"); + +test("extractCurrentTurnMedia pulls data-url images and remote image urls from the last user turn", () => { + const { inline, remoteImageUrls } = extractCurrentTurnMedia([ + { role: "user", content: [{ type: "image_url", image_url: { url: "https://ex.com/a.png" } }] }, + { role: "assistant", content: "ok" }, + { + role: "user", + content: [ + { type: "text", text: "what is this?" }, + { type: "image_url", image_url: { url: PNG_DATA_URL } }, + ], + }, + ]); + // only the CURRENT (last) user turn's media + assert.equal(inline.length, 1); + assert.equal(inline[0].contentType, "image/png"); + assert.equal(remoteImageUrls.length, 0); +}); + +test("extractCurrentTurnMedia decodes OpenAI file, input_file, and Claude document parts", () => { + const openaiFile = extractCurrentTurnMedia([ + { + role: "user", + content: [{ type: "file", file: { filename: "report.pdf", file_data: PDF_DATA_URL } }], + }, + ]); + assert.equal(openaiFile.inline[0].contentType, "application/pdf"); + + const claudeDoc = extractCurrentTurnMedia([ + { + role: "user", + content: [ + { + type: "document", + source: { + type: "base64", + media_type: "application/pdf", + data: Buffer.from("x").toString("base64"), + }, + }, + ], + }, + ]); + assert.equal(claudeDoc.inline[0].contentType, "application/pdf"); +}); + +test("extractCurrentTurnMedia returns empty for a plain text turn", () => { + const { inline } = extractCurrentTurnMedia([{ role: "user", content: "hello" }]); + assert.equal(inline.length, 0); +}); + +test("uploadUcBlob runs the signed-url → PUT → ready flow and returns the blob descriptor", async () => { + const calls: string[] = []; + const fakeFetch = (async (url: string, init?: RequestInit) => { + const u = String(url); + calls.push(`${init?.method ?? "GET"} ${u}`); + if (u.includes("/generate-signed-url")) { + return new Response( + JSON.stringify({ signed_url: "https://d.moveinwater.com/up/tok", blob_name: "blob_123" }), + { status: 200 } + ); + } + if (u.includes("/up/tok")) return new Response("", { status: 200 }); // PUT + if (u.includes("/blob_123")) return new Response("", { status: 200 }); // ready HEAD + return new Response("", { status: 404 }); + }) as unknown as typeof fetch; + + const blob = await uploadUcBlob( + { bytes: Buffer.from("img"), contentType: "image/png" }, + { jwt: "jwt", uid: "uid-1", fetchImpl: fakeFetch } + ); + assert.ok(blob); + assert.equal(blob!.blobName, "blob_123"); + assert.equal(blob!.contentType, "image/png"); + // The signed-url POST carried the Bearer + content_type; the PUT sent the bytes. + assert.ok(calls.some((c) => c.startsWith("POST") && c.includes("/generate-signed-url"))); + assert.ok(calls.some((c) => c.startsWith("PUT") && c.includes("/up/tok"))); +}); + +test("uploadUcBlob returns null (best-effort) on a signed-url failure", async () => { + const fakeFetch = (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch; + const blob = await uploadUcBlob( + { bytes: Buffer.from("x"), contentType: "image/png" }, + { jwt: "j", uid: "u", fetchImpl: fakeFetch } + ); + assert.equal(blob, null); +}); + +test("uploadUcTurnMedia uploads several files and skips failures", async () => { + let n = 0; + const fakeFetch = (async (url: string) => { + const u = String(url); + if (u.includes("/generate-signed-url")) { + n++; + // first file succeeds, second fails at signed-url + if (n === 1) { + return new Response( + JSON.stringify({ signed_url: "https://d.moveinwater.com/up/t1", blob_name: "b1" }), + { + status: 200, + } + ); + } + return new Response("", { status: 500 }); + } + return new Response("", { status: 200 }); + }) as unknown as typeof fetch; + + const blobs = await uploadUcTurnMedia( + [ + { bytes: Buffer.from("a"), contentType: "image/png" }, + { bytes: Buffer.from("b"), contentType: "application/pdf" }, + ], + { jwt: "j", uid: "u", fetchImpl: fakeFetch } + ); + assert.equal(blobs.length, 1); + assert.equal(blobs[0].blobName, "b1"); +}); + +test("buildPersonaFrame carries a media blob when provided (and stays clean without one)", () => { + const withMedia = buildPersonaFrame({ + model: "claude-opus-46", + text: "hi", + history: [], + uid: "uid", + media: [{ blobName: "blob_9", contentType: "image/png" }], + }); + assert.equal(withMedia.media_blob_name, "blob_9"); + assert.equal(withMedia.media_content_type, "image/png"); + + const noMedia = buildPersonaFrame({ + model: "claude-opus-46", + text: "hi", + history: [], + uid: "uid", + }); + assert.equal(noMedia.media_blob_name, ""); + assert.equal(noMedia.media_content_type, ""); +}); + +// ─── Vision catalog flags ──────────────────────────────────────────────────── + +test("catalog flags the vision-capable persona models (and not the text-only ones)", () => { + const visionCount = UC_MODELS.filter((m) => m.supportsVision).length; + assert.equal(visionCount, 15); + const byId = new Map(UC_MODELS.map((m) => [m.id, m])); + assert.ok(byId.get("claude-opus-46")?.supportsVision); + assert.ok(byId.get("grok-4-3")?.supportsVision); + assert.ok(byId.get("kimi-k2.5")?.supportsVision); + // text-only models must NOT be flagged + assert.ok(!byId.get("deepseek-r1")?.supportsVision); + assert.ok(!byId.get("glm-5.1")?.supportsVision); + assert.ok(!byId.get("minimax-m2-her")?.supportsVision); +}); + +test("UC_REGISTRY_MODELS surfaces supportsVision so /v1/models advertises it", () => { + const claude = UC_REGISTRY_MODELS.find((m) => m.id === "claude-opus-46"); + assert.ok(claude?.supportsVision); + const deepseek = UC_REGISTRY_MODELS.find((m) => m.id === "deepseek-r1"); + assert.ok(!deepseek?.supportsVision); +}); diff --git a/tests/unit/uc-image.test.ts b/tests/unit/uc-image.test.ts new file mode 100644 index 0000000000..1b75832468 --- /dev/null +++ b/tests/unit/uc-image.test.ts @@ -0,0 +1,361 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { + resolveUcImageModel, + ucAspectToSize, + extractUcDirectImages, + handleUcImageGeneration, + UC_PERSONA_IMAGE_URL, + UC_DIRECT_IMAGE_URL, +} from "../../open-sse/handlers/imageGeneration/providers/ucImage.ts"; +import { IMAGE_PROVIDERS } from "../../open-sse/config/imageRegistry.ts"; + +// A valid PERSONA credential (durable Clerk cookie + sid + uid in psd). No API +// key, so the handler takes the persona web path (mint -> POST -> poll). +const PERSONA_CRED = { + providerSpecificData: { + ucClientCookie: "clientcookie-abc", + ucSid: "sess_123", + ucUid: "b03dd963-d0c1-4193-99c9-f5a9d0c66b7f", + ucCookies: { __client: "clientcookie-abc", __cf_bm: "cf" }, + }, +}; + +// A valid uc-direct metered credential (X-api-key). Presence of a uai_ key +// routes to the REST OpenAI-compatible path. +const DIRECT_CRED = { apiKey: "uai_sk_live_deadbeef" }; + +// A 60s Clerk JWT with a `uid` claim, exp far in the future (so the mint succeeds +// and expiry decoding is happy). header.payload.sig; only payload matters here. +function fakeJwt(uid: string, expEpoch: number): string { + const b64 = (o: unknown) => + Buffer.from(JSON.stringify(o)) + .toString("base64") + .replace(/=+$/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + return `${b64({ alg: "RS256" })}.${b64({ uid, exp: expEpoch, sub: "user_1", sid: "sess_123" })}.sig`; +} + +const FUTURE_EXP = Math.floor(Date.now() / 1000) + 60; + +// --- Registry ------------------------------------------------------------ + +test("uc is registered in IMAGE_PROVIDERS with the uc-image format + 22 models", () => { + const entry = ( + IMAGE_PROVIDERS as Record + )["uc"]; + assert.ok(entry, "uc must exist in IMAGE_PROVIDERS"); + assert.equal(entry.format, "uc-image"); + assert.match(String(entry.baseUrl), /internal\.chatuncensored\.ai\/v2\/image-gen/); + assert.equal((entry.models ?? []).length, 22); +}); + +// --- Pure helpers -------------------------------------------------------- + +test("resolveUcImageModel strips uc/ and uc-direct/ prefixes", () => { + assert.equal(resolveUcImageModel("uc/seedream-v4.5"), "seedream-v4.5"); + assert.equal(resolveUcImageModel("uc-direct/seedream-v5"), "seedream-v5"); + assert.equal(resolveUcImageModel("nano-banana-pro"), "nano-banana-pro"); + assert.equal(resolveUcImageModel(undefined), ""); +}); + +test("ucAspectToSize maps explicit aspect ratios to string width/height", () => { + assert.deepEqual(ucAspectToSize("1:1"), { + aspect_ratio: "1:1", + imageWidth: "1024", + imageHeight: "1024", + }); + assert.deepEqual(ucAspectToSize("16:9"), { + aspect_ratio: "16:9", + imageWidth: "1024", + imageHeight: "576", + }); + assert.deepEqual(ucAspectToSize("9:16"), { + aspect_ratio: "9:16", + imageWidth: "576", + imageHeight: "1024", + }); + assert.deepEqual(ucAspectToSize("4:3"), { + aspect_ratio: "4:3", + imageWidth: "1024", + imageHeight: "768", + }); + assert.deepEqual(ucAspectToSize("3:4"), { + aspect_ratio: "3:4", + imageWidth: "768", + imageHeight: "1024", + }); +}); + +test("ucAspectToSize snaps OpenAI WxH sizes to the nearest aspect bucket", () => { + // Square -> 1:1 + assert.equal(ucAspectToSize("512x512").aspect_ratio, "1:1"); + // Wide -> 16:9 + assert.equal(ucAspectToSize("1920x1080").aspect_ratio, "16:9"); + // Tall -> 9:16 + assert.equal(ucAspectToSize("1080x1920").aspect_ratio, "9:16"); + // Landscape-ish 4:3 + assert.equal(ucAspectToSize("800x600").aspect_ratio, "4:3"); + // Unknown / absent -> default 1:1 + assert.equal(ucAspectToSize(undefined).aspect_ratio, "1:1"); + assert.equal(ucAspectToSize("garbage").aspect_ratio, "1:1"); +}); + +test("extractUcDirectImages pulls url and b64_json items", () => { + assert.deepEqual( + extractUcDirectImages({ created: 1, data: [{ url: "https://x/a.png" }, { b64_json: "AAAA" }] }), + [{ url: "https://x/a.png" }, { b64_json: "AAAA" }] + ); + assert.deepEqual(extractUcDirectImages({ data: [] }), []); + assert.deepEqual(extractUcDirectImages(null), []); +}); + +// --- Persona handler (mocked mint -> POST -> poll) ----------------------- + +// Builds a fetch that mints a JWT, accepts the image-gen POST (returns the +// pending result URL), then serves the result URL as 403 (pending) N times +// before finally 200. Records the calls so we can assert on them. +function personaFetch(opts: { + pendingPolls: number; + resultUrl: string; + jwt: string; + onImagePost?: (body: Record, headers: Record) => void; +}): typeof fetch { + let pollsSeen = 0; + return (async (url: string, init: RequestInit = {}) => { + // 1) Clerk mint + if (url.includes("clerk.uncensored.com")) { + return { + ok: true, + status: 200, + headers: { get: () => "" }, + async text() { + return JSON.stringify({ object: "token", jwt: opts.jwt }); + }, + } as unknown as Response; + } + // 2) image-gen POST + if (url === UC_PERSONA_IMAGE_URL) { + opts.onImagePost?.(JSON.parse(String(init.body)), init.headers as Record); + return { + ok: true, + status: 200, + async json() { + return { status: "pending", url: opts.resultUrl, request_id: "req_1" }; + }, + async text() { + return ""; + }, + } as unknown as Response; + } + // 3) result URL polling + if (url === opts.resultUrl) { + pollsSeen += 1; + const ready = pollsSeen > opts.pendingPolls; + return { + ok: ready, + status: ready ? 200 : 403, + async text() { + return ""; + }, + } as unknown as Response; + } + throw new Error(`unexpected fetch to ${url}`); + }) as unknown as typeof fetch; +} + +const noSleep = async () => {}; + +test("handleUcImageGeneration (persona) mints, posts, polls to 200, returns the url", async () => { + const resultUrl = "https://gen.moveinwater.com/img_uid_uuid.png"; + let postedBody: Record = {}; + let postedHeaders: Record = {}; + const fetchImpl = personaFetch({ + pendingPolls: 2, // 403, 403, then 200 + resultUrl, + jwt: fakeJwt("b03dd963-d0c1-4193-99c9-f5a9d0c66b7f", FUTURE_EXP), + onImagePost: (b, h) => { + postedBody = b; + postedHeaders = h; + }, + }); + + const result = (await handleUcImageGeneration({ + model: "uc/seedream-v4.5", + provider: "uc", + body: { prompt: "a red cube on a wooden table", aspect_ratio: "16:9" }, + credentials: PERSONA_CRED, + fetchImpl, + sleepImpl: noSleep, + })) as { success: boolean; data?: { data: Array<{ url: string }> } }; + + assert.equal(result.success, true); + assert.deepEqual(result.data?.data, [{ url: resultUrl }]); + // The image-gen POST carried the spec-shaped web body. + assert.equal(postedBody.model_version, "seedream-v4.5"); + assert.equal(postedBody.mode, "dev"); + assert.equal(postedBody.m_n_user, true); + assert.equal(postedBody.moderationMode, "SUPER_LIGHT"); + assert.equal(postedBody.aspect_ratio, "16:9"); + assert.equal(postedBody.imageWidth, "1024"); + assert.equal(postedBody.imageHeight, "576"); + assert.equal(postedBody.country, "US"); + assert.equal(postedBody.vdiscount, false); + // Auth + origin headers were attached. + assert.match(String(postedHeaders.Authorization), /^Bearer /); + assert.equal(postedHeaders.Origin, "https://uncensored.com"); +}); + +test("handleUcImageGeneration (persona) 401s (retryable) when the credential is missing", async () => { + const result = (await handleUcImageGeneration({ + model: "uc/seedream-v4.5", + provider: "uc", + body: { prompt: "x" }, + credentials: {}, // no psd, no api key + fetchImpl: (async () => { + throw new Error("should not fetch"); + }) as unknown as typeof fetch, + sleepImpl: noSleep, + })) as { success: boolean; status?: number; retryable?: boolean }; + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.equal(result.retryable, true); +}); + +test("handleUcImageGeneration (persona) times out with 504 when the result never readies", async () => { + const resultUrl = "https://gen.moveinwater.com/img_never.png"; + const fetchImpl = personaFetch({ + pendingPolls: 1000, // never becomes ready within the window + resultUrl, + jwt: fakeJwt("uid", FUTURE_EXP), + }); + const result = (await handleUcImageGeneration({ + model: "uc/seedream-v5", + provider: "uc", + body: { prompt: "x", timeout_ms: 5, poll_interval_ms: 1 }, + credentials: PERSONA_CRED, + fetchImpl, + sleepImpl: noSleep, + })) as { success: boolean; status?: number }; + assert.equal(result.success, false); + assert.equal(result.status, 504); +}); + +test("handleUcImageGeneration (persona) surfaces a Clerk mint failure", async () => { + const fetchImpl = (async (url: string) => { + if (url.includes("clerk.uncensored.com")) { + return { + ok: false, + status: 401, + headers: { get: () => "" }, + async text() { + return "unauthorized"; + }, + } as unknown as Response; + } + throw new Error("should not reach image-gen"); + }) as unknown as typeof fetch; + + const result = (await handleUcImageGeneration({ + model: "uc/seedream-v4.5", + provider: "uc", + body: { prompt: "x" }, + credentials: PERSONA_CRED, + fetchImpl, + sleepImpl: noSleep, + })) as { success: boolean; status?: number; retryable?: boolean }; + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.equal(result.retryable, true); +}); + +// --- Direct REST handler (mocked fetch) ---------------------------------- + +test("handleUcImageGeneration (direct) returns OpenAI image data on success", async () => { + let capturedUrl = ""; + let capturedBody: Record = {}; + let capturedHeaders: Record = {}; + const fetchImpl = (async (url: string, init: RequestInit) => { + capturedUrl = url; + capturedBody = JSON.parse(String(init.body)); + capturedHeaders = init.headers as Record; + return { + ok: true, + status: 200, + async json() { + return { created: 123, data: [{ url: "https://cdn/x.png" }] }; + }, + async text() { + return ""; + }, + } as unknown as Response; + }) as unknown as typeof fetch; + + const result = (await handleUcImageGeneration({ + model: "uc-direct/seedream-v5", + provider: "uc", + body: { prompt: "a blue sphere", size: "1024x1024", n: 2 }, + credentials: DIRECT_CRED, + fetchImpl, + })) as { success: boolean; data?: { created: number; data: Array<{ url: string }> } }; + + assert.equal(result.success, true); + assert.deepEqual(result.data?.data, [{ url: "https://cdn/x.png" }]); + assert.equal(result.data?.created, 123); + assert.equal(capturedUrl, UC_DIRECT_IMAGE_URL); + assert.equal(capturedBody.model, "seedream-v5"); + assert.equal(capturedBody.n, 2); + assert.equal(capturedBody.size, "1024x1024"); + // X-api-key auth (exact casing), no Bearer. + assert.equal(capturedHeaders["X-api-key"], "uai_sk_live_deadbeef"); +}); + +test("handleUcImageGeneration (direct) 429 is retryable, 402/403 are not", async () => { + function directErr(status: number) { + return (async () => + ({ + ok: false, + status, + async text() { + return "err"; + }, + }) as unknown as Response) as unknown as typeof fetch; + } + + const rate = (await handleUcImageGeneration({ + model: "uc-direct/seedream-v5", + provider: "uc", + body: { prompt: "x" }, + credentials: DIRECT_CRED, + fetchImpl: directErr(429), + })) as { success: boolean; status?: number; retryable?: boolean }; + assert.equal(rate.success, false); + assert.equal(rate.status, 429); + assert.equal(rate.retryable, true); + + const funds = (await handleUcImageGeneration({ + model: "uc-direct/seedream-v5", + provider: "uc", + body: { prompt: "x" }, + credentials: DIRECT_CRED, + fetchImpl: directErr(402), + })) as { success: boolean; status?: number; retryable?: boolean }; + assert.equal(funds.success, false); + assert.equal(funds.status, 402); + assert.equal(funds.retryable, undefined); +}); + +test("handleUcImageGeneration rejects an empty prompt with 400 (both surfaces)", async () => { + const result = (await handleUcImageGeneration({ + model: "uc/seedream-v4.5", + provider: "uc", + body: { prompt: " " }, + credentials: DIRECT_CRED, + fetchImpl: (async () => { + throw new Error("should not fetch"); + }) as unknown as typeof fetch, + })) as { success: boolean; status?: number }; + assert.equal(result.success, false); + assert.equal(result.status, 400); +}); diff --git a/tests/unit/uc-tts.test.ts b/tests/unit/uc-tts.test.ts new file mode 100644 index 0000000000..da9e343eb5 --- /dev/null +++ b/tests/unit/uc-tts.test.ts @@ -0,0 +1,253 @@ +/** + * Unit tests for the UC (uncensored.com) TEXT-TO-SPEECH handler. + * + * UC TTS is a WebSocket web-app port: a 60s Clerk `__session` JWT (minted from a + * durable `__client` cookie) authenticates a dedicated voice socket, one `start` + * frame carries the text + voice, and the server streams base64-encoded MP3 + * chunks in `{data:'...'}` frames (plus `usage_update` quota frames) until it + * closes. These tests exercise the pure frame builder + the full handler path + * with a MOCKED WebSocket and a mocked token-mint `fetch` (no live network). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildUcTtsStartFrame, + buildUcTtsWsUrl, + handleUcTextToSpeech, + runUcTtsSocket, + __setUcTtsWebSocketForTesting, +} from "../../open-sse/handlers/uc/ucTts.ts"; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const UID = "b03dd963-d0c1-4193-99c9-f5a9d0c66b7f"; +const SID = "sess_3EyqBpAa2C25iB8eJzZ2fwdsqLM"; + +/** Build a fake unsigned JWT with the given claims (base64url payload). */ +function fakeJwt(claims: Record): string { + const b64 = (o: unknown) => + Buffer.from(JSON.stringify(o)) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `${b64({ alg: "RS256", typ: "JWT" })}.${b64(claims)}.sig`; +} + +function psd(extra: Record = {}): Record { + return { + ucClientCookie: "client.jwt.cookie", + ucSid: SID, + ucUid: UID, + ucCookies: { __client: "client.jwt.cookie", __cf_bm: "cf", _cfuvid: "uv" }, + ...extra, + }; +} + +/** Mint-token fetch stub so mintUcSessionToken succeeds. */ +function tokenFetch(): typeof fetch { + const jwt = fakeJwt({ uid: UID, sid: SID, exp: Math.floor(Date.now() / 1000) + 60 }); + return (async () => + new Response(JSON.stringify({ object: "token", jwt }), { + status: 200, + })) as unknown as typeof fetch; +} + +/** A failing mint fetch (401) to exercise the auth error path. */ +function failingTokenFetch(status = 401): typeof fetch { + return (async () => + new Response(JSON.stringify({ errors: [{ message: "invalid" }] }), { + status, + })) as unknown as typeof fetch; +} + +/** base64 of a tiny MP3-ish payload (ID3 header + bytes). */ +function b64(bytes: number[]): string { + return Buffer.from(Uint8Array.from(bytes)).toString("base64"); +} + +/** + * A minimal fake WebSocket matching the `ws` surface the driver uses: onopen / + * onmessage / onerror / onclose + send/close. On `send` it replays a scripted set + * of server frames (each an already-JSON-stringified string) then closes. + */ +function makeFakeWs(frames: string[], opts: { failConnect?: boolean } = {}) { + return class FakeWS { + onopen: (() => void) | null = null; + onmessage: ((e: { data: unknown }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + readyState = 1; + constructor(_url: string, _opts?: unknown) { + if (opts.failConnect) { + setTimeout(() => this.onerror?.(), 0); + return; + } + setTimeout(() => this.onopen?.(), 0); + } + send(_data: string) { + setTimeout(() => { + for (const f of frames) this.onmessage?.({ data: f }); + this.onclose?.(); + }, 0); + } + close() { + /* no-op */ + } + } as unknown as typeof import("ws").default; +} + +// ─── buildUcTtsStartFrame / buildUcTtsWsUrl ────────────────────────────────── + +test("buildUcTtsStartFrame carries the text, voice, and jwt with fresh uuids", () => { + const jwt = fakeJwt({ uid: UID, sid: SID }); + const frame = buildUcTtsStartFrame({ text: "hello world", voice: "jade", jwt }); + assert.equal(frame.message_type, "start"); + assert.equal(frame.text, "hello world"); + assert.equal(frame.raw_text, "hello world"); + assert.equal(frame.voice, "jade"); + assert.equal(frame.model, "default"); + assert.equal(frame.token, jwt); + // thread_id and threadId must be the same uuid. + assert.equal(frame.thread_id, frame.threadId); + assert.match(frame.message_id, /^[0-9a-f-]{36}$/); + assert.notEqual(frame.message_id, frame.turn_anchor_message_id); +}); + +test("buildUcTtsWsUrl targets the tts-stream host with token in query", () => { + const url = buildUcTtsWsUrl(UID, "the.jwt.here"); + assert.match(url, /^wss:\/\/tts-stream\.chatuncensored\.ai\//); + assert.ok(url.includes(encodeURIComponent(UID))); + assert.ok(url.includes("token=the.jwt.here")); +}); + +// ─── runUcTtsSocket with a MOCKED WebSocket ────────────────────────────────── + +test("runUcTtsSocket accumulates + decodes base64 MP3 data frames", async (t) => { + // usage_update (ignored for audio) + 2 base64 MP3 chunks, then close. + const restore = __setUcTtsWebSocketForTesting( + makeFakeWs([ + JSON.stringify({ type: "usage_update", usage_percent: 13, threshold_crossed: 10 }), + JSON.stringify({ data: b64([0x49, 0x44, 0x33]) }), // "ID3" + JSON.stringify({ data: b64([0x04, 0x00, 0xff]) }), + ]) + ); + t.after(restore); + + const result = await runUcTtsSocket({ jwt: "jwt", uid: UID, text: "hi", voice: "jade" }); + assert.equal(result.error, undefined); + assert.equal(result.usagePercent, 13); + assert.deepEqual(Array.from(result.audio), [0x49, 0x44, 0x33, 0x04, 0x00, 0xff]); +}); + +test("runUcTtsSocket surfaces an error when the socket produces no audio", async (t) => { + const restore = __setUcTtsWebSocketForTesting( + makeFakeWs([JSON.stringify({ type: "usage_update", usage_percent: 5, threshold_crossed: 0 })]) + ); + t.after(restore); + + const result = await runUcTtsSocket({ jwt: "jwt", uid: UID, text: "hi", voice: "jade" }); + assert.equal(result.audio.length, 0); + assert.match(result.error ?? "", /no audio/i); +}); + +test("runUcTtsSocket resolves with an error on a connect failure", async (t) => { + const restore = __setUcTtsWebSocketForTesting(makeFakeWs([], { failConnect: true })); + t.after(restore); + + const result = await runUcTtsSocket({ jwt: "jwt", uid: UID, text: "hi", voice: "jade" }); + assert.equal(result.audio.length, 0); + assert.ok(result.error); +}); + +// ─── handleUcTextToSpeech full path (mint + socket) ────────────────────────── + +test("handleUcTextToSpeech mints a token then returns decoded MP3 bytes", async (t) => { + const restore = __setUcTtsWebSocketForTesting( + makeFakeWs([ + JSON.stringify({ type: "usage_update", usage_percent: 20, threshold_crossed: 10 }), + JSON.stringify({ data: b64([0x49, 0x44, 0x33, 0x01]) }), + JSON.stringify({ data: b64([0x02, 0x03]) }), + ]) + ); + t.after(restore); + + const result = await handleUcTextToSpeech({ + text: "read this aloud", + voice: "jade", + credentials: { providerSpecificData: psd() }, + fetchImpl: tokenFetch(), + }); + + assert.equal(result.ok, true); + assert.equal(result.status, 200); + assert.equal(result.contentType, "audio/mpeg"); + assert.ok(result.audio); + assert.deepEqual(Array.from(result.audio as Uint8Array), [0x49, 0x44, 0x33, 0x01, 0x02, 0x03]); +}); + +test("handleUcTextToSpeech defaults an empty voice to jade", async (t) => { + let sentFrame: Record | null = null; + const FakeWS = class { + onopen: (() => void) | null = null; + onmessage: ((e: { data: unknown }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + readyState = 1; + constructor(_url: string, _opts?: unknown) { + setTimeout(() => this.onopen?.(), 0); + } + send(data: string) { + sentFrame = JSON.parse(data) as Record; + setTimeout(() => { + this.onmessage?.({ data: JSON.stringify({ data: b64([0x49, 0x44, 0x33]) }) }); + this.onclose?.(); + }, 0); + } + close() { + /* no-op */ + } + } as unknown as typeof import("ws").default; + const restore = __setUcTtsWebSocketForTesting(FakeWS); + t.after(restore); + + const result = await handleUcTextToSpeech({ + text: "hi", + voice: " ", + credentials: { providerSpecificData: psd() }, + fetchImpl: tokenFetch(), + }); + assert.equal(result.ok, true); + assert.equal((sentFrame as unknown as { voice?: string } | null)?.voice, "jade"); +}); + +test("handleUcTextToSpeech rejects an empty input", async () => { + const result = await handleUcTextToSpeech({ + text: " ", + credentials: { providerSpecificData: psd() }, + fetchImpl: tokenFetch(), + }); + assert.equal(result.ok, false); + assert.equal(result.status, 400); +}); + +test("handleUcTextToSpeech returns 401 when no UC credential is configured", async () => { + const result = await handleUcTextToSpeech({ + text: "hi", + credentials: { providerSpecificData: {} }, + fetchImpl: tokenFetch(), + }); + assert.equal(result.ok, false); + assert.equal(result.status, 401); +}); + +test("handleUcTextToSpeech maps a Clerk 401 mint failure to 401", async () => { + const result = await handleUcTextToSpeech({ + text: "hi", + credentials: { providerSpecificData: psd() }, + fetchImpl: failingTokenFetch(401), + }); + assert.equal(result.ok, false); + assert.equal(result.status, 401); +}); diff --git a/tests/unit/uc-video.test.ts b/tests/unit/uc-video.test.ts new file mode 100644 index 0000000000..af204f2ffc --- /dev/null +++ b/tests/unit/uc-video.test.ts @@ -0,0 +1,543 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { + resolveUcVideoModel, + isUcDirectVideoCredential, + resolveUcInputImage, + buildUcPersonaVideoBody, + extractUcDirectVideo, + handleUcVideoGeneration, + UC_PERSONA_SIGNED_URL, + UC_PERSONA_IMAGE_TO_VIDEO_URL, + UC_PERSONA_TEXT_TO_VIDEO_URL, + UC_DIRECT_VIDEO_URL, +} from "../../open-sse/handlers/videoGeneration/providers/ucVideo.ts"; +import { VIDEO_PROVIDERS } from "../../open-sse/config/videoRegistry.ts"; + +// A valid PERSONA credential (durable Clerk cookie + sid + uid in psd). No API +// key, so the handler takes the persona web path (mint -> generate -> poll). +const PERSONA_CRED = { + providerSpecificData: { + ucClientCookie: "clientcookie-abc", + ucSid: "sess_123", + ucUid: "b03dd963-d0c1-4193-99c9-f5a9d0c66b7f", + ucCookies: { __client: "clientcookie-abc", __cf_bm: "cf" }, + }, +}; + +// A valid uc-direct metered credential (X-api-key). Presence of a uai_ key +// routes to the REST OpenAI-compatible path. +const DIRECT_CRED = { apiKey: "uai_sk_live_deadbeef" }; + +// A 60s Clerk JWT with a `uid` claim, exp far in the future. +function fakeJwt(uid: string, expEpoch: number): string { + const b64 = (o: unknown) => + Buffer.from(JSON.stringify(o)) + .toString("base64") + .replace(/=+$/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + return `${b64({ alg: "RS256" })}.${b64({ uid, exp: expEpoch, sub: "user_1", sid: "sess_123" })}.sig`; +} + +const FUTURE_EXP = Math.floor(Date.now() / 1000) + 60; +const noSleep = async () => {}; + +// --- Registry ------------------------------------------------------------ + +test("uc is registered in VIDEO_PROVIDERS with the uc-video format", () => { + const entry = ( + VIDEO_PROVIDERS as Record + )["uc"]; + assert.ok(entry, "uc must exist in VIDEO_PROVIDERS"); + assert.equal(entry.format, "uc-video"); + assert.match(String(entry.baseUrl), /chatuncensored\.ai/); + assert.ok((entry.models ?? []).some((m) => (m as { id?: string }).id === "wan-2.2-spicy")); + assert.ok((entry.models ?? []).some((m) => (m as { id?: string }).id === "seedance-2.0")); +}); + +// --- Pure helpers -------------------------------------------------------- + +test("resolveUcVideoModel strips uc/ and uc-direct/ prefixes and defaults", () => { + assert.equal(resolveUcVideoModel("uc/wan-2.2-spicy"), "wan-2.2-spicy"); + assert.equal(resolveUcVideoModel("uc-direct/t2v-turbo"), "t2v-turbo"); + assert.equal(resolveUcVideoModel("seedance-2.0"), "seedance-2.0"); + // Empty / absent -> persona default. + assert.equal(resolveUcVideoModel(undefined), "wan-2.2-spicy"); + assert.equal(resolveUcVideoModel("uc/"), "wan-2.2-spicy"); +}); + +test("isUcDirectVideoCredential is true only for uai_ keys", () => { + assert.equal(isUcDirectVideoCredential({ apiKey: "uai_sk_live_x" }), true); + assert.equal(isUcDirectVideoCredential({ apiKey: "sk-other" }), false); + assert.equal(isUcDirectVideoCredential({}), false); +}); + +test("resolveUcInputImage picks the first image-ish field, else null", () => { + assert.equal(resolveUcInputImage({ image: "https://x/a.png" }), "https://x/a.png"); + assert.equal( + resolveUcInputImage({ image_url: "data:image/png;base64,AAA" }), + "data:image/png;base64,AAA" + ); + assert.equal(resolveUcInputImage({ input_image: "b64payload" }), "b64payload"); + assert.equal(resolveUcInputImage({ prompt: "x" }), null); +}); + +test("buildUcPersonaVideoBody carries capture-confirmed defaults + blob name", () => { + const b = buildUcPersonaVideoBody("a logo", "wan-2.2-spicy", {}, "blob_1"); + assert.equal(b.prompt, "a logo"); + assert.equal(b.media_blob_name, "blob_1"); + assert.equal(b.num_frames, 81); + assert.equal(b.frames_per_second, 16); + assert.equal(b.num_inference_steps, 30); + assert.equal(b.guide_scale, 5); + assert.equal(b.shift, 5); + assert.equal(b.aspect_ratio, "auto"); + assert.equal(b.pro_mode, false); + assert.equal(b.turbo, false); + assert.equal(b.resolution, "480p"); + assert.equal(b.sora_resolution, "480p"); + assert.equal(b.end_frame_blob_name, null); + assert.equal(b.model, "wan-2.2-spicy"); + assert.equal(b.seconds, 5); + assert.equal(b.video_to_video_duration, 5); + assert.equal(b.vdiscount, false); + // text-to-video: null blob. + assert.equal(buildUcPersonaVideoBody("x", "wan-2.2-spicy", {}, null).media_blob_name, null); +}); + +test("extractUcDirectVideo tolerates several async shapes", () => { + assert.deepEqual( + extractUcDirectVideo({ data: [{ url: "https://cdn/v.mp4" }] }).url, + "https://cdn/v.mp4" + ); + assert.deepEqual(extractUcDirectVideo({ url: "https://cdn/top.mp4" }).url, "https://cdn/top.mp4"); + assert.deepEqual( + extractUcDirectVideo({ video: { url: "https://cdn/nested.mp4" } }).url, + "https://cdn/nested.mp4" + ); + const job = extractUcDirectVideo({ + status: "pending", + status_url: "https://api/s/1", + id: "job_1", + }); + assert.equal(job.status, "pending"); + assert.equal(job.statusUrl, "https://api/s/1"); + assert.equal(job.requestId, "job_1"); + assert.deepEqual(extractUcDirectVideo(null), {}); +}); + +// --- Persona text-to-video (mint -> generate -> HEAD poll) ---------------- + +// Builds a fetch that mints a JWT, accepts the generate POST (returns the +// pre-determined result URL), then serves the result URL as 403 (pending) N +// times before finally 200. Records calls so we can assert on them. +function personaFetch(opts: { + pendingPolls: number; + resultUrl: string; + jwt: string; + expectSigned?: boolean; + onGenerate?: ( + url: string, + body: Record, + headers: Record + ) => void; + onSigned?: (body: Record) => void; + onPut?: (url: string, init: RequestInit) => void; +}): typeof fetch { + let pollsSeen = 0; + return (async (url: string, init: RequestInit = {}) => { + // Clerk mint + if (url.includes("clerk.uncensored.com")) { + return { + ok: true, + status: 200, + headers: { get: () => "" }, + async text() { + return JSON.stringify({ object: "token", jwt: opts.jwt }); + }, + } as unknown as Response; + } + // signed-url POST + if (url === UC_PERSONA_SIGNED_URL) { + opts.onSigned?.(JSON.parse(String(init.body))); + return { + ok: true, + status: 200, + async json() { + return { signed_url: "https://d.moveinwater.com/up/tok", blob_name: "blob_xyz" }; + }, + async text() { + return ""; + }, + } as unknown as Response; + } + // PUT upload to signed URL + if (url.startsWith("https://d.moveinwater.com/up/")) { + opts.onPut?.(url, init); + return { + ok: true, + status: 200, + async text() { + return ""; + }, + } as unknown as Response; + } + // generate POST (text_to_video or image_to_video) + if (url === UC_PERSONA_TEXT_TO_VIDEO_URL || url === UC_PERSONA_IMAGE_TO_VIDEO_URL) { + opts.onGenerate?.(url, JSON.parse(String(init.body)), init.headers as Record); + return { + ok: true, + status: 200, + async json() { + return { + request_id: "req_v1", + message: "Request in progress", + thumbnail_url: "https://d.moveinwater.com/thumb", + url: opts.resultUrl, + eta_seconds: 43, + timeout_seconds: 267, + }; + }, + async text() { + return ""; + }, + } as unknown as Response; + } + // result URL HEAD polling + if (url === opts.resultUrl) { + pollsSeen += 1; + const ready = pollsSeen > opts.pendingPolls; + return { + ok: ready, + status: ready ? 200 : 403, + async text() { + return ""; + }, + } as unknown as Response; + } + throw new Error(`unexpected fetch to ${url}`); + }) as unknown as typeof fetch; +} + +test("handleUcVideoGeneration (persona t2v) mints, posts text_to_video, polls to 200", async () => { + const resultUrl = "https://videogen.moveinwater.com/uid_ts_uuid"; + let genUrl = ""; + let genBody: Record = {}; + let genHeaders: Record = {}; + const fetchImpl = personaFetch({ + pendingPolls: 2, // 403, 403, then 200 + resultUrl, + jwt: fakeJwt("b03dd963-d0c1-4193-99c9-f5a9d0c66b7f", FUTURE_EXP), + onGenerate: (u, b, h) => { + genUrl = u; + genBody = b; + genHeaders = h; + }, + }); + + const result = (await handleUcVideoGeneration({ + model: "uc/wan-2.2-spicy", + provider: "uc", + body: { prompt: "generate an animated logo", poll_interval_ms: 1 }, + credentials: PERSONA_CRED, + fetchImpl, + sleepImpl: noSleep, + })) as { success: boolean; data?: { data: Array<{ url: string; format: string }> } }; + + assert.equal(result.success, true); + assert.equal(result.data?.data[0].url, resultUrl); + assert.equal(result.data?.data[0].format, "mp4"); + // Took the text_to_video path (no input image). + assert.equal(genUrl, UC_PERSONA_TEXT_TO_VIDEO_URL); + assert.equal(genBody.model, "wan-2.2-spicy"); + assert.equal(genBody.media_blob_name, null); + assert.equal(genBody.num_frames, 81); + assert.match(String(genHeaders.Authorization), /^Bearer /); + assert.equal(genHeaders.Origin, "https://uncensored.com"); +}); + +test("handleUcVideoGeneration (persona i2v) uploads then posts image_to_video", async () => { + const resultUrl = "https://videogen.moveinwater.com/uid_ts_i2v"; + let signedBody: Record = {}; + let putSeen = false; + let genUrl = ""; + let genBody: Record = {}; + const fetchImpl = personaFetch({ + pendingPolls: 1, + resultUrl, + jwt: fakeJwt("b03dd963-d0c1-4193-99c9-f5a9d0c66b7f", FUTURE_EXP), + onSigned: (b) => { + signedBody = b; + }, + onPut: () => { + putSeen = true; + }, + onGenerate: (u, b) => { + genUrl = u; + genBody = b; + }, + }); + + const result = (await handleUcVideoGeneration({ + model: "uc/wan-2.2-spicy", + provider: "uc", + body: { + prompt: "animate this", + image: "data:image/png;base64,iVBORw0KGgo=", + poll_interval_ms: 1, + }, + credentials: PERSONA_CRED, + fetchImpl, + sleepImpl: noSleep, + })) as { success: boolean; data?: { data: Array<{ url: string }> } }; + + assert.equal(result.success, true); + assert.equal(result.data?.data[0].url, resultUrl); + // 3-step flow ran: signed-url carried the uid, PUT happened, generate used the blob. + assert.equal(signedBody.user_identifier, "b03dd963-d0c1-4193-99c9-f5a9d0c66b7f"); + assert.equal(signedBody.content_type, "image/png"); + assert.equal(putSeen, true); + assert.equal(genUrl, UC_PERSONA_IMAGE_TO_VIDEO_URL); + assert.equal(genBody.media_blob_name, "blob_xyz"); +}); + +test("handleUcVideoGeneration (persona) 401s (retryable) when credential missing", async () => { + const result = (await handleUcVideoGeneration({ + model: "uc/wan-2.2-spicy", + provider: "uc", + body: { prompt: "x" }, + credentials: {}, // no psd, no api key + fetchImpl: (async () => { + throw new Error("should not fetch"); + }) as unknown as typeof fetch, + sleepImpl: noSleep, + })) as { success: boolean; status?: number; retryable?: boolean }; + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.equal(result.retryable, true); +}); + +test("handleUcVideoGeneration (persona) times out with 504 when never ready", async () => { + const resultUrl = "https://videogen.moveinwater.com/never"; + const fetchImpl = personaFetch({ + pendingPolls: 1000, + resultUrl, + jwt: fakeJwt("uid", FUTURE_EXP), + }); + const result = (await handleUcVideoGeneration({ + model: "uc/wan-2.2-spicy", + provider: "uc", + body: { prompt: "x", timeout_ms: 5, poll_interval_ms: 1 }, + credentials: PERSONA_CRED, + fetchImpl, + sleepImpl: noSleep, + })) as { success: boolean; status?: number }; + assert.equal(result.success, false); + assert.equal(result.status, 504); +}); + +test("handleUcVideoGeneration (persona) surfaces a Clerk mint failure", async () => { + const fetchImpl = (async (url: string) => { + if (url.includes("clerk.uncensored.com")) { + return { + ok: false, + status: 401, + headers: { get: () => "" }, + async text() { + return "unauthorized"; + }, + } as unknown as Response; + } + throw new Error("should not reach generate"); + }) as unknown as typeof fetch; + + const result = (await handleUcVideoGeneration({ + model: "uc/wan-2.2-spicy", + provider: "uc", + body: { prompt: "x" }, + credentials: PERSONA_CRED, + fetchImpl, + sleepImpl: noSleep, + })) as { success: boolean; status?: number; retryable?: boolean }; + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.equal(result.retryable, true); +}); + +// --- Direct REST handler (mocked fetch) ---------------------------------- + +test("handleUcVideoGeneration (direct) returns the url when the submit is complete", async () => { + let capturedUrl = ""; + let capturedBody: Record = {}; + let capturedHeaders: Record = {}; + const fetchImpl = (async (url: string, init: RequestInit) => { + capturedUrl = url; + capturedBody = JSON.parse(String(init.body)); + capturedHeaders = init.headers as Record; + return { + ok: true, + status: 200, + async json() { + return { status: "completed", data: [{ url: "https://cdn/v.mp4" }] }; + }, + async text() { + return ""; + }, + } as unknown as Response; + }) as unknown as typeof fetch; + + const result = (await handleUcVideoGeneration({ + model: "uc-direct/seedance-2.0", + provider: "uc", + body: { prompt: "a blue sphere spinning", resolution: "480p", duration: 5 }, + credentials: DIRECT_CRED, + fetchImpl, + sleepImpl: noSleep, + })) as { success: boolean; data?: { data: Array<{ url: string }> } }; + + assert.equal(result.success, true); + assert.equal(result.data?.data[0].url, "https://cdn/v.mp4"); + assert.equal(capturedUrl, UC_DIRECT_VIDEO_URL); + assert.equal(capturedBody.model, "seedance-2.0"); + assert.equal(capturedBody.resolution, "480p"); + assert.equal(capturedBody.duration, 5); + // X-api-key auth (exact casing), no Bearer. + assert.equal(capturedHeaders["X-api-key"], "uai_sk_live_deadbeef"); +}); + +test("handleUcVideoGeneration (direct) polls status_url until complete", async () => { + let submits = 0; + let statusPolls = 0; + const fetchImpl = (async (url: string, init: RequestInit = {}) => { + if (url === UC_DIRECT_VIDEO_URL) { + submits += 1; + return { + ok: true, + status: 200, + async json() { + return { + status: "pending", + status_url: "https://api.uncensored.com/api/v1/videos/status/1", + id: "job_1", + }; + }, + async text() { + return ""; + }, + } as unknown as Response; + } + if (url === "https://api.uncensored.com/api/v1/videos/status/1") { + // Status poll carries the X-api-key too. + assert.equal((init.headers as Record)["X-api-key"], "uai_sk_live_deadbeef"); + statusPolls += 1; + const done = statusPolls >= 2; + return { + ok: true, + status: 200, + async json() { + return done + ? { status: "completed", url: "https://cdn/done.mp4" } + : { status: "processing" }; + }, + async text() { + return ""; + }, + } as unknown as Response; + } + throw new Error(`unexpected fetch to ${url}`); + }) as unknown as typeof fetch; + + const result = (await handleUcVideoGeneration({ + model: "uc-direct/t2v-standard", + provider: "uc", + body: { prompt: "x", poll_interval_ms: 1, timeout_ms: 60000 }, + credentials: DIRECT_CRED, + fetchImpl, + sleepImpl: noSleep, + })) as { success: boolean; data?: { data: Array<{ url: string }> } }; + + assert.equal(result.success, true); + assert.equal(result.data?.data[0].url, "https://cdn/done.mp4"); + assert.equal(submits, 1); + assert.equal(statusPolls, 2); +}); + +test("handleUcVideoGeneration (direct) returns a job id when callback-only", async () => { + const fetchImpl = (async () => + ({ + ok: true, + status: 200, + async json() { + return { status: "queued", id: "job_async_7" }; + }, + async text() { + return ""; + }, + }) as unknown as Response) as unknown as typeof fetch; + + const result = (await handleUcVideoGeneration({ + model: "uc-direct/i2v-pro", + provider: "uc", + body: { prompt: "x" }, + credentials: DIRECT_CRED, + fetchImpl, + sleepImpl: noSleep, + })) as { success: boolean; data?: { data: Array<{ request_id?: string; status?: string }> } }; + + assert.equal(result.success, true); + assert.equal(result.data?.data[0].request_id, "job_async_7"); + assert.equal(result.data?.data[0].status, "queued"); +}); + +test("handleUcVideoGeneration (direct) 429 retryable, 402/403 not", async () => { + function directErr(status: number) { + return (async () => + ({ + ok: false, + status, + async text() { + return "err"; + }, + }) as unknown as Response) as unknown as typeof fetch; + } + + const rate = (await handleUcVideoGeneration({ + model: "uc-direct/t2v-turbo", + provider: "uc", + body: { prompt: "x" }, + credentials: DIRECT_CRED, + fetchImpl: directErr(429), + sleepImpl: noSleep, + })) as { success: boolean; status?: number; retryable?: boolean }; + assert.equal(rate.success, false); + assert.equal(rate.status, 429); + assert.equal(rate.retryable, true); + + const funds = (await handleUcVideoGeneration({ + model: "uc-direct/t2v-turbo", + provider: "uc", + body: { prompt: "x" }, + credentials: DIRECT_CRED, + fetchImpl: directErr(402), + sleepImpl: noSleep, + })) as { success: boolean; status?: number; retryable?: boolean }; + assert.equal(funds.success, false); + assert.equal(funds.status, 402); + assert.equal(funds.retryable, undefined); +}); + +test("handleUcVideoGeneration rejects an empty prompt with 400 (both surfaces)", async () => { + const result = (await handleUcVideoGeneration({ + model: "uc/wan-2.2-spicy", + provider: "uc", + body: { prompt: " " }, + credentials: DIRECT_CRED, + fetchImpl: (async () => { + throw new Error("should not fetch"); + }) as unknown as typeof fetch, + sleepImpl: noSleep, + })) as { success: boolean; status?: number }; + assert.equal(result.success, false); + assert.equal(result.status, 400); +}); diff --git a/tests/unit/uc.test.ts b/tests/unit/uc.test.ts new file mode 100644 index 0000000000..4bbdd159ce --- /dev/null +++ b/tests/unit/uc.test.ts @@ -0,0 +1,731 @@ +/** + * Unit tests for the UC (uncensored.com) persona executor + helpers. + * + * UC persona is a WebSocket web-app port: a 60s Clerk `__session` JWT (minted + * from a durable `__client` cookie) authenticates the socket, one persona frame + * carries the current turn + chat_history, and newline-delimited frames stream + * back. These tests exercise the pure logic (credential resolution, JWT decode, + * token mint, browserless Clerk email login, persona frame + context assembly, + * frame parsing / error surfaces, soft-error detection) with a mocked `fetch`, + * and the full executor path with a mocked WebSocket (no network). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + resolveUcCredential, + uidFromSessionJwt, + sessionJwtExpiry, + normalizeCookieJar, + cookieHeader, +} from "../../open-sse/executors/uc/credentials.ts"; +import { + mintUcSessionToken, + parseSetCookie, + UcTokenCache, +} from "../../open-sse/executors/uc/clerkAuth.ts"; +import { + requestUcEmailCode, + verifyUcEmailCode, + UC_SIGNIN_PATH, +} from "../../open-sse/executors/uc/emailLogin.ts"; +import { + assembleUcTurn, + buildPersonaFrame, + ucContentToText, + UC_IDENTITY_STEER, +} from "../../open-sse/executors/uc/protocol.ts"; +import { + UcFrameParser, + detectUcSoftError, + estimateUcTokens, +} from "../../open-sse/executors/uc/stream.ts"; +import { UC_REGISTRY_MODELS, ucContextWindow } from "../../open-sse/executors/uc/catalog.ts"; +import { buildUcWsUrl, __setUcWebSocketForTesting } from "../../open-sse/executors/uc/ws.ts"; +import { UcExecutor } from "../../open-sse/executors/uc.ts"; +import { ucDirectProvider } from "../../open-sse/config/providers/registry/uc-direct/index.ts"; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const UID = "b03dd963-d0c1-4193-99c9-f5a9d0c66b7f"; +const SID = "sess_3EyqBpAa2C25iB8eJzZ2fwdsqLM"; + +/** Build a fake unsigned JWT with the given claims (base64url payload). */ +function fakeJwt(claims: Record): string { + const b64 = (o: unknown) => + Buffer.from(JSON.stringify(o)) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `${b64({ alg: "RS256", typ: "JWT" })}.${b64(claims)}.sig`; +} + +function psd(extra: Record = {}): Record { + return { + ucClientCookie: "client.jwt.cookie", + ucSid: SID, + ucUid: UID, + ucCookies: { __client: "client.jwt.cookie", __cf_bm: "cf", _cfuvid: "uv" }, + ...extra, + }; +} + +// ─── credentials.ts ────────────────────────────────────────────────────────── + +test("resolveUcCredential resolves the full credential from providerSpecificData", () => { + const cred = resolveUcCredential(psd()); + assert.ok(cred); + assert.equal(cred!.sid, SID); + assert.equal(cred!.uid, UID); + assert.equal(cred!.clientCookie, "client.jwt.cookie"); + assert.equal(cred!.cookies.__client, "client.jwt.cookie"); +}); + +test("resolveUcCredential returns null when the __client cookie is missing", () => { + assert.equal(resolveUcCredential({ ucSid: SID, ucUid: UID }), null); +}); + +test("resolveUcCredential returns null when the sid is missing", () => { + assert.equal(resolveUcCredential({ ucClientCookie: "c", ucUid: UID }), null); +}); + +test("resolveUcCredential folds __client into the jar when absent", () => { + const cred = resolveUcCredential({ + ucClientCookie: "durable", + ucSid: SID, + ucUid: UID, + ucCookies: { __cf_bm: "cf" }, + }); + assert.ok(cred); + assert.equal(cred!.cookies.__client, "durable"); +}); + +test("uidFromSessionJwt + sessionJwtExpiry decode the claims", () => { + const jwt = fakeJwt({ uid: UID, sid: SID, exp: 1787659826 }); + assert.equal(uidFromSessionJwt(jwt), UID); + assert.equal(sessionJwtExpiry(jwt), 1787659826); +}); + +test("uidFromSessionJwt returns null on garbage", () => { + assert.equal(uidFromSessionJwt("not.a.jwt"), null); + assert.equal(sessionJwtExpiry("not.a.jwt"), 0); +}); + +test("normalizeCookieJar handles both raw scalar and {value} shapes", () => { + const flat = normalizeCookieJar({ a: "1", b: { value: "2" }, junk: { nope: 1 } }); + assert.equal(flat.a, "1"); + assert.equal(flat.b, "2"); + assert.equal(flat.junk, undefined); +}); + +test("cookieHeader serializes a jar to a Cookie header value", () => { + assert.equal(cookieHeader({ a: "1", b: "2" }), "a=1; b=2"); +}); + +// ─── clerkAuth.ts ──────────────────────────────────────────────────────────── + +test("parseSetCookie extracts rotated cookies and skips attributes", () => { + const sc = "__cf_bm=NEWVAL; path=/; secure; HttpOnly, __client=DURABLE; SameSite=Lax"; + const got = parseSetCookie(sc); + assert.equal(got.__cf_bm, "NEWVAL"); + assert.equal(got.__client, "DURABLE"); + assert.equal(got.path, undefined); + assert.equal(got.secure, undefined); +}); + +test("mintUcSessionToken mints a 60s JWT from the cookie jar", async () => { + const jwt = fakeJwt({ uid: UID, sid: SID, exp: Math.floor(Date.now() / 1000) + 60 }); + let seenUrl = ""; + let seenInit: RequestInit = {}; + const fakeFetch = (async (url: string, init: RequestInit) => { + seenUrl = String(url); + seenInit = init; + return new Response(JSON.stringify({ object: "token", jwt }), { + status: 200, + headers: { "set-cookie": "__cf_bm=ROT; path=/" }, + }); + }) as unknown as typeof fetch; + + const r = await mintUcSessionToken({ + sid: SID, + cookies: { __client: "c" }, + fetchImpl: fakeFetch, + }); + assert.equal(r.ok, true); + assert.equal(r.token!.jwt, jwt); + assert.ok(r.token!.expiresAt > 0); + assert.equal(r.rotatedCookies!.__cf_bm, "ROT"); + // URL + headers are the exact Clerk mint contract. + assert.match(seenUrl, new RegExp(`/v1/client/sessions/${SID}/tokens`)); + const headers = seenInit.headers as Record; + assert.equal(headers.Origin, "https://uncensored.com"); + assert.match(headers.Cookie, /__client=c/); +}); + +test("mintUcSessionToken surfaces a 401 as a failure (durable login invalid)", async () => { + const fakeFetch = (async () => + new Response("unauthorized", { status: 401 })) as unknown as typeof fetch; + const r = await mintUcSessionToken({ + sid: SID, + cookies: { __client: "c" }, + fetchImpl: fakeFetch, + }); + assert.equal(r.ok, false); + assert.equal(r.status, 401); +}); + +test("mintUcSessionToken fails fast without a __client cookie", async () => { + const r = await mintUcSessionToken({ sid: SID, cookies: {} }); + assert.equal(r.ok, false); + assert.match(r.error ?? "", /__client/); +}); + +test("UcTokenCache returns a fresh token and re-mints within the skew window", () => { + const cache = new UcTokenCache(); + const now = () => 1_000_000; // fixed clock (seconds base handled internally) + // exp 20s out (> 8s skew): fresh + cache.set(SID, { jwt: "fresh", expiresAt: 1_000_000 / 1000 + 20 }); + assert.equal(cache.get(SID, now), "fresh"); + // exp 3s out (< 8s skew): needs re-mint + cache.set(SID, { jwt: "stale", expiresAt: 1_000_000 / 1000 + 3 }); + assert.equal(cache.get(SID, now), null); +}); + +// ─── emailLogin.ts (browserless Clerk 3-step) ──────────────────────────────── + +test("requestUcEmailCode creates the sign-in attempt and requests the code", async () => { + const calls: string[] = []; + const fakeFetch = (async (url: string) => { + const u = String(url); + calls.push(u); + if (u.includes("/prepare_first_factor")) { + return new Response(JSON.stringify({ response: { status: "needs_first_factor" } }), { + status: 200, + }); + } + // step 1: create sign-in + return new Response( + JSON.stringify({ + response: { + id: "sia_ABC", + status: "needs_first_factor", + supported_first_factors: [ + { strategy: "password" }, + { strategy: "email_code", email_address_id: "idn_XYZ", safe_identifier: "a@b.c" }, + ], + }, + }), + { status: 200, headers: { "set-cookie": "__client=SIGNIN; path=/" } } + ); + }) as unknown as typeof fetch; + + const r = await requestUcEmailCode({ email: "a@b.c", fetchImpl: fakeFetch }); + assert.equal(r.ok, true); + assert.equal(r.sia, "sia_ABC"); + assert.equal(r.emailAddressId, "idn_XYZ"); + // Both steps hit the sign-in path; the second is prepare_first_factor. + assert.equal(calls.length, 2); + assert.ok(calls[0].includes(UC_SIGNIN_PATH)); + assert.ok(calls[1].includes("/sia_ABC/prepare_first_factor")); +}); + +test("requestUcEmailCode errors when email_code is not an available factor", async () => { + const fakeFetch = (async () => + new Response( + JSON.stringify({ + response: { id: "sia_1", supported_first_factors: [{ strategy: "password" }] }, + }), + { status: 200 } + )) as unknown as typeof fetch; + const r = await requestUcEmailCode({ email: "a@b.c", fetchImpl: fakeFetch }); + assert.equal(r.ok, false); + assert.match(r.error ?? "", /email_code/); +}); + +test("verifyUcEmailCode harvests __client + sid + uid on complete", async () => { + const fakeFetch = (async () => + new Response( + JSON.stringify({ + response: { status: "complete", created_session_id: SID }, + client: { sessions: [{ id: SID, user: { id: UID } }] }, + }), + { + status: 200, + headers: { "set-cookie": "__client=DURABLE_COOKIE; path=/, __cf_bm=CF; path=/" }, + } + )) as unknown as typeof fetch; + + const r = await verifyUcEmailCode({ sia: "sia_ABC", code: "123456", fetchImpl: fakeFetch }); + assert.equal(r.ok, true); + assert.equal(r.credential!.clientCookie, "DURABLE_COOKIE"); + assert.equal(r.credential!.sid, SID); + assert.equal(r.credential!.uid, UID); + assert.equal(r.credential!.cookies.__cf_bm, "CF"); +}); + +test("verifyUcEmailCode fails when the sign-in is not complete", async () => { + const fakeFetch = (async () => + new Response(JSON.stringify({ response: { status: "needs_first_factor" } }), { + status: 200, + })) as unknown as typeof fetch; + const r = await verifyUcEmailCode({ sia: "sia_ABC", code: "000000", fetchImpl: fakeFetch }); + assert.equal(r.ok, false); + assert.match(r.error ?? "", /not complete/); +}); + +test("verifyUcEmailCode fails when no __client cookie is set", async () => { + const fakeFetch = (async () => + new Response( + JSON.stringify({ + response: { status: "complete", created_session_id: SID }, + client: { sessions: [{ id: SID, user: { id: UID } }] }, + }), + { status: 200 } // no Set-Cookie + )) as unknown as typeof fetch; + const r = await verifyUcEmailCode({ sia: "sia_ABC", code: "123456", fetchImpl: fakeFetch }); + assert.equal(r.ok, false); + assert.match(r.error ?? "", /__client/); +}); + +// ─── protocol.ts ───────────────────────────────────────────────────────────── + +test("ucContentToText flattens string and multipart content", () => { + assert.equal(ucContentToText("hi"), "hi"); + assert.equal( + ucContentToText([ + { type: "text", text: "a" }, + { type: "image_url", image_url: { url: "x" } }, + { type: "text", text: "b" }, + ]), + "a\nb" + ); +}); + +test("assembleUcTurn splits history at the last assistant and folds systems + steer", () => { + const { text, history } = assembleUcTurn([ + { role: "system", content: "be terse" }, + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + { role: "user", content: "what's 2+2?" }, + ]); + // history = everything up to & incl last assistant, roles mapped human/assistant + assert.deepEqual(history, [ + { role: "human", content: [{ type: "text", text: "hi" }] }, + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + ]); + // current turn is the trailing user; systems + identity steer are folded in. + assert.match(text, /be terse/); + assert.match(text, new RegExp(UC_IDENTITY_STEER.slice(0, 20))); + assert.match(text, /what's 2\+2\?/); + assert.match(text, /\n\n---\n\n/); +}); + +test("assembleUcTurn maps a trailing tool result into the active text", () => { + const { text, history } = assembleUcTurn([ + { role: "user", content: "weather?" }, + { role: "assistant", content: "calling tool" }, + { role: "tool", name: "get_weather", content: "sunny 20C" }, + ]); + assert.equal(history.length, 2); + assert.match(text, /get_weather tool already ran/); + assert.match(text, /sunny 20C/); +}); + +test("assembleUcTurn maps a tool result in HISTORY to a human turn", () => { + const { history } = assembleUcTurn([ + { role: "user", content: "q" }, + { role: "tool", content: "toolout" }, + { role: "assistant", content: "a" }, + { role: "user", content: "next" }, + ]); + assert.deepEqual(history[1], { + role: "human", + content: [{ type: "text", text: "[tool result] toolout" }], + }); +}); + +test("buildPersonaFrame emits the exact persona wire shape and NO max_tokens", () => { + const frame = buildPersonaFrame({ + model: "claude-opus-46", + text: "hi", + history: [{ role: "human", content: [{ type: "text", text: "prev" }] }], + uid: UID, + }); + assert.equal(frame.model, "claude-opus-46"); + assert.equal(frame.text, "hi"); + assert.equal(frame.chat_mode, "chat"); + assert.equal(frame.use_memory, false); + assert.equal(frame.user_identifier, UID); + assert.equal(frame.app_version, "1.0.0-web"); + assert.equal(frame.no_media_in_chat, true); + // The forbidden knobs must NOT be present (max_tokens aborts the persona turn). + assert.equal("max_tokens" in frame, false); + assert.equal("direct_params" in frame, false); + assert.equal("temperature" in frame, false); + // Fresh uuids present. + assert.match(String(frame.message_id), /[0-9a-f-]{36}/); +}); + +// ─── stream.ts ─────────────────────────────────────────────────────────────── + +test("UcFrameParser accumulates deltas and finishes on end_of_stream raw_text", () => { + const p = new UcFrameParser(); + const e1 = p.feed(JSON.stringify({ message_type: "status", status: "Thinking" })); + assert.deepEqual(e1, [{ kind: "status", text: "Thinking" }]); + const e2 = p.feed(JSON.stringify({ message_type: "text", text: "Hel" })); + assert.deepEqual(e2, [{ kind: "delta", text: "Hel" }]); + const e3 = p.feed( + JSON.stringify({ message_type: "text", text: "lo", end_of_stream: true, raw_text: "Hello!" }) + ); + assert.deepEqual(e3, [{ kind: "done", text: "Hello!" }]); + assert.equal(p.done, true); +}); + +test("UcFrameParser splits multiple newline-delimited frames in one payload", () => { + const p = new UcFrameParser(); + const raw = + JSON.stringify({ message_type: "intermediary_message", text: "reasoning" }) + + "\n" + + JSON.stringify({ message_type: "text", text: "answer" }); + const evts = p.feed(raw); + assert.deepEqual(evts, [ + { kind: "reasoning", text: "reasoning" }, + { kind: "delta", text: "answer" }, + ]); +}); + +test("UcFrameParser surfaces a top-level error frame immediately (incl paywall)", () => { + for (const code of ["message_limit_exceeded", "paywall_exceeded", "rate_limit_exceeded"]) { + const p = new UcFrameParser(); + const evts = p.feed( + JSON.stringify({ type: "error", code, message: "nope", next_reset: "2026-01-01" }) + ); + assert.equal(evts.length, 1); + assert.equal(evts[0].kind, "error"); + assert.match(evts[0].text, new RegExp(code)); + assert.equal(p.done, true); + } +}); + +test("UcFrameParser surfaces generation_failed as a retryable error", () => { + const p = new UcFrameParser(); + const evts = p.feed( + JSON.stringify({ message_type: "generation_failed", direct_mode_error: "boom" }) + ); + assert.equal(evts[0].kind, "error"); + assert.match(evts[0].text, /boom/); +}); + +test("UcFrameParser falls back to concatenated deltas when no raw_text", () => { + const p = new UcFrameParser(); + p.feed(JSON.stringify({ message_type: "text", text: "a" })); + p.feed(JSON.stringify({ message_type: "text", text: "b" })); + assert.equal(p.finalText(), "ab"); +}); + +test("detectUcSoftError flags a short capacity apology but not a long real answer", () => { + assert.ok( + detectUcSoftError("Server overloaded temporarily, please switch models and try again.") + ); + assert.equal(detectUcSoftError("x".repeat(400) + " server overloaded temporarily"), null); + assert.equal( + detectUcSoftError("Here is a normal answer about servers and load balancing."), + null + ); +}); + +test("estimateUcTokens is a positive ~4char/token estimate", () => { + assert.equal(estimateUcTokens(""), 0); + assert.equal(estimateUcTokens("abcd"), 1); + assert.ok(estimateUcTokens("a".repeat(40)) >= 10); +}); + +// ─── catalog.ts ────────────────────────────────────────────────────────────── + +test("UC catalog exposes the verified persona models, all tool-calling", () => { + assert.equal(UC_REGISTRY_MODELS.length, 19); + assert.ok(UC_REGISTRY_MODELS.every((m) => m.toolCalling === true)); + const ids = UC_REGISTRY_MODELS.map((m) => m.id); + assert.ok(ids.includes("claude-opus-46")); + assert.ok(ids.includes("claude-opus-48-uncensored")); + assert.ok(ids.includes("grok-4-3")); + // reasoning flags where expected + assert.ok(UC_REGISTRY_MODELS.find((m) => m.id === "deepseek-r1")?.supportsReasoning); +}); + +test("ucContextWindow returns per-model windows with a sane default", () => { + assert.equal(ucContextWindow("claude-opus-46"), 1_000_000); + assert.equal(ucContextWindow("grok-4-20"), 2_000_000); + assert.equal(ucContextWindow("nonexistent"), 128_000); +}); + +// ─── ws.ts URL construction ────────────────────────────────────────────────── + +test("buildUcWsUrl embeds uid, token, and a cache-bust", () => { + const url = buildUcWsUrl(UID, "JWT123"); + assert.match(url, new RegExp(`wss://internal-6\\.pubyar\\.com/ws/${UID}`)); + assert.match(url, /token=JWT123/); + assert.match(url, /_t=\d+/); +}); + +// ─── Executor path with a MOCKED WebSocket ─────────────────────────────────── + +/** + * A minimal fake WebSocket matching the `ws` surface the driver uses: onopen / + * onmessage / onerror / onclose + send/close. It replays a scripted set of + * server frames (newline-delimited JSON strings) right after `send` is called. + */ +function makeFakeWs(frames: string[], opts: { failConnect?: boolean } = {}) { + return class FakeWS { + onopen: (() => void) | null = null; + onmessage: ((e: { data: unknown }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + readyState = 1; + constructor(_url: string, _opts?: unknown) { + if (opts.failConnect) { + setTimeout(() => this.onerror?.(), 0); + return; + } + setTimeout(() => this.onopen?.(), 0); + } + send(_data: string) { + // Deliver scripted frames, then close. + setTimeout(() => { + for (const f of frames) this.onmessage?.({ data: f }); + this.onclose?.(); + }, 0); + } + close() { + /* no-op */ + } + } as unknown as typeof import("ws").default; +} + +/** Mint-token fetch stub so the executor's ensureSessionToken succeeds. */ +function tokenFetch(): typeof fetch { + const jwt = fakeJwt({ uid: UID, sid: SID, exp: Math.floor(Date.now() / 1000) + 60 }); + return (async () => + new Response(JSON.stringify({ object: "token", jwt }), { + status: 200, + })) as unknown as typeof fetch; +} + +// Loose completion shape for assertions (avoids `any` while allowing drilling). +interface LooseCompletion { + object?: string; + choices?: Array<{ + index?: number; + message?: { role?: string; content?: string; reasoning_content?: string; tool_calls?: unknown }; + finish_reason?: string; + }>; + usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }; + error?: { code?: string; message?: string; type?: string }; +} + +async function readJson(result: unknown): Promise<{ status: number; json: LooseCompletion }> { + const resp = (result as { response?: Response }).response ?? (result as Response); + const text = await resp.text(); + return { status: resp.status, json: text ? (JSON.parse(text) as LooseCompletion) : {} }; +} + +test("UcExecutor non-streaming returns an OpenAI chat.completion", async (t) => { + const origFetch = globalThis.fetch; + globalThis.fetch = tokenFetch(); + const restore = __setUcWebSocketForTesting( + makeFakeWs([JSON.stringify({ message_type: "text", end_of_stream: true, raw_text: "PONG" })]) + ); + t.after(() => { + restore(); + globalThis.fetch = origFetch; + }); + + const exec = new UcExecutor(); + const result = await exec.execute({ + model: "claude-opus-46", + stream: false, + credentials: { providerSpecificData: psd() }, + body: { messages: [{ role: "user", content: "ping" }] }, + } as never); + const { status, json } = await readJson(result); + assert.equal(status, 200); + assert.equal(json.object, "chat.completion"); + assert.equal(json.choices[0].message.content, "PONG"); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.ok(json.usage.total_tokens > 0); +}); + +test("UcExecutor streaming emits SSE chunks incl the raw_text remainder", async (t) => { + const origFetch = globalThis.fetch; + globalThis.fetch = tokenFetch(); + // Short answer arrives ONLY in raw_text (no deltas) — the remainder-flush must emit it. + const restore = __setUcWebSocketForTesting( + makeFakeWs([JSON.stringify({ message_type: "text", end_of_stream: true, raw_text: "READY" })]) + ); + t.after(() => { + restore(); + globalThis.fetch = origFetch; + }); + + const exec = new UcExecutor(); + const result = await exec.execute({ + model: "grok-4-3", + stream: true, + credentials: { providerSpecificData: psd() }, + body: { messages: [{ role: "user", content: "ping" }] }, + } as never); + const resp = (result as { response: Response }).response; + assert.equal(resp.status, 200); + const body = await resp.text(); + const assembled = body + .split("\n\n") + .filter((l) => l.startsWith("data:") && !l.includes("[DONE]")) + .map((c) => { + try { + return JSON.parse(c.slice(5).trim())?.choices?.[0]?.delta?.content ?? ""; + } catch { + return ""; + } + }) + .join(""); + assert.equal(assembled, "READY"); + assert.match(body, /data: \[DONE\]/); +}); + +test("UcExecutor streaming flushes long delta content without duplication", async (t) => { + const origFetch = globalThis.fetch; + globalThis.fetch = tokenFetch(); + const restore = __setUcWebSocketForTesting( + makeFakeWs([ + JSON.stringify({ message_type: "text", text: "Hel" }), + JSON.stringify({ message_type: "text", text: "lo" }), + JSON.stringify({ message_type: "text", end_of_stream: true, raw_text: "Hello" }), + ]) + ); + t.after(() => { + restore(); + globalThis.fetch = origFetch; + }); + + const exec = new UcExecutor(); + const result = await exec.execute({ + model: "claude-opus-46", + stream: true, + credentials: { providerSpecificData: psd() }, + body: { messages: [{ role: "user", content: "hi" }] }, + } as never); + const resp = (result as { response: Response }).response; + const body = await resp.text(); + const assembled = body + .split("\n\n") + .filter((l) => l.startsWith("data:") && !l.includes("[DONE]")) + .map((c) => { + try { + return JSON.parse(c.slice(5).trim())?.choices?.[0]?.delta?.content ?? ""; + } catch { + return ""; + } + }) + .join(""); + // Deltas streamed "Hello"; raw_text "Hello" adds no duplicate remainder. + assert.equal(assembled, "Hello"); +}); + +test("UcExecutor maps a paywall_exceeded frame to a 429", async (t) => { + const origFetch = globalThis.fetch; + globalThis.fetch = tokenFetch(); + const restore = __setUcWebSocketForTesting( + makeFakeWs([ + JSON.stringify({ + type: "error", + code: "paywall_exceeded", + message: "Paywall limit exceeded", + }), + ]) + ); + t.after(() => { + restore(); + globalThis.fetch = origFetch; + }); + + const exec = new UcExecutor(); + const result = await exec.execute({ + model: "claude-opus-46", + stream: false, + credentials: { providerSpecificData: psd() }, + body: { messages: [{ role: "user", content: "ping" }] }, + } as never); + const { status, json } = await readJson(result); + assert.equal(status, 429); + assert.equal(json.error.code, "uc_paywall_exceeded"); +}); + +test("UcExecutor returns 401 when the connection is unconfigured", async () => { + const exec = new UcExecutor(); + const result = await exec.execute({ + model: "claude-opus-46", + stream: false, + credentials: { providerSpecificData: {} }, + body: { messages: [{ role: "user", content: "ping" }] }, + } as never); + const { status, json } = await readJson(result); + assert.equal(status, 401); + assert.equal(json.error.code, "uc_unconfigured"); +}); + +test("UcExecutor returns the executor wrapper shape (response+url+headers+transformedBody)", async (t) => { + const origFetch = globalThis.fetch; + globalThis.fetch = tokenFetch(); + const restore = __setUcWebSocketForTesting( + makeFakeWs([JSON.stringify({ message_type: "text", end_of_stream: true, raw_text: "ok" })]) + ); + t.after(() => { + restore(); + globalThis.fetch = origFetch; + }); + + const exec = new UcExecutor(); + const result = (await exec.execute({ + model: "claude-opus-46", + stream: false, + credentials: { providerSpecificData: psd() }, + body: { messages: [{ role: "user", content: "ping" }] }, + } as never)) as { + response: Response; + url: string; + headers: Record; + transformedBody: unknown; + }; + assert.ok(result.response instanceof Response); + assert.equal(typeof result.url, "string"); + assert.ok(result.transformedBody); +}); + +// ─── uc-direct registry (metered OpenAI-compatible REST) ───────────────────── + +test("ucDirectProvider is a default-executor OpenAI provider with x-api-key auth", () => { + assert.equal(ucDirectProvider.id, "uc-direct"); + assert.equal(ucDirectProvider.alias, "ucd"); + assert.equal(ucDirectProvider.format, "openai"); + assert.equal(ucDirectProvider.executor, "default"); + assert.equal(ucDirectProvider.authType, "apikey"); + // UC uses X-api-key (never-expiring uai_sk_live_ key), NOT Bearer. + assert.equal(ucDirectProvider.authHeader, "x-api-key"); + assert.equal(ucDirectProvider.baseUrl, "https://api.uncensored.com/api/v1"); +}); + +test("ucDirectProvider ships the metered catalog with unique ids", () => { + assert.ok(ucDirectProvider.models.length >= 60, "expected the full metered catalog"); + const ids = ucDirectProvider.models.map((m) => m.id); + assert.equal(new Set(ids).size, ids.length, "model ids must be unique"); + // Ids are REST SHORTNAMES (no provider prefix) — this is what the API expects. + assert.ok( + ids.every((id) => !id.includes("/")), + "uc-direct ids must be shortnames" + ); + // A few representative live models. + assert.ok(ids.includes("claude-opus-4.8")); + assert.ok(ids.includes("gpt-5.5")); + assert.ok(ids.includes("gemini-3.1-pro-preview")); +}); From 6a91002b398f3090a219b34ec48de7d399c49619 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 2 Sep 2026 02:54:12 -0300 Subject: [PATCH 03/34] =?UTF-8?q?fix(release):=20drain=20the=202026-09-02?= =?UTF-8?q?=20base-red=20=E2=80=94=20rerank-providers=20import=20+=20api-t?= =?UTF-8?q?ypecheck=20baseline=20ratchet=20(#12414)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(memory): point the rerank-providers dynamic import at the real db module #11390 landed with a dynamic import of the localDb barrel, which #12052 had already removed from the base (and which Hard Rule #2 forbids) — the API Route Typecheck gate reds on the tip with TS2307. getCachedProviderNodes lives in src/lib/db/readCache. * chore(quality): ratchet the api-typecheck baseline down (163 stale entries gone) Regenerated with --update on a faithful npm ci environment (the .113 box) against the current tip plus the rerank-providers import fix — the gate now reads OK at 289 pre-existing errors, all baselined. No new entries added. --- config/quality/api-typecheck-baseline.json | 645 +++++++++---------- src/app/api/memory/rerank-providers/route.ts | 2 +- 2 files changed, 323 insertions(+), 324 deletions(-) diff --git a/config/quality/api-typecheck-baseline.json b/config/quality/api-typecheck-baseline.json index 7556ac39e2..0c184969de 100644 --- a/config/quality/api-typecheck-baseline.json +++ b/config/quality/api-typecheck-baseline.json @@ -1,401 +1,400 @@ { "open-sse/transformer/responsesTransformer.ts": { - "TS2353": 2 + "TS2353": 1 }, "open-sse/utils/progressTracker.ts": { - "TS2353": 2 + "TS2353": 1 }, "open-sse/utils/sseHeartbeat.ts": { - "TS2353": 2 + "TS2353": 1 }, "open-sse/utils/stream.ts": { - "TS2353": 2 + "TS2353": 1 }, "src/app/api/assess/route.ts": { - "TS2339": 2 + "TS2339": 1 }, "src/app/api/cache/route.ts": { - "TS2339": 2 + "TS2339": 1 }, "src/app/api/cli-tools/all-statuses/route.ts": { - "TS2339": 2 + "TS2339": 1 }, "src/app/api/cli-tools/claude-settings/route.ts": { - "TS2339": 2 + "TS2339": 1 }, "src/app/api/cli-tools/cline-settings/route.ts": { - "TS2339": 6 - }, - "src/app/api/cli-tools/codex-settings/route.ts": { - "TS2345": 3 - }, - "src/app/api/cli-tools/grok-build-settings/route.ts": { - "TS2304": 2 - }, - "src/app/api/cli-tools/hermes-agent-settings/route.ts": { - "TS2345": 2 - }, - "src/app/api/cli-tools/letta-settings/route.ts": { - "TS2339": 2 - }, - "src/app/api/cli-tools/omp-settings/route.ts": { - "TS2339": 10 - }, - "src/app/api/cli-tools/qwen-settings/route.ts": { - "TS2322": 2 - }, - "src/app/api/combos/auto/route.ts": { - "TS2322": 2 - }, - "src/app/api/combos/test/route.ts": { - "TS2345": 2, - "TS2339": 2 - }, - "src/app/api/compression/compare/route.ts": { - "TS2345": 2 - }, - "src/app/api/compression/preview/route.ts": { - "TS2345": 2 - }, - "src/app/api/context/combos/[id]/route.ts": { - "TS2345": 2 - }, - "src/app/api/context/combos/route.ts": { - "TS2345": 2 - }, - "src/app/api/copilot/chat/route.ts": { - "TS2345": 2 - }, - "src/app/api/guardrails/test/route.ts": { - "TS2554": 2 - }, - "src/app/api/internal/codex-responses-ws/route.ts": { - "TS2740": 2, - "TS2339": 9 - }, - "src/app/api/keys/[id]/route.ts": { - "TS2339": 2 - }, - "src/app/api/local/redis/start/route.ts": { - "TS2339": 2 - }, - "src/app/api/local/redis/stop/route.ts": { - "TS2339": 2 - }, - "src/app/api/logs/[id]/route.ts": { - "TS2322": 2 - }, - "src/app/api/model-capability-overrides/route.ts": { - "TS2339": 2 - }, - "src/app/api/model-combo-mappings/route.ts": { - "TS2339": 2 - }, - "src/app/api/models/alias/route.ts": { - "TS2339": 6 - }, - "src/app/api/models/route.ts": { - "TS2345": 4, - "TS2538": 2 - }, - "src/app/api/monitoring/health/route.ts": { - "TS2322": 2 - }, - "src/app/api/oauth/codex/import-token/route.ts": { - "TS2339": 4 - }, - "src/app/api/oauth/codex/import/route.ts": { - "TS2554": 2, - "TS2353": 2, - "TS2339": 4 - }, - "src/app/api/oauth/cursor/login/poll/route.ts": { - "TS2554": 2 - }, - "src/app/api/oauth/kiro/auto-import/route.ts": { - "TS2345": 2 - }, - "src/app/api/omniroute/route/preview/route.ts": { - "TS2345": 2 - }, - "src/app/api/playground/presets/[id]/route.ts": { - "TS2339": 4 - }, - "src/app/api/provider-nodes/validate/route.ts": { - "TS2339": 3 - }, - "src/app/api/providers/[id]/login/route.ts": { - "TS2739": 2 - }, - "src/app/api/providers/[id]/models/route.ts": { - "TS2367": 2, - "TS2339": 3, - "TS2322": 3, - "TS2554": 3, - "TS2345": 4 - }, - "src/app/api/providers/[id]/refresh-cursor/route.ts": { - "TS2352": 2 - }, - "src/app/api/providers/[id]/refresh/route.ts": { - "TS2345": 2, - "TS2698": 2, - "TS2339": 8 - }, - "src/app/api/providers/[id]/sync-models/route.ts": { - "TS2345": 2 - }, - "src/app/api/providers/[id]/test/route.ts": { - "TS2362": 2, - "TS2698": 2 - }, - "src/app/api/providers/free-onboarding/route.ts": { - "TS2345": 2 - }, - "src/app/api/providers/health-autopilot/actions/route.ts": { - "TS2339": 2 - }, - "src/app/api/providers/route.ts": { - "TS2352": 2, - "TS2322": 3, - "TS2345": 4 - }, - "src/app/api/providers/test-batch/route.ts": { - "TS2345": 5 - }, - "src/app/api/providers/validate/route.ts": { - "TS2322": 2 - }, - "src/app/api/providers/volcengine-plan/connect/[sessionId]/cancel/route.ts": { - "TS2739": 2 - }, - "src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts": { - "TS2739": 2 - }, - "src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts": { - "TS2739": 2 - }, - "src/app/api/providers/volcengine-plan/connect/[sessionId]/resend/route.ts": { - "TS2739": 2 - }, - "src/app/api/providers/volcengine-plan/connect/[sessionId]/status/route.ts": { - "TS2739": 2 - }, - "src/app/api/providers/volcengine-plan/connect/route.ts": { - "TS2739": 2 - }, - "src/app/api/radar/local-model-state/route.ts": { "TS2339": 5 }, - "src/app/api/resilience/model-cooldowns/route.ts": { - "TS2339": 2 - }, - "src/app/api/services/_shared/installRoute.ts": { - "TS2339": 2 - }, - "src/app/api/settings/cache-config/route.ts": { - "TS2339": 2, - "TS2322": 2 - }, - "src/app/api/settings/database/route.ts": { + "src/app/api/cli-tools/codex-settings/route.ts": { "TS2345": 2 }, - "src/app/api/settings/models-dev/route.ts": { - "TS2339": 2 + "src/app/api/cli-tools/grok-build-settings/route.ts": { + "TS2304": 1 }, - "src/app/api/settings/obsidian/webdav/route.ts": { - "TS2339": 2 + "src/app/api/cli-tools/hermes-agent-settings/route.ts": { + "TS2345": 1 }, - "src/app/api/settings/proxies/bulk-import/route.ts": { - "TS2345": 2 + "src/app/api/cli-tools/letta-settings/route.ts": { + "TS2339": 1 }, - "src/app/api/settings/proxy/cloudflare-deploy/route.ts": { - "TS2769": 2, - "TS2322": 3 + "src/app/api/cli-tools/omp-settings/route.ts": { + "TS2339": 8 }, - "src/app/api/settings/proxy/deno-deploy/route.ts": { - "TS2322": 5 + "src/app/api/cli-tools/qwen-settings/route.ts": { + "TS2322": 1 }, - "src/app/api/settings/proxy/vercel-deploy/route.ts": { - "TS2322": 4 + "src/app/api/combos/auto/route.ts": { + "TS2322": 1 }, - "src/app/api/settings/reasoning-routing-rules/[id]/route.ts": { - "TS2339": 2 + "src/app/api/combos/test/route.ts": { + "TS2345": 1, + "TS2339": 1 }, - "src/app/api/settings/reasoning-routing-rules/route.ts": { - "TS2339": 2 + "src/app/api/compression/compare/route.ts": { + "TS2345": 1 }, - "src/app/api/settings/reasoning-routing-rules/simulate/route.ts": { - "TS2322": 2, - "TS2339": 2 + "src/app/api/compression/preview/route.ts": { + "TS2345": 1 }, - "src/app/api/system/env/repair/route.ts": { - "TS2578": 2, - "TS2353": 4 + "src/app/api/context/combos/[id]/route.ts": { + "TS2345": 1 }, - "src/app/api/system/version/route.ts": { - "TS2769": 2 + "src/app/api/context/combos/route.ts": { + "TS2345": 1 }, - "src/app/api/tools/agent-bridge/agents/[id]/detected-models/route.ts": { - "TS2769": 2 + "src/app/api/copilot/chat/route.ts": { + "TS2345": 1 }, - "src/app/api/tools/traffic-inspector/internal/ingest/route.ts": { - "TS1117": 3, - "TS2345": 2 + "src/app/api/guardrails/test/route.ts": { + "TS2554": 1 }, - "src/app/api/tools/traffic-inspector/ws/route.ts": { - "TS2578": 2 + "src/app/api/internal/codex-responses-ws/route.ts": { + "TS2740": 1, + "TS2339": 7 }, - "src/app/api/translator/send/route.ts": { - "TS2345": 2, - "TS2322": 2, - "TS2339": 2 + "src/app/api/keys/[id]/route.ts": { + "TS2339": 1 }, - "src/app/api/translator/translate/route.ts": { - "TS2345": 2, - "TS2322": 2 + "src/app/api/local/redis/start/route.ts": { + "TS2339": 1 }, - "src/app/api/usage/analytics/route.ts": { - "TS2352": 18 + "src/app/api/local/redis/stop/route.ts": { + "TS2339": 1 }, - "src/app/api/usage/combo-health-autopilot/route.ts": { - "TS2769": 3 + "src/app/api/logs/[id]/route.ts": { + "TS2322": 1 }, - "src/app/api/v1/batches/route.ts": { - "TS2339": 2 + "src/app/api/model-capability-overrides/route.ts": { + "TS2339": 1 }, - "src/app/api/v1/classify/route.ts": { - "TS2322": 2 + "src/app/api/model-combo-mappings/route.ts": { + "TS2339": 1 }, - "src/app/api/v1/files/[id]/content/route.ts": { - "TS2345": 2 + "src/app/api/models/alias/route.ts": { + "TS2339": 5 }, - "src/app/api/v1/files/route.ts": { - "TS2339": 2 + "src/app/api/models/route.ts": { + "TS2345": 3, + "TS2538": 1 }, - "src/app/api/v1/images/edits/route.ts": { - "TS2339": 22, - "TS2322": 5 + "src/app/api/monitoring/health/route.ts": { + "TS2322": 1 }, - "src/app/api/v1/messages/count_tokens/route.ts": { - "TS2339": 3, - "TS2322": 2 - }, - "src/app/api/v1/music/generations/route.ts": { - "TS2322": 2, - "TS2345": 2 - }, - "src/app/api/v1/ocr/route.ts": { - "TS2345": 2 - }, - "src/app/api/v1/provider-plugin-manifest/route.ts": { - "TS2345": 2 - }, - "src/app/api/v1/providers/[provider]/embeddings/route.ts": { - "TS2339": 4, - "TS2322": 2 - }, - "src/app/api/v1/providers/[provider]/images/generations/route.ts": { - "TS2339": 6 - }, - "src/app/api/v1/rerank/route.ts": { + "src/app/api/oauth/codex/import-token/route.ts": { "TS2339": 3 }, - "src/app/api/v1/segment/route.ts": { - "TS2322": 2 + "src/app/api/oauth/codex/import/route.ts": { + "TS2554": 1, + "TS2353": 1, + "TS2339": 3 }, - "src/app/api/v1/session-leases/route.ts": { - "TS2339": 5, - "TS2345": 2 + "src/app/api/oauth/cursor/login/poll/route.ts": { + "TS2554": 1 }, - "src/app/api/v1/speech-to-text/route.ts": { - "TS2353": 2 + "src/app/api/oauth/kiro/auto-import/route.ts": { + "TS2345": 1 }, - "src/app/api/v1/text-to-speech/[voiceId]/route.ts": { - "TS2353": 2 + "src/app/api/omniroute/route/preview/route.ts": { + "TS2345": 1 }, - "src/app/api/v1/web/fetch/route.ts": { + "src/app/api/playground/presets/[id]/route.ts": { + "TS2339": 3 + }, + "src/app/api/provider-nodes/validate/route.ts": { "TS2339": 2 }, - "src/app/api/v1beta/models/route.ts": { - "TS2345": 2, - "TS2538": 2 + "src/app/api/providers/[id]/login/route.ts": { + "TS2739": 1 }, - "src/app/api/version-manager/restart/route.ts": { - "TS2339": 2 - }, - "src/app/api/version-manager/start/route.ts": { - "TS2339": 2 - }, - "src/app/api/version-manager/stop/route.ts": { - "TS2339": 2 - }, - "src/app/api/webhooks/[id]/route.ts": { - "TS2554": 2 - }, - "src/app/api/webhooks/[id]/test/route.ts": { - "TS2352": 3 - }, - "src/app/api/webhooks/route.ts": { + "src/app/api/providers/[id]/models/route.ts": { + "TS2367": 1, + "TS2339": 2, + "TS2322": 2, "TS2554": 2, - "TS2345": 2 - }, - "src/lib/db/tierConfig.ts": { "TS2345": 3 }, - "src/lib/monitoring/comboHealthAutopilot.ts": { - "TS2305": 2, - "TS2345": 2 + "src/app/api/providers/[id]/refresh-cursor/route.ts": { + "TS2352": 1 }, - "src/lib/monitoring/providerHealthAutopilot.ts": { - "TS2352": 5 + "src/app/api/providers/[id]/refresh/route.ts": { + "TS2345": 1, + "TS2698": 1, + "TS2339": 6 }, - "src/lib/omnirouteStatus.ts": { + "src/app/api/providers/[id]/sync-models/route.ts": { + "TS2345": 1 + }, + "src/app/api/providers/[id]/test/route.ts": { + "TS2362": 1, + "TS2698": 1 + }, + "src/app/api/providers/free-onboarding/route.ts": { + "TS2345": 1 + }, + "src/app/api/providers/health-autopilot/actions/route.ts": { + "TS2339": 1 + }, + "src/app/api/providers/route.ts": { + "TS2352": 1, "TS2322": 2, - "TS2558": 2 + "TS2345": 3 }, - "src/lib/providerModels/managedModelImport.ts": { - "TS2352": 5 - }, - "src/lib/proxySubscription/parse.ts": { + "src/app/api/providers/test-batch/route.ts": { "TS2345": 4 }, - "src/lib/quota/quotaAnalytics.ts": { + "src/app/api/providers/validate/route.ts": { + "TS2322": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/cancel/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/resend/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/status/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/route.ts": { + "TS2739": 1 + }, + "src/app/api/radar/local-model-state/route.ts": { + "TS2339": 4 + }, + "src/app/api/resilience/model-cooldowns/route.ts": { + "TS2339": 1 + }, + "src/app/api/services/_shared/installRoute.ts": { + "TS2339": 1 + }, + "src/app/api/settings/cache-config/route.ts": { + "TS2339": 1, + "TS2322": 1 + }, + "src/app/api/settings/database/route.ts": { + "TS2345": 1 + }, + "src/app/api/settings/models-dev/route.ts": { + "TS2339": 1 + }, + "src/app/api/settings/obsidian/webdav/route.ts": { + "TS2339": 1 + }, + "src/app/api/settings/proxies/bulk-import/route.ts": { + "TS2345": 1 + }, + "src/app/api/settings/proxy/cloudflare-deploy/route.ts": { + "TS2769": 1, + "TS2322": 2 + }, + "src/app/api/settings/proxy/deno-deploy/route.ts": { + "TS2322": 4 + }, + "src/app/api/settings/proxy/vercel-deploy/route.ts": { + "TS2322": 3 + }, + "src/app/api/settings/reasoning-routing-rules/[id]/route.ts": { + "TS2339": 1 + }, + "src/app/api/settings/reasoning-routing-rules/route.ts": { + "TS2339": 1 + }, + "src/app/api/settings/reasoning-routing-rules/simulate/route.ts": { + "TS2322": 1, + "TS2339": 1 + }, + "src/app/api/system/env/repair/route.ts": { + "TS2578": 1, + "TS2353": 3 + }, + "src/app/api/system/version/route.ts": { + "TS2769": 1 + }, + "src/app/api/tools/agent-bridge/agents/[id]/detected-models/route.ts": { + "TS2769": 1 + }, + "src/app/api/tools/traffic-inspector/internal/ingest/route.ts": { + "TS1117": 2, + "TS2345": 1 + }, + "src/app/api/tools/traffic-inspector/ws/route.ts": { + "TS2578": 1 + }, + "src/app/api/translator/send/route.ts": { + "TS2345": 1, + "TS2322": 1, + "TS2339": 1 + }, + "src/app/api/translator/translate/route.ts": { + "TS2345": 1, + "TS2322": 1 + }, + "src/app/api/usage/analytics/route.ts": { + "TS2352": 15 + }, + "src/app/api/usage/combo-health-autopilot/route.ts": { "TS2769": 2 }, - "src/lib/quota/quotaResetTimers.ts": { - "TS2769": 3 + "src/app/api/v1/batches/route.ts": { + "TS2339": 1 }, - "src/lib/usage/comboForecast.ts": { - "TS2345": 2 + "src/app/api/v1/classify/route.ts": { + "TS2322": 1 }, - "src/lib/usage/comboHealth.ts": { - "TS2345": 2 + "src/app/api/v1/files/[id]/content/route.ts": { + "TS2345": 1 }, - "src/lib/usage/comboScoringInspector.ts": { - "TS2352": 2, - "TS2741": 2 + "src/app/api/v1/files/route.ts": { + "TS2339": 1 }, - "src/lib/usage/providerWindowCosts.ts": { - "TS2322": 3, - "TS2558": 6, - "TS2339": 15, - "TS2345": 2 + "src/app/api/v1/images/edits/route.ts": { + "TS2339": 18, + "TS2322": 4 }, - "src/lib/vscode/modelPresentation.ts": { - "TS2554": 2 + "src/app/api/v1/messages/count_tokens/route.ts": { + "TS2339": 2, + "TS2322": 1 }, - "src/lib/ws/handshake.ts": { + "src/app/api/v1/music/generations/route.ts": { + "TS2322": 1, + "TS2345": 1 + }, + "src/app/api/v1/ocr/route.ts": { + "TS2345": 1 + }, + "src/app/api/v1/provider-plugin-manifest/route.ts": { + "TS2345": 1 + }, + "src/app/api/v1/providers/[provider]/embeddings/route.ts": { + "TS2339": 3, + "TS2322": 1 + }, + "src/app/api/v1/providers/[provider]/images/generations/route.ts": { + "TS2339": 5 + }, + "src/app/api/v1/rerank/route.ts": { "TS2339": 2 }, - "src/mitm/detection/index.ts": { - "TS2741": 2 + "src/app/api/v1/segment/route.ts": { + "TS2322": 1 }, - "src/mitm/inspector/httpProxyServer.ts": { + "src/app/api/v1/session-leases/route.ts": { + "TS2339": 4, + "TS2345": 1 + }, + "src/app/api/v1/speech-to-text/route.ts": { + "TS2353": 1 + }, + "src/app/api/v1/text-to-speech/[voiceId]/route.ts": { + "TS2353": 1 + }, + "src/app/api/v1/web/fetch/route.ts": { + "TS2339": 1 + }, + "src/app/api/v1beta/models/route.ts": { + "TS2345": 1, + "TS2538": 1 + }, + "src/app/api/version-manager/restart/route.ts": { + "TS2339": 1 + }, + "src/app/api/version-manager/start/route.ts": { + "TS2339": 1 + }, + "src/app/api/version-manager/stop/route.ts": { + "TS2339": 1 + }, + "src/app/api/webhooks/[id]/route.ts": { + "TS2554": 1 + }, + "src/app/api/webhooks/[id]/test/route.ts": { + "TS2352": 2 + }, + "src/app/api/webhooks/route.ts": { + "TS2554": 1, + "TS2345": 1 + }, + "src/lib/db/tierConfig.ts": { + "TS2345": 2 + }, + "src/lib/monitoring/comboHealthAutopilot.ts": { + "TS2305": 1, + "TS2345": 1 + }, + "src/lib/monitoring/providerHealthAutopilot.ts": { + "TS2352": 4 + }, + "src/lib/omnirouteStatus.ts": { + "TS2322": 1, + "TS2558": 1 + }, + "src/lib/providerModels/managedModelImport.ts": { + "TS2352": 4 + }, + "src/lib/proxySubscription/parse.ts": { + "TS2345": 3 + }, + "src/lib/quota/quotaAnalytics.ts": { + "TS2769": 1 + }, + "src/lib/quota/quotaResetTimers.ts": { "TS2769": 2 }, - "src/shared/schemas/cliCatalog.ts": { - "TS2554": 3 + "src/lib/usage/comboForecast.ts": { + "TS2345": 1 }, - "_relax_velocity_2026_08_30": "per-file TS diagnostic counts raised by 20% (289 → 455); velocity phase, see quality-baseline.json _policy." + "src/lib/usage/comboHealth.ts": { + "TS2345": 1 + }, + "src/lib/usage/comboScoringInspector.ts": { + "TS2352": 1, + "TS2741": 1 + }, + "src/lib/usage/providerWindowCosts.ts": { + "TS2322": 2, + "TS2558": 5, + "TS2339": 12, + "TS2345": 1 + }, + "src/lib/vscode/modelPresentation.ts": { + "TS2554": 1 + }, + "src/lib/ws/handshake.ts": { + "TS2339": 1 + }, + "src/mitm/detection/index.ts": { + "TS2741": 1 + }, + "src/mitm/inspector/httpProxyServer.ts": { + "TS2769": 1 + }, + "src/shared/schemas/cliCatalog.ts": { + "TS2554": 2 + } } diff --git a/src/app/api/memory/rerank-providers/route.ts b/src/app/api/memory/rerank-providers/route.ts index 7ab9102340..cb933300db 100644 --- a/src/app/api/memory/rerank-providers/route.ts +++ b/src/app/api/memory/rerank-providers/route.ts @@ -40,7 +40,7 @@ export async function GET(request: NextRequest) { // Local rerank-capable provider_nodes appended after curated entries. const extra = []; try { - const { getCachedProviderNodes } = await import("@/lib/localDb"); + const { getCachedProviderNodes } = await import("@/lib/db/readCache"); const nodes = await getCachedProviderNodes(); for (const n of Array.isArray(nodes) ? nodes : []) { const apiType = (n as { apiType?: string }).apiType || ""; From 090ae83e12db745177d0944f567b459263231652 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:12:13 +0200 Subject: [PATCH 04/34] fix(docker): find the Chrome binary in chrome-linux64 for the codex browser image (#12376) The playwright:v1.62.0-noble base ships Chromium as a Chrome for Testing build, which extracts to chrome-linux64/chrome. The CMD's find -path '*/chrome-linux/chrome' matched nothing, $chrome_path came out empty, and the container crash-looped on `exec: --headless=new: not found`. Widening the glob to '*/chrome-linux*/chrome' resolves both the legacy and the Chrome for Testing layout. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- ...12376-codex-browser-chrome-linux64-path.md | 1 + docker/chatgpt-web-codex-browser/Dockerfile | 2 +- tests/unit/chatgpt-web-codex.test.ts | 22 +++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/12376-codex-browser-chrome-linux64-path.md diff --git a/changelog.d/fixes/12376-codex-browser-chrome-linux64-path.md b/changelog.d/fixes/12376-codex-browser-chrome-linux64-path.md new file mode 100644 index 0000000000..7d5993f6f2 --- /dev/null +++ b/changelog.d/fixes/12376-codex-browser-chrome-linux64-path.md @@ -0,0 +1 @@ +- **fix(docker):** the `chatgpt-web-codex-browser` image now finds the Chrome binary under `chrome-linux64/` (Chrome for Testing layout in `playwright:v1.62.0-noble`) as well as the legacy `chrome-linux/`, so the container no longer crash-loops with `exec: --headless=new: not found` ([#12024](https://github.com/diegosouzapw/OmniRoute/issues/12024)) diff --git a/docker/chatgpt-web-codex-browser/Dockerfile b/docker/chatgpt-web-codex-browser/Dockerfile index 5cffe481ff..c257f3f62d 100644 --- a/docker/chatgpt-web-codex-browser/Dockerfile +++ b/docker/chatgpt-web-codex-browser/Dockerfile @@ -7,4 +7,4 @@ USER pwuser EXPOSE 9223 -CMD ["/bin/sh", "-lc", "node /opt/cdp-proxy.mjs & chrome_path=$(find /ms-playwright -path '*/chrome-linux/chrome' -type f | head -n 1); test -n \"$chrome_path\"; exec xvfb-run -a --server-args='-screen 0 1920x1080x24 -nolisten tcp' \"$chrome_path\" --no-sandbox --disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir=/browser-profile about:blank"] +CMD ["/bin/sh", "-lc", "node /opt/cdp-proxy.mjs & chrome_path=$(find /ms-playwright -path '*/chrome-linux*/chrome' -type f | head -n 1); test -n \"$chrome_path\"; exec xvfb-run -a --server-args='-screen 0 1920x1080x24 -nolisten tcp' \"$chrome_path\" --no-sandbox --disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir=/browser-profile about:blank"] diff --git a/tests/unit/chatgpt-web-codex.test.ts b/tests/unit/chatgpt-web-codex.test.ts index 11b10bfc9a..6b4c4e57a6 100644 --- a/tests/unit/chatgpt-web-codex.test.ts +++ b/tests/unit/chatgpt-web-codex.test.ts @@ -111,6 +111,28 @@ test("runs the Docker browser headed inside a private Xvfb display", () => { assert.match(dockerfile, /-nolisten tcp/); }); +test("#12024 Docker browser find pattern matches both chrome-linux and chrome-linux64 layouts", () => { + const dockerfile = readFileSync( + join(process.cwd(), "docker/chatgpt-web-codex-browser/Dockerfile"), + "utf8" + ); + const found = dockerfile.match(/find \/ms-playwright -path '([^']+)' -type f/); + assert.ok(found, "Dockerfile CMD must locate the Chrome binary with a find -path glob"); + const glob = found[1]; + const matcher = new RegExp( + `^${glob.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$` + ); + // playwright:v1.62.0-noble ships Chrome for Testing, which extracts to chrome-linux64/. + assert.match("/ms-playwright/chromium-1234/chrome-linux64/chrome", matcher); + // Older images keep the legacy chrome-linux/ directory. + assert.match("/ms-playwright/chromium-1234/chrome-linux/chrome", matcher); + // The separate headless-shell build ships a different binary name and must not be picked up. + assert.doesNotMatch( + "/ms-playwright/chromium_headless_shell-1234/chrome-linux/headless_shell", + matcher + ); +}); + test("preserves browser-verified ChatGPT auth cookies across runtime rotation", () => { const cookie = (name: string, value: string) => ({ name, From 8e474914ea15e43de71c52422c1990f3a7ec6b89 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:12:18 +0200 Subject: [PATCH 05/34] fix(providers): mark groq compound and allam-2-7b as non-reasoning models (#12379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit groq/compound and allam-2-7b were absent from the curated Groq registry, so the capability heuristic defaulted them to reasoning-capable and forwarded reasoning_effort verbatim — Groq answers HTTP 400. Declaring supportsReasoning: false makes applyThinkingBudget() strip reasoning_effort, output_config.effort and thinking, same class as #3258. The gpt-oss reasoning models keep the field. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .../12379-groq-compound-allam-no-reasoning.md | 1 + .../config/providers/registry/groq/index.ts | 4 ++ tests/unit/thinking-budget-groq-12134.test.ts | 61 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 changelog.d/fixes/12379-groq-compound-allam-no-reasoning.md create mode 100644 tests/unit/thinking-budget-groq-12134.test.ts diff --git a/changelog.d/fixes/12379-groq-compound-allam-no-reasoning.md b/changelog.d/fixes/12379-groq-compound-allam-no-reasoning.md new file mode 100644 index 0000000000..d9db32cc6b --- /dev/null +++ b/changelog.d/fixes/12379-groq-compound-allam-no-reasoning.md @@ -0,0 +1 @@ +- **fix(providers):** declare `groq/compound` and `allam-2-7b` as non-reasoning models in the curated Groq registry so `reasoning_effort` / `output_config.effort` / `thinking` from Claude Code are stripped instead of forwarded, which Groq rejected with HTTP 400 ([#12134](https://github.com/diegosouzapw/OmniRoute/issues/12134)) diff --git a/open-sse/config/providers/registry/groq/index.ts b/open-sse/config/providers/registry/groq/index.ts index 07fa17d666..974e24e710 100644 --- a/open-sse/config/providers/registry/groq/index.ts +++ b/open-sse/config/providers/registry/groq/index.ts @@ -16,6 +16,10 @@ export const groqProvider: RegistryEntry = { supportsReasoning: false, }, { id: "llama-3.3-70b-versatile", name: "Llama 3.3 70B", supportsReasoning: false }, + // Same class (#12134): compound and ALLaM are not reasoning models on Groq either, so + // declare it here — undeclared models default to reasoning-capable via the heuristic. + { id: "groq/compound", name: "Groq Compound", supportsReasoning: false }, + { id: "allam-2-7b", name: "ALLaM 2 7B", supportsReasoning: false }, { id: "openai/gpt-oss-120b", name: "GPT-OSS 120B" }, { id: "openai/gpt-oss-20b", name: "GPT-OSS 20B" }, { id: "qwen/qwen3-32b", name: "Qwen3 32B" }, diff --git a/tests/unit/thinking-budget-groq-12134.test.ts b/tests/unit/thinking-budget-groq-12134.test.ts new file mode 100644 index 0000000000..c9b6aec646 --- /dev/null +++ b/tests/unit/thinking-budget-groq-12134.test.ts @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { applyThinkingBudget, setThinkingBudgetConfig, ThinkingMode, DEFAULT_THINKING_CONFIG } = + await import("../../open-sse/services/thinkingBudget.ts"); + +// Regression coverage for #12134 (same class as #3258): Claude Code → Groq failed with +// `reasoning_effort` HTTP 400 for `groq/compound` and `allam-2-7b`. Neither model was in the +// curated Groq registry, so the capability heuristic defaulted them to reasoning-capable and +// `reasoning_effort` (derived from Claude Code's `output_config.effort`) was forwarded verbatim. +// Both must now be declared `supportsReasoning: false` so the field is stripped, while reasoning +// models (gpt-oss) keep it. + +test("#12134 groq/groq/compound strips reasoning_effort", () => { + setThinkingBudgetConfig({ mode: ThinkingMode.PASSTHROUGH }); + const out = applyThinkingBudget({ + model: "groq/groq/compound", + messages: [{ role: "user", content: "hi" }], + reasoning_effort: "medium", + }) as Record; + assert.equal(out.reasoning_effort, undefined, "reasoning_effort must be stripped for compound"); + setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG); +}); + +test("#12134 groq/groq/compound strips output_config.effort and thinking", () => { + setThinkingBudgetConfig({ mode: ThinkingMode.PASSTHROUGH }); + const out = applyThinkingBudget({ + model: "groq/groq/compound", + messages: [{ role: "user", content: "hi" }], + output_config: { effort: "high" }, + thinking: { type: "enabled", budget_tokens: 10240 }, + }) as Record; + assert.equal(out.thinking, undefined, "thinking must be stripped"); + assert.ok( + !out.output_config || out.output_config.effort === undefined, + "output_config.effort must be stripped (else claude→openai re-injects reasoning_effort)" + ); + setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG); +}); + +test("#12134 groq/allam-2-7b strips reasoning_effort", () => { + setThinkingBudgetConfig({ mode: ThinkingMode.PASSTHROUGH }); + const out = applyThinkingBudget({ + model: "groq/allam-2-7b", + messages: [{ role: "user", content: "hi" }], + reasoning_effort: "low", + }) as Record; + assert.equal(out.reasoning_effort, undefined, "reasoning_effort must be stripped for allam"); + setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG); +}); + +test("#12134 groq/openai/gpt-oss-20b KEEPS reasoning_effort (reasoning model — no regression)", () => { + setThinkingBudgetConfig({ mode: ThinkingMode.PASSTHROUGH }); + const out = applyThinkingBudget({ + model: "groq/openai/gpt-oss-20b", + messages: [{ role: "user", content: "hi" }], + reasoning_effort: "high", + }) as Record; + assert.equal(out.reasoning_effort, "high", "gpt-oss is a reasoning model — must keep the field"); + setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG); +}); From e7b14482812d6f55d4ed34fd8d8960e4cddc1ff2 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:12:22 +0200 Subject: [PATCH 06/34] fix(combo): name output_tokens as the exclusion reason instead of structured output (#12374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When every combo target is excluded because the request's max_tokens exceeds each target's known output limit, the terminal 400 now says so — requested max_tokens against the pool's highest known ceiling — instead of the unrelated "supports structured output for this request". Diagnostics (unmet, excluded[].reason, terminalReason) are unchanged; only the message for the output_tokens primary reason moves. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- ...74-combo-exclusion-reason-output-tokens.md | 1 + open-sse/services/combo/comboStructure.ts | 22 ++++++++++++++ ...8488-capability-filter-fail-closed.test.ts | 29 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 changelog.d/fixes/12374-combo-exclusion-reason-output-tokens.md diff --git a/changelog.d/fixes/12374-combo-exclusion-reason-output-tokens.md b/changelog.d/fixes/12374-combo-exclusion-reason-output-tokens.md new file mode 100644 index 0000000000..d89a27bfec --- /dev/null +++ b/changelog.d/fixes/12374-combo-exclusion-reason-output-tokens.md @@ -0,0 +1 @@ +- **fix(combo):** capability-filter exhaustion caused by `max_tokens` above every target's known output limit now reports that reason (requested `max_tokens` vs the pool's highest known ceiling) instead of the unrelated "supports structured output" message ([#12229](https://github.com/diegosouzapw/OmniRoute/issues/12229)) — thanks @DW-MediaLab diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 8d7adc068a..137561fbaa 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -627,6 +627,18 @@ export type CompatFilterOptions = { failOpen?: boolean; }; +function highestKnownOutputLimit(targets: ResolvedComboTarget[]): number { + let ceiling = 0; + for (const target of targets) { + const limit = getResolvedModelCapabilities({ + provider: target.providerId || target.provider || null, + model: target.modelStr, + }).maxOutputTokens; + if (typeof limit === "number" && limit > ceiling) ceiling = limit; + } + return ceiling; +} + export function hasHardCapabilityFailure(reasons: string[]): boolean { return reasons.some((reason) => HARD_COMPAT_REASONS.has(reason)); } @@ -668,6 +680,16 @@ export function describeCapabilityFilterExhaustion( message = `No target in combo ${name} supports tool calling; request carried ${toolCount} tools`; } else if (primary === "vision") { message = `No target in combo ${name} has confirmed vision support for this image request`; + } else if (primary === "output_tokens") { + // #12229: name the real reason. Collapsing this into the structured-output + // message sent operators chasing response_format when the request's + // max_tokens simply exceeded every target's known output ceiling. + const ceiling = highestKnownOutputLimit( + rejected.filter((entry) => entry.reasons.includes("output_tokens")).map((e) => e.target) + ); + message = + `No target in combo ${name} can produce the requested max_tokens=${requirements.requestedOutputTokens}; ` + + `the highest known output limit in the pool is ${ceiling}`; } else { message = `No target in combo ${name} supports structured output for this request`; } diff --git a/tests/unit/8488-capability-filter-fail-closed.test.ts b/tests/unit/8488-capability-filter-fail-closed.test.ts index 2be07aa7e7..418d9be453 100644 --- a/tests/unit/8488-capability-filter-fail-closed.test.ts +++ b/tests/unit/8488-capability-filter-fail-closed.test.ts @@ -320,3 +320,32 @@ test("auto context estimate still dispatches when all known limits look too smal assert.equal(result.status, 200); assert.deepEqual(dispatches, ["openai/tiny"]); }); + +test("#12229 exhaustion: output_tokens exclusion names max_tokens vs the model ceiling", () => { + saveModelsDevCapabilities({ + claude: { + "claude-haiku-4-5-20251001": capabilityEntry(200000, { + tool_call: true, + structured_output: true, + limit_output: 64000, + }), + }, + }); + + const targets = [target("claude", "claude/claude-haiku-4-5-20251001")]; + const body = { + messages: [{ role: "user", content: "hoi wie ben je?" }], + max_tokens: 100000, + }; + + const exhaustion = describeCapabilityFilterExhaustion(targets, body, "hermes-main"); + assert.ok(exhaustion); + assert.deepEqual(exhaustion!.unmet, ["output_tokens"]); + assert.equal(exhaustion!.excluded[0].reason, "output_tokens"); + assert.equal( + exhaustion!.message, + "No target in combo hermes-main can produce the requested max_tokens=100000; the highest known output limit in the pool is 64000" + ); + assert.doesNotMatch(exhaustion!.message, /structured output/i); + assert.equal(exhaustion!.terminalReason, "capability_mismatch"); +}); From 0389b07257f0073e4ea984ae322a559073c18201 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:12:25 +0200 Subject: [PATCH 07/34] fix(auth): prefer accounts without backoff in least-used rotation (#12375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The least-used strategy ranked candidates by lastUsedAt alone, so after a 429 excluded the active account the replacement could be one that was merely oldest while still carrying its own backoff — it served a single request before the next one settled on a healthy account, the one-request detour with two cache misses reported on Codex. least-used now applies the backoffLevel tie-break the round-robin fallback branch already had, ahead of the existing never-used / oldest / priority order. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .../12375-least-used-backoff-tiebreak.md | 1 + src/sse/services/auth.ts | 9 +++++- tests/unit/sse-auth.test.ts | 32 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/12375-least-used-backoff-tiebreak.md diff --git a/changelog.d/fixes/12375-least-used-backoff-tiebreak.md b/changelog.d/fixes/12375-least-used-backoff-tiebreak.md new file mode 100644 index 0000000000..21a892f6d6 --- /dev/null +++ b/changelog.d/fixes/12375-least-used-backoff-tiebreak.md @@ -0,0 +1 @@ +- **fix(auth):** the `least-used` account strategy now prefers accounts without backoff before falling back to oldest `lastUsedAt`, the same tie-break `round-robin` already applies, so a failover no longer lands on a just-rate-limited account for a single request ([#12279](https://github.com/diegosouzapw/OmniRoute/issues/12279)) — thanks @tenshiak diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 4e54c87615..ec2b42a17d 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -2095,8 +2095,15 @@ export async function getProviderCredentials( parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) % orderedConnections.length; connection = orderedConnections[idx]; } else if (strategy === "least-used") { - // Least Used: pick the one with oldest lastUsedAt + // Least Used: pick the one with oldest lastUsedAt. + // #12279: prefer accounts without backoff first, the same tie-break the + // round-robin fallback branch applies. Without it the oldest lastUsedAt + // could belong to an account that just 429'd, so a failover landed on it + // for one request before the next call settled on a healthy account. const sorted = [...orderedConnections].sort((a, b) => { + const aBackoff = a.backoffLevel || 0; + const bBackoff = b.backoffLevel || 0; + if (aBackoff !== bBackoff) return aBackoff - bBackoff; // lower backoff first if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999); if (!a.lastUsedAt) return -1; if (!b.lastUsedAt) return 1; diff --git a/tests/unit/sse-auth.test.ts b/tests/unit/sse-auth.test.ts index af56075d1a..53cd373228 100644 --- a/tests/unit/sse-auth.test.ts +++ b/tests/unit/sse-auth.test.ts @@ -1061,6 +1061,38 @@ test("getProviderCredentials least-used prefers the oldest timestamp when all ac assert.equal(selected.connectionId, oldest.id); }); +test("getProviderCredentials least-used prefers an account without backoff over the least recently used one (#12279)", async () => { + await settingsDb.updateSettings({ fallbackStrategy: "least-used" }); + // Oldest lastUsedAt, but still carrying a backoff from a recent 429. + const backedOff = await seedConnection("openai", { + name: "least-used-backed-off", + priority: 1, + }); + // Used more recently, but healthy. + const healthy = await seedConnection("openai", { + name: "least-used-healthy", + priority: 9, + }); + // createProviderConnection does not persist backoffLevel; write it through + // update. rateLimitedUntil in the future keeps the backoff from auto-decaying, + // and allowRateLimitedConnections below keeps the account in the pool. + await providersDb.updateProviderConnection(backedOff.id, { + backoffLevel: 2, + rateLimitedUntil: futureIso(), + lastUsedAt: new Date(Date.now() - 120_000).toISOString(), + }); + await providersDb.updateProviderConnection(healthy.id, { + lastUsedAt: new Date(Date.now() - 1_000).toISOString(), + }); + + const selected = await auth.getProviderCredentials("openai", null, null, null, { + allowRateLimitedConnections: true, + }); + + assert.equal(selected.connectionId, healthy.id); + assert.notEqual(selected.connectionId, backedOff.id); +}); + test("getProviderCredentials cost-optimized selects the lowest priority account", async () => { await settingsDb.updateSettings({ fallbackStrategy: "cost-optimized" }); const cheapest = await seedConnection("openai", { From d337c5d30dc13e286ff48edf900890055066a9c0 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:12:46 +0200 Subject: [PATCH 08/34] test(executors): restore the #10986 reasoning-only fallback guards (#12364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #10265 rewrite of command-code-executor.test.ts (b6412c6fe) deleted the two regression tests #10986 added for reasoning-only Command Code output, while the production fallback in createJsonResponse / createStreamResponse survived — leaving it unguarded. Both are restored, now routed through the /alpha/generate fallback that is the only way to reach the CLI translator since #10265, via a shared goPlanFallbackFetch() helper. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- tests/unit/command-code-executor.test.ts | 111 +++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/tests/unit/command-code-executor.test.ts b/tests/unit/command-code-executor.test.ts index 01ee533af3..3e288c0ba7 100644 --- a/tests/unit/command-code-executor.test.ts +++ b/tests/unit/command-code-executor.test.ts @@ -485,6 +485,117 @@ test("Command Code executor falls back to /alpha/generate on 403 (Go plan) for n assert.equal(usage.total_tokens, 5); }); +// Simulates a Go-plan key: /provider/v1/chat/completions answers 403 and the executor +// falls back to /alpha/generate, whose CLI SSE stream is built from `cliLines`. +function goPlanFallbackFetch(cliLines: unknown[]) { + const calls: string[] = []; + globalThis.fetch = async (url) => { + const urlStr = String(url); + calls.push(urlStr); + + if (urlStr.includes("/provider/v1/chat/completions")) { + return new Response( + JSON.stringify({ error: { message: "upgrade_required", code: "upgrade_required" } }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); + } + + if (urlStr.includes("/alpha/generate")) { + const cliSse = cliLines.map((line) => `data: ${JSON.stringify(line)}\n\n`).join(""); + return new Response(cliSse, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + + return new Response("Not found", { status: 404 }); + }; + return calls; +} + +test("Command Code /alpha/generate fallback: reasoning-only output falls back to reasoning as content (non-stream) (#10986)", async () => { + const calls = goPlanFallbackFetch([ + { type: "reasoning-delta", text: "The user wants 79874+93658. " }, + { type: "reasoning-delta", text: "That equals 173532." }, + { + type: "finish", + finishReason: "stop", + totalUsage: { + inputTokens: 20, + outputTokens: 64, + outputTokenDetails: { reasoningTokens: 61 }, + }, + }, + ]); + + const { response, url } = await ( + await getExecutor("command-code") + ).execute({ + model: "deepseek/deepseek-v4-flash", + stream: false, + credentials: { apiKey: "cc_go_plan_key" }, + body: { + messages: [ + { role: "user", content: "Calculate 79874+93658, and reply with the result only." }, + ], + }, + }); + + assert.equal(calls.length, 2, "probed /provider/v1 first, then fell back to /alpha/generate"); + assert.ok(url.includes("/alpha/generate")); + const json = (await response.json()) as { + choices: Array<{ + message: { content: string; reasoning_content?: string }; + finish_reason: string; + }>; + usage: { completion_tokens_details: { reasoning_tokens: number } }; + }; + const message = json.choices[0].message; + // Regression #10986: when the model emits only reasoning-delta events (never a + // text-delta), content must fall back to the reasoning text instead of "" (which + // OpenAI-compatible clients treat as null/no answer). + assert.equal(message.content, "The user wants 79874+93658. That equals 173532."); + // reasoning_content must STAY populated for reasoning-aware clients. + assert.equal(message.reasoning_content, "The user wants 79874+93658. That equals 173532."); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.equal(json.usage.completion_tokens_details.reasoning_tokens, 61); +}); + +test("Command Code /alpha/generate fallback: reasoning-only output emits a content delta chunk when streaming (#10986)", async () => { + const calls = goPlanFallbackFetch([ + { type: "reasoning-delta", text: "The result is 173532." }, + { type: "finish", finishReason: "stop" }, + ]); + + const { response, url } = await ( + await getExecutor("command-code") + ).execute({ + model: "deepseek/deepseek-v4-flash", + stream: true, + credentials: { apiKey: "cc_go_plan_key" }, + body: { messages: [{ role: "user", content: "Calcular 79874+93658" }] }, + }); + + assert.equal(calls.length, 2, "probed /provider/v1 first, then fell back to /alpha/generate"); + assert.ok(url.includes("/alpha/generate")); + const sse = await response.text(); + assert.match(sse, /data: \[DONE\]/); + const chunks = parseSsePayloads(sse); + assert.equal(chunks[0].choices[0].delta.role, "assistant"); + // Regression #10986: the reasoning-only stream must emit a content delta when it + // otherwise ends with no content. reasoning_content stays present too. + const contentChunks = chunks.filter((c) => c.choices[0]?.delta?.content !== undefined); + assert.equal(contentChunks.length, 1, "exactly one synthesized content delta"); + assert.equal(contentChunks[0].choices[0].delta.content, "The result is 173532."); + const reasoningDelta = chunks.find((c) => c.choices[0]?.delta?.reasoning_content !== undefined); + assert.equal(reasoningDelta.choices[0].delta.reasoning_content, "The result is 173532."); + // The synthesized content lands after the reasoning delta and before the finish chunk. + const finishIndex = chunks.findIndex((c) => c.choices[0]?.finish_reason === "stop"); + assert.ok(finishIndex > chunks.indexOf(contentChunks[0])); + assert.ok(chunks.indexOf(contentChunks[0]) > chunks.indexOf(reasoningDelta)); + assert.equal(chunks[finishIndex].choices[0].finish_reason, "stop"); +}); + test("Command Code executor surfaces fallback error when both /provider/v1 and /alpha/generate fail", async () => { globalThis.fetch = async (url) => { const urlStr = String(url); From 393c305a71286c73b502a1305d103e815ad1f7c0 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:12:51 +0200 Subject: [PATCH 09/34] fix(providers): resolve the Codex auto-ping model from the live catalog instead of a retired id (#12361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opt-in Codex quota auto-ping pinned gpt-5.1-codex-mini. OpenAI shut that model down on 2026-07-23 and the repo's own lifecycle registry already rejects it on the request path, but the scheduler never consulted that gate — every window slide sent a dead id, hit the 15-minute failure cooldown, and retried the same id forever. The ping model now resolves per tick from the provider catalog through isModelSelectable(), the same gate chatCore uses, with the registry import kept lazy because this module sits on the instrumentation boot path (#12074). When nothing is selectable the provider is paused before any throttle slot, usage read or executor call, with one warning per state change. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .../fixes/12361-codex-quota-ping-model.md | 1 + src/lib/services/quotaAutoPing.ts | 83 ++++++++++++++++-- src/shared/constants/quotaAutoPing.ts | 7 +- tests/unit/quota-auto-ping.test.ts | 85 ++++++++++++++++++- 4 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fixes/12361-codex-quota-ping-model.md diff --git a/changelog.d/fixes/12361-codex-quota-ping-model.md b/changelog.d/fixes/12361-codex-quota-ping-model.md new file mode 100644 index 0000000000..7e9522820c --- /dev/null +++ b/changelog.d/fixes/12361-codex-quota-ping-model.md @@ -0,0 +1 @@ +- **fix(providers):** resolve the Codex quota auto-ping model from the live provider catalog and lifecycle registry instead of the retired `gpt-5.1-codex-mini`, and pause the ping with one actionable warning when no selectable Codex model exists rather than retrying a shut-down id every cooldown window ([#11905](https://github.com/diegosouzapw/OmniRoute/issues/11905)) diff --git a/src/lib/services/quotaAutoPing.ts b/src/lib/services/quotaAutoPing.ts index 95447a3984..1f7d2aede4 100644 --- a/src/lib/services/quotaAutoPing.ts +++ b/src/lib/services/quotaAutoPing.ts @@ -23,6 +23,8 @@ import { logger } from "@omniroute/open-sse/utils/logger.ts"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; import type { BaseExecutor } from "@omniroute/open-sse/executors/base"; +import { splitCodexReasoningSuffix } from "@omniroute/open-sse/executors/codex/reasoningSuffix.ts"; +import { isModelSelectable } from "@omniroute/open-sse/services/modelLifecycle.ts"; import { getCodexUsage } from "@omniroute/open-sse/services/usage/codex.ts"; import { throttleQuotaFetch } from "@omniroute/open-sse/services/quotaFetchThrottle.ts"; import { getSettings } from "@/lib/db/settings"; @@ -42,6 +44,9 @@ const log = logger("QuotaAutoPing"); type JsonRecord = Record; +/** Provider config plus the ping model resolved for this tick (#11905). */ +type ResolvedQuotaAutoPingProviderConfig = QuotaAutoPingProviderConfig & { pingModel: string }; + export interface QuotaAutoPingConnection { id: string; provider: string; @@ -73,16 +78,25 @@ export interface QuotaAutoPingDeps { getExecutor: (provider: "codex") => Promise; canExecuteProvider: (provider: string) => boolean; isConnectionUnavailableToAuxiliaryActivity: (connectionId: string) => Promise; + /** + * #11905: which model the tiny ping is sent as. Resolved from the live provider + * catalog + lifecycle registry every tick (see resolveQuotaAutoPingModel) instead + * of a pinned id, so a vendor shutdown pauses the ping with a diagnostic rather + * than turning the scheduler into a retry loop against a dead model. + */ + resolvePingModel: (provider: "codex", nowMs: number) => Promise; } export interface QuotaAutoPingState { running: boolean; resetCache: Record; failureCache: Record; + /** Last resolved ping model per provider (`null` = nothing selectable); logs on change only. */ + pingModelCache: Record; } export function createQuotaAutoPingState(): QuotaAutoPingState { - return { running: false, resetCache: {}, failureCache: {} }; + return { running: false, resetCache: {}, failureCache: {}, pingModelCache: {} }; } let codexExecutorPromise: Promise | null = null; @@ -103,6 +117,32 @@ async function loadQuotaAutoPingExecutor(provider: string): Promise { + // Lazy for the same reason loadQuotaAutoPingExecutor is: this module sits on the + // instrumentation boot path and the model registry is a large import graph (#12074). + const { getProviderModels } = await import("@omniroute/open-sse/config/providerModels.ts"); + for (const model of getProviderModels(provider)) { + if (splitCodexReasoningSuffix(model.id).effort !== null) continue; + if (!isModelSelectable(provider, model.id, { asOf })) continue; + return model.id; + } + return null; +} + export function createDefaultQuotaAutoPingDeps(): QuotaAutoPingDeps { return { getSettings, @@ -115,6 +155,7 @@ export function createDefaultQuotaAutoPingDeps(): QuotaAutoPingDeps { getExecutor: loadQuotaAutoPingExecutor, canExecuteProvider: (provider) => getCircuitBreaker(provider).canExecute(), isConnectionUnavailableToAuxiliaryActivity, + resolvePingModel: resolveQuotaAutoPingModel, }; } @@ -184,7 +225,7 @@ function isRateLimited(connection: QuotaAutoPingConnection, nowMs: number): bool return Number.isFinite(untilMs) && untilMs > nowMs; } -function buildCodexPingBody(providerConfig: QuotaAutoPingProviderConfig): JsonRecord { +function buildCodexPingBody(providerConfig: ResolvedQuotaAutoPingProviderConfig): JsonRecord { return { model: providerConfig.pingModel, input: [ @@ -223,7 +264,7 @@ async function drainResponseBody(response: Response | undefined): Promise async function sendCodexPing( connection: QuotaAutoPingConnection, - providerConfig: QuotaAutoPingProviderConfig, + providerConfig: ResolvedQuotaAutoPingProviderConfig, deps: QuotaAutoPingDeps ): Promise { const executor = await deps.getExecutor("codex"); @@ -341,7 +382,7 @@ async function refreshConnectionForPing( async function pingConnection( connection: QuotaAutoPingConnection, provider: "codex", - providerConfig: QuotaAutoPingProviderConfig, + providerConfig: ResolvedQuotaAutoPingProviderConfig, deps: QuotaAutoPingDeps, state: QuotaAutoPingState, nowMs: number @@ -396,7 +437,33 @@ async function pingConnection( lastPingedResetKey: resetKey, lastPingAt: new Date(nowMs).toISOString(), }); - log.info(`${provider}:${current.id}: ping sent`, { resetAt }); + log.info(`${provider}:${current.id}: ping sent`, { resetAt, model: providerConfig.pingModel }); +} + +/** + * Resolve this tick's ping model and log only when the answer changes, so a + * catalog with nothing selectable produces one actionable warning rather than one + * per tick, and a model swap after an upgrade is visible in the log. + */ +async function resolveProviderPingModel( + provider: "codex", + deps: QuotaAutoPingDeps, + state: QuotaAutoPingState, + nowMs: number +): Promise { + const pingModel = await deps.resolvePingModel(provider, nowMs); + if (state.pingModelCache[provider] !== pingModel) { + state.pingModelCache[provider] = pingModel; + if (pingModel) { + log.info(`${provider}: ping model resolved`, { model: pingModel }); + } else { + log.warn( + `${provider}: no selectable ping model in the ${provider} catalog — auto-ping paused ` + + "until the model registry or lifecycle data lists a live model (#11905)" + ); + } + } + return pingModel; } function getEnabledConnectionIds( @@ -411,7 +478,7 @@ function getEnabledConnectionIds( async function pingProviderConnections( provider: "codex", - providerConfig: QuotaAutoPingProviderConfig, + providerConfig: ResolvedQuotaAutoPingProviderConfig, enabledMap: Record, deps: QuotaAutoPingDeps, state: QuotaAutoPingState, @@ -452,9 +519,11 @@ export async function runQuotaAutoPingTick( for (const [provider, providerConfig] of Object.entries(QUOTA_AUTOPING_PROVIDERS)) { const enabledMap = getEnabledConnectionIds(settings, providerConfig); if (Object.keys(enabledMap).length === 0) continue; + const pingModel = await resolveProviderPingModel(provider as "codex", deps, state, nowMs); + if (!pingModel) continue; await pingProviderConnections( provider as "codex", - providerConfig, + { ...providerConfig, pingModel }, enabledMap, deps, state, diff --git a/src/shared/constants/quotaAutoPing.ts b/src/shared/constants/quotaAutoPing.ts index 15e2380b9d..4cb90254af 100644 --- a/src/shared/constants/quotaAutoPing.ts +++ b/src/shared/constants/quotaAutoPing.ts @@ -27,7 +27,11 @@ export type QuotaAutoPingProviderConfig = { minPingIntervalMs: number; /** Skip the ping when a non-session quota (e.g. weekly) is already exhausted. */ skipWhenBlockingQuotaExhausted: true; - pingModel: string; + // The ping model is deliberately NOT part of this config (#11905): a pinned id + // outlives its vendor lifecycle (`gpt-5.1-codex-mini` was shut down 2026-07-23 + // while still hardcoded here). It is resolved per tick from the live provider + // catalog + lifecycle registry — see resolveQuotaAutoPingModel in + // src/lib/services/quotaAutoPing.ts. pingText: string; pingInstructions: string; pingReasoningEffort: string; @@ -41,7 +45,6 @@ export const QUOTA_AUTOPING_PROVIDERS: Record<"codex", QuotaAutoPingProviderConf resetAtDriftMs: 30_000, minPingIntervalMs: 10 * 60 * 1000, skipWhenBlockingQuotaExhausted: true, - pingModel: "gpt-5.1-codex-mini", pingText: "hi", pingInstructions: "Reply with OK.", pingReasoningEffort: "none", diff --git a/tests/unit/quota-auto-ping.test.ts b/tests/unit/quota-auto-ping.test.ts index 747ee99d46..2be93286e7 100644 --- a/tests/unit/quota-auto-ping.test.ts +++ b/tests/unit/quota-auto-ping.test.ts @@ -21,9 +21,13 @@ import path from "node:path"; // exercises the real DB, this only prevents an accidental production open). process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-quota-autoping-")); -const { runQuotaAutoPingTick, createQuotaAutoPingState } = +const { runQuotaAutoPingTick, createQuotaAutoPingState, resolveQuotaAutoPingModel } = await import("../../src/lib/services/quotaAutoPing.ts"); const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { getProviderModels } = await import("../../open-sse/config/providerModels.ts"); +const { isModelSelectable } = await import("../../open-sse/services/modelLifecycle.ts"); +const { splitCodexReasoningSuffix } = + await import("../../open-sse/executors/codex/reasoningSuffix.ts"); test.after(() => { resetDbInstance(); @@ -64,6 +68,10 @@ function baseDeps(overrides = {}) { }, canExecuteProvider: () => true, isConnectionUnavailableToAuxiliaryActivity: async () => false, + // #11905: real callers resolve the ping model from the live catalog; the fixture + // does the same so the default path is exercised, and tests override it to + // simulate an empty catalog. + resolvePingModel: resolveQuotaAutoPingModel, ...overrides, }; return { deps, calls }; @@ -458,3 +466,78 @@ test("does not consume a throttle slot when the connection is skipped before fet assert.deepEqual(order, []); }); + +const RETIRED_CODEX_PING_MODEL = "gpt-5.1-codex-mini"; + +test("resolves the Codex ping model from the live registry and lifecycle data (#11905)", async () => { + // #11905: the ping model used to be pinned to gpt-5.1-codex-mini, which OpenAI + // shut down on 2026-07-23 and which the repo's own lifecycle registry already + // rejects on the request path. The resolver must hand back a model that is (a) + // in the Codex catalog, (b) selectable by the same gate chatCore applies, and + // (c) a base id — the ping sets `reasoning.effort` itself, so an effort-suffixed + // variant would be redundant. + const model = await resolveQuotaAutoPingModel("codex", NOW_MS); + + assert.equal(typeof model, "string"); + assert.notEqual(model, RETIRED_CODEX_PING_MODEL); + assert.ok( + getProviderModels("codex").some((entry) => entry.id === model), + `${model} must come from the Codex catalog` + ); + assert.equal(isModelSelectable("codex", model, { asOf: NOW_MS }), true); + assert.equal(splitCodexReasoningSuffix(model).effort, null); +}); + +test("sends the ping with the runtime-resolved model instead of a hardcoded id (#11905)", async () => { + const { deps, calls } = baseDeps({ + getCodexUsage: async () => ({ + quotas: { + session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" }, + }, + }), + }); + const state = createQuotaAutoPingState(); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + const expected = await resolveQuotaAutoPingModel("codex", NOW_MS); + assert.equal(calls.executorExecute.length, 1); + const input = calls.executorExecute[0]; + assert.equal(input.model, expected); + assert.equal(input.body.model, expected); + assert.notEqual(input.model, RETIRED_CODEX_PING_MODEL); + assert.equal(state.pingModelCache.codex, expected); +}); + +test("pauses the provider without any network I/O when no selectable Codex model exists (#11905)", async () => { + // A retired or empty catalog must surface as a diagnostic, not as a blind retry + // of a dead id every failure-cooldown window: no throttle slot, no usage read, + // no executor call, no DB write — on the first tick or any later one. + const order = []; + const { deps, calls } = baseDeps({ + resolvePingModel: async () => null, + throttleQuotaFetch: async () => { + order.push("throttle"); + }, + getCodexUsage: async () => { + order.push("fetch"); + return { + quotas: { + session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" }, + }, + }; + }, + }); + const state = createQuotaAutoPingState(); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + await runQuotaAutoPingTick(deps, state, () => NOW_MS + 16 * 60 * 1000); + + assert.deepEqual(order, []); + assert.equal(calls.getExecutor.length, 0); + assert.equal(calls.updateProviderConnection.length, 0); + assert.equal(state.pingModelCache.codex, null); + assert.equal(state.failureCache["codex:codex-1"], undefined); +}); From 290f723ec0990e040bb1d0a1e990240ae154018e Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:12:55 +0200 Subject: [PATCH 10/34] fix(guardrails): keep auto combos exempt from the vision bridge credential guard (#12373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getBestVisionModel() validates a configured fixedModel with hasUsableCredentialsForModel() before short-circuiting (#8430). auto / auto/* ids are virtual combos with no provider row, so that check always reported a confirmed false and the combo was silently discarded in favour of global auto-selection — it never got the chance to rotate its members. This mirrors the exemption the reroute guard in visionBridge.ts already carries; concrete fixedModel ids keep the #8430 fall-through unchanged. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .../12373-vision-bridge-auto-combo-guard.md | 1 + src/lib/guardrails/visionBridgeRouter.ts | 67 ++++++++++-- .../guardrails/visionBridgeRouter.test.ts | 103 +++++++++++++----- 3 files changed, 135 insertions(+), 36 deletions(-) create mode 100644 changelog.d/fixes/12373-vision-bridge-auto-combo-guard.md diff --git a/changelog.d/fixes/12373-vision-bridge-auto-combo-guard.md b/changelog.d/fixes/12373-vision-bridge-auto-combo-guard.md new file mode 100644 index 0000000000..033324bfc5 --- /dev/null +++ b/changelog.d/fixes/12373-vision-bridge-auto-combo-guard.md @@ -0,0 +1 @@ +- **fix(guardrails):** keep `auto`/`auto/*` virtual combos exempt from the Vision Bridge `fixedModel` credential guard so a combo target is passed through instead of silently falling back to global auto-selection ([#12237](https://github.com/diegosouzapw/OmniRoute/issues/12237)) diff --git a/src/lib/guardrails/visionBridgeRouter.ts b/src/lib/guardrails/visionBridgeRouter.ts index 5660f1e557..c2dbbc9121 100644 --- a/src/lib/guardrails/visionBridgeRouter.ts +++ b/src/lib/guardrails/visionBridgeRouter.ts @@ -231,7 +231,9 @@ async function getVisionCapableModels( }; }); - return candidates.filter((candidate): candidate is VisionModelCandidate => candidate !== null); + return candidates.filter( + (candidate): candidate is VisionModelCandidate => candidate !== null + ); }) ); @@ -271,6 +273,51 @@ function selectBestModel( return scored[0]; } +/** + * (#12237) `auto` / `auto/*` ids are VIRTUAL combos: there is no provider + * row for "auto", so the credential check always reports `false` for them. + * Member-level credentials are enforced downstream when the combo + * dispatches (mirrors the reroute guard in visionBridge.ts), so a virtual + * combo must not be discarded by the #8430 short-circuit — otherwise the + * combo silently falls through to auto-selection and never rotates. It is + * still subject to the pool check in `getBestVisionModel`: when the ENTIRE + * vision pool is unusable there is nothing the combo could dispatch to, and + * returning the combo id would let a raw image reach a text-only backend + * (#8430). + * + * Returns the combo id when `fixedModel` is virtual, `undefined` otherwise. + */ +function resolveVirtualCombo(fixedModel: string | undefined): string | undefined { + return fixedModel === "auto" || fixedModel?.startsWith("auto/") ? fixedModel : undefined; +} + +/** + * Resolve a live selection-cache entry for `cacheKey`. + * + * Returns the id to hand back: the cached member for a concrete target, or + * `virtualCombo` once the cached member proves it still has usable + * credentials (the cache never re-validates credentials, and the caller + * exempts virtual combos from that check). A missing or expired entry yields + * `null`; an entry whose member is no longer available or usable is dropped + * so the pool is rescanned. + */ +async function resolveCachedSelection( + cacheKey: string, + virtualCombo: string | undefined, + deps: VisionBridgeRouterDeps +): Promise { + const cached = selectionCache.get(cacheKey); + if (!cached || cached.expiresAt <= Date.now()) return null; + + if (await cachedModelRemainsAvailable(cached.modelId, deps)) { + if (!virtualCombo) return cached.modelId; + const checkCreds = deps.hasUsableCredentials ?? hasUsableCredentialsForModel; + if ((await checkCreds(cached.modelId)) !== false) return virtualCombo; + } + selectionCache.delete(cacheKey); + return null; +} + /** * Get the best vision model for image description. * Respects fixed model override if configured, but validates it has usable @@ -283,12 +330,15 @@ export async function getBestVisionModel( deps: VisionBridgeRouterDeps = {} ): Promise { const fullConfig = { ...DEFAULT_ROUTER_CONFIG, ...config }; + const virtualCombo = resolveVirtualCombo(fullConfig.fixedModel); // If fixed model is configured, validate it has usable credentials first. // (#8430) An unreachable fixedModel (e.g. the default "openai/gpt-4o-mini" // on an instance with no OpenAI connection/key) must not short-circuit the // credential check — fall through to auto-selection instead. - if (fullConfig.fixedModel) { + // (#12237) A virtual combo is exempt here and goes through the pool + // selection below instead; see `resolveVirtualCombo`. + if (fullConfig.fixedModel && !virtualCombo) { const checkCreds = deps.hasUsableCredentials ?? hasUsableCredentialsForModel; const usable = await checkCreds(fullConfig.fixedModel); // Only skip credential validation when the check is indeterminate (null). @@ -304,13 +354,8 @@ export async function getBestVisionModel( fullConfig.excludedModels.length > 0 ? `excl:${[...fullConfig.excludedModels].sort().join(",")}` : "default"; - const cached = selectionCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - if (await cachedModelRemainsAvailable(cached.modelId, deps)) { - return cached.modelId; - } - selectionCache.delete(cacheKey); - } + const cachedPick = await resolveCachedSelection(cacheKey, virtualCombo, deps); + if (cachedPick) return cachedPick; // Get all vision-capable candidates const candidates = await getVisionCapableModels(deps); @@ -329,7 +374,9 @@ export async function getBestVisionModel( expiresAt: Date.now() + fullConfig.selectionCacheTtlMs, }); - return best.fullName; + // A virtual combo is returned as-is once the pool proves at least one + // vision-capable member is usable; it rotates its own members downstream. + return virtualCombo ?? best.fullName; } /** diff --git a/tests/unit/guardrails/visionBridgeRouter.test.ts b/tests/unit/guardrails/visionBridgeRouter.test.ts index c520aa834d..7c7f75052a 100644 --- a/tests/unit/guardrails/visionBridgeRouter.test.ts +++ b/tests/unit/guardrails/visionBridgeRouter.test.ts @@ -80,8 +80,65 @@ test("getBestVisionModel — should exclude specified models", async () => { test("getBestVisionModel — excludes a candidate with no usable active connection", async () => { // Every candidate reports a confirmed-unusable connection (`false`) -> // no candidate survives -> returns null instead of an unreachable default. + const model = await getBestVisionModel({}, { hasUsableCredentials: async () => false }); + assert.equal(model, null); +}); + +// `auto` / `auto/*` ids are VIRTUAL combos: there is no provider row for +// "auto", so hasUsableCredentialsForModel reports a confirmed `false` for the +// combo id itself while the pool members remain usable (indeterminate here). +const virtualComboOnlyUnusable = async (fullModelId: string) => + fullModelId === "auto" || fullModelId.startsWith("auto/") ? false : null; + +test("getBestVisionModel — keeps an auto/* virtual-combo fixedModel when its credential check is false (#12237)", async () => { + // The #8430 short-circuit must not discard the combo — member credentials + // are enforced downstream when the combo dispatches (same exemption as the + // reroute guard in visionBridge.ts). + const fixedModel = "auto/vision"; const model = await getBestVisionModel( - {}, + { fixedModel }, + { hasUsableCredentials: virtualComboOnlyUnusable } + ); + assert.equal(model, fixedModel); +}); + +test('getBestVisionModel — keeps a bare "auto" fixedModel when its credential check is false (#12237)', async () => { + const model = await getBestVisionModel( + { fixedModel: "auto" }, + { hasUsableCredentials: virtualComboOnlyUnusable } + ); + assert.equal(model, "auto"); +}); + +test("getBestVisionModel — keeps an auto/* virtual-combo fixedModel on a cached pool selection (#12237)", async () => { + // Warm the selection cache with a pool pick, then ask for the combo: the + // cache-hit branch must still hand back the combo, not the cached member. + const warm = await getBestVisionModel({}, { hasUsableCredentials: virtualComboOnlyUnusable }); + assert.ok(warm); + const model = await getBestVisionModel( + { fixedModel: "auto/vision" }, + { hasUsableCredentials: virtualComboOnlyUnusable } + ); + assert.equal(model, "auto/vision"); +}); + +test("getBestVisionModel — discards an auto/* virtual-combo fixedModel when the ENTIRE vision pool is unusable (#8430)", async () => { + // The exemption only bypasses the credential check on the virtual id. With + // no usable vision-capable member anywhere, the combo has nothing to + // dispatch to and must fall through to `null` so the caller describes + // instead of forwarding a raw image to a text-only backend. + const model = await getBestVisionModel( + { fixedModel: "auto/vision" }, + { hasUsableCredentials: async () => false } + ); + assert.equal(model, null); +}); + +test("getBestVisionModel — still falls through when a concrete fixedModel has no usable credentials (#8430)", async () => { + // Regression guard for the exemption above: a non-virtual fixedModel with + // a confirmed-unusable credential check must still be discarded. + const model = await getBestVisionModel( + { fixedModel: "openai/gpt-4o-mini" }, { hasUsableCredentials: async () => false } ); assert.equal(model, null); @@ -105,20 +162,17 @@ test("getBestVisionModel — does not query live catalogs for providers without assert.equal(catalogCalls, 0); }); -test( - "getBestVisionModel — selects a credentialed candidate over an uncredentialed higher-priority one", - async () => { - // openai (priority 50, would normally win) has no usable connection; - // every other vision-capable provider does. - const model = await getBestVisionModel( - {}, - { - hasUsableCredentials: async (fullModelId) => fullModelId.split("/")[0] !== "openai", - } - ); - assert.equal(model.startsWith("openai/"), false); - } -); +test("getBestVisionModel — selects a credentialed candidate over an uncredentialed higher-priority one", async () => { + // openai (priority 50, would normally win) has no usable connection; + // every other vision-capable provider does. + const model = await getBestVisionModel( + {}, + { + hasUsableCredentials: async (fullModelId) => fullModelId.split("/")[0] !== "openai", + } + ); + assert.equal(model.startsWith("openai/"), false); +}); test("getBestVisionModel — excludes static models missing from an authoritative live catalog", async () => { const model = await getBestVisionModel( @@ -188,17 +242,14 @@ test("getFallbackModels — should respect max fallback attempts", async () => { assert.ok(fallbacks.length <= 2); }); -test( - "getFallbackModels — does not include candidates with a confirmed-unusable connection", - async () => { - const fallbacks = await getFallbackModels( - "openai/gpt-4o-mini", - {}, - { hasUsableCredentials: async (fullModelId) => fullModelId.split("/")[0] !== "anthropic" } - ); - assert.ok(!fallbacks.some((m) => m.startsWith("anthropic/"))); - } -); +test("getFallbackModels — does not include candidates with a confirmed-unusable connection", async () => { + const fallbacks = await getFallbackModels( + "openai/gpt-4o-mini", + {}, + { hasUsableCredentials: async (fullModelId) => fullModelId.split("/")[0] !== "anthropic" } + ); + assert.ok(!fallbacks.some((m) => m.startsWith("anthropic/"))); +}); test("getFallbackModels — excludes fallbacks missing from an authoritative live catalog", async () => { const fallbacks = await getFallbackModels( From 674d39137d315e7936ec42ab850c60397ec36a5a Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:13:00 +0200 Subject: [PATCH 11/34] fix(cli): resolve the Bun preload path against the package root (#12387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under Bun the server child is spawned with --preload /open-sse/utils/setupPolyfill.ts, and all three spawn sites built that path next to the server bundle — but the polyfill only ships at the package root and nothing copies it into dist/. Every `bun install -g omniroute` start died with `error: preload not found`. The preload now resolves from the supervisor module's own location and is shared by the two serve.mjs spawns, with the child argv moved into a pure buildServerSpawnArgs() so both branches are directly assertable (same seam as #8131). Node users are unaffected. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- bin/cli/commands/serve.mjs | 14 +++-- bin/cli/runtime/processSupervisor.mjs | 39 +++++++----- .../fixes/12387-bun-preload-package-root.md | 1 + tests/unit/cli-process-supervisor.test.ts | 59 +++++++++++++++++++ 4 files changed, 94 insertions(+), 19 deletions(-) create mode 100644 changelog.d/fixes/12387-bun-preload-package-root.md diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index b58271e833..80e97725db 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -5,7 +5,11 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { platform, totalmem } from "node:os"; import { t } from "../i18n.mjs"; import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs"; -import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs"; +import { + ServerSupervisor, + detectMitmCrash, + BUN_PRELOAD_PATH, +} from "../runtime/processSupervisor.mjs"; import { isTermux } from "../../../scripts/build/postinstallSupport.mjs"; import { ensureAndroidCacheDir, @@ -306,7 +310,7 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) { process.versions.bun ? process.execPath : "node", [ ...(process.versions.bun - ? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")] + ? ["--preload", BUN_PRELOAD_PATH] : buildNodeHeapArgs(process.env, memoryLimit)), serverJs, ], @@ -331,7 +335,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, process.versions.bun ? process.execPath : "node", [ ...(process.versions.bun - ? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")] + ? ["--preload", BUN_PRELOAD_PATH] : buildNodeHeapArgs(process.env, memoryLimit)), serverJs, ], @@ -423,7 +427,9 @@ async function runWithSupervisor( if (detectMitmCrash(crashLog)) { try { const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); - const { updateSettings } = await import(pathToFileURL(join(PROJECT_ROOT, "src/lib/db/settings.ts")).href); + const { updateSettings } = await import( + pathToFileURL(join(PROJECT_ROOT, "src/lib/db/settings.ts")).href + ); updateSettings({ mitmEnabled: false }); } catch {} return "disable-mitm-and-retry"; diff --git a/bin/cli/runtime/processSupervisor.mjs b/bin/cli/runtime/processSupervisor.mjs index cf0ede4ce9..3d7bf39742 100644 --- a/bin/cli/runtime/processSupervisor.mjs +++ b/bin/cli/runtime/processSupervisor.mjs @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { writePidFile, cleanupPidFile, killAllSubprocesses, isPidRunning } from "../utils/pid.mjs"; import { RESTART_RESET_MS, @@ -17,6 +18,24 @@ import { const CRASH_LOG_LINES = 50; +const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +// Bun needs the Node-compat polyfill preloaded (#9761). The file ships at the +// package root via package.json "files" (see scripts/build/pack-artifact-policy.ts) +// and is never copied into dist/, so the path must resolve against the package +// root — resolving it next to the server bundle fails with "preload not found" (#11980). +export const BUN_PRELOAD_PATH = join(PACKAGE_ROOT, "open-sse", "utils", "setupPolyfill.ts"); + +/** + * Argument vector for the server child. Kept pure so tests can assert on it + * directly: the bare `import { spawn }` above cannot be intercepted without + * --experimental-test-module-mocks (same seam as #8131). + */ +export function buildServerSpawnArgs(serverPath, memoryLimit, env = process.env) { + return process.versions.bun + ? ["--preload", BUN_PRELOAD_PATH, serverPath] + : buildNodeRuntimeArgs(env, memoryLimit, serverPath); +} + export class ServerSupervisor { constructor({ serverPath, @@ -55,21 +74,11 @@ export class ServerSupervisor { // Node args come from buildNodeRuntimeArgs (#9209 IPv4-first DNS + #5238 // heap flag handling); the Bun branch keeps #9761's polyfill preload — // Bun does not accept the Node-only flags. - this.child = spawn( - process.execPath, - process.versions.bun - ? [ - "--preload", - join(dirname(this.serverPath), "open-sse/utils/setupPolyfill.ts"), - this.serverPath, - ] - : buildNodeRuntimeArgs(process.env, this.memoryLimit, this.serverPath), - { - cwd: dirname(this.serverPath), - env: this.env, - stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"], - } - ); + this.child = spawn(process.execPath, buildServerSpawnArgs(this.serverPath, this.memoryLimit), { + cwd: dirname(this.serverPath), + env: this.env, + stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"], + }); writePidFile("server", this.child.pid); diff --git a/changelog.d/fixes/12387-bun-preload-package-root.md b/changelog.d/fixes/12387-bun-preload-package-root.md new file mode 100644 index 0000000000..6d03d46579 --- /dev/null +++ b/changelog.d/fixes/12387-bun-preload-package-root.md @@ -0,0 +1 @@ +- **fix(cli):** Resolve Bun's `--preload` polyfill path against the package root instead of `dist/`, so `omniroute` installed with `bun install -g` no longer crashes at startup with `error: preload not found …/dist/open-sse/utils/setupPolyfill.ts` ([#11980](https://github.com/diegosouzapw/OmniRoute/issues/11980)) — thanks @joglomedia diff --git a/tests/unit/cli-process-supervisor.test.ts b/tests/unit/cli-process-supervisor.test.ts index 642a1c7182..ffb1efeef3 100644 --- a/tests/unit/cli-process-supervisor.test.ts +++ b/tests/unit/cli-process-supervisor.test.ts @@ -1,6 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import path from "node:path"; // #4425: the supervisor now waits for the listen port to free up before respawning. // Point that probe at the no-op port 0 so the restart tests don't open real sockets. @@ -276,3 +278,60 @@ test("writePidFile/readPidFile/cleanupPidFile operam por service", async () => { cleanupPidFile("mitm"); delete process.env.DATA_DIR; }); + +// --- #11980: Bun --preload must resolve against the package root, never dist/ --- + +const REPO_ROOT = path.resolve(import.meta.dirname, "../.."); + +function withBunRuntime(fn: () => T): T { + const versions = process.versions as Record; + const hadBun = Object.prototype.hasOwnProperty.call(versions, "bun"); + const previous = versions.bun; + versions.bun = "1.2.0"; + try { + return fn(); + } finally { + if (hadBun) versions.bun = previous; + else delete versions.bun; + } +} + +test("buildServerSpawnArgs under Bun preloads the package-root polyfill, not dist/ (#11980)", async () => { + const { buildServerSpawnArgs } = await import("../../bin/cli/runtime/processSupervisor.mjs"); + // The published layout: bin/ + open-sse/ + dist/server.js are siblings under the package root. + const serverPath = path.join(REPO_ROOT, "dist", "server.js"); + + const args = withBunRuntime(() => buildServerSpawnArgs(serverPath, 512)); + + const expectedPreload = path.join(REPO_ROOT, "open-sse", "utils", "setupPolyfill.ts"); + assert.deepEqual(args, ["--preload", expectedPreload, serverPath]); + assert.ok(fs.existsSync(args[1]), `Bun --preload target must exist on disk: ${args[1]}`); +}); + +test("buildServerSpawnArgs under Node keeps the runtime args and never passes --preload (#11980)", async () => { + const { buildServerSpawnArgs } = await import("../../bin/cli/runtime/processSupervisor.mjs"); + const { buildNodeRuntimeArgs } = await import("../../scripts/build/runtime-env.mjs"); + const serverPath = "/fake/dist/server.js"; + const env = {}; + + const args = buildServerSpawnArgs(serverPath, 512, env); + + assert.deepEqual(args, buildNodeRuntimeArgs(env, 512, serverPath)); + assert.ok(!args.includes("--preload")); +}); + +test("every Bun server spawn (supervisor, --daemon, --no-recovery) uses the shared package-root preload (#11980)", () => { + const supervisorSrc = fs.readFileSync( + path.join(REPO_ROOT, "bin/cli/runtime/processSupervisor.mjs"), + "utf8" + ); + const serveSrc = fs.readFileSync(path.join(REPO_ROOT, "bin/cli/commands/serve.mjs"), "utf8"); + + assert.match(supervisorSrc, /spawn\(\s*process\.execPath,\s*buildServerSpawnArgs\(/); + assert.equal( + (serveSrc.match(/"--preload",\s*BUN_PRELOAD_PATH\b/g) ?? []).length, + 2, + "serve.mjs --daemon and --no-recovery must both preload BUN_PRELOAD_PATH" + ); + assert.doesNotMatch(serveSrc, /join\(APP_DIR,\s*"open-sse/); +}); From 1146c9b5b5149744e0a60102510d4fc9c10c0c5b Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:13:27 +0200 Subject: [PATCH 12/34] fix(catalog): write NUL key separators as escape sequences instead of raw bytes (#12403) Four files embedded the U+0000 separator of a memo/group key as a raw NUL byte rather than the \\0 escape the codebase uses for the same idiom elsewhere. The runtime value is identical, but the raw byte trips the binary heuristics of git, GitHub and ripgrep: git diff --numstat reported `- -`, the introducing PRs rendered three of the files as "Binary file not shown", and rg silently skipped them in recursive mode. Rewritten as escapes, with a guard test keeping raw NUL bytes out of src/, open-sse/ and tests/. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .../fixes/12403-catalog-nul-literal.md | 1 + src/app/api/v1/models/catalog.ts | 10 +- .../videoBridgePromotionAggregator.ts | Bin 5043 -> 5052 bytes src/lib/providers/serviceKindIndex.ts | Bin 1369 -> 1374 bytes tests/unit/json-size-exactness.test.ts | Bin 6412 -> 6427 bytes tests/unit/source-no-raw-nul-bytes.test.ts | 95 ++++++++++++++++++ 6 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fixes/12403-catalog-nul-literal.md create mode 100644 tests/unit/source-no-raw-nul-bytes.test.ts diff --git a/changelog.d/fixes/12403-catalog-nul-literal.md b/changelog.d/fixes/12403-catalog-nul-literal.md new file mode 100644 index 0000000000..2a2da60cf1 --- /dev/null +++ b/changelog.d/fixes/12403-catalog-nul-literal.md @@ -0,0 +1 @@ +- **fix(catalog):** write the NUL separator of the catalog connection memo key, the provider serviceKind memo key, the Video Bridge promotion group key and a JSON-exactness test fixture as the `\u0000` escape instead of a raw byte — same runtime value, but the raw byte made git, GitHub and ripgrep treat those files as binary (hidden PR diffs, silently skipped searches); a guard test now keeps raw NUL bytes out of `src/`, `open-sse/` and `tests/` (#12403 — thanks @pacocartones) diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 2d97747f58..e5e427f54f 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -498,7 +498,7 @@ async function buildUnifiedModelsResponseCore( const cacheKey = keys .filter((k): k is string => Boolean(k)) .sort() - .join("�"); + .join("\u0000"); const cached = connectionsForProviderCache.get(cacheKey); if (cached) return cached; const seen = new Set(); @@ -925,12 +925,8 @@ async function buildUnifiedModelsResponseCore( context_length: contextLength, max_input_tokens: contextLength, max_output_tokens: maxOutputTokens, - ...(autoInputModalities.length > 0 - ? { input_modalities: autoInputModalities } - : {}), - ...(autoOutputModalities.length > 0 - ? { output_modalities: autoOutputModalities } - : {}), + ...(autoInputModalities.length > 0 ? { input_modalities: autoInputModalities } : {}), + ...(autoOutputModalities.length > 0 ? { output_modalities: autoOutputModalities } : {}), capabilities: autoCapabilities, }); } catch (err) { diff --git a/src/lib/guardrails/videoBridgePromotionAggregator.ts b/src/lib/guardrails/videoBridgePromotionAggregator.ts index 34dc271851940773a59d2213c313fae5a1716130..f3032c81dbf39b35fa6be3827421356b4627fc79 100644 GIT binary patch delta 29 jcmdn2zDIq76B`Scf`YP6Wiq59Fm*k*`6>nGHmwbD&_ { + const files: string[] = []; + for (const dir of SCAN_DIRS) { + const abs = path.join(ROOT, dir); + if (fs.existsSync(abs)) walk(abs, files); + } + assert.ok(files.length > 100, `expected to scan the source tree, scanned ${files.length} files`); + + const offenders = files.flatMap(rawNulLocations).sort(); + assert.deepEqual( + offenders, + [], + `raw NUL bytes make git/GitHub/ripgrep treat the file as binary; write the separator as "\\u0000" instead:\n ${offenders.join("\n ")}` + ); +}); + +test("serviceKindIndex memo key keeps (providerId, declared) pairs distinct that plain concatenation would merge", async () => { + const { getProviderServiceKinds } = await import("../../src/lib/providers/serviceKindIndex.ts"); + // "openai" + "llm" and "openaillm" + "" concatenate to the same string; the NUL separator + // must keep them apart, otherwise the second call would hit the first call's memo entry. + const openai = getProviderServiceKinds("openai", ["llm"]); + const unknown = getProviderServiceKinds("openaillm", undefined); + assert.ok(openai.includes("llm")); + assert.ok(!unknown.includes("llm"), "memo entry leaked across a colliding key"); + assert.notDeepEqual(openai, unknown); +}); + +test("videoBridgePromotionAggregator groups (caseId, model) pairs distinct that plain concatenation would merge", async () => { + const { aggregatePromotionObservations } = + await import("../../src/lib/guardrails/videoBridgePromotionAggregator.ts"); + const aggregates = aggregatePromotionObservations([ + { caseId: "c1", metrics: { latencyMs: 100 }, model: "m1" }, + { caseId: "c", metrics: { latencyMs: 200 }, model: "1m1" }, + ]); + assert.equal( + aggregates.length, + 2, + "two observations with colliding concatenated keys must form two groups" + ); + assert.deepEqual(aggregates.map((a) => [a.caseId, a.model, a.sampleCount]).sort(), [ + ["c", "1m1", 1], + ["c1", "m1", 1], + ]); +}); From eb09e894cbeb5f121fb0a49bcf8fc3271a1c016e Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:13:31 +0200 Subject: [PATCH 13/34] docs: align env and troubleshooting docs with the code (#12404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four documentation claims contradicted the code: .env.example called OMNIROUTE_USE_TURBOPACK dev-only and said the production build still uses webpack (it reads the same flag and defaults to Turbopack); the README's Bun section said `bun run build` auto-detects Bun and switches to Webpack (only `bun run dev` does — the production bundler is decided by the flag alone); TROUBLESHOOTING.md gave OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT a default of 1 when unset means no request-count cap; and it quoted the pre-#12223 wording of the structural 503 chat_admission_busy message. The Retry-After bullet in the same section is deliberately untouched because #12395 rewrites it. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .env.example | 7 ++++--- README.md | 2 +- .../maintenance/12404-env-and-troubleshooting-drift.md | 1 + docs/guides/TROUBLESHOOTING.md | 6 +++--- 4 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 changelog.d/maintenance/12404-env-and-troubleshooting-drift.md diff --git a/.env.example b/.env.example index 9b93361457..e4ef62f091 100644 --- a/.env.example +++ b/.env.example @@ -240,7 +240,7 @@ PORT=20128 # Used by: src/app/api/v1/relay/chat/completions/route.ts # RELAY_IP_PER_MINUTE=30 -# Bundler selection for `npm run dev`. Set to 0 to fall back to webpack. +# Bundler selection for `npm run dev` and `npm run build`. Set to 0 to fall back to webpack. # Default is 1 (Turbopack). PR #4092 had forced webpack because earlier # Turbopack 16.2.x panicked on the OmniRoute module graph with "internal error: # entered unreachable code: there must be a path to a root" @@ -250,8 +250,9 @@ PORT=20128 # /api/v1/models, /api/mcp) and repeated HMR rebuilds: zero panics. Turbopack # also keeps dev memory far lower on the edit→rebuild loop (HMR rebuild RSS stays # ~flat vs webpack's monotonic growth), which mitigates the dev-server OOM on -# this 60+ route app. The production build still uses webpack (build pipeline is -# unaffected by this dev-only flag). +# this 60+ route app. The production build (scripts/build/build-next-isolated.mjs) +# reads the same flag: Turbopack by default, 0 builds with webpack (`npm run +# build:contributor` sets it for you). OMNIROUTE_USE_TURBOPACK=1 # Disable systemd sd_notify (Type=notify / WatchdogSec=) even when running diff --git a/README.md b/README.md index cec7cb1924..8fe723c816 100644 --- a/README.md +++ b/README.md @@ -1020,7 +1020,7 @@ Full table: [Docker Guide — runtime RAM](docs/guides/DOCKER_GUIDE.md#runtime-r Standard `bun install` and global installation (`bun install -g omniroute`) are supported via Bun runtime detection: - **Built-in `bun:sqlite`**: OmniRoute uses Bun's built-in `bun:sqlite` driver when running under Bun, falling back to `better-sqlite3` on Node.js or `sql.js`. -- **Automatic Webpack bundler selection**: Development (`bun run dev`) and production builds (`bun run build`) automatically detect Bun and disable Turbopack in favor of Webpack to prevent native V8 binding incompatibilities. +- **Automatic Webpack bundler selection in dev**: Development (`bun run dev`) automatically detects Bun and disables Turbopack in favor of Webpack to prevent native V8 binding incompatibilities. Production builds (`bun run build`) follow `OMNIROUTE_USE_TURBOPACK` exactly as on Node: Turbopack by default, `OMNIROUTE_USE_TURBOPACK=0` to build with Webpack (`Dockerfile.bun` exposes it as a `--build-arg`). - **Dedicated Bun Dockerfile**: Multi-stage `Dockerfile.bun` for native Bun production deployments (`docker build -f Dockerfile.bun -t omniroute:bun .`). ```bash diff --git a/changelog.d/maintenance/12404-env-and-troubleshooting-drift.md b/changelog.d/maintenance/12404-env-and-troubleshooting-drift.md new file mode 100644 index 0000000000..527943736e --- /dev/null +++ b/changelog.d/maintenance/12404-env-and-troubleshooting-drift.md @@ -0,0 +1 @@ +- **docs(env):** align `.env.example`, the README Bun section, and the troubleshooting guide with the code: `OMNIROUTE_USE_TURBOPACK` also governs `npm run build` (not dev-only), `bun run build` follows that flag instead of auto-selecting Webpack, `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` is unset by default (no request-count cap), and the structural `503 chat_admission_busy` message matches `chatAdmissionResponses.ts` (#12404 — thanks @pacocartones) diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index c08476b5f3..4e0cb8883b 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -52,7 +52,7 @@ Common problems and solutions for OmniRoute. ```bash export OMNIROUTE_ROTATE_ON_400=true # hop to another model/provider on 400/401 (skips broken passthrough models) -export OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=4 # raise the heavyweight admission ceiling (default 1) so long-context bursts are not rejected +export OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=4 # explicit heavyweight admission ceiling (unset by default: no request-count cap, see note below) export OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=5000 # longer bounded wait for heavyweight capacity instead of an immediate retryable 503 ``` @@ -556,8 +556,8 @@ The byte-based response body is: ``` The structure-based response uses the same type and code, with the message -`Structurally heavy chat request capacity is busy; retry shortly.` and -`reason: "structure_limit"`. +`Local chat admission capacity is busy for this structurally heavy request; upstream provider routing was not attempted. Retry shortly.` +and `reason: "structure_limit"`. At the default thresholds, a request is structurally heavy when it has at least `200` messages, at least `64` tools, or at least `32,000` estimated tokens, or when bounded structure estimation exhausts its bounds of `10,000` visited nodes or depth `12`. From bb5c6d148eef5bed032609062439f2255bb5077d Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:13:37 +0200 Subject: [PATCH 14/34] feat(gamification): enforce the per-key XP rate limit on the award path (#12390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateScoreChange() — the documented 1000 XP/min per-API-key limit plus the velocity anomaly check — was exported but never called, so the award path applied every XP delta unconditionally. It now runs before addXp; a rejected award is logged at warn level and skipped, and the fire-and-forget path never throws. The second finding is the one that made the first invisible: getRecentXp's window query was inert. created_at is stored by the table default as YYYY-MM-DD HH:MM:SS and was compared lexically against a JS ISO string, so same-day rows never matched and the limit could not have tripped even if it had been wired. The window start is now computed in SQLite, matching the style computeZScore already used. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- ...2390-gamification-anti-cheat-award-path.md | 1 + src/lib/gamification/antiCheat.ts | 10 ++- src/lib/gamification/events.ts | 10 +++ tests/unit/gamification/antiCheat.test.ts | 33 ++++++++ tests/unit/gamification/events.test.ts | 79 +++++++++++++++++++ 5 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 changelog.d/features/12390-gamification-anti-cheat-award-path.md diff --git a/changelog.d/features/12390-gamification-anti-cheat-award-path.md b/changelog.d/features/12390-gamification-anti-cheat-award-path.md new file mode 100644 index 0000000000..20a44e5b04 --- /dev/null +++ b/changelog.d/features/12390-gamification-anti-cheat-award-path.md @@ -0,0 +1 @@ +- **feat(gamification):** enforce the documented 1000 XP/min per-API-key anti-cheat rate limit on the XP award path; over-limit awards are logged and skipped instead of persisted, and the sliding window now matches the timestamp format stored in `xp_audit_log` ([#2403](https://github.com/diegosouzapw/OmniRoute/issues/2403)) diff --git a/src/lib/gamification/antiCheat.ts b/src/lib/gamification/antiCheat.ts index beba55ccf8..1ef7b41a46 100644 --- a/src/lib/gamification/antiCheat.ts +++ b/src/lib/gamification/antiCheat.ts @@ -140,13 +140,17 @@ async function computeZScore(apiKeyId: string): Promise { */ async function getRecentXp(apiKeyId: string, windowMs: number): Promise { const d = db(); - const since = new Date(Date.now() - windowMs).toISOString(); + // xp_audit_log.created_at is written by the table default datetime('now') as + // "YYYY-MM-DD HH:MM:SS", and TEXT compares are lexical. Computing the window start in + // SQLite keeps both sides in the same format (an ISO "T…Z" string from JS never matched + // same-day rows, so the window read as empty). + const windowStart = `-${Math.ceil(windowMs / 1000)} seconds`; const row = d .prepare( - "SELECT COALESCE(SUM(xp_earned), 0) AS total FROM xp_audit_log WHERE api_key_id = ? AND created_at > ?" + "SELECT COALESCE(SUM(xp_earned), 0) AS total FROM xp_audit_log WHERE api_key_id = ? AND created_at > datetime('now', ?)" ) - .get(apiKeyId, since) as { total: number }; + .get(apiKeyId, windowStart) as { total: number }; return row.total; } diff --git a/src/lib/gamification/events.ts b/src/lib/gamification/events.ts index cde799f5c0..9bd52a8d24 100644 --- a/src/lib/gamification/events.ts +++ b/src/lib/gamification/events.ts @@ -44,6 +44,16 @@ export async function emitGamificationEvent(params: { // 1. Award XP const xpAmount = getXpForAction(action); if (xpAmount > 0) { + // Anti-cheat gate (#2403): the per-key 1000 XP/min rate limit and the z-score anomaly + // check run before anything is persisted. A rejected award is dropped and logged — the + // caller is fire-and-forget, so this must never throw. + const { validateScoreChange } = await import("./antiCheat"); + const verdict = await validateScoreChange(apiKeyId, action, xpAmount); + if (!verdict.allowed) { + log.warn("events.award_rejected", { apiKeyId, action, xpAmount, reason: verdict.reason }); + return; + } + const { addXp } = await import("../db/gamification"); addXp(apiKeyId, action, xpAmount, metadata ? JSON.stringify(metadata) : undefined); diff --git a/tests/unit/gamification/antiCheat.test.ts b/tests/unit/gamification/antiCheat.test.ts index 36e46ef59c..e8b16e3653 100644 --- a/tests/unit/gamification/antiCheat.test.ts +++ b/tests/unit/gamification/antiCheat.test.ts @@ -1,6 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { validateScoreChange, getAnomalies } from "../../../src/lib/gamification/antiCheat"; +import { getDbInstance } from "../../../src/lib/db/core"; describe("Anti-Cheat", () => { describe("validateScoreChange", () => { @@ -14,6 +15,38 @@ describe("Anti-Cheat", () => { assert.equal(result.allowed, false); assert.ok(result.reason); }); + + // #2403: rows written through the table default (datetime('now'), "YYYY-MM-DD HH:MM:SS") + // must count toward the sliding window. Compares are lexical on TEXT, so the window + // boundary has to use the same format as the stored timestamps. + it("counts XP persisted inside the window toward the per-minute limit", async () => { + const db = getDbInstance(); + const key = `window-hit-${Date.now()}`; + db.prepare("INSERT INTO xp_audit_log (api_key_id, action, xp_earned) VALUES (?, ?, ?)").run( + key, + "request", + 1000 + ); + + const result = await validateScoreChange(key, "request", 1); + assert.equal(result.allowed, false); + assert.match(result.reason ?? "", /Rate limit exceeded: 1001 > 1000 XP\/min/); + + db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(key); + }); + + it("ignores XP persisted before the window", async () => { + const db = getDbInstance(); + const key = `window-miss-${Date.now()}`; + db.prepare( + "INSERT INTO xp_audit_log (api_key_id, action, xp_earned, created_at) VALUES (?, ?, ?, datetime('now', '-2 minutes'))" + ).run(key, "request", 1000); + + const result = await validateScoreChange(key, "request", 1); + assert.equal(result.allowed, true); + + db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(key); + }); }); describe("getAnomalies", () => { diff --git a/tests/unit/gamification/events.test.ts b/tests/unit/gamification/events.test.ts index 4b08c11607..0e2a9b6ed3 100644 --- a/tests/unit/gamification/events.test.ts +++ b/tests/unit/gamification/events.test.ts @@ -42,4 +42,83 @@ describe("Gamification Events", () => { // Cleanup db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey); }); + + // #2403: the per-key rate limit (1000 XP/min) documented for the anti-cheat layer must + // actually gate the award path. Each case seeds xp_audit_log directly so the window state + // is deterministic, then emits a 1 XP "request" event. + describe("anti-cheat gate on the award path", () => { + function seedXp(apiKeyId: string, xp: number, createdAtModifier?: string): void { + const db = getDbInstance(); + if (createdAtModifier) { + db.prepare( + "INSERT INTO xp_audit_log (api_key_id, action, xp_earned, created_at) VALUES (?, ?, ?, datetime('now', ?))" + ).run(apiKeyId, "seed", xp, createdAtModifier); + } else { + db.prepare("INSERT INTO xp_audit_log (api_key_id, action, xp_earned) VALUES (?, ?, ?)").run( + apiKeyId, + "seed", + xp + ); + } + } + + function countRequestRows(apiKeyId: string): number { + const row = getDbInstance() + .prepare( + "SELECT COUNT(*) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = 'request'" + ) + .get(apiKeyId) as { count: number }; + return row.count; + } + + function leaderboardScore(apiKeyId: string): number | undefined { + const row = getDbInstance() + .prepare("SELECT score FROM leaderboard WHERE api_key_id = ? AND scope = 'global'") + .get(apiKeyId) as { score: number } | undefined; + return row?.score; + } + + function cleanup(apiKeyId: string): void { + const db = getDbInstance(); + db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(apiKeyId); + db.prepare("DELETE FROM leaderboard WHERE api_key_id = ?").run(apiKeyId); + db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(apiKeyId); + } + + it("skips the award once the key has exhausted 1000 XP inside the last minute", async () => { + const key = `rate-limited-${Date.now()}`; + seedXp(key, 1000); + + await assert.doesNotReject(emitGamificationEvent({ apiKeyId: key, action: "request" })); + + assert.equal(countRequestRows(key), 0, "over-limit award must not be persisted"); + assert.equal( + leaderboardScore(key), + undefined, + "over-limit award must not reach the leaderboard" + ); + cleanup(key); + }); + + it("applies the award when the window total stays at or below the limit", async () => { + const key = `under-limit-${Date.now()}`; + seedXp(key, 999); // 999 + 1 == 1000, which is allowed (limit is exclusive of the cap) + + await emitGamificationEvent({ apiKeyId: key, action: "request" }); + + assert.equal(countRequestRows(key), 1); + assert.equal(leaderboardScore(key), 1); + cleanup(key); + }); + + it("ignores XP that was earned before the one-minute window", async () => { + const key = `stale-window-${Date.now()}`; + seedXp(key, 1000, "-2 minutes"); + + await emitGamificationEvent({ apiKeyId: key, action: "request" }); + + assert.equal(countRequestRows(key), 1, "stale XP must not block a fresh award"); + cleanup(key); + }); + }); }); From c8e2cb3ffcae67098f5fe196261f392144cd5366 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:13:41 +0200 Subject: [PATCH 15/34] fix(db): install busy_timeout before the connection's first statement (#12394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDbInstance() ran PRAGMA journal_mode = WAL as the connection's first statement, before PRAGMA busy_timeout, and openSqliteDatabase() passes no driver-level timeout. A process opening the database while another closed its WAL connection — checkpoint plus WAL delete hold an EXCLUSIVE lock for a few hundred microseconds — therefore died with `database is locked` instead of waiting. That is the flake behind exclusive-connection-leases.test.ts on release/v3.8.51 runs 33525300898 and 33493797519 and on unrelated PR runs. The second half is worse than the flake: isTransientProbeError matched /SQLITE_BUSY/ against error.message, but both drivers report the plain text `database is locked` and put the code in .code / .errcode. A transient lock during the corruption probe therefore took the corrupt-database path and renamed the file to storage.sqlite.probe-failed-… with "Manual recovery required". The probe now recognises the drivers' real BUSY/PROTOCOL/IOERR signals. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- ...394-deflake-exclusive-connection-leases.md | 1 + src/lib/db/core.ts | 7 +- src/lib/db/probeUtils.ts | 14 ++- ...-open-first-statement-busy-timeout.test.ts | 118 ++++++++++++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/12394-deflake-exclusive-connection-leases.md create mode 100644 tests/unit/db-open-first-statement-busy-timeout.test.ts diff --git a/changelog.d/fixes/12394-deflake-exclusive-connection-leases.md b/changelog.d/fixes/12394-deflake-exclusive-connection-leases.md new file mode 100644 index 0000000000..a8f145d6d9 --- /dev/null +++ b/changelog.d/fixes/12394-deflake-exclusive-connection-leases.md @@ -0,0 +1 @@ +- **fix(db):** install `busy_timeout` before the SQLite connection's first statement so a process opening the database while another one closes its WAL connection waits out the transient EXCLUSIVE lock instead of dying with `database is locked`, and recognise the drivers' real BUSY/PROTOCOL/IOERR errors as transient in the corruption probe so the same lock no longer renames the database away as corrupt; deflakes `cross-process contenders never both acquire the same connection` (#12394 — thanks @pacocartones) diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 43132c6f7e..d636899a32 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -1277,13 +1277,18 @@ export function getDbInstance(): SqliteDatabase { // selected on the server's primary DB path too, not only the backup-import // route. console.log(`[DB] Driver: ${db.driver} | file: ${sqliteFile}`); - db.pragma("journal_mode = WAL"); // better-sqlite3 is synchronous, so a contended write parks the Node event loop for up to // busy_timeout ms (a 0-CPU freeze that stacks under load → /health stops responding). The // hot-path writers here (usage_history, call_logs) are best-effort and the WinUI host opens // the same DB, so cap the block at 2s instead of 5s: normal writes complete in <1ms, and a // contended op can no longer freeze the loop past the host watchdog's 6s liveness probe. + // + // Install the busy handler before the connection's first statement. `journal_mode = WAL` + // needs a SHARED lock, and another process closing its WAL connection briefly holds the + // file EXCLUSIVE (checkpoint + WAL delete); node:sqlite opens with busy timeout 0, so with + // the pragmas in the other order that window surfaced as `database is locked` at startup. db.pragma("busy_timeout = 2000"); + db.pragma("journal_mode = WAL"); db.pragma("synchronous = NORMAL"); db.pragma(`cache_size = -${DEFAULT_DATABASE_SETTINGS.optimization.cacheSize}`); db.pragma("temp_store = MEMORY"); diff --git a/src/lib/db/probeUtils.ts b/src/lib/db/probeUtils.ts index e5f2bd3885..4f0df076e8 100644 --- a/src/lib/db/probeUtils.ts +++ b/src/lib/db/probeUtils.ts @@ -22,7 +22,19 @@ import path from "node:path"; */ export function isTransientProbeError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); - return /SQLITE_BUSY|SQLITE_PROTOCOL|SQLITE_IOERR|ENOENT/i.test(message); + if (/SQLITE_BUSY|SQLITE_PROTOCOL|SQLITE_IOERR|ENOENT|database is locked/i.test(message)) { + return true; + } + // The real drivers do not put the result-code name in the message: both + // report plain "database is locked" for SQLITE_BUSY. better-sqlite3 carries + // the name in `code`, node:sqlite the numeric primary code in `errcode` + // (5 BUSY, 10 IOERR, 15 PROTOCOL; extended codes live in the high bits). + // Without this, a transient lock during the probe was classified as + // corruption and the database was renamed away. + if (typeof error !== "object" || error === null) return false; + const { code, errcode } = error as { code?: unknown; errcode?: unknown }; + if (typeof code === "string" && /^SQLITE_(BUSY|PROTOCOL|IOERR)/.test(code)) return true; + return typeof errcode === "number" && [5, 10, 15].includes(errcode & 0xff); } /** diff --git a/tests/unit/db-open-first-statement-busy-timeout.test.ts b/tests/unit/db-open-first-statement-busy-timeout.test.ts new file mode 100644 index 0000000000..5ff8661eb9 --- /dev/null +++ b/tests/unit/db-open-first-statement-busy-timeout.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import test from "node:test"; + +// Regression for the "cross-process contenders never both acquire the same +// connection" flake (tests/unit/exclusive-connection-leases.test.ts): the +// faster contender exited while the slower one was still opening, and a +// closing WAL connection briefly takes an EXCLUSIVE lock on the database file +// (checkpoint + WAL delete). getDbInstance() issued `PRAGMA journal_mode = WAL` +// — the connection's first statement, which needs a SHARED lock — *before* +// installing the busy handler, so on node:sqlite (busy timeout 0 by default) +// the slower process died with `database is locked` instead of waiting the few +// milliseconds the lock is held. The corruption probe that runs first had the +// same gap: it only recognised BUSY when the driver put "SQLITE_BUSY" in the +// message, which neither node:sqlite nor better-sqlite3 does, so a transient +// lock there renamed the database away as corrupt. +// +// The holder below reproduces the lock deterministically (WAL + EXCLUSIVE +// locking mode keeps the file lock from the first read until close) and +// releases it only after the child has reached getDbInstance(), so the open +// path meets the lock on every run and must wait it out via busy_timeout. + +const CORE_URL = new URL("../../src/lib/db/core.ts", import.meta.url).href; +// Longer than the probe's first transient-retry delay (500ms), so the main +// open still meets the lock after the probe has retried; well inside the +// 2000ms busy_timeout getDbInstance() configures, so the fixed open waits it +// out instead of timing out. +const HOLD_MS = 1200; + +type ChildResult = { code: number | null; stdout: string; stderr: string }; + +function runChild(script: string, env: Record): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ["--import", "tsx/esm", "--input-type=module", "-e", script], + { + cwd: process.cwd(), + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + } + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk)); + child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk)); + child.once("error", reject); + child.once("exit", (code) => resolve({ code, stdout, stderr })); + }); +} + +async function waitForFile(file: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (!fs.existsSync(file)) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${file}`); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +test("getDbInstance() waits out a transient exclusive file lock instead of failing on its first statement", async () => { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-open-busy-")); + const sqliteFile = path.join(dataDir, "storage.sqlite"); + const ready = path.join(dataDir, "ready"); + const env = { DATA_DIR: dataDir, OPEN_READY_FILE: ready }; + let holder: DatabaseSync | null = null; + try { + // Seed a real database (schema + migrations) so the corruption probe in + // getDbInstance() sees a healthy file rather than a skeleton. + const seed = await runChild( + `const core = await import(${JSON.stringify(CORE_URL)}); core.getDbInstance(); core.closeDbInstance();`, + env + ); + assert.equal(seed.code, 0, seed.stderr); + + // Hold the database file's EXCLUSIVE lock from another connection, exactly + // what a closing WAL connection holds while it checkpoints and deletes the WAL. + holder = new DatabaseSync(sqliteFile); + holder.exec("PRAGMA locking_mode = EXCLUSIVE"); + holder.prepare("SELECT count(*) AS n FROM sqlite_master").get(); + + const opener = runChild( + [ + `import fs from "node:fs";`, + `const core = await import(${JSON.stringify(CORE_URL)});`, + `fs.writeFileSync(process.env.OPEN_READY_FILE, "ready");`, + `const busyTimeout = core.getDbInstance().pragma("busy_timeout", { simple: true });`, + `core.closeDbInstance();`, + `process.stdout.write(JSON.stringify({ busyTimeout }) + "\\n");`, + ].join("\n"), + env + ); + await waitForFile(ready, 30_000); + await new Promise((resolve) => setTimeout(resolve, HOLD_MS)); + holder.close(); + holder = null; + + const result = await opener; + assert.equal(result.code, 0, `open failed under a transient lock: ${result.stderr}`); + // The probe may log that it met the lock; what must not happen is the + // corruption path (rename + manual-recovery abort) or a failed main open. + assert.doesNotMatch(result.stderr, /Renamed corrupt DB|probe-failed|Manual recovery/); + assert.deepEqual( + fs.readdirSync(dataDir).filter((name) => name.includes("probe-failed")), + [], + "a transient lock must not rename the database away as corrupt" + ); + const summary = result.stdout.match(/^\{"busyTimeout":(\d+)\}$/m); + assert.ok(summary, `child did not report its busy timeout: ${result.stdout}`); + assert.equal(Number(summary[1]), 2000); + } finally { + holder?.close(); + fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); From 3a5641839e018cbf3e8f1fe311734080d8261648 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:14:05 +0200 Subject: [PATCH 16/34] fix(sse): name the shadowed custom provider node in the no-credentials error (#12365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a built-in provider's id or alias reserves the prefix of an existing OpenAI/Anthropic-compatible node — v3.8.50 added openference with alias of, shadowing nodes created earlier with prefix of — the runtime error `No active credentials for provider: openference` gave the operator nothing to act on. It now explains that the prefix routed to the built-in, names the shadowed node, and logs an AUTH warning. Precedence is unchanged and the lookup runs only on the credential-failure path when no connection was tried, so the hot routing path is byte-identical. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .../12365-custom-provider-prefix-shadowing.md | 1 + src/sse/handlers/chat.ts | 9 +- src/sse/handlers/chatHelpers.ts | 100 ++++++++- ...om-provider-prefix-shadowing-11943.test.ts | 195 ++++++++++++++++++ 4 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/12365-custom-provider-prefix-shadowing.md create mode 100644 tests/unit/custom-provider-prefix-shadowing-11943.test.ts diff --git a/changelog.d/fixes/12365-custom-provider-prefix-shadowing.md b/changelog.d/fixes/12365-custom-provider-prefix-shadowing.md new file mode 100644 index 0000000000..278a0bc1f3 --- /dev/null +++ b/changelog.d/fixes/12365-custom-provider-prefix-shadowing.md @@ -0,0 +1 @@ +- **fix(sse):** Name the shadowed custom provider node when a built-in provider id/alias (e.g. `openference` → `of`) reserves the prefix of an existing OpenAI/Anthropic-compatible node, so the runtime `No active credentials for provider: ` error explains that the prefix routed to the built-in and never reached the node's healthy connections, instead of contradicting the dashboard ([#11943](https://github.com/diegosouzapw/OmniRoute/issues/11943)) — thanks @morpheus9393 diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 18a2760519..26e58825f5 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -90,6 +90,7 @@ import { checkPipelineGates, checkResourcePressureBeforeProviderWork, executeChatWithBreaker, + findShadowedCompatibleNode, handleNoCredentials, safeResolveProxy, safeLogEvents, @@ -1701,6 +1702,11 @@ async function handleSingleModelChat( (candidate): candidate is string => typeof candidate === "string" ) : undefined; + // #11943: only when no connection was ever tried — a built-in provider + // whose id/alias is also a configured compatible-node prefix means the + // operator's node was shadowed by the reserved-prefix guard, not broken. + const shadowedNode = + excludedConnectionIds.size === 0 ? await findShadowedCompatibleNode(provider) : null; const noCredsRes = handleNoCredentials( credentials, excludedConnectionIds.size > 0 ? Array.from(excludedConnectionIds)[0] : null, @@ -1709,7 +1715,8 @@ async function handleSingleModelChat( lastError, lastStatus, candidateAliases, - isCombo + isCombo, + shadowedNode ); const lastFailedConnectionId = excludedConnectionIds.size > 0 diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 958d934573..7bf4eef08f 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -23,6 +23,8 @@ import { } from "@omniroute/open-sse/utils/error.ts"; import { inheritTrustedLocalRateLimitResponse } from "@omniroute/open-sse/services/rateLimitManager/errors.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { getCachedProviderNodes } from "@/lib/db/readCache"; import { runWithProxyContext, runWithAppliedProxyCapture, @@ -629,6 +631,82 @@ export async function executeChatWithBreaker({ } } +/** A compatible provider node whose prefix is reserved by a built-in provider (#11943). */ +export interface ShadowedProviderNode { + id: string; + name: string | null; + prefix: string; +} + +/** + * #11943: find a compatible provider node whose configured prefix collides with + * the built-in `provider` (registry id or alias). The runtime model resolver + * deliberately gives built-in ids/aliases precedence over user-defined node + * prefixes (src/sse/services/model.ts, reserved-prefix guard), so such a node is + * unreachable through its prefix — every `/model` request lands on the + * built-in provider instead. The write-path validation rejects reserved prefixes + * at node creation time, but a node created BEFORE the built-in existed (the + * issue: an `of/` node predating the `openference` provider, alias `of`) is + * never re-validated. Only consulted on the credential-failure path, so the hot + * path is untouched; any lookup failure degrades to "no diagnostic". + */ +export async function findShadowedCompatibleNode( + provider: unknown +): Promise { + const reservedByProvider = reservedPrefixesOf(provider); + if (!reservedByProvider) return null; + + try { + const nodes = await getCachedProviderNodes(); + for (const node of Array.isArray(nodes) ? nodes : []) { + const shadowed = asShadowedCompatibleNode(node, reservedByProvider); + if (shadowed) return shadowed; + } + } catch { + // Diagnostic only — never let a node lookup failure change the error path. + } + return null; +} + +/** Node types whose user-configured prefix the reserved-prefix guard can shadow. */ +const SHADOWABLE_NODE_TYPES: ReadonlySet = new Set([ + "openai-compatible", + "anthropic-compatible", +]); + +/** + * Registry id + alias that `provider` reserves, or null when it is not a + * built-in provider (or reserves nothing). + */ +function reservedPrefixesOf(provider: unknown): ReadonlySet | null { + if (typeof provider !== "string" || provider.trim().length === 0) return null; + const entry = getRegistryEntry(provider) as { id?: unknown; alias?: unknown } | null; + if (!entry) return null; + const reserved = new Set(); + for (const value of [entry.id, entry.alias]) { + if (typeof value === "string" && value.length > 0) reserved.add(value); + } + return reserved.size > 0 ? reserved : null; +} + +function trimmedString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +/** The node as a `ShadowedProviderNode` when its prefix is one of `reserved`, else null. */ +function asShadowedCompatibleNode( + node: unknown, + reserved: ReadonlySet +): ShadowedProviderNode | null { + if (!node || typeof node !== "object") return null; + const record = node as { type?: unknown; prefix?: unknown; id?: unknown; name?: unknown }; + if (!SHADOWABLE_NODE_TYPES.has(record.type)) return null; + const prefix = trimmedString(record.prefix); + const id = trimmedString(record.id); + if (!id || !prefix || !reserved.has(prefix)) return null; + return { id, name: trimmedString(record.name) || null, prefix }; +} + export function handleNoCredentials( credentials: any, excludeConnectionId: string | null, @@ -637,7 +715,8 @@ export function handleNoCredentials( lastError: string | null, lastStatus: number | null, candidateAliases?: readonly string[], - isCombo: boolean = false + isCombo: boolean = false, + shadowedNode: ShadowedProviderNode | null = null ) { if (credentials?.allRateLimited) { const errorMsg = lastError || credentials.lastError || "Unavailable"; @@ -715,7 +794,7 @@ export function handleNoCredentials( // Without this, "No active credentials for provider: byNara" leaves the // user staring at a wall — most bugs in this area are actually "wrong // provider was picked", not "the provider is broken". - const hint = + const aliasHint = Array.isArray(candidateAliases) && candidateAliases.length > 0 ? ` Try one of: ${candidateAliases .slice(0, 3) @@ -723,6 +802,23 @@ export function handleNoCredentials( .join(", ")}.` : ""; + // #11943: "No active credentials for provider: openference" is technically + // true but misleading when the operator's own compatible node carries the + // prefix that resolved to that built-in — the node's connections are healthy, + // they were simply never consulted. Say so, and name the node. + let shadowHint = ""; + if (shadowedNode) { + const nodeLabel = shadowedNode.name + ? `"${shadowedNode.name}" (${shadowedNode.id})` + : shadowedNode.id; + log.warn( + "AUTH", + `Custom provider node ${nodeLabel} is shadowed: its prefix "${shadowedNode.prefix}" is reserved by built-in provider "${provider}", so "${shadowedNode.prefix}/${model}" routed to the built-in instead of the node` + ); + shadowHint = ` The prefix "${shadowedNode.prefix}" is reserved by the built-in provider "${provider}", so requests using it (e.g. "${shadowedNode.prefix}/${model}") route to that built-in and never reach your custom provider node ${nodeLabel}. Rename that node's prefix to an unreserved value and update your model ids.`; + } + const hint = `${aliasHint}${shadowHint}`; + // Issue #2: for single-model (non-combo) requests, a 404 leaks a misleading // "No active credentials" status to a direct API client (e.g. OpenCode) that // then mis-files it as "resource not found" instead of an auth/credential diff --git a/tests/unit/custom-provider-prefix-shadowing-11943.test.ts b/tests/unit/custom-provider-prefix-shadowing-11943.test.ts new file mode 100644 index 0000000000..7ad3e08fa2 --- /dev/null +++ b/tests/unit/custom-provider-prefix-shadowing-11943.test.ts @@ -0,0 +1,195 @@ +/** + * #11943 — a custom OpenAI-compatible provider node created with prefix "of" + * (before Openference became a built-in provider with alias "of") is silently + * shadowed at runtime: the model resolver gives built-in ids/aliases precedence + * over compatible-node prefixes, so `of/GLM-5.2` resolves to the BUILT-IN + * `openference` provider (no OAuth connection) and the operator gets + * `401 "No active credentials for provider: openference"` while the dashboard + * shows the node's three connections as healthy. + * + * The precedence itself is deliberate (a node with prefix "cf" must not hijack + * cloudflare-ai) and is NOT changed here. What must change is the runtime + * diagnostic: when the provider that ran out of credentials is a built-in whose + * id/alias collides with a configured compatible-node prefix, the error has to + * say that the prefix resolved to the built-in and name the shadowed node, so the + * operator does not have to diff a changelog to find out why routing broke. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; + +const harness = await createChatPipelineHarness("prefix-shadow-11943"); +const { buildRequest, handleChat, resetStorage, seedConnection } = harness; + +const nodesDb = await import("../../src/lib/db/providers/nodes.ts"); +const { getModelInfo } = await import("../../src/sse/services/model.ts"); +const { findShadowedCompatibleNode, handleNoCredentials } = + await import("../../src/sse/handlers/chatHelpers.ts"); + +const SHADOWED_NODE_ID = "openai-compatible-chat-01f72ee6-0000-4000-8000-000000000000"; +const SHADOWED_NODE_NAME = "Openference (custom node)"; +const SAFE_NODE_ID = "openai-compatible-chat-02f72ee6-0000-4000-8000-000000000000"; + +type ErrorBody = { error?: { message?: string; code?: string } }; + +async function seedShadowedNode() { + // Written straight to the node table: the node predates the built-in, so the + // reserved-prefix write-path validation never saw it (exactly the issue). + await nodesDb.createProviderNode({ + id: SHADOWED_NODE_ID, + type: "openai-compatible", + name: SHADOWED_NODE_NAME, + prefix: "of", + apiType: "chat", + baseUrl: "https://api.openference.com/v1", + chatPath: "/chat/completions", + modelsPath: "/models", + }); + for (const name of ["main", "burst1", "burst2"]) { + await seedConnection(SHADOWED_NODE_ID, { + name, + apiKey: `sk-openference-${name}`, + providerSpecificData: { prefix: "of", baseUrl: "https://api.openference.com/v1" }, + }); + } +} + +test.beforeEach(async () => { + process.env.REQUIRE_API_KEY = "false"; + await resetStorage(); +}); + +test.after(async () => { + await harness.cleanup(); +}); + +test("of/GLM-5.2 keeps resolving to the built-in openference provider (precedence unchanged)", async () => { + await seedShadowedNode(); + + const info = (await getModelInfo("of/GLM-5.2")) as { provider?: string; model?: string }; + + assert.equal(info.provider, "openference"); + assert.equal(info.model, "GLM-5.2"); +}); + +test("handleChat names the shadowed custom node when the built-in prefix has no credentials (#11943)", async () => { + await seedShadowedNode(); + + const response = await handleChat( + buildRequest({ + body: { + model: "of/GLM-5.2", + stream: false, + messages: [{ role: "user", content: "Hello" }], + }, + }) + ); + const json = (await response.json()) as ErrorBody; + const message = json.error?.message ?? ""; + + assert.equal(response.status, 401); + assert.match(message, /No active credentials for provider: openference/); + assert.match( + message, + /prefix "of" is reserved by the built-in provider "openference"/, + `runtime error must explain that the prefix resolved to the built-in, got: ${message}` + ); + assert.match( + message, + new RegExp(`"${SHADOWED_NODE_NAME.replace(/[()]/g, "\\$&")}" \\(${SHADOWED_NODE_ID}\\)`), + `runtime error must name the shadowed node and its id, got: ${message}` + ); + assert.match(message, /Rename that node's prefix/); +}); + +test("a non-colliding prefix still routes to the custom node and never gets the shadow hint", async () => { + await nodesDb.createProviderNode({ + id: SAFE_NODE_ID, + type: "openai-compatible", + name: "Openference (safe prefix)", + prefix: "ofc", + apiType: "chat", + baseUrl: "https://api.openference.com/v1", + }); + + const info = (await getModelInfo("ofc/GLM-5.2")) as { provider?: string }; + assert.equal(info.provider, SAFE_NODE_ID); + + const response = await handleChat( + buildRequest({ + body: { + model: "openference/GLM-5.2", + stream: false, + messages: [{ role: "user", content: "Hello" }], + }, + }) + ); + const json = (await response.json()) as ErrorBody; + + assert.equal(response.status, 401); + assert.equal(json.error?.message, "No active credentials for provider: openference."); +}); + +test("findShadowedCompatibleNode matches a compatible node by built-in id or alias only", async () => { + await seedShadowedNode(); + + const byAlias = await findShadowedCompatibleNode("openference"); + assert.deepEqual(byAlias, { id: SHADOWED_NODE_ID, name: SHADOWED_NODE_NAME, prefix: "of" }); + + // Other built-ins are untouched, and non-registry provider ids (e.g. a node's + // own internal id) can never shadow anything. + assert.equal(await findShadowedCompatibleNode("openai"), null); + assert.equal(await findShadowedCompatibleNode(SHADOWED_NODE_ID), null); + assert.equal(await findShadowedCompatibleNode(""), null); + assert.equal(await findShadowedCompatibleNode(undefined), null); +}); + +test("handleNoCredentials appends the shadowing diagnostic only when a shadowed node is supplied", async () => { + const shadowed = handleNoCredentials( + null, + null, + "openference", + "GLM-5.2", + null, + null, + undefined, + /* isCombo */ false, + { id: SHADOWED_NODE_ID, name: SHADOWED_NODE_NAME, prefix: "of" } + ); + assert.equal(shadowed.status, 401); + const shadowedMessage = ((await shadowed.json()) as ErrorBody).error?.message ?? ""; + assert.match(shadowedMessage, /^No active credentials for provider: openference\./); + assert.match(shadowedMessage, /"of\/GLM-5.2"/); + assert.match(shadowedMessage, /never reach your custom provider node/); + + // Combo routing keeps the 404 fall-through contract and gets the same hint. + const combo = handleNoCredentials( + null, + null, + "openference", + "GLM-5.2", + null, + null, + ["ofc"], + /* isCombo */ true, + { id: SHADOWED_NODE_ID, name: null, prefix: "of" } + ); + assert.equal(combo.status, 404); + const comboMessage = ((await combo.json()) as ErrorBody).error?.message ?? ""; + assert.match(comboMessage, /Try one of: ofc\/GLM-5.2\./); + assert.match(comboMessage, /custom provider node openai-compatible-chat-01f72ee6/); + + const plain = handleNoCredentials( + null, + null, + "openference", + "GLM-5.2", + null, + null, + undefined, + false + ); + const plainMessage = ((await plain.json()) as ErrorBody).error?.message ?? ""; + assert.equal(plainMessage, "No active credentials for provider: openference."); +}); From 8d16a50df52b923b354487e57886ceee96b5a2d6 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:14:09 +0200 Subject: [PATCH 17/34] fix(api): keep the images wrapper on combo routes and default Codex to b64_json (#12362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /v1/images/generations through a combo returned a bare array instead of the OpenAI {created, data} payload: executeImageCombo() unwrapped one level too many, and the n used for cost calculation read the same double-nested shape, so it was always 0. The combo path now returns the handler payload unchanged, matching the direct-model path. Second half: Codex image results emitted a data: URI in url whenever response_format was not b64_json, but OpenAI returns b64_json for the gpt-image-* family — clients that omit the field, Codex CLI's built-in image_gen among them, could decode neither shape. Codex now defaults to b64_json; an explicit response_format: "url" keeps its previous behaviour. Both land together because fixing one leaves Codex CLI failing at the other. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .../fixes/12362-image-gen-response-wrapper.md | 1 + open-sse/handlers/imageGeneration.ts | 6 +- open-sse/services/imageCombo.ts | 46 +++++-------- tests/unit/combo/image-combo.test.ts | 67 +++++++++++++++++++ tests/unit/image-generation-handler.test.ts | 25 ++++++- tests/unit/image-generation-route.test.ts | 35 ++++++++++ 6 files changed, 149 insertions(+), 31 deletions(-) create mode 100644 changelog.d/fixes/12362-image-gen-response-wrapper.md diff --git a/changelog.d/fixes/12362-image-gen-response-wrapper.md b/changelog.d/fixes/12362-image-gen-response-wrapper.md new file mode 100644 index 0000000000..3de7c535aa --- /dev/null +++ b/changelog.d/fixes/12362-image-gen-response-wrapper.md @@ -0,0 +1 @@ +- **fix(api):** keep the `{created, data}` wrapper on combo-routed `/v1/images/generations` responses and default Codex image results to `b64_json` on both `/v1/images/generations` and `/v1/images/edits` so Codex CLI's built-in `image_gen` can decode them ([#12268](https://github.com/diegosouzapw/OmniRoute/issues/12268)) diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index c9175a2612..62a9488565 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -2679,7 +2679,11 @@ async function handleCodexImageGeneration({ } } - const wantsUrl = body.response_format !== "b64_json"; + // OpenAI returns b64_json for the gpt-image-* family and reserves `url` for + // fetchable HTTPS links, so clients that omit response_format (Codex CLI's + // built-in image_gen among them) expect the bytes in b64_json. Only emit the + // data: URI when the caller explicitly asks for `url` (#12268). + const wantsUrl = body.response_format === "url"; const data = wantsUrl ? collected.map((item) => ({ url: `data:image/png;base64,${item.b64_json}`, diff --git a/open-sse/services/imageCombo.ts b/open-sse/services/imageCombo.ts index 0ff784ce83..650829d2b2 100644 --- a/open-sse/services/imageCombo.ts +++ b/open-sse/services/imageCombo.ts @@ -57,19 +57,13 @@ export async function executeImageCombo( const combo = await getComboByName(comboName); if (!combo) { // Model name is not a combo; the caller should handle this as a direct model - return errorResponse( - HTTP_STATUS.BAD_REQUEST, - `Combo not found: ${comboName}` - ); + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Combo not found: ${comboName}`); } const allCombos = await getCombos(); const targets = resolveComboTargets(combo as never, allCombos as never); if (!targets || targets.length === 0) { - return errorResponse( - HTTP_STATUS.BAD_REQUEST, - `Combo "${comboName}" has no usable targets` - ); + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Combo "${comboName}" has no usable targets`); } // 2. Filter to images-capable targets @@ -154,10 +148,7 @@ export async function executeImageCombo( // Terminal failures (400 bad model, 403 banned, etc.) — stop iterating // Non-terminal failures (429, 5xx) — try next target if (status === 400 || status === 403 || status === 401) { - return errorResponse( - status, - `[${targetProvider}] ${error}` - ); + return errorResponse(status, `[${targetProvider}] ${error}`); } lastError = { status, error: `[${targetProvider}] ${error}` }; @@ -166,18 +157,12 @@ export async function executeImageCombo( // 4. Build response if (successResult) { - const n = Math.max( - Number(body.n) || 1, - ( - successResult.data as { data?: { data?: unknown[] } } - ).data?.data?.length || 0 - ); - const costUsd = await calculateModalCost( - "image", - selectedProvider, - selectedModel, - { n } - ); + // handleImageGeneration() already returns the public OpenAI images payload + // ({ created, data: [...] }); count the images at that level (#12268). + const payload = successResult.data as { created?: number; data?: unknown[] } | unknown[]; + const images = Array.isArray(payload) ? payload : payload?.data; + const n = Math.max(Number(body.n) || 1, images?.length || 0); + const costUsd = await calculateModalCost("image", selectedProvider, selectedModel, { n }); const headers = new Headers({ "Content-Type": "application/json" }); attachOmniRouteMetaHeaders(headers, { @@ -190,10 +175,13 @@ export async function executeImageCombo( fallbackAttempts: fallbackCount, }); - return new Response( - JSON.stringify((successResult.data as { data: unknown }).data), - { status: 200, headers } - ); + // Return the handler payload unchanged so the combo path matches the + // direct-model path byte-for-byte; re-wrap only if a handler ever yields + // a bare array (#12268). + const responseBody = Array.isArray(payload) + ? { created: Math.floor(Date.now() / 1000), data: payload } + : payload; + return new Response(JSON.stringify(responseBody), { status: 200, headers }); } // All targets failed — return the last error @@ -205,4 +193,4 @@ export async function executeImageCombo( status: lastError?.status || 502, headers: { "Content-Type": "application/json" }, }); -} \ No newline at end of file +} diff --git a/tests/unit/combo/image-combo.test.ts b/tests/unit/combo/image-combo.test.ts index d455875b96..3d1e0946d1 100644 --- a/tests/unit/combo/image-combo.test.ts +++ b/tests/unit/combo/image-combo.test.ts @@ -23,6 +23,7 @@ fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); const core = await import("@/lib/db/core.ts"); const { createCombo } = await import("@/lib/db/combos"); +const { createProviderConnection } = await import("@/lib/db/providers"); const { executeImageCombo } = await import("@omniroute/open-sse/services/imageCombo"); type LogEntry = { level: string; tag: unknown; msg: unknown }; @@ -283,3 +284,69 @@ test("all error responses from executeImageCombo sanitize stack traces", async ( ); } }); + +// --------------------------------------------------------------------------- +// Success path — public response shape (#12268) +// --------------------------------------------------------------------------- + +function buildCodexSSE(items: Array>): string { + const frames = items.map((item) => JSON.stringify({ type: "response.output_item.done", item })); + return frames.map((frame) => `event: response.output_item.done\ndata: ${frame}\n`).join("\n"); +} + +test("combo success keeps the OpenAI {created, data} wrapper and Codex defaults to b64_json (#12268)", async () => { + // Codex CLI hardcodes the model name `gpt-image-2`; a combo is what lets it + // reach a codex target. The combo response must match the direct-model + // response shape byte-for-byte or the client aborts while decoding `created`. + await createProviderConnection({ + provider: "codex", + authType: "apikey", + apiKey: "codex-token", + name: "codex-image-combo", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + await createCombo({ + name: "gpt-image-2", + strategy: "priority", + models: ["codex/gpt-5.6-sol"], + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + buildCodexSSE([ + { + type: "image_generation_call", + id: "ig_combo_1", + status: "completed", + revised_prompt: "a green tree icon", + result: "aVZCT1J3MEtHZ28=", + }, + ]), + { status: 200, headers: { "content-type": "text/event-stream" } } + ); + + try { + const log = createLog(); + const response = await executeImageCombo( + "gpt-image-2", + { model: "gpt-image-2", prompt: "a green tree icon, white background, minimal flat" }, + createMockAuth(), + Date.now(), + log + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.ok(!Array.isArray(body), "combo path must not return a bare array"); + assert.equal(typeof body.created, "number"); + assert.ok(Array.isArray(body.data)); + assert.equal(body.data.length, 1); + assert.equal(body.data[0].b64_json, "aVZCT1J3MEtHZ28="); + assert.equal(body.data[0].url, undefined); + assert.equal(body.data[0].revised_prompt, "a green tree icon"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index 1d7a18a3e3..9842b956cd 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -1843,7 +1843,7 @@ test("handleImageGeneration routes codex image requests through /responses with } }); -test("handleImageGeneration (codex) returns a data URL when response_format is not b64_json", async () => { +test("handleImageGeneration (codex) defaults to b64_json when response_format is unset (#12268)", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async () => { const sse = buildCodexSSE([ @@ -1859,6 +1859,29 @@ test("handleImageGeneration (codex) returns a data URL when response_format is n log: null, }); assert.equal(result.success, true); + assert.equal(result.data.data[0].b64_json, "YWJjZA=="); + assert.equal(result.data.data[0].url, undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration (codex) returns a data URL only when response_format is url", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + const sse = buildCodexSSE([ + { type: "image_generation_call", id: "ig_3", status: "completed", result: "YWJjZA==" }, + ]); + return new Response(sse, { status: 200 }); + }; + + try { + const result = await handleImageGeneration({ + body: { model: "cx/gpt-5.6-sol", prompt: "kitten", response_format: "url" }, + credentials: { accessToken: "codex-token" }, + log: null, + }); + assert.equal(result.success, true); assert.equal(result.data.data[0].url, "data:image/png;base64,YWJjZA=="); assert.equal(result.data.data[0].b64_json, undefined); } finally { diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts index a9526585d5..baff5fc622 100644 --- a/tests/unit/image-generation-route.test.ts +++ b/tests/unit/image-generation-route.test.ts @@ -466,6 +466,41 @@ test("v1 image edit POST routes built-in Codex references through native Respons assert.equal(captured.body.input[0].content.length, 3); }); +test("v1 image edit POST defaults Codex results to b64_json when response_format is unset (#12268)", async () => { + await seedConnection("codex", { apiKey: "codex-oauth-token" }); + + globalThis.fetch = async () => { + const event = { + type: "response.output_item.done", + item: { + type: "image_generation_call", + id: "ig_edit_default", + status: "completed", + result: "ZGVmYXVsdC1lZGl0", + }, + }; + return new Response(`data: ${JSON.stringify(event)}\n\ndata: [DONE]\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + + // Codex CLI's built-in image_gen never sends response_format; it expects + // the OpenAI gpt-image-* shape with the bytes in b64_json. + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + body: createCodexEditForm("make it cute"), + }) + ); + const body = (await response.json()) as ImageResponseBody & { created?: number }; + + assert.equal(response.status, 200); + assert.equal(typeof body.created, "number"); + assert.equal(body.data[0].b64_json, "ZGVmYXVsdC1lZGl0"); + assert.equal(body.data[0].url, undefined); +}); + test("v1 image edit POST rejects excessive or malformed Codex reference sets", async () => { await seedConnection("codex", { apiKey: "codex-oauth-token" }); globalThis.fetch = async () => { From 70f33e323c313685f9c8449770c3711fcc55fd75 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:14:13 +0200 Subject: [PATCH 18/34] fix(executors): let the ambient proxy stand when an OpenCode account has none (#12380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A proxy assigned to an opencode / opencode-go connection is pinned by the chat handler as the ambient proxy context before the executor runs. OpencodeExecutor only reads per-account proxies from providerSpecificData.accountProxies, so an API-key connection with none took the single-account fast path — which wrapped the dispatch in runWithDirectFetchContext(), and that direct sentinel makes patchedFetch bypass the ambient context and hit native fetch. The assigned proxy was discarded and the request egressed from the host IP, giving `403 This model is not available in your country` on geoblocked hosts. The fast path now applies the direct pin only when no ambient context exists. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .../fixes/12380-opencode-ambient-proxy.md | 1 + open-sse/executors/opencode.ts | 18 ++- open-sse/utils/proxyFetch.ts | 10 ++ ...894-opencode-ambient-proxy-context.test.ts | 126 ++++++++++++++++++ 4 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/12380-opencode-ambient-proxy.md create mode 100644 tests/unit/11894-opencode-ambient-proxy-context.test.ts diff --git a/changelog.d/fixes/12380-opencode-ambient-proxy.md b/changelog.d/fixes/12380-opencode-ambient-proxy.md new file mode 100644 index 0000000000..091a00b5e5 --- /dev/null +++ b/changelog.d/fixes/12380-opencode-ambient-proxy.md @@ -0,0 +1 @@ +- **fix(executors):** `OpencodeExecutor` no longer forces a direct connection when the connection has a proxy assigned in Proxy Management but no per-account proxies: the single-account fast path used to wrap the upstream dispatch in the direct-egress sentinel, discarding the ambient proxy context the chat handler had pinned from `proxy_assignments`, so API-key `opencode`/`opencode-go` connections egressed from the host IP (and hit geoblocks) despite the assignment. The direct pin is now applied only when no ambient proxy context exists ([#11894](https://github.com/diegosouzapw/OmniRoute/issues/11894) — thanks @hizzt) diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 2472700e70..bb3c10845b 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -11,7 +11,11 @@ import { injectReasoningContentForThinkingModel, isThinkingMessageModel, } from "../utils/reasoningContentInjector.ts"; -import { runWithDirectFetchContext, runWithProxyContext } from "../utils/proxyFetch.ts"; +import { + hasAmbientProxyContext, + runWithDirectFetchContext, + runWithProxyContext, +} from "../utils/proxyFetch.ts"; import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts"; import { type AccountProxyConfig, @@ -505,9 +509,15 @@ export class OpencodeExecutor extends BaseExecutor { // else passes untouched: this path deliberately preserves BaseExecutor's // intra-URL 429 retries (no skipUpstreamRetry here). if (this.accounts.length === 1 && !hasProxies) { - const single = (await runWithDirectFetchContext(() => - super.execute(input) - )) as HttpExecuteResult; + // #11894: a connection-level proxy assignment (proxy_assignments) reaches + // the executor as the AMBIENT proxy context — the chat handler wraps + // execute() in runWithProxyContext(proxyInfo.proxy, ...) before we run. + // Only pin direct egress when no such context exists; otherwise let the + // ambient proxy stand instead of clobbering it with the direct sentinel. + const dispatch = () => super.execute(input); + const single = (await (hasAmbientProxyContext() + ? dispatch() + : runWithDirectFetchContext(dispatch))) as HttpExecuteResult; if (single.response.status === 400) { let bodyText: string | null = null; try { diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 8e080bbfe5..8dd5d013e8 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -727,6 +727,16 @@ export function runWithDirectFetchContext(fn: () => T): T { return proxyContext.run(DIRECT_PROXY_CONTEXT, fn); } +/** + * True when the caller already runs inside an explicit proxy context — i.e. an + * outer runWithProxyContext(proxyConfig, ...) pinned a proxy for this async + * scope. False for an empty store and for the direct sentinel. + */ +export function hasAmbientProxyContext(): boolean { + const store = proxyContext.getStore(); + return Boolean(store) && store !== DIRECT_PROXY_CONTEXT; +} + /** * Like {@link runWithProxyContext}, but if the assigned proxy is unreachable or fails * its pre-checks the request can degrade to a DIRECT connection instead of throwing. diff --git a/tests/unit/11894-opencode-ambient-proxy-context.test.ts b/tests/unit/11894-opencode-ambient-proxy-context.test.ts new file mode 100644 index 0000000000..5ffcd1f044 --- /dev/null +++ b/tests/unit/11894-opencode-ambient-proxy-context.test.ts @@ -0,0 +1,126 @@ +/** + * #11894 — a connection-level proxy assignment (proxy_assignments, scope + * "account") is applied by the chat handler as the AMBIENT proxy context via + * runWithProxyContext(proxyInfo.proxy, () => executor.execute(...)) BEFORE the + * executor runs. When no per-account multi-fingerprint proxies are configured + * (API-key connections), OpencodeExecutor keeps a single account whose + * `proxy` is null and must NOT clobber that ambient context with a nested + * runWithProxyContext(null, ...) — the upstream fetch has to egress through + * the ambient proxy, not direct. + */ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import net from "node:net"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import { resolveProxyForRequest, runWithProxyContext } from "../../open-sse/utils/proxyFetch.ts"; + +const log = { debug() {}, info() {}, warn() {}, error() {} }; + +// A throwaway local TCP listener stands in for the proxy so the fast-fail +// reachability probe inside runWithProxyContext passes. +let server: net.Server; +let port = 0; + +function listen(s: net.Server): Promise { + return new Promise((resolve) => { + s.listen(0, "127.0.0.1", () => resolve((s.address() as net.AddressInfo).port)); + }); +} + +before(async () => { + server = net.createServer((s) => s.destroy()); + port = await listen(server); +}); + +after(() => { + server?.close(); +}); + +type Observed = { source: string; proxyPort: string | null }; + +const FINGERPRINT_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const FINGERPRINT_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +async function executeUnderAmbientProxy( + providerSpecificData: Record = {} +): Promise { + const exec = new OpencodeExecutor("opencode-go"); + const observed: Observed[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: unknown) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as { url: string }).url; + const resolved = resolveProxyForRequest(url); + let proxyPort: string | null = null; + if (resolved.proxyUrl) { + try { + proxyPort = new URL(resolved.proxyUrl).port; + } catch { + proxyPort = null; + } + } + observed.push({ source: resolved.source, proxyPort }); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + + try { + const ambientProxy = { type: "http" as const, host: "127.0.0.1", port }; + const result = await runWithProxyContext(ambientProxy, () => + exec.execute({ + model: "muse-spark-1.2-contributor", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + // Default (empty providerSpecificData): an API-key connection with no + // fingerprints / accountProxies, so the executor keeps its single + // default account with proxy === null and takes the fast path. + credentials: { apiKey: "sk-test", providerSpecificData } as never, + log, + }) + ); + assert.strictEqual((result as { response: Response }).response.status, 200); + } finally { + globalThis.fetch = originalFetch; + } + return observed; +} + +describe("#11894 OpencodeExecutor lets the ambient proxy stand when the account has no proxy", () => { + it("egresses through the ambient (connection-assigned) proxy instead of direct", async () => { + const observed = await executeUnderAmbientProxy(); + assert.ok(observed.length >= 1, "at least one upstream dispatch happened"); + const first = observed[0]; + assert.strictEqual( + first.source, + "context", + `upstream fetch must see the ambient proxy context, got source="${first.source}"` + ); + assert.strictEqual( + first.proxyPort, + String(port), + `upstream fetch must egress through the ambient proxy port ${port}, got "${first.proxyPort}"` + ); + }); + + it("keeps the ambient proxy on the rotation path when the selected account has no proxy of its own", async () => { + // Multi-fingerprint connection without accountProxies: every account has + // proxy === null, so execute() goes through the rotation loop and its + // nested runWithProxyContext(account.proxy, ...) must inherit the ambient + // proxy rather than force a direct connection. + const observed = await executeUnderAmbientProxy({ + fingerprints: [FINGERPRINT_A, FINGERPRINT_B], + }); + assert.ok(observed.length >= 1, "at least one upstream dispatch happened"); + for (const dispatch of observed) { + assert.strictEqual(dispatch.source, "context"); + assert.strictEqual(dispatch.proxyPort, String(port)); + } + }); +}); From 01d97beb8b88966ac5f113548df8cf6db3560e29 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:14:18 +0200 Subject: [PATCH 19/34] fix(translator): drop unsigned thinking blocks instead of fabricating a Claude signature (#12386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thinking content part arriving with no signature — typical after a cross-provider hop where reasoning_content was converted into a thinking block — was stamped with DEFAULT_THINKING_CLAUDE_SIGNATURE. prepareClaudeRequest treats any non-empty signature on the latest assistant turn as genuine and preserves it verbatim, so the fabricated one reached Anthropic and the replay failed with "Invalid signature". A missing signature is now treated the same as an empty one, aligned with the stricter check claudeHelper.ts already used: the block is dropped rather than fabricated. Real signatures are still preserved verbatim and redacted_thinking is unchanged. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- ...386-claude-thinking-undefined-signature.md | 1 + .../translator/request/openai-to-claude.ts | 16 +- ...-claude-strip-empty-signature-6953.test.ts | 17 +- ...o-claude-undefined-signature-12105.test.ts | 167 ++++++++++++++++++ tests/unit/translator-helper-branches.test.ts | 57 +++++- 5 files changed, 242 insertions(+), 16 deletions(-) create mode 100644 changelog.d/fixes/12386-claude-thinking-undefined-signature.md create mode 100644 tests/unit/openai-to-claude-undefined-signature-12105.test.ts diff --git a/changelog.d/fixes/12386-claude-thinking-undefined-signature.md b/changelog.d/fixes/12386-claude-thinking-undefined-signature.md new file mode 100644 index 0000000000..fd0e979235 --- /dev/null +++ b/changelog.d/fixes/12386-claude-thinking-undefined-signature.md @@ -0,0 +1 @@ +- **fix(translator):** Drop replayed `thinking` blocks that carry no signature (the shape produced from cross-provider `reasoning_content`) instead of stamping the default Claude signature on them, which Anthropic rejected with `400 Invalid signature in thinking block` on the next turn served by an Anthropic rung ([#12105](https://github.com/diegosouzapw/OmniRoute/issues/12105)) — thanks @atescivitci-cmd diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 8a5c115c2a..496e77c272 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -622,13 +622,15 @@ function getContentBlocksFromMessage( // turn introduced a `signature:""` thinking block, every subsequent Anthropic leg // attempt 400'd and the router silently fell back to codex forever. // - // Fix: strip thinking blocks whose signature is the empty string — that explicit - // empty value is the hallmark of a synthesized block from a non-Anthropic provider. - // Thinking blocks with `signature: undefined` (field absent) are legitimate Claude- - // format messages and fall through to the DEFAULT_THINKING_CLAUDE_SIGNATURE fallback - // as before. - if (part.type === "thinking" && part.signature === "") { - continue; // drop — synthesized by non-Anthropic provider, no valid signature + // Fix: strip thinking blocks that carry no signature at all. `signature: ""` is the + // shape codex/gpt-5.x emit; a MISSING field is what the response translator produces + // from cross-provider `reasoning_content` (#12105). Neither can be replayed to + // Anthropic, and fabricating DEFAULT_THINKING_CLAUDE_SIGNATURE is worse than dropping: + // prepareClaudeRequest treats any non-empty signature on the latest assistant turn as + // genuine and forwards the block verbatim, so the fake signature 400s upstream. This + // mirrors the stricter "non-empty string" check already used in claudeHelper.ts. + if (part.type === "thinking" && !part.signature) { + continue; // drop — no replayable signature (empty or absent) } if (part.type === "redacted_thinking" && part.data === "") { continue; // drop — same: empty data from non-Anthropic provider diff --git a/tests/unit/openai-to-claude-strip-empty-signature-6953.test.ts b/tests/unit/openai-to-claude-strip-empty-signature-6953.test.ts index f129a64f70..61fe0d7671 100644 --- a/tests/unit/openai-to-claude-strip-empty-signature-6953.test.ts +++ b/tests/unit/openai-to-claude-strip-empty-signature-6953.test.ts @@ -90,10 +90,11 @@ test("#6953: thinking block with valid signature is preserved verbatim", () => { assert.equal(thinkingBlocks[0].signature, realSig, "valid signature must be preserved verbatim"); }); -test("#6953: thinking block with undefined signature (Claude-format) is preserved with fallback", () => { - // Claude-format messages may have thinking blocks without a signature field at all. - // These are legitimate and must NOT be stripped — only signature:"" (empty string) - // indicates a non-Anthropic synthesized block. +test("#6953/#12105: thinking block with undefined signature is stripped like the empty-string case", () => { + // A thinking block without a signature field is what the response translator emits for + // cross-provider reasoning_content (#12105). It carries no replayable signature either, so + // it must be dropped rather than stamped with the fabricated default — Anthropic rejects + // that fabricated signature with HTTP 400 exactly like the empty-string case. const result = openaiToClaudeRequest( "claude-opus-4-8", { @@ -118,11 +119,11 @@ test("#6953: thinking block with undefined signature (Claude-format) is preserve const thinkingBlocks = assistant.content.filter((b) => b && b.type === "thinking"); assert.equal( thinkingBlocks.length, - 1, - "thinking block with undefined signature must be preserved" + 0, + "thinking block with undefined signature must be stripped, not fabricated" ); - assert.equal(thinkingBlocks[0].thinking, "I already have this", "thinking content must match"); - assert.ok(thinkingBlocks[0].signature, "fallback signature must be applied"); + const textBlocks = assistant.content.filter((b) => b && b.type === "text"); + assert.equal(textBlocks.length, 1, "text block must be preserved"); }); test("#6953: redacted_thinking with empty data is stripped", () => { diff --git a/tests/unit/openai-to-claude-undefined-signature-12105.test.ts b/tests/unit/openai-to-claude-undefined-signature-12105.test.ts new file mode 100644 index 0000000000..e5b7418d47 --- /dev/null +++ b/tests/unit/openai-to-claude-undefined-signature-12105.test.ts @@ -0,0 +1,167 @@ +/** + * TDD regression for #12105 — cross-provider `reasoning_content` becomes an unsigned + * `thinking` block, then "Invalid signature" on replay to Claude. + * + * The response translator (response/openai-to-claude.ts) builds a `thinking` block from + * `reasoning_content` and never attaches a `signature` field. The client stores that + * block verbatim and replays it on the next turn. When that turn is served by an + * Anthropic-native rung, `openaiToClaudeRequest` only treated `signature: ""` as + * synthesized (#6953); a block with the field ABSENT fell through to the + * DEFAULT_THINKING_CLAUDE_SIGNATURE fallback. Anthropic validates `thinking` + * signatures cryptographically and rejects the fabricated one with HTTP 400. + * + * `prepareClaudeRequest` cannot repair this afterwards: its latest-assistant guard + * classifies any non-empty signature string as genuine and preserves the block + * verbatim (Anthropic 400s on modified latest-turn blocks), so the fabricated + * signature reaches the upstream unchanged. + * + * Fix: treat a missing signature the same as an empty one — drop the block. Older + * turns and tool_use precursors are already handled by prepareClaudeRequest + * (redacted_thinking rewrite / precursor injection), which never fabricates a + * `thinking` signature. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToClaudeRequest } = + await import("../../open-sse/translator/request/openai-to-claude.ts"); +const { prepareClaudeRequest } = await import("../../open-sse/translator/helpers/claudeHelper.ts"); +const { DEFAULT_THINKING_CLAUDE_SIGNATURE } = + await import("../../open-sse/config/defaultThinkingSignature.ts"); + +test("#12105: thinking block with NO signature field is dropped, not stamped with the default signature", () => { + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "cross-provider reasoning" }, + { type: "text", text: "response" }, + ], + }, + { role: "user", content: "next turn" }, + ], + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant, "expected assistant message"); + + const fabricated = assistant.content.find( + (b) => b && b.type === "thinking" && b.signature === DEFAULT_THINKING_CLAUDE_SIGNATURE + ); + assert.equal( + fabricated, + undefined, + "must NOT emit a `thinking` block carrying the fabricated default signature" + ); + assert.equal( + assistant.content.filter((b) => b && b.type === "thinking").length, + 0, + 'unsigned thinking block must be dropped, exactly like the signature:"" case' + ); + assert.deepEqual( + assistant.content.map((b) => b.type), + ["text"], + "text block must survive" + ); +}); + +test("#12105: unsigned thinking block on the latest assistant turn with tool_use does not leak a fabricated signature through prepareClaudeRequest", () => { + // Mirrors the reported combo scenario: the previous turn was served by a + // non-Anthropic rung (unsigned thinking + tool_use), and this turn routes to + // an Anthropic-native rung with thinking enabled. + const translated = openaiToClaudeRequest( + "claude-opus-4-8", + { + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [ + { role: "user", content: "write a function" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "**Reviewing the request**" }, + { + type: "tool_use", + id: "toolu_01abc", + name: "write_file", + input: { path: "main.rs", content: "fn main() {}" }, + }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_01abc", content: "ok" }], + }, + ], + }, + false + ); + + const outbound = prepareClaudeRequest(translated, "claude"); + const assistant = outbound.messages.find((m) => m.role === "assistant"); + assert.ok(assistant, "expected assistant message"); + + const fabricated = assistant.content.find( + (b) => b && b.type === "thinking" && b.signature === DEFAULT_THINKING_CLAUDE_SIGNATURE + ); + assert.equal( + fabricated, + undefined, + "a `thinking` block with the fabricated signature must never reach the Anthropic upstream" + ); + assert.equal( + assistant.content.find((b) => b && b.type === "thinking"), + undefined, + "no `thinking`-typed block may survive on the latest assistant turn" + ); + + // Anthropic's schema still needs a thinking-ish precursor before tool_use when + // thinking is enabled; prepareClaudeRequest supplies the signature-less + // redacted_thinking placeholder (accepted without signature validation). + assert.equal( + assistant.content[0].type, + "redacted_thinking", + "precursor must be redacted_thinking" + ); + assert.equal( + assistant.content[0].signature, + undefined, + "redacted_thinking must carry no signature" + ); + assert.ok( + assistant.content.some((b) => b.type === "tool_use"), + "tool_use block must be preserved" + ); +}); + +test("#12105: thinking block with a real signature is still preserved verbatim", () => { + const realSig = "ErUBCkYI...real-anthropic-signature...=="; + const result = openaiToClaudeRequest( + "claude-opus-4-8", + { + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "real reasoning", signature: realSig }, + { type: "text", text: "response" }, + ], + }, + { role: "user", content: "ok" }, + ], + }, + false + ); + + const assistant = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistant); + const thinking = assistant.content.filter((b) => b && b.type === "thinking"); + assert.equal(thinking.length, 1, "signed thinking block must be preserved"); + assert.equal(thinking[0].signature, realSig, "real signature must be preserved verbatim"); +}); diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index bf3bfea581..4f1a10b56b 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -812,7 +812,9 @@ test("translateRequest does NOT inject duplicate thinking for Claude-format mess { role: "assistant", content: [ - { type: "thinking", thinking: "I already have this" }, + // Signed: a thinking block without a signature is dropped by the request + // translator (#12105), which would leave nothing for this test to protect. + { type: "thinking", thinking: "I already have this", signature: "sig_existing" }, { type: "tool_use", id: "toolu_existing", name: "read", input: {} }, ], }, @@ -838,3 +840,56 @@ test("translateRequest does NOT inject duplicate thinking for Claude-format mess clearReasoningCacheAll(); }); + +test("translateRequest replays cached reasoning when the client's Claude-format thinking block has no signature", () => { + // #12105: an unsigned thinking block cannot be replayed to Claude, so the request + // translator drops it instead of stamping a fabricated signature. For Kimi Coding the + // tool_use turn still needs a thinking precursor, and the reasoning cache (keyed by the + // tool_use id) is the authentic source — it must be re-hydrated exactly once. + clearReasoningCacheAll(); + cacheReasoningByKey("toolu_unsigned", "kimi-coding-apikey", "k3-256k", "cached thinking"); + + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "k3-256k", + { + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "unsigned client thinking" }, + { type: "tool_use", id: "toolu_unsigned", name: "read", input: {} }, + ], + }, + { role: "tool", tool_call_id: "toolu_unsigned", content: "data" }, + ], + }, + false, + null, + "kimi-coding-apikey" + ); + + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + const thinkingBlocks = + Array.isArray(assistantMsg.content) && + assistantMsg.content.filter((b) => b?.type === "thinking"); + assert.equal(thinkingBlocks?.length, 1, "should have exactly one thinking block (no duplicate)"); + assert.equal( + thinkingBlocks[0].thinking, + "cached thinking", + "cached reasoning should be replayed" + ); + assert.equal( + thinkingBlocks[0].signature, + undefined, + "replayed thinking must not carry a fabricated signature" + ); + const thinkingIdx = assistantMsg.content.indexOf(thinkingBlocks[0]); + const toolUseIdx = assistantMsg.content.findIndex((b) => b?.type === "tool_use"); + assert.ok(thinkingIdx < toolUseIdx, "thinking block should be before tool_use"); + assert.equal(getReasoningCacheServiceStats().replays, 1); + + clearReasoningCacheAll(); +}); From 4f4aa74199b91c6e65190e89be20ccde611fcece Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:14:41 +0200 Subject: [PATCH 20/34] feat(gamification): show the real daily streak on the profile page (#12377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Profile page already rendered a streak card but fed it a hard-coded useState(0) with a "streak data comes from future API" note — while streaks.ts tracked per-key streaks all along and the MCP gamification_profile tool already returned them. GET /api/gamification/level now returns streak: { current, longest } next to level: the key's own streak with apiKeyId, the operator-wide maximum otherwise, matching the aggregate mode getAggregateXp uses (#3484). No new route, no OpenAPI change, no new i18n keys; a missing or zero streak keeps the card hidden exactly as before. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .../features/12377-profile-streak-card.md | 1 + .../(dashboard)/dashboard/profile/page.tsx | 11 +- src/app/api/gamification/level/route.ts | 7 +- src/lib/gamification/index.ts | 2 +- src/lib/gamification/streaks.ts | 32 ++++++ .../gamification/level-route-streak.test.ts | 83 ++++++++++++++ tests/unit/gamification/streaks.test.ts | 71 +++++++++++- tests/unit/ui/profile-streak.test.tsx | 101 ++++++++++++++++++ 8 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 changelog.d/features/12377-profile-streak-card.md create mode 100644 tests/unit/gamification/level-route-streak.test.ts create mode 100644 tests/unit/ui/profile-streak.test.tsx diff --git a/changelog.d/features/12377-profile-streak-card.md b/changelog.d/features/12377-profile-streak-card.md new file mode 100644 index 0000000000..61467c6183 --- /dev/null +++ b/changelog.d/features/12377-profile-streak-card.md @@ -0,0 +1 @@ +- **feat(gamification):** the dashboard Profile page now shows the real daily streak — `/api/gamification/level` returns `streak: { current, longest }` (per key with `apiKeyId`, operator-wide maximum otherwise) and the streak card reads it instead of a hard-coded 0 (#2403) diff --git a/src/app/(dashboard)/dashboard/profile/page.tsx b/src/app/(dashboard)/dashboard/profile/page.tsx index 4864fc8c78..1aed52eb09 100644 --- a/src/app/(dashboard)/dashboard/profile/page.tsx +++ b/src/app/(dashboard)/dashboard/profile/page.tsx @@ -79,6 +79,14 @@ function BadgeIcon({ icon, earned }: { icon: string | null; earned: boolean }) { ); } +/** + * Current daily streak carried by `/api/gamification/level` (#2403). Older or partial + * payloads without a `streak` field, or with a non-numeric count, render as no streak. + */ +function readStreakCount(data: { streak?: { current?: unknown } | null }): number { + return Number(data.streak?.current) || 0; +} + const RARITY_COLORS: Record = { common: "text-gray-400 border-gray-500/30", uncommon: "text-green-400 border-green-500/30", @@ -97,7 +105,7 @@ export default function ProfilePage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [selectedBadge, setSelectedBadge] = useState(null); - const [streak] = useState(0); // streak data comes from future API + const [streak, setStreak] = useState(0); const fetchData = useCallback(async () => { try { @@ -114,6 +122,7 @@ export default function ProfilePage() { if (levelRes.ok) { const data = await levelRes.json(); setUserLevel(data.level ?? data); + setStreak(readStreakCount(data)); } if (badgesRes.ok) { const data = await badgesRes.json(); diff --git a/src/app/api/gamification/level/route.ts b/src/app/api/gamification/level/route.ts index 572081967c..dc4d94b7ed 100644 --- a/src/app/api/gamification/level/route.ts +++ b/src/app/api/gamification/level/route.ts @@ -1,6 +1,8 @@ /** * GET /api/gamification/level — current XP/level for a key, or the operator-wide * aggregate when no `apiKeyId` is supplied (the dashboard profile page case). (#3484) + * The daily streak rides along in the same payload so the profile streak card can show + * real data without a second round trip. (#2403) * * LOCAL_ONLY: not process-spawning; management-scoped via requireManagementAuth. */ @@ -8,6 +10,7 @@ import { NextRequest, NextResponse } from "next/server"; import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { getXp, getAggregateXp } from "@/lib/db/gamification"; +import { getStreak, getAggregateStreak } from "@/lib/gamification/streaks"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; export async function OPTIONS() { @@ -20,5 +23,7 @@ export async function GET(request: NextRequest) { const apiKeyId = new URL(request.url).searchParams.get("apiKeyId"); const level = apiKeyId ? getXp(apiKeyId) : getAggregateXp(); - return NextResponse.json({ level }, { headers: CORS_HEADERS }); + const streakData = apiKeyId ? await getStreak(apiKeyId) : await getAggregateStreak(); + const streak = { current: streakData.currentStreak, longest: streakData.longestStreak }; + return NextResponse.json({ level, streak }, { headers: CORS_HEADERS }); } diff --git a/src/lib/gamification/index.ts b/src/lib/gamification/index.ts index 8cf0db476e..56862fcad6 100644 --- a/src/lib/gamification/index.ts +++ b/src/lib/gamification/index.ts @@ -26,7 +26,7 @@ export { XP_REWARDS, type XpAction, } from "./xp"; -export { updateStreak } from "./streaks"; +export { getStreak, getAggregateStreak, updateStreak, type StreakData } from "./streaks"; export { recordBadgeUnlock, consumeBadgeUnlocks, diff --git a/src/lib/gamification/streaks.ts b/src/lib/gamification/streaks.ts index b4973ff93e..4406375ac1 100644 --- a/src/lib/gamification/streaks.ts +++ b/src/lib/gamification/streaks.ts @@ -107,6 +107,38 @@ export async function getStreak(apiKeyId: string): Promise { return parseStreakJson(row.value); } +/** + * Operator-wide streak for the dashboard profile page, which has no single API key + * (the aggregate mode of `/api/gamification/level`, #3484): the best `currentStreak` + * and the best `longestStreak` over every key in the namespace. Both are maxima, not + * sums, and may come from different keys. Malformed rows count as zero. + * + * @returns The highest current/longest streak across all API keys + * + * @example + * const agg = await getAggregateStreak(); + * console.log(agg.currentStreak); // 7 + */ +export async function getAggregateStreak(): Promise< + Pick +> { + const aggregate = { currentStreak: 0, longestStreak: 0 }; + if (isBuildPhase || isCloud) return aggregate; + + const db = getDbInstance() as unknown as DbLike; + const rows = db + .prepare("SELECT value FROM key_value WHERE namespace = ?") + .all(NAMESPACE) as KeyValueRow[]; + + for (const row of rows) { + const streak = parseStreakJson(row.value); + aggregate.currentStreak = Math.max(aggregate.currentStreak, streak.currentStreak); + aggregate.longestStreak = Math.max(aggregate.longestStreak, streak.longestStreak); + } + + return aggregate; +} + /** * Update streak for today. Returns the new current streak count. * diff --git a/tests/unit/gamification/level-route-streak.test.ts b/tests/unit/gamification/level-route-streak.test.ts new file mode 100644 index 0000000000..88698ab978 --- /dev/null +++ b/tests/unit/gamification/level-route-streak.test.ts @@ -0,0 +1,83 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// The dashboard profile page reads `/api/gamification/level` without an apiKeyId +// (operator-wide view, #3484) and now expects the streak alongside the level payload so +// the streak card (#2403) shows real data instead of a hard-coded 0. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-level-streak-")); +process.env.DATA_DIR = TEST_DATA_DIR; +if (!process.env.API_KEY_SECRET) { + process.env.API_KEY_SECRET = "test-level-streak-secret-" + Date.now(); +} + +const { getDbInstance, resetDbInstance } = await import("../../../src/lib/db/core.ts"); +const { updateStreak } = await import("../../../src/lib/gamification/streaks.ts"); +const { GET } = await import("../../../src/app/api/gamification/level/route.ts"); +const { NextRequest } = await import("next/server"); + +const STREAK_NAMESPACE = "gamification:streaks"; + +interface LevelPayload { + level: { apiKeyId: string; totalXp: number; currentLevel: number } | null; + streak: { current: number; longest: number }; +} + +async function getLevel(query = ""): Promise { + const response = await GET(new NextRequest(`http://localhost/api/gamification/level${query}`)); + assert.equal(response.status, 200); + return (await response.json()) as LevelPayload; +} + +test.before(async () => { + await updateStreak("key-a"); // today → current 1 / longest 1 + getDbInstance() + .prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)") + .run( + STREAK_NAMESPACE, + "key-b", + JSON.stringify({ + currentStreak: 7, + longestStreak: 9, + lastActiveDate: "2026-08-31", + streakStartDate: "2026-08-25", + }) + ); +}); + +test.after(() => { + try { + getDbInstance().close(); + } catch { + /* ignore */ + } + try { + resetDbInstance(); + } catch { + /* ignore */ + } + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("GET /api/gamification/level without apiKeyId returns the aggregate streak next to the level", async () => { + const body = await getLevel(); + assert.equal(body.level?.apiKeyId, "*"); + assert.deepEqual(body.streak, { current: 7, longest: 9 }); +}); + +test("GET /api/gamification/level?apiKeyId returns that key's own streak", async () => { + const keyA = await getLevel("?apiKeyId=key-a"); + assert.deepEqual(keyA.streak, { current: 1, longest: 1 }); + + const keyB = await getLevel("?apiKeyId=key-b"); + assert.deepEqual(keyB.streak, { current: 7, longest: 9 }); +}); + +test("GET /api/gamification/level?apiKeyId for an unknown key returns a zero streak, not an error", async () => { + const body = await getLevel("?apiKeyId=never-seen"); + assert.equal(body.level, null); + assert.deepEqual(body.streak, { current: 0, longest: 0 }); +}); diff --git a/tests/unit/gamification/streaks.test.ts b/tests/unit/gamification/streaks.test.ts index 0b2e6bf557..9e7188f0ea 100644 --- a/tests/unit/gamification/streaks.test.ts +++ b/tests/unit/gamification/streaks.test.ts @@ -1,6 +1,24 @@ -import { describe, it } from "node:test"; +import { after, describe, it } from "node:test"; import assert from "node:assert/strict"; -import { getStreak, updateStreak } from "../../../src/lib/gamification/streaks"; +import { getDbInstance, resetDbInstance } from "../../../src/lib/db/core"; +import { getAggregateStreak, getStreak, updateStreak } from "../../../src/lib/gamification/streaks"; + +const STREAK_NAMESPACE = "gamification:streaks"; + +function seedStreakRow(apiKeyId: string, value: string): void { + getDbInstance() + .prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)") + .run(STREAK_NAMESPACE, apiKeyId, value); +} + +after(() => { + try { + getDbInstance().close(); + } catch { + /* ignore */ + } + resetDbInstance(); +}); describe("Streak Tracker", () => { describe("getStreak", () => { @@ -33,4 +51,53 @@ describe("Streak Tracker", () => { assert.equal(streak.streakStartDate, streak.lastActiveDate); }); }); + + describe("getAggregateStreak", () => { + it("returns zero streak when no key has ever been active", async () => { + // The updateStreak cases above already wrote rows for this process' DB. + getDbInstance().prepare("DELETE FROM key_value WHERE namespace = ?").run(STREAK_NAMESPACE); + + const agg = await getAggregateStreak(); + assert.equal(agg.currentStreak, 0); + assert.equal(agg.longestStreak, 0); + }); + + it("takes the max current and max longest streak across every key", async () => { + await updateStreak("agg-key-a"); // current 1 / longest 1, written by the tracker itself + seedStreakRow( + "agg-key-b", + JSON.stringify({ + currentStreak: 3, + longestStreak: 3, + lastActiveDate: "2026-08-31", + streakStartDate: "2026-08-29", + }) + ); + seedStreakRow( + "agg-key-c", + JSON.stringify({ + currentStreak: 0, + longestStreak: 9, + lastActiveDate: "2026-07-01", + streakStartDate: "2026-06-23", + }) + ); + + const agg = await getAggregateStreak(); + assert.equal(agg.currentStreak, 3); // max(1, 3, 0), not the sum + assert.equal(agg.longestStreak, 9); // max(1, 3, 9) — may come from a different key + }); + + it("ignores malformed rows in the namespace instead of throwing", async () => { + seedStreakRow("agg-key-broken", "not json"); + seedStreakRow( + "agg-key-strings", + JSON.stringify({ currentStreak: "12", longestStreak: null }) + ); + + const agg = await getAggregateStreak(); + assert.equal(agg.currentStreak, 3); + assert.equal(agg.longestStreak, 9); + }); + }); }); diff --git a/tests/unit/ui/profile-streak.test.tsx b/tests/unit/ui/profile-streak.test.tsx new file mode 100644 index 0000000000..56d3ccd2c3 --- /dev/null +++ b/tests/unit/ui/profile-streak.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +// Render the key plus its ICU arguments so the assertions can see the count that reached +// the `dayStreak` message (e.g. `dayStreak{"count":7}`). +const translate = (key: string, values?: Record) => + values ? `${key}${JSON.stringify(values)}` : key; +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => Object.assign(translate, { has: () => false }), +})); + +const { default: ProfilePage } = await import("@/app/(dashboard)/dashboard/profile/page"); + +const roots: Array<{ root: ReturnType; container: HTMLDivElement }> = []; + +function stubFetch(levelBody: Record) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/level")) { + return { ok: true, json: async () => levelBody }; + } + return { ok: true, json: async () => ({ badges: [] }) }; + }) + ); +} + +function mountProfile() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push({ root, container }); + act(() => root.render()); + return container; +} + +async function waitForLoad(container: HTMLDivElement) { + for (let i = 0; i < 40 && container.querySelector('[role="status"]'); i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } +} + +afterEach(() => { + for (const { root, container } of roots.splice(0)) { + act(() => root.unmount()); + container.remove(); + } + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("Profile streak card", () => { + it("renders the current streak from the level response", async () => { + stubFetch({ + level: { totalXp: 150, currentLevel: 2 }, + streak: { current: 7, longest: 9 }, + }); + + const container = mountProfile(); + await waitForLoad(container); + + const text = container.textContent ?? ""; + expect(text).toContain('dayStreak{"count":7}'); + expect(text).toContain("maintainStreak"); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + + it("hides the streak card when the current streak is 0", async () => { + stubFetch({ + level: { totalXp: 150, currentLevel: 2 }, + streak: { current: 0, longest: 9 }, + }); + + const container = mountProfile(); + await waitForLoad(container); + + const text = container.textContent ?? ""; + expect(text).not.toContain("dayStreak"); + expect(text).not.toContain("maintainStreak"); + }); + + it("hides the streak card when the response carries no streak field", async () => { + stubFetch({ level: { totalXp: 150, currentLevel: 2 } }); + + const container = mountProfile(); + await waitForLoad(container); + + expect(container.textContent ?? "").not.toContain("dayStreak"); + }); +}); From 5a490b19e27ead9c068e3288af771552ffa36c9c Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:14:46 +0200 Subject: [PATCH 21/34] feat(gamification): show API key names on the leaderboard (#12385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Leaderboard rendered apiKeyId.slice(0, 8)… under a column translated as "name". The route now enriches each entry with the key's display name — route-local, so the shared getTopN helper and the federation leaderboard stay id-only — and the page renders name ?? shortId with the full id in a title attribute. The lookup selects only id and name from api_keys, chunked at 200 ids, with unknown ids and blank names omitted; no key material leaves the DB layer. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .../12385-leaderboard-api-key-names.md | 1 + .../dashboard/leaderboard/page.tsx | 35 +++- src/app/api/gamification/leaderboard/route.ts | 18 +- src/lib/db/apiKeys/displayNames.ts | 42 +++++ .../leaderboard-route-names.test.ts | 166 ++++++++++++++++++ .../ui/leaderboard-api-key-names.test.tsx | 130 ++++++++++++++ 6 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 changelog.d/features/12385-leaderboard-api-key-names.md create mode 100644 src/lib/db/apiKeys/displayNames.ts create mode 100644 tests/unit/gamification/leaderboard-route-names.test.ts create mode 100644 tests/unit/ui/leaderboard-api-key-names.test.tsx diff --git a/changelog.d/features/12385-leaderboard-api-key-names.md b/changelog.d/features/12385-leaderboard-api-key-names.md new file mode 100644 index 0000000000..c15940ba86 --- /dev/null +++ b/changelog.d/features/12385-leaderboard-api-key-names.md @@ -0,0 +1 @@ +- **feat(gamification):** the dashboard leaderboard now shows each API key's display name under the Name column instead of a truncated key id; `GET /api/gamification/leaderboard` attaches `name` per entry (name only — no key material), while the shared ranking helper and the federation leaderboard stay id-only — thanks @pacocartones diff --git a/src/app/(dashboard)/dashboard/leaderboard/page.tsx b/src/app/(dashboard)/dashboard/leaderboard/page.tsx index 7a8571e01b..89e39dde6c 100644 --- a/src/app/(dashboard)/dashboard/leaderboard/page.tsx +++ b/src/app/(dashboard)/dashboard/leaderboard/page.tsx @@ -9,6 +9,31 @@ type LeaderboardScope = "global" | "weekly" | "monthly" | "tokens_shared"; interface LeaderboardEntry { apiKeyId: string; score: number; + /** API key display name from the REST endpoint; absent on SSE payloads. */ + name?: string | null; +} + +/** Key name when known, otherwise a shortened id so the row is still identifiable. */ +function entryLabel(entry: LeaderboardEntry, idLength: number): string { + const name = entry.name?.trim(); + return name ? name : `${entry.apiKeyId.slice(0, idLength)}...`; +} + +/** + * Live SSE updates carry scores only. Carry the names already fetched over + * REST forward so rows do not flip back to raw ids on every refresh. + */ +function withKnownNames( + previous: LeaderboardEntry[], + incoming: LeaderboardEntry[] +): LeaderboardEntry[] { + const known = new Map(); + for (const entry of previous) { + if (entry.name) known.set(entry.apiKeyId, entry.name); + } + return incoming.map((entry) => + entry.name || !known.has(entry.apiKeyId) ? entry : { ...entry, name: known.get(entry.apiKeyId) } + ); } const SCOPE_LABEL_KEYS: Record = { @@ -67,7 +92,7 @@ export default function LeaderboardPage() { try { const data = JSON.parse(event.data); if (data.type === "leaderboard" && data.scope === scope) { - setEntries(data.entries || []); + setEntries((previous) => withKnownNames(previous, data.entries || [])); } } catch { // ignore parse errors from heartbeats @@ -152,8 +177,8 @@ export default function LeaderboardPage() {
{MEDAL_EMOJI[idx]}
-

- {entry.apiKeyId.slice(0, 8)}... +

+ {entryLabel(entry, 8)}

{entry.score.toLocaleString(locale)} @@ -188,7 +213,9 @@ export default function LeaderboardPage() { className="border-b border-border/50 last:border-b-0" > {idx + 4} - {entry.apiKeyId.slice(0, 12)}... + + {entryLabel(entry, 12)} + {entry.score.toLocaleString(locale)} diff --git a/src/app/api/gamification/leaderboard/route.ts b/src/app/api/gamification/leaderboard/route.ts index cded801d13..eb52fa2e8e 100644 --- a/src/app/api/gamification/leaderboard/route.ts +++ b/src/app/api/gamification/leaderboard/route.ts @@ -7,11 +7,27 @@ import { type LeaderboardScope, } from "@/lib/gamification/leaderboard"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getApiKeyDisplayNames } from "@/lib/db/apiKeys/displayNames"; export async function OPTIONS() { return handleCorsOptions(); } +/** + * Attach each entry's API key display name for the dashboard "Name" column. + * + * Route-local on purpose: the shared getTopN helper stays id-only so the + * federation leaderboard never ships operator key names to peer servers. Only + * the name is added — the lookup reads no key material, and a leaderboard row + * whose key was deleted keeps `name: null` so the UI can fall back to the id. + */ +function withApiKeyNames( + entries: T[] +): Array { + const names = getApiKeyDisplayNames(entries.map((entry) => entry.apiKeyId)); + return entries.map((entry) => ({ ...entry, name: names.get(entry.apiKeyId) ?? null })); +} + export async function GET(request: NextRequest) { const authError = await requireManagementAuth(request); if (authError) return authError; @@ -29,7 +45,7 @@ export async function GET(request: NextRequest) { ); } - const entries = await getTopN(scope, limit); + const entries = withApiKeyNames(await getTopN(scope, limit)); let myRank: number | null = null; let neighbors = null; diff --git a/src/lib/db/apiKeys/displayNames.ts b/src/lib/db/apiKeys/displayNames.ts new file mode 100644 index 0000000000..3a47bcbb93 --- /dev/null +++ b/src/lib/db/apiKeys/displayNames.ts @@ -0,0 +1,42 @@ +import { getDbInstance } from "../core"; + +// Bind slots per IN(...) query — keeps the largest caller batch (a 200-row +// leaderboard page) in one statement while staying far below SQLite's default +// 999-variable limit for pathological lists. +const DISPLAY_NAME_LOOKUP_CHUNK = 200; + +interface DisplayNameRow { + id: string; + name: string | null; +} + +/** + * Display names for a set of API key ids, keyed by id. + * + * Reads only `id` and `name` — never `key`, `key_hash`, `key_prefix` or any + * policy column — so a caller can label a key (leaderboards, audit views) + * without receiving a full key record that would need masking. Unknown ids and + * blank names are simply absent from the result. + */ +export function getApiKeyDisplayNames(ids: readonly string[]): Map { + const names = new Map(); + const unique = Array.from( + new Set(ids.filter((id) => typeof id === "string" && id.trim() !== "")) + ); + if (unique.length === 0) return names; + + const db = getDbInstance(); + for (let start = 0; start < unique.length; start += DISPLAY_NAME_LOOKUP_CHUNK) { + const chunk = unique.slice(start, start + DISPLAY_NAME_LOOKUP_CHUNK); + const placeholders = chunk.map(() => "?").join(", "); + const rows = db + .prepare(`SELECT id, name FROM api_keys WHERE id IN (${placeholders})`) + .all(...chunk) as DisplayNameRow[]; + for (const row of rows) { + if (typeof row.name === "string" && row.name.trim() !== "") { + names.set(row.id, row.name); + } + } + } + return names; +} diff --git a/tests/unit/gamification/leaderboard-route-names.test.ts b/tests/unit/gamification/leaderboard-route-names.test.ts new file mode 100644 index 0000000000..6811b25955 --- /dev/null +++ b/tests/unit/gamification/leaderboard-route-names.test.ts @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { after, before, describe, it } from "node:test"; + +// The dashboard leaderboard labels its rows under a "Name" column but only had the +// API key id to show. GET /api/gamification/leaderboard now attaches the key's +// display name per entry. The enrichment is route-local: the shared getTopN helper +// and the federation endpoint keep returning id-only rows, and no key material +// (key, key_hash, key_prefix, machine_id, ...) may ever ride along with the name. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-leaderboard-names-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "leaderboard-names-route-test-secret"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts"); +const displayNames = await import("../../../src/lib/db/apiKeys/displayNames.ts"); +const gamificationDb = await import("../../../src/lib/db/gamification.ts"); +const settingsDb = await import("../../../src/lib/db/settings.ts"); +const leaderboardRoute = await import("../../../src/app/api/gamification/leaderboard/route.ts"); +const federationRoute = + await import("../../../src/app/api/gamification/federation/leaderboard/route.ts"); +const { NextRequest } = await import("next/server"); + +const SCOPE = "global"; +const FEDERATION_TOKEN = "federation-test-token"; +const KEY_MATERIAL_FIELDS = [ + "key", + "keyHash", + "key_hash", + "keyPrefix", + "key_prefix", + "machineId", + "machine_id", + "scopes", + "allowedModels", +]; + +let namedKeyId = ""; +const orphanKeyId = "orphan-key-with-no-api-key-row"; + +async function leaderboardJson(query = `?scope=${SCOPE}&limit=50`) { + const response = await leaderboardRoute.GET( + new NextRequest(`http://localhost/api/gamification/leaderboard${query}`) + ); + assert.equal(response.status, 200); + return (await response.json()) as { + entries: Array>; + myRank: number | null; + neighbors: unknown; + }; +} + +before(async () => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + await settingsDb.updateSettings({ requireLogin: false }); + + const created = await apiKeysDb.createApiKey("Alpha billing key", "machine-alpha"); + namedKeyId = created.id; + + gamificationDb.updateScore(namedKeyId, SCOPE, 500); + gamificationDb.updateScore(orphanKeyId, SCOPE, 250); + + const tokenHash = crypto + .pbkdf2Sync(FEDERATION_TOKEN, "omniroute-federation-salt", 120000, 32, "sha256") + .toString("hex"); + gamificationDb.connectServer( + "federation-test-server", + "Federation test server", + "http://federation.test", + tokenHash + ); +}); + +after(() => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +describe("GET /api/gamification/leaderboard — API key display names", () => { + it("attaches the API key name to each entry and null when the key is unknown", async () => { + const { entries } = await leaderboardJson(); + + const named = entries.find((e) => e.apiKeyId === namedKeyId); + const orphan = entries.find((e) => e.apiKeyId === orphanKeyId); + assert.ok(named, "named key must be on the leaderboard"); + assert.ok(orphan, "orphan key must be on the leaderboard"); + + assert.equal(named.name, "Alpha billing key"); + assert.equal(named.score, 500); + assert.equal(orphan.name, null); + assert.equal(orphan.score, 250); + }); + + it("exposes only the display name — never key material", async () => { + const { entries } = await leaderboardJson(); + assert.ok(entries.length >= 2); + + for (const entry of entries) { + assert.deepEqual(Object.keys(entry).sort(), [ + "apiKeyId", + "name", + "scope", + "score", + "updatedAt", + ]); + for (const field of KEY_MATERIAL_FIELDS) { + assert.equal(field in entry, false, `${field} must not be exposed`); + } + } + }); + + it("keeps rank/neighbors behaviour and limit validation unchanged", async () => { + const { myRank, neighbors } = await leaderboardJson( + `?scope=${SCOPE}&limit=50&apiKeyId=${namedKeyId}` + ); + assert.equal(myRank, 1); + assert.ok(neighbors && typeof neighbors === "object"); + + const bad = await leaderboardRoute.GET( + new NextRequest("http://localhost/api/gamification/leaderboard?limit=0") + ); + assert.equal(bad.status, 400); + }); + + it("leaves the shared getTopN helper id-only", () => { + const rows = gamificationDb.getTopN(SCOPE, 50) as Array>; + assert.ok(rows.length >= 2); + for (const row of rows) { + assert.equal("name" in row, false, "getTopN must not carry names"); + } + }); + + it("leaves the federation leaderboard id-only", async () => { + const response = await federationRoute.GET( + new NextRequest(`http://localhost/api/gamification/federation/leaderboard?scope=${SCOPE}`, { + headers: { Authorization: `Bearer ${FEDERATION_TOKEN}` }, + }) + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { entries: Array> }; + assert.ok(body.entries.length >= 2); + for (const entry of body.entries) { + assert.deepEqual(Object.keys(entry).sort(), ["apiKeyId", "score"]); + } + }); +}); + +describe("getApiKeyDisplayNames", () => { + it("returns names only for ids that exist and skips blanks", () => { + const names = displayNames.getApiKeyDisplayNames([namedKeyId, orphanKeyId, "", namedKeyId]); + assert.equal(names.size, 1); + assert.equal(names.get(namedKeyId), "Alpha billing key"); + assert.equal(names.has(orphanKeyId), false); + }); + + it("returns an empty map for an empty id list", () => { + assert.equal(displayNames.getApiKeyDisplayNames([]).size, 0); + }); +}); diff --git a/tests/unit/ui/leaderboard-api-key-names.test.tsx b/tests/unit/ui/leaderboard-api-key-names.test.tsx new file mode 100644 index 0000000000..ab7e49378c --- /dev/null +++ b/tests/unit/ui/leaderboard-api-key-names.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const translate = (key: string) => key; +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => Object.assign(translate, { has: () => false }), +})); + +const { default: LeaderboardPage } = await import("@/app/(dashboard)/dashboard/leaderboard/page"); + +// The page opens an EventSource on mount; jsdom has none. Capture instances so a +// test can push a live update through `onmessage`. +class FakeEventSource { + static instances: FakeEventSource[] = []; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + readonly url: string; + constructor(url: string) { + this.url = url; + FakeEventSource.instances.push(this); + } + close() {} +} + +const NAMED_ID = "0f3c2a11-named-key-aaaaaaaaaaaa"; +const UNNAMED_ID = "9b8e7d66-unnamed-key-bbbbbbbbbb"; +const THIRD_ID = "4c4c4c4c-third-key-cccccccccccc"; +const TABLE_NAMED_ID = "1d2e3f40-table-key-dddddddddddd"; +const TABLE_UNNAMED_ID = "5a5a5a5a-table-anon-eeeeeeeeeeee"; + +const ENTRIES = [ + { apiKeyId: NAMED_ID, score: 900, name: "Alpha team" }, + { apiKeyId: UNNAMED_ID, score: 800, name: null }, + { apiKeyId: THIRD_ID, score: 700, name: "Gamma" }, + { apiKeyId: TABLE_NAMED_ID, score: 600, name: "Delta billing" }, + { apiKeyId: TABLE_UNNAMED_ID, score: 500 }, +]; + +const roots: Array<{ root: ReturnType; container: HTMLDivElement }> = []; + +function mountLeaderboard() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push({ root, container }); + act(() => root.render()); + return container; +} + +async function settle() { + for (let i = 0; i < 5; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } +} + +function tableCells(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll("tbody td:nth-child(2)")).map( + (td) => td.textContent ?? "" + ); +} + +beforeEach(() => { + FakeEventSource.instances = []; + vi.stubGlobal("EventSource", FakeEventSource); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ entries: ENTRIES, myRank: null, neighbors: null }), + })) + ); +}); + +afterEach(() => { + for (const { root, container } of roots.splice(0)) { + act(() => root.unmount()); + container.remove(); + } + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("Leaderboard API key names", () => { + it("renders the key name on the podium and in the table, falling back to a short id", async () => { + const container = mountLeaderboard(); + await settle(); + + const text = container.textContent ?? ""; + expect(text).toContain("Alpha team"); + expect(text).toContain("Gamma"); + expect(text).toContain(`${UNNAMED_ID.slice(0, 8)}...`); + expect(text).not.toContain(`${NAMED_ID.slice(0, 8)}...`); + + expect(tableCells(container)).toEqual(["Delta billing", `${TABLE_UNNAMED_ID.slice(0, 12)}...`]); + }); + + it("keeps known names when a live update arrives without them", async () => { + const container = mountLeaderboard(); + await settle(); + expect(container.textContent).toContain("Alpha team"); + + const es = FakeEventSource.instances.at(-1); + expect(es).toBeDefined(); + await act(async () => { + es!.onmessage?.({ + data: JSON.stringify({ + type: "leaderboard", + scope: "global", + entries: ENTRIES.map(({ apiKeyId, score }) => ({ apiKeyId, score: score + 1 })), + }), + }); + }); + + const text = container.textContent ?? ""; + expect(text).toContain("901"); + expect(text).toContain("Alpha team"); + expect(tableCells(container)).toEqual(["Delta billing", `${TABLE_UNNAMED_ID.slice(0, 12)}...`]); + }); +}); From 62e2481eef63e1c387e298ea947fbb7289d54bde Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:14:50 +0200 Subject: [PATCH 22/34] fix(resilience): derive the chat_admission_busy Retry-After from observed lease occupancy (#12395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retryable chat_admission_busy 503 advertised a fixed Retry-After of 1s or 2s while the heavyweight lease it waits on is held for the entire SSE lifetime. Clients that honour the header — Codex CLI, agent fan-out — re-sent the same ~1 MiB /v1/responses body every second into a gate that could not have cleared, producing the queue_timeout retry storm that persisted even after OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT was raised. ChatAdmissionController now tracks each live heavy lease's acquisition time and derives the hint from observed occupancy: the larger of the queue window the waiter already exhausted and the age of the youngest live lease, rounded up and capped at 60s. Both builders floor it at the historical 1s / 2s, so an idle gate answers exactly as before. Using the youngest rather than the oldest lease avoids a pessimistic hint when several slots are in flight. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .../12395-heavy-admission-retry-after.md | 1 + docs/guides/TROUBLESHOOTING.md | 8 +- .../middleware/chatAdmissionResponses.ts | 41 +++- src/shared/middleware/chatBodyAdmission.ts | 72 +++++- .../heavy-admission-retry-after-12135.test.ts | 229 ++++++++++++++++++ 5 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 changelog.d/fixes/12395-heavy-admission-retry-after.md create mode 100644 tests/unit/heavy-admission-retry-after-12135.test.ts diff --git a/changelog.d/fixes/12395-heavy-admission-retry-after.md b/changelog.d/fixes/12395-heavy-admission-retry-after.md new file mode 100644 index 0000000000..4cc5badb6c --- /dev/null +++ b/changelog.d/fixes/12395-heavy-admission-retry-after.md @@ -0,0 +1 @@ +- **fix(chat-admission):** derive the `chat_admission_busy` 503 `Retry-After` from observed heavyweight-lease occupancy — the larger of the exhausted `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` window and the time since capacity last turned over, capped at 60 s — instead of a fixed 1 s (structural) / 2 s (byte-stage) hint that invited Codex/agent fan-out clients to re-send ~1 MiB `/v1/responses` bodies every second into a gate held for the whole SSE lifetime; an idle gate keeps the historical floors ([#12135](https://github.com/diegosouzapw/OmniRoute/issues/12135)) (#12395 — thanks @pacocartones) diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index 4e0cb8883b..e512afbfee 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -538,8 +538,12 @@ When many concurrent requests hit a rate-limited provider, OmniRoute uses mutex - The chat completions endpoint returns a retryable `503` response whose error code is `chat_admission_busy`. -- The response includes `Retry-After`; the byte-based path uses 2 seconds, while the - structure-based path uses 1 second and includes `reason: "structure_limit"`. +- The response includes `Retry-After`. Since #12135 the value is derived from observed + occupancy — the larger of the `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` window the request already + waited and the time the current heavyweight leases have been held — rounded up to whole + seconds and capped at 60. On an idle gate it keeps the historical floors: 2 seconds on the + byte-based path, 1 second on the structure-based path (which also includes + `reason: "structure_limit"`). - This can happen while another heavyweight chat or long-running streaming response is still in flight. diff --git a/src/shared/middleware/chatAdmissionResponses.ts b/src/shared/middleware/chatAdmissionResponses.ts index ac1dfdce66..0d848b1fba 100644 --- a/src/shared/middleware/chatAdmissionResponses.ts +++ b/src/shared/middleware/chatAdmissionResponses.ts @@ -4,10 +4,34 @@ import { CORS_HEADERS } from "../utils/cors"; const JSON_HEADERS = { ...CORS_HEADERS, "Content-Type": "application/json" }; -export function chatAdmissionRejectionResponse(status: 413 | 503, hardMaxBytes: number): Response { +/** + * `Retry-After` floors for the retryable 503s — the pre-#12135 fixed values. A caller + * passes an occupancy-derived hint (`ChatAdmissionController#retryAfterSeconds`) and the + * header carries whichever is larger, so an idle gate still answers exactly as before + * while a gate whose leases have been busy for a whole SSE stream stops inviting a + * 1-second retry storm. + */ +const BYTE_STAGE_RETRY_AFTER_FLOOR_SECONDS = 2; +const STRUCTURAL_RETRY_AFTER_FLOOR_SECONDS = 1; + +function retryAfterHeader(floorSeconds: number, hintSeconds: number | undefined): string { + const hint = Number.isFinite(hintSeconds) ? Math.ceil(hintSeconds as number) : 0; + return String(Math.max(floorSeconds, hint)); +} + +export function chatAdmissionRejectionResponse( + status: 413 | 503, + hardMaxBytes: number, + retryAfterSeconds?: number +): Response { const isPayload = status === 413; const headers: Record = { ...JSON_HEADERS }; - if (!isPayload) headers["Retry-After"] = "2"; + if (!isPayload) { + headers["Retry-After"] = retryAfterHeader( + BYTE_STAGE_RETRY_AFTER_FLOOR_SECONDS, + retryAfterSeconds + ); + } const message = isPayload ? `Request body too large for chat completions (max ${Math.floor( hardMaxBytes / (1024 * 1024) @@ -53,10 +77,19 @@ export function resourcePressureRejectionResponse(): Response { ); } -export function structuralRejectionResponse(status: 413 | 503, maxMessages: number): Response { +export function structuralRejectionResponse( + status: 413 | 503, + maxMessages: number, + retryAfterSeconds?: number +): Response { const historyLimit = status === 413; const headers: Record = { ...JSON_HEADERS }; - if (!historyLimit) headers["Retry-After"] = "1"; + if (!historyLimit) { + headers["Retry-After"] = retryAfterHeader( + STRUCTURAL_RETRY_AFTER_FLOOR_SECONDS, + retryAfterSeconds + ); + } const body = buildErrorBody( status, historyLimit diff --git a/src/shared/middleware/chatBodyAdmission.ts b/src/shared/middleware/chatBodyAdmission.ts index 0fc550ba6b..b30ad4cad7 100644 --- a/src/shared/middleware/chatBodyAdmission.ts +++ b/src/shared/middleware/chatBodyAdmission.ts @@ -92,6 +92,15 @@ export const CHAT_ADMISSION_MAX_QUEUED_BYTES = parsePositiveInt( 4 * 1024 * 1024 ); +/** + * Ceiling for the occupancy-derived `Retry-After` on a capacity 503 (#12135). A + * heavyweight lease is held for the whole SSE lifetime, so the hint is derived from how + * long capacity has demonstrably been busy (`ChatAdmissionController#retryAfterSeconds`); + * this cap keeps a multi-minute stream from telling a client to sleep for minutes when + * another slot may free far sooner. + */ +export const CHAT_ADMISSION_RETRY_AFTER_MAX_SECONDS = 60; + export const CHAT_HEAVY_MESSAGE_COUNT = parsePositiveInt( process.env.OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT, 200 @@ -260,6 +269,9 @@ export class ChatAdmissionController { * `CHAT_MAX_HEAVY_IN_FLIGHT` bound, but still a real, finite ceiling instead of * the unconditional bypass this replaces. */ #activeHealthy = 0; + /** #12135: acquisition time of every live heavy lease, keyed by an opaque token, so the + * capacity 503 can advertise a `Retry-After` derived from observed occupancy. */ + #heavyLeaseStartedAt = new Map(); /** Per-key FIFOs. A key groups one client's waiters so they are served * round-robin against the shared budget instead of monopolizing a strict * FIFO (see #dispatchFair). */ @@ -397,6 +409,8 @@ export class ChatAdmissionController { tryAcquireHeavy(): ChatAdmissionLease | null { if (this.#activeHeavy >= this.maxHeavyInFlight) return null; this.#activeHeavy += 1; + const token = Symbol("heavy-lease"); + this.#heavyLeaseStartedAt.set(token, Date.now()); const done = trackRequest(); let released = false; return { @@ -407,12 +421,39 @@ export class ChatAdmissionController { if (released) return; released = true; this.#activeHeavy = Math.max(0, this.#activeHeavy - 1); + this.#heavyLeaseStartedAt.delete(token); done(); this.#dispatchFair(); }, }; } + /** + * `Retry-After` (whole seconds) for a capacity 503, derived from live occupancy instead + * of a fixed constant (#12135). A heavyweight lease is held for the ENTIRE SSE lifetime + * (tens of seconds to minutes), so a fixed 1–2 s hint invited clients to re-send the + * same ~1 MiB body every second into a gate that could not possibly have cleared. The + * hint is the larger of: + * - `queueMs`, the bounded wait the caller already exhausted — the server itself needed + * longer than that, so advertising less is dishonest; and + * - the age of the YOUNGEST live heavy lease: the time since heavyweight capacity last + * turned over. Every slot has been continuously held at least that long, so it is the + * observed floor on how long "busy" has lasted (the oldest lease would be a pessimist + * with N slots in flight). + * Rounded up and capped at `CHAT_ADMISSION_RETRY_AFTER_MAX_SECONDS`. The response + * builders floor the result at their historical value (1 s structural, 2 s byte-stage), + * so an idle gate answers exactly as before. + */ + retryAfterSeconds(queueMs: number, now = Date.now()): number { + let youngestAgeMs = Number.POSITIVE_INFINITY; + for (const startedAt of this.#heavyLeaseStartedAt.values()) { + youngestAgeMs = Math.min(youngestAgeMs, now - startedAt); + } + const occupancyMs = Number.isFinite(youngestAgeMs) ? youngestAgeMs : 0; + const hintSeconds = Math.ceil(Math.max(0, queueMs, occupancyMs) / 1000); + return Math.min(CHAT_ADMISSION_RETRY_AFTER_MAX_SECONDS, Math.max(1, hintSeconds)); + } + /** * Wait up to `timeoutMs` for heavyweight capacity, retrying atomically on each * release. Resolves `null` when the deadline expires with no capacity freed, in @@ -843,26 +884,41 @@ export async function admitChatStructure( // Structural-only waits happen on byte-light bodies (a byte-heavy body already // holds the byte-stage lease), so the conservative 256KB weight bounds the // parsed JSON the waiter keeps resident while parked. + const queueMs = options.queueMs ?? 0; const acquiredCount = await controller.acquireHeavyWithin( - options.queueMs ?? 0, + queueMs, options.signal, CHAT_LARGE_BODY_BYTES, options.sessionId ); if (!acquiredCount) { - return { admit: false, response: structuralRejectionResponse(503, maxMessages) }; + return { + admit: false, + response: structuralRejectionResponse( + 503, + maxMessages, + controller.retryAfterSeconds(queueMs) + ), + }; } // #503-fanout: same composed count+budget gate as the fast path above. const acquiredBudget = await controller.acquireBudgetWithin( CHAT_LARGE_BODY_BYTES, - options.queueMs ?? 0, + queueMs, options.signal, options.sessionId ); if (acquiredBudget.status !== "acquired") { acquiredCount.release(); - return { admit: false, response: structuralRejectionResponse(503, maxMessages) }; + return { + admit: false, + response: structuralRejectionResponse( + 503, + maxMessages, + controller.retryAfterSeconds(queueMs) + ), + }; } return { admit: true, @@ -1006,6 +1062,10 @@ export async function admitChatRequest( return true; }; + // #12135: the capacity 503 advertises an occupancy-derived Retry-After. + const busyResponse = () => + chatAdmissionRejectionResponse(503, hardMaxBytes, controller.retryAfterSeconds(queueMs)); + // A known-large declaration can reserve before ingestion. Unknown lengths are boundedly // sniffed below; this avoids consuming scarce heavyweight capacity for small chunked bodies. if ( @@ -1013,7 +1073,7 @@ export async function admitChatRequest( contentLength >= largeBodyBytes && !(await reserve(Math.min(contentLength, hardMaxBytes))) ) { - return { admit: false, response: chatAdmissionRejectionResponse(503, hardMaxBytes) }; + return { admit: false, response: busyResponse() }; } const reader = request.body?.getReader(); @@ -1039,7 +1099,7 @@ export async function admitChatRequest( } if (totalBytes >= largeBodyBytes && !(await reserve(totalBytes))) { await reader.cancel("chat admission capacity unavailable").catch(() => undefined); - return { admit: false, response: chatAdmissionRejectionResponse(503, hardMaxBytes) }; + return { admit: false, response: busyResponse() }; } chunks.push(value); } diff --git a/tests/unit/heavy-admission-retry-after-12135.test.ts b/tests/unit/heavy-admission-retry-after-12135.test.ts new file mode 100644 index 0000000000..fda3b5f259 --- /dev/null +++ b/tests/unit/heavy-admission-retry-after-12135.test.ts @@ -0,0 +1,229 @@ +// #12135: "[BUG] Heavy /v1/responses still 503 chat_admission_busy after MAX_HEAVY is +// raised: QUEUE_MS and Retry-After: 1 are far shorter than SSE occupancy". +// +// A heavyweight admission lease is held for the ENTIRE SSE lifetime (tens of seconds to +// minutes), but the retryable 503 advertised a fixed `Retry-After: 1` (structural path) +// or `Retry-After: 2` (byte-stage path) regardless of how long capacity had actually been +// busy or how long the waiter had already spent in the bounded queue. Clients that honor +// the header (Codex CLI, agent fan-out) re-sent the same ~1 MiB body every second into a +// gate that could not possibly have cleared, producing a `queue_timeout` retry storm. +// +// The maintainer scoped the fix on the issue: "A `Retry-After` derived from observed +// lease age/occupancy would be honest." These tests pin that contract WITHOUT touching +// the queue posture (`OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` default), which the maintainer +// explicitly left as a separate decision: +// (a) after a `queue_timeout`, `Retry-After` is at least the queue window the waiter +// already exhausted — never less than what the server itself needed; +// (b) `Retry-After` reflects the observed age of the in-flight heavy lease (time since +// heavyweight capacity last turned over), on BOTH the structural and byte-stage 503s; +// (c) the hint is capped so a multi-minute stream never tells a client to sleep for +// minutes when another slot may free sooner; +// (d) an idle gate (fresh lease, no queue) still answers exactly as before (1 s / 2 s), +// so no existing client behavior changes on a quiet host; +// (e) with the count cap raised, a third heavy `/v1/responses` request is queued and +// admitted when a lease frees inside `queueMs` instead of being rejected. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + admitChatRequest, + admitChatStructure, + ChatAdmissionController, + type ChatAdmissionLease, +} from "../../src/shared/middleware/chatBodyAdmission.ts"; + +/** The reporter's shape: a Codex `/v1/responses` session with ~70 function tools. */ +function responsesHeavyBody() { + const tools = Array.from({ length: 70 }, (_, i) => ({ + type: "function", + name: `tool_${i}`, + description: "a".repeat(64), + parameters: { type: "object", properties: {} }, + })); + return { + model: "gpt-5.6-sol", + input: [{ role: "user", content: "run the plan" }], + tools, + stream: true, + }; +} + +const NOOP_SHED_SINK = () => {}; + +function heavyController(maxHeavyInFlight: number): ChatAdmissionController { + // healthyHeadroom=0 forces the bounded-wait/shed path; the sink keeps pino quiet. + return new ChatAdmissionController(maxHeavyInFlight, undefined, 0, NOOP_SHED_SINK); +} + +type Rejected = { admit: false; response: Response }; + +function admitStructure( + controller: ChatAdmissionController, + queueMs: number +): ReturnType { + return admitChatStructure(responsesHeavyBody(), null, { + controller, + queueMs, + heapPressureCheck: () => true, + }); +} + +async function holdStructural(controller: ChatAdmissionController): Promise { + const holder = await admitStructure(controller, 0); + assert.equal(holder.admit, true); + const lease = (holder as { admit: true; lease: ChatAdmissionLease | null }).lease; + assert.ok(lease, "the first heavy request must hold the heavyweight lease"); + return lease; +} + +/** Let a pending admission park in the queue before the mocked clock advances. */ +async function settleMicrotasks(): Promise { + for (let i = 0; i < 8; i++) await Promise.resolve(); +} + +test("#12135 (a): structural queue_timeout 503 advertises at least the exhausted queue window", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"] }); + const controller = heavyController(1); + const lease = await holdStructural(controller); + try { + const pending = admitStructure(controller, 5_000); + await settleMicrotasks(); + assert.equal(controller.waitingCount, 1, "the second heavy request must park, not fail fast"); + t.mock.timers.tick(5_000); + const result = (await pending) as Rejected; + assert.equal(result.admit, false); + assert.equal(result.response.status, 503); + assert.equal( + result.response.headers.get("Retry-After"), + "5", + "Retry-After must not be shorter than the queue window the waiter already burned" + ); + const body = await result.response.json(); + assert.equal(body.error?.code, "chat_admission_busy"); + assert.equal(body.error?.reason, "structure_limit"); + } finally { + lease.release(); + } +}); + +test("#12135 (b): structural 503 Retry-After reflects the observed age of the in-flight lease", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"] }); + const controller = heavyController(1); + const lease = await holdStructural(controller); + try { + // The holder streams for 45 s; a fast-fail (queueMs=0) arrival must be told to wait + // on the order of what capacity has demonstrably been busy for, not 1 s. + t.mock.timers.tick(45_000); + const result = (await admitStructure(controller, 0)) as Rejected; + assert.equal(result.admit, false); + assert.equal(result.response.status, 503); + assert.equal(result.response.headers.get("Retry-After"), "45"); + } finally { + lease.release(); + } +}); + +test("#12135 (b): Retry-After is the age of the YOUNGEST live lease — time since capacity last turned over", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"] }); + const controller = heavyController(2); + const first = await holdStructural(controller); + t.mock.timers.tick(40_000); + const second = await holdStructural(controller); + try { + t.mock.timers.tick(7_000); + const result = (await admitStructure(controller, 0)) as Rejected; + assert.equal(result.admit, false); + // first is 47 s old, second is 7 s old: every slot has been continuously held for + // at least 7 s, so that is the honest occupancy floor — not the 47 s pessimist. + assert.equal(result.response.headers.get("Retry-After"), "7"); + } finally { + second.release(); + first.release(); + } +}); + +test("#12135 (c): the occupancy-derived hint is capped", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"] }); + const controller = heavyController(1); + const lease = await holdStructural(controller); + try { + t.mock.timers.tick(10 * 60_000); + const result = (await admitStructure(controller, 0)) as Rejected; + assert.equal(result.admit, false); + // CHAT_ADMISSION_RETRY_AFTER_MAX_SECONDS: a 10-minute-old stream must not advertise + // 600 s — another slot may free long before that. + assert.equal(result.response.headers.get("Retry-After"), "60"); + } finally { + lease.release(); + } +}); + +function largeRequest(): Request { + const body = JSON.stringify({ messages: [{ role: "user", content: "x".repeat(40) }] }); + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", "content-length": String(body.length) }, + body, + }); +} + +test("#12135 (b): byte-stage 503 (admitChatRequest) carries the same occupancy-derived Retry-After", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"] }); + const controller = heavyController(1); + const options = { controller, largeBodyBytes: 32, hardMaxBytes: 1024, queueMs: 0 }; + const first = await admitChatRequest(largeRequest(), options); + assert.equal(first.admit, true); + if (!first.admit) return; + try { + t.mock.timers.tick(30_000); + const second = (await admitChatRequest(largeRequest(), options)) as Rejected; + assert.equal(second.admit, false); + assert.equal(second.response.status, 503); + assert.equal(second.response.headers.get("Retry-After"), "30"); + assert.equal((await second.response.json()).error.code, "chat_admission_busy"); + } finally { + first.lease?.release(); + } +}); + +test("#12135 (d): an idle gate keeps the historical 1 s (structural) and 2 s (byte-stage) floors", async () => { + const structural = heavyController(1); + const structuralLease = await holdStructural(structural); + try { + const result = (await admitStructure(structural, 0)) as Rejected; + assert.equal(result.admit, false); + assert.equal(result.response.headers.get("Retry-After"), "1"); + } finally { + structuralLease.release(); + } + + const byteStage = heavyController(1); + const options = { controller: byteStage, largeBodyBytes: 32, hardMaxBytes: 1024, queueMs: 0 }; + const first = await admitChatRequest(largeRequest(), options); + assert.equal(first.admit, true); + if (!first.admit) return; + try { + const second = (await admitChatRequest(largeRequest(), options)) as Rejected; + assert.equal(second.admit, false); + assert.equal(second.response.headers.get("Retry-After"), "2"); + } finally { + first.lease?.release(); + } +}); + +test("#12135 (e): with the count cap raised, a third heavy /v1/responses request queues and is admitted when a lease frees inside queueMs", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"] }); + const controller = heavyController(2); + const first = await holdStructural(controller); + const second = await holdStructural(controller); + assert.equal(controller.activeHeavy, 2); + const pending = admitStructure(controller, 10_000); + await settleMicrotasks(); + assert.equal(controller.waitingCount, 1, "the third request must wait, not 503"); + t.mock.timers.tick(1_000); + first.release(); + const third = await pending; + assert.equal(third.admit, true, "a freed lease inside the queue window must admit the waiter"); + if (third.admit) third.lease?.release(); + second.release(); + assert.equal(controller.activeHeavy, 0); +}); From 5a34111125e45950d2abd21f3bd05913ca8024a6 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:14:54 +0200 Subject: [PATCH 23/34] fix(resilience): count resolved 5xx results against the provider breaker (#12360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CircuitBreaker.execute() treated every resolved promise as a success, but handleChatCore() reports most upstream failures by resolving with { success: false, status: 5xx }. On the chat path that spurious _onSuccess() decayed failureCount right before the call site's _onFailure() for the same attempt, so a provider answering 503s indefinitely stayed CLOSED at failureCount: 1 and kept receiving traffic — the breaker was structurally unable to open. Combo dispatches hit the same cancellation through the shared per-provider breaker. execute() now takes an optional per-call classifyResult; without it the resolved-means-success contract every throw-based caller relies on is unchanged. executeChatWithBreaker() passes ignore and the chat path accounts for the outcome exactly once where the request context lives, so a combo success is no longer counted twice. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .../12360-circuit-breaker-resolved-5xx.md | 1 + src/shared/utils/circuitBreaker.ts | 46 ++++- src/sse/handlers/chat.ts | 8 +- src/sse/handlers/chatHelpers.ts | 16 +- src/sse/handlers/chatPredicates.ts | 32 +++ stryker.conf.json | 1 + .../unit/breaker-network-error-guard.test.ts | 51 ++++- ...circuit-breaker-resolved-5xx-12254.test.ts | 192 ++++++++++++++++++ 8 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fixes/12360-circuit-breaker-resolved-5xx.md create mode 100644 tests/unit/circuit-breaker-resolved-5xx-12254.test.ts diff --git a/changelog.d/fixes/12360-circuit-breaker-resolved-5xx.md b/changelog.d/fixes/12360-circuit-breaker-resolved-5xx.md new file mode 100644 index 0000000000..419d2ff76f --- /dev/null +++ b/changelog.d/fixes/12360-circuit-breaker-resolved-5xx.md @@ -0,0 +1 @@ +- **fix(resilience):** count resolved upstream 5xx results against the provider circuit breaker on the chat path — `CircuitBreaker.execute()` no longer reads a resolved `{ success: false, status: 5xx }` as a success that cancels the call-site failure, so a provider answering 503s now trips its breaker instead of staying `CLOSED` at `failureCount: 1`; single-model and combo dispatches are each accounted exactly once ([#12254](https://github.com/diegosouzapw/OmniRoute/issues/12254)) diff --git a/src/shared/utils/circuitBreaker.ts b/src/shared/utils/circuitBreaker.ts index 773d757aa2..02e4e67811 100644 --- a/src/shared/utils/circuitBreaker.ts +++ b/src/shared/utils/circuitBreaker.ts @@ -150,6 +150,25 @@ interface CircuitBreakerOptions { backoffEscalationCount?: number; } +/** + * How a RESOLVED `execute()` result is accounted (#12254). Callers such as + * `handleChatCore()` report most upstream failures by resolving with + * `{ success: false, status: 5xx }` instead of throwing, so a breaker that reads every + * resolution as a success never trips on that path. + */ +export type CircuitBreakerResultOutcome = "success" | "failure" | "ignore"; + +export interface CircuitBreakerExecuteOptions { + /** + * Classify a resolved result. Omitted: every resolution is a success (the + * throw-based contract every other caller relies on). Return "ignore" when the + * call site accounts for the outcome itself with request context the breaker + * does not have — the chat path does (`classifyProviderBreakerResult()` in + * chat.ts, `recordProviderFailure()`/`recordProviderSuccess()` in combo.ts). + */ + classifyResult?: (result: T) => CircuitBreakerResultOutcome; +} + export interface TransitionRecord { from: string; to: string; @@ -300,7 +319,7 @@ export class CircuitBreaker { ); } - async execute(fn: () => Promise): Promise { + async execute(fn: () => Promise, options?: CircuitBreakerExecuteOptions): Promise { this._refreshOpenState(); if (this.state === STATE.OPEN) { @@ -325,7 +344,7 @@ export class CircuitBreaker { try { const result = await fn(); - this._onSuccess(); + this._recordResolvedResult(result, options?.classifyResult); return result; } catch (error) { if (this.isFailure(error)) { @@ -387,6 +406,29 @@ export class CircuitBreaker { // ─── Internal ───────────────────────────────── + /** + * Account a resolved `execute()` result exactly once. A classifier that throws + * falls back to the legacy "resolved = success" reading, mirroring `classifyError`. + */ + _recordResolvedResult( + result: T, + classifyResult?: (result: T) => CircuitBreakerResultOutcome + ): void { + let outcome: CircuitBreakerResultOutcome = "success"; + if (classifyResult) { + try { + outcome = classifyResult(result); + } catch { + outcome = "success"; + } + } + if (outcome === "failure") { + this._onFailure(); + } else if (outcome === "success") { + this._onSuccess(); + } + } + _onSuccess() { if (this.state === STATE.OPEN) { this._transition(STATE.CLOSED, "success-recovery"); diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 26e58825f5..9d8bc608e1 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -105,10 +105,10 @@ import { import { buildModalityBridgeHeader } from "@/lib/guardrails/modalityBridge/bridgeStats"; import { resolveConversationId } from "@omniroute/open-sse/services/conversationTracker.ts"; import { + classifyProviderBreakerResult, isAntigravityMissingProjectError, isProviderBreakerFailureStatus, resolveStreamReadinessClassificationError, - shouldTripProviderBreakerForResult, } from "./chatPredicates"; import { markAntigravityMissingCloudCodeProject } from "@omniroute/open-sse/services/antigravityProjectPersistence.ts"; import { connectionHasExtraKeys } from "@omniroute/open-sse/services/apiKeyRotator.ts"; @@ -1923,7 +1923,9 @@ async function handleSingleModelChat( if (result.success) { clearModelLock(provider, credentials.connectionId, model); - if (!forceLiveComboTest) { + // #12254: exactly-once breaker accounting — combo successes are recorded by + // combo.ts (recordProviderSuccess); live combo tests never touch the breaker. + if (classifyProviderBreakerResult(result, isCombo, forceLiveComboTest) === "success") { breaker._onSuccess(); } if (injectedHandoff && runtimeOptions.sessionId && comboName) { @@ -2370,7 +2372,7 @@ async function handleSingleModelChat( // breaker for real traffic (#9817). if ( !(await shouldIsolateProbeFailures()) && - shouldTripProviderBreakerForResult(result, isCombo, forceLiveComboTest) + classifyProviderBreakerResult(result, isCombo, forceLiveComboTest) === "failure" ) { breaker._onFailure(); } diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 7bf4eef08f..b736203b75 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -399,6 +399,13 @@ export function checkResourcePressureBeforeProviderWork(): ResourcePressureGuard } } +// #12254: handleChatCore resolves `{ success: false, status: 5xx }` for most upstream +// failures, so execute() must not read a resolution as a success (it used to, and that +// spurious _onSuccess() cancelled the call site's _onFailure() for the same attempt). +// The chat path accounts for the outcome exactly once where the request context lives: +// chat.ts via classifyProviderBreakerResult(), combo.ts via recordProviderFailure/Success. +const chatPathOwnsBreakerAccounting = () => "ignore" as const; + export async function executeChatWithBreaker({ bypassCircuitBreaker, breaker, @@ -592,13 +599,16 @@ export async function executeChatWithBreaker({ } if (tlsFingerprintActive) { - const tracked = await breaker.execute(async () => - runWithTlsTracking(tlsTrackingIdentity, chatFn) + const tracked = await breaker.execute( + async () => runWithTlsTracking(tlsTrackingIdentity, chatFn), + { classifyResult: chatPathOwnsBreakerAccounting } ); return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed }; } - const result = await breaker.execute(chatFn); + const result = await breaker.execute(chatFn, { + classifyResult: chatPathOwnsBreakerAccounting, + }); return { result, tlsFingerprintUsed: false }; } catch (cbErr: any) { if (cbErr instanceof CircuitBreakerOpenError) { diff --git a/src/sse/handlers/chatPredicates.ts b/src/sse/handlers/chatPredicates.ts index db9abd3da2..f1ccbbaab2 100644 --- a/src/sse/handlers/chatPredicates.ts +++ b/src/sse/handlers/chatPredicates.ts @@ -43,6 +43,38 @@ export function shouldTripProviderBreakerForResult( ); } +export type ProviderBreakerResultOutcome = "success" | "failure" | "ignore"; + +/** + * #12254: single source of truth for how a resolved dispatch result is accounted + * against the per-provider breaker. `handleChatCore()` resolves with + * `{ success: false, status: 5xx }` for most upstream failures, so `breaker.execute()` + * cannot classify it — the call site does, exactly once: + * - combo dispatches and live combo tests are "ignore": the combo target loop owns the + * accounting (`recordProviderFailure()` / `recordProviderSuccess()`), which also knows + * about same-provider-next and `skipProviderBreaker`; + * - a successful single-model dispatch is a "success"; + * - a failed one is a "failure" only when `shouldTripProviderBreakerForResult()` agrees. + */ +export function classifyProviderBreakerResult( + result: { + success?: boolean; + status: number; + response?: Response; + errorCode?: string | null; + errorType?: string | null; + error?: unknown; + }, + isCombo: boolean, + forceLiveComboTest: boolean +): ProviderBreakerResultOutcome { + if (forceLiveComboTest || isCombo) return "ignore"; + if (result.success) return "success"; + return shouldTripProviderBreakerForResult(result, isCombo, forceLiveComboTest) + ? "failure" + : "ignore"; +} + export function isAntigravityMissingProjectError( provider: string, result: { status?: number; errorCode?: string; errorType?: string } diff --git a/stryker.conf.json b/stryker.conf.json index 8345b15d89..adfd35dcdf 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -147,6 +147,7 @@ "tests/unit/circuit-breaker-failure-kind.test.ts", "tests/unit/circuit-breaker-local-execution.test.ts", "tests/unit/circuit-breaker-registry-cap.test.ts", + "tests/unit/circuit-breaker-resolved-5xx-12254.test.ts", "tests/unit/circuit-breaker-stream-controller-4602.test.ts", "tests/unit/claude-code-parity.test.ts", "tests/unit/claude-effort-suffix-strip.test.ts", diff --git a/tests/unit/breaker-network-error-guard.test.ts b/tests/unit/breaker-network-error-guard.test.ts index c03c25e561..8d53edc080 100644 --- a/tests/unit/breaker-network-error-guard.test.ts +++ b/tests/unit/breaker-network-error-guard.test.ts @@ -1,6 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { shouldTripProviderBreakerForResult } from "../../src/sse/handlers/chatPredicates.ts"; +import { + classifyProviderBreakerResult, + shouldTripProviderBreakerForResult, +} from "../../src/sse/handlers/chatPredicates.ts"; import { recordProviderFailure, clearProviderFailure, @@ -70,6 +73,50 @@ test("forceLiveComboTest=true prevents breaker trip (combo will try next target) assert.equal(result, false); }); +// #12254: the single-model call site accounts for a RESOLVED dispatch result exactly +// once through this classifier — `breaker.execute()` no longer reads a resolved +// `{ success: false, status: 5xx }` as a success. +test("classifyProviderBreakerResult: a resolved 503 on the single-model path is a failure", () => { + const outcome = classifyProviderBreakerResult( + { success: false, status: 503, errorCode: null, errorType: null, error: "overloaded" }, + false, + false + ); + assert.equal(outcome, "failure"); +}); +test("classifyProviderBreakerResult: a successful single-model dispatch is a success", () => { + const outcome = classifyProviderBreakerResult({ success: true, status: 200 }, false, false); + assert.equal(outcome, "success"); +}); +test("classifyProviderBreakerResult: excluded failures are ignored, not counted as successes", () => { + const outcome = classifyProviderBreakerResult( + { success: false, status: 502, errorCode: "proxy_unreachable", errorType: null }, + false, + false + ); + assert.equal(outcome, "ignore"); +}); +test("classifyProviderBreakerResult: combo dispatches leave accounting to combo.ts (success and failure)", () => { + assert.equal( + classifyProviderBreakerResult({ success: true, status: 200 }, true, false), + "ignore" + ); + assert.equal( + classifyProviderBreakerResult({ success: false, status: 503, errorCode: null }, true, false), + "ignore" + ); +}); +test("classifyProviderBreakerResult: live combo tests never touch the breaker", () => { + assert.equal( + classifyProviderBreakerResult({ success: true, status: 200 }, false, true), + "ignore" + ); + assert.equal( + classifyProviderBreakerResult({ success: false, status: 503, errorCode: null }, false, true), + "ignore" + ); +}); + test("queue-timeout recordProviderFailure never opens the provider breaker", () => { // Control first: that many real failures WOULD open the breaker — proving the // isQueueTimeout flag, not an inert provider, is what keeps it closed. @@ -136,4 +183,4 @@ test("persistent dead proxy across windows still opens the breaker", () => { } finally { Date.now = originalNow; } -}); \ No newline at end of file +}); diff --git a/tests/unit/circuit-breaker-resolved-5xx-12254.test.ts b/tests/unit/circuit-breaker-resolved-5xx-12254.test.ts new file mode 100644 index 0000000000..613f1d846d --- /dev/null +++ b/tests/unit/circuit-breaker-resolved-5xx-12254.test.ts @@ -0,0 +1,192 @@ +/** + * #12254: `handleChatCore()` reports most upstream failures by RESOLVING with + * `{ success: false, status: 5xx }` rather than throwing. `CircuitBreaker.execute()` + * used to treat every resolved promise as a success, so a provider could return 5xx + * indefinitely while its breaker stayed CLOSED — the spurious `_onSuccess()` decayed + * the counter by one and cancelled the very next call-site `_onFailure()` for the same + * attempt, pinning `failureCount` at 1. + * + * The first test drives the real single-model pipeline + * (chat.ts → executeChatWithBreaker → breaker.execute) against an upstream that always + * answers 503. The remaining tests pin the `execute()` result-classification contract. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; + +const harness = await createChatPipelineHarness("circuit-breaker-resolved-5xx-12254"); +const { BaseExecutor, buildRequest, handleChat, resetStorage, seedConnection, settingsDb } = + harness; +const { CircuitBreaker, getCircuitBreaker, STATE } = + await import("../../src/shared/utils/circuitBreaker.ts"); + +const originalFetch = globalThis.fetch; +const originalRetryConfig = { + maxAttempts: BaseExecutor.RETRY_CONFIG.maxAttempts, + delayMs: BaseExecutor.RETRY_CONFIG.delayMs, +}; + +const uniqueName = (s: string) => `cb-12254-${s}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + +test.beforeEach(async () => { + BaseExecutor.RETRY_CONFIG.maxAttempts = 1; + BaseExecutor.RETRY_CONFIG.delayMs = 0; + await resetStorage(); +}); + +test.afterEach(async () => { + globalThis.fetch = originalFetch; + BaseExecutor.RETRY_CONFIG.maxAttempts = originalRetryConfig.maxAttempts; + BaseExecutor.RETRY_CONFIG.delayMs = originalRetryConfig.delayMs; + await resetStorage(); +}); + +test.after(async () => { + await harness.cleanup(); +}); + +test("#12254: consecutive resolved 503s open the provider breaker on the single-model path", async () => { + const failureThreshold = 3; + await settingsDb.updateSettings({ + requestRetry: 0, + maxRetryIntervalSec: 0, + resilienceSettings: { + providerBreaker: { + apikey: { failureThreshold, degradationThreshold: 2, resetTimeoutMs: 60_000 }, + }, + }, + }); + + let upstreamCalls = 0; + globalThis.fetch = async () => { + upstreamCalls += 1; + return new Response(JSON.stringify({ error: { message: "Service temporarily overloaded" } }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); + }; + + const breaker = getCircuitBreaker("openai"); + const trace: string[] = []; + for (let i = 0; i < failureThreshold; i++) { + // A 503 puts the dispatched connection into cooldown; seed a fresh active one so + // every request really reaches the upstream and flows through breaker.execute(). + await seedConnection("openai", { apiKey: `sk-openai-resolved-503-${i}` }); + const upstreamCallsBefore = upstreamCalls; + const response = await handleChat( + buildRequest({ + body: { + model: "openai/o3-mini", + stream: false, + messages: [{ role: "user", content: `resolved 503 attempt ${i}` }], + }, + }) + ); + trace.push( + `req${i}: http=${response.status} upstreamCalls=${upstreamCalls} failureCount=${breaker.failureCount} state=${breaker.state}` + ); + assert.equal(response.status, 503, trace.join("\n")); + assert.ok(upstreamCalls > upstreamCallsBefore, `request ${i} must reach the upstream`); + assert.equal( + breaker.failureCount, + i + 1, + `each resolved 503 must count exactly once\n${trace.join("\n")}` + ); + } + + assert.equal(breaker.state, STATE.OPEN, trace.join("\n")); + + // The breaker now protects the chat path: the next request is short-circuited + // before any upstream dispatch. + await seedConnection("openai", { apiKey: "sk-openai-resolved-503-after-open" }); + const upstreamCallsBeforeOpen = upstreamCalls; + const rejected = await handleChat( + buildRequest({ + body: { + model: "openai/o3-mini", + stream: false, + messages: [{ role: "user", content: "breaker is open" }], + }, + }) + ); + assert.equal(rejected.status, 503); + assert.equal(upstreamCalls, upstreamCallsBeforeOpen, "an OPEN breaker must not dispatch"); + assert.match(await rejected.text(), /circuit breaker/i); +}); + +test("#12254: execute() counts a resolved failure payload when the classifier says so", async () => { + const cb = new CircuitBreaker(uniqueName("resolved-failure"), { + failureThreshold: 3, + resetTimeout: 30_000, + }); + const chatFn = async () => ({ success: false, status: 503, error: "overloaded" }); + const classifyResult = (result: { success: boolean }) => + result.success ? ("success" as const) : ("failure" as const); + + for (let i = 0; i < 3; i++) { + await cb.execute(chatFn, { classifyResult }); + } + + assert.equal(cb.failureCount, 3); + assert.equal(cb.state, STATE.OPEN); + cb.reset(); +}); + +test("#12254: execute() leaves accounting to the caller when the classifier returns ignore", async () => { + const cb = new CircuitBreaker(uniqueName("ignore"), { + failureThreshold: 3, + resetTimeout: 30_000, + }); + cb._onFailure(); + cb._onFailure(); + assert.equal(cb.failureCount, 2); + const stateBefore = cb.state; + + // Neither a resolved failure nor a resolved success may move the counter or the + // state: the caller records the outcome exactly once itself. + await cb.execute(async () => ({ success: false, status: 503 }), { + classifyResult: () => "ignore", + }); + await cb.execute(async () => ({ success: true, status: 200 }), { + classifyResult: () => "ignore", + }); + + assert.equal(cb.failureCount, 2); + assert.equal(cb.state, stateBefore); + cb.reset(); +}); + +test("#12254: execute() without a classifier keeps the resolved-is-success contract", async () => { + const cb = new CircuitBreaker(uniqueName("default"), { + failureThreshold: 3, + resetTimeout: 30_000, + }); + cb._onFailure(); + assert.equal(cb.failureCount, 1); + + await cb.execute(async () => ({ success: false, status: 503 })); + + // Gradual recovery on success: the legacy behaviour every throw-based caller relies on. + assert.equal(cb.failureCount, 0); + assert.equal(cb.state, STATE.CLOSED); + cb.reset(); +}); + +test("#12254: a throwing classifier never wedges the breaker", async () => { + const cb = new CircuitBreaker(uniqueName("throwing"), { + failureThreshold: 3, + resetTimeout: 30_000, + }); + + const result = await cb.execute(async () => "ok", { + classifyResult: () => { + throw new Error("classifier bug"); + }, + }); + + assert.equal(result, "ok"); + assert.equal(cb.state, STATE.CLOSED); + assert.equal(cb.failureCount, 0); + cb.reset(); +}); From 0ec7504024da2daf5dc9e116c70e6e1bfbcc6860 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:15:19 +0200 Subject: [PATCH 24/34] fix(sse): preserve ZWNJ and ZWJ in sanitized responses (#12359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The response de-obfuscation stripped the whole U+200B..U+200D range, so Persian/Kurdish half-spaces (U+200C), Arabic/Indic shaping and emoji ZWJ sequences (U+200D) were deleted from every assistant response — text, reasoning and tool-call arguments, streaming and non-streaming, every provider: ارائه‌دهنده came back as ارائهدهنده. The request side only ever inserts a U+200D between two ASCII word characters, so the new stripObfuscationZeroWidth() removes a joiner only there, or at a string edge next to one so a word split across streaming deltas is still cleaned; U+200B and U+FEFF keep their unconditional removal. All seven copies of the old regex now go through the helper. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- changelog.d/fixes/12359-preserve-zwnj-zwj.md | 1 + open-sse/executors/antigravity/sseCollect.ts | 5 +- open-sse/handlers/responseSanitizer.ts | 3 +- open-sse/handlers/responseTranslator.ts | 3 +- open-sse/handlers/sseParser/geminiResponse.ts | 5 +- .../translator/response/gemini-to-openai.ts | 3 +- open-sse/utils/stream.ts | 20 +- open-sse/utils/textualToolCall.ts | 8 +- open-sse/utils/zeroWidth.ts | 38 ++ tests/unit/12186-preserve-zwnj-zwj.test.ts | 337 ++++++++++++++++++ 10 files changed, 402 insertions(+), 21 deletions(-) create mode 100644 changelog.d/fixes/12359-preserve-zwnj-zwj.md create mode 100644 open-sse/utils/zeroWidth.ts create mode 100644 tests/unit/12186-preserve-zwnj-zwj.test.ts diff --git a/changelog.d/fixes/12359-preserve-zwnj-zwj.md b/changelog.d/fixes/12359-preserve-zwnj-zwj.md new file mode 100644 index 0000000000..31d61a0c9d --- /dev/null +++ b/changelog.d/fixes/12359-preserve-zwnj-zwj.md @@ -0,0 +1 @@ +- **fix(sse):** Keep ZWNJ (U+200C) and ZWJ (U+200D) in assistant text, reasoning and tool-call arguments — Persian/Kurdish half-space (`ارائه‌دهنده`), Arabic/Indic shaping and emoji sequences no longer lose them; the response de-obfuscation now removes joiners only between ASCII word characters, where the request side inserts them ([#12186](https://github.com/diegosouzapw/OmniRoute/issues/12186)) — thanks @rezjalibd diff --git a/open-sse/executors/antigravity/sseCollect.ts b/open-sse/executors/antigravity/sseCollect.ts index d84b1fb077..630b091aab 100644 --- a/open-sse/executors/antigravity/sseCollect.ts +++ b/open-sse/executors/antigravity/sseCollect.ts @@ -1,6 +1,7 @@ // Pure SSE-payload -> collected-stream parsing for the Antigravity executor. // Extracted verbatim from antigravity.ts (no host state, no fetch/auth). import { normalizeOpenAICompatibleFinishReasonString } from "../../utils/finishReason.ts"; +import { stripObfuscationZeroWidth } from "../../utils/zeroWidth.ts"; export type AntigravityCollectedStream = { textContent: string; @@ -17,7 +18,7 @@ export type AntigravityCollectedStream = { export function stripZeroWidth(value: unknown): unknown { if (typeof value === "string") { - return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); + return stripObfuscationZeroWidth(value); } if (Array.isArray(value)) { return value.map((item) => stripZeroWidth(item)); @@ -37,7 +38,7 @@ export function parseAntigravityTextualToolCall( text: unknown ): { name: string; args: unknown } | null { if (typeof text !== "string") return null; - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + const normalized = stripObfuscationZeroWidth(text); const match = normalized.match( /^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/ ); diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index a2210681d7..ce2d2af227 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -12,6 +12,7 @@ import { applyCacheHitTokensToUsage, applyCacheHitTokensToResponsesUsage, } from "./responseSanitizer/cacheHitTokens.ts"; +import { stripObfuscationZeroWidth } from "../utils/zeroWidth.ts"; export { extractThinkingFromContent, shouldParseTextualReasoningTags, @@ -85,7 +86,7 @@ function deleteOpenAICompatibleReasoningFields(record: JsonRecord): void { } function stripZeroWidthText(value: string): string { - return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); + return stripObfuscationZeroWidth(value); } function stripZeroWidthToolArgumentJson(value: unknown): string { diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 0038f7625b..05195b8011 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -5,6 +5,7 @@ import { } from "../services/geminiThoughtSignatureStore.ts"; import { normalizeOpenAICompatibleFinishReasonString } from "../utils/finishReason.ts"; import { containsTextualToolCallMarker } from "../utils/textualToolCall.ts"; +import { stripObfuscationZeroWidth } from "../utils/zeroWidth.ts"; import { getAnyReasoningValue } from "../utils/reasoningFields.ts"; import { caseInsensitiveToolNameLookup, @@ -63,7 +64,7 @@ function parseTextualToolCall(text: unknown): { name: string; args: unknown } | // variations, e.g. a leading "(empty)" marker or zero-width chars inserted // into argument strings. Normalize those variants before parsing so the // response is still surfaced as a structured OpenAI tool call. - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + const normalized = stripObfuscationZeroWidth(text); const match = normalized.match( /^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/ ); diff --git a/open-sse/handlers/sseParser/geminiResponse.ts b/open-sse/handlers/sseParser/geminiResponse.ts index 1657cf2030..df18008c04 100644 --- a/open-sse/handlers/sseParser/geminiResponse.ts +++ b/open-sse/handlers/sseParser/geminiResponse.ts @@ -2,6 +2,7 @@ // Extracted verbatim from sseParser.ts (file-size cap): pure parsing, no host // state, following the handlers submodule pattern (chatCore/, responseSanitizer/). import { normalizeOpenAICompatibleFinishReasonString } from "../../utils/finishReason.ts"; +import { stripObfuscationZeroWidth } from "../../utils/zeroWidth.ts"; type AccumulatedToolCall = { id: string; @@ -20,7 +21,7 @@ type GeminiSSEAccumulator = { }; function stripZeroWidth(value: unknown): unknown { - if (typeof value === "string") return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); + if (typeof value === "string") return stripObfuscationZeroWidth(value); return value; } @@ -29,7 +30,7 @@ function stripZeroWidth(value: unknown): unknown { * Gemini/Antigravity models emit instead of a native functionCall part. */ function tryParseTextualToolCall(text: string): { name: string; args: unknown } | null { - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + const normalized = stripObfuscationZeroWidth(text); const match = normalized.match( /^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/ ); diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 2a8240e290..98808808e1 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -14,6 +14,7 @@ import { isMalformedToolCallFinishReason, } from "../../utils/finishReason.ts"; import { stripAnsiCodes } from "../../utils/streamHelpers.ts"; +import { stripObfuscationZeroWidth } from "../../utils/zeroWidth.ts"; type GeminiToOpenAIState = { functionIndex: number; @@ -483,7 +484,7 @@ export function geminiToOpenAIResponse(chunk, state) { let candidate = parseTextualToolCallCandidate(accumulated); if (candidate) { - accumulated = accumulated.replace(/[\u200B-\u200D\uFEFF]/g, ""); + accumulated = stripObfuscationZeroWidth(accumulated); let toolCallIndex = accumulated.lastIndexOf("(empty)[Tool call:"); if (toolCallIndex < 0) { toolCallIndex = accumulated.lastIndexOf("[Tool call:"); diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 16572b18b3..dd37eda217 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -47,6 +47,7 @@ import { } from "./responsesCommentaryDrop.ts"; import { buildErrorBody } from "./error.ts"; import { parseTextualToolCallCandidate, isValidToolCallHeaderPrefix } from "./textualToolCall.ts"; +import { stripObfuscationZeroWidth } from "./zeroWidth.ts"; import { formatTranslatedStreamError, normalizeStreamFailurePayload, @@ -272,7 +273,7 @@ function containsMalformedTextualToolCall( allowedToolNames?: Set | null ): boolean { if (typeof text !== "string") return false; - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + const normalized = stripObfuscationZeroWidth(text); let searchIdx = 0; while (true) { @@ -1637,10 +1638,7 @@ export function createSSEStream(options: StreamOptions = {}) { isResponsesCommentaryMessageItem ).items : passthroughResponsesOutputItems; - const backfilled = backfillResponsesCompletedOutput( - parsed, - backfillCandidates - ); + const backfilled = backfillResponsesCompletedOutput(parsed, backfillCandidates); const usageNormalized = normalizeUsage(parsed); if ( stripped || @@ -1760,7 +1758,11 @@ export function createSSEStream(options: StreamOptions = {}) { ) { const pt = emptyChoicesUsage.prompt_tokens ?? 0; if (pt === 0) { - const estimated = estimateUsage(body, totalContentLength, sourceFormat || FORMATS.OPENAI); + const estimated = estimateUsage( + body, + totalContentLength, + sourceFormat || FORMATS.OPENAI + ); if (estimated?.prompt_tokens > 0) { emptyChoicesUsage.prompt_tokens = estimated.prompt_tokens; emptyChoicesUsage.total_tokens = @@ -2519,11 +2521,7 @@ export function createSSEStream(options: StreamOptions = {}) { // [DONE], so metered clients still see token counts. When the // upstream DID send usage (trailing or in-band), it was forwarded // already and passthroughForwardedUsage guards this off. - if ( - shouldEmitDoneTerminator && - !passthroughForwardedUsage && - hasValidUsage(usage) - ) { + if (shouldEmitDoneTerminator && !passthroughForwardedUsage && hasValidUsage(usage)) { const usageOnlyChunk = { id: passthroughLastChatId ?? passthroughResponsesId ?? `chatcmpl-${Date.now()}`, object: "chat.completion.chunk", diff --git a/open-sse/utils/textualToolCall.ts b/open-sse/utils/textualToolCall.ts index 67c7fde1a2..36b5668fe3 100644 --- a/open-sse/utils/textualToolCall.ts +++ b/open-sse/utils/textualToolCall.ts @@ -1,6 +1,8 @@ +import { stripObfuscationZeroWidth } from "./zeroWidth.ts"; + export function stripZeroWidth(value: unknown): unknown { if (typeof value === "string") { - return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); + return stripObfuscationZeroWidth(value); } if (Array.isArray(value)) { return value.map((item) => stripZeroWidth(item)); @@ -58,7 +60,7 @@ export function parseTextualToolCallCandidate( text: unknown ): { kind: "complete"; name: string; args: unknown } | { kind: "partial" } | null { if (typeof text !== "string") return null; - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + const normalized = stripObfuscationZeroWidth(text); const toolCallIndex = normalized.lastIndexOf("[Tool call:"); if (toolCallIndex < 0) { const lastParen = normalized.lastIndexOf("("); @@ -102,7 +104,7 @@ export function parseTextualToolCallCandidate( export function containsTextualToolCallMarker(text: unknown): boolean { if (typeof text !== "string") return false; - const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + const normalized = stripObfuscationZeroWidth(text); if (!normalized.includes("[Tool call:")) return false; if (normalized.includes("Arguments:")) return true; diff --git a/open-sse/utils/zeroWidth.ts b/open-sse/utils/zeroWidth.ts new file mode 100644 index 0000000000..ecce702bc3 --- /dev/null +++ b/open-sse/utils/zeroWidth.ts @@ -0,0 +1,38 @@ +/** + * Zero-width character cleanup for model output. + * + * The request side obfuscates configurable agent words by inserting a + * U+200D ZERO WIDTH JOINER after their first letter (`o\u200Dpencode`, see + * `services/claudeCodeObfuscation.ts` and `services/systemTransforms.ts`), and + * the response side removes zero-width code points again so an echoed word is + * not corrupted. Removing every U+200B..U+200D also deletes U+200C ZERO WIDTH + * NON-JOINER and U+200D where they belong to the text itself: the Persian and + * Kurdish half-space (ارائه\u200Cدهنده, می\u200Cروم, کتاب\u200Cها), Arabic and Indic shaping, + * and emoji ZWJ sequences (👨\u200D👩\u200D👧). See #12186. + * + * The obfuscator only ever places a joiner between two ASCII word characters, + * so a joiner is removed only there. A joiner touching the edge of the string + * is removed as well when its other neighbour is an ASCII word character, so + * an obfuscated word split across streaming deltas (`o\u200D` + `pencode`) + * is still cleaned. A joiner next to non-ASCII text, and a delta that consists + * of nothing but a joiner (an emoji sequence split by the tokenizer), pass + * through untouched. + * + * U+200B ZERO WIDTH SPACE and U+FEFF have no shaping role and keep the + * unconditional removal they always had. + */ + +const ANY_ZERO_WIDTH = /[\u200B-\u200D\uFEFF]/; +const ZERO_WIDTH_SPACE_OR_BOM = /[\u200B\uFEFF]/g; +const JOINER_BETWEEN_ASCII_WORD_CHARS = + /(?<=[A-Za-z0-9_])[\u200C\u200D]+(?=[A-Za-z0-9_]|$)|^[\u200C\u200D]+(?=[A-Za-z0-9_])/g; + +/** + * Strip the zero-width markers used for agent-word obfuscation while keeping + * ZWNJ/ZWJ that are part of the text (Persian half-space, Arabic/Indic + * shaping, emoji sequences). + */ +export function stripObfuscationZeroWidth(text: string): string { + if (!text || !ANY_ZERO_WIDTH.test(text)) return text; + return text.replace(ZERO_WIDTH_SPACE_OR_BOM, "").replace(JOINER_BETWEEN_ASCII_WORD_CHARS, ""); +} diff --git a/tests/unit/12186-preserve-zwnj-zwj.test.ts b/tests/unit/12186-preserve-zwnj-zwj.test.ts new file mode 100644 index 0000000000..d60c595a9b --- /dev/null +++ b/tests/unit/12186-preserve-zwnj-zwj.test.ts @@ -0,0 +1,337 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #12186 — the response pipeline strips every zero-width code point in +// U+200B..U+200D to undo the request-side agent-word obfuscation (which inserts +// one U+200D after the first letter of an ASCII word). That blanket strip also +// deletes U+200C ZERO WIDTH NON-JOINER and U+200D ZERO WIDTH JOINER where they +// are part of the text itself: Persian half-space, Arabic/Indic shaping and +// emoji ZWJ sequences. These tests pin that linguistic joiners survive while the +// ASCII obfuscation marker is still removed. + +const { sanitizeOpenAIResponse, sanitizeStreamingChunk } = + await import("../../open-sse/handlers/responseSanitizer.ts"); +const { parseTextualToolCallCandidate } = await import("../../open-sse/utils/textualToolCall.ts"); +const { parseAntigravityTextualToolCall } = + await import("../../open-sse/executors/antigravity/sseCollect.ts"); +const { parseSSEToGeminiResponse } = + await import("../../open-sse/handlers/sseParser/geminiResponse.ts"); +const { translateNonStreamingResponse } = + await import("../../open-sse/handlers/responseTranslator.ts"); +const { geminiToOpenAIResponse } = + await import("../../open-sse/translator/response/gemini-to-openai.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); +const { stripObfuscationZeroWidth } = await import("../../open-sse/utils/zeroWidth.ts"); +const { obfuscateSensitiveWords, getSensitiveWords } = + await import("../../open-sse/services/claudeCodeObfuscation.ts"); + +// Exact word from the issue report: ارائه + U+200C + دهنده ("provider"). +const PERSIAN_WORD = "ارائه\u200Cدهنده"; +// Family emoji: MAN + ZWJ + WOMAN + ZWJ + GIRL. +const FAMILY_EMOJI = "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}"; + +function openAIChunk(delta: Record) { + return { + id: "chatcmpl_12186", + object: "chat.completion.chunk", + created: 1, + model: "auto", + choices: [{ index: 0, delta, finish_reason: null }], + }; +} + +test("#12186 sanitizeOpenAIResponse keeps Persian ZWNJ in non-stream message content", () => { + const sanitized = sanitizeOpenAIResponse({ + id: "chatcmpl_12186", + model: "auto", + choices: [ + { + index: 0, + finish_reason: "stop", + message: { role: "assistant", content: PERSIAN_WORD }, + }, + ], + }) as unknown as { choices: { message: { content: string } }[] }; + + assert.equal(sanitized.choices[0].message.content, PERSIAN_WORD); +}); + +test("#12186 sanitizeOpenAIResponse keeps joiners in text but still de-obfuscates ASCII agent words", () => { + const sanitized = sanitizeOpenAIResponse({ + id: "chatcmpl_12186_mixed", + model: "auto", + choices: [ + { + index: 0, + finish_reason: "stop", + message: { + role: "assistant", + content: `o\u200Dpencode ${PERSIAN_WORD} می\u200Cروم کتاب\u200Cها ${FAMILY_EMOJI} c\u200Dursor`, + }, + }, + ], + }) as unknown as { choices: { message: { content: string } }[] }; + + assert.equal( + sanitized.choices[0].message.content, + `opencode ${PERSIAN_WORD} می\u200Cروم کتاب\u200Cها ${FAMILY_EMOJI} cursor` + ); +}); + +test("#12186 sanitizeOpenAIResponse still strips U+200B and U+FEFF from message content", () => { + const sanitized = sanitizeOpenAIResponse({ + id: "chatcmpl_12186_zwsp", + model: "auto", + choices: [ + { + index: 0, + finish_reason: "stop", + message: { role: "assistant", content: "\uFEFFhello\u200B world o\u200Bpencode" }, + }, + ], + }) as unknown as { choices: { message: { content: string } }[] }; + + assert.equal(sanitized.choices[0].message.content, "hello world opencode"); +}); + +test("#12186 sanitizeOpenAIResponse keeps Persian ZWNJ inside tool-call arguments", () => { + const args = JSON.stringify({ command: `echo ${PERSIAN_WORD}`, note: "o\u200Dpencode" }); + const sanitized = sanitizeOpenAIResponse({ + id: "chatcmpl_12186_tool", + model: "auto", + choices: [ + { + index: 0, + finish_reason: "tool_calls", + message: { + role: "assistant", + content: "", + tool_calls: [ + { id: "call_1", type: "function", function: { name: "run", arguments: args } }, + ], + }, + }, + ], + }) as unknown as { + choices: { message: { tool_calls: { function: { arguments: string } }[] } }[]; + }; + + assert.equal( + sanitized.choices[0].message.tool_calls[0].function.arguments, + JSON.stringify({ command: `echo ${PERSIAN_WORD}`, note: "opencode" }) + ); +}); + +test("#12186 sanitizeStreamingChunk keeps Persian ZWNJ in OpenAI delta content", () => { + const sanitized = sanitizeStreamingChunk(openAIChunk({ content: PERSIAN_WORD })) as unknown as { + choices: { delta: { content: string } }[]; + }; + + assert.equal(sanitized.choices[0].delta.content, PERSIAN_WORD); +}); + +test("#12186 sanitizeStreamingChunk keeps an emoji ZWJ sequence in OpenAI delta content", () => { + const sanitized = sanitizeStreamingChunk(openAIChunk({ content: FAMILY_EMOJI })) as unknown as { + choices: { delta: { content: string } }[]; + }; + + assert.equal(sanitized.choices[0].delta.content, FAMILY_EMOJI); +}); + +test("#12186 sanitizeStreamingChunk keeps a delta that is only a ZWJ (emoji sequence split by the tokenizer)", () => { + const sanitized = sanitizeStreamingChunk(openAIChunk({ content: "\u200D" })) as unknown as { + choices: { delta: { content: string } }[]; + }; + + assert.equal(sanitized.choices[0].delta.content, "\u200D"); +}); + +test("#12186 sanitizeStreamingChunk still de-obfuscates an ASCII word split across deltas", () => { + const first = sanitizeStreamingChunk(openAIChunk({ content: "o\u200D" })) as unknown as { + choices: { delta: { content: string } }[]; + }; + const second = sanitizeStreamingChunk(openAIChunk({ content: "\u200Dpencode" })) as unknown as { + choices: { delta: { content: string } }[]; + }; + + assert.equal(first.choices[0].delta.content, "o"); + assert.equal(second.choices[0].delta.content, "pencode"); +}); + +test("#12186 sanitizeStreamingChunk keeps Persian ZWNJ in reasoning_content deltas", () => { + const sanitized = sanitizeStreamingChunk( + openAIChunk({ reasoning_content: `${PERSIAN_WORD} c\u200Dursor` }) + ) as unknown as { choices: { delta: { reasoning_content: string } }[] }; + + assert.equal(sanitized.choices[0].delta.reasoning_content, `${PERSIAN_WORD} cursor`); +}); + +test("#12186 sanitizeStreamingChunk keeps Persian ZWNJ in Anthropic text_delta events", () => { + const sanitized = sanitizeStreamingChunk({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: `${PERSIAN_WORD} ${FAMILY_EMOJI} a\u200Dider` }, + }) as unknown as { delta: { text: string } }; + + assert.equal(sanitized.delta.text, `${PERSIAN_WORD} ${FAMILY_EMOJI} aider`); +}); + +test("#12186 sanitizeStreamingChunk keeps Persian ZWNJ in native response.output_text.delta", () => { + const sanitized = sanitizeStreamingChunk({ + type: "response.output_text.delta", + delta: PERSIAN_WORD, + }) as unknown as { delta: string }; + + assert.equal(sanitized.delta, PERSIAN_WORD); +}); + +test("#12186 sanitizeStreamingChunk keeps Persian ZWNJ in native response.output_text.done", () => { + const sanitized = sanitizeStreamingChunk({ + type: "response.output_text.done", + text: `${PERSIAN_WORD} o\u200Dpencode`, + }) as unknown as { text: string }; + + assert.equal(sanitized.text, `${PERSIAN_WORD} opencode`); +}); + +test("#12186 parseTextualToolCallCandidate keeps Persian ZWNJ in textual tool-call arguments", () => { + const parsed = parseTextualToolCallCandidate( + `[Tool call: terminal]\nArguments: {"command":"echo ${PERSIAN_WORD} o\u200Dpencode"}` + ); + + assert.ok(parsed && parsed.kind === "complete"); + assert.equal(parsed.name, "terminal"); + assert.deepEqual(parsed.args, { command: `echo ${PERSIAN_WORD} opencode` }); +}); + +test("#12186 parseAntigravityTextualToolCall keeps Persian ZWNJ in textual tool-call arguments", () => { + const parsed = parseAntigravityTextualToolCall( + `[Tool call: terminal]\nArguments: {"command":"echo ${PERSIAN_WORD} o\u200Dpencode"}` + ); + + assert.ok(parsed); + assert.equal(parsed.name, "terminal"); + assert.deepEqual(parsed.args, { command: `echo ${PERSIAN_WORD} opencode` }); +}); + +test("#12186 parseSSEToGeminiResponse keeps Persian ZWNJ in textual tool-call arguments", () => { + const text = `[Tool call: terminal]\nArguments: {"command":"echo ${PERSIAN_WORD}"}`; + const rawSSE = `data: ${JSON.stringify({ + response: { + candidates: [{ content: { parts: [{ text }] }, finishReason: "STOP" }], + }, + })}`; + + const parsed = parseSSEToGeminiResponse(rawSSE, "gemini-2.5-flash") as { + choices: { message: { tool_calls: { function: { arguments: string } }[] } }[]; + }; + + assert.ok(parsed); + assert.equal( + parsed.choices[0].message.tool_calls[0].function.arguments, + JSON.stringify({ command: `echo ${PERSIAN_WORD}` }) + ); +}); + +test("#12186 Gemini non-stream translation keeps Persian ZWNJ in textual tool-call arguments", () => { + const result = translateNonStreamingResponse( + { + responseId: "resp-12186", + modelVersion: "gemini-2.5-flash", + candidates: [ + { + content: { + parts: [ + { + text: `[Tool call: terminal]\nArguments: {"command":"echo ${PERSIAN_WORD} o\u200Dpencode"}`, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + FORMATS.GEMINI, + FORMATS.OPENAI + ) as { choices: { message: { tool_calls: { function: { arguments: string } }[] } }[] }; + + assert.equal( + result.choices[0].message.tool_calls[0].function.arguments, + JSON.stringify({ command: `echo ${PERSIAN_WORD} opencode` }) + ); +}); + +test("#12186 Gemini stream translation keeps Persian ZWNJ in text emitted before a textual tool call", () => { + const result = geminiToOpenAIResponse( + { + responseId: "resp-12186-stream", + modelVersion: "gemini-2.5-flash", + candidates: [ + { + content: { + parts: [ + { text: `${PERSIAN_WORD}: [Tool call: terminal]\nArguments: {"command":"whoami"}` }, + ], + }, + finishReason: "STOP", + }, + ], + }, + { toolCalls: new Map() } + ) as Array<{ choices?: { delta?: { content?: string; tool_calls?: unknown[] } }[] }>; + + const leakedContent = result.map((event) => event.choices?.[0]?.delta?.content || "").join(""); + assert.equal(leakedContent, `${PERSIAN_WORD}: `); + + const toolCalls = result.flatMap((event) => event.choices?.[0]?.delta?.tool_calls || []); + assert.equal(toolCalls.length, 1); +}); + +test("#12186 stripObfuscationZeroWidth keeps ZWNJ/ZWJ that belong to the text", () => { + for (const text of [ + PERSIAN_WORD, + "می\u200Cروم نمی\u200Cدانم کتاب\u200Cها", + FAMILY_EMOJI, + "\u{1F3F3}\u{FE0F}\u200D\u{1F308}", + "\u200D", + "\u{1F468}\u200D", + "\u200D\u{1F469}", + "\u200C", + ]) { + assert.equal(stripObfuscationZeroWidth(text), text); + } +}); + +test("#12186 stripObfuscationZeroWidth reverses the request-side obfuscation for every default agent word", () => { + const original = `Use ${getSensitiveWords().join(", ")} in ${PERSIAN_WORD} ${FAMILY_EMOJI}`; + const obfuscated = obfuscateSensitiveWords(original); + + assert.notEqual(obfuscated, original); + assert.equal(stripObfuscationZeroWidth(obfuscated), original); +}); + +test("#12186 stripObfuscationZeroWidth removes joiners only between ASCII word characters", () => { + assert.equal(stripObfuscationZeroWidth("o\u200Dpencode"), "opencode"); + assert.equal(stripObfuscationZeroWidth("roo_\u200Dcline 4\u200D2"), "roo_cline 42"); + assert.equal(stripObfuscationZeroWidth("a\u200Cb"), "ab"); + assert.equal(stripObfuscationZeroWidth("o\u200D\u200D\u200Cpencode"), "opencode"); + assert.equal(stripObfuscationZeroWidth("o\u200D"), "o"); + assert.equal(stripObfuscationZeroWidth("\u200Dpencode"), "pencode"); + assert.equal(stripObfuscationZeroWidth("x \u200D y"), "x \u200D y"); + // Neither side ASCII-adjacent on both ends: a joiner next to whitespace is not + // an obfuscation marker and is left alone. + assert.equal(stripObfuscationZeroWidth("x\u200D \u200Dy"), "x\u200D \u200Dy"); +}); + +test("#12186 stripObfuscationZeroWidth still removes U+200B and U+FEFF anywhere", () => { + assert.equal(stripObfuscationZeroWidth(`\u200B${PERSIAN_WORD}\uFEFF`), PERSIAN_WORD); + assert.equal(stripObfuscationZeroWidth("\uFEFF"), ""); + assert.equal(stripObfuscationZeroWidth("o\u200B\u200Dp"), "op"); + assert.equal(stripObfuscationZeroWidth("\u200BКак исправить"), "Как исправить"); +}); + +test("#12186 stripObfuscationZeroWidth returns the same reference when nothing needs stripping", () => { + const text = `plain ${PERSIAN_WORD}`; + assert.equal(stripObfuscationZeroWidth(text), text); + assert.equal(stripObfuscationZeroWidth(""), ""); +}); From 990aeca1db24eaca0e55a75712e48c1bd5b13f97 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:15:24 +0200 Subject: [PATCH 25/34] fix(api): list self-aliased providers in canonical models catalog mode (#12381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/models with MODELS_CATALOG_PREFIX_MODE=canonical dropped every chat row of a provider whose registry alias is undefined (antigravity) or equal to its own id (agy, most built-ins). Each emission loop pushes alias/model only when includeAlias, and canonicalProviderId/model only when the ids differ — for a self-aliased provider both are the same string, so neither fired. #11918 fixed the class for custom nodes but not built-ins, and not the static loop. The alias row is now treated as the canonical row whenever the ids coincide, across the static, synced, custom and alias-backed loops; the canonical branch's !== alias guard is untouched, so dual and alias output cannot double up. Docs that described the omission as intended are corrected. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .env.example | 2 +- ...1-models-catalog-canonical-self-aliased.md | 1 + docs/guides/VSCODE-COPILOT.md | 2 +- docs/reference/API_REFERENCE.md | 10 +- docs/reference/ENVIRONMENT.md | 2 +- src/app/api/v1/models/catalog.ts | 21 +- ...els-catalog-canonical-self-aliased.test.ts | 224 ++++++++++++++++++ 7 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 changelog.d/fixes/12381-models-catalog-canonical-self-aliased.md create mode 100644 tests/unit/12058-models-catalog-canonical-self-aliased.test.ts diff --git a/.env.example b/.env.example index e4ef62f091..f1a333125c 100644 --- a/.env.example +++ b/.env.example @@ -1830,7 +1830,7 @@ APP_LOG_TO_FILE=true # short alias prefix and the canonical provider prefix for each model (cc/claude-sonnet-4-6 # AND claude/claude-sonnet-4-6) so client configs that hardcoded either form keep working — # which roughly doubles the catalog. "alias" emits one id per model; "canonical" emits only -# the full provider-id prefix (and drops providers whose alias is already canonical). +# the full provider-id prefix (providers whose alias is already canonical keep their one id). # A client can override per request with GET /v1/models?prefix=alias instead. # Also configurable from Dashboard > Settings > Feature Flags. # Used by: src/shared/constants/featureFlagDefinitions.ts, src/app/api/v1/models/catalog.ts diff --git a/changelog.d/fixes/12381-models-catalog-canonical-self-aliased.md b/changelog.d/fixes/12381-models-catalog-canonical-self-aliased.md new file mode 100644 index 0000000000..34611fa2ef --- /dev/null +++ b/changelog.d/fixes/12381-models-catalog-canonical-self-aliased.md @@ -0,0 +1 @@ +- **fix(api):** `GET /v1/models` with `MODELS_CATALOG_PREFIX_MODE=canonical` (or `?prefix=canonical`) now lists providers whose registry alias is undefined or equal to their own id (Antigravity, Antigravity CLI and other self-aliased built-ins) — their single `provider/model` id was dropped by the alias/canonical duplicate guard in the static, synced, custom and alias-backed catalog loops ([#12058](https://github.com/diegosouzapw/OmniRoute/issues/12058)) — thanks @cheynetom diff --git a/docs/guides/VSCODE-COPILOT.md b/docs/guides/VSCODE-COPILOT.md index e0a4386fc0..4b49f34779 100644 --- a/docs/guides/VSCODE-COPILOT.md +++ b/docs/guides/VSCODE-COPILOT.md @@ -63,7 +63,7 @@ changing the server-wide setting for your other clients. On a reference instance If you would rather fix it server-wide for _every_ client, set the `MODELS_CATALOG_PREFIX_MODE` feature flag to `alias` in the dashboard. See [API_REFERENCE → prefix](../reference/API_REFERENCE.md#model-id-prefixes-prefix) for the -query parameter and the warning about `canonical`. +query parameter and the per-mode table. ### It hides models that cannot chat diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 32d9291207..42dbb44632 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -405,11 +405,11 @@ GET /v1/models?prefix=dual # both forms (server default) GET /v1/models?prefix=canonical # only the full provider-id prefix ``` -| Mode | Emits | Notes | -| ----------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dual` | `cc/claude-sonnet-4-6` **and** `claude/claude-sonnet-4-6` | **Default.** Both ids route to the same model; kept so client configs that hardcoded either form keep working. Roughly doubles the catalog. | -| `alias` | `cc/claude-sonnet-4-6` | One entry per model. Providers without a distinct alias still emit their entry, so nothing is lost. | -| `canonical` | `claude/claude-sonnet-4-6` | ⚠️ The canonical row is only emitted when the canonical provider id **differs** from the alias, so providers without a distinct alias emit nothing in this mode. Prefer `alias` for a de-duplicated list. | +| Mode | Emits | Notes | +| ----------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dual` | `cc/claude-sonnet-4-6` **and** `claude/claude-sonnet-4-6` | **Default.** Both ids route to the same model; kept so client configs that hardcoded either form keep working. Roughly doubles the catalog. | +| `alias` | `cc/claude-sonnet-4-6` | One entry per model. Providers without a distinct alias still emit their entry, so nothing is lost. | +| `canonical` | `claude/claude-sonnet-4-6` | One entry per model under the full provider-id prefix. Providers without a distinct alias (e.g. `antigravity/…`, `agy/…`) emit their single id here too, so nothing is lost. | A `dual`-mode mirror can also be recognised without the query parameter: it carries a `parent` field pointing at the primary id. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index c059b2b1ad..c63cadc12c 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -949,7 +949,7 @@ Automatic model pricing data synchronization from external sources. | Variable | Default | Source File | Description | | ------------------------- | ------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | | `ARENA_ELO_SYNC_ENABLED` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | Periodic Arena AI leaderboard ELO sync, configurable from Dashboard Feature Flags or with `false` to opt out. | -| `MODELS_CATALOG_PREFIX_MODE` | `dual` | `src/shared/constants/featureFlagDefinitions.ts`, `src/app/api/v1/models/catalog.ts` | Prefix form used for model ids in `GET /v1/models`. `dual` advertises both the short alias prefix and the canonical provider prefix for every model (backward compatibility — roughly doubles the catalog); `alias` emits one id per model; `canonical` emits only the full provider-id prefix and omits providers whose alias already is the canonical id. Clients can override per request with `?prefix=alias`. See [API_REFERENCE](API_REFERENCE.md#model-id-prefixes-prefix). | +| `MODELS_CATALOG_PREFIX_MODE` | `dual` | `src/shared/constants/featureFlagDefinitions.ts`, `src/app/api/v1/models/catalog.ts` | Prefix form used for model ids in `GET /v1/models`. `dual` advertises both the short alias prefix and the canonical provider prefix for every model (backward compatibility — roughly doubles the catalog); `alias` emits one id per model; `canonical` emits only the full provider-id prefix (providers whose alias already is the canonical id keep their single entry). Clients can override per request with `?prefix=alias`. See [API_REFERENCE](API_REFERENCE.md#model-id-prefixes-prefix). | | `ARENA_ELO_SYNC_INTERVAL` | `86400` (24h) | `src/lib/arenaEloSync.ts` | Sync interval in seconds. | --- diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index e5e427f54f..3e60e8bea2 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -1089,7 +1089,14 @@ async function buildUnifiedModelsResponseCore( ); const thinkingCapabilities = Object.keys(thinkingFields).length > 0 ? { capabilities: thinkingFields } : {}; - if (includeAlias) { + // #12058: a self-aliased provider (registry `alias` undefined or equal to its + // own id — antigravity, agy, most built-ins) has a single id form, so its + // alias row IS its canonical row. Emit it in canonical mode too; the + // canonical branch below still skips it (`canonicalProviderId !== alias`), + // so dual mode cannot double up. Same class as #11832 (custom nodes, + // PR #11918), which only widened the synced/custom/alias-backed loops. + const selfAliased = canonicalProviderId === alias; + if (includeAlias || selfAliased) { models.push({ id: aliasId, object: "model", @@ -1181,6 +1188,8 @@ async function buildUnifiedModelsResponseCore( const prefix = providerIdToPrefix[providerId]; const alias = prefix || providerIdToAlias[providerId] || providerId; const canonicalProviderId = resolveCanonicalProviderId(alias, providerId); + // #12058: see the static loop — the alias row is the only row here. + const selfAliased = canonicalProviderId === alias; const parentProviderType = nodeIdToProviderType[providerId]; if ( @@ -1280,7 +1289,7 @@ async function buildUnifiedModelsResponseCore( continue; } - if (includeAlias || Boolean(prefix)) { + if (includeAlias || Boolean(prefix) || selfAliased) { models.push({ id: aliasId, object: "model", @@ -1628,6 +1637,8 @@ async function buildUnifiedModelsResponseCore( const prefix = providerIdToPrefix[providerId]; const alias = prefix || providerIdToAlias[providerId] || providerId; const canonicalProviderId = resolveCanonicalProviderId(alias, providerId); + // #12058: see the static loop — the alias row is the only row here. + const selfAliased = canonicalProviderId === alias; // Only include if provider is active — check alias, canonical ID, raw providerId, // or the parent provider type (for compatible providers whose node ID is a UUID) @@ -1733,7 +1744,7 @@ async function buildUnifiedModelsResponseCore( ? getCustomVisionCapabilityFields(model, aliasId, modelId) : null; - if (includeAlias || Boolean(prefix)) { + if (includeAlias || Boolean(prefix) || selfAliased) { models.push({ id: aliasId, object: "model", @@ -1852,7 +1863,9 @@ async function buildUnifiedModelsResponseCore( const visionFields = getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(modelId); - if (includeAlias || Boolean(nodePrefix)) { + // #12058: see the static loop — the alias row is the only row here. + const selfAliased = canonicalProviderId === alias; + if (includeAlias || Boolean(nodePrefix) || selfAliased) { models.push({ id: aliasId, object: "model", diff --git a/tests/unit/12058-models-catalog-canonical-self-aliased.test.ts b/tests/unit/12058-models-catalog-canonical-self-aliased.test.ts new file mode 100644 index 0000000000..d029146c0b --- /dev/null +++ b/tests/unit/12058-models-catalog-canonical-self-aliased.test.ts @@ -0,0 +1,224 @@ +/** + * Regression test for #12058 — `MODELS_CATALOG_PREFIX_MODE=canonical` (or + * `?prefix=canonical`) dropped every chat row of a *self-aliased* provider: a + * registry entry whose `alias` is undefined (`antigravity`) or equal to its own id + * (`agy`, and most built-in providers). + * + * Root cause: every emission loop in `catalog.ts` pushes the `alias/model` row only + * when `includeAlias` is set and the `canonicalProviderId/model` row only when + * `canonicalProviderId !== alias` (a dual-mode duplicate guard). For a self-aliased + * provider both ids are the same string, so in canonical mode neither branch fires + * and the provider vanishes. #11832 / PR #11918 fixed the same class for custom + * provider nodes (`includeAlias || Boolean(prefix)`) but left built-in providers + * behind. + * + * Fix: treat the alias row as the canonical row whenever the two ids coincide, in + * the static, synced, custom and alias-backed loops alike. `alias` and `dual` modes + * already emitted that single row, so their output must not change. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-12058-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const aliasesDb = await import("../../src/lib/db/models/aliases.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +type CatalogRow = { id: string; parent: string | null; root: string | null }; +type PrefixMode = "alias" | "canonical" | "dual"; + +// Both ship this id in their curated static catalog (ANTIGRAVITY_PUBLIC_MODELS / +// AGY_PUBLIC_MODELS). `antigravity` has `alias: undefined`, `agy` has `alias: "agy"`. +const SELF_ALIASED_PROVIDERS = ["antigravity", "agy"] as const; +const STATIC_MODEL_ID = "gemini-3.7-flash-high"; + +// A self-aliased api-key provider used to exercise the synced / custom / +// alias-backed loops, which carry the same guard as the static loop. +const SYNCED_PROVIDER = "groq"; +const SYNCED_MODEL_ID = "probe-synced-12058"; +// A synced audio model must survive too (it is a chat-loop row with `type: "audio"`). +const SYNCED_AUDIO_MODEL_ID = "probe-tts-12058"; +const CUSTOM_MODEL_ID = "probe-custom-12058"; +const ALIAS_BACKED_MODEL_ID = "probe-alias-backed-12058"; + +// Control: a normally-aliased provider (alias `cc`, canonical `claude`) whose +// mode gating must stay exactly as it was. +const CONTROL_ALIAS_ID = "cc/claude-sonnet-4-6"; +const CONTROL_CANONICAL_ID = "claude/claude-sonnet-4-6"; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +async function seedOauthConnection(provider: string) { + await providersDb.createProviderConnection({ + provider, + authType: "oauth", + name: `${provider}-12058`, + apiKey: null, + accessToken: `${provider}-access-token`, + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); +} + +async function seedCatalog() { + for (const provider of SELF_ALIASED_PROVIDERS) await seedOauthConnection(provider); + await seedOauthConnection("claude"); + + const connection = await providersDb.createProviderConnection({ + provider: SYNCED_PROVIDER, + authType: "apikey", + name: `${SYNCED_PROVIDER}-12058`, + apiKey: "sk-test-12058", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + await modelsDb.replaceSyncedAvailableModelsForConnection( + SYNCED_PROVIDER, + (connection as { id: string }).id, + [ + { id: SYNCED_MODEL_ID, source: "imported", supportedEndpoints: ["chat"] }, + { id: SYNCED_AUDIO_MODEL_ID, source: "imported", supportedEndpoints: ["audio-speech"] }, + ] + ); + await modelsDb.addCustomModel(SYNCED_PROVIDER, CUSTOM_MODEL_ID, "Probe Custom 12058"); + await aliasesDb.setModelAlias( + ALIAS_BACKED_MODEL_ID, + `${SYNCED_PROVIDER}/${ALIAS_BACKED_MODEL_ID}` + ); +} + +async function getRows(mode: PrefixMode): Promise { + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request(`http://localhost/api/v1/models?prefix=${mode}`) + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { data: CatalogRow[] }; + return body.data; +} + +function idsWithPrefix(rows: CatalogRow[], prefix: string): string[] { + return rows.map((row) => row.id).filter((id) => id.startsWith(`${prefix}/`)); +} + +function duplicates(rows: CatalogRow[]): string[] { + const ids = rows.map((row) => row.id); + return ids.filter((id, index) => ids.indexOf(id) !== index); +} + +function assertExactlyOnce(rows: CatalogRow[], id: string, mode: PrefixMode) { + const matches = rows.filter((row) => row.id === id); + assert.equal( + matches.length, + 1, + `${mode} mode: expected exactly one "${id}", got ${matches.length}` + ); + return matches[0]; +} + +test.beforeEach(async () => { + await resetStorage(); + await seedCatalog(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("#12058 canonical mode lists the curated models of self-aliased providers once, re-rooted", async () => { + const rows = await getRows("canonical"); + + for (const provider of SELF_ALIASED_PROVIDERS) { + const row = assertExactlyOnce(rows, `${provider}/${STATIC_MODEL_ID}`, "canonical"); + // The single surviving row is the head of its chain: no parent to point at. + assert.equal(row.parent, null, `${provider}: the canonical row must not carry a parent`); + assert.equal(row.root, STATIC_MODEL_ID, `${provider}: root must be the bare model id`); + + // Anti-vacuity: the whole curated chat catalog is back, not just the sampled id. + const listed = idsWithPrefix(rows, provider); + assert.ok( + listed.length >= 5, + `${provider}: expected the curated catalog in canonical mode, got ${JSON.stringify(listed)}` + ); + } + + assert.deepEqual(duplicates(rows), [], "canonical mode must not emit duplicate ids"); +}); + +test("#12058 canonical mode keeps synced, custom and alias-backed rows of a self-aliased provider", async () => { + const rows = await getRows("canonical"); + + for (const modelId of [ + SYNCED_MODEL_ID, + SYNCED_AUDIO_MODEL_ID, + CUSTOM_MODEL_ID, + ALIAS_BACKED_MODEL_ID, + ]) { + const row = assertExactlyOnce(rows, `${SYNCED_PROVIDER}/${modelId}`, "canonical"); + assert.equal(row.parent, null, `${modelId}: the canonical row must not carry a parent`); + } +}); + +test("#12058 canonical mode still suppresses the alias row of a normally-aliased provider", async () => { + // Guards against "fixing" the defect by disabling the alias gate outright. + const rows = await getRows("canonical"); + const ids = new Set(rows.map((row) => row.id)); + + assert.ok(ids.has(CONTROL_CANONICAL_ID), `expected "${CONTROL_CANONICAL_ID}" in canonical mode`); + assert.equal( + ids.has(CONTROL_ALIAS_ID), + false, + `"${CONTROL_ALIAS_ID}" must stay suppressed in canonical mode` + ); +}); + +test("#12058 self-aliased providers emit the same single id set in every mode; alias/dual stay unchanged", async () => { + const byMode = { + alias: await getRows("alias"), + canonical: await getRows("canonical"), + dual: await getRows("dual"), + } satisfies Record; + + for (const mode of ["alias", "dual"] as const) { + assert.deepEqual(duplicates(byMode[mode]), [], `${mode} mode must not emit duplicate ids`); + } + + // A self-aliased provider has exactly one id form, so all three modes must agree. + for (const provider of [...SELF_ALIASED_PROVIDERS, SYNCED_PROVIDER]) { + const aliasIds = idsWithPrefix(byMode.alias, provider).sort(); + assert.ok(aliasIds.length > 0, `${provider}: alias mode must list the provider at all`); + assert.deepEqual( + idsWithPrefix(byMode.canonical, provider).sort(), + aliasIds, + `${provider}: canonical mode must list the same ids as alias mode` + ); + assert.deepEqual( + idsWithPrefix(byMode.dual, provider).sort(), + aliasIds, + `${provider}: dual mode must list the same ids as alias mode` + ); + } + + // The normally-aliased control keeps its per-mode shape. + const aliasIds = new Set(byMode.alias.map((row) => row.id)); + const dualIds = new Set(byMode.dual.map((row) => row.id)); + assert.ok(aliasIds.has(CONTROL_ALIAS_ID), "alias mode keeps the cc/ row"); + assert.equal(aliasIds.has(CONTROL_CANONICAL_ID), false, "alias mode suppresses the claude/ row"); + assert.ok(dualIds.has(CONTROL_ALIAS_ID), "dual mode keeps the cc/ row"); + assert.ok(dualIds.has(CONTROL_CANONICAL_ID), "dual mode keeps the claude/ row"); +}); From 26d20a000939f353e3cfd9241f14b37c739ce8d1 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:15:31 +0200 Subject: [PATCH 26/34] fix(api-manager): preserve allowedCombos entries the Combo picker cannot render (#12397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API key permissions modal silently dropped allowedCombos entries its Combo picker cannot render — routing-rule names such as rt-*, which matchesComboAccessRule() already honours. Stored entries rendered as zero selected, and clicking All then Restrict then Save persisted allowedCombos: [], which is deny-all for combo requests. Those entries now survive the All toggle, are listed read-only under the combo list so the header count and the list agree, and are saved back verbatim. The UI does not learn routing-rule semantics (option 1 from the issue). The Allowed Combos section moves out of the frozen ApiManagerPageClient.tsx into its own component following the UsageLimitSettings pattern. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- ...97-allowed-combos-preserve-unrenderable.md | 1 + .../api-manager/ApiManagerPageClient.tsx | 88 ++------ .../api-manager/apiManagerPageUtils.ts | 21 ++ .../components/AllowedCombosSection.tsx | 191 ++++++++++++++++++ src/i18n/messages/ar.json | 2 + src/i18n/messages/az.json | 2 + src/i18n/messages/bg.json | 2 + src/i18n/messages/bn.json | 2 + src/i18n/messages/cs.json | 2 + src/i18n/messages/da.json | 2 + src/i18n/messages/de.json | 2 + src/i18n/messages/en.json | 2 + src/i18n/messages/es.json | 2 + src/i18n/messages/fa.json | 2 + src/i18n/messages/fi.json | 2 + src/i18n/messages/fr.json | 2 + src/i18n/messages/gu.json | 2 + src/i18n/messages/he.json | 2 + src/i18n/messages/hi.json | 2 + src/i18n/messages/hu.json | 2 + src/i18n/messages/id.json | 2 + src/i18n/messages/in.json | 2 + src/i18n/messages/it.json | 2 + src/i18n/messages/ja.json | 2 + src/i18n/messages/ko.json | 2 + src/i18n/messages/mr.json | 2 + src/i18n/messages/ms.json | 2 + src/i18n/messages/nl.json | 2 + src/i18n/messages/no.json | 2 + src/i18n/messages/phi.json | 2 + src/i18n/messages/pl.json | 2 + src/i18n/messages/pt-BR.json | 2 + src/i18n/messages/pt.json | 2 + src/i18n/messages/ro.json | 2 + src/i18n/messages/ru.json | 2 + src/i18n/messages/sk.json | 2 + src/i18n/messages/sv.json | 2 + src/i18n/messages/sw.json | 2 + src/i18n/messages/ta.json | 2 + src/i18n/messages/te.json | 2 + src/i18n/messages/th.json | 2 + src/i18n/messages/tr.json | 2 + src/i18n/messages/uk-UA.json | 2 + src/i18n/messages/ur.json | 2 + src/i18n/messages/vi.json | 2 + src/i18n/messages/zh-CN.json | 2 + src/i18n/messages/zh-TW.json | 2 + ...keys-allowed-combos-preserve-12267.test.ts | 78 +++++++ .../combo-picker-unrenderable-12267.test.tsx | 140 +++++++++++++ 49 files changed, 529 insertions(+), 76 deletions(-) create mode 100644 changelog.d/fixes/12397-allowed-combos-preserve-unrenderable.md create mode 100644 src/app/(dashboard)/dashboard/api-manager/components/AllowedCombosSection.tsx create mode 100644 tests/unit/api-keys-allowed-combos-preserve-12267.test.ts create mode 100644 tests/unit/ui/combo-picker-unrenderable-12267.test.tsx diff --git a/changelog.d/fixes/12397-allowed-combos-preserve-unrenderable.md b/changelog.d/fixes/12397-allowed-combos-preserve-unrenderable.md new file mode 100644 index 0000000000..2774b29cc6 --- /dev/null +++ b/changelog.d/fixes/12397-allowed-combos-preserve-unrenderable.md @@ -0,0 +1 @@ +- **fix(api-manager):** the API key permissions modal no longer silently drops `allowedCombos` entries its Combo picker cannot render — routing-rule names such as `rt-*`, which the backend already honours — when "All" is clicked and the key is switched back to "Restrict"; those entries now survive the toggle, are listed read-only under the combo list so the count and the list agree, and are saved back verbatim instead of persisting `[]` (deny-all) (#12397 — thanks @pacocartones) diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index ab837efa2a..aa5b997aa9 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -31,6 +31,7 @@ import { UsageLimitSettings } from "./components/UsageLimitSettings"; import { ChaosModeAccessToggle } from "./components/ChaosModeAccessToggle"; import { BypassProviderQuotaToggle } from "./components/BypassProviderQuotaToggle"; import { ApiKeyCompressionToggle } from "./components/ApiKeyCompressionToggle"; +import { AllowedCombosSection } from "./components/AllowedCombosSection"; import ProviderModelPermissionList from "./components/ProviderModelPermissionList"; import ReasoningRoutingRules from "@/shared/components/ReasoningRoutingRules"; import { ALL_COMBOS_ACCESS_RULE } from "@/shared/constants/comboAccess"; @@ -3018,82 +3019,17 @@ const PermissionsModal = memo(function PermissionsModal({ )} {/* Allowed Combos Section */} - {allCombos.length > 0 && ( -

-
-

{t("allowedCombos")}

-
- - -
-
-

- {allowAllCombos - ? t("allCombosAllowed") - : t("restrictedComboCount", { count: selectedCombos.length })} -

- {!allowAllCombos && ( -
- {allCombos - .slice() - .sort((a, b) => a.name.localeCompare(b.name)) - .map((combo) => { - const isSelected = selectedCombos.includes(combo.name); - return ( - - ); - })} -
- )} -
- )} + { + setAllowAllCombos(true); + setSelectedCombos(preservedRules); + }} + onRestrict={() => setAllowAllCombos(false)} + onToggleCombo={handleToggleCombo} + /> {/* Allowed Endpoints Section */}
diff --git a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts index 7c89d9460b..f7b9de448b 100644 --- a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts @@ -1,3 +1,5 @@ +import { ALL_COMBOS_ACCESS_RULE } from "@/shared/constants/comboAccess"; + export type KeyStatus = "active" | "disabled" | "banned" | "expired"; // "manage" scope = management key; "restricted" = has model/connection allowlists; @@ -286,3 +288,22 @@ export function buildModelAccessSavePayload(input: { if (input.allowAll) return { modelAccessMode: "all", allowedModels: [] }; return { modelAccessMode: "restricted", allowedModels: input.selectedModels }; } + +/** + * Entries of an API key's `allowedCombos` that the Allowed Combos picker cannot + * render: routing-rule names (`rt-*`) that `matchesComboAccessRule()` accepts via + * its `rule === requestedModel` branch, or combos that are no longer loaded. The + * picker keeps them in the selection (so Save round-trips the stored ACL), shows + * them read-only, and lets them survive the "All" toggle — otherwise a later + * "Restrict" + Save persisted `[]`, which is deny-all (#12267). Stored order is + * preserved; the `combo/*` wildcard is the "All" marker, not a rule. + */ +export function listUnrenderableComboAccessRules( + selectedCombos: readonly string[], + allCombos: ReadonlyArray<{ name: string }> +): string[] { + const renderable = new Set(allCombos.map((combo) => combo.name)); + return selectedCombos.filter( + (name) => name !== ALL_COMBOS_ACCESS_RULE && !renderable.has(name) + ); +} diff --git a/src/app/(dashboard)/dashboard/api-manager/components/AllowedCombosSection.tsx b/src/app/(dashboard)/dashboard/api-manager/components/AllowedCombosSection.tsx new file mode 100644 index 0000000000..a91be4cad9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/api-manager/components/AllowedCombosSection.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { listUnrenderableComboAccessRules } from "../apiManagerPageUtils"; + +export interface AllowedComboOption { + id?: string; + name: string; + models?: unknown[]; +} + +const MODE_BUTTON_ACTIVE = "bg-primary text-white"; +const MODE_BUTTON_IDLE = "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"; + +function ComboAccessModeToggle({ + allowAllCombos, + onAllowAll, + onRestrict, +}: { + allowAllCombos: boolean; + onAllowAll: () => void; + onRestrict: () => void; +}) { + const t = useTranslations("apiManager"); + const tc = useTranslations("common"); + return ( +
+ + +
+ ); +} + +function ComboOptionRow({ + combo, + isSelected, + onToggle, +}: { + combo: AllowedComboOption; + isSelected: boolean; + onToggle: (comboName: string) => void; +}) { + return ( + + ); +} + +/** + * Read-only chips for allowedCombos entries the list above cannot render, so the + * header count and the visible entries agree and the user sees what Save keeps. + */ +function PreservedComboRules({ rules }: { rules: string[] }) { + const t = useTranslations("apiManager"); + if (rules.length === 0) return null; + return ( +
+

+ {t("preservedComboRules", { count: rules.length })} +

+
+ {rules.map((rule, index) => ( + + lock + + {rule} + + + ))} +
+
+ ); +} + +/** + * Allowed Combos picker for the API Key permissions modal. Extracted out of + * ApiManagerPageClient.tsx (frozen god-file — see config/quality/file-size-baseline.json) + * following the same pattern as UsageLimitSettings.tsx. + * + * `allowedCombos` may hold entries this list cannot render: routing-rule names + * (`rt-*`) that `matchesComboAccessRule()` accepts via `rule === requestedModel`, + * or combos that are no longer loaded. Those entries stay in the selection so Save + * round-trips them, are shown read-only so the header count and the list agree, + * and survive the "All" toggle — so switching back to Restrict cannot turn a + * working key into deny-all (#12267). + * + * The modal owns the All/Restrict state: `onAllowAll` receives the entries this + * picker cannot render (empty when every selected entry is a loaded combo, so the + * "All" selection serialises exactly as before), and `onRestrict` leaves the + * selection untouched. + */ +export function AllowedCombosSection({ + allCombos, + allowAllCombos, + selectedCombos, + onAllowAll, + onRestrict, + onToggleCombo, +}: { + allCombos: AllowedComboOption[]; + allowAllCombos: boolean; + selectedCombos: string[]; + onAllowAll: (preservedRules: string[]) => void; + onRestrict: () => void; + onToggleCombo: (comboName: string) => void; +}) { + const t = useTranslations("apiManager"); + + const preservedRules = useMemo( + () => listUnrenderableComboAccessRules(selectedCombos, allCombos), + [selectedCombos, allCombos] + ); + const sortedCombos = useMemo( + () => allCombos.slice().sort((a, b) => a.name.localeCompare(b.name)), + [allCombos] + ); + + if (allCombos.length === 0) return null; + + return ( +
+
+

{t("allowedCombos")}

+ onAllowAll(preservedRules)} + onRestrict={onRestrict} + /> +
+

+ {allowAllCombos + ? t("allCombosAllowed") + : t("restrictedComboCount", { count: selectedCombos.length })} +

+ {!allowAllCombos && ( + <> +
+ {sortedCombos.map((combo) => ( + + ))} +
+ + + )} +
+ ); +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 385322eb26..6a25642374 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2372,6 +2372,8 @@ "allowedCombos": "التركيبات المسموح بها", "allCombosAllowed": "يمكن لهذا المفتاح استخدام أي تركيبة.", "restrictedComboCount": "مقتصر على {count, plural, one {# تركيبة} other {# تركيبات}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "حدود معدل الطلبات المخصصة لمدير الـ API", "apiManagerCustomRateLimitsDesc": "تجاوز الحدود الافتراضية العالمية. اتركه فارغًا لاستخدام الإعدادات الافتراضية.", "apiManagerRateLimitRequestsPlaceholder": "الطلبات", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 33478087a1..e6a7491fe3 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -2372,6 +2372,8 @@ "allowedCombos": "İcazə verilən kombinasiyalar", "allCombosAllowed": "Bu açar istənilən kombinasiyanı istifadə edə bilər.", "restrictedComboCount": "{count, plural, one {# kombinasiya} other {# kombinasiya}} ilə məhdudlaşdırılıb.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Fərdi tarif limitləri", "apiManagerCustomRateLimitsDesc": "Qlobal standart limitləri ləğv edin. Defoltları istifadə etmək üçün boş buraxın.", "apiManagerRateLimitRequestsPlaceholder": "Sorğular", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index a573f5ffa7..33fc7d1828 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Разрешени комбинации", "allCombosAllowed": "Този ключ може да използва всяка комбинация.", "restrictedComboCount": "Ограничено до {count, plural, one {# комбинация} other {# комбинации}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Персонализирани ограничения на скоростта", "apiManagerCustomRateLimitsDesc": "Замяна на глобалните ограничения по подразбиране. Оставете празно, за да използвате настройките по подразбиране.", "apiManagerRateLimitRequestsPlaceholder": "Заявки", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index e09c47c736..ee917384b4 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -2372,6 +2372,8 @@ "allowedCombos": "অনুমোদিত কম্বো", "allCombosAllowed": "এই কী যেকোনো কম্বো ব্যবহার করতে পারে।", "restrictedComboCount": "{count, plural, one {#টি কম্বোতে} other {#টি কম্বোতে}} সীমাবদ্ধ।", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "কাস্টম হার সীমা", "apiManagerCustomRateLimitsDesc": "বিশ্বব্যাপী ডিফল্ট সীমা ওভাররাইড করুন। ডিফল্ট ব্যবহার করতে খালি ছেড়ে দিন।", "apiManagerRateLimitRequestsPlaceholder": "অনুরোধ", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 9f766bdcfc..410d86b60d 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Povolené kombinace", "allCombosAllowed": "Tento klíč může použít jakoukoli kombinaci.", "restrictedComboCount": "Omezeno na {count, plural, one {# kombinaci} few {# kombinace} other {# kombinací}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Vlastní limity sazeb", "apiManagerCustomRateLimitsDesc": "Přepsat globální výchozí limity. Chcete-li použít výchozí hodnoty, ponechte prázdné.", "apiManagerRateLimitRequestsPlaceholder": "Žádosti", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 56361cf6ed..4088c0ae75 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Tilladte kombinationer", "allCombosAllowed": "Denne nøgle kan bruge enhver kombination.", "restrictedComboCount": "Begrænset til {count, plural, one {# kombination} other {# kombinationer}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Brugerdefinerede satsgrænser", "apiManagerCustomRateLimitsDesc": "Tilsidesæt globale standardgrænser. Lad være tom for at bruge standardindstillinger.", "apiManagerRateLimitRequestsPlaceholder": "Forespørgsler", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 5e6a47b106..27fcbfe251 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Zulässige Kombinationen", "allCombosAllowed": "Dieser Schlüssel kann jede Kombination verwenden.", "restrictedComboCount": "Beschränkt auf {count, plural, one {# Kombination} other {# Kombinationen}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Benutzerdefinierte Tariflimits", "apiManagerCustomRateLimitsDesc": "Überschreiben Sie globale Standardgrenzen. Lassen Sie das Feld leer, um die Standardeinstellungen zu verwenden.", "apiManagerRateLimitRequestsPlaceholder": "Anfragen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index fafe050a7c..74921e0717 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Allowed Combos", "allCombosAllowed": "This key can use any combo.", "restrictedComboCount": "Restricted to {count, plural, one {# combo} other {# combos}}.", + "preservedComboRules": "{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Custom Rate Limits", "apiManagerCustomRateLimitsDesc": "Override global default limits. Leave empty to use defaults.", "apiManagerRateLimitRequestsPlaceholder": "Requests", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index a9a9f826cc..045fa9f4f9 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Allowed Combos", "allCombosAllowed": "This key can use any combo.", "restrictedComboCount": "Restricted to {count, plural, one {# combo} other {# combos}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Límites de tarifas personalizadas", "apiManagerCustomRateLimitsDesc": "Anule los límites predeterminados globales. Déjelo vacío para usar los valores predeterminados.", "apiManagerRateLimitRequestsPlaceholder": "Solicitudes", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index a9cf6a7b9d..3335d4b8c7 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -2372,6 +2372,8 @@ "allowedCombos": "ترکیب‌های مجاز", "allCombosAllowed": "این کلید می‌تواند از هر ترکیبی استفاده کند.", "restrictedComboCount": "محدود به {count, plural, one {# ترکیب} other {# ترکیب}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "محدودیت های نرخ سفارشی", "apiManagerCustomRateLimitsDesc": "محدودیت های پیش فرض جهانی را لغو کنید. برای استفاده از پیش فرض ها خالی بگذارید.", "apiManagerRateLimitRequestsPlaceholder": "درخواست ها", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 42f07c18d1..aaf1b28fa0 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Sallitut yhdistelmät", "allCombosAllowed": "Tämä avain voi käyttää mitä tahansa yhdistelmää.", "restrictedComboCount": "Rajoitettu {count, plural, one {# yhdistelmään} other {# yhdistelmään}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Mukautetut hintarajat", "apiManagerCustomRateLimitsDesc": "Ohita globaalit oletusrajat. Jätä tyhjäksi, jos haluat käyttää oletusasetuksia.", "apiManagerRateLimitRequestsPlaceholder": "Pyynnöt", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index ccfb9660c2..27e451e78a 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Combinaisons autorisées", "allCombosAllowed": "Cette clé peut utiliser n'importe quelle combinaison.", "restrictedComboCount": "Restreint à {count, plural, one {# combinaison} other {# combinaisons}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Limites de taux personnalisées", "apiManagerCustomRateLimitsDesc": "Remplacez les limites globales par défaut. Laissez vide pour utiliser les valeurs par défaut.", "apiManagerRateLimitRequestsPlaceholder": "Demandes", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index efc4d4cab6..23c1755507 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -2372,6 +2372,8 @@ "allowedCombos": "મંજૂર સંયોજનો", "allCombosAllowed": "આ કી કોઈપણ સંયોજનનો ઉપયોગ કરી શકે છે.", "restrictedComboCount": "{count, plural, one {# સંયોજન} other {# સંયોજનો}} સુધી મર્યાદિત.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "કસ્ટમ દર મર્યાદાઓ", "apiManagerCustomRateLimitsDesc": "વૈશ્વિક ડિફૉલ્ટ મર્યાદાઓને ઓવરરાઇડ કરો. ડિફૉલ્ટનો ઉપયોગ કરવા માટે ખાલી છોડો.", "apiManagerRateLimitRequestsPlaceholder": "વિનંતીઓ", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 597a4e0d2d..ae1dee22bd 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -2372,6 +2372,8 @@ "allowedCombos": "שילובים מורשים", "allCombosAllowed": "מפתח זה יכול להשתמש בכל שילוב.", "restrictedComboCount": "מוגבל ל-{count, plural, one {# שילוב} other {# שילובים}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "מגבלות תעריף מותאמות אישית", "apiManagerCustomRateLimitsDesc": "עוקף מגבלות ברירת מחדל גלובליות. השאר ריק כדי להשתמש בברירות המחדל.", "apiManagerRateLimitRequestsPlaceholder": "בקשות", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 729e043b88..db15ec5953 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -2372,6 +2372,8 @@ "allowedCombos": "अनुमत कॉम्बो", "allCombosAllowed": "यह कुंजी किसी भी कॉम्बो का उपयोग कर सकती है।", "restrictedComboCount": "{count, plural, one {# कॉम्बो} other {# कॉम्बो}} तक सीमित।", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "कस्टम दर सीमाएँ", "apiManagerCustomRateLimitsDesc": "वैश्विक डिफ़ॉल्ट सीमाओं को ओवरराइड करें। डिफ़ॉल्ट का उपयोग करने के लिए खाली छोड़ें.", "apiManagerRateLimitRequestsPlaceholder": "अनुरोध", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index dd6cba9409..266e324f26 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Engedélyezett kombinációk", "allCombosAllowed": "Ez a kulcs bármilyen kombinációt használhat.", "restrictedComboCount": "{count, plural, one {# kombinációra korlátozva} other {# kombinációra korlátozva}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Egyéni díjkorlátok", "apiManagerCustomRateLimitsDesc": "A globális alapértelmezett korlátok felülbírálása. Hagyja üresen az alapértelmezett értékek használatához.", "apiManagerRateLimitRequestsPlaceholder": "Kérések", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 924f2b6d83..01e5997ea0 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Kombo yang Diizinkan", "allCombosAllowed": "Kunci ini dapat menggunakan kombo apa pun.", "restrictedComboCount": "Dibatasi hingga {count, plural, one {# kombo} other {# kombo}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Batas Tarif Khusus", "apiManagerCustomRateLimitsDesc": "Ganti batas default global. Biarkan kosong untuk menggunakan default.", "apiManagerRateLimitRequestsPlaceholder": "Permintaan", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index 429851704b..fc01270a8b 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Kombinasi yang Diizinkan", "allCombosAllowed": "Kunci ini dapat menggunakan kombinasi apa pun.", "restrictedComboCount": "{count, plural, one {Dibatasi untuk # kombinasi} other {Dibatasi untuk # kombinasi}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Batas Tarif Khusus", "apiManagerCustomRateLimitsDesc": "Ganti batas default global. Biarkan kosong untuk menggunakan default.", "apiManagerRateLimitRequestsPlaceholder": "Permintaan", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 561b9b6791..784e8411d0 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Combinazioni consentite", "allCombosAllowed": "Questa chiave può utilizzare qualsiasi combinazione.", "restrictedComboCount": "Limitato a {count, plural, one {# combinazione} other {# combinazioni}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Limiti di velocità personalizzati", "apiManagerCustomRateLimitsDesc": "Sostituisci i limiti predefiniti globali. Lascia vuoto per utilizzare le impostazioni predefinite.", "apiManagerRateLimitRequestsPlaceholder": "Richieste", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index b9ccd8c963..1ffa8b0004 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2372,6 +2372,8 @@ "allowedCombos": "許可された組み合わせ", "allCombosAllowed": "このキーは任意の組み合わせを使用できます。", "restrictedComboCount": "{count, plural, one {# 個の組み合わせ} other {# 個の組み合わせ}}に制限されています。", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "カスタムレート制限", "apiManagerCustomRateLimitsDesc": "グローバルなデフォルト制限をオーバーライドします。デフォルトを使用する場合は空のままにしてください。", "apiManagerRateLimitRequestsPlaceholder": "リクエスト", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 7f5a941f4d..b70f940a06 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2372,6 +2372,8 @@ "allowedCombos": "허용된 조합", "allCombosAllowed": "이 키는 모든 조합을 사용할 수 있습니다.", "restrictedComboCount": "{count, plural, one {#개 조합} other {#개 조합}}으로 제한됨.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "사용자 정의 속도 제한", "apiManagerCustomRateLimitsDesc": "전역 기본 제한을 재정의합니다. 기본값을 사용하려면 비워 두세요.", "apiManagerRateLimitRequestsPlaceholder": "요청사항", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 0b56a8bb6c..e8cbc50ff5 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -2372,6 +2372,8 @@ "allowedCombos": "अनुमत कॉम्बोज", "allCombosAllowed": "ही की कोणताही कॉम्बो वापरू शकते.", "restrictedComboCount": "{count, plural, one {# कॉम्बोपुरते मर्यादित} other {# कॉम्बोजपुरते मर्यादित}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "सानुकूल दर मर्यादा", "apiManagerCustomRateLimitsDesc": "जागतिक डीफॉल्ट मर्यादा ओव्हरराइड करा. डीफॉल्ट वापरण्यासाठी रिकामे सोडा.", "apiManagerRateLimitRequestsPlaceholder": "विनंत्या", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 09e6755e57..3d2481f9ce 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Kombo yang Dibenarkan", "allCombosAllowed": "Kunci ini boleh menggunakan mana-mana kombo.", "restrictedComboCount": "Terhad kepada {count, plural, one {# kombo} other {# kombo}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Had Kadar Tersuai", "apiManagerCustomRateLimitsDesc": "Gantikan had lalai global. Biarkan kosong untuk menggunakan lalai.", "apiManagerRateLimitRequestsPlaceholder": "Permintaan", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index c02a4b5ce1..4187fd4f92 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Toegestane combo's", "allCombosAllowed": "Deze sleutel kan elke combo gebruiken.", "restrictedComboCount": "Beperkt tot {count, plural, one {# combo} other {# combo's}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Aangepaste tarieflimieten", "apiManagerCustomRateLimitsDesc": "Overschrijf de algemene standaardlimieten. Laat leeg om standaardinstellingen te gebruiken.", "apiManagerRateLimitRequestsPlaceholder": "Verzoeken", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index ae9e8505c2..9065387bec 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Tillatte kombinasjoner", "allCombosAllowed": "Denne nøkkelen kan bruke alle kombinasjoner.", "restrictedComboCount": "Begrenset til {count, plural, one {# kombinasjon} other {# kombinasjoner}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Egendefinerte satsgrenser", "apiManagerCustomRateLimitsDesc": "Overstyr globale standardgrenser. La stå tomt for å bruke standardinnstillinger.", "apiManagerRateLimitRequestsPlaceholder": "Forespørsler", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 9c0e02326e..e1d3bec1b8 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Mga Pinapayagang Combo", "allCombosAllowed": "Maaaring gumamit ng anumang combo ang key na ito.", "restrictedComboCount": "Limitado sa {count, plural, one {# combo} other {# na combo}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Mga Limitasyon ng Custom na Rate", "apiManagerCustomRateLimitsDesc": "I-override ang mga pandaigdigang default na limitasyon. Iwanang walang laman upang gamitin ang mga default.", "apiManagerRateLimitRequestsPlaceholder": "Mga kahilingan", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 1d862e8e85..da6087cbc8 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Dozwolone kombinacje", "allCombosAllowed": "Ten klucz może używać dowolnej kombinacji.", "restrictedComboCount": "Ograniczono do {count, plural, one {# kombinacji} few {# kombinacji} many {# kombinacji} other {# kombinacji}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Niestandardowe rate limits", "apiManagerCustomRateLimitsDesc": "Zastąp globalne limity domyślne. Pozostaw puste, aby użyć domyślnych.", "apiManagerRateLimitRequestsPlaceholder": "Żądania", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 7961814ada..fcc68875d0 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -2373,6 +2373,8 @@ "allowedCombos": "Combos Permitidos", "allCombosAllowed": "Esta chave pode usar qualquer combo.", "restrictedComboCount": "Restrito a {count, plural, one {# combo} other {# combos}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Limites de taxas personalizadas", "apiManagerCustomRateLimitsDesc": "Substitua os limites padrão globais. Deixe em branco para usar os padrões.", "apiManagerRateLimitRequestsPlaceholder": "Solicitações", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 7f2092e445..5da9871889 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Combinações permitidas", "allCombosAllowed": "Esta chave pode utilizar qualquer combinação.", "restrictedComboCount": "Restrito a {count, plural, one {# combinação} other {# combinações}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Limites de taxas personalizadas", "apiManagerCustomRateLimitsDesc": "Substitua os limites padrão globais. Deixe em branco para usar os padrões.", "apiManagerRateLimitRequestsPlaceholder": "Solicitações", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index e2c7a4fb37..c1693890d5 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Combinații permise", "allCombosAllowed": "Această cheie poate utiliza orice combinație.", "restrictedComboCount": "Restricționat la {count, plural, one {# combinație} few {# combinații} other {# de combinații}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Limite de tarif personalizate", "apiManagerCustomRateLimitsDesc": "Înlocuiți limitele globale implicite. Lăsați gol pentru a utiliza valorile implicite.", "apiManagerRateLimitRequestsPlaceholder": "Cereri", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index b3f27490b0..3e68411fb6 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Разрешенные комбинации", "allCombosAllowed": "Этот ключ может использовать любую комбинацию.", "restrictedComboCount": "Ограничено {count, plural, one {# комбинацией} few {# комбинациями} many {# комбинациями} other {# комбинациями}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Пользовательские лимиты ставок", "apiManagerCustomRateLimitsDesc": "Переопределить глобальные ограничения по умолчанию. Оставьте пустым, чтобы использовать значения по умолчанию.", "apiManagerRateLimitRequestsPlaceholder": "Запросы", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 9f765e18f1..76dd54b13b 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Povolené kombinácie", "allCombosAllowed": "Tento kľúč môže použiť akúkoľvek kombináciu.", "restrictedComboCount": "Obmedzené na {count, plural, one {# kombináciu} few {# kombinácie} other {# kombinácií}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Vlastné limity sadzieb", "apiManagerCustomRateLimitsDesc": "Prepísať globálne predvolené limity. Ak chcete použiť predvolené hodnoty, nechajte prázdne.", "apiManagerRateLimitRequestsPlaceholder": "Žiadosti", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index d211c4937a..232e5ecd3f 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Tillåtna kombinationer", "allCombosAllowed": "Denna nyckel kan använda valfri kombination.", "restrictedComboCount": "Begränsad till {count, plural, one {# kombination} other {# kombinationer}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Anpassade prisgränser", "apiManagerCustomRateLimitsDesc": "Åsidosätt globala standardgränser. Lämna tomt om du vill använda standardinställningarna.", "apiManagerRateLimitRequestsPlaceholder": "Förfrågningar", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index c47dfd16a0..785451d51d 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Michanganyiko Inayoruhusiwa", "allCombosAllowed": "Ufunguo huu unaweza kutumia mchanganyiko wowote.", "restrictedComboCount": "Imezuiwa kwa {count, plural, one {mchanganyiko #} other {michanganyiko #}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Vikomo vya Viwango Maalum", "apiManagerCustomRateLimitsDesc": "Batilisha mipaka chaguomsingi ya kimataifa. Acha tupu ili kutumia chaguomsingi.", "apiManagerRateLimitRequestsPlaceholder": "Maombi", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 209f967435..e9a514ba73 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -2372,6 +2372,8 @@ "allowedCombos": "அனுமதிக்கப்பட்ட சேர்க்கைகள்", "allCombosAllowed": "இந்த key எந்த சேர்க்கையையும் பயன்படுத்தலாம்.", "restrictedComboCount": "{count, plural, one {# சேர்க்கைக்கு} other {# சேர்க்கைகளுக்கு}} மட்டுமே கட்டுப்படுத்தப்பட்டது.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "விருப்ப விகித வரம்புகள்", "apiManagerCustomRateLimitsDesc": "உலகளாவிய இயல்புநிலை வரம்புகளை மீறு. இயல்புநிலைகளைப் பயன்படுத்த காலியாக விடவும்.", "apiManagerRateLimitRequestsPlaceholder": "கோரிக்கைகள்", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index d80a08bbf1..5494a7fef5 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -2372,6 +2372,8 @@ "allowedCombos": "అనుమతించబడిన కాంబోలు", "allCombosAllowed": "ఈ కీ ఏ కాంబోనైనా ఉపయోగించవచ్చు.", "restrictedComboCount": "{count, plural, one {# కాంబో} other {# కాంబోలు}}కి పరిమితం చేయబడింది.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "కస్టమ్ రేట్ పరిమితులు", "apiManagerCustomRateLimitsDesc": "గ్లోబల్ డిఫాల్ట్ పరిమితులను భర్తీ చేయండి. డిఫాల్ట్‌లను ఉపయోగించడానికి ఖాళీగా ఉంచండి.", "apiManagerRateLimitRequestsPlaceholder": "అభ్యర్థనలు", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 7ba8fe935a..37a3b1d3ef 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -2372,6 +2372,8 @@ "allowedCombos": "คอมโบที่อนุญาต", "allCombosAllowed": "คีย์นี้สามารถใช้คอมโบใดก็ได้.", "restrictedComboCount": "จำกัดไว้ที่ {count, plural, one {# คอมโบ} other {# คอมโบ}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "ขีดจำกัดอัตราที่กำหนดเอง", "apiManagerCustomRateLimitsDesc": "แทนที่ขีดจำกัดเริ่มต้นส่วนกลาง เว้นว่างไว้เพื่อใช้ค่าเริ่มต้น", "apiManagerRateLimitRequestsPlaceholder": "คำขอ", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index e3f054af39..0483bc95c9 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -2372,6 +2372,8 @@ "allowedCombos": "İzin Verilen Kombinasyonlar", "allCombosAllowed": "Bu anahtar herhangi bir kombinasyonu kullanabilir.", "restrictedComboCount": "{count, plural, one {# kombinasyon} other {# kombinasyon}} ile sınırlandırıldı.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Özel Fiyat Limitleri", "apiManagerCustomRateLimitsDesc": "Genel varsayılan sınırları geçersiz kılın. Varsayılanları kullanmak için boş bırakın.", "apiManagerRateLimitRequestsPlaceholder": "İstekler", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 2019c5d2f6..157338891f 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -2372,6 +2372,8 @@ "allowedCombos": "Дозволені комбінації", "allCombosAllowed": "Цей ключ може використовувати будь-яку комбінацію.", "restrictedComboCount": "Обмежено до {count, plural, one {# комбінації} few {# комбінацій} many {# комбінацій} other {# комбінацій}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "Спеціальні ліміти ставок", "apiManagerCustomRateLimitsDesc": "Перевизначити глобальні обмеження за умовчанням. Залиште пустим, щоб використовувати значення за умовчанням.", "apiManagerRateLimitRequestsPlaceholder": "Запити", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 746e6e3845..0614613dd4 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -2372,6 +2372,8 @@ "allowedCombos": "اجازت یافتہ کمبوز", "allCombosAllowed": "یہ کلید کوئی بھی کمبو استعمال کر سکتی ہے۔", "restrictedComboCount": "{count, plural, one {# کمبو} other {# کمبوز}} تک محدود۔", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "حسب ضرورت شرح کی حدیں", "apiManagerCustomRateLimitsDesc": "عالمی ڈیفالٹ حدود کو اوور رائیڈ کریں۔ ڈیفالٹس استعمال کرنے کے لیے خالی چھوڑ دیں۔", "apiManagerRateLimitRequestsPlaceholder": "درخواستیں", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index c578e4b2cd..a91b7d0446 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -2373,6 +2373,8 @@ "allowedCombos": "Combo được phép", "allCombosAllowed": "Khóa này có thể sử dụng mọi combo.", "restrictedComboCount": "Restricted to {count, plural, one {# combo} other {# combos}}.", + "preservedComboRules": "{count, plural, one {# mục đã lưu} other {# mục đã lưu}} không có trong danh sách combo này (ví dụ: quy tắc định tuyến) và sẽ được giữ nguyên như đã lưu.", + "preservedComboRuleHint": "Được giữ nguyên như đã lưu. Mục này không phải là combo trong danh sách ở trên, nên chỉ có thể thay đổi thông qua API.", "apiManagerCustomRateLimits": "Giới hạn tốc độ tùy chỉnh", "apiManagerCustomRateLimitsDesc": "Ghi đè giới hạn mặc định toàn cục. Để trống để sử dụng mặc định.", "apiManagerRateLimitRequestsPlaceholder": "Yêu cầu", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 3b4a2945d3..5cd019fe4d 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2372,6 +2372,8 @@ "allowedCombos": "允许的组合", "allCombosAllowed": "此密钥可以使用任何组合。", "restrictedComboCount": "限制为 {count, plural, one {# 个组合} other {# 个组合}}。", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "自定义费率限制", "apiManagerCustomRateLimitsDesc": "覆盖全局默认限制。留空以使用默认值。", "apiManagerRateLimitRequestsPlaceholder": "要求", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 7ff97a2998..2f1ecfff9c 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2372,6 +2372,8 @@ "allowedCombos": "允許的組合", "allCombosAllowed": "此金鑰可使用任何組合。", "restrictedComboCount": "Restricted to {count, plural, one {# combo} other {# combos}}.", + "preservedComboRules": "__MISSING__:{count, plural, one {# stored entry is} other {# stored entries are}} not in this combo list (for example routing rules) and will be kept as saved.", + "preservedComboRuleHint": "__MISSING__:Kept as stored. This entry is not a combo in the list above, so it can only be changed through the API.", "apiManagerCustomRateLimits": "自定義費率限制", "apiManagerCustomRateLimitsDesc": "覆蓋全域性預設限制。留空以使用預設值。", "apiManagerRateLimitRequestsPlaceholder": "要求", diff --git a/tests/unit/api-keys-allowed-combos-preserve-12267.test.ts b/tests/unit/api-keys-allowed-combos-preserve-12267.test.ts new file mode 100644 index 0000000000..97d4a4acb7 --- /dev/null +++ b/tests/unit/api-keys-allowed-combos-preserve-12267.test.ts @@ -0,0 +1,78 @@ +/** + * #12267 — API-key allowedCombos must not silently drop entries the Allowed + * Combos picker cannot render. + * + * `matchesComboAccessRule()` (src/shared/utils/apiKeyPolicy.ts) accepts + * routing-rule names such as `rt-*` as valid `allowedCombos` entries through its + * `rule === requestedModel` branch, but the API Manager picker only renders + * `GET /api/combos` entities (`cb-*`). The helper under test is what the picker + * uses to keep those entries alive across the "All" toggle and to surface them + * read-only, so the header count and the list agree. + * + * Rules: + * R1 Entries that name no loaded Combo entity are reported, in stored order. + * R2 Entries that name a loaded Combo entity are not reported (the list renders them). + * R3 The `combo/*` wildcard is never reported — it is the "All" mode marker, not a rule. + * R4 A key restricted only to rule-layer names keeps every entry. + * R5 Nothing is reported when the selection is empty or every entry is renderable. + * R6 The management PATCH schema keeps rule-layer names verbatim (no server-side drop). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const pageUtils = + await import("../../src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts"); +const schemas = await import("../../src/shared/validation/schemas.ts"); +const { ALL_COMBOS_ACCESS_RULE } = await import("../../src/shared/constants/comboAccess.ts"); + +const LOADED_COMBOS = [ + { id: "1", name: "cb-gpt-5.6-sol" }, + { id: "2", name: "cb-claude-opus-5" }, +]; + +test("R1/R2: rule-layer entries are reported in stored order, Combo entities are not", () => { + const stored = ["rt-gpt-5.6-sol", "cb-gpt-5.6-sol", "rt-claude-opus-5"]; + assert.deepEqual(pageUtils.listUnrenderableComboAccessRules(stored, LOADED_COMBOS), [ + "rt-gpt-5.6-sol", + "rt-claude-opus-5", + ]); +}); + +test("R3: the combo/* wildcard is never reported as an unrenderable rule", () => { + assert.deepEqual( + pageUtils.listUnrenderableComboAccessRules( + [ALL_COMBOS_ACCESS_RULE, "rt-gpt-5.6-sol"], + LOADED_COMBOS + ), + ["rt-gpt-5.6-sol"] + ); +}); + +test("R4: a key restricted only to rule-layer names keeps every entry", () => { + const stored = ["rt-gpt-5.6-sol", "rt-claude-opus-5"]; + assert.deepEqual(pageUtils.listUnrenderableComboAccessRules(stored, LOADED_COMBOS), stored); + // No combos loaded at all: still nothing is lost. + assert.deepEqual(pageUtils.listUnrenderableComboAccessRules(stored, []), stored); +}); + +test("R5: nothing is reported for an empty or fully renderable selection", () => { + assert.deepEqual(pageUtils.listUnrenderableComboAccessRules([], LOADED_COMBOS), []); + assert.deepEqual( + pageUtils.listUnrenderableComboAccessRules( + ["cb-gpt-5.6-sol", "cb-claude-opus-5"], + LOADED_COMBOS + ), + [] + ); +}); + +test("R6: PATCH schema keeps rule-layer names verbatim", () => { + const parsed = schemas.updateKeyPermissionsSchema.safeParse({ + modelAccessMode: "restricted", + allowedCombos: ["rt-gpt-5.6-sol", "rt-claude-opus-5"], + }); + assert.equal(parsed.success, true); + if (!parsed.success) return; + assert.deepEqual(parsed.data.allowedCombos, ["rt-gpt-5.6-sol", "rt-claude-opus-5"]); +}); diff --git a/tests/unit/ui/combo-picker-unrenderable-12267.test.tsx b/tests/unit/ui/combo-picker-unrenderable-12267.test.tsx new file mode 100644 index 0000000000..52ce8c1d68 --- /dev/null +++ b/tests/unit/ui/combo-picker-unrenderable-12267.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment jsdom +// +// #12267 — the Allowed Combos picker keeps allowedCombos entries it cannot render +// (routing-rule names such as `rt-*`, accepted by matchesComboAccessRule()) instead +// of silently dropping them: they are shown read-only, counted, and survive the +// "All" toggle so a later "Restrict" + Save cannot persist `[]` (deny-all). +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => + values && typeof values.count === "number" ? `${key}:${values.count}` : key, +})); + +const { AllowedCombosSection } = + await import("../../../src/app/(dashboard)/dashboard/api-manager/components/AllowedCombosSection"); + +const LOADED_COMBOS = [ + { id: "1", name: "cb-gpt-5.6-sol", models: ["a", "b"] }, + { id: "2", name: "cb-claude-opus-5", models: ["c"] }, +]; +const STORED_ACL = ["rt-gpt-5.6-sol", "rt-claude-opus-5", "cb-gpt-5.6-sol"]; + +const containers: Array<{ root: ReturnType; el: HTMLDivElement }> = []; + +function render(props: Partial> = {}) { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + const handlers = { + onAllowAll: vi.fn(), + onRestrict: vi.fn(), + onToggleCombo: vi.fn(), + }; + act(() => { + root.render( + + ); + }); + containers.push({ root, el }); + return { el, ...handlers }; +} + +/** Text content without Material Symbols ligatures ("check", "lock"). */ +function visibleText(node: Element): string { + const clone = node.cloneNode(true) as Element; + clone.querySelectorAll(".material-symbols-outlined").forEach((icon) => icon.remove()); + return clone.textContent?.trim() ?? ""; +} + +function buttonByText(el: HTMLElement, text: string): HTMLButtonElement { + const button = Array.from(el.querySelectorAll("button")).find( + (candidate) => visibleText(candidate) === text + ); + if (!button) throw new Error(`button "${text}" not found`); + return button; +} + +function click(target: HTMLElement) { + act(() => { + target.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +function preservedChips(el: HTMLElement): string[] { + return Array.from(el.querySelectorAll('[data-testid="preserved-combo-rule"]')).map( + (chip) => chip.textContent?.trim() ?? "" + ); +} + +afterEach(() => { + for (const { root, el } of containers) { + act(() => root.unmount()); + el.remove(); + } + containers.length = 0; +}); + +describe("AllowedCombosSection keeps unrenderable allowedCombos entries (#12267)", () => { + it("shows stored rule-layer entries read-only and counts them with the rendered ones", () => { + const { el } = render(); + + expect(el.textContent).toContain("restrictedComboCount:3"); + expect(preservedChips(el)).toEqual(["rt-gpt-5.6-sol", "rt-claude-opus-5"]); + expect(el.textContent).toContain("preservedComboRules:2"); + // The Combo entity that is stored is still rendered as a selected row. + expect(buttonByText(el, "cb-gpt-5.6-sol2 models").className).toContain("bg-primary/10"); + }); + + it("hands the rule-layer entries to onAllowAll when the All toggle clears the picker", () => { + const { el, onAllowAll, onRestrict } = render(); + + click(buttonByText(el, "all")); + + expect(onAllowAll).toHaveBeenCalledTimes(1); + expect(onAllowAll).toHaveBeenCalledWith(["rt-gpt-5.6-sol", "rt-claude-opus-5"]); + expect(onRestrict).not.toHaveBeenCalled(); + }); + + it("hands an empty selection to onAllowAll when every entry is renderable", () => { + const { el, onAllowAll } = render({ selectedCombos: ["cb-gpt-5.6-sol"] }); + + click(buttonByText(el, "all")); + + expect(onAllowAll).toHaveBeenCalledWith([]); + }); + + it("switches back to Restrict without touching the selection", () => { + const { el, onAllowAll, onRestrict } = render({ allowAllCombos: true }); + + expect(preservedChips(el)).toEqual([]); + expect(el.textContent).toContain("allCombosAllowed"); + + click(buttonByText(el, "restrict")); + + expect(onRestrict).toHaveBeenCalledTimes(1); + expect(onAllowAll).not.toHaveBeenCalled(); + }); + + it("delegates rendered combo toggles to onToggleCombo", () => { + const { el, onToggleCombo } = render(); + + click(buttonByText(el, "cb-claude-opus-51 models")); + + expect(onToggleCombo).toHaveBeenCalledWith("cb-claude-opus-5"); + }); + + it("renders nothing when no combos are loaded", () => { + const { el } = render({ allCombos: [] }); + + expect(el.innerHTML).toBe(""); + }); +}); From f41a9bd835f4f72b45b14f743d6d234bc5bbd3f2 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:15:42 +0200 Subject: [PATCH 27/34] feat(admin): localize the anomalies page and add it to the sidebar (#12401) The gamification anomalies page had hard-coded English for its loading state, Status column header and Suspicious badge, and was the only standalone non-redirect dashboard page without a sidebar entry. Both are fixed: the strings come from the common catalog, and the page joins the Gamification sidebar group as a hideable section item shown only by the "all" preset, like its siblings. The loading and empty states also become role="status" aria-live="polite" live regions with aria-busy, matching profile/page.tsx and health/page.tsx. Three new keys in en.json, propagated to the other 42 locales with the __MISSING__ sentinel. Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green. Thanks @pacocartones. --- .../features/12401-admin-anomalies-i18n.md | 1 + .../dashboard/gamification/admin/page.tsx | 12 +- src/i18n/messages/ar.json | 3 + src/i18n/messages/az.json | 3 + src/i18n/messages/bg.json | 3 + src/i18n/messages/bn.json | 3 + src/i18n/messages/cs.json | 3 + src/i18n/messages/da.json | 3 + src/i18n/messages/de.json | 3 + src/i18n/messages/en.json | 3 + src/i18n/messages/es.json | 3 + src/i18n/messages/fa.json | 3 + src/i18n/messages/fi.json | 3 + src/i18n/messages/fr.json | 3 + src/i18n/messages/gu.json | 3 + src/i18n/messages/he.json | 3 + src/i18n/messages/hi.json | 3 + src/i18n/messages/hu.json | 3 + src/i18n/messages/id.json | 3 + src/i18n/messages/in.json | 3 + src/i18n/messages/it.json | 3 + src/i18n/messages/ja.json | 3 + src/i18n/messages/ko.json | 3 + src/i18n/messages/mr.json | 3 + src/i18n/messages/ms.json | 3 + src/i18n/messages/nl.json | 3 + src/i18n/messages/no.json | 3 + src/i18n/messages/phi.json | 3 + src/i18n/messages/pl.json | 3 + src/i18n/messages/pt-BR.json | 3 + src/i18n/messages/pt.json | 3 + src/i18n/messages/ro.json | 3 + src/i18n/messages/ru.json | 3 + src/i18n/messages/sk.json | 3 + src/i18n/messages/sv.json | 3 + src/i18n/messages/sw.json | 3 + src/i18n/messages/ta.json | 3 + src/i18n/messages/te.json | 3 + src/i18n/messages/th.json | 3 + src/i18n/messages/tr.json | 3 + src/i18n/messages/uk-UA.json | 3 + src/i18n/messages/ur.json | 3 + src/i18n/messages/vi.json | 3 + src/i18n/messages/zh-CN.json | 3 + src/i18n/messages/zh-TW.json | 3 + src/shared/constants/sidebarVisibility.ts | 1 + .../constants/sidebarVisibility/sections.ts | 7 ++ .../constants/sidebarVisibility/types.ts | 1 + .../gamification-admin-sidebar-i18n.test.ts | 108 ++++++++++++++++++ .../unit/ui/gamification-admin-page.test.tsx | 81 +++++++++++++ 50 files changed, 336 insertions(+), 4 deletions(-) create mode 100644 changelog.d/features/12401-admin-anomalies-i18n.md create mode 100644 tests/unit/gamification-admin-sidebar-i18n.test.ts create mode 100644 tests/unit/ui/gamification-admin-page.test.tsx diff --git a/changelog.d/features/12401-admin-anomalies-i18n.md b/changelog.d/features/12401-admin-anomalies-i18n.md new file mode 100644 index 0000000000..b8c23012cf --- /dev/null +++ b/changelog.d/features/12401-admin-anomalies-i18n.md @@ -0,0 +1 @@ +- **feat(admin):** localize the gamification anomalies page — the loading state, the Status column and the Suspicious badge now come from the `common` catalog (new `common.suspicious` key propagated to every locale) — add it to the Gamification sidebar group as `gamification-admin` (`/dashboard/gamification/admin`), and expose the loading and empty states as polite `role="status"` live regions (#12401 — thanks @pacocartones) diff --git a/src/app/(dashboard)/dashboard/gamification/admin/page.tsx b/src/app/(dashboard)/dashboard/gamification/admin/page.tsx index f9d561d623..9c514fff10 100644 --- a/src/app/(dashboard)/dashboard/gamification/admin/page.tsx +++ b/src/app/(dashboard)/dashboard/gamification/admin/page.tsx @@ -42,9 +42,13 @@ export default function GamificationAdminPage() {

{t("flaggedAnomalies")}

{loading ? ( -
Loading...
+
+ {t("loading")} +
) : anomalies.length === 0 ? ( -
{t("noAnomaliesDetected")}
+
+ {t("noAnomaliesDetected")} +
) : (
@@ -53,7 +57,7 @@ export default function GamificationAdminPage() { - + @@ -64,7 +68,7 @@ export default function GamificationAdminPage() { diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 6a25642374..a9b80b7ada 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -704,6 +704,7 @@ "apiKey": "مفتاح واجهة برمجة التطبيقات", "xpLastHour": "XP (ساعة واحدة)", "zScore": "Z-النتيجة", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "خوادم المجتمع", "tokensServerNamePlaceholder": "اسم الخادم", "tokensApiKeyPlaceholder": "مفتاح واجهة برمجة التطبيقات", @@ -1208,9 +1209,11 @@ "leaderboard": "لوحة المتصدرين", "profile": "الملف الشخصي", "tokens": "الرموز", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "التصنيفات والإنجازات", "profileSubtitle": "الحساب والتفضيلات", "tokensSubtitle": "استخدام الرموز والميزانيات", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "إحصاءات حركة المرور والاستخدام", "analyticsComboHealthSubtitle": "موثوقية أهداف المجموعة", "analyticsUtilizationSubtitle": "استخدام المزود", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index e6a7491fe3..a1960d059c 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -704,6 +704,7 @@ "apiKey": "API Açarı", "xpLastHour": "XP (1 saat)", "zScore": "Z-Balı", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "İcma serverləri", "tokensServerNamePlaceholder": "Server adı", "tokensApiKeyPlaceholder": "API açarı", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 33fc7d1828..b33e962d69 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -704,6 +704,7 @@ "apiKey": "API ключ", "xpLastHour": "XP (1 ч)", "zScore": "Z-резултат", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Общностни сървъри", "tokensServerNamePlaceholder": "Име на сървъра", "tokensApiKeyPlaceholder": "API ключ", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index ee917384b4..5710458710 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -704,6 +704,7 @@ "apiKey": "API কী", "xpLastHour": "XP (1 ঘন্টা)", "zScore": "জেড-স্কোর", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "কমিউনিটি সার্ভার", "tokensServerNamePlaceholder": "সার্ভারের নাম", "tokensApiKeyPlaceholder": "API কী", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 410d86b60d..0fe3d4dc44 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -704,6 +704,7 @@ "apiKey": "Klíč API", "xpLastHour": "XP (1 h)", "zScore": "Z-skóre", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Komunitní servery", "tokensServerNamePlaceholder": "Název serveru", "tokensApiKeyPlaceholder": "API klíč", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 4088c0ae75..c202022656 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -704,6 +704,7 @@ "apiKey": "API nøgle", "xpLastHour": "XP (1 time)", "zScore": "Z-score", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Fællesskabsservere", "tokensServerNamePlaceholder": "Servernavn", "tokensApiKeyPlaceholder": "API nøgle", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 27fcbfe251..5d989583e1 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -704,6 +704,7 @@ "apiKey": "API-Schlüssel", "xpLastHour": "XP (1h)", "zScore": "Z-Score", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Community-Server", "tokensServerNamePlaceholder": "Servername", "tokensApiKeyPlaceholder": "API-Schlüssel", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 74921e0717..bc69e6d72a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -704,6 +704,7 @@ "apiKey": "API Key", "xpLastHour": "XP (1h)", "zScore": "Z-Score", + "suspicious": "Suspicious", "tokensCommunityServers": "Community Servers", "tokensServerNamePlaceholder": "Server name", "tokensApiKeyPlaceholder": "API key", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 045fa9f4f9..99e294f04a 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -704,6 +704,7 @@ "apiKey": "Clave API", "xpLastHour": "XP (1h)", "zScore": "Puntuación Z", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Servidores comunitarios", "tokensServerNamePlaceholder": "Nombre del servidor", "tokensApiKeyPlaceholder": "clave API", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 3335d4b8c7..e8aede1db9 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -704,6 +704,7 @@ "apiKey": "کلید API", "xpLastHour": "XP (1 ساعت)", "zScore": "Z-Score", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "سرورهای جامعه", "tokensServerNamePlaceholder": "نام سرور", "tokensApiKeyPlaceholder": "کلید API", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index aaf1b28fa0..dc99fd9afc 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -704,6 +704,7 @@ "apiKey": "API-avain", "xpLastHour": "XP (1h)", "zScore": "Z-pisteet", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Yhteisön palvelimet", "tokensServerNamePlaceholder": "Palvelimen nimi", "tokensApiKeyPlaceholder": "API-avain", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 27e451e78a..6b8f919f8b 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -704,6 +704,7 @@ "apiKey": "Clé API", "xpLastHour": "XP (1h)", "zScore": "Score Z", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Serveurs communautaires", "tokensServerNamePlaceholder": "Nom du serveur", "tokensApiKeyPlaceholder": "Clé API", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 23c1755507..8815915a8c 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -704,6 +704,7 @@ "apiKey": "API કી", "xpLastHour": "XP (1h)", "zScore": "Z-સ્કોર", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "કોમ્યુનિટી સર્વર્સ", "tokensServerNamePlaceholder": "સર્વર નામ", "tokensApiKeyPlaceholder": "API કી", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index ae1dee22bd..c6195712d4 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -704,6 +704,7 @@ "apiKey": "מפתח API", "xpLastHour": "XP (שעה אחת)", "zScore": "ציון Z", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "שרתי קהילה", "tokensServerNamePlaceholder": "שם השרת", "tokensApiKeyPlaceholder": "מפתח API", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index db15ec5953..7a487687c0 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -704,6 +704,7 @@ "apiKey": "एपीआई कुंजी", "xpLastHour": "एक्सपी (1 घंटा)", "zScore": "Z-स्कोर", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "सामुदायिक सर्वर", "tokensServerNamePlaceholder": "सर्वर का नाम", "tokensApiKeyPlaceholder": "एपीआई कुंजी", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 266e324f26..84357ab19c 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -704,6 +704,7 @@ "apiKey": "API kulcs", "xpLastHour": "XP (1h)", "zScore": "Z-pontszám", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Közösségi szerverek", "tokensServerNamePlaceholder": "Server name", "tokensApiKeyPlaceholder": "API kulcs", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 01e5997ea0..0ab43e7247 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -704,6 +704,7 @@ "apiKey": "Kunci API", "xpLastHour": "XP (1 jam)", "zScore": "Skor-Z", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Server Komunitas", "tokensServerNamePlaceholder": "Nama server", "tokensApiKeyPlaceholder": "Kunci API", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index fc01270a8b..7af3f51c52 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -704,6 +704,7 @@ "apiKey": "Kunci API", "xpLastHour": "XP (1 jam)", "zScore": "Skor-Z", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Server Komunitas", "tokensServerNamePlaceholder": "Nama server", "tokensApiKeyPlaceholder": "Kunci API", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 784e8411d0..9b7dd943a7 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -704,6 +704,7 @@ "apiKey": "Chiave API", "xpLastHour": "XP (1 ora)", "zScore": "Punteggio Z", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Server della comunità", "tokensServerNamePlaceholder": "Nome del server", "tokensApiKeyPlaceholder": "Chiave API", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 1ffa8b0004..b98b911f11 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -704,6 +704,7 @@ "apiKey": "APIキー", "xpLastHour": "XP (1時間)", "zScore": "Zスコア", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "コミュニティサーバー", "tokensServerNamePlaceholder": "サーバー名", "tokensApiKeyPlaceholder": "APIキー", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index b70f940a06..621156e8dc 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -704,6 +704,7 @@ "apiKey": "API 키", "xpLastHour": "경험치 (1시간)", "zScore": "Z-점수", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "커뮤니티 서버", "tokensServerNamePlaceholder": "서버 이름", "tokensApiKeyPlaceholder": "API 키", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index e8cbc50ff5..dd93516044 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -704,6 +704,7 @@ "apiKey": "API की", "xpLastHour": "XP (1h)", "zScore": "Z-स्कोअर", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "समुदाय सर्व्हर", "tokensServerNamePlaceholder": "सर्व्हरचे नाव", "tokensApiKeyPlaceholder": "API की", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 3d2481f9ce..512425c1fa 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -704,6 +704,7 @@ "apiKey": "Kunci API", "xpLastHour": "XP (1j)", "zScore": "Skor Z", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Pelayan Komuniti", "tokensServerNamePlaceholder": "Nama pelayan", "tokensApiKeyPlaceholder": "kunci API", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 4187fd4f92..7a968a4c7b 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -704,6 +704,7 @@ "apiKey": "API-sleutel", "xpLastHour": "XP (1 uur)", "zScore": "Z-score", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Gemeenschapsservers", "tokensServerNamePlaceholder": "Servernaam", "tokensApiKeyPlaceholder": "API-sleutel", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 9065387bec..94a5a13a3c 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -704,6 +704,7 @@ "apiKey": "API-nøkkel", "xpLastHour": "XP (1t)", "zScore": "Z-score", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Fellesskapsservere", "tokensServerNamePlaceholder": "Servernavn", "tokensApiKeyPlaceholder": "API-nøkkel", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index e1d3bec1b8..8ae29385e4 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -704,6 +704,7 @@ "apiKey": "API Key", "xpLastHour": "XP (1h)", "zScore": "Z-Score", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Mga Server ng Komunidad", "tokensServerNamePlaceholder": "Pangalan ng server", "tokensApiKeyPlaceholder": "API key", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index da6087cbc8..c210231a76 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -704,6 +704,7 @@ "apiKey": "Klucz API", "xpLastHour": "XP (1h)", "zScore": "Z-Score", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Serwery społeczności", "tokensServerNamePlaceholder": "Nazwa serwera", "tokensApiKeyPlaceholder": "Klucz API", @@ -1208,9 +1209,11 @@ "leaderboard": "Tabela liderów", "profile": "Profil", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankingi i osiągnięcia", "profileSubtitle": "Konto i preferencje", "tokensSubtitle": "Zużycie tokens i budżety", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Statystyki ruchu i użycia", "analyticsComboHealthSubtitle": "Niezawodność celów combo", "analyticsUtilizationSubtitle": "Utylizacja provider", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index fcc68875d0..11d1cc3ce3 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -704,6 +704,7 @@ "apiKey": "Chave de API", "xpLastHour": "EXP (1h)", "zScore": "Pontuação Z", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Servidores da Comunidade", "tokensServerNamePlaceholder": "Nome do servidor", "tokensApiKeyPlaceholder": "Chave de API", @@ -1209,9 +1210,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Estatísticas de tráfego e uso", "analyticsComboHealthSubtitle": "Confiabilidade dos targets do combo", "analyticsUtilizationSubtitle": "Utilização de provedores", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 5da9871889..0d0c530e72 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -704,6 +704,7 @@ "apiKey": "Chave de API", "xpLastHour": "EXP (1h)", "zScore": "Pontuação Z", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Servidores da Comunidade", "tokensServerNamePlaceholder": "Nome do servidor", "tokensApiKeyPlaceholder": "Chave de API", @@ -1208,9 +1209,11 @@ "leaderboard": "Tabela de Classificação", "profile": "Perfil", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Classificações e conquistas", "profileSubtitle": "Conta e preferências", "tokensSubtitle": "Uso de tokens e orçamentos", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Estatísticas de tráfego e uso", "analyticsComboHealthSubtitle": "Confiabilidade dos targets do combo", "analyticsUtilizationSubtitle": "Utilização de provedores", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index c1693890d5..5f2e247202 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -704,6 +704,7 @@ "apiKey": "Cheia API", "xpLastHour": "XP (1h)", "zScore": "Scorul Z", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Servere comunitare", "tokensServerNamePlaceholder": "Numele serverului", "tokensApiKeyPlaceholder": "cheie API", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 3e68411fb6..130567bdab 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -704,6 +704,7 @@ "apiKey": "API-ключ", "xpLastHour": "Опыт (1 час)", "zScore": "Z-оценка", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Серверы сообщества", "tokensServerNamePlaceholder": "Имя сервера", "tokensApiKeyPlaceholder": "API-ключ", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 76dd54b13b..a946b69776 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -704,6 +704,7 @@ "apiKey": "API kľúč", "xpLastHour": "XP (1 h)", "zScore": "Z-skóre", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "komunitné servery", "tokensServerNamePlaceholder": "Názov servera", "tokensApiKeyPlaceholder": "API kľúč", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 232e5ecd3f..118611358b 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -704,6 +704,7 @@ "apiKey": "API-nyckel", "xpLastHour": "XP (1h)", "zScore": "Z-poäng", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Community-servrar", "tokensServerNamePlaceholder": "Servernamn", "tokensApiKeyPlaceholder": "API-nyckel", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 785451d51d..32e3b15507 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -704,6 +704,7 @@ "apiKey": "Ufunguo wa API", "xpLastHour": "XP (saa 1)", "zScore": "Z-Alama", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Wahudumu wa Jumuiya", "tokensServerNamePlaceholder": "Jina la seva", "tokensApiKeyPlaceholder": "Kitufe cha API", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index e9a514ba73..d068075175 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -704,6 +704,7 @@ "apiKey": "API விசை", "xpLastHour": "XP (1h)", "zScore": "Z-ஸ்கோர்", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "சமூக சேவையகங்கள்", "tokensServerNamePlaceholder": "சர்வர் பெயர்", "tokensApiKeyPlaceholder": "API விசை", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 5494a7fef5..c1452b02fe 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -704,6 +704,7 @@ "apiKey": "API కీ", "xpLastHour": "XP (1గం)", "zScore": "Z-స్కోరు", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "కమ్యూనిటీ సర్వర్లు", "tokensServerNamePlaceholder": "సర్వర్ పేరు", "tokensApiKeyPlaceholder": "API కీ", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 37a3b1d3ef..9f427524ba 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -704,6 +704,7 @@ "apiKey": "คีย์ API", "xpLastHour": "ประสบการณ์ (1ชม.)", "zScore": "Z-คะแนน", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "เซิร์ฟเวอร์ชุมชน", "tokensServerNamePlaceholder": "ชื่อเซิร์ฟเวอร์", "tokensApiKeyPlaceholder": "คีย์เอพีไอ", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 0483bc95c9..55535a8b2f 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -704,6 +704,7 @@ "apiKey": "API Anahtarı", "xpLastHour": "Deneyim (1 saat)", "zScore": "Z-Skoru", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Topluluk Sunucuları", "tokensServerNamePlaceholder": "Sunucu adı", "tokensApiKeyPlaceholder": "API anahtarı", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 157338891f..7e6fb3debd 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -704,6 +704,7 @@ "apiKey": "Ключ API", "xpLastHour": "XP (1 год)", "zScore": "Z-оцінка", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "Сервери спільноти", "tokensServerNamePlaceholder": "Ім'я сервера", "tokensApiKeyPlaceholder": "Ключ API", @@ -1208,9 +1209,11 @@ "leaderboard": "Рейтинг", "profile": "Профіль", "tokens": "Токени", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Рейтинги та досягнення", "profileSubtitle": "Акаунт і налаштування", "tokensSubtitle": "Використання токенів і бюджети", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Статистика трафіку та використання", "analyticsComboHealthSubtitle": "Надійність маршрутів комбінацій", "analyticsUtilizationSubtitle": "Завантаженість провайдерів", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 0614613dd4..f2a20f60d5 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -704,6 +704,7 @@ "apiKey": "API کلید", "xpLastHour": "XP (1h)", "zScore": "زیڈ سکور", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "کمیونٹی سرورز", "tokensServerNamePlaceholder": "سرور کا نام", "tokensApiKeyPlaceholder": "API کلید", @@ -1208,9 +1209,11 @@ "leaderboard": "Leaderboard", "profile": "Profile", "tokens": "Tokens", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "Rankings and achievements", "profileSubtitle": "Account and preferences", "tokensSubtitle": "Token usage and budgets", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "Traffic and usage stats", "analyticsComboHealthSubtitle": "Combo target reliability", "analyticsUtilizationSubtitle": "Provider utilization", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index a91b7d0446..c29ac82de9 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -704,6 +704,7 @@ "apiKey": "Khóa API", "xpLastHour": "XP (1 giờ)", "zScore": "Điểm Z", + "suspicious": "Đáng ngờ", "tokensCommunityServers": "Máy chủ cộng đồng", "tokensServerNamePlaceholder": "Tên máy chủ", "tokensApiKeyPlaceholder": "Khóa API", @@ -1209,9 +1210,11 @@ "leaderboard": "Bảng xếp hạng", "profile": "Hồ sơ", "tokens": "Token", + "gamificationAdmin": "Quản trị trò chơi hóa", "leaderboardSubtitle": "Xếp hạng và thành tích", "profileSubtitle": "Tài khoản và tùy chọn", "tokensSubtitle": "Mức sử dụng và ngân sách token", + "gamificationAdminSubtitle": "Giám sát bất thường và chống gian lận", "usageSubtitle": "Thống kê lưu lượng và mức sử dụng", "analyticsComboHealthSubtitle": "Độ tin cậy của combo mục tiêu", "analyticsUtilizationSubtitle": "Mức sử dụng nhà cung cấp", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 5cd019fe4d..1b183ee002 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -704,6 +704,7 @@ "apiKey": "API密钥", "xpLastHour": "XP(1 小时)", "zScore": "Z 分数", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "社区服务器", "tokensServerNamePlaceholder": "服务器名称", "tokensApiKeyPlaceholder": "API密钥", @@ -1208,9 +1209,11 @@ "leaderboard": "排行榜", "profile": "个人资料", "tokens": "令牌", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "排名与成就", "profileSubtitle": "账户与偏好", "tokensSubtitle": "令牌使用和预算", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "流量和使用统计", "analyticsComboHealthSubtitle": "组合目标可靠性", "analyticsUtilizationSubtitle": "提供者利用率", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 2f1ecfff9c..bed00a5085 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -704,6 +704,7 @@ "apiKey": "API金鑰", "xpLastHour": "XP(1 小時)", "zScore": "Z 分數", + "suspicious": "__MISSING__:Suspicious", "tokensCommunityServers": "社群伺服器", "tokensServerNamePlaceholder": "伺服器名稱", "tokensApiKeyPlaceholder": "API金鑰", @@ -1208,9 +1209,11 @@ "leaderboard": "排行榜", "profile": "個人資料", "tokens": "權杖", + "gamificationAdmin": "__MISSING__:Gamification Admin", "leaderboardSubtitle": "排名與成就", "profileSubtitle": "帳戶與偏好", "tokensSubtitle": "權杖使用和預算", + "gamificationAdminSubtitle": "__MISSING__:Anomaly monitoring and anti-cheat", "usageSubtitle": "流量和使用統計", "analyticsComboHealthSubtitle": "組合目標可靠性", "analyticsUtilizationSubtitle": "提供者利用率", diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index 270715ef42..d0cc1590ff 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -65,6 +65,7 @@ export const SIDEBAR_ICON_ACCENTS: Partial> = { leaderboard: "#FACC15", profile: "#60A5FA", tokens: "#A3E635", + "gamification-admin": "#F87171", media: "#D946EF", batch: "#14B8A6", "batch-files": "#38BDF8", diff --git a/src/shared/constants/sidebarVisibility/sections.ts b/src/shared/constants/sidebarVisibility/sections.ts index 7589a5f1fd..bbf72a01e9 100644 --- a/src/shared/constants/sidebarVisibility/sections.ts +++ b/src/shared/constants/sidebarVisibility/sections.ts @@ -649,6 +649,13 @@ const GAMIFICATION_GROUP: SidebarItemGroup = { subtitleKey: "tokensSubtitle", icon: "toll", }, + { + id: "gamification-admin", + href: "/dashboard/gamification/admin", + i18nKey: "gamificationAdmin", + subtitleKey: "gamificationAdminSubtitle", + icon: "admin_panel_settings", + }, ], }; diff --git a/src/shared/constants/sidebarVisibility/types.ts b/src/shared/constants/sidebarVisibility/types.ts index ef32610c70..792cd46dee 100644 --- a/src/shared/constants/sidebarVisibility/types.ts +++ b/src/shared/constants/sidebarVisibility/types.ts @@ -90,6 +90,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "leaderboard", "profile", "tokens", + "gamification-admin", // Other Features — flat "media", // Other Features > Batch diff --git a/tests/unit/gamification-admin-sidebar-i18n.test.ts b/tests/unit/gamification-admin-sidebar-i18n.test.ts new file mode 100644 index 0000000000..d8a257a284 --- /dev/null +++ b/tests/unit/gamification-admin-sidebar-i18n.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const { SIDEBAR_SECTIONS, HIDEABLE_SIDEBAR_ITEM_IDS, SIDEBAR_ICON_ACCENTS, getSectionItems } = + await import("../../src/shared/constants/sidebarVisibility.ts"); + +type Messages = Record; + +function readJson(relativePath: string): Messages { + return JSON.parse(readFileSync(path.join(repoRoot, relativePath), "utf8")) as Messages; +} + +function getMessage(messages: Messages, dottedKey: string): unknown { + return dottedKey.split(".").reduce((value, segment) => { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return (value as Messages)[segment]; + }, messages); +} + +const PAGE_PATH = "src/app/(dashboard)/dashboard/gamification/admin/page.tsx"; +const SIDEBAR_KEYS = ["sidebar.gamificationAdmin", "sidebar.gamificationAdminSubtitle"]; +const NEW_KEYS = ["common.suspicious", ...SIDEBAR_KEYS]; + +test("gamification anomalies page is reachable from the Gamification sidebar group", () => { + const section = SIDEBAR_SECTIONS.find((s) => s.id === "other-features"); + assert.ok(section, "other-features section must exist"); + + const group = section.children.find((child) => "type" in child && child.id === "gamification"); + assert.ok(group && "items" in group, "gamification group must exist"); + + const item = group.items.find((entry) => entry.id === "gamification-admin"); + assert.ok(item, "gamification-admin item must be in the gamification group"); + assert.equal(item.href, "/dashboard/gamification/admin"); + assert.equal(item.i18nKey, "gamificationAdmin"); + assert.equal(item.subtitleKey, "gamificationAdminSubtitle"); + assert.equal(typeof item.icon, "string"); + assert.ok(item.icon.length > 0, "sidebar item needs a Material Symbols icon"); + + assert.equal(group.items[group.items.length - 1]?.id, "gamification-admin"); + assert.equal( + getSectionItems(section).some((entry) => entry.id === "gamification-admin"), + true + ); + assert.equal(HIDEABLE_SIDEBAR_ITEM_IDS.includes("gamification-admin"), true); + assert.match(SIDEBAR_ICON_ACCENTS["gamification-admin"] ?? "", /^#[0-9A-Fa-f]{6}$/); +}); + +test("every translation key the anomalies page uses resolves in en.json common", () => { + const source = readFileSync(path.join(repoRoot, PAGE_PATH), "utf8"); + assert.match(source, /useTranslations\("common"\)/); + + const usedKeys = [...source.matchAll(/\bt\("([^"]+)"\)/g)].map((m) => m[1]); + assert.ok(usedKeys.length >= 10, `expected the page to translate its copy, got ${usedKeys}`); + for (const key of ["loading", "status", "suspicious", "noAnomaliesDetected"]) { + assert.ok(usedKeys.includes(key), `page must call t("${key}")`); + } + + const en = readJson("src/i18n/messages/en.json"); + for (const key of usedKeys) { + assert.equal(typeof getMessage(en, `common.${key}`), "string", `en.common.${key} must exist`); + } + for (const key of SIDEBAR_KEYS) { + assert.equal(typeof getMessage(en, key), "string", `en.${key} must exist`); + } +}); + +test("anomalies page has no hard-coded English copy left in JSX", () => { + const source = readFileSync(path.join(repoRoot, PAGE_PATH), "utf8"); + assert.doesNotMatch(source, />\s*Loading\.\.\.\s*\s*Status\s*\s*Suspicious\s* { + const source = readFileSync(path.join(repoRoot, PAGE_PATH), "utf8"); + const statusRegions = source.match(/role="status" aria-live="polite"/g) ?? []; + assert.equal(statusRegions.length, 2, "loading and empty states must both be live regions"); + assert.match(source, /role="status" aria-live="polite" aria-busy="true"/); +}); + +test("new anomalies and sidebar keys are propagated to every configured locale", () => { + const config = readJson("config/i18n.json") as { locales: Array<{ code: string }> }; + const codes = ["en", ...config.locales.map((locale) => locale.code)]; + assert.ok(codes.length > 40, "expected the full locale roster"); + + for (const code of codes) { + const messages = readJson(`src/i18n/messages/${code}.json`); + for (const key of NEW_KEYS) { + const value = getMessage(messages, key); + assert.equal(typeof value, "string", `${code}.${key} must exist`); + assert.ok((value as string).trim().length > 0, `${code}.${key} must not be empty`); + } + } + + // Vietnamese is kept fully translated (see i18n-vi-completeness.test.ts). + const vi = readJson("src/i18n/messages/vi.json"); + for (const key of NEW_KEYS) { + assert.doesNotMatch( + getMessage(vi, key) as string, + /^__MISSING__:/, + `vi.${key} must be translated` + ); + } +}); diff --git a/tests/unit/ui/gamification-admin-page.test.tsx b/tests/unit/ui/gamification-admin-page.test.tsx new file mode 100644 index 0000000000..4357317b20 --- /dev/null +++ b/tests/unit/ui/gamification-admin-page.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import React from "react"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Namespace-prefixed keys instead of the global en.json-backed mock: any English +// string still hard-coded in the page would surface verbatim in the rendered text. +vi.mock("next-intl", () => ({ + useTranslations: (namespace: string) => (key: string) => `${namespace}.${key}`, +})); + +vi.mock("@/shared/components", () => ({ + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const RAW_ENGLISH = ["Loading...", "Status", "Suspicious"]; +const originalFetch = globalThis.fetch; + +function mockFetch(payload: unknown) { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => payload, + }) as unknown as typeof fetch; +} + +async function renderPage() { + const { default: GamificationAdminPage } = + await import("../../../src/app/(dashboard)/dashboard/gamification/admin/page"); + return render(); +} + +describe("GamificationAdminPage (anomalies)", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("announces the loading state through a busy polite live region", async () => { + globalThis.fetch = vi.fn().mockReturnValue(new Promise(() => {})) as unknown as typeof fetch; + const { container } = await renderPage(); + + const status = screen.getByRole("status"); + expect(status.getAttribute("aria-live")).toBe("polite"); + expect(status.getAttribute("aria-busy")).toBe("true"); + expect(status.textContent).toBe("common.loading"); + for (const raw of RAW_ENGLISH) expect(container.textContent).not.toContain(raw); + }); + + it("announces the empty result through a polite live region", async () => { + mockFetch({ anomalies: [] }); + const { container } = await renderPage(); + + const status = await screen.findByText("common.noAnomaliesDetected"); + expect(status.getAttribute("role")).toBe("status"); + expect(status.getAttribute("aria-live")).toBe("polite"); + expect(status.hasAttribute("aria-busy")).toBe(false); + for (const raw of RAW_ENGLISH) expect(container.textContent).not.toContain(raw); + }); + + it("renders the flagged table with translated column headers and badge", async () => { + mockFetch({ + anomalies: [{ apiKeyId: "sk-0123456789abcdef0123", xpLastHour: 12345, zScore: 4.2 }], + }); + const { container } = await renderPage(); + + await waitFor(() => expect(screen.getByText("common.suspicious")).toBeTruthy()); + for (const key of ["common.apiKey", "common.xpLastHour", "common.zScore", "common.status"]) { + expect(screen.getByText(key)).toBeTruthy(); + } + expect(screen.getByText("4.20")).toBeTruthy(); + expect(screen.queryByRole("status")).toBeNull(); + for (const raw of RAW_ENGLISH) expect(container.textContent).not.toContain(raw); + }); +}); From 2c6e6cd13e9b04cac0b5c0c109d57ffac06e4c51 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:19:08 +0200 Subject: [PATCH 28/34] fix(providers): list gemini-business models in the registry (#12389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/providers/gemini-business/models returned nothing because gemini-business had no RegistryEntry: the listing route resolves the provider through getRegistryEntry and filters the unified catalog by owned_by, and open-sse/config/providers/index.ts only registered gemini and gemini-web. Adds a registry entry mirroring gemini_webProvider — id gemini-business, alias gembiz, cookie auth — with the twelve ids from the executor's MODEL_CATEGORY_MAP. Each model is declared toolCalling: false, supportsReasoning: false, the same live-behaviour contract applied to gemini-web in #9356: the executor returns plain text, hard-wires the thinking mode and parses no tool calls. Reconciled on merge: the only conflict was the reserved-prefix count assertion, which the tip had moved. Took the tip's text and measured the real value with this PR applied — 406 to 408, the gemini-business id plus its gembiz alias — rather than carrying the branch's number. Validated in a combined worktree with all 25 PRs of this batch boarded together (typecheck:core clean, 443/443 node-runner plus 14/14 vitest, all static gates green), and re-verified standalone on the current tip after the other 24 landed: 33/33 across provider-node-reserved-prefix, gemini-business-model-registry-12107 and web-cookie-validation-fallback, with check:provider-consistency OK at 272 REGISTRY entries and 355 canonical providers. Thanks @pacocartones. --- .../12389-gemini-business-model-registry.md | 1 + open-sse/config/providers/index.ts | 2 + .../registry/gemini/business/index.ts | 99 +++++++++++++++ src/lib/providers/validation/transport.ts | 12 ++ src/lib/providers/validation/webCookie.ts | 17 ++- tests/snapshots/provider/translate-path.json | 23 ++++ ...mini-business-model-registry-12107.test.ts | 113 ++++++++++++++++++ .../provider-node-reserved-prefix.test.ts | 4 +- .../web-cookie-validation-fallback.test.ts | 11 +- 9 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fixes/12389-gemini-business-model-registry.md create mode 100644 open-sse/config/providers/registry/gemini/business/index.ts create mode 100644 tests/unit/gemini-business-model-registry-12107.test.ts diff --git a/changelog.d/fixes/12389-gemini-business-model-registry.md b/changelog.d/fixes/12389-gemini-business-model-registry.md new file mode 100644 index 0000000000..3f3255a8d7 --- /dev/null +++ b/changelog.d/fixes/12389-gemini-business-model-registry.md @@ -0,0 +1 @@ +- **fix(providers):** `gemini-business` now publishes its model catalog — `/v1/models` and `/v1/providers/gemini-business/models` list the 12 enterprise Gemini ids the executor understands instead of returning an empty list (#12107) diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 21e0fdb91f..868fb90e68 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -201,6 +201,7 @@ import { maritalkProvider } from "./registry/maritalk/index.ts"; import { basetenProvider } from "./registry/baseten/index.ts"; import { geminiProvider } from "./registry/gemini/index.ts"; import { gemini_webProvider } from "./registry/gemini/web/index.ts"; +import { gemini_businessProvider } from "./registry/gemini/business/index.ts"; import { clineProvider } from "./registry/cline/index.ts"; import { herokuProvider } from "./registry/heroku/index.ts"; import { bluesmindsProvider } from "./registry/bluesminds/index.ts"; @@ -471,6 +472,7 @@ export const REGISTRY: Record = { baseten: basetenProvider, gemini: geminiProvider, "gemini-web": gemini_webProvider, + "gemini-business": gemini_businessProvider, cline: clineProvider, heroku: herokuProvider, bluesminds: bluesmindsProvider, diff --git a/open-sse/config/providers/registry/gemini/business/index.ts b/open-sse/config/providers/registry/gemini/business/index.ts new file mode 100644 index 0000000000..19d23bad84 --- /dev/null +++ b/open-sse/config/providers/registry/gemini/business/index.ts @@ -0,0 +1,99 @@ +import type { RegistryEntry } from "../../../shared.ts"; + +// #12107: gemini-business was registered only in the dashboard/connection +// catalog (src/shared/constants/providers/web-cookie.ts) and had no entry in +// this REGISTRY, so `/v1/models` and `/v1/providers/gemini-business/models` +// never published a model under `owned_by: "gemini-business"` and the listing +// came back empty. The model ids below are exactly the ones the executor's +// MODEL_CATEGORY_MAP understands (open-sse/executors/gemini-business.ts); keep +// the two lists in step when a model is added or retired. +// +// `toolCalling: false` / `supportsReasoning: false` are live-behavior statements +// with the same rationale as gemini-web (#9356): the executor posts a single +// prompt to the enterprise StreamGenerate endpoint with a fixed thinking mode +// and returns plain text only — it has no thinking-budget control to drive and +// no native function-calling channel, so agent routers reading /v1/models must +// not select these models for reasoning or native tool work. +export const gemini_businessProvider: RegistryEntry = { + id: "gemini-business", + alias: "gembiz", + format: "openai", + executor: "gemini-business", + baseUrl: "https://business.gemini.google/home", + authType: "apikey", + authHeader: "cookie", + models: [ + { + id: "gemini-3-pro", + name: "Gemini 3 Pro (Enterprise)", + toolCalling: false, + supportsReasoning: false, + }, + { + id: "gemini-3-ultra", + name: "Gemini 3 Ultra (Enterprise)", + toolCalling: false, + supportsReasoning: false, + }, + { + id: "gemini-3-flash", + name: "Gemini 3 Flash (Enterprise)", + toolCalling: false, + supportsReasoning: false, + }, + { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro (Enterprise)", + toolCalling: false, + supportsReasoning: false, + }, + { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash (Enterprise)", + toolCalling: false, + supportsReasoning: false, + }, + { + id: "gemini-2.5-flash-thinking", + name: "Gemini 2.5 Flash Thinking (Enterprise)", + toolCalling: false, + supportsReasoning: false, + }, + { + id: "gemini-2.0-pro", + name: "Gemini 2.0 Pro", + toolCalling: false, + supportsReasoning: false, + }, + { + id: "gemini-2.0-flash", + name: "Gemini 2.0 Flash", + toolCalling: false, + supportsReasoning: false, + }, + { + id: "gemini-2.0-flash-thinking", + name: "Gemini 2.0 Flash Thinking", + toolCalling: false, + supportsReasoning: false, + }, + { + id: "gemini-3-pro-image", + name: "Gemini 3 Pro Image", + toolCalling: false, + supportsReasoning: false, + }, + { + id: "gemini-2.0-flash-image", + name: "Gemini 2.0 Flash Image", + toolCalling: false, + supportsReasoning: false, + }, + { + id: "veo-3.1-generate", + name: "Veo 3.1 Generate", + toolCalling: false, + supportsReasoning: false, + }, + ], +}; diff --git a/src/lib/providers/validation/transport.ts b/src/lib/providers/validation/transport.ts index 21a8b1f45d..cbf6686aa9 100644 --- a/src/lib/providers/validation/transport.ts +++ b/src/lib/providers/validation/transport.ts @@ -128,6 +128,18 @@ export const WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API = new Set([ "copilot-m365-web", ]); +// #12107 — web-cookie providers whose registry entry exists to publish a model catalog +// (so `/v1/models` and `/v1/providers/{id}/models` list something) but whose `baseUrl` +// is a browser console, not an API host. gemini-business's entry points at +// business.gemini.google/home: the executor only uses that origin to derive a +// per-tenant StreamGenerate path (`/home/cid/{CID}/_/BardChatUi/...`), so there is no +// side-effect-free auth probe on the host — `${baseUrl}/models` is a page Google never +// served, and a 401/403 from a console page is not a credential signal either. Unlike +// WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API these providers are therefore not probed at +// all: validation stays the honest "unsupported" it reported before the registry entry +// existed, decided BEFORE any network call. +export const WEB_COOKIE_PROVIDERS_WITHOUT_AUTH_PROBE = new Set(["gemini-business"]); + export function toWebCookieValidationErrorResult(provider: string, error: unknown) { if ( error instanceof SafeOutboundFetchError && diff --git a/src/lib/providers/validation/webCookie.ts b/src/lib/providers/validation/webCookie.ts index 6a199e38b5..82d5a84e34 100644 --- a/src/lib/providers/validation/webCookie.ts +++ b/src/lib/providers/validation/webCookie.ts @@ -10,6 +10,7 @@ import { validationRead, toValidationErrorResult, toWebCookieValidationErrorResult, + WEB_COOKIE_PROVIDERS_WITHOUT_AUTH_PROBE, WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API, } from "./transport"; @@ -47,12 +48,16 @@ function resolveWebCookieProbe( } // Providers listed in WEB_COOKIE_PROVIDERS without a providerRegistry entry (e.g. - // gemini-business, poe-web, venice-web, v0-vercel-web) only expose a marketing - // website URL, not a real API host. Probing `${website}/models` does not reliably - // signal session validity for these — live verification showed most return - // redirects or SPA 200s regardless of cookie validity, which would silently report - // an expired/garbage cookie as "OK" (worse than an honest "not supported"). - if (!entry) return { rejection: UNSUPPORTED }; + // poe-web, venice-web, v0-vercel-web) only expose a marketing website URL, not a + // real API host. Probing `${website}/models` does not reliably signal session + // validity for these — live verification showed most return redirects or SPA 200s + // regardless of cookie validity, which would silently report an expired/garbage + // cookie as "OK" (worse than an honest "not supported"). The same refusal covers + // providers whose registry entry exists only for the model catalog and whose + // baseUrl is a browser console rather than an API host (#12107, gemini-business). + if (!entry || WEB_COOKIE_PROVIDERS_WITHOUT_AUTH_PROBE.has(provider)) { + return { rejection: UNSUPPORTED }; + } // Defense-in-depth: only an http(s) baseUrl without a query string is safe to probe // by blindly appending `/models`. A ws(s):// baseUrl (e.g. copilot-web) is already diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 903a210a92..a53e570e75 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -2391,6 +2391,29 @@ "stream": "https://generativelanguage.googleapis.com/v1beta/models/test-model:streamGenerateContent?alt=sse" } }, + "gemini-business": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "https://business.gemini.google/home", + "stream": "https://business.gemini.google/home" + } + }, "gemini-web": { "format": "openai", "headers": { diff --git a/tests/unit/gemini-business-model-registry-12107.test.ts b/tests/unit/gemini-business-model-registry-12107.test.ts new file mode 100644 index 0000000000..5531f6e530 --- /dev/null +++ b/tests/unit/gemini-business-model-registry-12107.test.ts @@ -0,0 +1,113 @@ +// Regression guard for #12107 — `gemini-business` had no model listing. +// +// The provider was registered only in the dashboard/connection catalog +// (src/shared/constants/providers/web-cookie.ts) and had no `RegistryEntry` in +// the model REGISTRY that backs `/v1/models` and `/v1/providers/{provider}/models`. +// The listing route resolved the provider fine, but its catalog filter +// (`owned_by === "gemini-business"`) never matched anything because no registry +// entry ever published a model under that owner — so it returned an empty list +// instead of an error. +// +// The executor (open-sse/executors/gemini-business.ts, MODEL_CATEGORY_MAP) already +// carries the static list of every model id it understands. This suite pins that +// list on the registry entry, mirroring the sibling cookie provider `gemini-web`. + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { REGISTRY, getRegistryEntry, generateModels, generateAliasMap, getRegisteredProviders } = + await import("../../open-sse/config/providerRegistry.ts"); +const { WEB_COOKIE_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +const { supportsReasoning, supportsToolCalling } = + await import("../../src/lib/modelCapabilities.ts"); + +// Exactly the ids the executor's MODEL_CATEGORY_MAP understands, in map order. +const EXECUTOR_MODEL_IDS = [ + "gemini-3-pro", + "gemini-3-ultra", + "gemini-3-flash", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-thinking", + "gemini-2.0-pro", + "gemini-2.0-flash", + "gemini-2.0-flash-thinking", + "gemini-3-pro-image", + "gemini-2.0-flash-image", + "veo-3.1-generate", +]; + +test("#12107 gemini-business has a REGISTRY entry wired to its executor", () => { + const entry = REGISTRY["gemini-business"]; + assert.ok( + entry, + "REGISTRY must contain a gemini-business entry — without it /v1/models lists nothing" + ); + assert.equal(entry.id, "gemini-business"); + assert.equal(entry.executor, "gemini-business"); + assert.equal(entry.format, "openai"); + assert.equal(entry.authType, "apikey"); + assert.equal(entry.authHeader, "cookie"); + assert.ok(getRegisteredProviders().includes("gemini-business")); +}); + +test("#12107 the registry alias matches the dashboard catalog alias", () => { + // The listing route resolves the provider via getRegistryEntry() (id OR alias) + // and the dashboard resolves it via WEB_COOKIE_PROVIDERS; both must agree. + const entry = REGISTRY["gemini-business"]; + const dashboard = WEB_COOKIE_PROVIDERS["gemini-business"]; + assert.ok(entry); + assert.equal(entry.alias, "gembiz"); + assert.equal(entry.alias, dashboard.alias); + assert.equal(getRegistryEntry("gembiz"), entry, "alias lookup must resolve to the same entry"); + assert.equal(getRegistryEntry("gemini-business"), entry); + assert.equal(generateAliasMap()["gemini-business"], "gembiz"); +}); + +test("#12107 gemini-business lists every model id the executor understands", () => { + const entry = REGISTRY["gemini-business"]; + assert.ok(entry); + assert.deepEqual( + entry.models.map(({ id }) => id), + EXECUTOR_MODEL_IDS, + "registry ids must mirror the executor's MODEL_CATEGORY_MAP exactly" + ); + for (const model of entry.models) { + assert.equal(typeof model.name, "string"); + assert.ok(model.name.length > 0, `${model.id} must carry a display name`); + } +}); + +test("#12107 the static catalog surface publishes gemini-business models under its alias", () => { + // generateModels() is what the static model catalog reads; it keys by alias. + const byAlias = generateModels(); + assert.ok( + byAlias.gembiz, + "generateModels() must expose the gemini-business catalog under 'gembiz'" + ); + assert.deepEqual( + byAlias.gembiz.map(({ id }) => id), + EXECUTOR_MODEL_IDS + ); +}); + +test("#12107 registry advertises no native tool calling and no reasoning (same contract as gemini-web, #9356)", () => { + // The executor drives StreamGenerate with a fixed thinking mode and returns + // plain text only: it never surfaces reasoning_content and has no + // function-calling channel. Agent routers reading /v1/models must not pick + // these models for reasoning or native tool work. + const entry = REGISTRY["gemini-business"]; + assert.ok(entry); + for (const model of entry.models) { + assert.equal(model.toolCalling, false, `${model.id} must not advertise native tool calling`); + assert.equal(model.supportsReasoning, false, `${model.id} must advertise reasoning:false`); + + const input = { provider: "gemini-business", model: model.id }; + assert.equal(supportsReasoning(input), false, `${model.id} resolved reasoning must be false`); + assert.equal( + supportsToolCalling(input), + false, + `${model.id} resolved native tool calling must be false` + ); + } +}); diff --git a/tests/unit/provider-node-reserved-prefix.test.ts b/tests/unit/provider-node-reserved-prefix.test.ts index 22d0955c5e..998f00945f 100644 --- a/tests/unit/provider-node-reserved-prefix.test.ts +++ b/tests/unit/provider-node-reserved-prefix.test.ts @@ -173,7 +173,9 @@ test("shared set size includes live REGISTRY and retired Designer + Felo + Qwen // ids/aliases removed from REGISTRY by #11691's migration 166. // #11513: the two UC providers add four REGISTRY prefixes — the persona id "uc" + // alias "ucn", and the Developer API id "uc-direct" + alias "ucd" (402 → 406). - assert.equal(RESERVED_PREFIX_COUNT, 406); + // #12389: the gemini-business registry entry adds its id "gemini-business" and + // alias "gembiz" to the REGISTRY walk (406 → 408). + assert.equal(RESERVED_PREFIX_COUNT, 408); }); test("isReservedProviderPrefix rejects non-string input", () => { diff --git a/tests/unit/web-cookie-validation-fallback.test.ts b/tests/unit/web-cookie-validation-fallback.test.ts index c2d4373664..6db7f590bc 100644 --- a/tests/unit/web-cookie-validation-fallback.test.ts +++ b/tests/unit/web-cookie-validation-fallback.test.ts @@ -1,6 +1,7 @@ // Tests for validateWebCookieProvider fallback when no registry entry exists. -// Covers providers like lmarena, gemini-business, poe-web, venice-web and v0-vercel-web -// that are listed in WEB_COOKIE_PROVIDERS but have no entry in providerRegistry.ts. +// Covers providers like poe-web, venice-web and v0-vercel-web that are listed in +// WEB_COOKIE_PROVIDERS but have no entry in providerRegistry.ts, plus the two that have +// since gained an entry and must keep their classification (lmarena, gemini-business). // // These providers only expose a marketing website URL (WEB_COOKIE_PROVIDERS[id].website), // not a real API host. Probing `${website}/models` does not reliably signal session @@ -70,7 +71,11 @@ test("lmarena validation rejects empty cookie before checking support", async () assert.equal(fetchCalls.length, 0); }); -// ── gemini-business (no registry entry, falls back to WEB_COOKIE_PROVIDERS) ── +// ── gemini-business (#12107: gained a catalog-only registry entry — must NOT be probed) ── +// The entry exists so /v1/models lists the executor's models; its baseUrl is the enterprise +// console (business.gemini.google/home), not an API host, so validation stays the +// pre-registry "unsupported" result via WEB_COOKIE_PROVIDERS_WITHOUT_AUTH_PROBE, decided +// before any network call. test("gemini-business validation is unsupported and makes no network call", async () => { const result = await validateProviderApiKey({ From 752aac65d66012f6d9daabda4eb33b8b690b0aec Mon Sep 17 00:00:00 2001 From: backryun Date: Wed, 2 Sep 2026 15:57:46 +0900 Subject: [PATCH 29/34] =?UTF-8?q?fix(ci):=20repair=20release-root=20regres?= =?UTF-8?q?sions=20=E2=80=94=20pack=20dedup,=20web-session=20syntax,=20uc-?= =?UTF-8?q?image=20ids=20(#12423)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three regressions inherited by every PR rebased onto release/v3.8.51, caught and documented with the exact failing output. The one that mattered most: src/shared/providers/webSessionCredentials.ts did not parse. The UC merge (#11513) inserted the uc: entry inside maxai.storageKeys and lost the array's closing ], plus the entry's }, leaving `ERROR: Expected "]" but found ":"` at line 351. That module is imported by the provider API routes, bulk-web-session, autoCombo's virtualFactory, keepaliveThreshold and dashboard components, so the break was live on the tip and flooded unrelated catalog tests with transform failures. That was my conflict resolution, not the contributor's code — thank you for catching it and for tracing it to the root commit rather than patching around the symptom. Also fixed: the duplicate bin/cli/utils/volatileEnvPath.mjs entry in PACK_ARTIFACT_REQUIRED_PATHS (findMissingArtifactPaths reported it twice), and UC image models made prefix-addressable without letting them claim historical bare model ids belonging to other providers. Reconciled on merge: #12394 landed the busy_timeout/probe work first, so src/lib/db/core.ts takes the tip's side. probeUtils.ts is the union of both rather than either side — this PR's message regex is wider (SQLite also reports "database table is locked", "database schema is locked" and "database is busy"), while #12394 added the driver code/errcode path that keeps a transient lock from being classified as corruption and renaming the database away. Taking either alone would have dropped the other half; this PR's own ENOENT test is what surfaced it. Verified: 76/76 across uc-image, probe-9541-repro, web-session-contract, pack-artifact-policy, bulk-web-session-import and exclusive-connection-leases, and every changed .ts file parses. Thanks @backryun. --- open-sse/config/imageRegistry.ts | 77 +++++++++---------- scripts/build/pack-artifact-policy.ts | 6 -- src/lib/db/probeUtils.ts | 9 ++- src/shared/providers/webSessionCredentials.ts | 2 + tests/unit/probe-9541-repro.test.ts | 5 ++ tests/unit/uc-image.test.ts | 25 +++++- 6 files changed, 76 insertions(+), 48 deletions(-) diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 4d64d4a6f5..27bb0d41b9 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -276,45 +276,6 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"], }, - // UC (uncensored.com) image generation. Two surfaces served by one handler - // (handleUcImageGeneration picks by credential): PERSONA web (un-metered, - // Clerk JWT -> internal.chatuncensored.ai/v2/image-gen + result-URL polling) - // and uc-direct REST (metered, X-api-key -> api.uncensored.com, OpenAI-shaped). - uc: { - id: "uc", - baseUrl: "https://internal.chatuncensored.ai/v2/image-gen", - authType: "apikey", - authHeader: "bearer", - format: "uc-image", - models: [ - { id: "model-dev", name: "Flux Dev (UC)" }, - { id: "model-pro", name: "Flux Pro (UC)" }, - { id: "model-1.1", name: "Flux Pro 1.1 (UC)" }, - { id: "model-1.2", name: "Wan 2.2 (UC)" }, - { id: "seedream-v4.5", name: "Seedream v4.5 (UC)" }, - { id: "seedream-v5", name: "Seedream v5 (UC)" }, - { id: "flux-2", name: "FLUX.2 (UC)" }, - { id: "flux-2-pro", name: "FLUX.2 Pro (UC)" }, - { id: "lustify-v7", name: "Lustify v7 (UC)" }, - { id: "nano-banana", name: "Nano Banana (UC)" }, - { id: "nano-banana-2", name: "Nano Banana 2 (UC)" }, - { id: "nano-banana-pro", name: "Nano Banana Pro (UC)" }, - { id: "nano-banana-ultra", name: "Nano Banana Ultra (UC)" }, - { id: "gpt-image", name: "GPT Image (UC)" }, - { id: "gpt-image-2", name: "GPT Image 2 (UC)" }, - { id: "realism", name: "Realism (UC)" }, - { id: "realism-2", name: "Realism 2 (UC)" }, - { id: "z-image-turbo", name: "Z-Image Turbo (UC)" }, - { id: "prefect-pony-xl", name: "Prefect Pony XL (UC)" }, - { id: "wan-2.6", name: "Wan 2.6 (UC)" }, - { id: "wan-2.7-text-to-image", name: "Wan 2.7 Text-to-Image (UC)" }, - { id: "wan-2.7-text-to-image-pro", name: "Wan 2.7 Text-to-Image Pro (UC)" }, - ], - // Persona web derives imageWidth/imageHeight from an aspect ratio; uc-direct - // passes any OpenAI-style size through. These are the aspect buckets. - supportedSizes: ["1024x1024", "1024x576", "576x1024", "1024x768", "768x1024"], - }, - xai: { id: "xai", baseUrl: "https://api.x.ai/v1/images/generations", @@ -894,6 +855,44 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "2048x2048"], }, aihorde: AI_HORDE_IMAGE_PROVIDER, + + // Keep UC after every existing image provider because parseImageModel() resolves + // bare duplicate ids by first match. Explicit `uc/` routes remain available while + // historical owners retain bare ids such as nano-banana and z-image-turbo. + uc: { + id: "uc", + baseUrl: "https://internal.chatuncensored.ai/v2/image-gen", + authType: "apikey", + authHeader: "bearer", + format: "uc-image", + models: [ + { id: "model-dev", name: "Flux Dev (UC)" }, + { id: "model-pro", name: "Flux Pro (UC)" }, + { id: "model-1.1", name: "Flux Pro 1.1 (UC)" }, + { id: "model-1.2", name: "Wan 2.2 (UC)" }, + { id: "seedream-v4.5", name: "Seedream v4.5 (UC)" }, + { id: "seedream-v5", name: "Seedream v5 (UC)" }, + { id: "flux-2", name: "FLUX.2 (UC)" }, + { id: "flux-2-pro", name: "FLUX.2 Pro (UC)" }, + { id: "lustify-v7", name: "Lustify v7 (UC)" }, + { id: "nano-banana", name: "Nano Banana (UC)" }, + { id: "nano-banana-2", name: "Nano Banana 2 (UC)" }, + { id: "nano-banana-pro", name: "Nano Banana Pro (UC)" }, + { id: "nano-banana-ultra", name: "Nano Banana Ultra (UC)" }, + { id: "gpt-image", name: "GPT Image (UC)" }, + { id: "gpt-image-2", name: "GPT Image 2 (UC)" }, + { id: "realism", name: "Realism (UC)" }, + { id: "realism-2", name: "Realism 2 (UC)" }, + { id: "z-image-turbo", name: "Z-Image Turbo (UC)" }, + { id: "prefect-pony-xl", name: "Prefect Pony XL (UC)" }, + { id: "wan-2.6", name: "Wan 2.6 (UC)" }, + { id: "wan-2.7-text-to-image", name: "Wan 2.7 Text-to-Image (UC)" }, + { id: "wan-2.7-text-to-image-pro", name: "Wan 2.7 Text-to-Image Pro (UC)" }, + ], + // Persona web derives imageWidth/imageHeight from an aspect ratio; uc-direct + // passes any OpenAI-style size through. These are the aspect buckets. + supportedSizes: ["1024x1024", "1024x576", "576x1024", "1024x768", "768x1024"], + }, }; /** diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 2358399fa6..240b62813b 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -216,12 +216,6 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "bin/mcpStdioConsoleGuard.mjs", "bin/nodeRuntimeSupport.mjs", "bin/omniroute.mjs", - // #11437: bin/omniroute.mjs imports ./cli/utils/volatileEnvPath.mjs at startup - // (describeVolatileEnvWarning — flags a .env living inside the installed package). - // bin/cli/ is only an allowlist PREFIX, so its absence would never fail the - // unexpected-paths check; list it REQUIRED so a regression is loud (#7065 class, - // enforced by tests/unit/pack-artifact-entrypoint-closures.test.ts). - "bin/cli/utils/volatileEnvPath.mjs", // #7808: aliasResolver + its hook file. bin/omniroute.mjs imports // bin/aliasResolver.mjs at startup, which in turn registers // bin/aliasResolverHook.mjs as the ESM loader. Both must ship in the tarball diff --git a/src/lib/db/probeUtils.ts b/src/lib/db/probeUtils.ts index 4f0df076e8..987f62c829 100644 --- a/src/lib/db/probeUtils.ts +++ b/src/lib/db/probeUtils.ts @@ -22,7 +22,14 @@ import path from "node:path"; */ export function isTransientProbeError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); - if (/SQLITE_BUSY|SQLITE_PROTOCOL|SQLITE_IOERR|ENOENT|database is locked/i.test(message)) { + // #12423 widened the message side: SQLite also reports "database table is + // locked", "database schema is locked" and "database is busy" for the same + // transient contention that "database is locked" covers. + if ( + /SQLITE_BUSY|SQLITE_PROTOCOL|SQLITE_IOERR|ENOENT|database(?: table| schema)? is (?:locked|busy)/i.test( + message + ) + ) { return true; } // The real drivers do not put the result-code name in the message: both diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 5ede7e9f55..b95f8d1e31 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -348,6 +348,8 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { "maxaiDeviceId", "userId", "maxaiUserId", + ], + }, uc: { // UC (uncensored.com) persona: auth is the durable Clerk `__client` cookie // (a JWT with no exp) plus the session id + user id, all stored in diff --git a/tests/unit/probe-9541-repro.test.ts b/tests/unit/probe-9541-repro.test.ts index 1a1caa76a6..0bda9dea7b 100644 --- a/tests/unit/probe-9541-repro.test.ts +++ b/tests/unit/probe-9541-repro.test.ts @@ -50,6 +50,11 @@ test("FIX-GREEN: isTransientProbeError does NOT classify fatal errors", () => { test("FIX-GREEN: isTransientProbeError classifies BUSY/PROTOCOL/IOERR/ENOENT", () => { const transientPatterns = [ "SQLITE_BUSY: database is locked", + // better-sqlite3 can omit the symbolic SQLite error code entirely. + "database is locked", + "database table is locked", + "database schema is locked: main", + "database is busy", "SQLITE_PROTOCOL: locking protocol", "SQLITE_IOERR: disk I/O error", "ENOENT: no such file or directory, open '/tmp/db.sqlite'", diff --git a/tests/unit/uc-image.test.ts b/tests/unit/uc-image.test.ts index 1b75832468..cb947cef45 100644 --- a/tests/unit/uc-image.test.ts +++ b/tests/unit/uc-image.test.ts @@ -8,7 +8,7 @@ import { UC_PERSONA_IMAGE_URL, UC_DIRECT_IMAGE_URL, } from "../../open-sse/handlers/imageGeneration/providers/ucImage.ts"; -import { IMAGE_PROVIDERS } from "../../open-sse/config/imageRegistry.ts"; +import { IMAGE_PROVIDERS, parseImageModel } from "../../open-sse/config/imageRegistry.ts"; // A valid PERSONA credential (durable Clerk cookie + sid + uid in psd). No API // key, so the handler takes the persona web path (mint -> POST -> poll). @@ -51,6 +51,25 @@ test("uc is registered in IMAGE_PROVIDERS with the uc-image format + 22 models", assert.equal((entry.models ?? []).length, 22); }); +test("uc image models require an explicit prefix when an existing provider owns the bare id", () => { + assert.deepEqual(parseImageModel("uc/nano-banana"), { + provider: "uc", + model: "nano-banana", + }); + assert.deepEqual(parseImageModel("uc/z-image-turbo"), { + provider: "uc", + model: "z-image-turbo", + }); + assert.deepEqual(parseImageModel("nano-banana"), { + provider: "adobe-firefly", + model: "nano-banana", + }); + assert.deepEqual(parseImageModel("z-image-turbo"), { + provider: "nanogpt", + model: "z-image-turbo", + }); +}); + // --- Pure helpers -------------------------------------------------------- test("resolveUcImageModel strips uc/ and uc-direct/ prefixes", () => { @@ -226,7 +245,9 @@ test("handleUcImageGeneration (persona) 401s (retryable) when the credential is test("handleUcImageGeneration (persona) times out with 504 when the result never readies", async () => { const resultUrl = "https://gen.moveinwater.com/img_never.png"; const fetchImpl = personaFetch({ - pendingPolls: 1000, // never becomes ready within the window + // The injected no-op sleep can execute more than 1,000 polls inside 5 ms on + // fast runners, so use an unbounded pending count to make the timeout deterministic. + pendingPolls: Number.POSITIVE_INFINITY, resultUrl, jwt: fakeJwt("uid", FUTURE_EXP), }); From 97041954171ee3aa3f51a7fb01748797c23716f8 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 2 Sep 2026 04:00:37 -0300 Subject: [PATCH 30/34] fix(providers): repair the maxai credential block truncated by merge auto-resolve (#12433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #11461 × #11513 merge ate the closing '],' + '},' of the maxai entry in webSessionCredentials.ts — 11 syntax errors (TS1005/1137/1128) on the tip, which also masked one real TS2322 the MaxAI block introduced in the models route (providerSpecificData is unknown on the connection; cast to the exact shape resolveMaxaiCredential already takes, zero runtime change). API Route Typecheck gate: OK — 289 pre-existing, all baselined. typecheck:core: 0. --- src/app/api/providers/[id]/models/route.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 09d9579be3..2e6bb736a2 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -612,7 +612,10 @@ export async function GET( try { const discovery = await discoverMaxaiModels({ - providerSpecificData: connection.providerSpecificData, + providerSpecificData: connection.providerSpecificData as + | Record + | null + | undefined, accessToken: apiKey || accessToken, fetchImpl: (url, init) => safeOutboundFetch(url, { From 6d556c24222b79606eb67d7ac3710f17a11036da Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 2 Sep 2026 04:27:08 -0300 Subject: [PATCH 31/34] fix(quality): record the 2026-09-02 merged growth in the file-size baseline (#12434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-file-size was red on release/v3.8.51 with nine violations — seven source files and two test files that the 2026-09-02 merge waves grew at existing chokepoints (#12359-#12404, #11461, #11513, #12423). The growth itself was reviewed: each file was measured and justified while validating those batches. What went wrong is the propagation — the rebaseline was computed in the throwaway combined validation worktree, and the PRs were then merged individually through their own branches, so the code landed and the caps did not. A shared-file edit made only in the validation tree reaches nothing. This records the caps against the merged state, each entry attributed to the PR that grew it, under one _rebaseline annotation. Verified mechanically: 9 caps recorded, 0 raised beyond the file's real merged LOC, 0 unrelated entries moved — the ratchet #12411 re-tightened is intact. Verified: check-file-size OK (135 frozen source entries across 4515 files; 39 frozen test entries across 5365), prettier clean. --- config/quality/file-size-baseline.json | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 3dce7fc978..aa55d4b6bf 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_02_v3851_merged_growth_basereds": "Base-red drain: the 2026-09-02 merge waves (#12359-#12404, #11461, #11513, #12423) each grew a frozen file at an existing chokepoint, but the rebaseline was computed in the throwaway combined validation worktree and never reached any PR branch, so the growth landed while the caps did not and check-file-size went red on the release tip. Recorded here against the merged state: src/app/api/providers/[id]/models/route.ts 2429->2432 (#12389 gemini-business listing on top of #11461's 2429); src/app/api/v1/models/catalog.ts 2066->2075 (#12381 self-aliased canonical rows + #12403 NUL escape); src/lib/db/core.ts 1740->1745 (#12394 busy_timeout ordering + probe classification); src/sse/handlers/chat.ts 2375->2384 (#12360 breaker result classification + #12365 shadowed-node error); src/sse/services/auth.ts 3420->3427 (#12375 backoffLevel tie-break); open-sse/handlers/imageGeneration.ts 3255->3259 (#11513 uc-image branch + #12423 uc-image id scoping); open-sse/utils/proxyFetch.ts 1261->1271 (#12380 hasAmbientProxyContext()); tests/unit/image-generation-handler.test.ts 2110->2133 (#12362 regression coverage); tests/unit/sse-auth.test.ts 1697->1729 (#12375 regression coverage). No cap is raised beyond the merged LOC; every other entry is untouched.", "_rebaseline_2026_09_02_11513_uc_provider": "PR #11513 (arminanton, feat/uc-native-standalone) own growth: open-sse/handlers/imageGeneration.ts 3243->3255 (+12) — the uc-image format branch for the UC persona provider's image surface. Additive at the existing per-format chokepoint, same rationale as _rebaseline_2026_09_02_11461_maxai_tls_profile.", "_rebaseline_2026_09_02_11461_maxai_tls_profile": "PR #11461 (arminanton, feat/maxai-provider) own growth, three files at existing per-provider chokepoints: open-sse/utils/proxyFetch.ts 1241->1261 (+20, the TLS_PROVIDER_PROFILE map giving MaxAI a Windows/firefox_150 impersonation profile instead of the tlsClient chrome_124/macos default); open-sse/handlers/imageGeneration.ts 3231->3243 (+12, the maxai-image format branch); src/app/api/providers/[id]/models/route.ts 2381->2429 (+48, live model listing via maxaiModels). Additive data, same no-split rationale as _rebaseline_2026_08_20_10531_freebuff_provider.", "_rebaseline_2026_09_02_11460_flat_rate_estimates": "PR #11460 (xiaoyaner0201, fix/11459-cc-cost-estimates) own growth: src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx 1283->1319 (+36) — the flat-rate estimate labelling and the includeFlatRateEstimates opt-in on the Costs dashboard. #11460 merged first so this ratchet re-tightening measures the real post-merge LOC; the cap still drops 2002->1319 (-683) versus the 2026-08-10 +30% loosening this PR reverses. Same own-growth rationale as _rebaseline_2026_08_20_10531_freebuff_provider.", @@ -210,14 +211,14 @@ "tests/unit/executor-codex.test.ts": 1465, "tests/unit/executor-default-base.test.ts": 1632, "tests/unit/grok-web.test.ts": 2437, - "tests/unit/image-generation-handler.test.ts": 2110, + "tests/unit/image-generation-handler.test.ts": 2133, "tests/unit/models-catalog-route.test.ts": 1652, "tests/unit/perplexity-web.test.ts": 1384, "tests/unit/provider-models-route.test.ts": 1783, "tests/unit/provider-validation-specialty.test.ts": 2912, "tests/unit/reasoning-cache.test.ts": 1291, "tests/unit/route-edge-coverage.test.ts": 1244, - "tests/unit/sse-auth.test.ts": 1697, + "tests/unit/sse-auth.test.ts": 1729, "tests/unit/stream-utils.test.ts": 2517, "tests/unit/token-refresh-service.test.ts": 1407, "tests/unit/translator-openai-responses-req.test.ts": 1470, @@ -408,7 +409,7 @@ "open-sse/executors/cursor.ts": 1759, "open-sse/executors/muse-spark-web.ts": 1405, "open-sse/handlers/chatCore.ts": 5946, - "open-sse/handlers/imageGeneration.ts": 3255, + "open-sse/handlers/imageGeneration.ts": 3259, "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, "open-sse/mcp-server/server.ts": 1572, @@ -417,7 +418,7 @@ "open-sse/services/combo.ts": 4023, "open-sse/translator/response/openai-responses.ts": 1466, "open-sse/utils/cursorAgentProtobuf.ts": 1547, - "open-sse/utils/proxyFetch.ts": 1261, + "open-sse/utils/proxyFetch.ts": 1271, "open-sse/utils/stream.ts": 3072, "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1322, @@ -434,20 +435,20 @@ "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1606, "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1597, "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2152, - "src/app/api/providers/[id]/models/route.ts": 2429, + "src/app/api/providers/[id]/models/route.ts": 2432, "src/app/api/providers/[id]/test/route.ts": 1252, - "src/app/api/v1/models/catalog.ts": 2066, + "src/app/api/v1/models/catalog.ts": 2075, "src/app/docs/lib/openapi.generated.ts": 1347, "src/lib/db/apiKeys.ts": 1610, - "src/lib/db/core.ts": 1740, + "src/lib/db/core.ts": 1745, "src/lib/db/migrationRunner.ts": 1201, "src/lib/tailscaleTunnel.ts": 1208, "src/lib/tokenHealthCheck.ts": 1218, "src/shared/components/RequestLoggerV2.tsx": 1718, "src/shared/constants/providers/apikey/gateways.ts": 1439, "src/shared/services/cliRuntime.ts": 1296, - "src/sse/handlers/chat.ts": 2375, - "src/sse/services/auth.ts": 3420, + "src/sse/handlers/chat.ts": 2384, + "src/sse/services/auth.ts": 3427, "tests/unit/account-fallback-service.test.ts": 2453, "tests/unit/provider-validation-specialty.test.ts": 4656 }, From 7802f6ea163f18f349348dbfd8710a159ac32e15 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 2 Sep 2026 05:12:11 -0300 Subject: [PATCH 32/34] fix(uc): route UC error strings through sanitizeErrorMessage; allowlist the retired codex id (#12437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drains the two remaining Fast Quality Gates reds the #11513 (UC) merge left on the tip: - error-helper: ucTts.ts and uc/ws.ts built error payloads from raw err.message (Hard Rule #12) — now wrapped in sanitizeErrorMessage(), behavior otherwise identical (uc suites 51/51). - model-lifecycle: the UC catalog registers the vendor-retired gpt-5.2-codex (bare id; only the prefixed openai/gpt-5.2-codex was allowlisted). Added to allowedRetiredInCatalog per its policy — forwarding globally would rewrite the just-approved provider's model. Tracking: Refs #12436. file-size, the third red of this window, was already drained by #12434. --- config/quality/model-lifecycle.json | 1 + open-sse/executors/uc/ws.ts | 5 +++-- open-sse/handlers/uc/ucTts.ts | 5 +++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/config/quality/model-lifecycle.json b/config/quality/model-lifecycle.json index 40ac870f9f..59da8bb77d 100644 --- a/config/quality/model-lifecycle.json +++ b/config/quality/model-lifecycle.json @@ -14,6 +14,7 @@ "claude-3-7-sonnet-20250219", "google/gemini-2.0-flash", "gpt-4-0125-preview", + "gpt-5.2-codex", "openai/gpt-5.2-codex" ], "allowedRetiredInCatalog_note": "TODO(#11503): ratchet to burn down. Each id is retired by its vendor but still routable from the provider catalog. Removing a catalog row or adding a BUILT_IN_ALIASES forward is a maintainer call (some aggregators still serve these ids), so they are allowlisted here rather than silently dropped. Delete an entry as soon as it is forwarded or removed; never add one without a tracking issue.", diff --git a/open-sse/executors/uc/ws.ts b/open-sse/executors/uc/ws.ts index cc27854332..6c2bd1f0ae 100644 --- a/open-sse/executors/uc/ws.ts +++ b/open-sse/executors/uc/ws.ts @@ -14,6 +14,7 @@ * fake socket (same pattern as muse-spark-web). */ import WebSocket from "ws"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; import { UC_ORIGIN, UC_WS_HOST, UC_WS_TIMEOUT_MS } from "./constants.ts"; import { buildPersonaFrame, type UcHistoryEntry } from "./protocol.ts"; @@ -82,7 +83,7 @@ export function runUcTurn(input: UcTurnInput): Promise { resolve({ content: "", reasoning: "", - error: `ws connect failed: ${err instanceof Error ? err.message : String(err)}`, + error: `ws connect failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`, }); return; } @@ -126,7 +127,7 @@ export function runUcTurn(input: UcTurnInput): Promise { }); ws.send(JSON.stringify(frame)); } catch (err) { - fail(`ws send failed: ${err instanceof Error ? err.message : String(err)}`); + fail(`ws send failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`); } }; diff --git a/open-sse/handlers/uc/ucTts.ts b/open-sse/handlers/uc/ucTts.ts index 57b651d512..6e9e1e5ab4 100644 --- a/open-sse/handlers/uc/ucTts.ts +++ b/open-sse/handlers/uc/ucTts.ts @@ -28,6 +28,7 @@ * path is unit-testable with no live network. */ import { randomUUID } from "node:crypto"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; import { Buffer } from "node:buffer"; import WebSocket from "ws"; @@ -151,7 +152,7 @@ export function runUcTtsSocket(input: UcTtsSocketInput): Promise, - error: `ws connect failed: ${err instanceof Error ? err.message : String(err)}`, + error: `ws connect failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`, }); return; } @@ -194,7 +195,7 @@ export function runUcTtsSocket(input: UcTtsSocketInput): Promise Date: Wed, 2 Sep 2026 06:22:30 -0300 Subject: [PATCH 33/34] chore(lint): adopt eslint-plugin-react-hooks 7.1.1 (#12428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(lint): adopt eslint-plugin-react-hooks 7.1.1 The #12146 migration (284 react-hooks compiler-rule violations resolved in 8 batches) completed on 2026-09-01, unblocking the 7.1.1 adoption the pin test was holding back. Exact pin kept in both devDependencies and overrides; the pin test moves to 7.1.1 (the dependabot-level ignore from #12329 stays — a lint plugin coupled to the compiler rules always bumps via its own reviewed PR, never riding a group). * chore(lint): lockfile for the react-hooks 7.1.1 adoption Generated with a bare 'npm install --package-lock-only' (naming the package on the CLI rewrites the devDependency with a caret, which npm 11 then rejects against the exact override). Validated on the .113 with a fresh npm ci + cold NODE_OPTIONS=8G lint:json --max-warnings 0 → exit 0 (zero new violations from the 7.1.1 rule set) and the re-pinned version test green. --- package-lock.json | 10 +++++----- package.json | 4 ++-- tests/unit/eslint-react-hooks-version-pinned.test.ts | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4b71b7e3b0..31a73d5464 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,7 +132,7 @@ "esbuild": "0.28.2", "eslint": "^10.9.0", "eslint-config-next": "16.3.3", - "eslint-plugin-react-hooks": "7.0.1", + "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-sonarjs": "^4.1.0", "espree": "^11.2.0", "fast-check": "^4.8.0", @@ -20913,9 +20913,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", "dependencies": { @@ -20929,7 +20929,7 @@ "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-react/node_modules/resolve": { diff --git a/package.json b/package.json index 41c9a125b2..4e9f576908 100644 --- a/package.json +++ b/package.json @@ -396,7 +396,7 @@ "esbuild": "0.28.2", "eslint": "^10.9.0", "eslint-config-next": "16.3.3", - "eslint-plugin-react-hooks": "7.0.1", + "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-sonarjs": "^4.1.0", "espree": "^11.2.0", "fast-check": "^4.8.0", @@ -455,7 +455,7 @@ }, "overrides": { "onnxruntime-node": "1.24.3", - "eslint-plugin-react-hooks": "7.0.1", + "eslint-plugin-react-hooks": "7.1.1", "fast-xml-parser": "^5.10.1", "sharp": "^0.35.4", "postcss": "^8.5.18", diff --git a/tests/unit/eslint-react-hooks-version-pinned.test.ts b/tests/unit/eslint-react-hooks-version-pinned.test.ts index eb2072bc1d..a076347f89 100644 --- a/tests/unit/eslint-react-hooks-version-pinned.test.ts +++ b/tests/unit/eslint-react-hooks-version-pinned.test.ts @@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url"; const ROOT = new URL("../../", import.meta.url); const PLUGIN = "eslint-plugin-react-hooks"; -const EXPECTED_VERSION = "7.0.1"; +const EXPECTED_VERSION = "7.1.1"; async function readJson(relative: string): Promise> { return JSON.parse(await readFile(fileURLToPath(new URL(relative, ROOT)), "utf8")); @@ -30,7 +30,7 @@ test("eslint-plugin-react-hooks is directly pinned to the locked version", async assert.equal( declared, EXPECTED_VERSION, - `${PLUGIN} must remain pinned to ${EXPECTED_VERSION} until the 7.1.1 lint migration` + `${PLUGIN} must remain pinned to ${EXPECTED_VERSION} so a group bump can never ride past the compiler-rules migration review` ); assert.ok(locked, `${PLUGIN} missing from package-lock.json`); assert.equal( From 6da2418247d75acd0af3a617c7289b004b5a86f5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 2 Sep 2026 07:11:38 -0300 Subject: [PATCH 34/34] chore(providers): remove a keyless provider integration at its operator's request (#12440) The service operator asked in writing (2026-08-30) that their service be removed from OmniRoute entirely: executor, registry entry, no-auth catalog entry and alias, icon mapping, env var, docs rows, dedicated tests and snapshots, and every passing mention in comments, fixtures and CHANGELOG entries. Provider count drops from 355 to 354 on every canonical surface. Co-authored-by: Markus Hartung --- .env.example | 5 - @omniroute/opencode-plugin/src/index.ts | 4 +- .../opencode-plugin/tests/combos.test.ts | 14 +- AGENTS.md | 2 +- CHANGELOG.md | 15 +- README.md | 6 +- config/quality/eslint-suppressions.json | 8 - config/quality/file-size-baseline.json | 2 +- config/quality/test-discovery-baseline.json | 3 +- config/release/changelog-reconciliations.json | 28 +- docs/diagrams/cli-terminal.svg | 2 +- docs/diagrams/comparison-table.svg | 2 +- docs/diagrams/promise-pillars.svg | 6 +- docs/diagrams/readme-hero.svg | 4 +- docs/i18n/ar/CHANGELOG.md | 6 +- docs/i18n/ar/llm.txt | 4 +- docs/i18n/az/CHANGELOG.md | 6 +- docs/i18n/az/llm.txt | 4 +- docs/i18n/bg/CHANGELOG.md | 6 +- docs/i18n/bg/llm.txt | 4 +- docs/i18n/bn/CHANGELOG.md | 6 +- docs/i18n/bn/llm.txt | 4 +- docs/i18n/cs/CHANGELOG.md | 6 +- docs/i18n/cs/llm.txt | 4 +- docs/i18n/da/CHANGELOG.md | 6 +- docs/i18n/da/llm.txt | 4 +- docs/i18n/de/CHANGELOG.md | 6 +- docs/i18n/de/llm.txt | 4 +- docs/i18n/es/CHANGELOG.md | 6 +- docs/i18n/es/llm.txt | 4 +- docs/i18n/fa/CHANGELOG.md | 6 +- docs/i18n/fa/llm.txt | 4 +- docs/i18n/fi/CHANGELOG.md | 6 +- docs/i18n/fi/llm.txt | 4 +- docs/i18n/fr/CHANGELOG.md | 6 +- docs/i18n/fr/llm.txt | 4 +- docs/i18n/gu/CHANGELOG.md | 6 +- docs/i18n/gu/llm.txt | 4 +- docs/i18n/he/CHANGELOG.md | 6 +- docs/i18n/he/llm.txt | 4 +- docs/i18n/hi/CHANGELOG.md | 6 +- docs/i18n/hi/llm.txt | 4 +- docs/i18n/hu/CHANGELOG.md | 6 +- docs/i18n/hu/llm.txt | 4 +- docs/i18n/id/CHANGELOG.md | 6 +- docs/i18n/id/llm.txt | 4 +- docs/i18n/in/CHANGELOG.md | 6 +- docs/i18n/in/llm.txt | 4 +- docs/i18n/it/CHANGELOG.md | 6 +- docs/i18n/it/llm.txt | 4 +- docs/i18n/ja/CHANGELOG.md | 6 +- docs/i18n/ja/llm.txt | 4 +- docs/i18n/ko/CHANGELOG.md | 6 +- docs/i18n/ko/llm.txt | 4 +- docs/i18n/mr/CHANGELOG.md | 6 +- docs/i18n/mr/llm.txt | 4 +- docs/i18n/ms/CHANGELOG.md | 6 +- docs/i18n/ms/llm.txt | 4 +- docs/i18n/nl/CHANGELOG.md | 6 +- docs/i18n/nl/llm.txt | 4 +- docs/i18n/no/CHANGELOG.md | 6 +- docs/i18n/no/llm.txt | 4 +- docs/i18n/phi/CHANGELOG.md | 6 +- docs/i18n/phi/llm.txt | 4 +- docs/i18n/pl/CHANGELOG.md | 15 +- docs/i18n/pl/llm.txt | 4 +- docs/i18n/pt-BR/CHANGELOG.md | 6 +- docs/i18n/pt-BR/llm.txt | 4 +- docs/i18n/pt/CHANGELOG.md | 6 +- docs/i18n/pt/llm.txt | 4 +- docs/i18n/ro/CHANGELOG.md | 6 +- docs/i18n/ro/llm.txt | 4 +- docs/i18n/ru/CHANGELOG.md | 6 +- docs/i18n/ru/llm.txt | 4 +- docs/i18n/sk/CHANGELOG.md | 6 +- docs/i18n/sk/llm.txt | 4 +- docs/i18n/sv/CHANGELOG.md | 6 +- docs/i18n/sv/llm.txt | 4 +- docs/i18n/sw/CHANGELOG.md | 6 +- docs/i18n/sw/llm.txt | 4 +- docs/i18n/ta/CHANGELOG.md | 6 +- docs/i18n/ta/llm.txt | 4 +- docs/i18n/te/CHANGELOG.md | 6 +- docs/i18n/te/llm.txt | 4 +- docs/i18n/th/CHANGELOG.md | 6 +- docs/i18n/th/llm.txt | 4 +- docs/i18n/tr/CHANGELOG.md | 6 +- docs/i18n/tr/llm.txt | 4 +- docs/i18n/uk-UA/CHANGELOG.md | 6 +- docs/i18n/uk-UA/llm.txt | 4 +- docs/i18n/ur/CHANGELOG.md | 6 +- docs/i18n/ur/llm.txt | 4 +- docs/i18n/vi/CHANGELOG.md | 6 +- docs/i18n/vi/llm.txt | 4 +- docs/i18n/zh-CN/CHANGELOG.md | 6 +- docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md | 1 - docs/i18n/zh-CN/llm.txt | 4 +- docs/i18n/zh-TW/CHANGELOG.md | 6 +- docs/i18n/zh-TW/llm.txt | 4 +- docs/reference/ENVIRONMENT.md | 1 - docs/reference/FREE_TIERS.md | 4 +- docs/reference/PROVIDER_REFERENCE.md | 7 +- llm.txt | 4 +- open-sse/config/providers/index.ts | 2 - .../providers/registry/theoldllm/index.ts | 51 -- open-sse/executors/index.ts | 2 - open-sse/executors/theoldllm.ts | 460 ------------------ open-sse/services/autoCombo/virtualFactory.ts | 4 +- open-sse/services/errorClassifier.ts | 2 +- package.json | 2 +- public/images/tier-flow-dark.svg | 6 +- public/images/tier-flow-light.svg | 6 +- .../hooks/useSyncedModelsByProvider.ts | 2 +- .../dashboard/providers/providerPageUtils.ts | 2 +- src/shared/components/ProviderIcon.tsx | 5 +- src/shared/constants/providers/noauth.ts | 18 +- src/shared/reasoning/effortStandardization.ts | 2 +- tests/integration/combo-matrix/auto.test.ts | 9 +- tests/integration/freeModelBenchmarkShared.ts | 4 +- tests/snapshots/executors/executor-map.json | 12 +- tests/snapshots/provider/translate-path.json | 23 - tests/theoldllm-stress.test.ts | 278 ----------- ...accountfallback-ratelimit-400-4976.test.ts | 4 +- ...cutor-buildheaders-extra-keys-8493.test.ts | 2 +- tests/unit/deepseek-native-max-effort.test.ts | 8 +- .../unit/discontinued-providers-2026.test.ts | 10 +- .../errorClassifier-noauth-403-6315.test.ts | 4 +- tests/unit/free-model-catalog.test.ts | 2 +- .../free-provider-onboarding-selector.test.ts | 2 - .../free-provider-onboarding-setup.test.ts | 18 +- ...-model-catalog-reconciliation-8926.test.ts | 1 - tests/unit/models-catalog-route.test.ts | 8 +- tests/unit/noauth-autocombo-allowlist.test.ts | 4 +- .../unit/noauth-imported-models-3200.test.ts | 18 +- tests/unit/noauth-provider-validation.test.ts | 13 +- .../provider-assets-generic-fallback.test.mjs | 7 +- ...der-model-filter-live-catalog-7250.test.ts | 2 +- .../provider-node-reserved-prefix.test.ts | 4 +- tests/unit/proxy-noauth-provider-6272.test.ts | 12 +- .../theoldllm-body-double-read-3296.test.ts | 53 -- .../theoldllm-context-length-4184.test.ts | 57 --- .../unit/theoldllm-model-refresh-5181.test.ts | 90 ---- tests/unit/theoldllm-provider-proxy.test.ts | 62 --- .../unit/theoldllm-request-token-3491.test.ts | 35 -- tests/unit/ui/ProviderIcon-icon-url.test.tsx | 5 +- tests/unit/virtual-auto-combo.test.ts | 2 +- 146 files changed, 260 insertions(+), 1600 deletions(-) delete mode 100644 open-sse/config/providers/registry/theoldllm/index.ts delete mode 100644 open-sse/executors/theoldllm.ts delete mode 100644 tests/theoldllm-stress.test.ts delete mode 100644 tests/unit/theoldllm-body-double-read-3296.test.ts delete mode 100644 tests/unit/theoldllm-context-length-4184.test.ts delete mode 100644 tests/unit/theoldllm-model-refresh-5181.test.ts delete mode 100644 tests/unit/theoldllm-provider-proxy.test.ts delete mode 100644 tests/unit/theoldllm-request-token-3491.test.ts diff --git a/.env.example b/.env.example index f1a333125c..c50a6517ad 100644 --- a/.env.example +++ b/.env.example @@ -1152,11 +1152,6 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann # Trae OAuth token override. Used by: open-sse/executors/trae.ts. # TRAE_TOKEN= -# ── The Old LLM (theoldllm) ── -# Playwright navigation timeout (ms) for the browser-backed token capture. -# Used by: open-sse/executors/theoldllm.ts. Default: 30000 (30s). -# THEOLDLLM_NAV_TIMEOUT_MS=30000 - # ── Gemini / Antigravity (Google-based) ── # These providers ship public OAuth client_id/secret values embedded in their # public CLIs. Defaults are baked into the code via diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 50768e9351..18545bfece 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -3477,7 +3477,7 @@ export function createOmniRouteProviderHook( // ── Combo LCD across nested combo-refs (T-NN) ─────────────────────── // Combos can nest other combos via `kind: "combo-ref"` members - // (e.g. MASTER-LIGHT contains OldLLM, KIRO, Opecode Zen FREE). The + // (e.g. MASTER-LIGHT contains LEGACY, KIRO, Opecode Zen FREE). The // nested combo's own `limit.context` is computed below in this same // loop, so we need a fixpoint iteration: if a combo-ref points at a // combo not yet processed, defer this combo and try again after the @@ -4495,7 +4495,7 @@ export function buildStaticProviderEntry( // ── Combo LCD across nested combo-refs (T-NN mirror) ───────────────── // Mirror of the dynamic-catalog fixpoint iteration: combos can nest // other combos via `kind: "combo-ref"` members (e.g. MASTER-LIGHT - // contains OldLLM, KIRO, Opecode Zen FREE). The nested combo's own + // contains LEGACY, KIRO, Opecode Zen FREE). The nested combo's own // capabilities and limits are computed in this same loop, so we need // a fixpoint pass: if a combo-ref points at a combo not yet processed, // defer this combo and try again after the sibling combos catch up. diff --git a/@omniroute/opencode-plugin/tests/combos.test.ts b/@omniroute/opencode-plugin/tests/combos.test.ts index ff209a9a67..ce2e2e3af5 100644 --- a/@omniroute/opencode-plugin/tests/combos.test.ts +++ b/@omniroute/opencode-plugin/tests/combos.test.ts @@ -641,13 +641,13 @@ test("models(): combos fetcher receives the resolved baseURL + apiKey", async () test("models(): nested combo-ref context is the min of nested + raw members", async () => { // Top-level combo MASTER-LIGHT has 1 raw model (claude-primary, 200k) - // and 2 combo-refs: OldLLM (8k member) and KIRO (32k member). The OLD + // and 2 combo-refs: LEGACY (8k member) and KIRO (32k member). The OLD // plugin would advertise 200k (only the raw model); the fix should // make it advertise 8k (the bottleneck across the member graph). const modelsFetcher = stubModelsFetcher([ MODEL_PRIMARY, { - id: "oldllm-member-1", + id: "legacy-member-1", context_length: 8_000, max_output_tokens: 4_000, capabilities: { @@ -677,9 +677,9 @@ test("models(): nested combo-ref context is the min of nested + raw members", as ]); const combosFetcher = stubCombosFetcher([ { - id: "oldllm", - name: "OldLLM", - models: [{ id: "s1", kind: "model", model: "oldllm-member-1", weight: 100 }], + id: "legacy", + name: "LEGACY", + models: [{ id: "s1", kind: "model", model: "legacy-member-1", weight: 100 }], }, { id: "kiro", @@ -691,7 +691,7 @@ test("models(): nested combo-ref context is the min of nested + raw members", as name: "MASTER-LIGHT", models: [ { id: "r1", kind: "model", model: "claude-primary", weight: 50 }, - { id: "r2", kind: "combo-ref", comboName: "OldLLM", weight: 25 }, + { id: "r2", kind: "combo-ref", comboName: "LEGACY", weight: 25 }, { id: "r3", kind: "combo-ref", comboName: "KIRO", weight: 25 }, ], }, @@ -706,6 +706,6 @@ test("models(): nested combo-ref context is the min of nested + raw members", as assert.equal( masterLight.limit.context, 8_000, - `expected 8_000 (OldLLM bottleneck), got ${masterLight.limit.context}` + `expected 8_000 (LEGACY bottleneck), got ${masterLight.limit.context}` ); }); diff --git a/AGENTS.md b/AGENTS.md index 6150cd28e4..448b94ceaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below. ## Project at a Glance -**OmniRoute** — unified AI proxy/router. One endpoint, 355 LLM providers, auto-fallback. +**OmniRoute** — unified AI proxy/router. One endpoint, 354 LLM providers, auto-fallback. | Layer | Location | Purpose | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7194f88251..d6fd097613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -716,7 +716,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -888,7 +887,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -3000,7 +2999,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3442,7 +3440,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **fix(cli):** `omniroute serve` no longer hangs silently on a readiness timeout ([#6321](https://github.com/diegosouzapw/OmniRoute/issues/6321)) — the child server's stdout was piped to `"ignore"` whenever `--log`/`OMNIROUTE_SHOW_LOG` wasn't set (the default), discarding any debug output, and `runWithSupervisor`'s `waitForServer(...).then((up) => { if (up) {...} })` had no `else` branch, so a boot that never became ready produced zero further output after "⏳ Starting server...". Stdout is now buffered alongside stderr (`ServerSupervisor.getRecentLog()`), and a timeout prints a clear diagnostic plus the buffered output instead of staying silent. Does not by itself explain why boot never completes on a given machine — see the issue for further reproduction. Regression guard: `tests/unit/cli-serve-readiness-timeout-6321.test.ts`. - **fix(pricing):** Pricing Sync dashboard no longer stuck on "Next Sync: Never" / "Synced Models: 0" ([#6325](https://github.com/diegosouzapw/OmniRoute/issues/6325)) — `pricingSync.ts` kept sync state (`lastSyncTime`, `lastSyncModelCount`) in module-level vars, but the background periodic sync (`instrumentation-node.ts`) and the dashboard status route (`/api/pricing/sync`) each import the module from separate Next.js standalone webpack chunks, giving each its own independent state; `getSyncStatus()` read the (empty) API-route instance's vars. Sync status is now additionally persisted to a new `pricing_sync_status` `key_value` namespace and `getSyncStatus()` falls back to it when the local module instance never ran a sync itself. Regression guard: `tests/unit/pricing-sync-cross-instance.test.ts`. - **fix(api):** stop spuriously 403-ing "Invalid request origin" on `POST /api/providers/health-autopilot/actions` for Docker/LAN dashboard requests ([#6277](https://github.com/diegosouzapw/OmniRoute/issues/6277)) — the route carried a duplicate per-route `validateBrowserMutationOrigin` check re-added by the v3.8.42 release squash after PR #5278 centralized origin enforcement in the authz pipeline; the pipeline strips `PEER_IP_HEADER` before forwarding, so the stale duplicate check could no longer resolve the LAN "direct-local-host" candidate and rejected legitimate same-origin LAN mutations (e.g. clicking "remove cooldown" when accessed via a LAN IP). Removed the duplicate check — origin validation is now solely enforced by the centralized pipeline check, which already handles this case correctly. Regression guard: `tests/unit/serial/provider-health-autopilot.test.ts`. -- **fix(resilience):** a bare, unrecognized `403` from a no-credential (`authType:"none"`) provider like mimocode or theoldllm no longer permanently bans the connection ([#6315](https://github.com/diegosouzapw/OmniRoute/issues/6315), [#6345](https://github.com/diegosouzapw/OmniRoute/issues/6345)) — `classifyProviderError()`'s 403 branch only exempted `apikey` providers from the terminal `FORBIDDEN` classification, so these free/stateless proxies (no real account/credential to revoke) fell through to `FORBIDDEN` on the first unmatched 403 and got `isActive:false, testStatus:"banned"` with no cooldown or retry. The exemption now also covers `authType:"none"` providers, returning `null` (recoverable) so the existing connection-cooldown/retry layer handles it. Regression guard: `tests/unit/errorClassifier-noauth-403-6315.test.ts`. +- **fix(resilience):** a bare, unrecognized `403` from a no-credential (`authType:"none"`) provider like mimocode no longer permanently bans the connection ([#6315](https://github.com/diegosouzapw/OmniRoute/issues/6315), [#6345](https://github.com/diegosouzapw/OmniRoute/issues/6345)) — `classifyProviderError()`'s 403 branch only exempted `apikey` providers from the terminal `FORBIDDEN` classification, so these free/stateless proxies (no real account/credential to revoke) fell through to `FORBIDDEN` on the first unmatched 403 and got `isActive:false, testStatus:"banned"` with no cooldown or retry. The exemption now also covers `authType:"none"` providers, returning `null` (recoverable) so the existing connection-cooldown/retry layer handles it. Regression guard: `tests/unit/errorClassifier-noauth-403-6315.test.ts`. - **fix(providers):** the **Auggie (Augment CLI)** executor no longer fails on Windows with `spawn EINVAL` ([#6304](https://github.com/diegosouzapw/OmniRoute/issues/6304)) — the global-npm install exposes `auggie` as a `.cmd` shim, which Node's `child_process.spawn` cannot launch on win32 without `shell: true`. Both spawn sites (streaming + the `auggie --version` test) now go through a shared `buildAuggieSpawnOptions()` that sets `shell: process.platform === "win32"`; the argv (built by `buildAuggieArgs()` with a registry-validated `model` and a trailing `--` end-of-options marker) is unchanged, so the argument-injection surface stays closed on non-Windows. Regression guard: `tests/unit/auggie-win32-spawn-6304.test.ts`. - **fix(api):** the dashboard **"Test model"** action is now a clean connection test ([#6240](https://github.com/diegosouzapw/OmniRoute/issues/6240)) — `modelTestRunner` sent its probe request without an explicit compression override, so whenever the operator's global `compression.enabled` flag was on the test call inherited compression (and any Output-Styles system prompt), polluting the result. The internal test requests now send `X-OmniRoute-Compression: off`, and `chatCore` honors an explicit `off` header even when `compression.enabled` is globally true. Regression guards: `tests/unit/model-test-runner-compression-off-6240.test.ts`, `tests/integration/test-model-compression-off-6240.test.ts`. - **fix(startup):** an update/restart could crash the whole server at boot with `TypeError: Cannot create property 'message' on string 'Database closed'`, masking the real failure and 500-ing every request until manually restarted ([#6560](https://github.com/diegosouzapw/OmniRoute/issues/6560), plausibly the root cause of #6594's post-upgrade 500) — `driverFactory.ts::preInitSqlJs()` cached its sql.js WASM adapter per file path in a `globalThis`-backed map for idempotency, but never checked whether the cached adapter had since been closed (e.g. by `gracefulShutdown`/`resetDbInstance` racing a reload); reusing that dead handle made the very next query throw sql.js's own bare string `"Database closed"` (not an `Error`) straight out of `instrumentation-node.ts`'s previously-unguarded `ensureDbInitialized()` call, and Next.js's internal `registerInstrumentation()` wrapper unconditionally does `err.message = ...` on whatever `register()` rejects with — assigning `.message` on a primitive string throws in strict mode, so the secondary `TypeError` is what actually crashed the process. Fixed in two parts: `preInitSqlJs()` now evicts a closed cached adapter and creates a fresh one instead of returning it; a new `ensureDbReadyForBoot()` wraps the DB-init call, normalizes any non-Error throw via `normalizeBootError()`, and retries once specifically for a transient "database closed" message (now succeeding against the fresh adapter) before re-throwing anything else as a real `Error`. Regression guard: `tests/unit/instrumentation-database-closed-6560.test.ts`. @@ -3998,7 +3996,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5644,7 +5641,6 @@ Thanks to everyone whose work landed in v3.8.43: - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) @@ -6304,7 +6300,6 @@ Thanks to everyone whose work landed in v3.8.43: - **fix(catalog):** Codex CLI model-catalog refresh no longer errors — `GET /v1/models` now returns a top-level `models: []` array for Codex clients (detected via the `originator` / `user-agent` = `codex_*` headers it sends on `GET /v1/models?client_version=...`), so `codex_models_manager` stops failing to decode the OpenAI-standard response and no longer logs `failed to refresh available models` on every startup. The array is intentionally empty: Codex replaces its built-in per-model agent prompt (`base_instructions`, ~21k chars) with whatever a populated entry carries for the selected model, so emitting our catalog would break Codex's agent behaviour — an empty list keeps Codex on its built-in model info (same inference as before, minus the error). Non-Codex OpenAI clients receive the unchanged `{object,data}` response. ([#3481](https://github.com/diegosouzapw/OmniRoute/pull/3481) — thanks @diegosouzapw) - **fix(provider):** Cursor's Responses-API-shaped bodies on `/chat/completions` are detected and handled — a body with `input` but no `messages` is now classified as `openai-responses` (instead of forcing `openai` and building from undefined `messages` → upstream 400); standard OpenAI clients are unaffected by the `messages===undefined` guard. ([#3490](https://github.com/diegosouzapw/OmniRoute/pull/3490) — thanks @borodulin) - **fix(sse):** numeric provider IDs normalized to strings across 4 more surfaces — extends #3427 to the Responses-API SSE passthrough (`response_id`/`item_id`/`call_id`), the buffered/flush path in `stream.ts`, the dedup-key builders, and `sseParser.ts`, preventing `undefined` lookups when IDs arrive as numbers. ([#3451](https://github.com/diegosouzapw/OmniRoute/pull/3451) — thanks @disafronov) -- **fix(theoldllm):** `X-Request-Token` generated server-side, dropping the Playwright dependency — replicates the site's client `rie()` token (djb2 hash + `oldllm-client-2026` seed + UA prefix + 8-hex `crypto.randomUUID` suffix) directly, so The Old LLM no longer needs a headless browser to mint tokens. ([#3491](https://github.com/diegosouzapw/OmniRoute/pull/3491) — thanks @borodulin / @diegosouzapw) - **fix(combo):** parallel pre-screen + circuit-breaker fast-exit for priority combos — provider profiles and model availability for all targets are pre-screened concurrently (max 5), and targets whose circuit breaker is OPEN are skipped immediately, reducing first-token latency on multi-target priority combos. ([#3169](https://github.com/diegosouzapw/OmniRoute/pull/3169) — thanks @pizzav-xyz) - **fix(authz):** URL-tokenized client endpoints (`/api/v1/vscode//...`) authenticate again when the caller sends its own non-OmniRoute `Authorization` header — a non-`Bearer ` header (e.g. VS Code Copilot's own, or an empty `Bearer `) no longer short-circuits auth; it falls through to the path-scoped URL token (still validated downstream), instead of 401'ing under `REQUIRE_API_KEY=true`. ([#3504](https://github.com/diegosouzapw/OmniRoute/pull/3504) — thanks @zhiru / @diegosouzapw) - **fix(playground):** the dashboard provider Test playground works under `REQUIRE_API_KEY=true` — it previously sent the **masked** key (`sk-xxxx****yyyy`) as a bearer (always invalid → 401). It now authenticates via the dashboard session and sends only the key **id** (`x-omniroute-playground-key-id`); the gateway resolves the secret server-side, honored **only** for an authenticated session and never putting the key secret on the wire. ([#3503](https://github.com/diegosouzapw/OmniRoute/pull/3503) — thanks @zhiru / @diegosouzapw) @@ -6337,7 +6332,7 @@ Thanks to everyone whose work landed in v3.8.43: - **fix(translator):** Vertex AI tool calls no longer fail with `400 Unknown name "id"` — the OpenAI-style `id` field is stripped from `functionCall`/`functionResponse` parts for `vertex`/`vertex-partner`; the public Gemini API still receives `id` as required for Gemini 3+ signature matching. ([#3457](https://github.com/diegosouzapw/OmniRoute/pull/3457) — thanks @nullbytef0x / @diegosouzapw) - **fix(claude):** Claude Code `claude-opus-4-8` tool calls no longer break with `tool call could not be parsed` — OmniRoute no longer force-injects `interleaved-thinking` / `advanced-tool-use` / `effort` beta flags the client never negotiated; clients sending their own `anthropic-beta` header control those betas themselves. ([#3458](https://github.com/diegosouzapw/OmniRoute/pull/3458) — thanks @Forcerecon / @diegosouzapw) -- **fix(catalog):** imported/custom models on no-auth providers (e.g. The Old LLM) now appear in `GET /api/v1/models` and the Playground model selector — the eligibility gate required a DB connection row which no-auth providers never have, silently dropping every imported model for them. ([#3463](https://github.com/diegosouzapw/OmniRoute/pull/3463) — thanks @tjengbudi / @diegosouzapw) +- **fix(catalog):** imported/custom models on no-auth providers now appear in `GET /api/v1/models` and the Playground model selector — the eligibility gate required a DB connection row which no-auth providers never have, silently dropping every imported model for them. ([#3463](https://github.com/diegosouzapw/OmniRoute/pull/3463) — thanks @tjengbudi / @diegosouzapw) - **fix(browser):** optional `cloakbrowser` import no longer causes bundle errors when the package is absent — the import is now wrapped in a dynamic require so the build succeeds on environments that don't install the optional dep. ([#3460](https://github.com/diegosouzapw/OmniRoute/pull/3460) — thanks @rdself) - **fix(claude-web):** claude-web session handling cleanup — corrects an edge case where session cookies were not properly refreshed after a Turnstile challenge, and removes stale wrapper code left over from the provider split. ([#3449](https://github.com/diegosouzapw/OmniRoute/pull/3449) — thanks @androw) - **fix(analytics):** SQL named params are now scoped per query context — a shared params object was being mutated across concurrent analytics queries, causing `SQLITE_MISUSE: named parameter not found` errors under load. ([#3447](https://github.com/diegosouzapw/OmniRoute/pull/3447) — thanks @ReqX) @@ -6513,8 +6508,7 @@ Thanks to everyone whose work landed in v3.8.14: - **fix(dashboard):** Agent Bridge page (`/dashboard/tools/agent-bridge`) no longer crashes with "Internal Server Error" — the page replaced its well-shaped state with the raw `/api/tools/agent-bridge/state` response (`{ server, agents }`), leaving `serverState` undefined and throwing `Cannot read properties of undefined (reading 'running')`. A shared `normalizeAgentBridgeState()` now maps the route shape into the page contract (incl. `server.certExists → certTrusted`) and always returns safe defaults, used by both the SSR loader and the polling hook. (#3318 — thanks @tycronk20) - **fix(codex):** strip client-only params (`prompt_cache_retention`, `safety_identifier`, `user`) on the native `codex/` `/v1/responses` passthrough — Codex upstream rejects them with `400 Unsupported parameter`, which broke Factory Droid and any client injecting those fields. The chat-completions path already stripped them; the responses→responses passthrough now does too. (#3317 — thanks @tycronk20) -- **fix(theoldllm):** stop the `[502]: Body is unusable: Body has already been read` error on the cached-token path — the executor read the same upstream `Response` body with `.text()` twice; it now reads it once and only re-reads after a token-rejection refetch. (#3296 — thanks @onizukashonan14-png) -- **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) visible under the "Show configured only" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 — thanks @uniQta) +- **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, veoaifree-web) visible under the "Show configured only" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 — thanks @uniQta) - **fix(dashboard):** refresh the connection list after a Codex/Claude/Gemini auth import — the import modals called `fetchData()` (which only reloads provider metadata), so a freshly-imported connection stayed invisible until a manual reload; they now call `fetchConnections()`. ([#3320](https://github.com/diegosouzapw/OmniRoute/pull/3320) — thanks @zhiru) - **fix(cli):** `omniroute update` no longer always fails on a global install — `getCurrentVersion()` and `createBackup()` now resolve `package.json`/`bin` relative to the script (`import.meta.url`) instead of `process.cwd()` (the user's working dir on a global npm/brew install → _"Could not determine current version"_), and the backup copies the `cli` directory with `cpSync({recursive:true})` instead of `copyFileSync`, which threw a swallowed `EISDIR` → _"Failed to create backup. Aborting"_. (#3295 — thanks @uniQta) - **fix(sse):** harden the passthrough stream against empty upstream responses — emit a synthetic retry chunk on an empty `choices: []` (fixes a Copilot Chat crash) and log empty post-`tool_calls` completions; also registers **MiniMax M3** (1M context) across 8 provider tiers. ([#3297](https://github.com/diegosouzapw/OmniRoute/pull/3297), #3110 — thanks @wilsonicdev) @@ -6606,7 +6600,6 @@ Thanks to everyone whose work landed in v3.8.12: ### ✨ New Features -- **theoldllm:** add The Old LLM — a free, Playwright-backed provider with dual-mode operation (cached browser token + direct fetch) bridged through a Vercel relay (#3217 — thanks @oyi77) - **codex:** add Codex login via OpenAI's browser-driven device authorization flow, exposed as a shareable "Adicionar Externo" public link (`/connect/codex/{token}`) so a third party can complete the OpenAI device login without dashboard access (#3195 — thanks @zhiru) - **proxy:** per-connection proxy distribution — `proxy_enabled` DB schema + Zod-validated resolution backend, automatic proxy-fallback selection when provider validation hits a network error, and a dashboard UI with per-connection toggles and a tag-filtered "Distribute Proxies" button (#3170, #3171, #3172 — thanks @pizzav-xyz) - **api:** `/v1/images/generations` and `/v1/images/edits` now resolve a bare combo/alias model name (e.g. `image`) to its single image target, and `/v1/images/edits` forwards multipart edits to custom OpenAI-compatible providers' `{base_url}/images/edits` (also accepting JSON/data-URL edit input) instead of rejecting everything but chatgpt-web (#3214, #3215 — thanks @ngocquynh85) diff --git a/README.md b/README.md index 8fe723c816..9e066fabf6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ # 🚀 OmniRoute — The Free AI Gateway -OmniRoute — Never stop coding. Every AI tool → 355 providers — 150+ 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. 355 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. +OmniRoute — Never stop coding. Every AI tool → 354 providers — 150+ 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. 354 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. @@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \ -The Promise — One endpoint and 355 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 355 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files. +The Promise — One endpoint and 354 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 354 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files.

@@ -463,7 +463,7 @@ All **19** strategies — mix & match per combo step: -What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 355 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 43 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. +What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 354 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 43 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. 📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index c7fd7e1b9a..90f949af47 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -2476,14 +2476,6 @@ "count": 2 } }, - "tests/theoldllm-stress.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 10 - }, - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "tests/translator/testFromFile.ts": { "@typescript-eslint/no-explicit-any": { "count": 3 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index aa55d4b6bf..394ab6ce0c 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -90,7 +90,7 @@ "_rebaseline_2026_06_20_reviewprs_mine_r2_filesize": "Reconciliacao file-size pos-lote /review-prs 'apenas minhas' r2: dois frozen cresceram cumulativamente sem bump (cada PR media OK na sua base, mas o crescimento empilhou acima do frozen no tip de merge; o fast-path do release nao roda check:file-size, so release->main). (1) src/shared/constants/pricing.ts 1620->1623 (+3 = linhas de pricing Claude Code (cc) do #4440, sobre o 1620 que o #4447 ja setara para gpt-4.1-mini/nano + o3/o4-mini). (2) open-sse/executors/base.ts 1399->1407 (+8 = handling granular de reasoning_effort para Claude no Copilot do #4443). Ambos dados/wiring coesos nos chokepoints existentes; nao extraiveis. Cobertos por tests/unit (claude-code pricing / base-executor-sanitize-effort + github-claude-reasoning-effort-granular).", "_rebaseline_2026_06_22_4647_opencode_go_deepseek": "PR #4647 (DevEstacion/opencode-go DeepSeek V4 Pro effort variants) review feedback: open-sse/executors/base.ts 1407->1414 (+7 = supportsMaxEffortForProvider now opt-ins opencode-go+deepseek so the literal 'max' effort survives the post-transformReasoningEffortForProvider pass — without this, max was silently rewritten to xhigh (OmniRoute's internal top tier) and the opencode-go upstream rejected it. The check is scoped to opencode-go deliberately to preserve the OpenRouter-DeepSeek inverse invariant (pi#4055, asserted by base-executor-sanitize-effort test:OpenRouter DeepSeek normalizes max -> xhigh). The +5 explanatory comment is required: a naive maintainer could otherwise broaden the check to all deepseek models and break the OpenRouter contract. Cohesive at the existing supportsMaxEffortForProvider chokepoint, next to the Claude/CC-compatible check; not extractable. Covered by tests/unit/base-executor-sanitize-effort.test.ts (3 new opencode-go deepseek cases).", "_rebaseline_2026_06_30_v3842_release_basetsl_5480": "v3.8.42 cycle-close file-size reconciliation: open-sse/executors/base.ts 1497->1500 (+3 net = #5480 'gate claude adaptive thinking defaults' — the adaptive-thinking injection is now gated behind the operator's thinking-budget config at the existing transform chokepoint, so default/passthrough no longer force-injects). Cohesive at the existing reasoning/thinking transform site; not extractable. The fast-path release gate (PR->release/**) does not run check:file-size, so this surfaced only on the release PR (PR->main). Covered by tests/unit/base-thinking-budget-config-5312.test.ts + the #5480 gate test.", - "_rebaseline_2026_06_20_4023_web_cookie_noauth_validation": "PR #4023 (oyi77) own growth: src/lib/providers/validation.ts 4450->4518 (+68 = a new validateWebCookieProvider that probes the provider's /models endpoint — 401/403 => AUTH_007 SESSION_EXPIRED, any other status => valid session, empty cookie => invalid, provider-not-in-registry => unsupported — plus a local STANDARD_USER_AGENT const for the probe). Cohesive validator at the validateProviderApiKey dispatch; not extractable. Covered by tests/unit/provider-validation-web-cookie-auth007.test.ts. Heavily curated on merge — the PR's branch was badly stale-based (squash-base-stale), so its tree was DESTRUCTIVE: providers/index.ts deleted live providers openadapter/dit/tokenrouter (added by #4313) and the executor/base.ts edits reverted release fixes (#4037 duckduckgo host, theoldllm gpt5 models, base.ts fetch-start-timeout). Only the purely-additive validation feature was kept (validation.ts validateWebCookieProvider + errorCodes AUTH_007 + the test). Dropped: 5 malformed new registry entries (used non-RegistryEntry fields defaultModel/auth + referenced non-existent executors -> tsc TS2353), the destructive providers/index.ts + executor reverts, the unrelated pr-*.sh automation scripts, and evals/types.ts (belongs to the deferred evals modularization #4422). Also removed the PR's fragile 'Phase 2' executor probe (ran a live upstream chat during validation + classified any 'auth'-containing error as SESSION_EXPIRED) and rewrote the test to install its fetch mock before module load (the original mocked too late and silently hit live chatgpt.com).", + "_rebaseline_2026_06_20_4023_web_cookie_noauth_validation": "PR #4023 (oyi77) own growth: src/lib/providers/validation.ts 4450->4518 (+68 = a new validateWebCookieProvider that probes the provider's /models endpoint — 401/403 => AUTH_007 SESSION_EXPIRED, any other status => valid session, empty cookie => invalid, provider-not-in-registry => unsupported — plus a local STANDARD_USER_AGENT const for the probe). Cohesive validator at the validateProviderApiKey dispatch; not extractable. Covered by tests/unit/provider-validation-web-cookie-auth007.test.ts. Heavily curated on merge — the PR's branch was badly stale-based (squash-base-stale), so its tree was DESTRUCTIVE: providers/index.ts deleted live providers openadapter/dit/tokenrouter (added by #4313) and the executor/base.ts edits reverted release fixes (#4037 duckduckgo host, no-auth gpt5 model aliases, base.ts fetch-start-timeout). Only the purely-additive validation feature was kept (validation.ts validateWebCookieProvider + errorCodes AUTH_007 + the test). Dropped: 5 malformed new registry entries (used non-RegistryEntry fields defaultModel/auth + referenced non-existent executors -> tsc TS2353), the destructive providers/index.ts + executor reverts, the unrelated pr-*.sh automation scripts, and evals/types.ts (belongs to the deferred evals modularization #4422). Also removed the PR's fragile 'Phase 2' executor probe (ran a live upstream chat during validation + classified any 'auth'-containing error as SESSION_EXPIRED) and rewrote the test to install its fetch mock before module load (the original mocked too late and silently hit live chatgpt.com).", "_rebaseline_2026_06_20_1308_model_lockout_honors_reset": "port from 9router#1308 own growth: open-sse/services/accountFallback.ts 1731->1752 (+21 = the new exported pure helper selectLockoutCooldownMs + its doc comment — picks the parsed upstream reset as the model-lockout exactCooldownMs when it exceeds the base cooldown, e.g. Antigravity \"Resets in 160h\", else preserves the existing 0/base behavior) and open-sse/executors/antigravity.ts 1680->1686 (this PR +1 = parseRetryFromErrorMessage regex `reset` -> `resets?` so plural \"Resets in 160h27m24s\" matches, plus a comment line; frozen set to the SUM 1686 with the concurrent #1944 which adds +5 at the disjoint passthroughFields region of the same file, so either merge order passes — pair-file rule). The combo lockout call sites in combo.ts now pass selectLockoutCooldownMs(cooldownMs, mlSettings) instead of always base/exponential, so an exhausted model honors the real upstream reset instead of being retried within minutes. Both edits are cohesive at the existing lockout/parse chokepoints; the helper is its own pure function (not extractable further). Covered by tests/unit/combo-model-lockout-honors-reset-1308.test.ts.", "_rebaseline_2026_06_20_1944_antigravity_strip_output_config": "port from 9router#1944: open-sse/executors/antigravity.ts frozen set to the measured cumulative 1687 of two concurrent PRs that touch disjoint regions of this file, so either merge order passes (pair-file rule). #1944 adds +6 at the envelope passthroughFields destructuring (~line 759: drop output_config/output_format — Anthropic/Claude-Code-only fields that Google's Cloud Code envelope rejects with `400 Unknown name \"output_config\"`, which broke every Claude model on Antigravity); #1308 adds +1 at parseRetryFromErrorMessage (~line 889: regex reset->resets?). Base 1680 + 6 + 1 = 1687 (re-measured on the real merge tip — the earlier 1686 estimate was off by one). Both edits are cohesive at their chokepoints; not extractable. Covered by tests/unit/antigravity-strip-output-config-1944.test.ts.", "_rebaseline_2026_06_22_779_copilot_agent_antigravity_parity": "port from 9router#779 (@lukmanfauzie): open-sse/executors/antigravity.ts 1696->1721 (+25 = MAX_ANTIGRAVITY_OUTPUT_TOKENS constant + doc + final cap branch inside applyAntigravityGenerationDefaults + test-only export). Hard-caps generationConfig.maxOutputTokens at 16384 so VS Code GitHub Copilot Chat in Agent mode (which routinely requests 32K–65K tokens) stops triggering Antigravity upstream HTTP 400 'Invalid Argument'. The remaining items in upstream #779 (recursive JSON-schema sanitization, sanitizeFunctionName, $comment/enumDescriptions, functionResponse name resolution, VALIDATED mode) are already covered by OmniRoute's existing geminiHelper/geminiToolsSanitizer/openai-to-gemini pipeline — the cap is the only delta missing here. Cohesive guard at the existing generation-defaults chokepoint; not extractable. Covered by tests/unit/copilot-agent-antigravity-parity.test.ts.", diff --git a/config/quality/test-discovery-baseline.json b/config/quality/test-discovery-baseline.json index f7a823ffed..0cbe39364a 100644 --- a/config/quality/test-discovery-baseline.json +++ b/config/quality/test-discovery-baseline.json @@ -10,7 +10,6 @@ "tests/integration/services/cliproxy-coexistence.test.ts", "tests/integration/services/full-lifecycle.int.test.ts", "tests/integration/services/route-guard-services.int.test.ts", - "tests/live/deepseek-web-live.test.ts", - "tests/theoldllm-stress.test.ts" + "tests/live/deepseek-web-live.test.ts" ] } diff --git a/config/release/changelog-reconciliations.json b/config/release/changelog-reconciliations.json index db944ff34b..aa4cd00691 100644 --- a/config/release/changelog-reconciliations.json +++ b/config/release/changelog-reconciliations.json @@ -1,4 +1,30 @@ { "schemaVersion": 1, - "reconciliations": [] + "reconciliations": [ + { + "id": "provider-takedown-2026-08-30", + "reason": "Operator of a third-party keyless service asked in writing (2026-08-30) that every reference to it be removed from OmniRoute, including release documentation. Bullets whose sole subject was that provider are dropped; bullets that mentioned it in passing are reworded without the name.", + "baseChangelogSha256": "dba84d68ba25f539575ec8679b62f084d88b302c7b596c55a28017b481265ad7", + "resultChangelogSha256": "6b3d4fe192d3694bbef4cb5ba0f4985a81e269a159041b5dd9f11fefb91c04d7", + "removedBullets": [ + "- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun", + "- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{\"effort\":\"max\"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White", + "- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn", + "- **fix(resilience):** a bare, unrecognized `403` from a no-credential (`authType:\"none\"`) provider like mimocode or theoldllm no longer permanently bans the connection ([#6315](https://github.com/diegosouzapw/OmniRoute/issues/6315), [#6345](https://github.com/diegosouzapw/OmniRoute/issues/6345)) — `classifyProviderError()`'s 403 branch only exempted `apikey` providers from the terminal `FORBIDDEN` classification, so these free/stateless proxies (no real account/credential to revoke) fell through to `FORBIDDEN` on the first unmatched 403 and got `isActive:false, testStatus:\"banned\"` with no cooldown or retry. The exemption now also covers `authType:\"none\"` providers, returning `null` (recoverable) so the existing connection-cooldown/retry layer handles it. Regression guard: `tests/unit/errorClassifier-noauth-403-6315.test.ts`.", + "- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs)", + "- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa)", + "- **fix(theoldllm):** `X-Request-Token` generated server-side, dropping the Playwright dependency — replicates the site's client `rie()` token (djb2 hash + `oldllm-client-2026` seed + UA prefix + 8-hex `crypto.randomUUID` suffix) directly, so The Old LLM no longer needs a headless browser to mint tokens. ([#3491](https://github.com/diegosouzapw/OmniRoute/pull/3491) — thanks @borodulin / @diegosouzapw)", + "- **fix(catalog):** imported/custom models on no-auth providers (e.g. The Old LLM) now appear in `GET /api/v1/models` and the Playground model selector — the eligibility gate required a DB connection row which no-auth providers never have, silently dropping every imported model for them. ([#3463](https://github.com/diegosouzapw/OmniRoute/pull/3463) — thanks @tjengbudi / @diegosouzapw)", + "- **fix(theoldllm):** stop the `[502]: Body is unusable: Body has already been read` error on the cached-token path — the executor read the same upstream `Response` body with `.text()` twice; it now reads it once and only re-reads after a token-rejection refetch. (#3296 — thanks @onizukashonan14-png)", + "- **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) visible under the \"Show configured only\" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === \"no-auth\"` as configured. (#3290 — thanks @uniQta)", + "- **theoldllm:** add The Old LLM — a free, Playwright-backed provider with dual-mode operation (cached browser token + direct fetch) bridged through a Vercel relay (#3217 — thanks @oyi77)" + ], + "addedBullets": [ + "- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{\"effort\":\"max\"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White", + "- **fix(resilience):** a bare, unrecognized `403` from a no-credential (`authType:\"none\"`) provider like mimocode no longer permanently bans the connection ([#6315](https://github.com/diegosouzapw/OmniRoute/issues/6315), [#6345](https://github.com/diegosouzapw/OmniRoute/issues/6345)) — `classifyProviderError()`'s 403 branch only exempted `apikey` providers from the terminal `FORBIDDEN` classification, so these free/stateless proxies (no real account/credential to revoke) fell through to `FORBIDDEN` on the first unmatched 403 and got `isActive:false, testStatus:\"banned\"` with no cooldown or retry. The exemption now also covers `authType:\"none\"` providers, returning `null` (recoverable) so the existing connection-cooldown/retry layer handles it. Regression guard: `tests/unit/errorClassifier-noauth-403-6315.test.ts`.", + "- **fix(catalog):** imported/custom models on no-auth providers now appear in `GET /api/v1/models` and the Playground model selector — the eligibility gate required a DB connection row which no-auth providers never have, silently dropping every imported model for them. ([#3463](https://github.com/diegosouzapw/OmniRoute/pull/3463) — thanks @tjengbudi / @diegosouzapw)", + "- **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, veoaifree-web) visible under the \"Show configured only\" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === \"no-auth\"` as configured. (#3290 — thanks @uniQta)" + ] + } + ] } diff --git a/docs/diagrams/cli-terminal.svg b/docs/diagrams/cli-terminal.svg index 139a868898..378802ba5e 100644 --- a/docs/diagrams/cli-terminal.svg +++ b/docs/diagrams/cli-terminal.svg @@ -1,4 +1,4 @@ - + Compact animated terminal cycling three real OmniRoute CLI commands with a typewriter effect and a scrolling subcommand ticker; the first frame shows the completed providers-list screen. diff --git a/docs/diagrams/comparison-table.svg b/docs/diagrams/comparison-table.svg index 271cd367d6..9175f71a3c 100644 --- a/docs/diagrams/comparison-table.svg +++ b/docs/diagrams/comparison-table.svg @@ -1,4 +1,4 @@ - + Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses. diff --git a/docs/diagrams/promise-pillars.svg b/docs/diagrams/promise-pillars.svg index f32198f62f..d2d15adc5d 100644 --- a/docs/diagrams/promise-pillars.svg +++ b/docs/diagrams/promise-pillars.svg @@ -1,4 +1,4 @@ - + Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle. @@ -21,7 +21,7 @@ - One endpoint. 355 providers. Never stop building — OmniRoute picks the cheapest one that works. + One endpoint. 354 providers. Never stop building — OmniRoute picks the cheapest one that works. @@ -38,7 +38,7 @@ Never hit limits - Auto-fallback across 355 providers in + Auto-fallback across 354 providers in milliseconds. Quota out? The next provider takes over while a healthy target remains. diff --git a/docs/diagrams/readme-hero.svg b/docs/diagrams/readme-hero.svg index b758959878..56c61abbb2 100644 --- a/docs/diagrams/readme-hero.svg +++ b/docs/diagrams/readme-hero.svg @@ -1,4 +1,4 @@ - + Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame. @@ -28,7 +28,7 @@ Never stop coding. - Every AI tool → 355 providers150+ free — through one endpoint. + Every AI tool → 354 providers150+ free — through one endpoint. Claude Code · Codex · Cursor · Cline · Copilot · Antigravity  →  FREE Claude / GPT / Gemini · auto-fallback diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index c59144d2b0..f17dbe2550 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index 20123a9d69..f60971ac88 100644 --- a/docs/i18n/ar/llm.txt +++ b/docs/i18n/ar/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 7ad36fcb60..e92b9fd771 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 41f114138f..a23c8d2e88 100644 --- a/docs/i18n/az/llm.txt +++ b/docs/i18n/az/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 7ad36fcb60..e92b9fd771 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index 41f114138f..a23c8d2e88 100644 --- a/docs/i18n/bg/llm.txt +++ b/docs/i18n/bg/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 58737c2286..de7709da22 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index 11ea0f8513..23f59a997f 100644 --- a/docs/i18n/bn/llm.txt +++ b/docs/i18n/bn/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index 6e71ca2c15..1515825be0 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index ed922553d6..93f797b5c7 100644 --- a/docs/i18n/cs/llm.txt +++ b/docs/i18n/cs/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 2abb58a0ba..d7dea7dcfe 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index 51e98c8dbe..adca490bfa 100644 --- a/docs/i18n/da/llm.txt +++ b/docs/i18n/da/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index ad84d882e6..370e602fb5 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index 949de1e465..6de9f9f29c 100644 --- a/docs/i18n/de/llm.txt +++ b/docs/i18n/de/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index fa51687550..a0f75543a0 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index bf98130ebe..9da2f1955a 100644 --- a/docs/i18n/es/llm.txt +++ b/docs/i18n/es/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 8dd39f925c..778bcb8523 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index 487087c5fe..30b373904f 100644 --- a/docs/i18n/fa/llm.txt +++ b/docs/i18n/fa/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index 38df0b1b79..32724b71c4 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index 29535373bb..fd888ae1d4 100644 --- a/docs/i18n/fi/llm.txt +++ b/docs/i18n/fi/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 1749bfd051..8008f46891 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index d25a9f0a08..3ee5208dd4 100644 --- a/docs/i18n/fr/llm.txt +++ b/docs/i18n/fr/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index fad70705d6..a3945bfee3 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index db1b62755f..51f95407a6 100644 --- a/docs/i18n/gu/llm.txt +++ b/docs/i18n/gu/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index ae5f7331c7..9025c8ae3a 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index 67f152fb90..362149c878 100644 --- a/docs/i18n/he/llm.txt +++ b/docs/i18n/he/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 238335d06a..238af74037 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index 25e1a61464..0169a7a933 100644 --- a/docs/i18n/hi/llm.txt +++ b/docs/i18n/hi/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 4dbee1cbda..13513d034e 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index 9d3622b254..8d1f4368d7 100644 --- a/docs/i18n/hu/llm.txt +++ b/docs/i18n/hu/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 503237146c..998788ed72 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index f7dd30f547..2bb388cec9 100644 --- a/docs/i18n/id/llm.txt +++ b/docs/i18n/id/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index d864c07e8a..625878d46d 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/in/llm.txt b/docs/i18n/in/llm.txt index 224371a5b4..c73a3fb3c5 100644 --- a/docs/i18n/in/llm.txt +++ b/docs/i18n/in/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index d79372ffad..942ca3b8bd 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index 83fe17538d..b035d1e2d9 100644 --- a/docs/i18n/it/llm.txt +++ b/docs/i18n/it/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 910ea80ad1..528d9d1b17 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index d2e467bae3..1bfd416c4d 100644 --- a/docs/i18n/ja/llm.txt +++ b/docs/i18n/ja/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 7da105a72b..ee4bdc729a 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index 604d91e5c8..f06ee25deb 100644 --- a/docs/i18n/ko/llm.txt +++ b/docs/i18n/ko/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 71568ba540..acb278bbc1 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index 3951cd5b2f..af434b2a63 100644 --- a/docs/i18n/mr/llm.txt +++ b/docs/i18n/mr/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 9e155d04a6..c6b6389bdc 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index 803c5a5456..3137cb7f3c 100644 --- a/docs/i18n/ms/llm.txt +++ b/docs/i18n/ms/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 915b15a1ce..c4d7411ecb 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index 6136d36574..46a877840a 100644 --- a/docs/i18n/nl/llm.txt +++ b/docs/i18n/nl/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index c28298b63e..5f985ffd33 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index 89204a67d2..9198500788 100644 --- a/docs/i18n/no/llm.txt +++ b/docs/i18n/no/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index a5b8340dc8..192df7b879 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index e2117f53b6..578210cd06 100644 --- a/docs/i18n/phi/llm.txt +++ b/docs/i18n/phi/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 681d8592b3..ba3a33f56a 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -635,7 +635,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -807,7 +806,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2918,7 +2917,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3364,7 +3362,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **fix(cli):** `omniroute serve` no longer hangs silently on a readiness timeout ([#6321](https://github.com/diegosouzapw/OmniRoute/issues/6321)) — the child server's stdout was piped to `"ignore"` whenever `--log`/`OMNIROUTE_SHOW_LOG` wasn't set (the default), discarding any debug output, and `runWithSupervisor`'s `waitForServer(...).then((up) => { if (up) {...} })` had no `else` branch, so a boot that never became ready produced zero further output after "⏳ Starting server...". Stdout is now buffered alongside stderr (`ServerSupervisor.getRecentLog()`), and a timeout prints a clear diagnostic plus the buffered output instead of staying silent. Does not by itself explain why boot never completes on a given machine — see the issue for further reproduction. Regression guard: `tests/unit/cli-serve-readiness-timeout-6321.test.ts`. - **fix(pricing):** Pricing Sync dashboard no longer stuck on "Next Sync: Never" / "Synced Models: 0" ([#6325](https://github.com/diegosouzapw/OmniRoute/issues/6325)) — `pricingSync.ts` kept sync state (`lastSyncTime`, `lastSyncModelCount`) in module-level vars, but the background periodic sync (`instrumentation-node.ts`) and the dashboard status route (`/api/pricing/sync`) each import the module from separate Next.js standalone webpack chunks, giving each its own independent state; `getSyncStatus()` read the (empty) API-route instance's vars. Sync status is now additionally persisted to a new `pricing_sync_status` `key_value` namespace and `getSyncStatus()` falls back to it when the local module instance never ran a sync itself. Regression guard: `tests/unit/pricing-sync-cross-instance.test.ts`. - **fix(api):** stop spuriously 403-ing "Invalid request origin" on `POST /api/providers/health-autopilot/actions` for Docker/LAN dashboard requests ([#6277](https://github.com/diegosouzapw/OmniRoute/issues/6277)) — the route carried a duplicate per-route `validateBrowserMutationOrigin` check re-added by the v3.8.42 release squash after PR #5278 centralized origin enforcement in the authz pipeline; the pipeline strips `PEER_IP_HEADER` before forwarding, so the stale duplicate check could no longer resolve the LAN "direct-local-host" candidate and rejected legitimate same-origin LAN mutations (e.g. clicking "remove cooldown" when accessed via a LAN IP). Removed the duplicate check — origin validation is now solely enforced by the centralized pipeline check, which already handles this case correctly. Regression guard: `tests/unit/serial/provider-health-autopilot.test.ts`. -- **fix(resilience):** a bare, unrecognized `403` from a no-credential (`authType:"none"`) provider like mimocode or theoldllm no longer permanently bans the connection ([#6315](https://github.com/diegosouzapw/OmniRoute/issues/6315), [#6345](https://github.com/diegosouzapw/OmniRoute/issues/6345)) — `classifyProviderError()`'s 403 branch only exempted `apikey` providers from the terminal `FORBIDDEN` classification, so these free/stateless proxies (no real account/credential to revoke) fell through to `FORBIDDEN` on the first unmatched 403 and got `isActive:false, testStatus:"banned"` with no cooldown or retry. The exemption now also covers `authType:"none"` providers, returning `null` (recoverable) so the existing connection-cooldown/retry layer handles it. Regression guard: `tests/unit/errorClassifier-noauth-403-6315.test.ts`. +- **fix(resilience):** a bare, unrecognized `403` from a no-credential (`authType:"none"`) provider like mimocode no longer permanently bans the connection ([#6315](https://github.com/diegosouzapw/OmniRoute/issues/6315), [#6345](https://github.com/diegosouzapw/OmniRoute/issues/6345)) — `classifyProviderError()`'s 403 branch only exempted `apikey` providers from the terminal `FORBIDDEN` classification, so these free/stateless proxies (no real account/credential to revoke) fell through to `FORBIDDEN` on the first unmatched 403 and got `isActive:false, testStatus:"banned"` with no cooldown or retry. The exemption now also covers `authType:"none"` providers, returning `null` (recoverable) so the existing connection-cooldown/retry layer handles it. Regression guard: `tests/unit/errorClassifier-noauth-403-6315.test.ts`. - **fix(providers):** the **Auggie (Augment CLI)** executor no longer fails on Windows with `spawn EINVAL` ([#6304](https://github.com/diegosouzapw/OmniRoute/issues/6304)) — the global-npm install exposes `auggie` as a `.cmd` shim, which Node's `child_process.spawn` cannot launch on win32 without `shell: true`. Both spawn sites (streaming + the `auggie --version` test) now go through a shared `buildAuggieSpawnOptions()` that sets `shell: process.platform === "win32"`; the argv (built by `buildAuggieArgs()` with a registry-validated `model` and a trailing `--` end-of-options marker) is unchanged, so the argument-injection surface stays closed on non-Windows. Regression guard: `tests/unit/auggie-win32-spawn-6304.test.ts`. - **fix(api):** the dashboard **"Test model"** action is now a clean connection test ([#6240](https://github.com/diegosouzapw/OmniRoute/issues/6240)) — `modelTestRunner` sent its probe request without an explicit compression override, so whenever the operator's global `compression.enabled` flag was on the test call inherited compression (and any Output-Styles system prompt), polluting the result. The internal test requests now send `X-OmniRoute-Compression: off`, and `chatCore` honors an explicit `off` header even when `compression.enabled` is globally true. Regression guards: `tests/unit/model-test-runner-compression-off-6240.test.ts`, `tests/integration/test-model-compression-off-6240.test.ts`. - **fix(startup):** an update/restart could crash the whole server at boot with `TypeError: Cannot create property 'message' on string 'Database closed'`, masking the real failure and 500-ing every request until manually restarted ([#6560](https://github.com/diegosouzapw/OmniRoute/issues/6560), plausibly the root cause of #6594's post-upgrade 500) — `driverFactory.ts::preInitSqlJs()` cached its sql.js WASM adapter per file path in a `globalThis`-backed map for idempotency, but never checked whether the cached adapter had since been closed (e.g. by `gracefulShutdown`/`resetDbInstance` racing a reload); reusing that dead handle made the very next query throw sql.js's own bare string `"Database closed"` (not an `Error`) straight out of `instrumentation-node.ts`'s previously-unguarded `ensureDbInitialized()` call, and Next.js's internal `registerInstrumentation()` wrapper unconditionally does `err.message = ...` on whatever `register()` rejects with — assigning `.message` on a primitive string throws in strict mode, so the secondary `TypeError` is what actually crashed the process. Fixed in two parts: `preInitSqlJs()` now evicts a closed cached adapter and creates a fresh one instead of returning it; a new `ensureDbReadyForBoot()` wraps the DB-init call, normalizes any non-Error throw via `normalizeBootError()`, and retries once specifically for a transient "database closed" message (now succeeding against the fresh adapter) before re-throwing anything else as a real `Error`. Regression guard: `tests/unit/instrumentation-database-closed-6560.test.ts`. @@ -3920,7 +3918,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5566,7 +5563,6 @@ Thanks to everyone whose work landed in v3.8.43: - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) @@ -6226,7 +6222,6 @@ Thanks to everyone whose work landed in v3.8.43: - **fix(catalog):** Codex CLI model-catalog refresh no longer errors — `GET /v1/models` now returns a top-level `models: []` array for Codex clients (detected via the `originator` / `user-agent` = `codex_*` headers it sends on `GET /v1/models?client_version=...`), so `codex_models_manager` stops failing to decode the OpenAI-standard response and no longer logs `failed to refresh available models` on every startup. The array is intentionally empty: Codex replaces its built-in per-model agent prompt (`base_instructions`, ~21k chars) with whatever a populated entry carries for the selected model, so emitting our catalog would break Codex's agent behaviour — an empty list keeps Codex on its built-in model info (same inference as before, minus the error). Non-Codex OpenAI clients receive the unchanged `{object,data}` response. ([#3481](https://github.com/diegosouzapw/OmniRoute/pull/3481) — thanks @diegosouzapw) - **fix(provider):** Cursor's Responses-API-shaped bodies on `/chat/completions` are detected and handled — a body with `input` but no `messages` is now classified as `openai-responses` (instead of forcing `openai` and building from undefined `messages` → upstream 400); standard OpenAI clients are unaffected by the `messages===undefined` guard. ([#3490](https://github.com/diegosouzapw/OmniRoute/pull/3490) — thanks @borodulin) - **fix(sse):** numeric provider IDs normalized to strings across 4 more surfaces — extends #3427 to the Responses-API SSE passthrough (`response_id`/`item_id`/`call_id`), the buffered/flush path in `stream.ts`, the dedup-key builders, and `sseParser.ts`, preventing `undefined` lookups when IDs arrive as numbers. ([#3451](https://github.com/diegosouzapw/OmniRoute/pull/3451) — thanks @disafronov) -- **fix(theoldllm):** `X-Request-Token` generated server-side, dropping the Playwright dependency — replicates the site's client `rie()` token (djb2 hash + `oldllm-client-2026` seed + UA prefix + 8-hex `crypto.randomUUID` suffix) directly, so The Old LLM no longer needs a headless browser to mint tokens. ([#3491](https://github.com/diegosouzapw/OmniRoute/pull/3491) — thanks @borodulin / @diegosouzapw) - **fix(combo):** parallel pre-screen + circuit-breaker fast-exit for priority combos — provider profiles and model availability for all targets are pre-screened concurrently (max 5), and targets whose circuit breaker is OPEN are skipped immediately, reducing first-token latency on multi-target priority combos. ([#3169](https://github.com/diegosouzapw/OmniRoute/pull/3169) — thanks @pizzav-xyz) - **fix(authz):** URL-tokenized client endpoints (`/api/v1/vscode//...`) authenticate again when the caller sends its own non-OmniRoute `Authorization` header — a non-`Bearer ` header (e.g. VS Code Copilot's own, or an empty `Bearer `) no longer short-circuits auth; it falls through to the path-scoped URL token (still validated downstream), instead of 401'ing under `REQUIRE_API_KEY=true`. ([#3504](https://github.com/diegosouzapw/OmniRoute/pull/3504) — thanks @zhiru / @diegosouzapw) - **fix(playground):** the dashboard provider Test playground works under `REQUIRE_API_KEY=true` — it previously sent the **masked** key (`sk-xxxx****yyyy`) as a bearer (always invalid → 401). It now authenticates via the dashboard session and sends only the key **id** (`x-omniroute-playground-key-id`); the gateway resolves the secret server-side, honored **only** for an authenticated session and never putting the key secret on the wire. ([#3503](https://github.com/diegosouzapw/OmniRoute/pull/3503) — thanks @zhiru / @diegosouzapw) @@ -6259,7 +6254,7 @@ Thanks to everyone whose work landed in v3.8.43: - **fix(translator):** Vertex AI tool calls no longer fail with `400 Unknown name "id"` — the OpenAI-style `id` field is stripped from `functionCall`/`functionResponse` parts for `vertex`/`vertex-partner`; the public Gemini API still receives `id` as required for Gemini 3+ signature matching. ([#3457](https://github.com/diegosouzapw/OmniRoute/pull/3457) — thanks @nullbytef0x / @diegosouzapw) - **fix(claude):** Claude Code `claude-opus-4-8` tool calls no longer break with `tool call could not be parsed` — OmniRoute no longer force-injects `interleaved-thinking` / `advanced-tool-use` / `effort` beta flags the client never negotiated; clients sending their own `anthropic-beta` header control those betas themselves. ([#3458](https://github.com/diegosouzapw/OmniRoute/pull/3458) — thanks @Forcerecon / @diegosouzapw) -- **fix(catalog):** imported/custom models on no-auth providers (e.g. The Old LLM) now appear in `GET /api/v1/models` and the Playground model selector — the eligibility gate required a DB connection row which no-auth providers never have, silently dropping every imported model for them. ([#3463](https://github.com/diegosouzapw/OmniRoute/pull/3463) — thanks @tjengbudi / @diegosouzapw) +- **fix(catalog):** imported/custom models on no-auth providers now appear in `GET /api/v1/models` and the Playground model selector — the eligibility gate required a DB connection row which no-auth providers never have, silently dropping every imported model for them. ([#3463](https://github.com/diegosouzapw/OmniRoute/pull/3463) — thanks @tjengbudi / @diegosouzapw) - **fix(browser):** optional `cloakbrowser` import no longer causes bundle errors when the package is absent — the import is now wrapped in a dynamic require so the build succeeds on environments that don't install the optional dep. ([#3460](https://github.com/diegosouzapw/OmniRoute/pull/3460) — thanks @rdself) - **fix(claude-web):** claude-web session handling cleanup — corrects an edge case where session cookies were not properly refreshed after a Turnstile challenge, and removes stale wrapper code left over from the provider split. ([#3449](https://github.com/diegosouzapw/OmniRoute/pull/3449) — thanks @androw) - **fix(analytics):** SQL named params are now scoped per query context — a shared params object was being mutated across concurrent analytics queries, causing `SQLITE_MISUSE: named parameter not found` errors under load. ([#3447](https://github.com/diegosouzapw/OmniRoute/pull/3447) — thanks @ReqX) @@ -6435,8 +6430,7 @@ Thanks to everyone whose work landed in v3.8.14: - **fix(dashboard):** Agent Bridge page (`/dashboard/tools/agent-bridge`) no longer crashes with "Internal Server Error" — the page replaced its well-shaped state with the raw `/api/tools/agent-bridge/state` response (`{ server, agents }`), leaving `serverState` undefined and throwing `Cannot read properties of undefined (reading 'running')`. A shared `normalizeAgentBridgeState()` now maps the route shape into the page contract (incl. `server.certExists → certTrusted`) and always returns safe defaults, used by both the SSR loader and the polling hook. (#3318 — thanks @tycronk20) - **fix(codex):** strip client-only params (`prompt_cache_retention`, `safety_identifier`, `user`) on the native `codex/` `/v1/responses` passthrough — Codex upstream rejects them with `400 Unsupported parameter`, which broke Factory Droid and any client injecting those fields. The chat-completions path already stripped them; the responses→responses passthrough now does too. (#3317 — thanks @tycronk20) -- **fix(theoldllm):** stop the `[502]: Body is unusable: Body has already been read` error on the cached-token path — the executor read the same upstream `Response` body with `.text()` twice; it now reads it once and only re-reads after a token-rejection refetch. (#3296 — thanks @onizukashonan14-png) -- **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) visible under the "Show configured only" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 — thanks @uniQta) +- **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, veoaifree-web) visible under the "Show configured only" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 — thanks @uniQta) - **fix(dashboard):** refresh the connection list after a Codex/Claude/Gemini auth import — the import modals called `fetchData()` (which only reloads provider metadata), so a freshly-imported connection stayed invisible until a manual reload; they now call `fetchConnections()`. ([#3320](https://github.com/diegosouzapw/OmniRoute/pull/3320) — thanks @zhiru) - **fix(cli):** `omniroute update` no longer always fails on a global install — `getCurrentVersion()` and `createBackup()` now resolve `package.json`/`bin` relative to the script (`import.meta.url`) instead of `process.cwd()` (the user's working dir on a global npm/brew install → _"Could not determine current version"_), and the backup copies the `cli` directory with `cpSync({recursive:true})` instead of `copyFileSync`, which threw a swallowed `EISDIR` → _"Failed to create backup. Aborting"_. (#3295 — thanks @uniQta) - **fix(sse):** harden the passthrough stream against empty upstream responses — emit a synthetic retry chunk on an empty `choices: []` (fixes a Copilot Chat crash) and log empty post-`tool_calls` completions; also registers **MiniMax M3** (1M context) across 8 provider tiers. ([#3297](https://github.com/diegosouzapw/OmniRoute/pull/3297), #3110 — thanks @wilsonicdev) @@ -6528,7 +6522,6 @@ Thanks to everyone whose work landed in v3.8.12: ### ✨ New Features -- **theoldllm:** add The Old LLM — a free, Playwright-backed provider with dual-mode operation (cached browser token + direct fetch) bridged through a Vercel relay (#3217 — thanks @oyi77) - **codex:** add Codex login via OpenAI's browser-driven device authorization flow, exposed as a shareable "Adicionar Externo" public link (`/connect/codex/{token}`) so a third party can complete the OpenAI device login without dashboard access (#3195 — thanks @zhiru) - **proxy:** per-connection proxy distribution — `proxy_enabled` DB schema + Zod-validated resolution backend, automatic proxy-fallback selection when provider validation hits a network error, and a dashboard UI with per-connection toggles and a tag-filtered "Distribute Proxies" button (#3170, #3171, #3172 — thanks @pizzav-xyz) - **api:** `/v1/images/generations` and `/v1/images/edits` now resolve a bare combo/alias model name (e.g. `image`) to its single image target, and `/v1/images/edits` forwards multipart edits to custom OpenAI-compatible providers' `{base_url}/images/edits` (also accepting JSON/data-URL edit input) instead of rejecting everything but chatgpt-web (#3214, #3215 — thanks @ngocquynh85) diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index 59e3b55802..9b595d5f10 100644 --- a/docs/i18n/pl/llm.txt +++ b/docs/i18n/pl/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 69ed3b7b68..bdb213ba85 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index 050bccae37..a305b0c458 100644 --- a/docs/i18n/pt-BR/llm.txt +++ b/docs/i18n/pt-BR/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 9982ac21b9..6353cf070a 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index ba0ac3b997..5ae8bb20a6 100644 --- a/docs/i18n/pt/llm.txt +++ b/docs/i18n/pt/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index 7e17dc4c3d..3f4f687e8a 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index 488f319a24..4f4e75843c 100644 --- a/docs/i18n/ro/llm.txt +++ b/docs/i18n/ro/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 4d0ffb1a36..0ea757558f 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index 55a640091a..2b520bd2c5 100644 --- a/docs/i18n/ru/llm.txt +++ b/docs/i18n/ru/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 74d32cad91..4748610097 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index 3d9cb70999..f09bc46cdb 100644 --- a/docs/i18n/sk/llm.txt +++ b/docs/i18n/sk/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index f5214f5139..d2775bc43d 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index b8c9565ca9..8c291ccf2a 100644 --- a/docs/i18n/sv/llm.txt +++ b/docs/i18n/sv/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 28800d4766..1f29a6aea1 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index 22f4341a71..d61a0d82f9 100644 --- a/docs/i18n/sw/llm.txt +++ b/docs/i18n/sw/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index abc40d25f6..26f1ae1455 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index b2e0455d6c..607fac3fa6 100644 --- a/docs/i18n/ta/llm.txt +++ b/docs/i18n/ta/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 615fb47a5f..3fc1710418 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index 535e6bcd18..d84c553830 100644 --- a/docs/i18n/te/llm.txt +++ b/docs/i18n/te/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 746b8f698c..0ad98f3814 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index 57ddded05e..4c442856de 100644 --- a/docs/i18n/th/llm.txt +++ b/docs/i18n/th/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index ec42bc4163..e8b198cac7 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index f3bffa57f1..3dc1fa6816 100644 --- a/docs/i18n/tr/llm.txt +++ b/docs/i18n/tr/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 1d71eeeefe..0d1e4c6de1 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index d9e88525eb..8f8ce9daa9 100644 --- a/docs/i18n/uk-UA/llm.txt +++ b/docs/i18n/uk-UA/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index f76325603a..51576d6a0d 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index a5a158fe3e..08a8d5be55 100644 --- a/docs/i18n/ur/llm.txt +++ b/docs/i18n/ur/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 024b481edd..7c3caa062a 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index e20235b199..1d6877ea00 100644 --- a/docs/i18n/vi/llm.txt +++ b/docs/i18n/vi/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 9bd9f74c1a..41f4a6d1e6 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): 将 `reasoning_effort` 映射到 DeepSeek V4 的原生 `{high, max}` 词汇** — DeepSeek V4 仅理解 `high`/`max` 推理级别,因此其他 `reasoning_effort` 值被映射到其原生词汇而非被拒绝。([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): 为 GLM-5.2+ 思考设置默认 `max_tokens` 和延长超时** — GLM-5.2+ 思考响应较慢且需要余量,因此 OmniRoute 现在为其设置合理的默认 `max_tokens` 和更长的超时。([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — 感谢 @dhaern) - **fix(antigravity): 现代 Gemini 模型的默认 `includeThoughts`** — Antigravity 路径上的现代 Gemini 模型现在默认包含思考,使推理不会被静默丢弃。([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — 感谢 @dhaern) -- **fix(provider-registry): 为 theoldllm 模型添加正确的 `contextLength`** — 为 theoldllm 的模型填入准确的上下文窗口大小。([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — 感谢 @herjarsa) - **fix(models): 暴露组合模型令牌限制** — `/v1/models` 现在报告组合模型的令牌限制。([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — 感谢 @megamen32) - **fix(combo): 保持透传配额容灾的范围限制** — 防止透传配额容灾泄漏到无关目标。([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — 感谢 @Svetznaniy33) - **fix(combo): 将主动容灾压缩纳入 TV1 逃生机制(无静默目标丢弃)** — 主动容灾压缩现在参与 TV1 逃生机制,确保目标永不静默丢弃。([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md index 9c20862123..cb40139435 100644 --- a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md @@ -264,7 +264,6 @@ OmniRoute 提供两层防护:请求侧的注入扫描和响应侧的 PII 脱 | `NEXT_PUBLIC_APP_URL` | _(未设置)_ | `src/shared/services/cloudSyncScheduler.ts` | `NEXT_PUBLIC_BASE_URL` 的旧版回退。 | | `OMNIROUTE_PUBLIC_BASE_URL` | _(未设置)_ | 公共源解析器、图片 URL | 最高优先级的浏览器侧 OmniRoute 源,用于公共 URL 生成和非 Dashboard 浏览器源校验。当 OpenWebUI 或其他中继通过内部 URL 访问 OmniRoute,但用户浏览器必须从 LAN、隧道或公共源获取生成媒体时设置。**不要**包含 `/v1`。 | | `OMNIROUTE_TRUST_PROXY` | _(未设置)_ | `src/server/origin/publicOrigin.ts` | 可选的转发公共源头信任模式。未设置 = 出于安全考虑不信任 `Forwarded` / `X-Forwarded-*`。`true` / `loopback` 仅信任来自经过 Token 戳记的 loopback 代理的转发 host/proto。`private` / `lan` 还信任私有 LAN 代理对端。生产环境中推荐显式设置 `NEXT_PUBLIC_BASE_URL`。 | -| `THEOLDLLM_NAV_TIMEOUT_MS` | `30000`(30 秒) | `open-sse/executors/theoldllm.ts` | 浏览器端 Token 捕获(The Old LLM (theoldllm) 免费服务商使用)的 Playwright 导航超时(毫秒)。如果中继页面加载慢,可在慢速网络上提高。 | | `KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 异步 kie.ai 任务的公共回调 URL。优先级高于 `OMNIROUTE_KIE_CALLBACK_URL` 和 `OMNIROUTE_PUBLIC_URL`。 | | `OMNIROUTE_KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | `KIE_CALLBACK_URL` 的替代写法。主变量未设置时的回退。 | | `OMNIROUTE_PUBLIC_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 用于组合异步回调 URL 的公共源。kie.ai 回调的最低优先级回退;也用作其他中继的通用公共 URL。 | diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index 9122fc6694..2838914df5 100644 --- a/docs/i18n/zh-CN/llm.txt +++ b/docs/i18n/zh-CN/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/zh-TW/CHANGELOG.md b/docs/i18n/zh-TW/CHANGELOG.md index d318123602..008fdcd2bb 100644 --- a/docs/i18n/zh-TW/CHANGELOG.md +++ b/docs/i18n/zh-TW/CHANGELOG.md @@ -633,7 +633,6 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **[TS7] fix(types): normalize DuckDuckGo request messages** ([#9847](https://github.com/diegosouzapw/OmniRoute/pull/9847), original [#9797](https://github.com/diegosouzapw/OmniRoute/pull/9797)) — thanks @backryun - **[TS7] fix(types): expose SQLite transaction state** ([#9848](https://github.com/diegosouzapw/OmniRoute/pull/9848), original [#9796](https://github.com/diegosouzapw/OmniRoute/pull/9796)) — thanks @backryun - **[TS7] fix(types): validate default executor pool config** ([#9849](https://github.com/diegosouzapw/OmniRoute/pull/9849), original [#9795](https://github.com/diegosouzapw/OmniRoute/pull/9795)) — thanks @backryun -- **[TS7] fix(types): preserve The Old LLM proxy contracts** ([#9850](https://github.com/diegosouzapw/OmniRoute/pull/9850), original [#9793](https://github.com/diegosouzapw/OmniRoute/pull/9793)) — thanks @backryun - **[TS7] fix(types): normalize Gemini Business credentials** ([#9851](https://github.com/diegosouzapw/OmniRoute/pull/9851), original [#9792](https://github.com/diegosouzapw/OmniRoute/pull/9792)) — thanks @backryun - **[TS7] fix(types): preserve Claude thinking body contracts** ([#9852](https://github.com/diegosouzapw/OmniRoute/pull/9852), original [#9791](https://github.com/diegosouzapw/OmniRoute/pull/9791)) — thanks @backryun - **fix(response): strip internal reasoning placeholder from all reasoning fields** ([#9853](https://github.com/diegosouzapw/OmniRoute/pull/9853), original [#9790](https://github.com/diegosouzapw/OmniRoute/pull/9790)) — thanks @adevwithpurpose @@ -805,7 +804,7 @@ _By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailm - **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) - **fix(api):** deleting a manually-added custom model no longer tombstones a provider-synced model that shares its id. `DELETE /api/provider-models` is addressed by `provider` + `model` alone, so when both a custom row and a synced row existed for one id it removed both and wrote `isDeleted:true`. `replaceSyncedAvailableModelsForConnection` then filtered that id out of every subsequent re-import, so the provider could never resync — model sync kept reporting `added: N` while the catalog stayed empty and `/v1/models` never listed the model again, even though routing to it still worked. The custom row is now removed first and its presence is treated as the operator's intent, leaving the synced sibling importable; a synced-only delete still tombstones as before (#3199, #3782 unaffected) ([#10228](https://github.com/diegosouzapw/OmniRoute/pull/10228)) — thanks @Neuron-Mr-White - **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)). -- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `tllm/deepseek_v4`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White +- **fix(api):** DeepSeek V4's native `max` reasoning tier is now reachable. DeepSeek accepts `reasoning_effort` `low`/`high`/`max` and maps `medium`/`xhigh` down to `high`, while OmniRoute's canonical vocabulary collapses `max` onto `xhigh` — so `{"effort":"max"}` silently resolved to `high` and the catalog never advertised a `max` tier (or its `-max` variant). Following the existing `extendCodexGpt56EffortValues` precedent, the native tier is now preserved for `deepseek`/`ds` V4 models only; the global effort vocabulary is unchanged, routed namespaces (`openrouter/deepseek/…`, `oc/deepseek-v4-flash-free`) keep the canonical behavior, and an explicit client `reasoning_effort` still wins ([#10230](https://github.com/diegosouzapw/OmniRoute/pull/10230)) — thanks @Neuron-Mr-White - **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) - **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) - **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) @@ -2916,7 +2915,6 @@ _Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62 - Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn - Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn - Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn -- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn - Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn - Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn - Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn @@ -3608,7 +3606,6 @@ Thanks to everyone whose work landed in v3.8.45: - **feat(api):** expose a read-only provider plugin manifest at `GET /api/v1/provider-plugin-manifest` for sidecar/relay discovery. ([#6001](https://github.com/diegosouzapw/OmniRoute/pull/6001)) (thanks @KooshaPari) - **feat(sidecar):** advertise the provider manifest URL to Bifrost/CLIProxyAPI via the `X-OmniRoute-Provider-Manifest-Url` header (`OMNIROUTE_PROVIDER_MANIFEST_URL`). ([#6007](https://github.com/diegosouzapw/OmniRoute/pull/6007)) (thanks @KooshaPari) - **feat(autoCombo):** add a latency/speed-optimized routing mode (shared `rankBySpeed` scoring core) plus the `omniroute_pick_fastest_model` MCP tool. ([#6011](https://github.com/diegosouzapw/OmniRoute/pull/6011)) (thanks @KooshaPari) -- **feat(providers):** refresh The Old LLM (Free) model catalog ([#5181](https://github.com/diegosouzapw/OmniRoute/issues/5181)) — seed the current free `/api/chatgpt` tier (GPT-5/5.1/5.2/5.3/5.4, o3/o4-mini, Gemini 3 Pro / 2.5 Pro / 2.0 Flash / 1.5 Flash, Claude 4.6 Opus/Sonnet & 4.5 Haiku, GPT-4o, Grok 4, DeepSeek V3/R1, Sonar Pro) while keeping the legacy alias IDs for saved-preference compatibility. Also fixes a latent routing bug: `mapModel()` now passes known upstream IDs through unchanged, so Gemini/o-series/Grok/DeepSeek/Sonar models no longer silently collapse onto `GPT_5_4`. Regression guard: `tests/unit/theoldllm-model-refresh-5181.test.ts`. (thanks @WslzGmzs) - **feat(resilience):** surface Codex **banked reset credits** per connected account ([#5199](https://github.com/diegosouzapw/OmniRoute/issues/5199)) — the Codex quota parsers (`buildCodexUsageQuotas`, `parseCodexUsageResponse`) now additively read `rate_limit_reset_credits.available_count` (+ optional `rate_limit_reached_type`) from the `/wham/usage` payload OmniRoute already fetches, and the provider-limits dashboard renders a **"Banked Reset Credits"** row when a positive count is present. Display-only and **fail-open** — the field is eligibility-gated, so accounts without it are unaffected (parsers never throw on absent/garbage shapes); redemption (an unofficial mutating endpoint) is intentionally out of scope. Regression guard: `tests/unit/codex-banked-reset-credits-5199.test.ts` (8). (thanks @ofekbetzalel) - **feat(providers):** add sign-up geo-restriction notices for **SenseNova** and **StepFun** ([#5462](https://github.com/diegosouzapw/OmniRoute/issues/5462)) — the provider add-form now warns that SenseNova's console appears to require a Chinese (+86) phone number with no documented international path, and that StepFun's default endpoint is its China platform while a global StepFun Open Platform (`platform.stepfun.ai`, operated by Sparkling AI Pte. Ltd., Singapore) with email/Google/Discord login exists for international users. Informational `notice` only — neither provider is disabled. Regression guard: `tests/unit/regional-provider-cn-notices-5462.test.ts`. (thanks @chirag127) - **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector. ([#5831](https://github.com/diegosouzapw/OmniRoute/pull/5831)) @@ -5030,7 +5027,6 @@ _See English CHANGELOG for v3.8.32 details._ - **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219)) - **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern) - **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern) -- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa) - **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32) - **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33) - **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228)) diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt index cfc263ea21..3df2020308 100644 --- a/docs/i18n/zh-TW/llm.txt +++ b/docs/i18n/zh-TW/llm.txt @@ -4,7 +4,7 @@ --- -> 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index c63cadc12c..4763b279a2 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -315,7 +315,6 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `OMNIROUTE_PROVIDER_MANIFEST_URL` | _(unset)_ | `open-sse/config/providerPluginManifestUrl.ts` | Absolute provider plugin manifest URL advertised to sidecar clients. When unset, OmniRoute derives `/api/v1/provider-plugin-manifest` from request origin or HOST/PORT. | | `OMNIROUTE_PUBLIC_PROTOCOL` | `http` | `open-sse/config/providerPluginManifestUrl.ts` | Protocol used when deriving the provider plugin manifest URL from HOST/PORT without a request origin. Set to `https` behind a TLS-terminating public proxy when no explicit `OMNIROUTE_PROVIDER_MANIFEST_URL` is set. | | `OMNIROUTE_TRUST_PROXY` | _(unset)_ | `src/server/origin/publicOrigin.ts` | Optional trust mode for forwarded public-origin headers. Unset = do not trust `Forwarded` / `X-Forwarded-*` for security decisions. `true` / `loopback` trusts forwarded host/proto only from a token-stamped loopback proxy. `private` / `lan` also trusts private-LAN proxy peers. Prefer explicit `NEXT_PUBLIC_BASE_URL` in production. | -| `THEOLDLLM_NAV_TIMEOUT_MS` | `30000` (30s) | `open-sse/executors/theoldllm.ts` | Playwright navigation timeout (ms) for the browser-backed token capture used by the The Old LLM (theoldllm) free provider. Raise on slow networks if the relay page is slow to settle. | | `KIE_CALLBACK_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public callback URL for asynchronous kie.ai jobs. Highest-priority override before `OMNIROUTE_KIE_CALLBACK_URL` and `OMNIROUTE_PUBLIC_URL`. | | `OMNIROUTE_KIE_CALLBACK_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Alternate spelling of `KIE_CALLBACK_URL`. Falls back when the primary variable is unset. | | `OMNIROUTE_PUBLIC_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public origin used to compose async callback URLs. Lowest-priority fallback for kie.ai callbacks; also used as a generic public URL for other relays. | diff --git a/docs/reference/FREE_TIERS.md b/docs/reference/FREE_TIERS.md index dd049ffd27..de03e3712b 100644 --- a/docs/reference/FREE_TIERS.md +++ b/docs/reference/FREE_TIERS.md @@ -39,7 +39,7 @@ Biggest **documented** contributors: `mistral` 1.00B, `llm7` 150M, `nara` 150M, A 50-agent web-research pass (official docs + last-7-days news, adversarially verified) refreshed the whole catalog. Highlights: -- **Removed / no free tier (2026):** `chutes` (free tier ended 2026-03), `phind` (company shut down 2026-01), `kluster` (sunset 2026-06-09 → MITO), `gitlawb` + `gitlawb-gmi` (MiMo free revoked 2026-05-24, Nemotron promo ended 2026-06 — re-verified 2026-06-18), `aimlapi` (free tier paused — re-verified 2026-06-18), `yi` (Yi-Light retired, pay-as-you-go — re-verified 2026-06-18), `theoldllm` / `featherless-ai` (no current free tier). `iflytek` / `sparkdesk` stay listed but carry a ToS-caution note (Spark Lite is free; the ToS restricts proxy/relay use). +- **Removed / no free tier (2026):** `chutes` (free tier ended 2026-03), `phind` (company shut down 2026-01), `kluster` (sunset 2026-06-09 → MITO), `gitlawb` + `gitlawb-gmi` (MiMo free revoked 2026-05-24, Nemotron promo ended 2026-06 — re-verified 2026-06-18), `aimlapi` (free tier paused — re-verified 2026-06-18), `yi` (Yi-Light retired, pay-as-you-go — re-verified 2026-06-18), `featherless-ai` (no current free tier). `iflytek` / `sparkdesk` stay listed but carry a ToS-caution note (Spark Lite is free; the ToS restricts proxy/relay use). - **Gemini** — `2.0 Flash` / `2.0 Flash-Lite` shut down 2026-06-01 and `2.5 Pro` left the free tier (2026-04); free tier is now **Flash-family only** (2.5/3/3.1/3.5 Flash + Gemma). The catalog now **pools** the Flash family (was inflated by counting each variant separately: 462M → 60M). - **Corrected numbers:** `cloudflare-ai` 122M → **30M** (real 10k-Neurons/day), `doubao` reclassified as a one-time signup credit (not recurring), `llm7` 4M → **150M** (documented 5M tokens/day), `together` "-Free" endpoints discontinued → only the **$25** signup credit remains, `longcat` Preview ended + Flash models retired → **LongCat-2.0** only, reclassified as a one-time **10M**-token signup credit (KYC-gated, not recurring). - **New free providers discovered:** ⭐ **Kilo Code** (`kilo-gateway` — rotating "Auto Free" set: NVIDIA Nemotron 3 family, StepFun, Poolside, Nex-N2-Pro), ⭐ **OpenCode Zen** (`opencode-zen` — 6 rotating free coding models), ⭐ **Z.AI / Zhipu** (`glm-cn` — GLM-4-Flash / 4.5-Flash / 4.7-Flash permanently free + 20M signup bonus), and `arcee-ai` Trinity Large Preview. @@ -175,7 +175,6 @@ purpose. | `freemodel-dev` | unknown | The Terms of Service page (freemodel.dev/terms) returned only a header with no readable content via WebFetch; no clause… | | `gitlawb` | unknown | No ToS or acceptable-use policy found; proxy/resale restrictions unknown — assume caution for self-hosted proxy use. | | `liquid` | unknown | No hosted API exists to proxy; open-source model commercial use is free for orgs under $10M annual revenue. No self-hos… | -| `theoldllm` | unknown | No terms of service document was found on the site; proxying, resale, or self-hosted use policy is entirely undocumente… | | `yi` | unknown | ToS not publicly accessible without login; no proxy/resale clauses could be reviewed. Self-hosted personal proxy use st… | | `comfyui` | ok | GPL-3.0 open-source license explicitly permits self-hosted personal proxy use; Comfy Org ToS confirms commercial use of… | | `scaleway` | ok | Scaleway's General Terms of Services are a standard commercial cloud agreement with no explicit prohibition on self-hos… | @@ -328,7 +327,6 @@ purpose. - **`t3-web`** — The shipped freeNote is broadly accurate (limited model access, Pro unlocks 50+ models for $8/month), but misses two key updates: (1) the free tier now resets daily instead of monthly (changed around… - **`tavily-search`** — Catalog ships freeNote "(none)" implying no free tier, but Tavily does in fact offer a documented recurring free tier of 1,000 credits/month with no credit card required. This is a significant discre… - **`tencent`** — Largely matches — the shipped freeNote ("Free Hunyuan Lite models") is accurate. Hunyuan-lite has been permanently free since May 2024 and remains so as of 2026. The catalog note undersells the detai… -- **`theoldllm`** — Our shipped freeNote was "(none)" — this still matches in the sense that no structured API/free tier offering exists; the service remains a UI-only chat wrapper with no catalogable API tier. - **`together`** — The shipped note says "$25 signup credits + 3 permanently free models" but reality shows far more permanently free models (~80, not 3). The $25 trial credit figure is contested — official billing doc… - **`uncloseai`** — Largely matches — still free forever with no signup. However, the ToS (terms-of-use.html) clarifies IP-based throttling exists for excessive use and prohibits building competing ML services without a… - **`veoaifree-web`** — The shipped freeNote states "6 requests/hour" but no such explicit limit is currently documented anywhere on veoaifree.com. The site claims unlimited free generation with no login. The models listed … diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 3b181d9f73..3774a1de95 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -10,7 +10,7 @@ lastUpdated: 2026-09-02 > Regenerate with: `npm run gen:provider-reference` > **Last generated:** 2026-09-02 -Total providers: **355**. See category breakdown below. +Total providers: **354**. See category breakdown below. ## Categories @@ -34,7 +34,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each --- -## No-auth Providers (no key required) (12) +## No-auth Providers (no key required) (11) | ID | Alias | Name | Tags | Website | Notes | Tool calling | |----|-------|------|------|---------|-------|--------------| @@ -46,7 +46,6 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `devin-cli-agentic` | `dva` | Devin CLI Agentic Bridge | No-auth | [link](https://docs.devin.ai/work-with-devin/devin-cli) | Authentication is owned by the official Devin CLI in its isolated bridge volume. | emulated | | `duckduckgo-web` | `ddgw` | DuckDuckGo AI Chat | No-auth | [link](https://duckduckgo.com/duckchat) | No credentials required — DuckDuckGo AI Chat is anonymous and free. | emulated | | `opencode` | `oc` | OpenCode Free | No-auth | [link](https://opencode.ai) | No API key required — uses OpenCode's public free endpoint. | — | -| `theoldllm` | `tllm` | The Old LLM (Free) | No-auth | [link](https://theoldllm.vercel.app) | No credentials required. The executor auto-generates access tokens via an embedded Playwright browser instance. | — | | `uncloseai` | `unc` | UncloseAI | No-auth | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync; verified live model: solidrust/Hermes-3-Llama-3.1-8B-AWQ. | — | | `veoaifree-web` | `veo-free` | Veo AI Free | No-auth, video | [link](https://veoaifree.com) | No auth required. Rate limited to 6 requests/hour per IP. | — | | `zcode` | `zc` | ZCode (GLM Coding Plan) | No-auth | [link](https://zcode.z.ai) | No API key stored by OmniRoute. The local ZCode app-server uses the existing builtin:zai-coding-plan login. | — | @@ -443,7 +442,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each - Catalog: [`src/shared/constants/providers.ts`](../../src/shared/constants/providers.ts) - Registry (per-model details): [`open-sse/config/providerRegistry.ts`](../../open-sse/config/providerRegistry.ts) -- Executors: [`open-sse/executors/`](../../open-sse/executors/) (108 implementations) +- Executors: [`open-sse/executors/`](../../open-sse/executors/) (107 implementations) - Translators: [`open-sse/translator/`](../../open-sse/translator/) ## See Also diff --git a/llm.txt b/llm.txt index 12bdfe1ffb..3816cba7d7 100644 --- a/llm.txt +++ b/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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 354 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -277,7 +277,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **354 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 868fb90e68..cc937cee21 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -205,7 +205,6 @@ import { gemini_businessProvider } from "./registry/gemini/business/index.ts"; import { clineProvider } from "./registry/cline/index.ts"; import { herokuProvider } from "./registry/heroku/index.ts"; import { bluesmindsProvider } from "./registry/bluesminds/index.ts"; -import { theoldllmProvider } from "./registry/theoldllm/index.ts"; import { baiduProvider } from "./registry/baidu/index.ts"; import { pollinationsProvider } from "./registry/pollinations/index.ts"; import { veoaifree_webProvider } from "./registry/veoaifree-web/index.ts"; @@ -476,7 +475,6 @@ export const REGISTRY: Record = { cline: clineProvider, heroku: herokuProvider, bluesminds: bluesmindsProvider, - theoldllm: theoldllmProvider, baidu: baiduProvider, pollinations: pollinationsProvider, "veoaifree-web": veoaifree_webProvider, diff --git a/open-sse/config/providers/registry/theoldllm/index.ts b/open-sse/config/providers/registry/theoldllm/index.ts deleted file mode 100644 index a22d9901ae..0000000000 --- a/open-sse/config/providers/registry/theoldllm/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { RegistryEntry } from "../../shared.ts"; - -export const theoldllmProvider: RegistryEntry = { - id: "theoldllm", - alias: "tllm", - format: "openai", - executor: "theoldllm", - // Playwright-backed executor — no standard auth; uses embedded browser for token generation - baseUrl: "https://theoldllm.vercel.app/api/chatgpt", - baseUrls: ["https://theoldllm.vercel.app/api/chatgpt"], - authType: "none", - authHeader: "none", - defaultContextLength: 200000, - // Catalog seed. `passthroughModels: true` means live /api/chatgpt discovery is - // authoritative; this list is the curated display/fallback set. The upstream IDs - // (GPT_5_*, gemini_*, CLAUDE_4_*, openrouter_*, etc.) mirror the site's free - // "chatgpt" tier and MUST match `CHATGPT_UPSTREAM_MODELS` in the executor so they - // route unchanged. Legacy alias IDs (GPT_4o, claude_opus_4, …) are kept for - // backward compatibility with saved model preferences (mapped in the executor). - models: [ - // ── Current free tier (refreshed for #5181) ── - { id: "GPT_5_4", name: "GPT-5.4 (The Old LLM 🆓)", contextLength: 400000 }, - { id: "GPT_5_3", name: "GPT-5.3 (The Old LLM 🆓)", contextLength: 400000 }, - { id: "GPT_5_2", name: "GPT-5.2 (The Old LLM 🆓)", contextLength: 400000 }, - { id: "GPT_5_1", name: "GPT-5.1 (The Old LLM 🆓)", contextLength: 400000 }, - { id: "GPT_5", name: "GPT-5 (The Old LLM 🆓)", contextLength: 400000 }, - { id: "GPT_o4_mini", name: "o4-mini (The Old LLM 🆓)" }, - { id: "GPT_o3_mini", name: "o3-mini (The Old LLM 🆓)" }, - { id: "gemini_3_pro", name: "Gemini 3 Pro (The Old LLM 🆓)", contextLength: 1000000 }, - { id: "gemini_2_5_pro", name: "Gemini 2.5 Pro (The Old LLM 🆓)", contextLength: 1000000 }, - { id: "gemini_2_0_flash", name: "Gemini 2.0 Flash (The Old LLM 🆓)", contextLength: 1000000 }, - { id: "gemini_1_5_flash", name: "Gemini 1.5 Flash (The Old LLM 🆓)", contextLength: 1000000 }, - { id: "CLAUDE_4_6_OPUS", name: "Claude 4.6 Opus (The Old LLM 🆓)", contextLength: 200000 }, - { id: "CLAUDE_4_6_SONNET", name: "Claude 4.6 Sonnet (The Old LLM 🆓)", contextLength: 200000 }, - { id: "CLAUDE_4_5_HAIKU", name: "Claude 4.5 Haiku (The Old LLM 🆓)", contextLength: 200000 }, - { id: "openrouter_gpt_4_o", name: "GPT-4o (The Old LLM 🆓)" }, - { id: "openrouter_gpt_4_o_mini", name: "GPT-4o mini (The Old LLM 🆓)" }, - { id: "openrouter_grok_4", name: "Grok 4 (The Old LLM 🆓)" }, - { id: "together_deepseek_v3", name: "DeepSeek V3 (The Old LLM 🆓)" }, - { id: "openrouter_deepseek_r1", name: "DeepSeek R1 (The Old LLM 🆓)" }, - { id: "sonar-pro", name: "Sonar Pro (The Old LLM 🆓)" }, - // ── Legacy alias IDs (kept for saved-preference backward compatibility) ── - { id: "GPT_4o", name: "GPT-4o (The Old LLM 🆓)" }, - { id: "claude_opus_4", name: "Claude Opus 4 (The Old LLM 🆓)", contextLength: 200000 }, - { id: "claude_sonnet_4", name: "Claude Sonnet 4 (The Old LLM 🆓)", contextLength: 200000 }, - { id: "claude_haiku_3_5", name: "Claude Haiku 3.5 (The Old LLM 🆓)", contextLength: 200000 }, - { id: "deepseek_v4", name: "DeepSeek V4 (The Old LLM 🆓)", contextLength: 200000 }, - { id: "gemini_3_flash", name: "Gemini 3 Flash (The Old LLM 🆓)", contextLength: 1000000 }, - ], - passthroughModels: true, -}; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 12296a3ffe..521ce140bd 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -158,8 +158,6 @@ const lazyExecutors: Record Promise> = { db: () => import("./doubao-web.ts").then((m) => new m.DoubaoWebExecutor()), // Alias "zai-web": () => import("./zai-web.ts").then((m) => new m.ZaiWebExecutor()), zw: () => import("./zai-web.ts").then((m) => new m.ZaiWebExecutor()), // Alias - theoldllm: () => import("./theoldllm.ts").then((m) => new m.TheOldLlmExecutor()), - tllm: () => import("./theoldllm.ts").then((m) => new m.TheOldLlmExecutor()), // Alias chipotle: () => import("./chipotle.ts").then((m) => new m.ChipotleExecutor()), pepper: () => import("./chipotle.ts").then((m) => new m.ChipotleExecutor()), // Alias lmarena: () => import("./lmarena.ts").then((m) => new m.LMArenaExecutor()), diff --git a/open-sse/executors/theoldllm.ts b/open-sse/executors/theoldllm.ts deleted file mode 100644 index 422452e7f2..0000000000 --- a/open-sse/executors/theoldllm.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { BaseExecutor, type ExecuteInput } from "./base.ts"; -import type { ProviderCredentials } from "./base.ts"; - -const API_BASE = "https://theoldllm.vercel.app"; -const API_PATH = "/api/chatgpt"; -const API_URL = `${API_BASE}${API_PATH}`; -const CHROME_UA = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; - -// ── Model name mapping ──────────────────────────────────────────────────── - -const GPT_MODELS: Record = { - "gpt-5.4": "GPT_5_4", - "gpt-5.3": "GPT_5_3", - "gpt-5.2": "GPT_5_2", - "gpt-5.1": "GPT_5_1", - "gpt-5": "GPT_5", - gpt5_4: "GPT_5_4", - gpt5_3: "GPT_5_3", - gpt5_2: "GPT_5_2", - gpt5_1: "GPT_5_1", - gpt_4o: "GPT_4O", - "gpt-4o": "GPT_4O", - gpt_5_3: "GPT_5_3", - gpt_5_2: "GPT_5_2", - gpt_5_1: "GPT_5_1", - gpt_5: "GPT_5", -}; - -const CLAUDE_NAMES: Record = { - "claude-4.6-opus": "CLAUDE_4_6_OPUS", - "claude-4.6-sonnet": "CLAUDE_4_6_SONNET", - "claude-4.5-haiku": "CLAUDE_4_5_HAIKU", - claude_opus_4: "CLAUDE_4_6_OPUS", - claude_sonnet_4: "CLAUDE_4_6_SONNET", - claude_haiku_3_5: "CLAUDE_4_5_HAIKU", - "claude opus 4": "CLAUDE_4_6_OPUS", - "claude sonnet 4": "CLAUDE_4_6_SONNET", - "claude haiku 3.5": "CLAUDE_4_5_HAIKU", -}; - -// Canonical upstream model IDs served by theoldllm's /api/chatgpt proxy -// (apiProvider "chatgpt" in the site's model catalog — the free, reachable tier). -// Source: https://theoldllm.vercel.app model list (reported in #5181). -// These pass through mapModel() UNCHANGED — critical for non-GPT/Claude models -// (Gemini, o-series, Grok, DeepSeek, Sonar) which would otherwise fall through -// to the GPT_5_4 default and silently misroute. -export const CHATGPT_UPSTREAM_MODELS: ReadonlySet = new Set([ - "GPT_5_4", - "GPT_5_3", - "GPT_5_2", - "GPT_5_1", - "GPT_5", - "GPT_o4_mini", - "GPT_o3_mini", - "gemini_3_pro", - "gemini_2_5_pro", - "gemini_2_0_flash", - "gemini_1_5_flash", - "CLAUDE_4_6_OPUS", - "CLAUDE_4_6_SONNET", - "CLAUDE_4_5_HAIKU", - "openrouter_gpt_4_o", - "openrouter_gpt_4_o_mini", - "openrouter_gpt_4", - "openrouter_grok_4", - "together_deepseek_r1", - "openrouter_deepseek_r1", - "together_deepseek_v3", - "openrouter_deepseek_v3", - "sonar-deep-research", - "sonar-pro", - "openrouter_web_search", -]); - -export function mapModel(model: string): string { - const trimmed = model.trim(); - // Known upstream IDs (from live discovery / refreshed catalog) route as-is. - if (CHATGPT_UPSTREAM_MODELS.has(trimmed)) return trimmed; - const n = model.toLowerCase().trim(); - const gptKey = n.replace(/[_\s]+/g, "-"); - if (GPT_MODELS[gptKey]) return GPT_MODELS[gptKey]; - const gptKey2 = n.replace(/[-\s]+/g, "_"); - if (GPT_MODELS[gptKey2]) return GPT_MODELS[gptKey2]; - if (CLAUDE_NAMES[n]) return CLAUDE_NAMES[n]; - if (n.includes("claude")) { - if (n.includes("opus")) return "CLAUDE_4_6_OPUS"; - if (n.includes("sonnet")) return "CLAUDE_4_6_SONNET"; - if (n.includes("haiku")) return "CLAUDE_4_5_HAIKU"; - } - if (n.includes("gpt") && n.includes("5")) return "GPT_5_4"; - return "GPT_5_4"; -} - -// ── Token generation (mirrors client-side rie() from theoldllm.vercel.app) ── -// -// The SPA generates X-Request-Token via: -// const nie = "oldllm-client-2026"; -// const n = Date.now(); -// const e = `${n}-${nie}-${navigator.userAgent.slice(0, 20)}`; -// let t = djb2_hash(e); -// const r = crypto.randomUUID().slice(0, 8); -// return `${n.toString(36)}-${Math.abs(t).toString(36)}-${r}`; -// -// Since nie is a static constant and the UA prefix is known, we can generate -// valid tokens server-side without launching a browser. - -const TOKEN_SEED = "oldllm-client-2026"; -const UA_PREFIX = CHROME_UA.slice(0, 20); // "Mozilla/5.0 (Windows" - -type TheOldLlmProxy = Awaited< - ReturnType ->; - -interface TheOldLlmFetchDependencies { - resolveProxy: () => Promise; - runWithProxy: (proxy: TheOldLlmProxy, request: () => Promise) => Promise; - fetch: typeof fetch; - hasBlockingProxyAssignment?: () => boolean; -} - -class TheOldLlmProxyUnavailableError extends Error {} - -export function generateRequestToken(): string { - const n = Date.now(); - const e = `${n}-${TOKEN_SEED}-${UA_PREFIX}`; - let t = 0; - for (let i = 0; i < e.length; i++) { - const s = e.charCodeAt(i); - t = (t << 5) - t + s; - t = t & t; - } - const r = crypto.randomUUID().replace(/-/g, "").slice(0, 8); - return `${n.toString(36)}-${Math.abs(t).toString(36)}-${r}`; -} - -// Exported for test compatibility — the new server-side token flow generates -// tokens per-request; this stub satisfies imports that set tokenCache.value. -export const tokenCache: { value: string; expiresAt: number } = { value: "", expiresAt: 0 }; - -// ── Direct Node.js fetch ────────────────────────────────────────────────── - -export async function fetchTheOldLlmWithProviderProxy( - reqBody: Record, - signal: AbortSignal, - dependencies?: TheOldLlmFetchDependencies -): Promise { - let deps = dependencies; - if (!deps) { - const [ - { resolveProxyForProvider, hasBlockingProxyAssignmentForProvider }, - { runWithProxyContext }, - ] = await Promise.all([import("../../src/lib/db/proxies"), import("../utils/proxyFetch.ts")]); - deps = { - resolveProxy: () => resolveProxyForProvider("theoldllm"), - runWithProxy: runWithProxyContext, - fetch: globalThis.fetch, - hasBlockingProxyAssignment: () => hasBlockingProxyAssignmentForProvider("theoldllm"), - }; - } - - const proxy = await deps.resolveProxy(); - if (!proxy && deps.hasBlockingProxyAssignment?.()) { - throw new TheOldLlmProxyUnavailableError("No active proxy is available for The Old LLM"); - } - return deps.runWithProxy(proxy, () => - deps.fetch(API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Client-Version": "3.8.4", - "X-Request-Token": generateRequestToken(), - "User-Agent": CHROME_UA, - }, - body: JSON.stringify(reqBody), - signal, - }) - ); -} - -async function directFetch( - reqBody: Record, - signal?: AbortSignal | null -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => { - const err = new Error("theoldllm timeout after 120000ms"); - err.name = "TimeoutError"; - controller.abort(err); - }, 120_000); - const onSignal = signal ? () => controller.abort(signal.reason) : undefined; - signal?.addEventListener("abort", onSignal!, { once: true }); - - try { - // No-auth providers do not have a connection row, so chatCore cannot apply - // a connection-scoped proxy context for them. Resolve the provider/global - // assignment explicitly; otherwise The Old LLM always leaks out through the - // VPS address and Vercel's bot protection denies every model. - return await fetchTheOldLlmWithProviderProxy(reqBody, controller.signal); - } finally { - clearTimeout(timer); - if (onSignal) signal?.removeEventListener("abort", onSignal); - } -} - -export function isVercelMitigationResponse(response: Response, body: string): boolean { - const mitigation = response.headers.get("x-vercel-mitigated")?.toLowerCase(); - if (mitigation === "deny" || mitigation === "challenge") return true; - return ( - (response.status === 403 || response.status === 429) && - /vercel security checkpoint|"message"\s*:\s*"forbidden"/i.test(body) - ); -} - -function isTokenRejected(status: number, body: string): boolean { - if (status === 401 || status === 403) return true; - try { - const p = JSON.parse(body); - return ( - p?.error?.type === "access_denied" || - (typeof p?.error === "string" && /blocked|denied|invalid/i.test(p.error)) - ); - } catch { - return false; - } -} - -// ── SSE helpers ─────────────────────────────────────────────────────────── - -function parseSseContent(sseText: string): string { - let content = ""; - for (const line of sseText.split("\n")) { - if (line.startsWith("data: ") && line !== "data: [DONE]") { - try { - const d = JSON.parse(line.slice(6)); - content += d.choices?.[0]?.delta?.content || d.choices?.[0]?.delta?.text || ""; - } catch {} - } - } - return content; -} - -function buildChatCompletion(content: string, model: string): string { - return JSON.stringify({ - id: `chatcmpl-${Date.now()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: mapModel(model), - choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], - usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, - }); -} - -function buildErrorResponse(status: number, body: string): string { - let detail = body; - for (const line of body.split("\n")) { - if (line.startsWith("data: ") && line !== "data: [DONE]") { - try { - const p = JSON.parse(line.slice(6)); - if (p.error) { - detail = JSON.stringify(p.error); - break; - } - } catch {} - } - } - return JSON.stringify({ - error: { message: detail, type: "upstream_error", code: `HTTP_${status}` }, - }); -} - -function buildVercelMitigationError(): string { - return JSON.stringify({ - error: { - message: - "The Old LLM is blocked by Vercel for this server egress IP. Configure a residential provider or global proxy for 'theoldllm' and retry.", - type: "upstream_access_denied", - code: "THEOLDLLM_VERCEL_MITIGATED", - }, - }); -} - -function buildProxyUnavailableError(): string { - return JSON.stringify({ - error: { - message: - "The Old LLM proxy assignment has no active proxies. Configure or enable a proxy and retry.", - type: "proxy_unavailable", - code: "THEOLDLLM_PROXY_UNAVAILABLE", - }, - }); -} - -async function fetchUpstreamWithRetry( - reqBody: Record, - signal: AbortSignal | null | undefined, - log: ExecuteInput["log"] -): Promise<{ response: Response; body: string; vercelMitigated: boolean }> { - let response = await directFetch(reqBody, signal); - let body = await response.text(); - let vercelMitigated = isVercelMitigationResponse(response, body); - if (!vercelMitigated && isTokenRejected(response.status, body)) { - log?.warn?.("THEOLDLLM", `Token rejected (${response.status}), retrying with fresh token…`); - response = await directFetch(reqBody, signal); - body = await response.text(); - vercelMitigated = isVercelMitigationResponse(response, body); - } - return { response, body, vercelMitigated }; -} - -// ── Executor ────────────────────────────────────────────────────────────── - -export class TheOldLlmExecutor extends BaseExecutor { - constructor() { - super("theoldllm", { format: "openai" }); - } - - buildUrl(_model: string, _stream: boolean): string { - return API_URL; - } - - buildHeaders(_credentials: ProviderCredentials): Record { - return { - "Content-Type": "application/json", - "X-Client-Version": "3.8.4", - "User-Agent": CHROME_UA, - }; - } - - transformRequest(model: string, body: unknown, _stream: boolean): unknown { - if (typeof body === "object" && body !== null) { - return { ...(body as Record), model: mapModel(model) }; - } - return body; - } - - private executionResult(input: ExecuteInput, response: Response, body: unknown) { - return { - response, - url: API_URL, - headers: this.buildHeaders(input.credentials), - transformedBody: body, - }; - } - - async testConnection( - _credentials: ProviderCredentials, - _signal?: AbortSignal | null, - log?: ExecuteInput["log"] - ): Promise { - try { - const resp = await directFetch( - { - model: "GPT_5_4", - messages: [{ role: "user", content: "ping" }], - stream: false, - }, - _signal - ); - const body = await resp.text(); - if (!resp.ok && isVercelMitigationResponse(resp, body)) { - log?.warn?.( - "THEOLDLLM", - "Vercel blocked this egress IP; configure a residential provider proxy" - ); - return false; - } - return resp.status === 200; - } catch { - log?.warn?.("THEOLDLLM", "testConnection network error"); - return false; - } - } - - async execute(input: ExecuteInput): Promise<{ - response: Response; - url: string; - headers: Record; - transformedBody: unknown; - }> { - const { model, stream, body, signal, log } = input; - const encoder = new TextEncoder(); - - if (signal?.aborted) { - return { - response: new Response( - encoder.encode( - JSON.stringify({ - error: { message: "Request aborted", type: "abort", code: "ABORTED" }, - }) - ), - { status: 499, headers: { "Content-Type": "application/json" } } - ), - url: API_URL, - headers: this.buildHeaders(input.credentials), - transformedBody: body, - }; - } - - try { - const reqBody = { - ...(body as Record), - model: mapModel(model), - stream: true, - }; - - const { - response: upstream, - body: finalBody, - vercelMitigated, - } = await fetchUpstreamWithRetry(reqBody, signal, log); - - if (upstream.status === 200 && finalBody) { - const payload = stream ? finalBody : buildChatCompletion(parseSseContent(finalBody), model); - return this.executionResult( - input, - new Response(encoder.encode(payload), { - status: 200, - headers: { - "Content-Type": stream ? "text/event-stream" : "application/json", - "Cache-Control": "no-cache", - }, - }), - body - ); - } - - const errorPayload = vercelMitigated - ? buildVercelMitigationError() - : buildErrorResponse(upstream.status, finalBody); - return this.executionResult( - input, - new Response(encoder.encode(errorPayload), { - status: upstream.status, - headers: { "Content-Type": "application/json" }, - }), - body - ); - } catch (err) { - const proxyUnavailable = err instanceof TheOldLlmProxyUnavailableError; - const msg = err instanceof Error ? err.message : String(err); - log?.error?.("THEOLDLLM", `Executor error: ${msg}`); - const errorPayload = proxyUnavailable - ? buildProxyUnavailableError() - : JSON.stringify({ - error: { message: msg, type: "upstream_error", code: "EXECUTOR_ERROR" }, - }); - return this.executionResult( - input, - new Response(encoder.encode(errorPayload), { - status: proxyUnavailable ? 503 : 502, - headers: { "Content-Type": "application/json" }, - }), - body - ); - } - } -} - -export default TheOldLlmExecutor; diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index d760794d49..8471727a9b 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -329,8 +329,8 @@ const SYNTHETIC_NOAUTH_CONNECTION_ID = RESILIENCE_NOAUTH_CONNECTION_ID; // Allowlist of no-auth (keyless) providers permitted to enter the `auto`/`auto-*` // candidate pool. Narrowed to the backends verified to answer without any // configuration on our reference egress (VPS .15): `opencode` returns 200 -// there, while duckduckgo-web (429/VQD rate limit), theoldllm -// (403 Vercel egress block), chipotle (502), aihorde (401, anon key rejected) +// there, while duckduckgo-web (429/VQD rate limit), +// chipotle (502), aihorde (401, anon key rejected) // and the others are unreliable. The excluded providers stay fully usable via // direct `/` calls — they are just kept OUT of auto-routing until // re-verified. Re-add an id here to bring it back into every auto/* pool. diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index 210bb01aba..2bdbbbc8c2 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -392,7 +392,7 @@ export function classifyProviderError( return null; } // No-credential ("authType: none") providers — free, stateless per-request - // token proxies like mimocode/theoldllm — have no real account/credential + // token proxies — have no real account/credential // to revoke. An unrecognized 403 from these is a transient upstream // rate-limit/blocklist signal, not an account ban: keep it recoverable so // the connection cooldown/retry layer handles it instead of a permanent diff --git a/package.json b/package.json index 4e9f576908..1f31542650 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniroute", "version": "3.8.51", - "description": "Unified AI router with 355 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", + "description": "Unified AI router with 354 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { "omniroute": "bin/omniroute.mjs", diff --git a/public/images/tier-flow-dark.svg b/public/images/tier-flow-dark.svg index 1cf2589812..588c034c1c 100644 --- a/public/images/tier-flow-dark.svg +++ b/public/images/tier-flow-dark.svg @@ -1,6 +1,6 @@ - + OmniRoute 4-tier fallback - OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 355 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. + OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 354 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. @@ -15,7 +15,7 @@ OmniRoute 4-tier fallback - Never stop building — automatic zero-config failover across 355 providers + Never stop building — automatic zero-config failover across 354 providers diff --git a/public/images/tier-flow-light.svg b/public/images/tier-flow-light.svg index cd79d47e3b..a3d2c2a2f7 100644 --- a/public/images/tier-flow-light.svg +++ b/public/images/tier-flow-light.svg @@ -1,6 +1,6 @@ - + OmniRoute 4-tier fallback - OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 355 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. + OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 354 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. @@ -15,7 +15,7 @@ OmniRoute 4-tier fallback - Never stop building — automatic zero-config failover across 355 providers + Never stop building — automatic zero-config failover across 354 providers diff --git a/src/app/(dashboard)/dashboard/providers/hooks/useSyncedModelsByProvider.ts b/src/app/(dashboard)/dashboard/providers/hooks/useSyncedModelsByProvider.ts index 86e89abcee..edc0658650 100644 --- a/src/app/(dashboard)/dashboard/providers/hooks/useSyncedModelsByProvider.ts +++ b/src/app/(dashboard)/dashboard/providers/hooks/useSyncedModelsByProvider.ts @@ -8,7 +8,7 @@ import type { LiveModelsByProviderId } from "../providerPageUtils"; * provider connection via GET /api/synced-available-models, so the Providers * page model-name filter can match against real upstream models (not just * the static curated registry). See #7250: aggregator providers (openrouter, - * kilocode, theoldllm...) declare a single-entry static placeholder, so a + * kilocode, ...) declare a single-entry static placeholder, so a * search for a real model name never matched and silently hid the provider. * * Fails soft — a fetch error leaves the map empty, and callers fall back to diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 185be8e945..8c7be96c71 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -405,7 +405,7 @@ export type LiveModelsByProviderId = Record>({}); diff --git a/src/shared/constants/providers/noauth.ts b/src/shared/constants/providers/noauth.ts index f4a6de066c..25493d5124 100644 --- a/src/shared/constants/providers/noauth.ts +++ b/src/shared/constants/providers/noauth.ts @@ -74,22 +74,6 @@ export const NOAUTH_PROVIDERS = { text: "Cloudflare AI Playground uses a reverse-engineered anonymous WebSocket protocol (no official API). Requires Playwright with a Chromium browser on first request. Rate limits apply per IP (error 3021).", }, }, - theoldllm: { - id: "theoldllm", - alias: "tllm", - name: "The Old LLM (Free)", - icon: "auto_awesome", - color: "#8B5CF6", - textIcon: "TL", - website: "https://theoldllm.vercel.app", - noAuth: true, - hasFree: true, - serviceKinds: ["llm"], - freeNote: - "Free — GPT-5.4, Claude 4.6 Opus/Sonnet/Haiku, + more. No API key — tokens auto-generated via browser.", - authHint: - "No credentials required. The executor auto-generates access tokens via an embedded Playwright browser instance.", - }, chipotle: { id: "chipotle", alias: "pepper", @@ -227,7 +211,7 @@ export const NOAUTH_PROVIDERS = { // upstream path runs through OmniRoute's proxy-aware global fetch. Providers // with browser, WebSocket, direct dispatcher, media, or local CLI paths stay // hidden until those paths can guarantee the configured provider proxy. -export const NOAUTH_PROVIDER_PROXY_SUPPORTED = new Set(["opencode", "theoldllm"]); +export const NOAUTH_PROVIDER_PROXY_SUPPORTED = new Set(["opencode"]); export function supportsNoAuthProviderProxy(providerId: string): boolean { return NOAUTH_PROVIDER_PROXY_SUPPORTED.has(providerId); diff --git a/src/shared/reasoning/effortStandardization.ts b/src/shared/reasoning/effortStandardization.ts index e3ddc7aa8e..d61ff4faa8 100644 --- a/src/shared/reasoning/effortStandardization.ts +++ b/src/shared/reasoning/effortStandardization.ts @@ -83,7 +83,7 @@ export function extendDeepSeekEffortValues( * DeepSeek provider (registry id `deepseek`, alias `ds`). * * Deliberately scoped to the native provider: routed namespaces such as - * `openrouter/deepseek/...` or `tllm/deepseek_v4` terminate at a different + * `openrouter/deepseek/...` or `oc/deepseek-v4-flash-free` terminate at a different * upstream whose accepted effort vocabulary we do not control. */ export function isDeepSeekNativeMaxModel( diff --git a/tests/integration/combo-matrix/auto.test.ts b/tests/integration/combo-matrix/auto.test.ts index 607fbd6ec1..08be8fb403 100644 --- a/tests/integration/combo-matrix/auto.test.ts +++ b/tests/integration/combo-matrix/auto.test.ts @@ -44,14 +44,7 @@ function body(model: string) { // connections each test seeds, which is what these assertions are actually // about (LKGP pinning and variant pool resolution) — rather than weakening the // assertions to accept whatever the open pool happens to pick. -const NO_AUTH_PROVIDER_IDS = [ - "opencode", - "duckduckgo-web", - "theoldllm", - "chipotle", - "veoaifree-web", - "auggie", -]; +const NO_AUTH_PROVIDER_IDS = ["opencode", "duckduckgo-web", "chipotle", "veoaifree-web", "auggie"]; test.beforeEach(async () => { BaseExecutor.RETRY_CONFIG.delayMs = 0; diff --git a/tests/integration/freeModelBenchmarkShared.ts b/tests/integration/freeModelBenchmarkShared.ts index e7a38a4844..caf5cd8e78 100644 --- a/tests/integration/freeModelBenchmarkShared.ts +++ b/tests/integration/freeModelBenchmarkShared.ts @@ -44,9 +44,7 @@ export const NO_AUTH_PROVIDER_IDS = new Set(["aihorde", "opencode", "duckduckgo- // above, which needed no configuration at all — they were just never // exercised. duckduckgo-web is kept in despite being currently broken // upstream (400 ERR_BAD_REQUEST as of this writing) because that's a real, -// reportable data point, not benchmark noise. theoldllm was tried and -// dropped: this deployment's egress IP is blocked by Vercel for it (403), -// an environment limitation, not a model worth benchmarking here. +// reportable data point, not benchmark noise. // // One or two representative models per provider, not the full catalog: a // full sweep of every free model across every provider would be a multi-hour diff --git a/tests/snapshots/executors/executor-map.json b/tests/snapshots/executors/executor-map.json index 23eedb21a9..4640c17599 100644 --- a/tests/snapshots/executors/executor-map.json +++ b/tests/snapshots/executors/executor-map.json @@ -550,21 +550,11 @@ "configSource": "", "provider": "tencent-aistudio-web" }, - "theoldllm": { - "className": "TheOldLlmExecutor", - "configSource": "", - "provider": "theoldllm" - }, "tinycms-web": { "className": "TinyCmsExecutor", "configSource": "", "provider": "tinycms-web" }, - "tllm": { - "className": "TheOldLlmExecutor", - "configSource": "", - "provider": "theoldllm" - }, "trae": { "className": "TraeExecutor", "configSource": "trae", @@ -676,6 +666,6 @@ "provider": "zai-web" } }, - "keyCount": 135, + "keyCount": 133, "sharedInstances": [] } diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index a53e570e75..5e81c58888 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -5567,29 +5567,6 @@ "stream": "https://aistudio.tencent.ai/api/chat" } }, - "theoldllm": { - "format": "openai", - "headers": { - "apiKey": { - "Accept": "text/event-stream", - "Authorization": "Bearer ", - "Content-Type": "application/json" - }, - "nonStream": { - "Authorization": "Bearer ", - "Content-Type": "application/json" - }, - "oauth": { - "Accept": "text/event-stream", - "Authorization": "Bearer ", - "Content-Type": "application/json" - } - }, - "url": { - "nonStream": "https://theoldllm.vercel.app/api/chatgpt", - "stream": "https://theoldllm.vercel.app/api/chatgpt" - } - }, "tinycms-web": { "format": "openai", "headers": { diff --git a/tests/theoldllm-stress.test.ts b/tests/theoldllm-stress.test.ts deleted file mode 100644 index e4b6ab59c6..0000000000 --- a/tests/theoldllm-stress.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { describe, it, beforeEach } from "node:test"; -import assert from "node:assert"; -import { TheOldLlmExecutor, tokenCache } from "../open-sse/executors/theoldllm.ts"; - -const executor = new TheOldLlmExecutor(); - -const MOCK_SSE = [ - 'data: {"choices":[{"delta":{"content":"hi"},"index":0,"finish_reason":null}]}', - "data: [DONE]", - "", -].join("\n"); - -const MOCK_ERR = JSON.stringify({ - error: { message: "auth", type: "access_denied" }, -}); - -function makeResponse(status: number, body = MOCK_SSE) { - return { - status, - ok: status < 300, - statusText: status < 300 ? "OK" : "Error", - headers: new Map([["content-type", "application/json"]]), - text: async () => body, - } as unknown as Response; -} - -function warmTokenCache() { - tokenCache.value = "test-token-abc123"; - tokenCache.expiresAt = Date.now() + 15 * 60 * 1000; -} - -function clearTokenCache() { - tokenCache.value = ""; - tokenCache.expiresAt = 0; -} - -describe("TheOldLlmExecutor", () => { - it("buildHeaders returns static upstream headers", () => { - const headers = (executor as any).buildHeaders({}); - assert.strictEqual(headers["Content-Type"], "application/json"); - assert.ok( - headers["User-Agent"].includes("Chrome/"), - `expected Chrome UA, got ${headers["User-Agent"]}` - ); - assert.ok( - headers["User-Agent"].includes("Mozilla/5.0"), - `expected Mozilla UA, got ${headers["User-Agent"]}` - ); - }); - - it("maps model aliases to upstream slugs", () => { - const cases: Record = { - "gpt-5.4": "GPT_5_4", - GPT_5_3: "GPT_5_3", - gpt_5_2: "GPT_5_2", - "gpt-4o": "GPT_4O", - "claude-4.6-opus": "CLAUDE_4_6_OPUS", - "claude sonnet 4": "CLAUDE_4_6_SONNET", - claude_haiku_3_5: "CLAUDE_4_5_HAIKU", - "weird-model": "GPT_5_4", - }; - - const transformRequest = (executor as any).transformRequest.bind(executor) as ( - model: string, - body: Record, - stream: boolean - ) => Record; - - for (const [model, expected] of Object.entries(cases)) { - const updated = transformRequest(model, { model, messages: [] }, true); - assert.strictEqual( - updated.model, - expected, - `model ${model} mapped to ${expected}, got ${updated.model}` - ); - } - }); - - it("returns true on 200 and false on 401 for testConnection", async () => { - const originalFetch = globalThis.fetch; - try { - globalThis.fetch = async () => makeResponse(200) as any; - assert.strictEqual( - await executor.testConnection({}, null, { - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - true - ); - - globalThis.fetch = async () => makeResponse(401) as any; - assert.strictEqual( - await executor.testConnection({}, null, { - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }), - false - ); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("retries once after 401 then succeeds", async () => { - const originalFetch = globalThis.fetch; - warmTokenCache(); - try { - let calls = 0; - const responses = [() => makeResponse(401, MOCK_ERR), () => makeResponse(200, MOCK_SSE)]; - - globalThis.fetch = async () => - responses[calls++ < responses.length ? calls - 1 : responses.length - 1]() as any; - - const result = await executor.execute({ - model: "gpt-5.4", - body: { messages: [{ role: "user", content: "hai" }], stream: true }, - stream: true, - signal: null, - credentials: {}, - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }, - }); - - assert.strictEqual((result as any).response.status, 200); - assert.ok(calls >= 2, `expected >=2 fetch calls, got ${calls}`); - } finally { - globalThis.fetch = originalFetch; - clearTokenCache(); - } - }); - - it("does not retry a Vercel egress denial as a stale request token", async () => { - const originalFetch = globalThis.fetch; - try { - let calls = 0; - globalThis.fetch = async () => { - calls++; - return new Response( - JSON.stringify({ error: { code: "403", message: "Forbidden", id: "fra1::test" } }), - { - status: 403, - headers: { - "content-type": "application/json", - "x-vercel-mitigated": "deny", - }, - } - ); - }; - - const result = await executor.execute({ - model: "gpt-5.4", - body: { messages: [{ role: "user", content: "ping" }] }, - stream: true, - signal: null, - credentials: {}, - log: { debug() {}, info() {}, warn() {}, error() {} }, - }); - - assert.strictEqual(calls, 1); - assert.strictEqual(result.response.status, 403); - const json = (await result.response.json()) as { - error?: { code?: string; message?: string }; - }; - assert.strictEqual(json.error?.code, "THEOLDLLM_VERCEL_MITIGATED"); - assert.match(json.error?.message || "", /residential.*proxy/i); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("lets cancellation abort before upstream work", async () => { - const controller = new AbortController(); - controller.abort(new Error("cancelled")); - warmTokenCache(); - - let fetchCalls = 0; - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => { - fetchCalls++; - return makeResponse(200) as any; - }; - - try { - await executor.execute({ - model: "gpt-5.4", - body: { messages: [{ role: "user", content: "ping" }], stream: true }, - stream: true, - signal: controller.signal, - credentials: {}, - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }, - }); - - assert.strictEqual(fetchCalls, 0); - } finally { - globalThis.fetch = originalFetch; - clearTokenCache(); - } - }); - - it("handles concurrent calls with cached token", async () => { - const originalFetch = globalThis.fetch; - warmTokenCache(); - try { - let fetchCalls = 0; - - globalThis.fetch = async () => { - fetchCalls++; - return makeResponse(200) as any; - }; - - const requests = Array.from({ length: 4 }, () => - executor.execute({ - model: "gpt-5.4", - body: { messages: [{ role: "user", content: "ping" }], stream: true }, - stream: true, - signal: null, - credentials: {}, - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }, - }) - ); - - await Promise.all(requests); - assert.ok(fetchCalls >= 1, `expected >=1 fetch calls, got ${fetchCalls}`); - } finally { - globalThis.fetch = originalFetch; - clearTokenCache(); - } - }); - - it("fast fails on network error", async () => { - const originalFetch = globalThis.fetch; - warmTokenCache(); - try { - globalThis.fetch = async () => { - const error = new Error("ECONNREFUSED"); - (error as any).cause = new Error("ECONNREFUSED"); - throw error; - }; - - const result = await executor.execute({ - model: "gpt-5.4", - body: { messages: [{ role: "user", content: "ping" }], stream: true }, - stream: true, - signal: null, - credentials: {}, - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }, - }); - - assert.strictEqual((result as any).response.status, 502); - } finally { - globalThis.fetch = originalFetch; - clearTokenCache(); - } - }); -}); diff --git a/tests/unit/accountfallback-ratelimit-400-4976.test.ts b/tests/unit/accountfallback-ratelimit-400-4976.test.ts index d99854894e..9f4fad6b0b 100644 --- a/tests/unit/accountfallback-ratelimit-400-4976.test.ts +++ b/tests/unit/accountfallback-ratelimit-400-4976.test.ts @@ -18,14 +18,14 @@ test("#4976 400 with rate-limit text (MiMoCode) → fallback with RATE_LIMIT_EXC "Detected high-frequency non-compliant requests from you.", 0, null, - "theoldllm" + "chipotle" ); assert.equal(res.shouldFallback, true); assert.equal(res.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); }); test("#4976 400 with Chinese rate-limit text → fallback with RATE_LIMIT_EXCEEDED", () => { - const res = checkFallbackError(400, "检测到您的请求频率过高,请稍后再试", 0, null, "theoldllm"); + const res = checkFallbackError(400, "检测到您的请求频率过高,请稍后再试", 0, null, "chipotle"); assert.equal(res.shouldFallback, true); assert.equal(res.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); }); diff --git a/tests/unit/base-executor-buildheaders-extra-keys-8493.test.ts b/tests/unit/base-executor-buildheaders-extra-keys-8493.test.ts index be947e85d4..fee0970ee8 100644 --- a/tests/unit/base-executor-buildheaders-extra-keys-8493.test.ts +++ b/tests/unit/base-executor-buildheaders-extra-keys-8493.test.ts @@ -5,7 +5,7 @@ import { BaseExecutor } from "../../open-sse/executors/base.ts"; /** * Generic BaseExecutor consumer — no buildHeaders() override — representing - * every provider (xai, cliproxyapi, chipotle, mimocode, ninerouter, theoldllm, + * every provider (xai, cliproxyapi, chipotle, mimocode, ninerouter, * gitlab, ...) that relies on BaseExecutor.buildHeaders() as-is. * * Regression guard for #8467/#8493: resolveEffectiveKey() already rotates to diff --git a/tests/unit/deepseek-native-max-effort.test.ts b/tests/unit/deepseek-native-max-effort.test.ts index 94329a610c..423dbebbdf 100644 --- a/tests/unit/deepseek-native-max-effort.test.ts +++ b/tests/unit/deepseek-native-max-effort.test.ts @@ -14,7 +14,7 @@ * * Guards: A = `max` survives for native DeepSeek models; B = `max` stays canonical * for every other provider (sanitizer maps per-upstream later); C = routed DeepSeek - * namespaces (openrouter/tllm) are NOT treated as native; D = an explicit client + * namespaces (openrouter/oc) are NOT treated as native; D = an explicit client * `reasoning_effort` still wins; E = catalog effort-tier extension is idempotent. */ import test from "node:test"; @@ -65,11 +65,7 @@ test("B: `max` is a first-class canonical value for every other provider", () => test("C: routed DeepSeek namespaces are not treated as the native provider", () => { // These terminate at a different upstream whose effort vocabulary we do not control. - for (const model of [ - "openrouter/deepseek/deepseek-v4-flash-0731", - "tllm/deepseek_v4", - "oc/deepseek-v4-flash-free", - ]) { + for (const model of ["openrouter/deepseek/deepseek-v4-flash-0731", "oc/deepseek-v4-flash-free"]) { assert.equal(isDeepSeekNativeMaxModel(null, model), false, `${model} is not native`); const out = normalizeReasoningRequest({ model, effort: "max" }) as Record; assert.equal(out.reasoning_effort, "max"); diff --git a/tests/unit/discontinued-providers-2026.test.ts b/tests/unit/discontinued-providers-2026.test.ts index 88f963ef91..61c1260de5 100644 --- a/tests/unit/discontinued-providers-2026.test.ts +++ b/tests/unit/discontinued-providers-2026.test.ts @@ -74,19 +74,11 @@ describe("2026 discontinued free tiers — providers.ts hasFree reconciliation", }); it("intentionally-kept providers still advertise free (genuinely free / ToS-flagged, not flipped)", async () => { - const { NOAUTH_PROVIDERS, APIKEY_PROVIDERS } = - await import("../../src/shared/constants/providers.ts"); - // theoldllm is a keyless, no-signup web chat (genuinely free, just no catalogable API tier) — kept. + const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); // iflytek/sparkdesk stay hasFree:true but carry a ToS-caution freeNote (Spark Lite is free, the ToS // restricts proxy/relay use). gitlawb/gitlawb-gmi/aimlapi/yi were re-verified dead 2026-06-18 and are // asserted false above — keeping them out of this list guards against a silent re-flip-to-true. - const noauth = NOAUTH_PROVIDERS as Record; const apikey = APIKEY_PROVIDERS as Record; - assert.strictEqual( - noauth["theoldllm"]?.hasFree, - true, - "theoldllm intentionally kept hasFree:true" - ); assert.strictEqual(apikey["iflytek"]?.hasFree, true, "iflytek kept free with ToS-caution note"); assert.match( apikey["iflytek"]?.freeNote ?? "", diff --git a/tests/unit/errorClassifier-noauth-403-6315.test.ts b/tests/unit/errorClassifier-noauth-403-6315.test.ts index 21c0c185e8..e26d2aedf7 100644 --- a/tests/unit/errorClassifier-noauth-403-6315.test.ts +++ b/tests/unit/errorClassifier-noauth-403-6315.test.ts @@ -9,9 +9,9 @@ import { classifyProviderError } from "../../open-sse/services/errorClassifier.t // 403 should be RECOVERABLE (null) and handled by the existing connection // cooldown/retry layer, same as apikey providers already are. -test("#6345: theoldllm 'Request blocked'/access_denied 403 -> recoverable (null), not FORBIDDEN", () => { +test("#6345: no-credential provider 'Request blocked'/access_denied 403 -> recoverable (null), not FORBIDDEN", () => { const body = { error: "Request blocked", type: "access_denied" }; - assert.equal(classifyProviderError(403, body, "theoldllm"), null); + assert.equal(classifyProviderError(403, body, "chipotle"), null); }); test("control: apikey-provider bare 403 still recoverable (null) — no regression", () => { diff --git a/tests/unit/free-model-catalog.test.ts b/tests/unit/free-model-catalog.test.ts index c50442b92c..6c69121d78 100644 --- a/tests/unit/free-model-catalog.test.ts +++ b/tests/unit/free-model-catalog.test.ts @@ -82,7 +82,7 @@ test("deposit-unlock boost is reported separately, not folded into steady", () = test("2026-06-17 refresh: discontinued providers dropped, new free providers added", () => { const providers = new Set(FREE_MODEL_BUDGETS.map((m) => m.provider)); // dead in 2026 — must be gone from the budget catalog - for (const dead of ["chutes", "phind", "kluster", "gitlawb", "aimlapi", "theoldllm"]) { + for (const dead of ["chutes", "phind", "kluster", "gitlawb", "aimlapi"]) { assert.ok(!providers.has(dead), `${dead} should be removed (discontinued)`); } assert.equal(providers.has("qwen-web"), false, "retired qwen-web must stay out of routing"); diff --git a/tests/unit/free-provider-onboarding-selector.test.ts b/tests/unit/free-provider-onboarding-selector.test.ts index 4263f64495..e506b273ef 100644 --- a/tests/unit/free-provider-onboarding-selector.test.ts +++ b/tests/unit/free-provider-onboarding-selector.test.ts @@ -17,9 +17,7 @@ test("free onboarding candidates come from the no-auth registry and exclude loca assert.ok(ids.includes("opencode")); assert.ok(ids.includes("duckduckgo-web")); assert.ok(!ids.includes("felo-web")); - assert.ok(ids.includes("theoldllm")); assert.ok(ids.includes("chipotle")); - assert.ok(ids.includes("theoldllm")); assert.ok(ids.includes("aihorde")); assert.ok(!ids.includes("devin-cli-agentic")); assert.ok(!ids.includes("auggie")); diff --git a/tests/unit/free-provider-onboarding-setup.test.ts b/tests/unit/free-provider-onboarding-setup.test.ts index 6f71623fca..4433c58d3f 100644 --- a/tests/unit/free-provider-onboarding-setup.test.ts +++ b/tests/unit/free-provider-onboarding-setup.test.ts @@ -10,7 +10,7 @@ test("batch setup creates missing providers, skips existing ones, and is retry-s const existing = [{ provider: "opencode", name: "My customized OpenCode" }]; const created: Array<{ provider: string; name: string }> = []; const candidates = getEligibleFreeOnboardingProviders(); - const requestedIds = ["opencode", "theoldllm"]; + const requestedIds = ["opencode", "chipotle"]; const first = await setupFreeProviderConnections({ requestedIds, @@ -33,14 +33,14 @@ test("batch setup creates missing providers, skips existing ones, and is retry-s assert.deepEqual(first.results, [ { providerId: "opencode", status: "skipped", reason: "already-configured" }, - { providerId: "theoldllm", status: "created", connectionId: "created-theoldllm" }, + { providerId: "chipotle", status: "created", connectionId: "created-chipotle" }, ]); assert.deepEqual(second.results, [ { providerId: "opencode", status: "skipped", reason: "already-configured" }, - { providerId: "theoldllm", status: "skipped", reason: "already-configured" }, + { providerId: "chipotle", status: "skipped", reason: "already-configured" }, ]); assert.deepEqual(existing, [{ provider: "opencode", name: "My customized OpenCode" }]); - assert.deepEqual(created, [{ provider: "theoldllm", name: "The Old LLM (Free)" }]); + assert.deepEqual(created, [{ provider: "chipotle", name: "Chipotle Pepper AI (Free)" }]); }); test("batch setup rejects unknown or ineligible IDs before creating anything", async () => { @@ -63,13 +63,13 @@ test("batch setup rejects unknown or ineligible IDs before creating anything", a test("partial failures are reported per provider and can be retried", async () => { const created = new Set(); - let oldllmAttempts = 0; + let chipotleAttempts = 0; const input = { - requestedIds: ["opencode", "theoldllm"], + requestedIds: ["opencode", "chipotle"], candidates: getEligibleFreeOnboardingProviders(), listExisting: async () => [...created].map((provider) => ({ provider })), create: async ({ provider }: { provider: string }) => { - if (provider === "theoldllm" && oldllmAttempts++ === 0) throw new Error("upstream detail"); + if (provider === "chipotle" && chipotleAttempts++ === 0) throw new Error("upstream detail"); created.add(provider); return { id: `created-${provider}` }; }, @@ -80,10 +80,10 @@ test("partial failures are reported per provider and can be retried", async () = assert.deepEqual(first.results, [ { providerId: "opencode", status: "created", connectionId: "created-opencode" }, - { providerId: "theoldllm", status: "failed", reason: "Failed to create provider" }, + { providerId: "chipotle", status: "failed", reason: "Failed to create provider" }, ]); assert.deepEqual(retry.results, [ { providerId: "opencode", status: "skipped", reason: "already-configured" }, - { providerId: "theoldllm", status: "created", connectionId: "created-theoldllm" }, + { providerId: "chipotle", status: "created", connectionId: "created-chipotle" }, ]); }); diff --git a/tests/unit/live-model-catalog-reconciliation-8926.test.ts b/tests/unit/live-model-catalog-reconciliation-8926.test.ts index 833c2d8551..4bd415dee3 100644 --- a/tests/unit/live-model-catalog-reconciliation-8926.test.ts +++ b/tests/unit/live-model-catalog-reconciliation-8926.test.ts @@ -187,7 +187,6 @@ test("#8926: live authority defaults to strict and honors explicit partial-disco assert.equal(providerUsesAuthoritativeLiveCatalog("github"), true); assert.equal(providerUsesAuthoritativeLiveCatalog("cursor"), true); assert.equal(providerUsesAuthoritativeLiveCatalog("unknown-provider-8926"), true); - assert.equal(providerUsesAuthoritativeLiveCatalog("theoldllm"), true); assert.equal(providerUsesAuthoritativeLiveCatalog("command-code"), false); }); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index e6216899f0..cfecc23c96 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -175,11 +175,11 @@ test("v1 models catalog includes display names by default", async () => { new Request("http://localhost/api/v1/models") ); const body = (await response.json()) as any; - const model = body.data.find((item) => item.id === "tllm/claude_sonnet_4"); + const model = body.data.find((item) => item.id === "oc/big-pickle"); assert.equal(response.status, 200); assert.ok(model); - assert.equal(model.name, "Claude Sonnet 4 (The Old LLM 🆓)"); + assert.equal(model.name, "Big Pickle"); }); test("v1 models catalog omits display names when the feature flag is disabled", async () => { @@ -190,12 +190,12 @@ test("v1 models catalog omits display names when the feature flag is disabled", new Request("http://localhost/api/v1/models") ); const body = (await response.json()) as any; - const model = body.data.find((item) => item.id === "tllm/claude_sonnet_4"); + const model = body.data.find((item) => item.id === "oc/big-pickle"); assert.equal(response.status, 200); assert.ok(model); assert.equal("name" in model, false); - assert.equal(model.root, "claude_sonnet_4"); + assert.equal(model.root, "big-pickle"); } finally { featureFlagsDb.removeFeatureFlagOverride("MODEL_CATALOG_INCLUDE_NAMES"); } diff --git a/tests/unit/noauth-autocombo-allowlist.test.ts b/tests/unit/noauth-autocombo-allowlist.test.ts index 70789f5887..1c8d05abd5 100644 --- a/tests/unit/noauth-autocombo-allowlist.test.ts +++ b/tests/unit/noauth-autocombo-allowlist.test.ts @@ -4,7 +4,7 @@ * our reference egress. As of this change that allowlist is narrowed to * `opencode`: on the reference VPS (.15) it answers 200 with zero configuration. * The other no-auth providers - * (duckduckgo-web, theoldllm, chipotle, aihorde) stay OUT of every auto/* pool + * (duckduckgo-web, chipotle, aihorde) stay OUT of every auto/* pool * until re-verified — they remain usable via direct `/` calls, they * are just not auto-routed to. * @@ -47,7 +47,7 @@ test.after(async () => { }); const ALLOWED_NOAUTH_PROVIDERS = ["opencode"]; -const EXCLUDED_NOAUTH_PROVIDERS = ["duckduckgo-web", "theoldllm", "chipotle", "aihorde"]; +const EXCLUDED_NOAUTH_PROVIDERS = ["duckduckgo-web", "chipotle", "aihorde"]; test("fresh install: the allowlisted no-auth providers are present in the auto-combo pool", async () => { const combo = await virtualFactory.createVirtualAutoCombo(undefined); diff --git a/tests/unit/noauth-imported-models-3200.test.ts b/tests/unit/noauth-imported-models-3200.test.ts index 9f6a24d1b4..83ed6e9766 100644 --- a/tests/unit/noauth-imported-models-3200.test.ts +++ b/tests/unit/noauth-imported-models-3200.test.ts @@ -4,7 +4,7 @@ // // Root cause: the custom-models loop in catalog.ts gated every model through // hasEligibleConnectionForModel(getConnectionsForProvider(...)). noAuth providers -// (e.g. theoldllm / alias "tllm") have NO DB connection rows, so getConnectionsForProvider +// (e.g. chipotle / alias "pepper") have NO DB connection rows, so getConnectionsForProvider // returns [] and hasEligibleConnectionForModel([]) === false → the model was dropped. // Built-in models survived because they go through providerSupportsModel(), which has a // noAuth bypass (#2798). This test asserts an IMPORTED model on a noAuth provider appears. @@ -42,12 +42,12 @@ test.after(async () => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); -test("#3200 imported model on a noAuth provider (theoldllm) appears in /api/v1/models", async () => { - // theoldllm is a noAuth provider (alias "tllm") — it never creates a DB connection row. - // Import a model that is NOT a built-in theoldllm model, so its presence is solely due +test("#3200 imported model on a noAuth provider (chipotle) appears in /api/v1/models", async () => { + // chipotle is a noAuth provider (alias "pepper") — it never creates a DB connection row. + // Import a model that is NOT a built-in chipotle model, so its presence is solely due // to the custom/imported path (the path the bug breaks). await modelsDb.addCustomModel( - "theoldllm", + "chipotle", "my-imported-model-3200", "My Imported Model", "imported" @@ -61,7 +61,7 @@ test("#3200 imported model on a noAuth provider (theoldllm) appears in /api/v1/m assert.equal(response.status, 200); assert.ok( - ids.has("tllm/my-imported-model-3200"), + ids.has("pepper/my-imported-model-3200"), "imported model on noAuth provider must appear under its alias prefix" ); }); @@ -89,9 +89,9 @@ test("#3200 custom/imported models on auth providers still appear (no regression }); test("#3200 imported models on noAuth providers are hidden when the provider is disabled", async () => { - await settingsDb.updateSettings({ blockedProviders: ["theoldllm"] }); + await settingsDb.updateSettings({ blockedProviders: ["chipotle"] }); await modelsDb.addCustomModel( - "theoldllm", + "chipotle", "my-imported-model-disabled", "Hidden Imported Model", "imported" @@ -105,7 +105,7 @@ test("#3200 imported models on noAuth providers are hidden when the provider is assert.equal(response.status, 200); assert.equal( - ids.has("tllm/my-imported-model-disabled"), + ids.has("pepper/my-imported-model-disabled"), false, "imported noAuth provider models must stay hidden while the provider is disabled" ); diff --git a/tests/unit/noauth-provider-validation.test.ts b/tests/unit/noauth-provider-validation.test.ts index 446dec8ded..8eb0de94eb 100644 --- a/tests/unit/noauth-provider-validation.test.ts +++ b/tests/unit/noauth-provider-validation.test.ts @@ -1,6 +1,6 @@ /** * Tests for noAuth provider validation: - * - Bug 1: `theoldllm` and `chipotle` missing from providerAllowsOptionalApiKey + * - Bug 1: `chipotle` missing from providerAllowsOptionalApiKey * - `kimi` API key provider stays on the dedicated Moonshot executor */ import test from "node:test"; @@ -14,13 +14,7 @@ import { import { hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; // Bug 1: all noAuth providers should allow optional API key -for (const provider of [ - "theoldllm", - "chipotle", - "opencode", - "duckduckgo-web", - "veoaifree-web", -]) { +for (const provider of ["chipotle", "opencode", "duckduckgo-web", "veoaifree-web"]) { test(`${provider} allows optional API key (noAuth provider)`, () => { assert.equal(providerAllowsOptionalApiKey(provider), true); }); @@ -42,10 +36,9 @@ test("kimi-coding-apikey still has specialized executor", () => { test("provider proxy controls use a centralized no-auth capability allowlist", () => { assert.equal(supportsNoAuthProviderProxy("opencode"), true); - assert.equal(supportsNoAuthProviderProxy("theoldllm"), true); for (const providerId of Object.keys(NOAUTH_PROVIDERS)) { - if (providerId !== "opencode" && providerId !== "theoldllm") { + if (providerId !== "opencode") { assert.equal(supportsNoAuthProviderProxy(providerId), false, providerId); } } diff --git a/tests/unit/provider-assets-generic-fallback.test.mjs b/tests/unit/provider-assets-generic-fallback.test.mjs index af9f3299d5..2f59cd805a 100644 --- a/tests/unit/provider-assets-generic-fallback.test.mjs +++ b/tests/unit/provider-assets-generic-fallback.test.mjs @@ -50,7 +50,6 @@ const LOCAL_SVG_IDS_WITHOUT_PROVENANCE = [ "serper-search", "soniox", "synthetic", - "theoldllm", "unorouter", "wandb", "youcom-search", @@ -178,9 +177,9 @@ const AUDITED_REFERENCE_FILES = [ ...referenceRoots.flatMap((directory) => collectTextFiles(join(root, directory))), ]; -test("provider bundle retires exactly the 79 unresolved assets and keeps the generic icon", () => { - assert.equal(retiredAssetNames.length, 79); - assert.equal(new Set(retiredAssetNames).size, 79); +test("provider bundle retires exactly the 78 unresolved assets and keeps the generic icon", () => { + assert.equal(retiredAssetNames.length, 78); + assert.equal(new Set(retiredAssetNames).size, 78); for (const assetName of retiredAssetNames) { assert.equal( diff --git a/tests/unit/provider-model-filter-live-catalog-7250.test.ts b/tests/unit/provider-model-filter-live-catalog-7250.test.ts index 36e11eb327..5f27637662 100644 --- a/tests/unit/provider-model-filter-live-catalog-7250.test.ts +++ b/tests/unit/provider-model-filter-live-catalog-7250.test.ts @@ -7,7 +7,7 @@ const providerPageUtils = // #7250: the Providers page model-name filter only matched against the static // curated model registry (getModelsByProviderId), never against a provider's // live/synced catalog. Aggregator providers (openrouter, kilocode, -// theoldllm...) declare a single-entry static placeholder +// ...) declare a single-entry static placeholder // (`{ id: "auto", name: "Auto (Best Available)" }` for openrouter), so a // search for any real upstream model name — e.g. "laguna" — could never // match, and the whole provider silently disappeared from the list. diff --git a/tests/unit/provider-node-reserved-prefix.test.ts b/tests/unit/provider-node-reserved-prefix.test.ts index 998f00945f..f701311927 100644 --- a/tests/unit/provider-node-reserved-prefix.test.ts +++ b/tests/unit/provider-node-reserved-prefix.test.ts @@ -175,7 +175,9 @@ test("shared set size includes live REGISTRY and retired Designer + Felo + Qwen // alias "ucn", and the Developer API id "uc-direct" + alias "ucd" (402 → 406). // #12389: the gemini-business registry entry adds its id "gemini-business" and // alias "gembiz" to the REGISTRY walk (406 → 408). - assert.equal(RESERVED_PREFIX_COUNT, 408); + // 2026-09-02: a keyless provider was removed at its operator's request, taking its id and + // alias out of the REGISTRY walk (408 → 406). + assert.equal(RESERVED_PREFIX_COUNT, 406); }); test("isReservedProviderPrefix rejects non-string input", () => { diff --git a/tests/unit/proxy-noauth-provider-6272.test.ts b/tests/unit/proxy-noauth-provider-6272.test.ts index ec64dd731c..ea520da16f 100644 --- a/tests/unit/proxy-noauth-provider-6272.test.ts +++ b/tests/unit/proxy-noauth-provider-6272.test.ts @@ -59,17 +59,17 @@ test("resolveProxyForConnection keeps provider-level no-auth proxies isolated", host: "127.0.0.2", port: 8889, }); - await settingsDb.setProxyForLevel("provider", "theoldllm", { + await settingsDb.setProxyForLevel("provider", "chipotle", { type: "http", host: "127.0.0.3", port: 8890, }); const opencode = await settingsDb.resolveProxyForConnection("noauth", undefined, "opencode"); - const theOldLlm = await settingsDb.resolveProxyForConnection("noauth", undefined, "theoldllm"); + const chipotle = await settingsDb.resolveProxyForConnection("noauth", undefined, "chipotle"); assert.equal(opencode?.proxy?.host, "127.0.0.2"); - assert.equal(theOldLlm?.proxy?.host, "127.0.0.3"); + assert.equal(chipotle?.proxy?.host, "127.0.0.3"); }); test("safeResolveProxy keeps the synthetic no-auth connection provider-specific", async () => { @@ -79,15 +79,15 @@ test("safeResolveProxy keeps the synthetic no-auth connection provider-specific" host: "127.0.0.4", port: 8891, }); - await settingsDb.setProxyForLevel("provider", "theoldllm", { + await settingsDb.setProxyForLevel("provider", "chipotle", { type: "http", host: "127.0.0.5", port: 8892, }); const opencode = await safeResolveProxy("noauth", undefined, "opencode"); - const theOldLlm = await safeResolveProxy("noauth", undefined, "theoldllm"); + const chipotle = await safeResolveProxy("noauth", undefined, "chipotle"); assert.equal(opencode?.proxy?.host, "127.0.0.4"); - assert.equal(theOldLlm?.proxy?.host, "127.0.0.5"); + assert.equal(chipotle?.proxy?.host, "127.0.0.5"); }); diff --git a/tests/unit/theoldllm-body-double-read-3296.test.ts b/tests/unit/theoldllm-body-double-read-3296.test.ts deleted file mode 100644 index c57aec8fdc..0000000000 --- a/tests/unit/theoldllm-body-double-read-3296.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { TheOldLlmExecutor, tokenCache } from "../../open-sse/executors/theoldllm.ts"; - -const SSE_BODY = - 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n' + - 'data: {"choices":[{"delta":{"content":" world"}}]}\n' + - "data: [DONE]\n"; - -// #3296: with a valid cached token the executor takes the direct-fetch path and -// never enters the token-refresh branch. It read the SAME upstream Response with -// .text() twice (once for the token-rejection check, once for the final body), -// which throws "Body is unusable: Body has already been read" → caught → [502]. -test("theoldllm does not double-read the upstream body on the cached-token path (#3296)", async () => { - const originalFetch = globalThis.fetch; - // Pre-populate the cached token so execute() uses the direct fetch (no Playwright). - tokenCache.value = "cached-token"; - tokenCache.expiresAt = Date.now() + 60_000; - - let fetchCalls = 0; - globalThis.fetch = (async () => { - fetchCalls += 1; - return new Response(SSE_BODY, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - }) as typeof fetch; - - try { - const executor = new TheOldLlmExecutor(); - const result = await executor.execute({ - model: "gpt-5.4", - body: { messages: [{ role: "user", content: "hi" }] }, - stream: false, - credentials: {} as never, - signal: null, - }); - - // Before the fix this was 502 with "Body has already been read". - assert.equal(result.response.status, 200); - assert.equal(fetchCalls, 1, "should fetch upstream exactly once on the cached-token path"); - - const json = (await result.response.json()) as { - choices?: Array<{ message?: { content?: string } }>; - }; - assert.equal(json.choices?.[0]?.message?.content, "Hello world"); - } finally { - globalThis.fetch = originalFetch; - tokenCache.value = ""; - tokenCache.expiresAt = 0; - } -}); diff --git a/tests/unit/theoldllm-context-length-4184.test.ts b/tests/unit/theoldllm-context-length-4184.test.ts deleted file mode 100644 index 385b635ac0..0000000000 --- a/tests/unit/theoldllm-context-length-4184.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -// Regression guard for #4184. -// -// The theoldllm provider (free OpenAI-compatible upstream) listed its models -// with NO contextLength, so getResolvedModelCapabilities resolved their context -// window to `null` and the dashboard/catalog reported no usable window. #4184 -// adds an entry-level `defaultContextLength` plus per-model `contextLength` -// overrides reflecting each upstream model's real window. This test asserts both -// the registry data (source of truth) and the resolved context window for the -// models that carry an explicit override — the latter would resolve to `null` -// on the pre-#4184 registry, so it fails without the fix. -const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts"); -const { getResolvedModelCapabilities } = await import("../../src/lib/modelCapabilities.ts"); - -function model(id: string) { - const entry = getRegistryEntry("theoldllm"); - assert.ok(entry, "theoldllm registry entry must exist"); - return (entry.models ?? []).find((m) => m.id === id); -} - -test("#4184 theoldllm entry declares a 200000 defaultContextLength", () => { - const entry = getRegistryEntry("theoldllm"); - assert.ok(entry, "theoldllm registry entry must exist"); - assert.equal(entry.defaultContextLength, 200000); -}); - -test("#4184 per-model contextLength overrides match each upstream window", () => { - assert.equal(model("GPT_5_4")?.contextLength, 400000, "GPT-5.4 window is 400K"); - assert.equal(model("gemini_3_flash")?.contextLength, 1000000, "Gemini 3 Flash window is 1M"); - assert.equal(model("gemini_3_pro")?.contextLength, 1000000, "Gemini 3 Pro window is 1M"); - for (const id of ["claude_opus_4", "claude_sonnet_4", "claude_haiku_3_5", "deepseek_v4"]) { - assert.equal(model(id)?.contextLength, 200000, `${id} window is 200K`); - } -}); - -test("#4184 GPT_4o carries no explicit contextLength (relies on defaultContextLength)", () => { - // Intentionally left to the entry default — documents the fallback contract so a - // later edit that removes defaultContextLength is caught by the assertion above. - assert.equal(model("GPT_4o")?.contextLength, undefined); -}); - -test("#4184 resolved context window reflects the override (null before the fix)", () => { - assert.equal( - getResolvedModelCapabilities({ provider: "theoldllm", model: "GPT_5_4" }).contextWindow, - 400000 - ); - assert.equal( - getResolvedModelCapabilities({ provider: "theoldllm", model: "gemini_3_pro" }).contextWindow, - 1000000 - ); - assert.equal( - getResolvedModelCapabilities({ provider: "theoldllm", model: "claude_opus_4" }).contextWindow, - 200000 - ); -}); diff --git a/tests/unit/theoldllm-model-refresh-5181.test.ts b/tests/unit/theoldllm-model-refresh-5181.test.ts deleted file mode 100644 index f41003268e..0000000000 --- a/tests/unit/theoldllm-model-refresh-5181.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -// Feature guard for #5181 — "Update The Old LLM (Free) model list". -// -// Two things this proves, both of which fail on the pre-#5181 code: -// 1. mapModel() now passes KNOWN upstream IDs through UNCHANGED. Before the fix, -// any non-GPT/Claude id (Gemini, o-series, Grok, DeepSeek, Sonar) fell through -// to the `return "GPT_5_4"` default and silently misrouted every request. -// 2. The registry catalog is refreshed with the current free-tier models while -// keeping the legacy alias IDs for saved-preference backward compatibility. -const { mapModel, CHATGPT_UPSTREAM_MODELS } = await import( - "../../open-sse/executors/theoldllm.ts" -); -const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts"); - -function catalogIds(): string[] { - const entry = getRegistryEntry("theoldllm"); - assert.ok(entry, "theoldllm registry entry must exist"); - return (entry.models ?? []).map((m) => m.id); -} - -test("#5181 known upstream IDs pass through mapModel unchanged (Gemini no longer misroutes to GPT_5_4)", () => { - // These are the exact cases the old default clause broke. - assert.equal(mapModel("gemini_3_pro"), "gemini_3_pro"); - assert.equal(mapModel("gemini_2_5_pro"), "gemini_2_5_pro"); - assert.equal(mapModel("gemini_2_0_flash"), "gemini_2_0_flash"); - assert.equal(mapModel("openrouter_grok_4"), "openrouter_grok_4"); - assert.equal(mapModel("together_deepseek_v3"), "together_deepseek_v3"); - assert.equal(mapModel("sonar-pro"), "sonar-pro"); - assert.equal(mapModel("GPT_o4_mini"), "GPT_o4_mini"); - // Every declared upstream id must round-trip through mapModel unchanged. - for (const id of CHATGPT_UPSTREAM_MODELS) { - assert.equal(mapModel(id), id, `${id} must route unchanged`); - } -}); - -test("#5181 legacy alias IDs still map to available upstream models (backward compatibility)", () => { - assert.equal(mapModel("claude_opus_4"), "CLAUDE_4_6_OPUS"); - assert.equal(mapModel("claude_sonnet_4"), "CLAUDE_4_6_SONNET"); - assert.equal(mapModel("claude_haiku_3_5"), "CLAUDE_4_5_HAIKU"); - assert.equal(mapModel("gpt-5.4"), "GPT_5_4"); - assert.equal(mapModel("gpt-4o"), "GPT_4O"); -}); - -test("#5181 catalog is refreshed with the current free-tier models", () => { - const ids = catalogIds(); - for (const id of [ - "GPT_5_3", - "GPT_5_2", - "GPT_5_1", - "GPT_5", - "GPT_o4_mini", - "GPT_o3_mini", - "gemini_2_5_pro", - "gemini_2_0_flash", - "gemini_1_5_flash", - "CLAUDE_4_6_OPUS", - "CLAUDE_4_6_SONNET", - "CLAUDE_4_5_HAIKU", - "openrouter_grok_4", - "sonar-pro", - ]) { - assert.ok(ids.includes(id), `catalog must include refreshed model ${id}`); - } -}); - -test("#5181 legacy catalog entries are preserved (no breaking removal of saved-preference IDs)", () => { - const ids = catalogIds(); - for (const id of ["GPT_5_4", "GPT_4o", "claude_opus_4", "gemini_3_pro"]) { - assert.ok(ids.includes(id), `legacy catalog id ${id} must be preserved`); - } -}); - -test("#5181 every refreshed catalog id routes to a valid upstream model", () => { - // No catalog id may fall through to the GPT_5_4 default unless it is genuinely a - // GPT-5 alias — Gemini/Grok/DeepSeek/Sonar/Claude entries must resolve to their - // own upstream id, not silently collapse onto GPT_5_4. - const nonGptExpectations: Record = { - gemini_2_5_pro: "gemini_2_5_pro", - gemini_2_0_flash: "gemini_2_0_flash", - gemini_1_5_flash: "gemini_1_5_flash", - CLAUDE_4_6_OPUS: "CLAUDE_4_6_OPUS", - openrouter_grok_4: "openrouter_grok_4", - "sonar-pro": "sonar-pro", - }; - for (const [id, expected] of Object.entries(nonGptExpectations)) { - assert.equal(mapModel(id), expected); - } -}); diff --git a/tests/unit/theoldllm-provider-proxy.test.ts b/tests/unit/theoldllm-provider-proxy.test.ts deleted file mode 100644 index 85505d84bf..0000000000 --- a/tests/unit/theoldllm-provider-proxy.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { fetchTheOldLlmWithProviderProxy } from "../../open-sse/executors/theoldllm.ts"; - -test("theoldllm dispatches through its provider proxy assignment", async () => { - const assignedProxy = { - type: "http", - host: "residential.example", - port: 8080, - username: "user", - password: "secret", - family: "ipv4", - name: "residential-primary", - }; - let observedProxy: unknown = null; - let fetchCalls = 0; - - const response = await fetchTheOldLlmWithProviderProxy( - { model: "GPT_5_4", messages: [], stream: true }, - new AbortController().signal, - { - resolveProxy: async () => assignedProxy, - runWithProxy: async (proxy, request) => { - observedProxy = proxy; - return request(); - }, - fetch: (async () => { - fetchCalls++; - return new Response("ok", { status: 200 }); - }) as typeof fetch, - } - ); - - assert.equal(response.status, 200); - assert.equal(fetchCalls, 1); - assert.deepEqual(observedProxy, assignedProxy); -}); - -test("theoldllm fails closed when an assigned proxy pool has no active proxy", async () => { - let fetchCalls = 0; - - await assert.rejects( - () => - fetchTheOldLlmWithProviderProxy( - { model: "GPT_5_4", messages: [], stream: true }, - new AbortController().signal, - { - resolveProxy: async () => null, - hasBlockingProxyAssignment: () => true, - runWithProxy: async (_proxy, request) => request(), - fetch: (async () => { - fetchCalls++; - return new Response("unexpected", { status: 200 }); - }) as typeof fetch, - } - ), - /No active proxy is available/ - ); - - assert.equal(fetchCalls, 0, "a dead assigned proxy pool must never fall back to direct egress"); -}); diff --git a/tests/unit/theoldllm-request-token-3491.test.ts b/tests/unit/theoldllm-request-token-3491.test.ts deleted file mode 100644 index 9000f90fbb..0000000000 --- a/tests/unit/theoldllm-request-token-3491.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { generateRequestToken } from "../../open-sse/executors/theoldllm.ts"; - -// #3491: the X-Request-Token is now generated server-side (mirroring the SPA's -// rie()) instead of intercepted via Playwright. Lock the wire contract so a -// future refactor can't silently change the shape the upstream validator expects: -// `${base36(Date.now())}-${base36(abs(djb2))}-${8 hex chars}` -test("generateRequestToken matches the rie() wire format (#3491)", () => { - const token = generateRequestToken(); - assert.match( - token, - /^[0-9a-z]+-[0-9a-z]+-[0-9a-f]{8}$/, - `token "${token}" must be base36(ts)-base36(hash)-8hex`, - ); - - const [tsSeg, hashSeg, randSeg] = token.split("-"); - // First segment decodes (base36) to a timestamp within a few seconds of now. - const decodedTs = parseInt(tsSeg, 36); - assert.ok( - Math.abs(Date.now() - decodedTs) < 10_000, - `decoded ts ${decodedTs} should be ~now`, - ); - // Hash segment is non-empty base36. - assert.ok(hashSeg.length > 0); - // Random suffix is exactly 8 hex chars (crypto.randomUUID slice). - assert.strictEqual(randSeg.length, 8); -}); - -test("generateRequestToken's random suffix differs across calls (#3491)", () => { - const a = generateRequestToken().split("-")[2]; - const b = generateRequestToken().split("-")[2]; - assert.notStrictEqual(a, b, "the 8-hex random suffix must vary per call"); -}); diff --git a/tests/unit/ui/ProviderIcon-icon-url.test.tsx b/tests/unit/ui/ProviderIcon-icon-url.test.tsx index 7ce75d2db7..95de173526 100644 --- a/tests/unit/ui/ProviderIcon-icon-url.test.tsx +++ b/tests/unit/ui/ProviderIcon-icon-url.test.tsx @@ -69,7 +69,6 @@ const PROVIDER_IDS_WITHOUT_LOCAL_ASSET_PROVENANCE = [ "serper-search", "soniox", "synthetic", - "theoldllm", "unorouter", "wandb", "youcom-search", @@ -245,8 +244,8 @@ describe("ProviderIcon — local SVG dimensions", () => { describe("ProviderIcon — unresolved local asset provenance", () => { it("covers the complete provider and alias inventory", () => { - expect(PROVIDER_IDS_WITHOUT_LOCAL_ASSET_PROVENANCE).toHaveLength(79); - expect(new Set(PROVIDER_IDS_WITHOUT_LOCAL_ASSET_PROVENANCE)).toHaveLength(79); + expect(PROVIDER_IDS_WITHOUT_LOCAL_ASSET_PROVENANCE).toHaveLength(78); + expect(new Set(PROVIDER_IDS_WITHOUT_LOCAL_ASSET_PROVENANCE)).toHaveLength(78); }); it.each(PROVIDER_IDS_WITHOUT_LOCAL_ASSET_PROVENANCE)( diff --git a/tests/unit/virtual-auto-combo.test.ts b/tests/unit/virtual-auto-combo.test.ts index 02a90f242a..e668451212 100644 --- a/tests/unit/virtual-auto-combo.test.ts +++ b/tests/unit/virtual-auto-combo.test.ts @@ -277,7 +277,7 @@ test("createVirtualAutoCombo restricts the no-auth pool to the allowlist", async ); } - for (const excluded of ["duckduckgo-web", "theoldllm", "chipotle", "aihorde"]) { + for (const excluded of ["duckduckgo-web", "chipotle", "aihorde"]) { assert.equal( combo.models.some((model) => model.providerId === excluded), false,
{t("apiKey")} {t("xpLastHour")} {t("zScore")}Status{t("status")}
{a.zScore.toFixed(2)} - Suspicious + {t("suspicious")}