diff --git a/.env.example b/.env.example index 8f3a700dde..546cd4dd69 100644 --- a/.env.example +++ b/.env.example @@ -86,8 +86,8 @@ PORT=20128 # Port for the real-time WebSocket live monitoring server. # Used by: src/server/ws/liveServer.ts, src/app/api/v1/ws/route.ts -# Default: 20129 -# LIVE_WS_PORT=20129 +# Default: 20132 +# LIVE_WS_PORT=20132 # Bind address for the live WebSocket server. # Default: 127.0.0.1 (loopback only). Set to 0.0.0.0 to expose on LAN — @@ -112,16 +112,14 @@ PORT=20128 # Public URL for the live dashboard WebSocket (client-side, browser only). # Set this when fronting the WS server with a reverse proxy or Cloudflare Tunnel. -# The browser will connect to this URL instead of ws://hostname:20129. -# The /live-ws path is already proxied from the main app (port 20128) to the -# live WS server (port 20129) by scripts/dev/standalone-server-ws.mjs. -# Used by: src/hooks/useLiveDashboard.ts +# The browser will connect to this URL instead of ws://hostname:20132. +# The path portion of this URL (e.g. ws://localhost:20132/live-ws -> /live-ws) is also used by the dev proxy +# (scripts/dev/standalone-server-ws.mjs) and the handshake response to route +# WebSocket upgrades. Default path: /live-ws. +# Used by: src/hooks/useLiveDashboard.ts, src/app/api/v1/ws/route.ts, +# scripts/dev/standalone-server-ws.mjs, and scripts/start-ws-server.mjs. # Example: NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=wss://ws.my-ai.com/live-ws -# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL= - -# Disable the standalone live WebSocket helper used by scripts/start-ws-server.mjs. -# Used by: scripts/start-ws-server.mjs (CI/embedded harness toggle). -# OMNIROUTE_DISABLE_LIVE_WS=0 +# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=ws://localhost:20132/live-ws # Enable the real-time dashboard WebSocket server. # Used by: src/server/ws/liveServer.ts, scripts/start-ws-server.mjs @@ -197,9 +195,9 @@ OMNIROUTE_USE_TURBOPACK=1 # the machine name by bash/zsh. The .env loader cannot override it (first-wins # semantics). Use OMNIROUTE_SERVER_HOST instead for `omniroute serve`. # See: https://github.com/diegosouzapw/OmniRoute/issues/6194 -#HOST=0.0.0.0 -#HOSTNAME=127.0.0.1 -#OMNIROUTE_SERVER_HOST=0.0.0.0 +# HOST=0.0.0.0 +# HOSTNAME=127.0.0.1 +# OMNIROUTE_SERVER_HOST=0.0.0.0 # Environment mode — affects Next.js behavior, logging verbosity, and caching. # Values: production | development | Default: production @@ -1529,6 +1527,11 @@ APP_LOG_TO_FILE=true # Timeout for fast-fail health checks (ms). Default: 2000 # PROXY_FAST_FAIL_TIMEOUT_MS=2000 +# Time window (hours) for calculating the average latency of candidate proxies +# in the latency-optimized pool strategy. Default: 3 +# Used by: src/lib/db/proxies.ts +# PROXY_LATENCY_WINDOW_HOURS=3 + # Health check result cache TTL (ms). Default: 30000 (30s) # PROXY_HEALTH_CACHE_TTL_MS=30000 @@ -2103,3 +2106,24 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # BIFROST_API_KEY= # BIFROST_STREAMING_ENABLED=true # BIFROST_TIMEOUT_MS=30000 + +# ───────────────────────────────────────────────────────────────────────────── +# Account rotation config (operator-managed; consumed by open-sse/services/rotationConfig.ts) +# Lets a supervising front-end mirror its rotation rules onto the backend's account-fallback +# engine. All optional; defaults preserve the historical behavior. +# ───────────────────────────────────────────────────────────────────────────── +# OMNIROUTE_ROTATION_ENABLED=true +# OMNIROUTE_ROTATION_RATE_LIMIT_RESET_SECONDS=0 +# OMNIROUTE_ROTATION_DISABLE_TAG_WITHOUT_RESET=true +# OMNIROUTE_ROTATE_ON_429=true +# OMNIROUTE_ROTATE_429_THRESHOLD=1 +# OMNIROUTE_ROTATE_429_WINDOW_SECONDS=120 +# OMNIROUTE_ROTATE_ON_500=true +# OMNIROUTE_ROTATE_500_THRESHOLD=1 +# OMNIROUTE_ROTATE_500_WINDOW_SECONDS=120 +# OMNIROUTE_ROTATE_ON_502=true +# OMNIROUTE_ROTATE_502_THRESHOLD=1 +# OMNIROUTE_ROTATE_502_WINDOW_SECONDS=120 +# OMNIROUTE_ROTATE_ON_400=false +# OMNIROUTE_ROTATE_400_THRESHOLD=1 +# OMNIROUTE_ROTATE_400_WINDOW_SECONDS=120 diff --git a/CHANGELOG.md b/CHANGELOG.md index 857b85deaf..c5059be03a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **fix(cli):** the dashboard's Claude Code CLI card could report "Not detected"/"Not installed" even when Claude Code was genuinely installed and previously used ([#6701](https://github.com/diegosouzapw/OmniRoute/issues/6701)) — `getCliRuntimeStatus()` (`src/shared/services/cliRuntime.ts`) determined `installed` purely from binary resolution (known install paths + a `where`/`which` PATH search), with no fallback when that lookup fails for reasons unrelated to whether the CLI is actually installed (stale PATH inherited by a long-running/background process, the binary having moved, an install method not yet catalogued, etc.) — even though `~/.claude/settings.json` on disk proves the tool was installed and used before. Upstream 9router's equivalent route already has this exact fallback. A new `withSettingsFallback()` (`src/shared/services/cliInstallFallback.ts`) restores 9router parity: when the binary lookup's own reason is `"not_found"` (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk, `installed` now reports `true`. Regression guard: `tests/unit/repro-6701-claude-detect-fallback.test.ts`. - **fix(cli):** per-agent AgentBridge DNS toggle was broken for 8 of the 9 supported agents, and a failed MITM startup step could orphan the spawned proxy child — `addDNSEntry`/`removeDNSEntry` (`src/mitm/dns/dnsConfig.ts`) always resolved the legacy Antigravity default hosts regardless of which agent's toggle was flipped, so enabling DNS for Cursor/Codex/Claude Code/etc. silently added only `daily-cloudcode-pa.googleapis.com` while the DB recorded `dns_enabled=true` for the selected agent. Both functions now accept an optional `agentId` and resolve hosts via `ALL_TARGETS`; `POST /api/tools/agent-bridge/agents/[id]/dns` passes the route's `id` through and now returns 404 for an id that doesn't match a known target instead of silently falling back. Separately, `startMitmInternal()` (`src/mitm/manager.ts`) now wraps `generateCert()` (log + rethrow), the `provisionDnsEntries()` call, and the PID-file write in try/catch so a mid-startup failure can't orphan the already-spawned MITM child process. On Windows, `addDNSEntries`/`removeDNSEntries` also batch every missing/present entry into a single elevated PowerShell invocation instead of one UAC prompt per host line. Regression guard: `tests/unit/dns-config-generic.test.ts` (agent-specific resolution + batching), `tests/unit/agent-bridge-dns-route-validation.test.ts` (404 for unknown agent id). ([#6338](https://github.com/diegosouzapw/OmniRoute/pull/6338) — thanks @hamsa0x7) - **fix(guardrails):** Vision Bridge's individual-model auto-reroute (route an image-bearing request straight to a vision-capable model instead of describe-then-forward) could bypass a policy-restricted API key's model allowlist/budget ([#6640](https://github.com/diegosouzapw/OmniRoute/pull/6640)) — `VisionBridgeGuardrail.preCall()` (`src/lib/guardrails/visionBridge.ts`) swaps `body.model` to the best available vision-capable model, but that swap happens in the guardrail pipeline AFTER `chat.ts` already called `enforceApiKeyPolicy()` against the ORIGINAL model, so a key scoped to a narrow `allowedModels` list could still execute against an unvetted (and possibly costlier) vision model the reroute picked. `chat.ts` now re-validates any guardrail-driven model change against the same per-key allowlist (`isModelAllowedForKey`) before honoring it, falling back to the original already-approved model when the reroute target is not allowed. The reroute path also now honors an explicit `settings.visionBridgeModel` operator override (previously ignored, unlike the combo/describe path a few lines below it, which already respects it via `getVisionBridgeConfig`). Regression guard: `tests/unit/guardrails/visionBridge.test.ts` (22 tests). (thanks @herjarsa) -- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). +- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) - **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) - **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. - **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. @@ -83,40 +83,71 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral Thanks to everyone whose work landed in v3.8.47: -| Contributor | PRs / Issues | -| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| [@anki1kr](https://github.com/anki1kr) | #6041, #6078 | -| [@arssnndr](https://github.com/arssnndr) | #6163 | -| [@backryun](https://github.com/backryun) | #6154, #6235, #6248, #6331 | -| [@charleszolot](https://github.com/charleszolot) | direct commit / report | -| [@chirag127](https://github.com/chirag127) | #6145, #6189, #6265, #6328, #6400, #6402, #6404, #6405, #6406, #6407, #6408, #6412, #6414, … | -| [@developerjillur](https://github.com/developerjillur) | #6451, #6452, #6541, #6542, #6543, #6545, #6553, #6554, #6558 | -| [@dilneiss](https://github.com/dilneiss) | #6499 | -| [@DKotsyuba](https://github.com/DKotsyuba) | #6193, #6292 | -| [@dtybnrj](https://github.com/dtybnrj) | #6349 | -| [@eidoog](https://github.com/eidoog) | direct commit / report | -| [@hao3039032](https://github.com/hao3039032) | #6351 | -| [@hartmark](https://github.com/hartmark) | #6216 | -| [@Iammilansoni](https://github.com/Iammilansoni) | #6200, #6209, #6245, #6366 | -| [@jmengit](https://github.com/jmengit) | #6372, #6443 | -| [@jordansilly77-stack](https://github.com/jordansilly77-stack) | #6316 | -| [@JxnLexn](https://github.com/JxnLexn) | #6361 | -| [@kanztu](https://github.com/kanztu) | #6181 | -| [@karimalsalah](https://github.com/karimalsalah) | #6291 | -| [@KooshaPari](https://github.com/KooshaPari) | #6144, #6166, #6173, #6257 | -| [@LuisAlejandroVega](https://github.com/LuisAlejandroVega) | #6177 | -| [@makcimbx](https://github.com/makcimbx) | #6303 | -| [@Moseyuh333](https://github.com/Moseyuh333) | #6186 | -| [@pizzav-xyz](https://github.com/pizzav-xyz) | #6648 | -| [@rianonehub](https://github.com/rianonehub) | #6204 | -| [@serverless83](https://github.com/serverless83) | #6212 | -| [@shabeer](https://github.com/shabeer) | direct commit / report | -| [@swingtempo](https://github.com/swingtempo) | #6312 | -| [@Theadd](https://github.com/Theadd) | #6195 | -| [@ThongAccount](https://github.com/ThongAccount) | #6649 | -| [@vinayakkulkarni](https://github.com/vinayakkulkarni) | #6324, #6332 | -| [@VXNCXNX](https://github.com/VXNCXNX) | #6213 | -| [@xz-dev](https://github.com/xz-dev) | #6322, #6336 | +| Contributor | PRs / Issues | +| --- | --- | +| [@alltomatos](https://github.com/alltomatos) | #6703, #6715, #6756, #6757, #6759, #6813, #6819, #6821 | +| [@andrewmunsell](https://github.com/andrewmunsell) | #6774, #6779, #6795 | +| [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #6828, #6829 | +| [@anhdiepmmk](https://github.com/anhdiepmmk) | direct commit / report | +| [@anki1kr](https://github.com/anki1kr) | #6041, #6078 | +| [@arssnndr](https://github.com/arssnndr) | #6163 | +| [@artickc](https://github.com/artickc) | #6363, #6763 | +| [@backryun](https://github.com/backryun) | #6154, #6235, #6248, #6280, #6331, #6675 | +| [@charleszolot](https://github.com/charleszolot) | #6571 | +| [@chirag127](https://github.com/chirag127) | #6145, #6189, #6265, #6328, #6400, #6402, #6404, #6405, #6406, #6407, #6408, #6412, #6414, #6513, #6515, #6516, #6517, #6519, #6521, #6523, #6525, #6526, #6532, #6534, #6546, #6547, #6548, #6549, #6550, #6551, #6552, #6577, #6643, #6644, #6645, #6646, #6703, #6756, #6757, #6769, #6804 | +| [@chy1211](https://github.com/chy1211) | direct commit / report | +| [@developerjillur](https://github.com/developerjillur) | #6451, #6452, #6541, #6542, #6543, #6545, #6553, #6554, #6558 | +| [@dilneiss](https://github.com/dilneiss) | #6499 | +| [@DKotsyuba](https://github.com/DKotsyuba) | #6193, #6292 | +| [@dtybnrj](https://github.com/dtybnrj) | #6349 | +| [@eidoog](https://github.com/eidoog) | direct commit / report | +| [@enjoyer-hub](https://github.com/enjoyer-hub) | #6647 | +| [@hajilok](https://github.com/hajilok) | #6126 | +| [@hamsa0x7](https://github.com/hamsa0x7) | #6317, #6318, #6338 | +| [@hao3039032](https://github.com/hao3039032) | #6351 | +| [@hartmark](https://github.com/hartmark) | #6216 | +| [@herjarsa](https://github.com/herjarsa) | #6640 | +| [@Iammilansoni](https://github.com/Iammilansoni) | #6200, #6209, #6245, #6366 | +| [@ianriizky](https://github.com/ianriizky) | #6072, #6538 | +| [@itiwant](https://github.com/itiwant) | direct commit / report | +| [@janeza2](https://github.com/janeza2) | #6308 | +| [@jmengit](https://github.com/jmengit) | #6372, #6443 | +| [@jordansilly77-stack](https://github.com/jordansilly77-stack) | #6316 | +| [@JxnLexn](https://github.com/JxnLexn) | #6335, #6361 | +| [@kanztu](https://github.com/kanztu) | #6181 | +| [@karimalsalah](https://github.com/karimalsalah) | #6291 | +| [@KooshaPari](https://github.com/KooshaPari) | #6144, #6166, #6173, #6257, #6611, #6632 | +| [@like3213934360-lab](https://github.com/like3213934360-lab) | direct commit / report | +| [@lucasjustinudin](https://github.com/lucasjustinudin) | direct commit / report | +| [@LuisAlejandroVega](https://github.com/LuisAlejandroVega) | #6177 | +| [@makcimbx](https://github.com/makcimbx) | #6303 | +| [@MikeTuev](https://github.com/MikeTuev) | #6586 | +| [@Moseyuh333](https://github.com/Moseyuh333) | #6186, #6294, #6728 | +| [@nowhats-br](https://github.com/nowhats-br) | #6700 | +| [@oyi77](https://github.com/oyi77) | #6309 | +| [@Pitchfork-and-Torch](https://github.com/Pitchfork-and-Torch) | #6747, #6791, #6792 | +| [@pizzav-xyz](https://github.com/pizzav-xyz) | #6648 | +| [@rafpigna](https://github.com/rafpigna) | #6574 | +| [@rianonehub](https://github.com/rianonehub) | #6204 | +| [@ryanngit](https://github.com/ryanngit) | direct commit / report | +| [@samimozcan](https://github.com/samimozcan) | #6753, #6762 | +| [@samir-abis](https://github.com/samir-abis) | direct commit / report | +| [@SeaXen](https://github.com/SeaXen) | #6496, #6678 | +| [@serverless83](https://github.com/serverless83) | #6212 | +| [@shabeer](https://github.com/shabeer) | direct commit / report | +| [@Squawk7777](https://github.com/Squawk7777) | #6565 | +| [@strangersp](https://github.com/strangersp) | #6587 | +| [@swingtempo](https://github.com/swingtempo) | #6312 | +| [@Theadd](https://github.com/Theadd) | #6195 | +| [@Thinkscape](https://github.com/Thinkscape) | #6635 | +| [@ThongAccount](https://github.com/ThongAccount) | #6625, #6649 | +| [@tjengbudi](https://github.com/tjengbudi) | #4009 | +| [@vinayakkulkarni](https://github.com/vinayakkulkarni) | #6324, #6332 | +| [@VXNCXNX](https://github.com/VXNCXNX) | #6213 | +| [@whale9820](https://github.com/whale9820) | direct commit / report | +| [@Witroch4](https://github.com/Witroch4) | #6753, #6762, #6790 | +| [@xz-dev](https://github.com/xz-dev) | #6322, #6323, #6330, #6336, #6702, #6727 | +| [@yinaoxiong](https://github.com/yinaoxiong) | #6805 | --- diff --git a/README.md b/README.md index a2447a82f6..3ab7eb1bee 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ 🔌 Every tool works
24+ coding agents — Claude Code, Codex, Cursor, Cline, Copilot, Antigravity — through one config. 🧩 One endpoint
OpenAI ↔ Claude ↔ Gemini ↔ Responses API translation. Point any tool at /v1 and it just works. - 🛡️ Production-grade
Circuit breakers, TLS stealth, MCP (95 tools), A2A, memory, guardrails, evals. 21,000+ tests. + 🛡️ Production-grade
Circuit breakers, TLS stealth, MCP (94 tools), A2A, memory, guardrails, evals. 21,000+ tests. @@ -194,7 +194,7 @@ ▼ ┌──────────────────────────────────────────────────────────┐ │ OmniRoute — Smart Router │ -│ RTK + Caveman compression · 17 routing strategies │ +│ RTK + Caveman compression · 18 routing strategies │ │ Circuit breakers · TLS stealth · MCP · A2A · Guardrails │ └─────────────────────────┬──────────────────────────────────┘ ┌─────────────┬────┴────────┬─────────────┐ @@ -232,9 +232,9 @@ No combo to create. Set your model to `auto` (or a variant) and OmniRoute builds ## -### 🔀 Or build your own — 17 routing strategies +### 🔀 Or build your own — 18 routing strategies -All **17** strategies — mix & match per combo step: +All **18** strategies — mix & match per combo step: | # | Strategy | What it does | | --- | ------------------- | ---------------------------------------------------------------- | @@ -253,10 +253,11 @@ All **17** strategies — mix & match per combo step: | 13 | `context-relay` | Hand off context across targets for long conversations 🧠 | | 14 | `context-optimized` | Pick the best fit for the current context size | | 15 | `lkgp` | Last-Known-Good Path — sticky to the last successful target | -| 16 | `auto` | 9-factor live scoring across every connection 🤖 | +| 16 | `auto` | 12-factor live scoring across every connection 🤖 | | 17 | `fusion` | Fan out to a panel of models + a judge synthesizes one answer 🧬 | +| 18 | `pipeline` | Chain steps — each target's output feeds the next one 🔗 | -The Auto-Combo engine scores every candidate on **9 factors** (health, quota, cost, latency, success rate, freshness…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md). +The Auto-Combo engine scores every candidate on **12 factors** (health, quota, cost, latency, success rate, freshness…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md). ## @@ -315,9 +316,9 @@ Result: 4 layers of fallback = zero downtime | -------------------------------------- | ------------------------------------------------------------------- | ------------- | | 🌐 Providers | **248** | 20–100 | | 🆓 Free providers | **90+ (11 free forever)** | 1–5 | -| 🔀 Routing strategies | **17** (priority, weighted, cost-optimized, context-relay, fusion…) | 1–3 | +| 🔀 Routing strategies | **18** (priority, weighted, cost-optimized, context-relay, fusion…) | 1–3 | | 🗜️ Token compression | **RTK + Caveman stacked (15–95%)** | None / 20–40% | -| 🧰 Built-in MCP server | **95 tools, 3 transports, 30 scopes** | Rare | +| 🧰 Built-in MCP server | **94 tools, 3 transports, 30 scopes** | Rare | | 🤝 A2A agent protocol | **6 skills, JSON-RPC 2.0** | None | | 🧠 Memory (FTS5 + vector) | **Yes** | Rare | | 🛡️ Guardrails (PII, injection, vision) | **Yes** | Rare | @@ -543,7 +544,7 @@ Expose OmniRoute over **MCP** or **A2A** and any capable agent gets the keys to | Protocol | Endpoint | Use it for | | ------------------ | ----------------------------------------------- | ------------------------------------------------------ | | 🧰 **MCP (stdio)** | `omniroute --mcp` | Plug into Claude Desktop, Cursor, any MCP client | -| 🌊 **MCP (HTTP)** | `http://localhost:20128/api/mcp/stream` | Remote MCP — **95 tools**, 30 scopes, full audit trail | +| 🌊 **MCP (HTTP)** | `http://localhost:20128/api/mcp/stream` | Remote MCP — **94 tools**, 30 scopes, full audit trail | | 📡 **MCP (SSE)** | `http://localhost:20128/api/mcp/sse` | Streaming MCP transport | | 🤝 **A2A** | `http://localhost:20128/.well-known/agent.json` | Agent-to-agent, **JSON-RPC 2.0** + SSE, 6 skills | @@ -882,7 +883,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo **Routing:** 18 strategies · task-aware smart routing · thinking budget controls · wildcard routing · system prompt injection. **Compatibility:** OpenAI ↔ Claude ↔ Gemini ↔ Responses API · auto OAuth refresh (PKCE, 8 providers) · multi-account round-robin · Batch + Files API · live OpenAPI 3.0. -**Protocols:** MCP (95 tools, 3 transports, 30 scopes) · A2A (JSON-RPC 2.0, SSE, 6 skills) · ACP · cloud agents (Codex, Cursor, Devin, Jules). +**Protocols:** MCP (94 tools, 3 transports, 30 scopes) · A2A (JSON-RPC 2.0, SSE, 6 skills) · ACP · cloud agents (Codex, Cursor, Devin, Jules). **Plugins:** custom plugin marketplace (system-configured registry URL with SSRF-guarded fetch) · install / enable / disable · Notion + Obsidian knowledge-base integrations (WebDAV file server, vault search, note CRUD). **Embedded services:** one-click install & lifecycle management of local sidecar services (CLIProxy, NineRouter). **Quality & Ops:** built-in **Evals** (golden-set: exact/contains/regex/custom) · guardrails (PII, injection, vision) · health dashboard · p50/p95/p99 telemetry · webhooks · compliance audit. @@ -1027,7 +1028,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo | [Compression Rules Format](docs/compression/COMPRESSION_RULES_FORMAT.md) | JSON rule-pack schemas for Caveman and RTK filters | | [Compression Language Packs](docs/compression/COMPRESSION_LANGUAGE_PACKS.md) | Language detection and Caveman rule-pack authoring | | [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing | -| [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) | 9-factor scoring, mode packs, self-healing | +| [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) | 12-factor scoring, mode packs, self-healing | | [Proxy Guide](docs/ops/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD | | [Free Tiers](docs/reference/FREE_TIERS.md) | 25+ free API providers consolidated directory | | [Features Gallery](docs/guides/FEATURES.md) | Visual dashboard tour with screenshots | diff --git a/bin/cli/locales/zh-TW.json b/bin/cli/locales/zh-TW.json new file mode 100644 index 0000000000..41a8974219 --- /dev/null +++ b/bin/cli/locales/zh-TW.json @@ -0,0 +1,1262 @@ +{ + "common": { + "error": "錯誤:{message}", + "serverOffline": "OmniRoute 伺服器已離線。請啟動:omniroute serve", + "authRequired": "需要認證。請設定 OMNIROUTE_API_KEY 或執行:omniroute setup", + "rateLimited": "請求超出限制。請在 {seconds}s 後重試。", + "timeout": "請求在 {ms}ms 後超時。", + "success": "完成。", + "yes": "是", + "no": "否", + "confirm": "確定嗎?(是/否)", + "dryRun": "【模擬】將執行:{action}", + "cancelled": "已取消。", + "jsonOpt": "以 JSON 格式輸出", + "yesOpt": "跳過確認" + }, + "program": { + "description": "OmniRoute — 具有自動故障轉移的智慧 AI 路由器", + "version": "列印版本並退出", + "output": "輸出格式(table, json, jsonl, csv)", + "quiet": "禁止非必要輸出", + "no_color": "停用彩色輸出", + "timeout": "HTTP 請求超時(毫秒)", + "api_key": "OmniRoute 伺服器的 API 金鑰", + "base_url": "OmniRoute 伺服器的基礎 URL", + "context": "此命令使用的伺服器上下文/配置檔案", + "lang": "設定 CLI 顯示語言(覆蓋 OMNIROUTE_LANG)" + }, + "setup": { + "title": "OmniRoute 設定", + "passwordPrompt": "管理員密碼", + "providerPrompt": "預設提供商(留空跳過)", + "done": "設定完成", + "passwordSet": "管理員密碼已配置", + "providerSet": "提供商已配置:{name}", + "testingProvider": "正在測試提供商連線:{name}", + "testPassed": "提供商測試通過", + "testFailed": "提供商測試失敗:{error}", + "loginEnabled": "登入:已啟用(密碼已更新)", + "loginDisabled": "登入:已停用", + "providerInfo": "提供商:{info}" + }, + "doctor": { + "title": "OmniRoute 診斷", + "dbOk": "資料庫:正常({path})", + "dbMissing": "資料庫:未初始化 — 執行 omniroute setup", + "portOk": "埠 {port}:可用", + "portConflict": "埠 {port}:已被其他程序佔用", + "encryptionOk": "加密金鑰:已配置", + "encryptionMissing": "加密金鑰缺失 — 執行 omniroute setup", + "allGood": "所有檢查通過。", + "warnings": "{count} 個警告 — 見上文。" + }, + "providers": { + "title": "提供商", + "noProviders": "未配置提供商。執行:omniroute setup", + "testing": "正在測試 {name}...", + "available": "{count} 個提供商可用", + "connected": "已連線", + "disconnected": "未連線", + "validationFailed": "驗證失敗:{error}", + "metrics": { + "description": "顯示提供商效能指標(延遲、成功率、成本)", + "provider": "按提供商 ID 篩選", + "connection_id": "按連線 ID 篩選", + "period": "時間範圍:1h|6h|24h|7d|30d(預設:24h)", + "metric": "關注特定指標欄位", + "sort": "按欄位排序(降序)", + "limit": "最大行數(預設:50)", + "watch": "每 5 秒重新整理(即時模式)", + "compare": "逗號分隔的提供商 ID,並排比較" + }, + "metric_single": { + "description": "獲取特定連線的單個指標值" + }, + "rotate": { + "description": "輪換提供商連線的上游 API 金鑰", + "newKeyOpt": "新 API 金鑰值(避免使用:優先使用 --from-env)", + "fromEnvOpt": "從環境變數 VAR 讀取新金鑰", + "oauthOpt": "改為觸發 OAuth 重新認證流程", + "skipTestOpt": "跳過輪換後的連通性測試", + "dryRunOpt": "預覽將要更改的內容而不寫入", + "confirmPrompt": "替換連線 \"{name}\" ({id}) 的 API 金鑰?[y/N] ", + "dryRunResult": "【模擬】將輪換 \"{name}\" ({id}) 的金鑰。未做任何更改。", + "oauthHint": "OAuth 連線 — 執行:omniroute oauth {provider}", + "envVarEmpty": "環境變數 {var} 未設定或為空。", + "success": "已為 \"{name}\" 輪換金鑰。執行 providers test {id} 驗證。", + "testPassed": "輪換後測試通過。", + "testFailed": "輪換後測試失敗:{error}" + }, + "status": { + "description": "顯示所有提供商連線的金鑰健康狀態(期限、過期、冷卻)", + "providerOpt": "按提供商名稱篩選", + "header": "ID 提供商 名稱 過期狀態 測試狀態 冷卻至", + "noData": "沒有可用的提供商連線資料。", + "requiresServer": "providers status 需要 OmniRoute 伺服器正在執行。" + } + }, + "keys": { + "title": "API 金鑰", + "addDescription": "為提供商新增或更新 API 金鑰", + "listDescription": "列出所有已配置的 API 金鑰", + "removeDescription": "移除提供商的 API 金鑰", + "regenerateDescription": "重新生成 OmniRoute API 金鑰", + "revokeDescription": "撤銷 OmniRoute API 金鑰", + "revealDescription": "顯示未掩碼的 API 金鑰值", + "usageDescription": "顯示 API 金鑰的最近使用情況", + "usageLimitOpt": "最近請求數", + "rotateDescription": "生成新金鑰並使舊金鑰失效", + "graceOpt": "舊金鑰失效前的寬限期(毫秒)", + "stdinOpt": "從標準輸入讀取 API 金鑰而不是引數", + "added": "已為 {provider} 新增金鑰。", + "removed": "金鑰已移除。", + "listed": "{count} 個金鑰。", + "noKeys": "未配置金鑰。", + "noUsage": "未找到使用資料。", + "confirmRemove": "移除金鑰 {id}?", + "confirmRegenerate": "重新生成金鑰 {id}?", + "confirmRevoke": "撤銷金鑰 {id}?", + "confirmRotate": "輪換金鑰 {id}?(舊金鑰將在寬限期後失效)", + "regenerated": "已重新生成。新金鑰:{key}", + "revoked": "金鑰 {id} 已撤銷。", + "rotated": "金鑰 {id} 已輪換。新金鑰 ID:{newId}", + "revealWarning": "⚠ 這將顯示完整的未掩碼金鑰。請確保您的螢幕不被人看到。", + "providerRequired": "需要提供商。", + "keyRequired": "需要 API 金鑰。", + "stdinEmpty": "未通過標準輸入提供 API 金鑰。", + "unknownProvider": "未知提供商:{provider}", + "policy": { + "title": "金鑰策略", + "showDescription": "顯示金鑰的速率/成本策略", + "setDescription": "設定金鑰的速率/成本策略", + "rateLimitOpt": "每分鐘最大請求數", + "maxCostOpt": "每日最大成本(美元)", + "allowedModelsOpt": "逗號分隔的允許模型列表", + "nothingToSet": "未提供策略欄位。使用 --rate-limit、--max-cost 或 --allowed-models。", + "updated": "策略已更新。" + }, + "expiration": { + "title": "金鑰過期", + "listDescription": "列出即將過期的金鑰", + "daysOpt": "顯示 N 天內過期的金鑰", + "none": "{days} 天內沒有金鑰過期。", + "listTitle": "{days} 天內過期的金鑰:" + } + }, + "stream": { + "description": "使用 SSE 檢查模式流式傳輸聊天響應", + "file": "從檔案讀取提示", + "stdin": "從標準輸入讀取提示", + "model": "模型 ID(預設:auto)", + "system": "系統提示", + "combo": "強制指定組合名稱", + "max_tokens": "響應最大令牌數", + "responses_api": "使用 /v1/responses 而不是 /v1/chat/completions", + "raw": "列印接收到的原始 SSE 行", + "debug": "在 stderr 中列印每塊的時間資訊", + "save": "將所有 SSE 事件儲存到 .jsonl 檔案", + "error": { + "empty_prompt": "錯誤:需要提供提示(位置引數、--file 或 --stdin)" + } + }, + "usage": { + "description": "使用分析、預算、配額和日誌", + "analytics": { + "description": "顯示彙總使用分析", + "period": "時間範圍:1d|7d|30d|90d|ytd|all(預設:30d)", + "provider": "按提供商 ID 篩選" + }, + "budget": { + "description": "管理成本預算", + "set": { + "scope": "預算範圍(預設:全域性)", + "period": "預算週期:daily|weekly|monthly(預設:monthly)" + } + }, + "quota": { + "description": "顯示提供商配額使用情況", + "provider": "按提供商 ID 篩選", + "check": "顯示新請求是否有可用配額" + }, + "logs": { + "description": "顯示請求呼叫日誌", + "limit": "返回的日誌條目數(預設:100)", + "search": "篩選日誌的搜尋查詢", + "since": "返回自此時間戳以來的日誌", + "follow": "持續跟蹤新的日誌條目", + "api_key": "按 API 金鑰篩選日誌" + }, + "utilization": { + "description": "顯示 API 金鑰利用率指標", + "api_key": "按 API 金鑰篩選" + }, + "history": { + "description": "顯示請求歷史", + "limit": "歷史條目數(預設:100)" + }, + "proxy_logs": { + "description": "顯示代理級請求日誌", + "limit": "代理日誌條目數(預設:100)" + } + }, + "cost": { + "description": "按提供商、模型、組合或 API 金鑰顯示成本報告", + "period": "時間範圍:1d|7d|30d|90d|ytd|all(預設:30d)", + "since": "開始日期(ISO 格式,例如 2026-01-01)— 覆蓋 --period", + "until": "結束日期(ISO 格式)", + "group_by": "按以下方式分組:provider|model|api-key|combo|day(預設:provider)", + "api_key_filter": "按特定 API 金鑰篩選", + "limit": "顯示的最大行數(預設:100)" + }, + "simulate": { + "description": "模擬路由(空執行)— 顯示將選擇哪些提供商而不呼叫上游", + "file": "從 JSON 檔案載入完整請求體", + "model": "模型 ID(預設:auto)", + "combo": "強制指定組合名稱", + "reasoning": "推理努力級別(low|medium|high)", + "thinking": "擴充套件思維令牌預算", + "explain": "在 stderr 中列印回退樹和成本範圍", + "noCombo": "未找到匹配的組合。使用以下命令配置:omniroute combo create" + }, + "chat": { + "description": "向 OmniRoute 傳送一次性聊天提示", + "file": "從檔案讀取提示", + "stdin": "從標準輸入讀取提示", + "system": "系統提示", + "model": "模型 ID(預設:auto)", + "max_tokens": "響應最大令牌數", + "temperature": "取樣溫度(0–2)", + "top_p": "Top-p 核取樣", + "reasoning_effort": "推理努力級別(low|medium|high)", + "thinking_budget": "擴充套件思維令牌預算", + "combo": "強制指定組合名稱", + "responses_api": "使用 /v1/responses 而不是 /v1/chat/completions", + "stream": "增量流式傳輸響應", + "no_history": "不儲存到 ~/.omniroute/cli-history.jsonl", + "error": { + "empty_prompt": "錯誤:需要提供提示(位置引數、--file 或 --stdin)" + } + }, + "serve": { + "description": "啟動 OmniRoute 伺服器(預設操作)", + "starting": "正在啟動 OmniRoute 伺服器(埠 {port})...", + "ready": "就緒地址:http://localhost:{port}", + "stopping": "正在停止伺服器(PID {pid})...", + "stopped": "伺服器已停止。", + "notRunning": "伺服器未在執行。", + "port": "監聽埠(預設:20128)", + "no_open": "不自動開啟瀏覽器", + "daemon": "以後臺守護程序方式執行伺服器", + "log": "內聯顯示伺服器日誌", + "no_recovery": "停用崩潰自動重啟(除錯模式)", + "max_restarts": "30 秒內的最大崩潰重啟次數(預設:2)", + "tray": "顯示系統托盤圖示(僅桌面,選擇加入)", + "no_tray": "停用系統托盤圖示" + }, + "backup": { + "title": "備份", + "description": "建立 OmniRoute 資料備份", + "creating": "正在建立備份...", + "done": "備份已儲存至 {path}", + "noFiles": "沒有可備份的檔案(資料庫未初始化)", + "failed": "備份失敗:{error}", + "restoring": "正在從 {path} 恢復...", + "restored": "恢復完成。", + "restoreDescription": "從備份恢復", + "listTitle": "可用備份", + "noBackups": "未找到備份。", + "notFound": "未找到備份:{name}", + "confirmRestore": "用 {ts} 的備份覆蓋當前資料?", + "createDescription": "建立 OmniRoute 資料備份", + "nameOpt": "自定義備份名稱", + "cloudOpt": "將備份上傳到雲端儲存", + "encryptOpt": "使用 AES-256-GCM 加密備份", + "keyFileOpt": "包含加密密碼的檔案路徑", + "excludeOpt": "排除匹配模式的檔案(可重複)", + "retentionOpt": "僅保留最近 N 個備份", + "passphrasePrompt": "加密密碼:", + "noPassphrase": "加密備份需要密碼。", + "cloudFailed": "警告:雲上傳失敗。本地備份已儲存。", + "cloudUploaded": "雲備份已上傳:{url}", + "auto": { + "title": "備份計劃", + "enableDescription": "啟用定時自動備份", + "disableDescription": "停用定時自動備份", + "statusDescription": "顯示當前備份計劃狀態", + "cronOpt": "計劃 cron 表示式(預設:每天凌晨 3 點)", + "enabled": "已啟用自動備份(cron:{cron})。", + "hint": " 計劃由 omniroute serve 啟動時讀取。", + "disabled": "已停用自動備份。", + "notConfigured": "未配置備份計劃。" + } + }, + "health": { + "description": "檢查伺服器健康狀態和元件狀態", + "noServer": "伺服器未執行。啟動:omniroute serve", + "title": "健康狀態", + "status": "狀態:{status}", + "uptime": "執行時間:{uptime}", + "requests": "請求數(24h):{count}", + "cost": "成本(24h):" + }, + "quota": { + "description": "顯示提供商配額使用情況", + "noServer": "伺服器未執行。啟動:omniroute serve", + "noData": "沒有可用的配額資訊。" + }, + "cache": { + "description": "管理響應快取", + "noServer": "伺服器未執行。啟動:omniroute serve", + "cleared": "快取已清除。", + "clearFailed": "清除快取失敗。" + }, + "redis": { + "description": "一鍵啟動本地 Redis 容器(Podman 或 Docker),用於 OmniRoute 快取和配額跟蹤" + }, + "test": { + "description": "測試提供商連線", + "noServer": "伺服器未執行。啟動:omniroute serve", + "testing": "正在測試 {provider} / {model}...", + "passed": "連線成功!", + "failed": "連線失敗:{error}", + "allProvidersOpt": "測試所有已配置的提供商", + "latencyOpt": "顯示延遲測量值(平均/最小/最大 毫秒)", + "repeatOpt": "重複測試 N 次並彙總結果", + "compareOpt": "逗號分隔的要比較的模型(例如 gpt-4o,claude-3-5-sonnet)", + "saveOpt": "將結果儲存到 JSON 檔案", + "saved": "結果已儲存至 {path}", + "compareTitle": "模型比較", + "compareMinTwo": "--compare 需要至少兩個模型(逗號分隔)", + "noProviders": "未配置提供商。新增:omniroute keys add" + }, + "update": { + "checking": "正在檢查更新...", + "upToDate": "已是最新版本({version})。", + "available": "有可用更新:{current} → {latest}", + "installing": "正在安裝 {latest}...", + "done": "已更新至 {latest}。重啟伺服器以生效。" + }, + "mcp": { + "title": "MCP 伺服器", + "running": "MCP 伺服器正在執行({transport})", + "stopped": "MCP 伺服器已停止。", + "restarted": "MCP 伺服器已重啟。", + "call": { + "description": "直接呼叫 MCP 工具", + "args": "JSON 引數物件(內聯)", + "args_file": "JSON 引數檔案路徑", + "stream": "使用流式端點(/api/mcp/stream)", + "scope": "所需作用域(可重複,例如 read:health)" + }, + "scopes": { + "description": "列出可用的 MCP 作用域", + "tool": "顯示特定工具所需的作用域" + }, + "tools": { + "description": "檢查 MCP 工具", + "list": { + "description": "列出所有 MCP 工具", + "scope": "按作用域篩選" + }, + "info": { + "description": "顯示 MCP 工具的後設資料" + }, + "schema": { + "description": "顯示 MCP 工具的輸入/輸出 JSON 模式", + "io": "模式型別:input|output(預設:input)" + } + }, + "audit": { + "description": "MCP 審計日誌(audit --source mcp 的別名)" + } + }, + "a2a": { + "skills": { + "description": "從代理卡片列出可用的 A2A 技能" + }, + "invoke": { + "description": "呼叫 A2A 技能並返回任務 ID", + "input": "JSON 輸入物件", + "input_file": "JSON 輸入檔案路徑", + "wait": "等待任務完成後返回", + "timeout": "等待完成的超時時間(毫秒,預設:60000)" + }, + "tasks": { + "description": "管理 A2A 任務", + "list": { + "status": "按狀態篩選", + "skill": "按技能 ID 篩選" + }, + "watch": { + "description": "輪詢任務狀態直至完成" + }, + "stream": { + "description": "通過 SSE 流式傳輸任務執行事件" + }, + "logs": { + "description": "顯示任務訊息和產物" + } + } + }, + "policy": { + "description": "管理 OmniRoute 授權策略", + "list": { + "description": "列出策略", + "kind": "按型別篩選(allow|deny|rate-limit|cost-cap)", + "scope": "按作用域篩選(global|api-key|provider)" + }, + "get": { + "description": "按 ID 獲取策略詳情" + }, + "create": { + "description": "從 JSON 檔案建立策略", + "file": "策略 JSON 檔案路徑" + }, + "update": { + "description": "從 JSON 檔案更新策略", + "file": "策略 JSON 檔案路徑" + }, + "delete": { + "description": "按 ID 刪除策略", + "yes": "跳過確認提示" + }, + "evaluate": { + "description": "空執行策略評估(退出碼 0=允許,4=拒絕)", + "api_key": "要評估的 API 金鑰", + "action": "要檢查的操作(例如 chat, embed, admin)", + "resource": "資源路徑或識別符號", + "context": "作為 JSON 物件的額外上下文" + }, + "export": { + "description": "將所有策略匯出到 JSON 檔案" + }, + "import": { + "description": "從 JSON 檔案匯入策略", + "overwrite": "覆蓋具有相同 ID 的現有策略" + } + }, + "compression": { + "description": "配置和檢查 OmniRoute 壓縮管道", + "status": { + "description": "顯示當前壓縮狀態和設定" + }, + "configure": { + "description": "配置壓縮設定", + "engine": "壓縮引擎(caveman|rtk|hybrid|none)", + "caveman_agg": "Caveman 激程序度 0.0–1.0", + "rtk_budget": "RTK 令牌預算", + "language_pack": "要啟用的語言包" + }, + "engine": { + "description": "獲取或設定活動壓縮引擎" + }, + "combos": { + "description": "管理壓縮組合統計" + }, + "rules": { + "description": "管理壓縮規則", + "add": { + "pattern": "要匹配的模式(正則或 field:pattern)", + "action": "操作:drop|shrink|replace" + } + }, + "language_packs": { + "description": "列出可用的壓縮語言包" + }, + "preview": { + "description": "預覽壓縮對請求的效果", + "file": "請求 JSON 檔案路徑" + } + }, + "tunnel": { + "title": "隧道", + "listDescription": "列出活動隧道", + "createDescription": "建立隧道", + "created": "隧道已建立:{url}", + "stopped": "隧道已停止。", + "confirmStop": "停止隧道 {id}?", + "stopDescription": "停止隧道", + "statusDescription": "顯示隧道的詳細狀態", + "logsDescription": "顯示隧道日誌", + "infoDescription": "顯示隧道的配置詳情", + "rotateDescription": "生成新的隧道 URL", + "tailOpt": "顯示的日誌行數", + "typeRequired": "需要隧道型別。", + "noLogs": "沒有可用日誌。", + "notAvailable": "隧道資訊不可用。", + "noTunnels": "沒有活動隧道。", + "infoTitle": "隧道資訊:{type}", + "rotated": "隧道 URL 已輪換:{url}", + "confirmRotate": "輪換隧道 {type}?(將生成新 URL)" + }, + "stop": { + "description": "停止 OmniRoute 伺服器", + "stopping": "正在停止伺服器(PID {pid})...", + "stopped": "伺服器已停止。", + "notRunning": "沒有伺服器在執行。", + "portFallback": "未找到 PID 檔案,嘗試通過埠停止..." + }, + "restart": { + "description": "重啟 OmniRoute 伺服器", + "restarting": "正在重啟 OmniRoute 伺服器..." + }, + "dashboard": { + "description": "在瀏覽器中開啟 OmniRoute 儀表盤", + "opening": "正在開啟儀表盤:{url}", + "urlOnly": "僅列印儀表盤 URL,不開啟瀏覽器", + "tui": "開啟互動式 TUI 儀表盤(終端 UI,7 個標籤頁)" + }, + "models": { + "description": "列出可用模型(需要伺服器)", + "search": "按 ID、名稱、提供商或描述篩選模型", + "noServer": "伺服器未執行。啟動:omniroute serve", + "noModels": "未找到模型。" + }, + "audit": { + "description": "訪問合規和 MCP 審計日誌", + "source": "日誌來源:all|compliance|mcp(預設:all)", + "since": "返回自此時間戳以來的條目(ISO 8601)", + "until": "返回直至此時間戳的條目(ISO 8601)", + "tail": { + "description": "顯示最近的審計日誌條目", + "follow": "持續跟蹤新條目(2 秒輪詢)", + "limit": "顯示的最大條目數(預設:100)" + }, + "search": { + "description": "按關鍵詞搜尋審計日誌條目", + "limit": "最大結果數(預設:200)", + "actor": "按參與者 ID 篩選", + "action": "按操作名稱篩選" + }, + "export": { + "description": "將審計日誌匯出到檔案", + "format": "輸出格式:jsonl|csv(預設:jsonl)" + }, + "stats": { + "description": "顯示審計日誌統計", + "period": "時間範圍:1d|7d|30d(預設:7d)" + }, + "get": { + "description": "按 ID 獲取單個審計日誌條目" + } + }, + "skills": { + "description": "管理 OmniRoute 技能(沙箱、內建、自定義、混合、skillssh)", + "list": { + "description": "列出已安裝的技能", + "type": "按型別篩選(sandbox|custom|builtin|hybrid|skillssh)", + "enabled": "僅顯示已啟用的技能", + "disabled": "僅顯示已停用的技能", + "api_key": "按 API 金鑰篩選" + }, + "get": { + "description": "按 ID 獲取技能詳情" + }, + "install": { + "description": "從檔案或 URL 安裝技能", + "from_file": "技能 JSON 定義檔案路徑", + "from_url": "遠端技能定義的 URL", + "type": "技能型別(sandbox|custom|hybrid)", + "enable": "安裝後啟用技能" + }, + "enable": { + "description": "按 ID 啟用技能" + }, + "disable": { + "description": "按 ID 停用技能", + "yes": "跳過確認提示" + }, + "delete": { + "description": "按 ID 刪除技能", + "yes": "跳過確認提示" + }, + "execute": { + "description": "按 ID 執行技能", + "input": "JSON 輸入物件", + "input_file": "JSON 輸入檔案路徑", + "timeout": "執行超時時間(毫秒,預設:30000)" + }, + "executions": { + "description": "列出技能執行歷史", + "skill": "按技能 ID 篩選", + "limit": "最大結果數(預設:50)", + "status": "按狀態篩選(running|completed|failed)" + }, + "skillssh": { + "description": "管理通過 SSH 安裝的技能" + }, + "marketplace": { + "description": "瀏覽和安裝市場中的技能" + }, + "mp": { + "search": { + "description": "搜尋市場包", + "category": "按類別篩選", + "tag": "按標籤篩選", + "limit": "最大結果數(預設:30)", + "sort": "排序方式:downloads|rating|recent" + }, + "info": { + "description": "顯示包詳情和說明文件" + }, + "install": { + "description": "安裝市場包", + "version": "包版本(預設:latest)", + "enable": "安裝後啟用", + "yes": "跳過確認提示" + }, + "categories": { + "description": "列出市場類別" + }, + "featured": { + "description": "顯示推薦市場包" + } + } + }, + "memory": { + "description": "管理 OmniRoute 對話記憶(FTS5 + 向量)", + "search": { + "description": "按語義查詢搜尋記憶條目", + "type": "按記憶型別篩選(user|feedback|project|reference)", + "limit": "最大結果數(預設:20)", + "api_key": "按 API 金鑰篩選", + "token_budget": "限制結果中的總令牌數" + }, + "add": { + "description": "新增新的記憶條目", + "content": "記憶文本內容", + "file": "從檔案讀取內容", + "type": "記憶型別(預設:user)", + "metadata": "JSON 後設資料物件", + "api_key": "關聯到 API 金鑰" + }, + "clear": { + "description": "刪除匹配篩選條件的記憶條目", + "type": "按型別篩選", + "older": "刪除早於指定時長的條目(30d, 6m, 1y)", + "api_key": "按 API 金鑰篩選", + "yes": "跳過確認提示" + }, + "list": { + "description": "列出記憶條目(不按搜尋排名)", + "type": "按型別篩選", + "limit": "最大結果數(預設:100)", + "api_key": "按 API 金鑰篩選" + }, + "get": { + "description": "按 ID 獲取單個記憶條目" + }, + "delete": { + "description": "按 ID 刪除記憶條目", + "yes": "跳過確認提示" + }, + "health": { + "description": "顯示記憶子系統健康狀態(FTS5 + Qdrant)" + } + }, + "oauth": { + "description": "管理 OAuth 提供商連線", + "providers": { + "description": "列出支援 OAuth 的提供商及其流程型別" + }, + "start": { + "description": "為提供商啟動 OAuth 授權流程", + "provider": "提供商 ID(gemini, copilot, cursor, ...)", + "no_browser": "僅列印 URL — 不開啟瀏覽器", + "import_system": "從本地系統配置自動匯入憑據", + "social": "社交登入提供商(google|github)— kiro 需要", + "timeout": "等待授權超時時間(毫秒,預設:300000)" + }, + "status": { + "description": "列出活動的 OAuth 連線", + "provider": "按提供商 ID 篩選" + }, + "revoke": { + "description": "撤銷 OAuth 連線", + "provider": "要撤銷的提供商 ID", + "connection_id": "按 ID 撤銷特定連線", + "yes": "跳過確認提示" + } + }, + "cloud": { + "description": "管理雲 AI 代理任務(codex, devin, jules)", + "agents": { + "description": "列出可用的雲代理" + }, + "agent": { + "description": "管理 {agent} 雲代理任務", + "auth": { + "description": "通過 OAuth 授權 {agent}" + } + }, + "task": { + "description": "管理雲代理任務", + "create": { + "description": "建立新的代理任務", + "title": "任務標題(預設為提示的前 80 個字元)", + "prompt": "任務提示文本", + "prompt_file": "從檔案讀取提示", + "repo": "要克隆的任務倉庫 URL", + "branch": "要使用的分支名稱", + "metadata": "JSON 後設資料物件" + }, + "list": { + "description": "列出代理任務", + "status": "按狀態篩選(running|completed|failed|cancelled)", + "limit": "最大結果數(預設:50)" + }, + "get": { + "description": "按 ID 獲取任務詳情" + }, + "status": { + "description": "列印任務狀態(running|completed|failed|cancelled)" + }, + "cancel": { + "description": "取消執行中的任務", + "yes": "跳過確認提示" + }, + "approve": { + "description": "批准代理計劃開始執行" + }, + "message": { + "description": "向執行中的任務傳送訊息" + } + }, + "sources": { + "description": "列出任務產生的原始檔" + } + }, + "eval": { + "description": "管理評估套件和執行", + "suites": { + "description": "管理評估套件", + "list": { + "description": "列出評估套件" + }, + "get": { + "description": "按 ID 獲取評估套件詳情" + }, + "create": { + "description": "從 JSON 檔案建立評估套件", + "file": "套件定義 JSON 檔案路徑" + } + }, + "run": { + "description": "為套件啟動評估執行", + "model": "要評估的模型 ID(預設:auto)", + "combo": "強制指定組合名稱", + "concurrency": "併發樣本數(預設:4)", + "tag": "標記此次執行以便後續篩選", + "watch": "觀察執行進度直至完成" + }, + "list": { + "description": "列出評估執行", + "suite": "按套件 ID 篩選", + "status": "按狀態篩選", + "since": "返回自此時間戳以來的執行", + "limit": "最大結果數(預設:50)" + }, + "get": { + "description": "按 ID 獲取評估執行詳情" + }, + "results": { + "description": "顯示評估執行的樣本結果", + "failed": "僅顯示失敗的樣本" + }, + "cancel": { + "description": "取消正在執行的評估", + "yes": "跳過確認提示" + }, + "scorecard": { + "description": "顯示已完成評估執行的記分卡" + } + }, + "webhooks": { + "description": "管理 OmniRoute Webhook", + "events": { + "description": "列出所有可用的 Webhook 事件型別" + }, + "list": { + "description": "列出已配置的 Webhook" + }, + "get": { + "description": "按 ID 獲取 Webhook 詳情" + }, + "add": { + "description": "註冊新的 Webhook", + "url": "目標 URL", + "events": "逗號分隔的事件型別列表", + "secret": "HMAC 簽名金鑰", + "header": "額外的標頭(key=value 格式,可重複)", + "no_enabled": "以停用狀態建立 Webhook" + }, + "update": { + "description": "更新現有 Webhook", + "enabled": "設定啟用狀態(true|false)" + }, + "remove": { + "description": "刪除 Webhook", + "yes": "跳過確認提示" + }, + "test": { + "description": "向 Webhook 傳送測試事件", + "event": "要模擬的事件型別(預設:request.completed)" + } + }, + "files": { + "description": "管理檔案(上傳、列出、獲取、下載、刪除)", + "list": { + "purpose": "按用途篩選", + "limit": "最大結果數" + }, + "get": { + "description": "獲取檔案後設資料" + }, + "upload": { + "description": "上傳檔案", + "purpose": "檔案用途(batch, assistants, fine-tune)" + }, + "content": { + "description": "下載檔案內容", + "out": "儲存到路徑(預設:stdout)" + }, + "delete": { + "yes": "跳過確認" + } + }, + "batches": { + "description": "管理相容 OpenAI 的批次作業", + "list": { + "status": "按狀態篩選", + "limit": "最大結果數" + }, + "create": { + "description": "建立批次作業", + "inputFile": "輸入檔案 ID", + "endpoint": "目標端點", + "window": "完成視窗", + "metadata": "新增後設資料 key=value" + }, + "submit": { + "description": "上傳 JSONL 檔案並建立批次作業", + "jsonl": "JSONL 檔案路徑", + "endpoint": "目標端點", + "wait": "等待完成" + }, + "cancel": { + "yes": "跳過確認" + }, + "wait": { + "timeout": "超時時間(毫秒)" + }, + "output": { + "out": "將輸出儲存到路徑" + }, + "errors": { + "out": "將錯誤儲存到路徑" + } + }, + "translator": { + "description": "在 LLM 格式之間轉換請求體", + "detect": { + "description": "檢測請求體格式" + }, + "translate": { + "description": "將請求體從一種格式轉換為另一種格式" + }, + "send": { + "description": "轉換併發送到上游" + }, + "stream": { + "description": "流式轉換請求體" + }, + "from": "源格式(openai|anthropic|gemini|cohere)", + "to": "目標格式(openai|anthropic|gemini|cohere)", + "file": "請求體 JSON 檔案路徑", + "out": "將輸出儲存到檔案", + "model": "覆蓋模型", + "history": { + "limit": "最大結果數" + } + }, + "pricing": { + "description": "管理模型定價資料", + "sync": { + "description": "從上游同步價格", + "provider": "按提供商篩選", + "force": "強制重新同步" + }, + "list": { + "provider": "按提供商篩選", + "model": "按模型篩選", + "limit": "最大結果數" + }, + "defaults": { + "description": "管理預設定價", + "input": "每 1M 令牌的輸入成本(美元)", + "output": "每 1M 令牌的輸出成本(美元)", + "cacheRead": "每 1M 令牌的快取讀取成本(美元)", + "cacheWrite": "每 1M 令牌的快取寫入成本(美元)" + }, + "diff": { + "description": "顯示與上游價格的差異", + "model": "按模型篩選" + } + }, + "resilience": { + "description": "檢查和管理彈性機制", + "status": { + "provider": "按提供商篩選" + }, + "breakers": { + "provider": "按提供商篩選" + }, + "cooldowns": { + "provider": "按提供商篩選", + "connectionId": "按連線 ID 篩選" + }, + "lockouts": { + "provider": "按提供商篩選", + "model": "按模型篩選" + }, + "reset": { + "description": "重置斷路器/冷卻狀態", + "provider": "要重置的提供商", + "connectionId": "要重置的連線 ID", + "model": "要重置鎖定狀態的模型", + "allCooldowns": "重置提供商的所有冷卻", + "yes": "跳過確認" + }, + "profile": { + "description": "管理彈性配置檔案", + "name": "配置檔名稱" + }, + "config": { + "description": "管理彈性配置", + "threshold": "失敗閾值", + "resetTimeout": "重置超時(毫秒)", + "baseCooldown": "基礎冷卻時間(毫秒)" + } + }, + "nodes": { + "description": "管理提供商節點(端點)", + "list": { + "provider": "按提供商篩選", + "enabled": "僅顯示已啟用的節點" + }, + "add": { + "provider": "提供商名稱", + "baseUrl": "節點的基礎 URL", + "name": "節點名稱", + "weight": "負載均衡權重", + "region": "區域標籤", + "authHeader": "自定義認證標頭(key=value)" + }, + "update": { + "baseUrl": "新的基礎 URL", + "name": "新名稱", + "weight": "新權重", + "region": "新區域", + "enabled": "啟用或停用(true|false)" + }, + "remove": { + "yes": "跳過確認" + }, + "validate": { + "baseUrl": "要驗證的 URL", + "provider": "要驗證的提供商" + }, + "test": { + "description": "向節點發送測試請求" + }, + "metrics": { + "description": "顯示節點指標", + "period": "時間範圍(例如 24h, 7d)" + } + }, + "context": { + "description": "配置上下文工程管道(Caveman, RTK)", + "analytics": { + "period": "時間範圍(例如 7d, 30d)" + }, + "caveman": { + "description": "管理 Caveman 上下文壓縮器", + "config": { + "description": "顯示或更新 Caveman 配置", + "aggressiveness": "激程序度 0.0–1.0", + "maxShrinkPct": "最大壓縮百分比", + "preserveTags": "要保留的逗號分隔標籤" + } + }, + "rtk": { + "description": "管理 RTK 上下文最佳化器", + "config": { + "description": "顯示或更新 RTK 配置", + "tokenBudget": "RTK 令牌預算", + "reservePct": "保留百分比" + }, + "filters": { + "description": "管理 RTK 過濾器", + "pattern": "過濾器模式(正則)", + "priority": "過濾器優先順序(預設:100)", + "action": "過濾器操作:drop|shrink|replace", + "yes": "跳過確認" + }, + "test": { + "file": "請求 JSON 檔案路徑" + } + }, + "combos": { + "description": "上下文感知的組合管理" + } + }, + "sessions": { + "description": "檢查和管理活動會話", + "list": { + "user": "按使用者篩選", + "kind": "按型別篩選(dashboard|api-key|mcp|a2a)", + "active": "僅顯示活動會話", + "limit": "最大結果數(預設:100)" + }, + "expire": { + "yes": "跳過確認" + }, + "expireAll": { + "user": "要過期其會話的使用者", + "yes": "跳過確認" + } + }, + "tags": { + "description": "管理資源標籤", + "add": { + "color": "標籤顏色(十六進位制或名稱)", + "description": "標籤描述" + }, + "remove": { + "yes": "跳過確認" + }, + "assign": { + "tag": "標籤名稱", + "to": "目標資源,格式為 type:id(例如 provider:openai)" + }, + "unassign": { + "tag": "標籤名稱", + "from": "來源資源,格式為 type:id" + } + }, + "openapi": { + "description": "訪問和測試 OmniRoute OpenAPI 規範", + "dump": { + "description": "將 OpenAPI 規範輸出到 stdout 或檔案", + "format": "輸出格式:yaml|json(預設:yaml)", + "out": "儲存到檔案路徑" + }, + "validate": { + "description": "驗證 OpenAPI 規範" + }, + "try": { + "description": "通過規範測試 API 端點", + "method": "HTTP 方法(預設:GET)", + "body": "請求體 JSON 檔案路徑", + "query": "查詢引數 key=value(可重複)", + "header": "標頭 key=value(可重複)" + }, + "endpoints": { + "description": "列出所有 API 端點", + "search": "按路徑或摘要篩選" + }, + "paths": { + "description": "列出所有 API 路徑" + } + }, + "combo": { + "title": "組合", + "switched": "當前組合:{name}", + "created": "組合已建立:{name}", + "deleted": "組合已刪除:{name}", + "noCombos": "未配置組合。", + "confirmDelete": "刪除組合 {name}?", + "suggest": { + "description": "使用 AI 評分為任務推薦最佳組合", + "task": "任務描述", + "maxCost": "每次請求的最大成本(美元)", + "maxLatencyMs": "最大延遲(毫秒)", + "weights": "JSON 評分權重,例如 {\"latency\":0.7,\"cost\":0.3}", + "top": "要顯示的候選數(預設:5)", + "explain": "將理由列印到 stderr", + "switch": "啟用排名最高的組合" + } + }, + "oneproxy": { + "description": "管理 OneProxy 上游代理池", + "stats": { + "provider": "按提供商篩選", + "period": "時間範圍(預設:24h)" + }, + "fetch": { + "description": "從代理池獲取代理", + "count": "要獲取的代理數(預設:1)", + "type": "代理型別:http|socks5(預設:http)" + }, + "rotate": { + "description": "強制輪換代理", + "provider": "要輪換的提供商", + "connectionId": "要輪換的特定連線 ID" + }, + "config": { + "description": "顯示或更新 OneProxy 配置", + "enabled": "啟用代理池(true|false)", + "poolSize": "池大小", + "providerSource": "代理提供商的 URL", + "rotationPolicy": "輪換策略:sticky|per-request|periodic" + }, + "pool": { + "description": "列出當前代理池及指標" + } + }, + "open": { + "description": "在瀏覽器中開啟特定的 OmniRoute 儀表盤頁面", + "url": "僅列印 URL,不開啟瀏覽器" + }, + "telemetry": { + "description": "訪問聚合遙測資料", + "summary": { + "description": "顯示聚合遙測摘要", + "period": "時間範圍:24h|7d|30d(預設:24h)", + "compareTo": "與上一時期比較" + }, + "export": { + "description": "將遙測事件匯出到 JSONL", + "out": "輸出檔案路徑(預設:telemetry.jsonl)", + "period": "匯出時間範圍(預設:7d)" + } + }, + "sync": { + "description": "在 OmniRoute 例項之間同步配置", + "push": { + "description": "將配置推送到雲或遠端例項", + "target": "目標(cloud 或 context:name)", + "bundle": "要同步的包部分", + "dryRun": "預覽但不應用" + }, + "pull": { + "description": "從雲或遠端拉取配置", + "source": "來源(cloud 或 context:name)", + "merge": "與現有配置合併", + "replace": "替換現有配置", + "dryRun": "預覽但不應用" + }, + "diff": { + "source": "源上下文", + "target": "目標上下文" + }, + "bundle": { + "description": "將配置匯出為包檔案", + "include": "要包含的部分(逗號分隔)" + }, + "import": { + "description": "匯入包檔案", + "dryRun": "驗證但不應用" + }, + "initialize": { + "fromCloud": "從雲備份初始化" + }, + "tokens": { + "description": "管理同步令牌", + "create": { + "name": "令牌名稱", + "scope": "令牌作用域", + "ttl": "令牌有效期(例如 30d)" + }, + "revoke": { + "yes": "跳過確認" + } + }, + "resolve": { + "description": "互動式解決同步衝突" + } + }, + "config": { + "contexts": { + "description": "管理伺服器上下文/配置檔案(新增、使用、列出、顯示、移除、重新命名、匯出、匯入)" + }, + "lang": { + "description": "管理 CLI 顯示語言", + "getDescription": "顯示當前活動的語言程式碼", + "setDescription": "設定顯示語言並儲存到配置", + "listDescription": "列出所有可用語言", + "listTitle": "可用語言", + "current": "語言:{code}({name})", + "saved": "語言已設定為 {code}({name})。", + "noCode": "需要語言程式碼。執行 omniroute config lang list 檢視可用程式碼。", + "unknown": "未知語言程式碼:{code}。執行 omniroute config lang list 檢視可用程式碼。", + "alreadySet": "語言已設定為 {code}。", + "envHint": "提示:您也可以在環境中設定 OMNIROUTE_LANG={code}。" + } + }, + "completion": { + "description": "生成或安裝 Shell 補全指令碼", + "zsh": "列印 zsh 補全指令碼", + "bash": "列印 bash 補全指令碼", + "fish": "列印 fish 補全指令碼", + "install": "為檢測到的 Shell 全域性安裝補全指令碼", + "refresh": "重新整理組合/提供商/模型快取" + }, + "logs": { + "description": "流式傳輸或匯出請求日誌", + "follow": "即時流式傳輸日誌", + "filter": "按級別篩選(error,warn,info)— 逗號分隔", + "lines": "要獲取的行數", + "timeout": "連線超時(毫秒)", + "baseUrl": "OmniRoute API 基礎 URL", + "requestId": "按請求 ID 篩選", + "apiKey": "按 API 金鑰篩選", + "combo": "按組合名稱篩選", + "status": "按 HTTP 狀態碼篩選", + "durationMin": "最小請求持續時間(毫秒)", + "durationMax": "最大請求持續時間(毫秒)", + "export": "將日誌儲存到檔案(json/jsonl/csv)", + "exported": "日誌已儲存至 {path}", + "stopped": "日誌流已停止。", + "streamError": "日誌流錯誤:{message}" + }, + "tray": { + "description": "控制系統托盤圖示", + "show": "顯示托盤圖示(如果伺服器使用 --tray 執行)", + "hide": "隱藏托盤圖示", + "quit": "通過托盤退出 OmniRoute" + }, + "autostart": { + "description": "管理 OmniRoute 開機自啟(Linux:systemd 使用者服務)", + "enable": "啟用開機自啟", + "disable": "停用開機自啟", + "status": "顯示自啟狀態", + "toggle": "切換開機自啟" + }, + "runtime": { + "description": "管理本地執行時依賴", + "check": "檢查執行時目錄中本地依賴的狀態", + "repair": "重新安裝執行時目錄中的本地依賴", + "repair_force": "即使有效也強制重新安裝", + "clean": "刪除執行時目錄(釋放磁碟空間)", + "clean_yes": "跳過確認" + }, + "repl": { + "description": "與 LLM 互動式多輪 REPL", + "model": "要使用的模型(預設:auto)", + "combo": "要使用的組合名稱", + "system": "系統提示", + "resume": "按名稱恢復儲存的會話" + }, + "plugin": { + "description": "管理 CLI 外掛(omniroute-cmd-*)", + "list": "列出已安裝的外掛", + "install": "從 npm 或本地路徑安裝外掛", + "remove": "刪除已安裝的外掛", + "info": "顯示已安裝外掛的詳情", + "search": "搜尋 npm 登錄檔中的可用外掛", + "update": "更新已安裝的外掛", + "scaffold": "搭建新的外掛模板" + } +} diff --git a/changelog.d/features/6072-ws-server-inprocess-autostart.md b/changelog.d/features/6072-ws-server-inprocess-autostart.md new file mode 100644 index 0000000000..b5b2aa5277 --- /dev/null +++ b/changelog.d/features/6072-ws-server-inprocess-autostart.md @@ -0,0 +1 @@ +- **feat(ws):** the live-dashboard WebSocket server now auto-starts in-process (via `instrumentation-node.ts`) across every deployment mode — dev, production, Docker, Electron — with no separate sidecar script; the default WS port moved from 20129 to **20132** to avoid colliding with `API_PORT` in split-port setups, the deprecated `OMNIROUTE_DISABLE_LIVE_WS` env was consolidated into `OMNIROUTE_ENABLE_LIVE_WS` (default enabled), and the WS path is now derived from `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL`'s pathname (`/live-ws` fallback) (#6072 — thanks @ianriizky). diff --git a/changelog.d/features/6556-omniglyph-engine.md b/changelog.d/features/6556-omniglyph-engine.md new file mode 100644 index 0000000000..5417e469e5 --- /dev/null +++ b/changelog.d/features/6556-omniglyph-engine.md @@ -0,0 +1 @@ +- **feat(compression):** new **omniglyph** engine (context-as-image) — renders system prompt, tool docs, and dense history as compact PNG pages the model reads instead of text (~10× fewer tokens on the converted block; 59–70% end-to-end measured). Works stacked with RTK/Caveman (`stackPriority: 90`) or standalone (`mode: omniglyph`); restricted to Claude Fable 5 over the direct Anthropic route, fail-closed gates with `skip:` techniques, preview (`stable: false`, off by default) (#6556). Dependency bumped to `omniglyph@^1.0.2` for upstream ReDoS fixes (#6661). diff --git a/changelog.d/features/6611-native-container-runtimes.md b/changelog.d/features/6611-native-container-runtimes.md new file mode 100644 index 0000000000..3515d9dc04 --- /dev/null +++ b/changelog.d/features/6611-native-container-runtimes.md @@ -0,0 +1 @@ +- **feat(sandbox):** the skill sandbox gained a container-provider abstraction that auto-detects and uses the best native runtime per host — Apple Container (macOS 26+), WSL container (`wslc.exe`), OrbStack, Podman — instead of hardcoding `docker run`, removing the Docker Desktop requirement on macOS/Windows (#6611 — thanks @KooshaPari). diff --git a/changelog.d/features/6763-operator-configurable-account-rotation.md b/changelog.d/features/6763-operator-configurable-account-rotation.md new file mode 100644 index 0000000000..71ff263a3a --- /dev/null +++ b/changelog.d/features/6763-operator-configurable-account-rotation.md @@ -0,0 +1 @@ +- **feat(resilience):** operator-configurable account rotation policy — a new `rotationConfig` layer lets operators tune how connections rotate on failure, wired into `accountFallback` (#6763 — thanks @artickc). diff --git a/changelog.d/features/6798-latency-optimized-proxy-rotation.md b/changelog.d/features/6798-latency-optimized-proxy-rotation.md new file mode 100644 index 0000000000..e15583b2ec --- /dev/null +++ b/changelog.d/features/6798-latency-optimized-proxy-rotation.md @@ -0,0 +1 @@ +- **feat(proxy):** add a latency-optimized proxy rotation strategy that ranks pool entries by measured round-trip latency, extending the existing round-robin/random/sticky proxy-pool selection (#6798 — thanks @iamraydoan). diff --git a/changelog.d/features/6804-fusion-judge-own-knowledge.md b/changelog.d/features/6804-fusion-judge-own-knowledge.md new file mode 100644 index 0000000000..7577033596 --- /dev/null +++ b/changelog.d/features/6804-fusion-judge-own-knowledge.md @@ -0,0 +1 @@ +- **feat(fusion):** the fusion judge may now draw on its own knowledge and override the panel when every panel answer is wrong or incomplete, instead of being restricted to synthesizing only from panel output (#6804 — thanks @chirag127). diff --git a/changelog.d/fixes/6280-lmarena-arena-modernize.md b/changelog.d/fixes/6280-lmarena-arena-modernize.md new file mode 100644 index 0000000000..4ba53979bc --- /dev/null +++ b/changelog.d/fixes/6280-lmarena-arena-modernize.md @@ -0,0 +1 @@ +- **fix(providers):** modernize the `lmarena` provider for the Arena.ai rebrand — route chat through `arena.ai` create-evaluation with Chrome TLS impersonation, seed a static Direct-chat Text/Search + Image catalog, and keep the `lmarena`/`lma` wire id for back-compat ([#6280](https://github.com/diegosouzapw/OmniRoute/pull/6280)) — thanks @backryun diff --git a/changelog.d/fixes/6308-web-model-discovery.md b/changelog.d/fixes/6308-web-model-discovery.md new file mode 100644 index 0000000000..a452c1ae73 --- /dev/null +++ b/changelog.d/fixes/6308-web-model-discovery.md @@ -0,0 +1 @@ +- **fix(providers):** web-provider model discovery updated — qwen-web uses the slash-terminated models endpoint (avoiding a blocked 307 redirect), and kimi-web matches the current request shape (POST with bearer + `kimi-auth` cookie replay) with its catalog refreshed to the current non-agent models (#6308 — thanks @janeza2). diff --git a/changelog.d/fixes/6323-log-detail-stale-reopen.md b/changelog.d/fixes/6323-log-detail-stale-reopen.md new file mode 100644 index 0000000000..ec1c53ca2f --- /dev/null +++ b/changelog.d/fixes/6323-log-detail-stale-reopen.md @@ -0,0 +1 @@ +- **fix(logs):** the request-log detail modal no longer reopens by itself after being closed — a stale in-flight detail refresh resolved after close and re-triggered the modal open state (#6323 — thanks @xz-dev). diff --git a/changelog.d/fixes/6538-tier-flow-svg-public.md b/changelog.d/fixes/6538-tier-flow-svg-public.md new file mode 100644 index 0000000000..e3d3f76cef --- /dev/null +++ b/changelog.d/fixes/6538-tier-flow-svg-public.md @@ -0,0 +1 @@ +- **fix(dashboard):** the onboarding tier-flow diagram rendered broken — its SVGs lived in the repo-root `images/` (not a served path); moved to `public/images/` so Next.js serves them (#6538 — thanks @ianriizky). diff --git a/changelog.d/fixes/6586-preserve-server-tool-names.md b/changelog.d/fixes/6586-preserve-server-tool-names.md new file mode 100644 index 0000000000..62ee75c234 --- /dev/null +++ b/changelog.d/fixes/6586-preserve-server-tool-names.md @@ -0,0 +1 @@ +- **fix(sse):** server-tool literal names (e.g. `web_search`) are preserved in message history and `tool_choice` instead of being namespaced/rewritten, so follow-up turns referencing those tools keep working (#6586 — thanks @MikeTuev). diff --git a/changelog.d/fixes/6647-winget-claude-detect.md b/changelog.d/fixes/6647-winget-claude-detect.md new file mode 100644 index 0000000000..c0ffef1324 --- /dev/null +++ b/changelog.d/fixes/6647-winget-claude-detect.md @@ -0,0 +1 @@ +- **fix(cli):** Claude Code installed via WinGet is now detected on Windows (the WinGet install path was missing from the binary lookup) (#6647 — thanks @enjoyer-hub). diff --git a/changelog.d/fixes/6675-remove-obsolete-providers.md b/changelog.d/fixes/6675-remove-obsolete-providers.md new file mode 100644 index 0000000000..4c4f4bcf1c --- /dev/null +++ b/changelog.d/fixes/6675-remove-obsolete-providers.md @@ -0,0 +1 @@ +- **fix(providers):** removed obsolete/defunct providers from the catalog (glhf, kluster, cablyai, inclusionai) (#6675 — thanks @backryun). diff --git a/changelog.d/fixes/6698-count-gate-rejected-usage.md b/changelog.d/fixes/6698-count-gate-rejected-usage.md new file mode 100644 index 0000000000..bb1288c407 --- /dev/null +++ b/changelog.d/fixes/6698-count-gate-rejected-usage.md @@ -0,0 +1 @@ +- **fix(sse):** requests rejected before `handleChatCore` (circuit-breaker/cooldown gate or combo with all targets exhausted) are now recorded in `usage_history` too, so a key whose traffic was entirely gate-rejected no longer shows "zero requests" in the per-API-key usage counter (#6698). diff --git a/changelog.d/fixes/6714-reasoning-buffer-cap-aware.md b/changelog.d/fixes/6714-reasoning-buffer-cap-aware.md new file mode 100644 index 0000000000..a67b36333c --- /dev/null +++ b/changelog.d/fixes/6714-reasoning-buffer-cap-aware.md @@ -0,0 +1 @@ +- **fix(routing):** the reasoning-token headroom buffer clamps to the model's explicit output cap instead of inflating past it, and `getExplicitModelOutputCap` falls through to the registry/spec cap when a synced capability row exists without a numeric `limit_output` ([#6714](https://github.com/diegosouzapw/OmniRoute/pull/6714)) — thanks @xz-dev diff --git a/changelog.d/fixes/6721-codex-spark-image-drop.md b/changelog.d/fixes/6721-codex-spark-image-drop.md index 06cc548828..d814d8bd39 100644 --- a/changelog.d/fixes/6721-codex-spark-image-drop.md +++ b/changelog.d/fixes/6721-codex-spark-image-drop.md @@ -1 +1 @@ -- **fix(providers):** Codex Desktop requests to `gpt-5.3-codex-spark` failed with `[400]: Tool 'image_generation' is not supported with gpt-5.3-codex-spark`, even on paid-plan accounts ([#6651](https://github.com/diegosouzapw/OmniRoute/issues/6651)) — `CodexExecutor.transformRequest` (`open-sse/executors/codex.ts`) only dropped the Codex Desktop-injected `image_generation` hosted tool when `isCodexFreePlan()` matched the account's plan, with no awareness that Spark-scope Codex models reject `image_generation` upstream regardless of plan. `dropImageGeneration` now also drops it when `getCodexModelScope(model) === "spark"` (the existing Spark classifier from `open-sse/config/codexQuotaScopes.ts`), independent of account plan. Regression guard: `tests/unit/codex-spark-image-generation.test.ts`. +- **fix(providers):** Codex Desktop requests to `gpt-5.3-codex-spark` failed with `[400]: Tool 'image_generation' is not supported with gpt-5.3-codex-spark`, even on paid-plan accounts ([#6651](https://github.com/diegosouzapw/OmniRoute/issues/6651)) — `CodexExecutor.transformRequest` (`open-sse/executors/codex.ts`) only dropped the Codex Desktop-injected `image_generation` hosted tool when `isCodexFreePlan()` matched the account's plan, with no awareness that Spark-scope Codex models reject `image_generation` upstream regardless of plan. `dropImageGeneration` now also drops it when `getCodexModelScope(model) === "spark"` (the existing Spark classifier from `open-sse/config/codexQuotaScopes.ts`), independent of account plan. Regression guard: `tests/unit/codex-spark-image-generation.test.ts` (thanks @alltomatos for independently catching and fixing it via #6819). diff --git a/changelog.d/fixes/6757-rtk-enable-renderers-schema.md b/changelog.d/fixes/6757-rtk-enable-renderers-schema.md new file mode 100644 index 0000000000..a6aa1c910e --- /dev/null +++ b/changelog.d/fixes/6757-rtk-enable-renderers-schema.md @@ -0,0 +1 @@ +- **fix(api):** the compression config PUT schema now accepts `enableRenderers` for the RTK engine instead of rejecting the documented option (#6703, #6757 — thanks @alltomatos, with an independent duplicate fix from @chirag127 via #6756). diff --git a/changelog.d/fixes/6759-cookie-provider-apikey-cap.md b/changelog.d/fixes/6759-cookie-provider-apikey-cap.md new file mode 100644 index 0000000000..56fa315e97 --- /dev/null +++ b/changelog.d/fixes/6759-cookie-provider-apikey-cap.md @@ -0,0 +1 @@ +- **fix(api):** raised the provider `apiKey` length cap for cookie-based web providers, whose session-cookie credentials legitimately exceed the previous limit (#6715, #6759 — thanks @alltomatos). diff --git a/changelog.d/fixes/6790-gemini-pdf-video-attachments.md b/changelog.d/fixes/6790-gemini-pdf-video-attachments.md index 9875a41091..ad4cf383d9 100644 --- a/changelog.d/fixes/6790-gemini-pdf-video-attachments.md +++ b/changelog.d/fixes/6790-gemini-pdf-video-attachments.md @@ -1 +1 @@ -- **fix(translator):** read PDF/video `file_data` attachments on the OpenAI→Gemini/Antigravity and OpenAI→Claude paths so multimodal documents (not just images) reach the upstream — PDFs map to `document`/`inlineData` and videos keep their `video/mp4` mime instead of being dropped (#6790 — thanks @Witroch4). +- **fix(translator):** read PDF/video `file_data` attachments on the OpenAI→Gemini/Antigravity and OpenAI→Claude paths so multimodal documents (not just images) reach the upstream — PDFs map to `document`/`inlineData` and videos keep their `video/mp4` mime instead of being dropped (#6790 — thanks @Witroch4, with an independent report/fix from @samimozcan via #6762/#6753). diff --git a/changelog.d/fixes/6821-budget-tokens-zero-gemini.md b/changelog.d/fixes/6821-budget-tokens-zero-gemini.md new file mode 100644 index 0000000000..3886b4e6b3 --- /dev/null +++ b/changelog.d/fixes/6821-budget-tokens-zero-gemini.md @@ -0,0 +1 @@ +- **fix(providers):** an explicit `thinking.budget_tokens: 0` is now honored in the OpenAI→Gemini transform (thinking disabled) instead of being treated as unset (#6813, #6821 — thanks @alltomatos). diff --git a/changelog.d/maintenance/ci-vps-runner-sharding.md b/changelog.d/maintenance/ci-vps-runner-sharding.md new file mode 100644 index 0000000000..925ce479a4 --- /dev/null +++ b/changelog.d/maintenance/ci-vps-runner-sharding.md @@ -0,0 +1 @@ +- **ci:** unit fast-path sharding doubled 2→4 (halves the heaviest job's wall time) (#6781); the 3 heaviest fast-path jobs can route to the self-hosted VPS runner pool behind `USE_VPS_RUNNER` (#6691); `VPS_ALWAYS_ON` keeps the dedicated 24/7 CI host up across releases (teardown becomes a no-op) (#6693). diff --git a/changelog.d/maintenance/docs-strategy-count-skill-names.md b/changelog.d/maintenance/docs-strategy-count-skill-names.md new file mode 100644 index 0000000000..a14507159b --- /dev/null +++ b/changelog.d/maintenance/docs-strategy-count-skill-names.md @@ -0,0 +1 @@ +- **docs:** routing-strategy count reconciled to 18 across AUTO-COMBO.md, README and AGENTS.md, and `p2c` casing fixed to match `ROUTING_STRATEGY_VALUES` (#6643, #6644, #6646 — thanks @chirag127); CLAUDE.md updated with the renamed review/triage/implement skill-family names (#6663). diff --git a/changelog.d/maintenance/readme-strategy-tool-counts.md b/changelog.d/maintenance/readme-strategy-tool-counts.md new file mode 100644 index 0000000000..6e82bd00c1 --- /dev/null +++ b/changelog.d/maintenance/readme-strategy-tool-counts.md @@ -0,0 +1 @@ +- **docs(readme):** fix stale counts — 18 routing strategies (adds the missing `pipeline` row), 94 MCP tools, 12-factor Auto-Combo scoring. diff --git a/config/i18n.json b/config/i18n.json index 6e1492496b..2b44ee7aa8 100644 --- a/config/i18n.json +++ b/config/i18n.json @@ -340,6 +340,14 @@ "native": "中文 (简体)", "english": "Chinese (Simplified)", "flag": "🇨🇳" + }, + { + "code": "zh-TW", + "label": "ZH-TW", + "name": "中文 (繁體)", + "native": "中文 (繁體)", + "english": "Chinese (Traditional)", + "flag": "🇹🇼" } ] } diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 9d3c854412..9065f5db48 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1502,16 +1502,6 @@ "count": 1 } }, - "tests/unit/live-ws-public-url.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "tests/unit/lmarena-provider.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, "tests/unit/lmarena-split-cookie-4271.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b442de5f10..3c995fa15e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -58,12 +58,16 @@ services: - PORT=${PORT:-20128} - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}} - API_PORT=${API_PORT:-20129} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:${PROD_DASHBOARD_PORT:-20130},http://127.0.0.1:${PROD_DASHBOARD_PORT:-20130}} - API_HOST=${API_HOST:-0.0.0.0} - HOSTNAME=0.0.0.0 - DATA_DIR=/app/data ports: - "${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}" - "${PROD_API_PORT:-20131}:${API_PORT:-20129}" + - "${PROD_LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" volumes: - omniroute-prod-data:/app/data healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index 2560644527..9b3add8ee3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,9 @@ x-common: &common - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - API_HOST=${API_HOST:-0.0.0.0} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128} - REDIS_URL=${REDIS_URL:-redis://redis:6379} volumes: - ./data:/app/data @@ -75,6 +78,7 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" profiles: - base @@ -92,6 +96,7 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" profiles: - web @@ -106,6 +111,7 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" volumes: - ./data:/app/data - /var/run/docker.sock:/var/run/docker.sock @@ -125,12 +131,16 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" environment: - DATA_DIR=/app/data - PORT=${PORT:-20128} - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - API_HOST=${API_HOST:-0.0.0.0} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128} - CLI_MODE=host - CLI_EXTRA_PATHS=/host-local/bin:/host-node/bin - CLI_CONFIG_HOME=/host-home diff --git a/docs/README.md b/docs/README.md index d43de85363..9cb3895425 100644 --- a/docs/README.md +++ b/docs/README.md @@ -177,7 +177,7 @@ Mermaid sources and exported SVG/PNG diagrams referenced from the docs above. Se ## i18n/ -Translated mirrors of the documentation in 42 locales. See [i18n/README.md](i18n/README.md) for the supported language list. +Translated mirrors of the documentation in 43 locales. See [i18n/README.md](i18n/README.md) for the supported language list. ## screenshots/ diff --git a/docs/guides/I18N.md b/docs/guides/I18N.md index 355093f24f..ed684900b9 100644 --- a/docs/guides/I18N.md +++ b/docs/guides/I18N.md @@ -6,7 +6,7 @@ lastUpdated: 2026-06-28 # i18n — Internationalization Guide -OmniRoute supports **42 languages** with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew. +OmniRoute supports **43 languages** with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew. 🌐 **Languages:** 🇺🇸 [English](./I18N.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/guides/I18N.md) | 🇪🇸 [Español](../i18n/es/docs/guides/I18N.md) | 🇫🇷 [Français](../i18n/fr/docs/guides/I18N.md) | 🇩🇪 [Deutsch](../i18n/de/docs/guides/I18N.md) | 🇮🇹 [Italiano](../i18n/it/docs/guides/I18N.md) | 🇷🇺 [Русский](../i18n/ru/docs/guides/I18N.md) | 🇨🇳 [中文 (简体)](../i18n/zh-CN/docs/guides/I18N.md) | 🇯🇵 [日本語](../i18n/ja/docs/guides/I18N.md) | 🇰🇷 [한국어](../i18n/ko/docs/guides/I18N.md) | 🇸🇦 [العربية](../i18n/ar/docs/guides/I18N.md) | 🇮🇳 [हिन्दी](../i18n/hi/docs/guides/I18N.md) | 🇹🇭 [ไทย](../i18n/th/docs/guides/I18N.md) | 🇹🇷 [Türkçe](../i18n/tr/docs/guides/I18N.md) | 🇺🇦 [Українська](../i18n/uk-UA/docs/guides/I18N.md) | 🇻🇳 [Tiếng Việt](../i18n/vi/docs/guides/I18N.md) | 🇧🇬 [Български](../i18n/bg/docs/guides/I18N.md) | 🇩🇰 [Dansk](../i18n/da/docs/guides/I18N.md) | 🇫🇮 [Suomi](../i18n/fi/docs/guides/I18N.md) | 🇮🇱 [עברית](../i18n/he/docs/guides/I18N.md) | 🇭🇺 [Magyar](../i18n/hu/docs/guides/I18N.md) | 🇮🇩 [Bahasa Indonesia](../i18n/id/docs/guides/I18N.md) | 🇲🇾 [Bahasa Melayu](../i18n/ms/docs/guides/I18N.md) | 🇳🇱 [Nederlands](../i18n/nl/docs/guides/I18N.md) | 🇳🇴 [Norsk](../i18n/no/docs/guides/I18N.md) | 🇵🇹 [Português (Portugal)](../i18n/pt/docs/guides/I18N.md) | 🇷🇴 [Română](../i18n/ro/docs/guides/I18N.md) | 🇵🇱 [Polski](../i18n/pl/docs/guides/I18N.md) | 🇸🇰 [Slovenčina](../i18n/sk/docs/guides/I18N.md) | 🇸🇪 [Svenska](../i18n/sv/docs/guides/I18N.md) | 🇵🇭 [Filipino](../i18n/phi/docs/guides/I18N.md) | 🇨🇿 [Čeština](../i18n/cs/docs/guides/I18N.md) @@ -134,6 +134,7 @@ README variants) are not yet handled by the new pipeline and are still used. | `uk-UA` | Українська | No | `uk` | | `vi` | Tiếng Việt | No | `vi` | | `zh-CN` | 中文 (简体) | No | `zh-CN` | +| `zh-TW` | 中文 (繁體) | No | `zh-TW` | ## Adding a New Language @@ -241,7 +242,7 @@ python3 scripts/i18n/i18n_autotranslate.py \ - Scans `docs/i18n/` markdown files for English paragraphs - Skips code blocks, tables, and already-translated content - Sends paragraphs to LLM with technical translation system prompt -- Supports all 42 languages +- Supports all 43 languages ## CLI i18n @@ -250,7 +251,7 @@ The `omniroute` CLI has its own i18n layer separate from the Next.js dashboard. ### How it works - Every user-facing string in CLI commands goes through `t("module.key", vars)` from `bin/cli/i18n.mjs`. -- Catalogs are JSON files in `bin/cli/locales/` — 42 ship out-of-the-box. +- Catalogs are JSON files in `bin/cli/locales/` — 43 ship out-of-the-box. - Locale falls back to `en` for any missing key, so partial translations are valid. - The source of truth for available locales is `config/i18n.json` (shared with the dashboard). @@ -301,7 +302,7 @@ invocation. Use `config lang set` to persist. ### Available locales -42 locale files ship in `bin/cli/locales/`. Full translations: `en`, `pt-BR`. +43 locale files ship in `bin/cli/locales/`. Full translations: `en`, `pt-BR`. Scaffold-only (all keys fall back to `en`): `bn`, `gu`, `he`, `in`, `mr`, `ms`, `phi`, `sw`, `ta`, `te`, `ur`. All other 29 locales have `common` + `program` keys translated. @@ -348,12 +349,13 @@ python3 scripts/i18n/validate_translation.py -l cs - **Placeholder mismatches** — ICU placeholders that don't match between source and translation **Exit codes:** -| Code | Meaning | -|------|---------| -| 0 | OK | -| 1 | Generic error | -| 2 | Missing strings (hard error) | -| 3 | Untranslated warning (soft) | + +| Code | Meaning | +| ---- | ---------------------------- | +| 0 | OK | +| 1 | Generic error | +| 2 | Missing strings (hard error) | +| 3 | Untranslated warning (soft) | **Environment:** Set `TRANSLATION_LANG=cs` or use `-l cs` flag. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 1963c24302..6bad9d7969 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -115,33 +115,32 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari ## 3. Network & Ports -| Variable | Default | Source File | Description | -| ------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | -| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs` | URL subpath for serving OmniRoute behind a reverse proxy under a subpath (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. | -| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | -| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | -| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | -| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. | -| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. | -| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. | -| `LIVE_WS_PORT` | `20129` | `src/server/ws/liveServer.ts` | Port for the real-time WebSocket live monitoring server. | -| `LIVE_WS_HOST` | `127.0.0.1` | `src/server/ws/liveServer.ts` | Bind address for the live WebSocket server. Set to `0.0.0.0` to expose on LAN (also configure `LIVE_WS_ALLOWED_ORIGINS`). | -| `LIVE_WS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/ws/liveServer.ts` | Comma-separated extra origins allowed to open a live WebSocket. Loopback dashboard origins are already permitted by default. | -| `LIVE_WS_ALLOWED_HOSTS` | _(unset)_ | `src/server/ws/liveServerAllowList.ts` | Comma-separated extra hostnames allowed for live WebSocket origins. Unlike `LIVE_WS_ALLOWED_ORIGINS` (full origin URLs), matches only the host portion — useful for LAN/Tailscale setups. | -| `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` | _(unset)_ | `src/hooks/useLiveDashboard.ts` | Public URL for the live dashboard WebSocket (browser-side). Set when fronting the WS server with a reverse proxy or Cloudflare Tunnel (e.g. `wss://ws.my-ai.com/live-ws`); the browser connects there instead of `ws://hostname:20129`. | -| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` | Set to `0` or `false` to disable the real-time WebSocket server (enabled by default, loopback-bound). | -| `OMNIROUTE_DISABLE_LIVE_WS` | `false` | `scripts/start-ws-server.mjs` | CI/harness toggle that disables the standalone live WebSocket helper script. | -| `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | Per-(token, IP) relay rate limit, requests/minute. In-memory, per instance. `0` or negative disables the IP-dimension gate (per-token DB limit still applies). | -| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. | -| `OMNIROUTE_USE_TURBOPACK` | `1` (Turbopack — code default) | `package.json` / Next.js 16 | Turbopack is the default bundler for `npm run dev` and `npm run build` (2-3× faster builds, benchmarked). Set to `0` to fall back to webpack on Windows or when running into native binding / bundler-compat incompatibilities. | -| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(unset)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | Set to `1` to skip the SQLite integrity health check on startup. Useful for faster boot on large databases. | -| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | Interval (ms) for the background credential health check scheduler. Minimum: 10000 (10s). | -| `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | TTL (ms) for cached credential health status. | -| `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | Set to `1` or `true` to disable background periodic testing of provider connections. | -| `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Bind address for the Next.js dev/start server. Overrides the default `0.0.0.0` when set. | -| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Bind address used by the Playwright runner when launching Next.js. Defaults to `127.0.0.1` for hermetic tests. **Do not use for `omniroute serve`** — use `OMNIROUTE_SERVER_HOST` instead (POSIX shells auto-set `HOSTNAME` to the machine name; `.env` cannot override it). | -| `OMNIROUTE_SERVER_HOST` | `0.0.0.0` | `bin/cli/commands/serve.mjs` | Bind address for `omniroute serve`. Avoids collision with the POSIX shell `HOSTNAME` variable (always set to the machine name by bash/zsh). Falls back to `0.0.0.0` when unset. (#6194) | +| Variable | Default | Source File | Description | +| ------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | +| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs` | URL subpath for serving OmniRoute behind a reverse proxy under a subpath (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. | +| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | +| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | +| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | +| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. | +| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. | +| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. | +| `LIVE_WS_PORT` | `20129` | `src/server/ws/liveServer.ts` | Port for the real-time WebSocket live monitoring server. | +| `LIVE_WS_HOST` | `127.0.0.1` | `src/server/ws/liveServer.ts` | Bind address for the live WebSocket server. Set to `0.0.0.0` to expose on LAN (also configure `LIVE_WS_ALLOWED_ORIGINS`). | +| `LIVE_WS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/ws/liveServer.ts` | Comma-separated extra origins allowed to open a live WebSocket. Loopback dashboard origins are already permitted by default. | +| `LIVE_WS_ALLOWED_HOSTS` | _(unset)_ | `src/server/ws/liveServerAllowList.ts` | Comma-separated extra hostnames allowed for live WebSocket origins. Unlike `LIVE_WS_ALLOWED_ORIGINS` (full origin URLs), matches only the host portion — useful for LAN/Tailscale setups. | +| `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` | _(unset)_ | `src/hooks/useLiveDashboard.ts` | Public URL for the live dashboard WebSocket (browser-side). Set when fronting the WS server with a reverse proxy or Cloudflare Tunnel (e.g. `wss://ws.my-ai.com/live-ws`); the browser connects there instead of `ws://hostname:20132`. The pathname portion is also used as the WebSocket upgrade path (default: `/live-ws`). | +| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` and `scripts/start-ws-server.mjs` | Set to `0` or `false` to disable the real-time WebSocket server (enabled by default, loopback-bound). CI/harness toggle that disables the standalone live WebSocket helper script. | +| `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | Per-(token, IP) relay rate limit, requests/minute. In-memory, per instance. `0` or negative disables the IP-dimension gate (per-token DB limit still applies). | +| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. | +| `OMNIROUTE_USE_TURBOPACK` | `1` (Turbopack — code default) | `package.json` / Next.js 16 | Turbopack is the default bundler for `npm run dev` and `npm run build` (2-3× faster builds, benchmarked). Set to `0` to fall back to webpack on Windows or when running into native binding / bundler-compat incompatibilities. | +| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(unset)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | Set to `1` to skip the SQLite integrity health check on startup. Useful for faster boot on large databases. | +| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | Interval (ms) for the background credential health check scheduler. Minimum: 10000 (10s). | +| `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | TTL (ms) for cached credential health status. | +| `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | Set to `1` or `true` to disable background periodic testing of provider connections. | +| `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Bind address for the Next.js dev/start server. Overrides the default `0.0.0.0` when set. | +| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Bind address used by the Playwright runner when launching Next.js. Defaults to `127.0.0.1` for hermetic tests. **Do not use for `omniroute serve`** — use `OMNIROUTE_SERVER_HOST` instead (POSIX shells auto-set `HOSTNAME` to the machine name; `.env` cannot override it). | +| `OMNIROUTE_SERVER_HOST` | `0.0.0.0` | `bin/cli/commands/serve.mjs` | Bind address for `omniroute serve`. Avoids collision with the POSIX shell `HOSTNAME` variable (always set to the machine name by bash/zsh). Falls back to `0.0.0.0` when unset. (#6194) | ### Port Modes @@ -836,6 +835,7 @@ Anthropic-compatible provider instead. | Variable | Default | Source File | Description | | ----------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. | +| `PROXY_LATENCY_WINDOW_HOURS` | `3` | `src/lib/db/proxies.ts` | Time window (hours) for calculating the average latency of candidate proxies in the latency-optimized pool strategy. | | `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. | | `PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS` | `2000` | `src/lib/proxyHealth.ts` | Cache TTL for failed proxy health probes. Keep this shorter than `PROXY_HEALTH_CACHE_TTL_MS` so transient proxy timeouts under high concurrency retry quickly without disabling fast-fail for truly dead proxies. | | `PROXY_HEALTH_ENABLED` | `true` | `src/lib/proxyHealth/scheduler.ts` | Set `false` to disable the background proxy health scheduler that periodically probes registered proxies. | @@ -1094,6 +1094,21 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `QDRANT_EMBEDDING_MODEL` | `text-embedding-3-small` | _(opt-in cluster profile)_ | Default embedding model name recorded in the Qdrant collection metadata. Actual embeddings are generated by whatever provider the `embeddingModel` field in OmniRoute's settings points to. | | `QDRANT_VECTOR_SIZE` | `1536` | _(opt-in cluster profile)_ | Embedding vector dimension. Must match the model you embed with (text-embedding-3-small → 1536; ada-002 → 1536; nomic-embed-text → 768). | | `QDRANT_HNSW_EF_CONSTRUCT` | `128` | _(opt-in cluster profile)_ | HNSW index construction-time accuracy. Higher = slower build, faster search. | +| `OMNIROUTE_ROTATION_ENABLED` | `true` | `open-sse/services/rotationConfig.ts` | Master switch for operator-configurable account rotation. When `false`, none of the `OMNIROUTE_ROTATE_*` classes below trigger account fallback (the master-off state also blocks the default-enabled 429/500/502 classes). Lets a supervising front-end (e.g. the VibeProxy desktop app) mirror its own rotation rules onto the backend's account-fallback engine. | +| `OMNIROUTE_ROTATION_RATE_LIMIT_RESET_SECONDS` | `0` | `open-sse/services/rotationConfig.ts` | Cooldown (seconds) applied to a rate-limited account when the upstream gives no explicit reset hint. `0` = use the engine default cooldown instead of a fixed override. | +| `OMNIROUTE_ROTATION_DISABLE_TAG_WITHOUT_RESET` | `true` | `open-sse/services/rotationConfig.ts` | Mirror of the front-end "don't tag as rate-limited without a reset time" preference. | +| `OMNIROUTE_ROTATE_ON_429` | `true` | `open-sse/services/rotationConfig.ts` | Per-status fallback enable for `429` errors. When `false` (and `OMNIROUTE_ROTATION_ENABLED=true`), a `429` no longer triggers account rotation and is returned to the client instead. | +| `OMNIROUTE_ROTATE_429_THRESHOLD` | `1` | `open-sse/services/rotationConfig.ts` | Number of `429` errors within `OMNIROUTE_ROTATE_429_WINDOW_SECONDS` required before the account is rotated. `1` (default) rotates immediately, preserving historical behavior. | +| `OMNIROUTE_ROTATE_429_WINDOW_SECONDS` | `120` | `open-sse/services/rotationConfig.ts` | Sliding window (seconds) over which `429` errors are counted toward `OMNIROUTE_ROTATE_429_THRESHOLD`. | +| `OMNIROUTE_ROTATE_ON_500` | `true` | `open-sse/services/rotationConfig.ts` | Per-status fallback enable for `5xx` server errors (excluding `502`, which has its own class). When `false`, these errors no longer trigger account rotation. | +| `OMNIROUTE_ROTATE_500_THRESHOLD` | `1` | `open-sse/services/rotationConfig.ts` | Number of `5xx` errors within `OMNIROUTE_ROTATE_500_WINDOW_SECONDS` required before the account is rotated. `1` (default) rotates immediately. | +| `OMNIROUTE_ROTATE_500_WINDOW_SECONDS` | `120` | `open-sse/services/rotationConfig.ts` | Sliding window (seconds) over which `5xx` errors are counted toward `OMNIROUTE_ROTATE_500_THRESHOLD`. | +| `OMNIROUTE_ROTATE_ON_502` | `true` | `open-sse/services/rotationConfig.ts` | Per-status fallback enable for `502` (bad gateway) errors. When `false`, `502`s no longer trigger account rotation. | +| `OMNIROUTE_ROTATE_502_THRESHOLD` | `1` | `open-sse/services/rotationConfig.ts` | Number of `502` errors within `OMNIROUTE_ROTATE_502_WINDOW_SECONDS` required before the account is rotated. `1` (default) rotates immediately. | +| `OMNIROUTE_ROTATE_502_WINDOW_SECONDS` | `120` | `open-sse/services/rotationConfig.ts` | Sliding window (seconds) over which `502` errors are counted toward `OMNIROUTE_ROTATE_502_THRESHOLD`. | +| `OMNIROUTE_ROTATE_ON_400` | `false` | `open-sse/services/rotationConfig.ts` | Opt-in (default OFF): when `true`, a plain `400` (bad request) also triggers account rotation. This is additive only — it never blocks the engine's existing behavior where a `400` carrying rate-limit/quota text still falls over regardless of this flag. | +| `OMNIROUTE_ROTATE_400_THRESHOLD` | `1` | `open-sse/services/rotationConfig.ts` | Number of `400` errors within `OMNIROUTE_ROTATE_400_WINDOW_SECONDS` required before the account is rotated (only consulted when `OMNIROUTE_ROTATE_ON_400=true`). | +| `OMNIROUTE_ROTATE_400_WINDOW_SECONDS` | `120` | `open-sse/services/rotationConfig.ts` | Sliding window (seconds) over which `400` errors are counted toward `OMNIROUTE_ROTATE_400_THRESHOLD`. | --- diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index e26558e11b..361f69eca2 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,14 +1,14 @@ --- title: "Provider Reference" version: 3.8.47 -lastUpdated: 2026-07-08 +lastUpdated: 2026-07-10 --- # 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-07-08 +> **Last generated:** 2026-07-10 Total providers: **248**. See category breakdown below. @@ -31,298 +31,298 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each --- -## OAuth Providers (21) +## OAuth Providers (22) -| ID | Alias | Name | Tags | Website | Notes | -| -------------- | ------------ | -------------------- | ----- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `agy` | `agy` | Antigravity CLI | OAuth | [link](https://antigravity.google) | Import your Antigravity CLI (`agy`) login (paste/upload its token file), auto-detect a local CLI login, or sign in with Google. Shares the Antigravity backend (incl. Claude models). | -| `amazon-q` | `aq` | Amazon Q | OAuth | [link](https://aws.amazon.com/q/developer/) | Uses the same AWS Builder ID or imported refresh-token flow as Kiro, but keeps Amazon Q connections separate. | -| `antigravity` | — | Antigravity | OAuth | — | — | -| `claude` | `cc` | Claude Code | OAuth | — | — | -| `cline` | `cl` | Cline | OAuth | — | — | -| `codebuddy-cn` | `cbcn` | CodeBuddy CN | OAuth | [link](https://copilot.tencent.com) | Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan. | -| `codex` | `cx` | OpenAI Codex | OAuth | — | — | -| `cursor` | `cu` | Cursor IDE | OAuth | — | — | -| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai | -| `github` | `gh` | GitHub Copilot | OAuth | — | — | -| `gitlab-duo` | `gitlab-duo` | GitLab Duo | OAuth | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance. | -| `grok-cli` | `gc` | Grok Build | OAuth | — | Paste your ~/.grok/auth.json (or the JWT access token) from the Grok Build CLI; refresh_token is rotated automatically. | -| `kilocode` | `kc` | Kilo Code | OAuth | — | — | -| `kimi-coding` | `kmc` | Kimi Coding | OAuth | — | — | -| `kiro` | `kr` | Kiro AI | OAuth | — | Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use. | -| `qoder` | `if` | Qoder | OAuth | — | — | -| `qwen` | `qw` | Qwen Code | OAuth | — | ⚠️ **DEPRECATED.** Qwen OAuth free tier was discontinued on 2026-04-15. Use 'bailian-coding-plan', 'alibaba', 'alibaba-cn', or 'openrouter' provider with API key instead. | -| `trae` | `tr` | Trae | OAuth | [link](https://trae.ai) | Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT ', ~14-day lifetime) as the access token; web_id/biz_user_id/user_unique_id/scope/tenant/region propagate via providerSpecificData. No headless refresh for pasted tokens — re-paste on expiry. | -| `windsurf` | `ws` | Windsurf (Devin CLI) | OAuth | [link](https://windsurf.com) | In the Windsurf / VS Code IDE, open the command palette and run `Windsurf: Provide Auth Token` (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it here. Note: opening windsurf.com/show-auth-token directly only renders a "Redirecting" page — the IDE must initiate the flow (it adds a `?state=...` param) for the token to appear. | -| `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. | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `agy` | `agy` | Antigravity CLI | OAuth | [link](https://antigravity.google) | Import your Antigravity CLI (`agy`) login (paste/upload its token file), auto-detect a local CLI login, or sign in with Google. Shares the Antigravity backend (incl. Claude models). | +| `amazon-q` | `aq` | Amazon Q | OAuth | [link](https://aws.amazon.com/q/developer/) | Uses the same AWS Builder ID or imported refresh-token flow as Kiro, but keeps Amazon Q connections separate. | +| `antigravity` | — | Antigravity | OAuth | — | — | +| `claude` | `cc` | Claude Code | OAuth | — | — | +| `cline` | `cl` | Cline | OAuth | — | — | +| `clinepass` | `cp` | ClinePass | OAuth | [link](https://cline.bot/clinepass) | ClinePass is Cline's $9.99/mo subscription bundling 10 open coding models. Sign in with your Cline account (same login as the Cline CLI/IDE), or paste a direct ClinePass API key (app.cline.bot → Settings → API Keys). A ClinePass subscription unlocks the cline-pass/* models. Reuses the Cline WorkOS OAuth flow. | +| `codebuddy-cn` | `cbcn` | CodeBuddy CN | OAuth | [link](https://copilot.tencent.com) | Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan. | +| `codex` | `cx` | OpenAI Codex | OAuth | — | — | +| `cursor` | `cu` | Cursor IDE | OAuth | — | — | +| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai | +| `github` | `gh` | GitHub Copilot | OAuth | — | — | +| `gitlab-duo` | `gitlab-duo` | GitLab Duo | OAuth | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance. | +| `grok-cli` | `gc` | Grok Build | OAuth | — | Paste your ~/.grok/auth.json (or the JWT access token) from the Grok Build CLI; refresh_token is rotated automatically. | +| `kilocode` | `kc` | Kilo Code | OAuth | — | — | +| `kimi-coding` | `kmc` | Kimi Coding | OAuth | — | — | +| `kiro` | `kr` | Kiro AI | OAuth | — | Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use. | +| `qoder` | `if` | Qoder | OAuth | — | — | +| `qwen` | `qw` | Qwen Code | OAuth | — | ⚠️ **DEPRECATED.** Qwen OAuth free tier was discontinued on 2026-04-15. Use 'bailian-coding-plan', 'alibaba', 'alibaba-cn', or 'openrouter' provider with API key instead. | +| `trae` | `tr` | Trae | OAuth | [link](https://trae.ai) | Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT ', ~14-day lifetime) as the access token; web_id/biz_user_id/user_unique_id/scope/tenant/region propagate via providerSpecificData. No headless refresh for pasted tokens — re-paste on expiry. | +| `windsurf` | `ws` | Windsurf (Devin CLI) | OAuth | [link](https://windsurf.com) | In the Windsurf / VS Code IDE, open the command palette and run `Windsurf: Provide Auth Token` (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it here. Note: opening windsurf.com/show-auth-token directly only renders a "Redirecting" page — the IDE must initiate the flow (it adds a `?state=...` param) for the token to appear. | +| `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 (24) -| ID | Alias | Name | Tags | Website | Notes | -| ------------------ | ------------- | ------------------------------- | ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `adapta-web` | `adp-web` | Adapta.org (Adapta One Web) | Web cookie | [link](https://agent.adapta.one) | Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies) | -| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai | -| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your __Secure-next-auth.session-token cookie value from chatgpt.com | -| `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `adapta-web` | `adp-web` | Adapta.org (Adapta One Web) | Web cookie | [link](https://agent.adapta.one) | Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies) | +| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai | +| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your __Secure-next-auth.session-token cookie value from chatgpt.com | +| `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | | `copilot-m365-web` | `m365copilot` | Microsoft 365 Copilot (BizChat) | Web cookie | [link](https://m365.cloud.microsoft/chat) | Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. | -| `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste your access_token from copilot.microsoft.com (or export a .har file from DevTools while logged in) | -| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | -| `doubao-web` | `db` | Dola Web (ByteDance) | Web cookie | [link](https://www.dola.com) | Paste the full Cookie header from www.dola.com. It should include sessionid, ttwid, and s_v_web_id. If s_v_web_id is unavailable, fp=verify_... from a chat/completion request URL can be used as a fallback. | -| `gemini-business` | `gembiz` | Gemini Business (Enterprise) | Web cookie | [link](https://business.gemini.google) | From your enterprise account: open business.gemini.google/home/cid/{your-cid}, then copy __Secure-1PSID and __Secure-1PSIDTS cookies from DevTools → Application → Cookies. Paste as a cookie header below. | -| `gemini-web` | `gweb` | Gemini Web (Free) | Web cookie | [link](https://gemini.google.com) | Paste your __Secure-1PSID cookie value from gemini.google.com. Optionally add __Secure-1PSIDTS separated by semicolon. | -| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste the full grok.com cookie line from DevTools → Application → Cookies. Include both `sso` and `sso-rw` (e.g. `sso=...; sso-rw=...`) — Grok's anti-bot rejects `sso` on its own. | -| `huggingchat` | `huggingchat` | HuggingChat (Free) | Web cookie | [link](https://huggingface.co/chat) | Paste the full Cookie header from huggingface.co/chat (DevTools → Network → /chat/conversation → Request Headers → Cookie). It should include hf-chat and may also include token / aws-waf-token. | -| `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 | -| `kimi-web` | `kimi-web` | Kimi Web (Moonshot AI) | Web cookie | [link](https://www.kimi.com) | Paste your Cookie header from www.kimi.com (must contain kimi-auth=...). Find it via DevTools → Network → request → Cookie. | -| `lmarena` | `lma` | LMArena (Free) | Web cookie | [link](https://lmarena.ai) | Paste the full Cookie header from lmarena.ai (DevTools → Network → request → Cookie). The session is now split across arena-auth-prod-v1.0, .1, … — copy the whole header. Optional — works with free tier for basic comparisons. | -| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess value or full cookie header from meta.ai | -| `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 | -| `poe-web` | `poe` | Poe Web (Subscription) | Web cookie | [link](https://poe.com) | Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b) | -| `qwen-web` | `qwen-web` | Qwen Web (Free) | Web cookie | [link](https://chat.qwen.ai) | Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token). | -| `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. | -| `v0-vercel-web` | `v0` | 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. | -| `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. | +| `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste your access_token from copilot.microsoft.com (or export a .har file from DevTools while logged in) | +| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | +| `doubao-web` | `db` | Dola Web (ByteDance) | Web cookie | [link](https://www.dola.com) | Paste the full Cookie header from www.dola.com. It should include sessionid, ttwid, and s_v_web_id. If s_v_web_id is unavailable, fp=verify_... from a chat/completion request URL can be used as a fallback. | +| `gemini-business` | `gembiz` | Gemini Business (Enterprise) | Web cookie | [link](https://business.gemini.google) | From your enterprise account: open business.gemini.google/home/cid/{your-cid}, then copy __Secure-1PSID and __Secure-1PSIDTS cookies from DevTools → Application → Cookies. Paste as a cookie header below. | +| `gemini-web` | `gweb` | Gemini Web (Free) | Web cookie | [link](https://gemini.google.com) | Paste your __Secure-1PSID cookie value from gemini.google.com. Optionally add __Secure-1PSIDTS separated by semicolon. | +| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste the full grok.com cookie line from DevTools → Application → Cookies. Include both `sso` and `sso-rw` (e.g. `sso=...; sso-rw=...`) — Grok's anti-bot rejects `sso` on its own. | +| `huggingchat` | `huggingchat` | HuggingChat (Free) | Web cookie | [link](https://huggingface.co/chat) | Paste the full Cookie header from huggingface.co/chat (DevTools → Network → /chat/conversation → Request Headers → Cookie). It should include hf-chat and may also include token / aws-waf-token. | +| `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 | +| `kimi-web` | `kimi-web` | Kimi Web (Moonshot AI) | Web cookie | [link](https://www.kimi.com) | Paste your Cookie header from www.kimi.com (must contain kimi-auth=...). Find it via DevTools → Network → request → Cookie. | +| `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. | +| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess value or full cookie header from meta.ai | +| `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 | +| `poe-web` | `poe` | Poe Web (Subscription) | Web cookie | [link](https://poe.com) | Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b) | +| `qwen-web` | `qwen-web` | Qwen Web (Free) | Web cookie | [link](https://chat.qwen.ai) | Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token). | +| `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. | +| `v0-vercel-web` | `v0` | 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. | +| `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) (167) +## API Key Providers (paid / paid-with-free-credits) (166) -| ID | Alias | Name | Tags | Website | Notes | -| --------------------- | -------------- | ------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `360ai` | `360ai` | 360 AI | API key | [link](https://ai.360.cn) | Get API key at ai.360.cn | -| `agentrouter` | `agentrouter` | AgentRouter | API key, aggregator | [link](https://agentrouter.org) | $200 free credits on signup - multi-model routing gateway | -| `ai21` | `ai21` | AI21 Labs | API key | [link](https://www.ai21.com) | $10 trial credits on signup (valid 3 months), no credit card required | -| `aimlapi` | `aiml` | AI/ML API | API key, aggregator | [link](https://aimlapi.com) | Free tier paused (2026) — AI/ML API is now pay-as-you-go only (min $20 top-up); no recurring free credits. | -| `alibaba` | `ali` | Alibaba | API key | [link](https://bailian.console.alibabacloud.com/) | — | -| `alibaba-cn` | `ali-cn` | Alibaba (China) | API key | [link](https://dashscope.console.aliyun.com/) | — | -| `anthropic` | `anthropic` | Anthropic | API key | [link](https://platform.claude.com) | — | -| `api-airforce` | `af` | Api.airforce | API key | [link](https://api.airforce) | 55 free tier models including Grok-3, Claude 3.7, Qwen3, Kimi-K2, Gemini 2.5 Flash, DeepSeek-V3 | -| `arcee-ai` | `arcee` | Arcee AI | API key | [link](https://arcee.ai) | Get API key at arcee.ai | -| `azure-ai` | `azure-ai` | Azure AI Foundry | API key, enterprise | [link](https://learn.microsoft.com/azure/ai-foundry) | Use your Azure AI Foundry key. Base URL can be https://.services.ai.azure.com/openai/v1/ or https://.openai.azure.com/openai/v1/. | -| `azure-openai` | `azure` | Azure OpenAI | API key, enterprise | [link](https://azure.microsoft.com/products/ai-services/openai-service) | Use your Azure OpenAI API key. Base URL should be your resource endpoint, for example https://my-resource.openai.azure.com. | -| `bai` | `bai` | b.ai | API key | [link](https://b.ai) | Bearer API key for the b.ai OpenAI-compatible LLM gateway (distinct from TheB.AI). Create a key at https://docs.b.ai, then use https://api.b.ai/v1 as the OpenAI-compatible base URL. | -| `baichuan` | `baichuan` | Baichuan | API key | [link](https://baichuan.com) | Get API key at platform.baichuan-ai.com | -| `baidu` | `baidu` | Baidu (ERNIE) | API key | [link](https://yiyan.baidu.com) | Get API key at console.bce.baidu.com | -| `bailian-coding-plan` | `bcp` | Alibaba Coding Plan | API key | [link](https://www.alibabacloud.com/help/en/model-studio/coding-plan) | — | -| `baseten` | `baseten` | Baseten | API key | [link](https://baseten.co) | $30 free trial credits for GPU inference | -| `bazaarlink` | `bzl` | BazaarLink | API key | [link](https://bazaarlink.ai) | Use your BazaarLink API key (starts with sk-bl-) in Authorization: Bearer . OpenAI SDK works with base URL https://bazaarlink.ai/api/v1. Models use provider/model-name format. | -| `bedrock` | `bedrock` | Amazon Bedrock | API key, enterprise | [link](https://aws.amazon.com/bedrock) | Use your Amazon Bedrock API key and configure the AWS region where your models are enabled (for example eu-west-2). OmniRoute calls Bedrock's native Converse API directly. | -| `black-forest-labs` | `bfl` | Black Forest Labs | API key, image | [link](https://blackforestlabs.ai) | — | -| `blackbox` | `bb` | Blackbox AI | API key | [link](https://blackbox.ai) | Free tier: unlimited basic chat plus Minimax-M2.5, no credit card required | -| `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 | -| `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — | -| `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks | -| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. | -| `charm-hyper` | `charm-hyper` | Charm Hyper | API key | [link](https://hyper.charm.land) | 100 free monthly Hypercredits on signup | -| `chutes` | `chutes` | Chutes.ai | API key, aggregator | [link](https://chutes.ai) | Bearer API key for the Chutes OpenAI-compatible gateway. | -| `clarifai` | `clarifai` | Clarifai | API key, enterprise | [link](https://docs.clarifai.com) | Use your Clarifai PAT or app-specific API key. OmniRoute targets the OpenAI-compatible endpoint at https://api.clarifai.com/v2/ext/openai/v1 and authenticates with Authorization: Key . | -| `clinepass` | `clinepass` | ClinePass | API key | [link](https://cline.bot) | — | -| `cloudflare-ai` | `cf` | Cloudflare Workers AI | API key | [link](https://developers.cloudflare.com/workers-ai) | Requires API Token AND Account ID (found at dash.cloudflare.com) | -| `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — | -| `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required | -| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. | -| `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api | -| `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — | -| `databricks` | `databricks` | Databricks | API key, enterprise | [link](https://www.databricks.com) | — | -| `datarobot` | `datarobot` | DataRobot | API key, enterprise | [link](https://docs.datarobot.com) | Use your DataRobot API token. Optional Base URL can be the account root (for LLM Gateway) or a deployment URL under /api/v2/deployments/. | -| `deepinfra` | `deepinfra` | DeepInfra | API key | [link](https://deepinfra.com) | Free signup credits for API testing and model exploration | -| `deepseek` | `ds` | DeepSeek | API key | [link](https://platform.deepseek.com) | 5M free tokens on signup - no credit card required | -| `dgrid` | `dgrid` | DGrid | API key | [link](https://dgrid.ai) | DGrid Free Models Router: 10 requests/minute and 100 requests/day. A $5 lifetime top-up unlocks up to 20 requests/minute and 1,000 requests/day. | -| `dify` | `dify` | Dify | API key | [link](https://dify.ai) | Get API key from your Dify instance. | -| `digitalocean` | `digitalocean` | DigitalOcean | API key | [link](https://docs.digitalocean.com/products/ai-platform/) | — | -| `dit` | `dai` | DIT.ai | API key | [link](https://dit.ai) | Use your dit.ai API key in Authorization: Bearer . Fully OpenAI-compatible — a drop-in replacement, just change the base URL to https://api.dit.ai/v1. | -| `doubao` | `doubao` | Doubao | API key | [link](https://doubao.com) | Get API key at console.volcengine.com | -| `empower` | `empower` | Empower | API key, aggregator | [link](https://docs.empower.dev) | Bearer API key for the Empower OpenAI-compatible endpoint. | -| `factory` | `factory` | Factory | API key | [link](https://factory.ai) | Bearer API key for the Factory OpenAI-compatible gateway. | -| `fal-ai` | `fal` | Fal.ai | API key, image | [link](https://fal.ai) | — | -| `featherless-ai` | `featherless` | Featherless AI | API key | [link](https://featherless.ai) | Free tier available — no credit card required | -| `fenayai` | `fenayai` | FenayAI | API key, aggregator | [link](https://fenayai.com) | Bearer API key for the FenayAI OpenAI-compatible gateway. | -| `firecrawl` | `fc` | Firecrawl | API key | [link](https://firecrawl.dev) | — | -| `fireworks` | `fireworks` | Fireworks AI | API key | [link](https://fireworks.ai) | $1 free starter credits on signup for API testing | -| `freeaiapikey` | `faik` | FreeAIAPIKey | API key | [link](https://freeaiapikey.com) | — | -| `freemodel-dev` | `fmd` | FreeModel.dev | API key | [link](https://freemodel.dev) | $300 free credits on signup — no credit card required. Access GPT-5.4 and GPT-5.5 (OpenAI's latest flagship models) through an OpenAI-compatible API. | -| `friendliai` | `friendli` | FriendliAI | API key | [link](https://friendli.ai) | Free tier for serverless inference — no credit card required | -| `galadriel` | `galadriel` | Galadriel | API key | [link](https://galadriel.com) | ⚠️ **DEPRECATED.** api.galadriel.ai no longer resolves (sweep 2026-06-19); the inference API appears discontinued. | -| `gemini` | `gemini` | Gemini (Google AI Studio) | API key | [link](https://aistudio.google.com) | Free forever: 1,500 req/day for Gemini 2.5 Flash — no credit card, get key at aistudio.google.com | -| `getgoapi` | `ggo` | GoAPI | API key, aggregator | [link](https://api.getgoapi.com) | — | -| `gigachat` | `gigachat` | GigaChat (Sber) | API key | [link](https://developers.sber.ru) | — | -| `github-models` | `ghm` | GitHub Models | API key | [link](https://github.com/marketplace/models) | Create a GitHub PAT with 'models: read' scope at github.com/settings/tokens | -| `gitlab` | `gitlab` | GitLab Duo PAT | API key | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | GitLab personal access token for the public Code Suggestions API. Configure a self-hosted base URL when not using gitlab.com. | -| `gitlawb` | `glb` | Gitlawb Opengateway (MiMo) | API key | [link](https://opengateway.gitlawb.com) | Free MiMo (xiaomi/mimo-v2.5) revoked 2026-05 — Opengateway is now a pay-as-you-go credit gateway; no recurring free model. | -| `gitlawb-gmi` | `glb-gmi` | Gitlawb Opengateway (GMI Cloud) | API key | [link](https://opengateway.gitlawb.com) | Free Nemotron promo ended 2026-06 — the GMI Cloud route is now pay-as-you-go credit only. | -| `glm` | `glm` | GLM Coding | API key | [link](https://z.ai/subscribe) | — | -| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — | -| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — | -| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card | -| `hackclub` | `hc` | Hackclub AI | API key, aggregator | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. | -| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api | -| `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn | -| `heroku` | `heroku` | Heroku AI | API key, enterprise | [link](https://www.heroku.com) | — | -| `huggingface` | `hf` | HuggingFace | API key | [link](https://huggingface.co) | Free Inference API for thousands of models (Whisper, VITS, SDXL…) | -| `hyperbolic` | `hyp` | Hyperbolic | API key | [link](https://hyperbolic.xyz) | $1-5 trial credits on signup for serverless inference | -| `ideogram` | `ideo` | Ideogram | API key | [link](https://ideogram.ai) | Get API key at ideogram.ai/docs/api | -| `iflytek` | `iflytek` | iFlytek Spark | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | -| `inference-net` | `inet` | Inference.net | API key | [link](https://inference.net) | $25 free credits on signup plus research grants available | -| `jina-ai` | `jina` | Jina AI | API key, embed/rerank | [link](https://jina.ai) | Bearer API key for the Jina AI rerank API. | -| `jina-reader` | `jr` | Jina Reader | API key | [link](https://jina.ai/reader) | — | -| `kenari` | `kenari` | Kenari | API key | [link](https://kenari.id) | Use your Kenari API key (kn-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://kenari.id/v1. | -| `kie` | `kie` | KIE.AI | API key | [link](https://kie.ai) | — | -| `kilo-gateway` | `kg` | Kilo Gateway | API key, aggregator | [link](https://kilo.ai) | — | -| `kimi` | `kimi` | Kimi | API key | [link](https://platform.moonshot.ai) | — | -| `kimi-coding-apikey` | `kmca` | Kimi Coding (API Key) | API key | [link](https://www.kimi.com/code) | — | -| `lambda-ai` | `lambda` | Lambda AI | API key | [link](https://lambda.ai) | — | -| `laozhang` | `lz` | LaoZhang AI | API key, aggregator | [link](https://api.laozhang.ai) | — | -| `leonardo` | `leo` | Leonardo AI | API key, video | [link](https://leonardo.ai) | Get API key at leonardo.ai/developer | -| `liquid` | `liquid` | Liquid AI | API key | [link](https://liquid.ai) | Get API key at liquid.ai | -| `llamagate` | `llamagate` | LlamaGate | API key | [link](https://llamagate.ai) | — | -| `llm7` | `llm7` | LLM7.io | API key | [link](https://llm7.io) | No signup required - 2 req/s, 20 RPM, 100 req/hr free tier | -| `longcat` | `lc` | LongCat AI | API key | [link](https://longcat.chat/platform/docs) | Free: one-time 10M-token grant after account signup + KYC verification (LongCat-2.0). One-time only — not a recurring daily/monthly allowance. | -| `maritalk` | `maritalk` | Maritalk | API key | [link](https://www.maritaca.ai) | — | -| `meta-llama` | `meta` | Meta Llama API | API key | [link](https://llama.developer.meta.com) | — | -| `minimax` | `minimax` | Minimax Coding | API key, video | [link](https://www.minimax.io) | — | -| `minimax-cn` | `minimax-cn` | Minimax (China) | API key | [link](https://www.minimaxi.com) | — | -| `mistral` | `mistral` | Mistral | API key | [link](https://mistral.ai) | Free Experiment tier: rate-limited access to all models, no credit card required | -| `modal` | `mdl` | Modal | API key, enterprise | [link](https://modal.com/docs) | Use the bearer token that protects your Modal deployment, if enabled. Base URL should point to your OpenAI-compatible Modal app, for example https://--.modal.run/v1. | -| `modelscope` | `ms` | ModelScope | API key | [link](https://modelscope.cn) | Free tier via ModelScope API-Inference — Alibaba account required. | -| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | Get API key at monsterapi.ai | -| `moonshot` | `moonshot` | Moonshot AI | API key | [link](https://platform.moonshot.ai) | — | -| `morph` | `morph` | Morph | API key | [link](https://morphllm.com) | Free tier: 250K credits/month, $0 | -| `nanogpt` | `nanogpt` | NanoGPT | API key | [link](https://nano-gpt.com) | — | -| `nebius` | `nebius` | Nebius AI | API key | [link](https://nebius.com) | ~$1 trial credits on signup for API testing | -| `nlpcloud` | `nlpc` | NLP Cloud | API key | [link](https://docs.nlpcloud.com) | Use your NLP Cloud API key in Authorization: Token . OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu//chatbot by default. | -| `nomic` | `nomic` | Nomic | API key | [link](https://nomic.ai) | Get API key at atlas.nomic.ai | -| `nous-research` | `nous` | Nous Research | API key | [link](https://portal.nousresearch.com/help) | Use your Nous Portal API key. OmniRoute targets the official OpenAI-compatible inference endpoint at https://inference-api.nousresearch.com/v1. | -| `novita` | `novita` | Novita AI | API key, aggregator | [link](https://novita.ai) | $0.50 trial credits on signup (valid about 1 year) | -| `nscale` | `nscale` | nScale | API key | [link](https://nscale.com) | $5 free credits on signup for inference testing | -| `nube` | `nube` | Nube.sh | API key | [link](https://nube.sh) | — | -| `nvidia` | `nvidia` | NVIDIA NIM | API key | [link](https://build.nvidia.com) | Free dev access: ~40 RPM, 70+ models (Kimi K2.5, GLM 4.7, DeepSeek V3.2...) | -| `oci` | `oci` | OCI Generative AI | API key, enterprise | [link](https://www.oracle.com/artificial-intelligence/generative-ai) | Use your OCI Generative AI API key or IAM bearer token. Base URL can be https://inference.generativeai..oci.oraclecloud.com/openai/v1/. | -| `ollama-cloud` | `ollamacloud` | Ollama Cloud | API key | [link](https://ollama.com/settings/keys) | — | -| `openadapter` | `oad` | OpenAdapter | API key | [link](https://openadapter.dev) | Use your OpenAdapter API key in Authorization: Bearer sk-cv-. Fully OpenAI-compatible. API base URL: https://api.openadapter.in/v1. | -| `openai` | `openai` | OpenAI | API key | [link](https://platform.openai.com) | — | -| `opencode-go` | `opencode-go` | OpenCode Go | API key | [link](https://opencode.ai/go) | — | -| `opencode-zen` | `opencode-zen` | OpenCode Zen | API key | [link](https://opencode.ai/zen) | — | -| `openrouter` | `openrouter` | OpenRouter | API key, aggregator | [link](https://openrouter.ai) | Free models at $0/token with :free suffix - 20 RPM / 200 RPD | -| `orcarouter` | `orcarouter` | OrcaRouter | API key | [link](https://www.orcarouter.ai) | — | -| `ovhcloud` | `ovh` | OVHcloud AI | API key | [link](https://www.ovhcloud.com) | — | -| `perplexity` | `pplx` | Perplexity | API key | [link](https://www.perplexity.ai) | — | -| `piapi` | `pi` | PiAPI | API key, aggregator | [link](https://piapi.ai) | — | -| `pioneer` | `pn` | Pioneer AI | API key | [link](https://pioneer.ai) | $75 free usage credits — no credit card required | -| `poe` | `poe` | Poe | API key, aggregator | [link](https://creator.poe.com/api-reference) | Bearer API key for the Poe OpenAI-compatible API. | -| `pollinations` | `pol` | Pollinations AI | API key, video | [link](https://pollinations.ai) | Free keyless tier: openai, openai-fast, openai-large, qwen-coder, mistral, deepseek, grok, gemini-flash-lite-3.1, perplexity-fast, perplexity-reasoning. Premium models (claude, gemini, midijourney) require a Pollinations API key from enter.pollinations.ai. | -| `predibase` | `predibase` | Predibase | API key | [link](https://predibase.com) | ⚠️ **DEPRECATED.** serving.app.predibase.com no longer resolves (sweep 2026-06-19); the managed serving API appears discontinued. | -| `publicai` | `publicai` | PublicAI | API key | [link](https://publicai.co) | Requires an API key — one-time signup credit, then paid | -| `puter` | `pu` | Puter AI | API key | [link](https://puter.com) | Get token at puter.com/dashboard → Copy Auth Token | -| `qianfan` | `qianfan` | Baidu Qianfan | API key | [link](https://cloud.baidu.com/product/wenxinworkshop) | — | -| `qiniu` | `qiniu` | Qiniu | API key | [link](https://www.qiniu.com) | — | -| `recraft` | `recraft` | Recraft | API key, image | [link](https://recraft.ai) | — | -| `reka` | `reka` | Reka | API key | [link](https://docs.reka.ai/chat/overview) | Use your Reka API key. OmniRoute supports the OpenAI-compatible base URL https://api.reka.ai/v1 and sends both Authorization and X-Api-Key headers for compatibility. | -| `requesty` | `requesty` | Requesty | API key | [link](https://requesty.ai) | Free tier ~200 requests/day - multi-model routing gateway (300+ models) | -| `runwayml` | `runway` | Runway | API key, video | [link](https://docs.dev.runwayml.com) | Use your Runway API key in Authorization: Bearer . OmniRoute targets the current Runway API at https://api.dev.runwayml.com/v1 and sends the required X-Runway-Version header automatically. | -| `sambanova` | `samba` | SambaNova | API key | [link](https://sambanova.ai) | $5 free credits on signup (30-day validity), no credit card required | -| `sap` | `sap` | SAP Generative AI Hub | API key, enterprise | [link](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/generative-ai-hub-in-sap-ai-core) | Use your SAP AI Core bearer token. Base URL can be your AI_API_URL root or a deploymentUrl from Generative AI Hub. | -| `scaleway` | `scw` | Scaleway AI | API key | [link](https://www.scaleway.com/en/docs/ai-data/generative-apis/) | 1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B | -| `sensenova` | `sensenova` | SenseNova | API key | [link](https://platform.sensenova.cn) | Get API key at platform.sensenova.cn | -| `siliconflow` | `siliconflow` | SiliconFlow | API key | [link](https://cloud.siliconflow.com) | $1 free credits plus permanently free models after identity verification | -| `snowflake` | `snowflake` | Snowflake Cortex | API key, enterprise | [link](https://www.snowflake.com) | — | -| `sparkdesk` | `sparkdesk` | SparkDesk | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | -| `stability-ai` | `stability` | Stability AI | API key, image | [link](https://stability.ai) | — | -| `stepfun` | `stepfun` | StepFun | API key | [link](https://stepfun.com) | Get API key at platform.stepfun.com | -| `sumopod` | `sumopod` | SumoPod | API key | [link](https://ai.sumopod.com) | Use your SumoPod API key (sk-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://ai.sumopod.com/v1. | -| `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) | -| `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — | -| `tencent` | `tencent` | Tencent Hunyuan | API key | [link](https://hunyuan.tencent.com) | Get API key at console.cloud.tencent.com | -| `thebai` | `thebai` | TheB.AI | API key, aggregator | [link](https://theb.ai) | Bearer API key for the TheB.AI OpenAI-compatible gateway. | -| `tinyfish` | `tf` | TinyFish Fetch | API key | [link](https://docs.tinyfish.ai/fetch-api) | X-API-Key from agent.tinyfish.ai/api-keys | -| `together` | `together` | Together AI | API key, video | [link](https://www.together.ai) | — | -| `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) | — | -| `udio` | `udio` | Udio | API key | [link](https://udio.com) | Paste session cookie from udio.com (Supabase auth) | -| `uncloseai` | `unc` | UncloseAI | API key | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. | -| `upstage` | `upstage` | Upstage | API key | [link](https://www.upstage.ai) | — | -| `v0-vercel` | `v0` | v0 (Vercel) | API key | [link](https://v0.dev) | — | -| `venice` | `venice` | Venice.ai | API key | [link](https://venice.ai) | — | -| `vercel-ai-gateway` | `vag` | Vercel AI Gateway | API key, aggregator | [link](https://vercel.com/docs/ai-gateway) | — | -| `vertex` | `vertex` | Vertex AI | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide Service Account JSON or OAuth access_token | -| `vertex-partner` | `vp` | Vertex AI Partners | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide the same Service Account JSON used for Vertex AI partner models. | -| `volcengine` | `volcengine` | Volcengine | API key | [link](https://www.volcengine.com) | — | -| `voyage-ai` | `voyage` | Voyage AI | API key, embed/rerank | [link](https://www.voyageai.com) | Bearer API key for Voyage AI embeddings and rerank APIs. | -| `wafer` | `wafer` | Wafer AI | API key | [link](https://wafer.ai) | — | -| `wandb` | `wandb` | Weights & Biases Inference | API key | [link](https://wandb.ai) | — | -| `watsonx` | `watsonx` | IBM watsonx.ai Gateway | API key, enterprise | [link](https://www.ibm.com/products/watsonx-ai) | Use your watsonx bearer token. Base URL can be https://.ml.cloud.ibm.com/ml/gateway/v1/ or a self-managed /ml/gateway/v1 endpoint. | -| `x5lab` | `x5lab` | X5Lab | API key | [link](https://x5lab.dev) | Use your X5Lab API key (x5-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.x5lab.dev/v1. | -| `xai` | `xai` | xAI (Grok) | API key | [link](https://x.ai) | — | -| `xiaomi-mimo` | `mimo` | Xiaomi MiMo | API key | [link](https://mimo.mi.com) | — | -| `yi` | `yi` | Yi (01.AI) | API key | [link](https://01.ai) | Get API key at platform.lingyiwanwu.com | -| `zai` | `zai` | Z.AI | API key | [link](https://open.bigmodel.cn) | — | -| `zenmux` | `zm` | ZenMux | API key | [link](https://zenmux.ai) | Use your ZenMux API key in Authorization: Bearer . ZenMux is fully OpenAI-compatible. Base URL: https://zenmux.ai/api/v1. | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `360ai` | `360ai` | 360 AI | API key | [link](https://ai.360.cn) | Get API key at ai.360.cn | +| `agentrouter` | `agentrouter` | AgentRouter | API key, aggregator | [link](https://agentrouter.org) | $200 free credits on signup - multi-model routing gateway | +| `ai21` | `ai21` | AI21 Labs | API key | [link](https://www.ai21.com) | $10 trial credits on signup (valid 3 months), no credit card required | +| `aimlapi` | `aiml` | AI/ML API | API key, aggregator | [link](https://aimlapi.com) | Free tier paused (2026) — AI/ML API is now pay-as-you-go only (min $20 top-up); no recurring free credits. | +| `alibaba` | `ali` | Alibaba | API key | [link](https://bailian.console.alibabacloud.com/) | — | +| `alibaba-cn` | `ali-cn` | Alibaba (China) | API key | [link](https://dashscope.console.aliyun.com/) | — | +| `anthropic` | `anthropic` | Anthropic | API key | [link](https://platform.claude.com) | — | +| `api-airforce` | `af` | Api.airforce | API key | [link](https://api.airforce) | 55 free tier models including Grok-3, Claude 3.7, Qwen3, Kimi-K2, Gemini 2.5 Flash, DeepSeek-V3 | +| `arcee-ai` | `arcee` | Arcee AI | API key | [link](https://arcee.ai) | Get API key at arcee.ai | +| `azure-ai` | `azure-ai` | Azure AI Foundry | API key, enterprise | [link](https://learn.microsoft.com/azure/ai-foundry) | Use your Azure AI Foundry key. Base URL can be https://.services.ai.azure.com/openai/v1/ or https://.openai.azure.com/openai/v1/. | +| `azure-openai` | `azure` | Azure OpenAI | API key, enterprise | [link](https://azure.microsoft.com/products/ai-services/openai-service) | Use your Azure OpenAI API key. Base URL should be your resource endpoint, for example https://my-resource.openai.azure.com. | +| `bai` | `bai` | b.ai | API key | [link](https://b.ai) | Bearer API key for the b.ai OpenAI-compatible LLM gateway (distinct from TheB.AI). Create a key at https://docs.b.ai, then use https://api.b.ai/v1 as the OpenAI-compatible base URL. | +| `baichuan` | `baichuan` | Baichuan | API key | [link](https://baichuan.com) | Get API key at platform.baichuan-ai.com | +| `baidu` | `baidu` | Baidu (ERNIE) | API key | [link](https://yiyan.baidu.com) | Get API key at console.bce.baidu.com | +| `bailian-coding-plan` | `bcp` | Alibaba Coding Plan | API key | [link](https://www.alibabacloud.com/help/en/model-studio/coding-plan) | — | +| `baseten` | `baseten` | Baseten | API key | [link](https://baseten.co) | $30 free trial credits for GPU inference | +| `bazaarlink` | `bzl` | BazaarLink | API key | [link](https://bazaarlink.ai) | Use your BazaarLink API key (starts with sk-bl-) in Authorization: Bearer . OpenAI SDK works with base URL https://bazaarlink.ai/api/v1. Models use provider/model-name format. | +| `bedrock` | `bedrock` | Amazon Bedrock | API key, enterprise | [link](https://aws.amazon.com/bedrock) | Use your Amazon Bedrock API key and configure the AWS region where your models are enabled (for example eu-west-2). OmniRoute calls Bedrock's native Converse API directly. | +| `black-forest-labs` | `bfl` | Black Forest Labs | API key, image | [link](https://blackforestlabs.ai) | — | +| `blackbox` | `bb` | Blackbox AI | API key | [link](https://blackbox.ai) | Free tier: unlimited basic chat plus Minimax-M2.5, no credit card required | +| `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 | +| `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — | +| `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks | +| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. | +| `charm-hyper` | `charm-hyper` | Charm Hyper | API key | [link](https://hyper.charm.land) | 100 free monthly Hypercredits on signup | +| `chutes` | `chutes` | Chutes.ai | API key, aggregator | [link](https://chutes.ai) | Bearer API key for the Chutes OpenAI-compatible gateway. | +| `clarifai` | `clarifai` | Clarifai | API key, enterprise | [link](https://docs.clarifai.com) | Use your Clarifai PAT or app-specific API key. OmniRoute targets the OpenAI-compatible endpoint at https://api.clarifai.com/v2/ext/openai/v1 and authenticates with Authorization: Key . | +| `cloudflare-ai` | `cf` | Cloudflare Workers AI | API key | [link](https://developers.cloudflare.com/workers-ai) | Requires API Token AND Account ID (found at dash.cloudflare.com) | +| `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — | +| `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required | +| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. | +| `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api | +| `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — | +| `databricks` | `databricks` | Databricks | API key, enterprise | [link](https://www.databricks.com) | — | +| `datarobot` | `datarobot` | DataRobot | API key, enterprise | [link](https://docs.datarobot.com) | Use your DataRobot API token. Optional Base URL can be the account root (for LLM Gateway) or a deployment URL under /api/v2/deployments/. | +| `deepinfra` | `deepinfra` | DeepInfra | API key | [link](https://deepinfra.com) | Free signup credits for API testing and model exploration | +| `deepseek` | `ds` | DeepSeek | API key | [link](https://platform.deepseek.com) | 5M free tokens on signup - no credit card required | +| `dgrid` | `dgrid` | DGrid | API key | [link](https://dgrid.ai) | DGrid Free Models Router: 10 requests/minute and 100 requests/day. A $5 lifetime top-up unlocks up to 20 requests/minute and 1,000 requests/day. | +| `dify` | `dify` | Dify | API key | [link](https://dify.ai) | Get API key from your Dify instance. | +| `digitalocean` | `digitalocean` | DigitalOcean | API key | [link](https://docs.digitalocean.com/products/ai-platform/) | — | +| `dit` | `dai` | DIT.ai | API key | [link](https://dit.ai) | Use your dit.ai API key in Authorization: Bearer . Fully OpenAI-compatible — a drop-in replacement, just change the base URL to https://api.dit.ai/v1. | +| `doubao` | `doubao` | Doubao | API key | [link](https://doubao.com) | Get API key at console.volcengine.com | +| `empower` | `empower` | Empower | API key, aggregator | [link](https://docs.empower.dev) | Bearer API key for the Empower OpenAI-compatible endpoint. | +| `factory` | `factory` | Factory | API key | [link](https://factory.ai) | Bearer API key for the Factory OpenAI-compatible gateway. | +| `fal-ai` | `fal` | Fal.ai | API key, image | [link](https://fal.ai) | — | +| `featherless-ai` | `featherless` | Featherless AI | API key | [link](https://featherless.ai) | Free tier available — no credit card required | +| `fenayai` | `fenayai` | FenayAI | API key, aggregator | [link](https://fenayai.com) | Bearer API key for the FenayAI OpenAI-compatible gateway. | +| `firecrawl` | `fc` | Firecrawl | API key | [link](https://firecrawl.dev) | — | +| `fireworks` | `fireworks` | Fireworks AI | API key | [link](https://fireworks.ai) | $1 free starter credits on signup for API testing | +| `freeaiapikey` | `faik` | FreeAIAPIKey | API key | [link](https://freeaiapikey.com) | — | +| `freemodel-dev` | `fmd` | FreeModel.dev | API key | [link](https://freemodel.dev) | $300 free credits on signup — no credit card required. Access GPT-5.4 and GPT-5.5 (OpenAI's latest flagship models) through an OpenAI-compatible API. | +| `friendliai` | `friendli` | FriendliAI | API key | [link](https://friendli.ai) | Free tier for serverless inference — no credit card required | +| `galadriel` | `galadriel` | Galadriel | API key | [link](https://galadriel.com) | ⚠️ **DEPRECATED.** api.galadriel.ai no longer resolves (sweep 2026-06-19); the inference API appears discontinued. | +| `gemini` | `gemini` | Gemini (Google AI Studio) | API key | [link](https://aistudio.google.com) | Free forever: 1,500 req/day for Gemini 2.5 Flash — no credit card, get key at aistudio.google.com | +| `getgoapi` | `ggo` | GoAPI | API key, aggregator | [link](https://api.getgoapi.com) | — | +| `gigachat` | `gigachat` | GigaChat (Sber) | API key | [link](https://developers.sber.ru) | — | +| `github-models` | `ghm` | GitHub Models | API key | [link](https://github.com/marketplace/models) | Create a GitHub PAT with 'models: read' scope at github.com/settings/tokens | +| `gitlab` | `gitlab` | GitLab Duo PAT | API key | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | GitLab personal access token for the public Code Suggestions API. Configure a self-hosted base URL when not using gitlab.com. | +| `gitlawb` | `glb` | Gitlawb Opengateway (MiMo) | API key | [link](https://opengateway.gitlawb.com) | Free MiMo (xiaomi/mimo-v2.5) revoked 2026-05 — Opengateway is now a pay-as-you-go credit gateway; no recurring free model. | +| `gitlawb-gmi` | `glb-gmi` | Gitlawb Opengateway (GMI Cloud) | API key | [link](https://opengateway.gitlawb.com) | Free Nemotron promo ended 2026-06 — the GMI Cloud route is now pay-as-you-go credit only. | +| `glm` | `glm` | GLM Coding | API key | [link](https://z.ai/subscribe) | — | +| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — | +| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — | +| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card | +| `hackclub` | `hc` | Hackclub AI | API key, aggregator | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. | +| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api | +| `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn | +| `heroku` | `heroku` | Heroku AI | API key, enterprise | [link](https://www.heroku.com) | — | +| `huggingface` | `hf` | HuggingFace | API key | [link](https://huggingface.co) | Free Inference API for thousands of models (Whisper, VITS, SDXL…) | +| `hyperbolic` | `hyp` | Hyperbolic | API key | [link](https://hyperbolic.xyz) | $1-5 trial credits on signup for serverless inference | +| `ideogram` | `ideo` | Ideogram | API key | [link](https://ideogram.ai) | Get API key at ideogram.ai/docs/api | +| `iflytek` | `iflytek` | iFlytek Spark | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | +| `inference-net` | `inet` | Inference.net | API key | [link](https://inference.net) | $25 free credits on signup plus research grants available | +| `jina-ai` | `jina` | Jina AI | API key, embed/rerank | [link](https://jina.ai) | Bearer API key for the Jina AI rerank API. | +| `jina-reader` | `jr` | Jina Reader | API key | [link](https://jina.ai/reader) | — | +| `kenari` | `kenari` | Kenari | API key | [link](https://kenari.id) | Use your Kenari API key (kn-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://kenari.id/v1. | +| `kie` | `kie` | KIE.AI | API key | [link](https://kie.ai) | — | +| `kilo-gateway` | `kg` | Kilo Gateway | API key, aggregator | [link](https://kilo.ai) | — | +| `kimi` | `kimi` | Kimi | API key | [link](https://platform.moonshot.ai) | — | +| `kimi-coding-apikey` | `kmca` | Kimi Coding (API Key) | API key | [link](https://www.kimi.com/code) | — | +| `lambda-ai` | `lambda` | Lambda AI | API key | [link](https://lambda.ai) | — | +| `laozhang` | `lz` | LaoZhang AI | API key, aggregator | [link](https://api.laozhang.ai) | — | +| `leonardo` | `leo` | Leonardo AI | API key, video | [link](https://leonardo.ai) | Get API key at leonardo.ai/developer | +| `liquid` | `liquid` | Liquid AI | API key | [link](https://liquid.ai) | Get API key at liquid.ai | +| `llamagate` | `llamagate` | LlamaGate | API key | [link](https://llamagate.ai) | — | +| `llm7` | `llm7` | LLM7.io | API key | [link](https://llm7.io) | No signup required - 2 req/s, 20 RPM, 100 req/hr free tier | +| `longcat` | `lc` | LongCat AI | API key | [link](https://longcat.chat/platform/docs) | Free: one-time 10M-token grant after account signup + KYC verification (LongCat-2.0). One-time only — not a recurring daily/monthly allowance. | +| `maritalk` | `maritalk` | Maritalk | API key | [link](https://www.maritaca.ai) | — | +| `meta-llama` | `meta` | Meta Llama API | API key | [link](https://llama.developer.meta.com) | — | +| `minimax` | `minimax` | Minimax Coding | API key, video | [link](https://www.minimax.io) | — | +| `minimax-cn` | `minimax-cn` | Minimax (China) | API key | [link](https://www.minimaxi.com) | — | +| `mistral` | `mistral` | Mistral | API key | [link](https://mistral.ai) | Free Experiment tier: rate-limited access to all models, no credit card required | +| `modal` | `mdl` | Modal | API key, enterprise | [link](https://modal.com/docs) | Use the bearer token that protects your Modal deployment, if enabled. Base URL should point to your OpenAI-compatible Modal app, for example https://--.modal.run/v1. | +| `modelscope` | `ms` | ModelScope | API key | [link](https://modelscope.cn) | Free tier via ModelScope API-Inference — Alibaba account required. | +| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | Get API key at monsterapi.ai | +| `moonshot` | `moonshot` | Moonshot AI | API key | [link](https://platform.moonshot.ai) | — | +| `morph` | `morph` | Morph | API key | [link](https://morphllm.com) | Free tier: 250K credits/month, $0 | +| `nanogpt` | `nanogpt` | NanoGPT | API key | [link](https://nano-gpt.com) | — | +| `nebius` | `nebius` | Nebius AI | API key | [link](https://nebius.com) | ~$1 trial credits on signup for API testing | +| `nlpcloud` | `nlpc` | NLP Cloud | API key | [link](https://docs.nlpcloud.com) | Use your NLP Cloud API key in Authorization: Token . OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu//chatbot by default. | +| `nomic` | `nomic` | Nomic | API key | [link](https://nomic.ai) | Get API key at atlas.nomic.ai | +| `nous-research` | `nous` | Nous Research | API key | [link](https://portal.nousresearch.com/help) | Use your Nous Portal API key. OmniRoute targets the official OpenAI-compatible inference endpoint at https://inference-api.nousresearch.com/v1. | +| `novita` | `novita` | Novita AI | API key, aggregator | [link](https://novita.ai) | $0.50 trial credits on signup (valid about 1 year) | +| `nscale` | `nscale` | nScale | API key | [link](https://nscale.com) | $5 free credits on signup for inference testing | +| `nube` | `nube` | Nube.sh | API key | [link](https://nube.sh) | — | +| `nvidia` | `nvidia` | NVIDIA NIM | API key | [link](https://build.nvidia.com) | Free dev access: ~40 RPM, 70+ models (Kimi K2.5, GLM 4.7, DeepSeek V3.2...) | +| `oci` | `oci` | OCI Generative AI | API key, enterprise | [link](https://www.oracle.com/artificial-intelligence/generative-ai) | Use your OCI Generative AI API key or IAM bearer token. Base URL can be https://inference.generativeai..oci.oraclecloud.com/openai/v1/. | +| `ollama-cloud` | `ollamacloud` | Ollama Cloud | API key | [link](https://ollama.com/settings/keys) | — | +| `openadapter` | `oad` | OpenAdapter | API key | [link](https://openadapter.dev) | Use your OpenAdapter API key in Authorization: Bearer sk-cv-. Fully OpenAI-compatible. API base URL: https://api.openadapter.in/v1. | +| `openai` | `openai` | OpenAI | API key | [link](https://platform.openai.com) | — | +| `opencode-go` | `opencode-go` | OpenCode Go | API key | [link](https://opencode.ai/go) | — | +| `opencode-zen` | `opencode-zen` | OpenCode Zen | API key | [link](https://opencode.ai/zen) | — | +| `openrouter` | `openrouter` | OpenRouter | API key, aggregator | [link](https://openrouter.ai) | Free models at $0/token with :free suffix - 20 RPM / 200 RPD | +| `orcarouter` | `orcarouter` | OrcaRouter | API key | [link](https://www.orcarouter.ai) | — | +| `ovhcloud` | `ovh` | OVHcloud AI | API key | [link](https://www.ovhcloud.com) | — | +| `perplexity` | `pplx` | Perplexity | API key | [link](https://www.perplexity.ai) | — | +| `piapi` | `pi` | PiAPI | API key, aggregator | [link](https://piapi.ai) | — | +| `pioneer` | `pn` | Pioneer AI | API key | [link](https://pioneer.ai) | $75 free usage credits — no credit card required | +| `poe` | `poe` | Poe | API key, aggregator | [link](https://creator.poe.com/api-reference) | Bearer API key for the Poe OpenAI-compatible API. | +| `pollinations` | `pol` | Pollinations AI | API key, video | [link](https://pollinations.ai) | Free keyless tier: openai, openai-fast, openai-large, qwen-coder, mistral, deepseek, grok, gemini-flash-lite-3.1, perplexity-fast, perplexity-reasoning. Premium models (claude, gemini, midijourney) require a Pollinations API key from enter.pollinations.ai. | +| `predibase` | `predibase` | Predibase | API key | [link](https://predibase.com) | ⚠️ **DEPRECATED.** serving.app.predibase.com no longer resolves (sweep 2026-06-19); the managed serving API appears discontinued. | +| `publicai` | `publicai` | PublicAI | API key | [link](https://publicai.co) | Requires an API key — one-time signup credit, then paid | +| `puter` | `pu` | Puter AI | API key | [link](https://puter.com) | Get token at puter.com/dashboard → Copy Auth Token | +| `qianfan` | `qianfan` | Baidu Qianfan | API key | [link](https://cloud.baidu.com/product/wenxinworkshop) | — | +| `qiniu` | `qiniu` | Qiniu | API key | [link](https://www.qiniu.com) | — | +| `recraft` | `recraft` | Recraft | API key, image | [link](https://recraft.ai) | — | +| `reka` | `reka` | Reka | API key | [link](https://docs.reka.ai/chat/overview) | Use your Reka API key. OmniRoute supports the OpenAI-compatible base URL https://api.reka.ai/v1 and sends both Authorization and X-Api-Key headers for compatibility. | +| `requesty` | `requesty` | Requesty | API key | [link](https://requesty.ai) | Free tier ~200 requests/day - multi-model routing gateway (300+ models) | +| `runwayml` | `runway` | Runway | API key, video | [link](https://docs.dev.runwayml.com) | Use your Runway API key in Authorization: Bearer . OmniRoute targets the current Runway API at https://api.dev.runwayml.com/v1 and sends the required X-Runway-Version header automatically. | +| `sambanova` | `samba` | SambaNova | API key | [link](https://sambanova.ai) | $5 free credits on signup (30-day validity), no credit card required | +| `sap` | `sap` | SAP Generative AI Hub | API key, enterprise | [link](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/generative-ai-hub-in-sap-ai-core) | Use your SAP AI Core bearer token. Base URL can be your AI_API_URL root or a deploymentUrl from Generative AI Hub. | +| `scaleway` | `scw` | Scaleway AI | API key | [link](https://www.scaleway.com/en/docs/ai-data/generative-apis/) | 1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B | +| `sensenova` | `sensenova` | SenseNova | API key | [link](https://platform.sensenova.cn) | Get API key at platform.sensenova.cn | +| `siliconflow` | `siliconflow` | SiliconFlow | API key | [link](https://cloud.siliconflow.com) | $1 free credits plus permanently free models after identity verification | +| `snowflake` | `snowflake` | Snowflake Cortex | API key, enterprise | [link](https://www.snowflake.com) | — | +| `sparkdesk` | `sparkdesk` | SparkDesk | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | +| `stability-ai` | `stability` | Stability AI | API key, image | [link](https://stability.ai) | — | +| `stepfun` | `stepfun` | StepFun | API key | [link](https://stepfun.com) | Get API key at platform.stepfun.com | +| `sumopod` | `sumopod` | SumoPod | API key | [link](https://ai.sumopod.com) | Use your SumoPod API key (sk-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://ai.sumopod.com/v1. | +| `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) | +| `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — | +| `tencent` | `tencent` | Tencent Hunyuan | API key | [link](https://hunyuan.tencent.com) | Get API key at console.cloud.tencent.com | +| `thebai` | `thebai` | TheB.AI | API key, aggregator | [link](https://theb.ai) | Bearer API key for the TheB.AI OpenAI-compatible gateway. | +| `tinyfish` | `tf` | TinyFish Fetch | API key | [link](https://docs.tinyfish.ai/fetch-api) | X-API-Key from agent.tinyfish.ai/api-keys | +| `together` | `together` | Together AI | API key, video | [link](https://www.together.ai) | — | +| `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) | — | +| `udio` | `udio` | Udio | API key | [link](https://udio.com) | Paste session cookie from udio.com (Supabase auth) | +| `uncloseai` | `unc` | UncloseAI | API key | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. | +| `upstage` | `upstage` | Upstage | API key | [link](https://www.upstage.ai) | — | +| `v0-vercel` | `v0` | v0 (Vercel) | API key | [link](https://v0.dev) | — | +| `venice` | `venice` | Venice.ai | API key | [link](https://venice.ai) | — | +| `vercel-ai-gateway` | `vag` | Vercel AI Gateway | API key, aggregator | [link](https://vercel.com/docs/ai-gateway) | — | +| `vertex` | `vertex` | Vertex AI | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide Service Account JSON or OAuth access_token | +| `vertex-partner` | `vp` | Vertex AI Partners | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide the same Service Account JSON used for Vertex AI partner models. | +| `volcengine` | `volcengine` | Volcengine | API key | [link](https://www.volcengine.com) | — | +| `voyage-ai` | `voyage` | Voyage AI | API key, embed/rerank | [link](https://www.voyageai.com) | Bearer API key for Voyage AI embeddings and rerank APIs. | +| `wafer` | `wafer` | Wafer AI | API key | [link](https://wafer.ai) | — | +| `wandb` | `wandb` | Weights & Biases Inference | API key | [link](https://wandb.ai) | — | +| `watsonx` | `watsonx` | IBM watsonx.ai Gateway | API key, enterprise | [link](https://www.ibm.com/products/watsonx-ai) | Use your watsonx bearer token. Base URL can be https://.ml.cloud.ibm.com/ml/gateway/v1/ or a self-managed /ml/gateway/v1 endpoint. | +| `x5lab` | `x5lab` | X5Lab | API key | [link](https://x5lab.dev) | Use your X5Lab API key (x5-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.x5lab.dev/v1. | +| `xai` | `xai` | xAI (Grok) | API key | [link](https://x.ai) | — | +| `xiaomi-mimo` | `mimo` | Xiaomi MiMo | API key | [link](https://mimo.mi.com) | — | +| `yi` | `yi` | Yi (01.AI) | API key | [link](https://01.ai) | Get API key at platform.lingyiwanwu.com | +| `zai` | `zai` | Z.AI | API key | [link](https://open.bigmodel.cn) | — | +| `zenmux` | `zm` | ZenMux | API key | [link](https://zenmux.ai) | Use your ZenMux API key in Authorization: Bearer . ZenMux is fully OpenAI-compatible. Base URL: https://zenmux.ai/api/v1. | ## Local Providers (12) -| ID | Alias | Name | Tags | Website | Notes | -| --------------------- | ------------ | ------------------- | ------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `comfyui` | `comfyui` | ComfyUI | Local | [link](https://github.com/comfyanonymous/ComfyUI) | No API key required. Configure the local ComfyUI base URL (default: http://localhost:8188). | -| `docker-model-runner` | `dmr` | Docker Model Runner | Local, self-hosted | [link](https://docs.docker.com/ai/model-runner/) | API key optional. Configure the local Docker Model Runner OpenAI-compatible base URL (default: http://localhost:12434/v1). | -| `lemonade` | `lemonade` | Lemonade Server | Local, self-hosted | [link](https://lemonade-server.ai) | API key optional. Configure the local Lemonade OpenAI-compatible base URL (default: http://localhost:13305/api/v1). | -| `llama-cpp` | `llamacpp` | llama.cpp | Local, self-hosted | [link](https://github.com/ggml-org/llama.cpp) | API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port. | -| `llamafile` | `llamafile` | Llamafile | Local, self-hosted | [link](https://github.com/Mozilla-Ocho/llamafile) | API key optional. Configure the local Llamafile OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). | -| `lm-studio` | `lmstudio` | LM Studio | Local, self-hosted | [link](https://lmstudio.ai) | API key optional. Configure the local LM Studio OpenAI-compatible base URL (default: http://localhost:1234/v1). | -| `ollama-local` | `ollama` | Ollama | Local, self-hosted | [link](https://ollama.com) | No API key required. Ollama runs locally — configure its OpenAI-compatible base URL (default: http://localhost:11434/v1) and make sure Ollama is running before connecting. | -| `oobabooga` | `ooba` | oobabooga | Local, self-hosted | [link](https://github.com/oobabooga/text-generation-webui) | API key optional. Configure the local oobabooga OpenAI-compatible base URL (default: http://localhost:5000/v1). | -| `sdwebui` | `sdwebui` | SD WebUI | Local | [link](https://github.com/AUTOMATIC1111/stable-diffusion-webui) | No API key required. Configure the local WebUI base URL (default: http://localhost:7860). | -| `triton` | `triton` | NVIDIA Triton | Local, self-hosted | [link](https://developer.nvidia.com/triton-inference-server) | API key optional. Configure the Triton OpenAI-compatible base URL (default: http://localhost:8000/v1). | -| `vllm` | `vllm` | vLLM | Local, self-hosted | [link](https://github.com/vllm-project/vllm) | API key optional. Configure the local vLLM OpenAI-compatible base URL (default: http://localhost:8000/v1). | -| `xinference` | `xinference` | XInference | Local, self-hosted | [link](https://inference.readthedocs.io) | API key optional. Configure the local XInference OpenAI-compatible base URL (default: http://localhost:9997/v1). | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `comfyui` | `comfyui` | ComfyUI | Local | [link](https://github.com/comfyanonymous/ComfyUI) | No API key required. Configure the local ComfyUI base URL (default: http://localhost:8188). | +| `docker-model-runner` | `dmr` | Docker Model Runner | Local, self-hosted | [link](https://docs.docker.com/ai/model-runner/) | API key optional. Configure the local Docker Model Runner OpenAI-compatible base URL (default: http://localhost:12434/v1). | +| `lemonade` | `lemonade` | Lemonade Server | Local, self-hosted | [link](https://lemonade-server.ai) | API key optional. Configure the local Lemonade OpenAI-compatible base URL (default: http://localhost:13305/api/v1). | +| `llama-cpp` | `llamacpp` | llama.cpp | Local, self-hosted | [link](https://github.com/ggml-org/llama.cpp) | API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port. | +| `llamafile` | `llamafile` | Llamafile | Local, self-hosted | [link](https://github.com/Mozilla-Ocho/llamafile) | API key optional. Configure the local Llamafile OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). | +| `lm-studio` | `lmstudio` | LM Studio | Local, self-hosted | [link](https://lmstudio.ai) | API key optional. Configure the local LM Studio OpenAI-compatible base URL (default: http://localhost:1234/v1). | +| `ollama-local` | `ollama` | Ollama | Local, self-hosted | [link](https://ollama.com) | No API key required. Ollama runs locally — configure its OpenAI-compatible base URL (default: http://localhost:11434/v1) and make sure Ollama is running before connecting. | +| `oobabooga` | `ooba` | oobabooga | Local, self-hosted | [link](https://github.com/oobabooga/text-generation-webui) | API key optional. Configure the local oobabooga OpenAI-compatible base URL (default: http://localhost:5000/v1). | +| `sdwebui` | `sdwebui` | SD WebUI | Local | [link](https://github.com/AUTOMATIC1111/stable-diffusion-webui) | No API key required. Configure the local WebUI base URL (default: http://localhost:7860). | +| `triton` | `triton` | NVIDIA Triton | Local, self-hosted | [link](https://developer.nvidia.com/triton-inference-server) | API key optional. Configure the Triton OpenAI-compatible base URL (default: http://localhost:8000/v1). | +| `vllm` | `vllm` | vLLM | Local, self-hosted | [link](https://github.com/vllm-project/vllm) | API key optional. Configure the local vLLM OpenAI-compatible base URL (default: http://localhost:8000/v1). | +| `xinference` | `xinference` | XInference | Local, self-hosted | [link](https://inference.readthedocs.io) | API key optional. Configure the local XInference OpenAI-compatible base URL (default: http://localhost:9997/v1). | ## Search Providers (11) -| ID | Alias | Name | Tags | Website | Notes | -| ------------------- | --------------- | -------------------------- | ------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `brave-search` | `brave-search` | Brave Search | Search | [link](https://brave.com/search/api) | Subscription token from Brave Search API dashboard | -| `exa-search` | `exa-search` | Exa Search | Search | [link](https://exa.ai) | API key from dashboard.exa.ai | -| `google-pse-search` | `google-pse` | Google Programmable Search | Search | [link](https://developers.google.com/custom-search/v1/overview) | Requires a Google API key and your Programmable Search Engine ID (cx) | -| `linkup-search` | `linkup` | Linkup Search | Search | [link](https://docs.linkup.so) | Bearer API key from the Linkup dashboard | -| `ollama-search` | `ollama-search` | Ollama Search | Search | [link](https://ollama.com/settings/keys) | Same API key as Ollama Cloud (from ollama.com/settings/keys) | -| `perplexity-search` | `pplx-search` | Perplexity Search | Search | [link](https://docs.perplexity.ai/guides/search-quickstart) | Same API key as Perplexity (pplx-...) | -| `searchapi-search` | `searchapi` | SearchAPI | Search | [link](https://www.searchapi.io/docs/google) | API key from SearchAPI (query param or Bearer auth) | -| `searxng-search` | `searxng` | SearXNG Search | Search | [link](https://docs.searxng.org) | API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access. | -| `serper-search` | `serper-search` | Serper Search | Search | [link](https://serper.dev) | API key from serper.dev dashboard | -| `tavily-search` | `tavily-search` | Tavily Search | Search | [link](https://tavily.com) | API key from app.tavily.com (format: tvly-...) | -| `youcom-search` | `youcom-search` | You.com Search | Search | [link](https://you.com/business/api/) | X-API-Key from the You.com platform dashboard | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `brave-search` | `brave-search` | Brave Search | Search | [link](https://brave.com/search/api) | Subscription token from Brave Search API dashboard | +| `exa-search` | `exa-search` | Exa Search | Search | [link](https://exa.ai) | API key from dashboard.exa.ai | +| `google-pse-search` | `google-pse` | Google Programmable Search | Search | [link](https://developers.google.com/custom-search/v1/overview) | Requires a Google API key and your Programmable Search Engine ID (cx) | +| `linkup-search` | `linkup` | Linkup Search | Search | [link](https://docs.linkup.so) | Bearer API key from the Linkup dashboard | +| `ollama-search` | `ollama-search` | Ollama Search | Search | [link](https://ollama.com/settings/keys) | Same API key as Ollama Cloud (from ollama.com/settings/keys) | +| `perplexity-search` | `pplx-search` | Perplexity Search | Search | [link](https://docs.perplexity.ai/guides/search-quickstart) | Same API key as Perplexity (pplx-...) | +| `searchapi-search` | `searchapi` | SearchAPI | Search | [link](https://www.searchapi.io/docs/google) | API key from SearchAPI (query param or Bearer auth) | +| `searxng-search` | `searxng` | SearXNG Search | Search | [link](https://docs.searxng.org) | API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access. | +| `serper-search` | `serper-search` | Serper Search | Search | [link](https://serper.dev) | API key from serper.dev dashboard | +| `tavily-search` | `tavily-search` | Tavily Search | Search | [link](https://tavily.com) | API key from app.tavily.com (format: tvly-...) | +| `youcom-search` | `youcom-search` | You.com Search | Search | [link](https://you.com/business/api/) | X-API-Key from the You.com platform dashboard | ## Audio-only Providers (7) -| ID | Alias | Name | Tags | Website | Notes | -| ------------ | ---------- | ---------- | ----- | ------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `assemblyai` | `aai` | AssemblyAI | Audio | [link](https://assemblyai.com) | — | -| `aws-polly` | `polly` | AWS Polly | Audio | [link](https://aws.amazon.com/polly/) | Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region. | -| `cartesia` | `cartesia` | Cartesia | Audio | [link](https://cartesia.ai) | — | -| `deepgram` | `dg` | Deepgram | Audio | [link](https://deepgram.com) | — | -| `elevenlabs` | `el` | ElevenLabs | Audio | [link](https://elevenlabs.io) | — | -| `inworld` | `inworld` | Inworld | Audio | [link](https://inworld.ai) | — | -| `playht` | `playht` | PlayHT | Audio | [link](https://play.ht) | — | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `assemblyai` | `aai` | AssemblyAI | Audio | [link](https://assemblyai.com) | — | +| `aws-polly` | `polly` | AWS Polly | Audio | [link](https://aws.amazon.com/polly/) | Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region. | +| `cartesia` | `cartesia` | Cartesia | Audio | [link](https://cartesia.ai) | — | +| `deepgram` | `dg` | Deepgram | Audio | [link](https://deepgram.com) | — | +| `elevenlabs` | `el` | ElevenLabs | Audio | [link](https://elevenlabs.io) | — | +| `inworld` | `inworld` | Inworld | Audio | [link](https://inworld.ai) | — | +| `playht` | `playht` | PlayHT | Audio | [link](https://play.ht) | — | ## Upstream Proxy Providers (2) -| ID | Alias | Name | Tags | Website | Notes | -| ------------- | ----- | ----------- | -------------- | ---------------------------------------------------- | ----- | -| `9router` | `nr` | 9router | Upstream proxy | [link](https://www.npmjs.com/package/9router) | — | -| `cliproxyapi` | `cpa` | CLIProxyAPI | Upstream proxy | [link](https://github.com/router-for-me/CLIProxyAPI) | — | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `9router` | `nr` | 9router | Upstream proxy | [link](https://www.npmjs.com/package/9router) | — | +| `cliproxyapi` | `cpa` | CLIProxyAPI | Upstream proxy | [link](https://github.com/router-for-me/CLIProxyAPI) | — | ## Cloud Agent Providers (3) -| ID | Alias | Name | Tags | Website | Notes | -| ------------- | ------------- | ------------ | ----------- | -------------------------------- | ----------------------------------------------------------- | -| `codex-cloud` | `codex-cloud` | Codex Cloud | Cloud agent | [link](https://openai.com/codex) | OpenAI API key with Codex Cloud task access. | -| `devin` | `devin` | Devin | Cloud agent | [link](https://devin.ai) | Devin API key for cloud agent sessions. | -| `jules` | `jules` | Google Jules | Cloud agent | [link](https://jules.google) | Jules API key for creating and managing cloud coding tasks. | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `codex-cloud` | `codex-cloud` | Codex Cloud | Cloud agent | [link](https://openai.com/codex) | OpenAI API key with Codex Cloud task access. | +| `devin` | `devin` | Devin | Cloud agent | [link](https://devin.ai) | Devin API key for cloud agent sessions. | +| `jules` | `jules` | Google Jules | Cloud agent | [link](https://jules.google) | Jules API key for creating and managing cloud coding tasks. | ## System Providers (1) -| ID | Alias | Name | Tags | Website | Notes | -| ------ | ------ | ------------------ | ------ | ------- | ----- | -| `auto` | `auto` | Auto (Zero-Config) | System | — | — | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `auto` | `auto` | Auto (Zero-Config) | System | — | — | ## Sources of truth diff --git a/open-sse/config/errorConfig.ts b/open-sse/config/errorConfig.ts index e685fe60a8..f5ac29fde3 100644 --- a/open-sse/config/errorConfig.ts +++ b/open-sse/config/errorConfig.ts @@ -199,3 +199,41 @@ export function matchErrorRuleByStatus(statusCode: number): ErrorRule | null { export function findMatchingErrorRule(statusCode: number, message: unknown): ErrorRule | null { return matchErrorRuleByText(message) || matchErrorRuleByStatus(statusCode); } + +export interface ServiceSupervisorCooldown { + shouldFallback: true; + cooldownMs: number; + baseCooldownMs: number; + newBackoffLevel: 0; + reason: string; + skipProviderBreaker: true; +} + +/** + * G-02: detect embedded service supervisor failures (X-Omni-Fallback-Hint: connection_cooldown). + * These are NOT upstream AI provider failures — they are local supervisor state changes. Returns + * a short 5s connection-cooldown decision (no provider circuit-breaker trip), or null when the + * status/header don't match. + */ +export function serviceSupervisorCooldown( + status: number, + headers: Headers | Record | null +): ServiceSupervisorCooldown | null { + if (status !== 503 || !headers) return null; + const hintValue = + typeof (headers as Headers).get === "function" + ? (headers as Headers).get("x-omni-fallback-hint") + : (headers as Record)["x-omni-fallback-hint"] || + (headers as Record)["X-Omni-Fallback-Hint"]; + if (typeof hintValue !== "string" || hintValue.toLowerCase() !== "connection_cooldown") { + return null; + } + return { + shouldFallback: true, + cooldownMs: 5_000, + baseCooldownMs: 5_000, + newBackoffLevel: 0, + reason: "service_not_running", + skipProviderBreaker: true, + }; +} diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 97f1dc60ce..0c0acf4e8d 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -5,6 +5,8 @@ * Each provider has its own request format and endpoint. */ +import { LMARENA_DIRECT_IMAGE_MODELS } from "./providers/registry/lmarena/directModels.ts"; + interface ImageModelEntry { id: string; name: string; @@ -576,7 +578,11 @@ export const IMAGE_PROVIDERS: Record = { authHeader: "bearer", format: "nvidia-nim", models: [ - { id: "black-forest-labs/flux.1-dev", name: "FLUX.1 Dev", inputModalities: ["text", "image"] }, + { + id: "black-forest-labs/flux.1-dev", + name: "FLUX.1 Dev", + inputModalities: ["text", "image"], + }, { id: "black-forest-labs/flux.1-schnell", name: "FLUX.1 Schnell" }, { id: "black-forest-labs/flux.1-kontext-dev", @@ -625,6 +631,20 @@ export const IMAGE_PROVIDERS: Record = { ], supportedSizes: ["1024x1024"], }, + + // Arena (formerly LMArena) Direct-chat Image category (static scrape 2026-07-09). + // Not listed in the chat registry — image catalog only. Generation path still + // uses cookie session auth via the lmarena provider connection (stable wire id). + lmarena: { + id: "lmarena", + alias: "lma", + baseUrl: "https://arena.ai/nextjs-api/stream/create-evaluation", + authType: "apikey", + authHeader: "cookie", + format: "openai", + models: LMARENA_DIRECT_IMAGE_MODELS, + supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], + }, }; /** diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index a7e0fa51eb..110091d04a 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -21,6 +21,7 @@ import { glmtProvider } from "./registry/glm/t/index.ts"; import { glm_cnProvider } from "./registry/glm/cn/index.ts"; import { traeProvider } from "./registry/trae/index.ts"; import { muse_spark_webProvider } from "./registry/muse-spark-web/index.ts"; +import { lmarenaProvider } from "./registry/lmarena/index.ts"; import { kilocodeProvider } from "./registry/kilocode/index.ts"; import { github_modelsProvider } from "./registry/github/models/index.ts"; import { githubProvider } from "./registry/github/index.ts"; @@ -204,6 +205,7 @@ export const REGISTRY: Record = { "glm-cn": glm_cnProvider, trae: traeProvider, "muse-spark-web": muse_spark_webProvider, + lmarena: lmarenaProvider, kilocode: kilocodeProvider, "github-models": github_modelsProvider, github: githubProvider, diff --git a/open-sse/config/providers/registry/lmarena/directModels.ts b/open-sse/config/providers/registry/lmarena/directModels.ts new file mode 100644 index 0000000000..3e75bc7597 --- /dev/null +++ b/open-sse/config/providers/registry/lmarena/directModels.ts @@ -0,0 +1,737 @@ +/** + * Arena (formerly LMArena) Direct-chat model allowlist (scraped 2026-07-09). + * - Text + Search → chat registry (providers/registry/lmarena, wire id unchanged) + * - Image → IMAGE_PROVIDERS in imageRegistry.ts (not chat catalog) + * Live HTML discovery is disabled. Scrape JSON stays local/desktop only — not shipped. + */ +import type { RegistryModel } from "../../shared.ts"; + +export interface LmarenaDirectModelEntry { + catalogId: string; + arenaId: string; + publicName: string; + displayName: string; + organization: string; + vision: boolean; + category: string; +} + +export const LMARENA_DIRECT_MODEL_ENTRIES: readonly LmarenaDirectModelEntry[] = Object.freeze([ + { + catalogId: "amazon.nova-pro-v1:0", + arenaId: "a14546b5-d78d-4cf6-bb61-ab5b8510a9d6", + publicName: "amazon.nova-pro-v1:0", + displayName: "amazon.nova-pro-v1:0", + organization: "amazon", + vision: true, + category: "Text", + }, + { + catalogId: "claude-haiku-4-5-20251001", + arenaId: "0199e8e9-01ed-73e0-96ba-cf43b286bf10", + publicName: "claude-haiku-4-5-20251001", + displayName: "claude-haiku-4-5-20251001", + organization: "anthropic", + vision: false, + category: "Text", + }, + { + catalogId: "claude-sonnet-5", + arenaId: "019f19f2-41f1-7c6d-9891-48d02fd9952c", + publicName: "claude-sonnet-5", + displayName: "claude-sonnet-5-high", + organization: "anthropic", + vision: true, + category: "Text", + }, + { + catalogId: "deepseek-v4-pro-thinking", + arenaId: "019dc1c1-c62d-7b70-85a1-e0565e29fce1", + publicName: "deepseek-v4-pro-thinking", + displayName: "deepseek-v4-pro-thinking", + organization: "deepseek", + vision: false, + category: "Text", + }, + { + catalogId: "dola-seed-2.0-preview-vision", + arenaId: "019c6453-641a-74f8-a689-a8a67175a359", + publicName: "dola-seed-2.0-preview-vision", + displayName: "dola-seed-2.0-preview-vision", + organization: "bytedance", + vision: true, + category: "Text", + }, + { + catalogId: "ernie-5.0-preview-1220", + arenaId: "019d44f1-26da-729f-a4ea-ddddfe7b4eae", + publicName: "ernie-5.0-preview-1220", + displayName: "ernie-5.0-preview-1220", + organization: "baidu", + vision: true, + category: "Text", + }, + { + catalogId: "gemini-3.1-flash-lite", + arenaId: "019f408a-186c-7f3a-9595-4e079e42a613", + publicName: "gemini-3.1-flash-lite", + displayName: "gemini-3.1-flash-lite", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "gemini-3.1-pro-preview", + arenaId: "019c7820-5480-78b6-9fef-04c0d7004054", + publicName: "gemini-3.1-pro-preview", + displayName: "gemini-3.1-pro-preview", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "gemini-3.5-flash-high", + arenaId: "019f406f-fc33-7b9d-9571-7b8443bc7ca0", + publicName: "gemini-3.5-flash-high", + displayName: "gemini-3.5-flash-high", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "significant-otter", + arenaId: "019d2cd3-2641-7628-94bd-67ecb0a7134e", + publicName: "significant-otter", + displayName: "gemma-4-26b-a4b", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "pteronura", + arenaId: "019d2cd2-dd83-75ab-a421-d0ba2e22b1e3", + publicName: "pteronura", + displayName: "gemma-4-31b", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "glm-5.1", + arenaId: "019ebf6a-94d4-7649-b704-1dbbd5eb0942", + publicName: "glm-5.1", + displayName: "glm-5.2 (max)", + organization: "zai", + vision: false, + category: "Text", + }, + { + catalogId: "glm-5v-turbo", + arenaId: "019d4a09-9651-78cb-86ea-bb0fa5ec77f4", + publicName: "glm-5v-turbo", + displayName: "glm-5v-turbo", + organization: "zai", + vision: true, + category: "Text", + }, + { + catalogId: "gpt-oss-120b", + arenaId: "6ee9f901-17b5-4fbe-9cc2-13c16497c23b", + publicName: "gpt-oss-120b", + displayName: "gpt-oss-120b", + organization: "openai", + vision: false, + category: "Text", + }, + { + catalogId: "gpt-5.2-high", + arenaId: "019b1449-0313-7911-b836-419e2ed79b2e", + publicName: "gpt-5.2-high", + displayName: "gpt-5.2-high", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "gpt-5.4-mini-high", + arenaId: "019cfcdd-5426-777e-8314-04619cb92cc4", + publicName: "gpt-5.4-mini-high", + displayName: "gpt-5.4-mini-high", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "gpt-5.4-nano-high", + arenaId: "019cfcdd-0bca-706f-92b5-a4c4cbd022d8", + publicName: "gpt-5.4-nano-high", + displayName: "gpt-5.4-nano-high", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "gpt-5.5-instant", + arenaId: "019e71ea-1e1d-740f-9c2d-dab5869ff108", + publicName: "gpt-5.5-instant", + displayName: "gpt-5.5-instant", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "grok-4.3/text", + arenaId: "019f42aa-9c3b-76d1-8bdf-2e883b1ca227", + publicName: "grok-4.3", + displayName: "grok-4.5", + organization: "xai", + vision: true, + category: "Text", + }, + { + catalogId: "hunyuan-vision-1.5-thinking", + arenaId: "6a3a1e04-050e-4cb4-9052-b9ac4bec0c38", + publicName: "hunyuan-vision-1.5-thinking", + displayName: "hunyuan-vision-1.5-thinking", + organization: "tencent", + vision: true, + category: "Text", + }, + { + catalogId: "hy3", + arenaId: "019f3911-ba1c-7e36-b631-01893e557290", + publicName: "hy3", + displayName: "hy3", + organization: "tencent", + vision: false, + category: "Text", + }, + { + catalogId: "kimi-k2.6", + arenaId: "019dac54-e8a4-7c54-904a-ff0ecd82af42", + publicName: "kimi-k2.6", + displayName: "kimi-k2.6", + organization: "moonshot", + vision: true, + category: "Text", + }, + { + catalogId: "ling-2.5-1t", + arenaId: "019c6e76-fbbc-7e92-b0ba-784c7ef3ad8b", + publicName: "ling-2.5-1t", + displayName: "ling-2.5-1t", + organization: "ant-group", + vision: false, + category: "Text", + }, + { + catalogId: "longcat-2.0", + arenaId: "019f3a0a-bd19-7b19-9eed-a98453759b48", + publicName: "longcat-2.0", + displayName: "longcat-2.0", + organization: "meituan", + vision: false, + category: "Text", + }, + { + catalogId: "mercury-2", + arenaId: "019cc65f-c1e3-7574-b332-898ab71c8211", + publicName: "mercury-2", + displayName: "mercury-2", + organization: "inception-ai", + vision: false, + category: "Text", + }, + { + catalogId: "mimo-v2.5", + arenaId: "019db651-bd2f-7d80-ab12-d69c6bb623df", + publicName: "mimo-v2.5", + displayName: "mimo-v2.5", + organization: "xiaomi", + vision: true, + category: "Text", + }, + { + catalogId: "mimo-v2.5-pro", + arenaId: "019db650-909d-7dec-8711-1907d7233cd4", + publicName: "mimo-v2.5-pro", + displayName: "mimo-v2.5-pro", + organization: "xiaomi", + vision: false, + category: "Text", + }, + { + catalogId: "minimax-m3", + arenaId: "019e809d-f62d-7192-bb7f-1657e066b5f2", + publicName: "minimax-m3", + displayName: "minimax-m3", + organization: "minimax", + vision: true, + category: "Text", + }, + { + catalogId: "mistral-large-3", + arenaId: "019acbac-df7c-73dc-9716-ebe040daaa4e", + publicName: "mistral-large-3", + displayName: "mistral-large-3", + organization: "mistral", + vision: true, + category: "Text", + }, + { + catalogId: "mistral-medium-3.5", + arenaId: "019f30a4-044d-7a14-9d3b-2e7299159e36", + publicName: "mistral-medium-3.5", + displayName: "mistral-medium-3.5", + organization: "mistral", + vision: true, + category: "Text", + }, + { + catalogId: "mistral-small-2603", + arenaId: "019cf983-532b-73fa-a057-7658e1e1c5ee", + publicName: "mistral-small-2603", + displayName: "mistral-small-2603", + organization: "mistral", + vision: false, + category: "Text", + }, + { + catalogId: "nova-2-lite", + arenaId: "019ae300-83b7-7717-a1e0-31accd1ff6fa", + publicName: "nova-2-lite", + displayName: "nova-2-lite", + organization: "amazon", + vision: false, + category: "Text", + }, + { + catalogId: "nvidia-nemotron-3-nano-30b-a3b-bf16", + arenaId: "019b0aa7-334a-78e8-b2a8-885f31f4fc0c", + publicName: "nvidia-nemotron-3-nano-30b-a3b-bf16", + displayName: "nvidia-nemotron-3-nano-30b-a3b-bf16", + organization: "nvidia", + vision: false, + category: "Text", + }, + { + catalogId: "march26-chatbot1-public", + arenaId: "019cd9e3-c3ff-7225-92f2-c392259b1fbe", + publicName: "march26-chatbot1-public", + displayName: "nvidia-nemotron-3-super-120b-a12b", + organization: "nvidia", + vision: false, + category: "Text", + }, + { + catalogId: "may26-chatbot4-public", + arenaId: "019e8ea8-2052-7f2e-b1b6-59bd94be5203", + publicName: "may26-chatbot4-public", + displayName: "nvidia-nemotron-3-ultra-550b-a55b-nvfp4", + organization: "nvidia", + vision: false, + category: "Text", + }, + { + catalogId: "o3-2025-04-16", + arenaId: "cb0f1e24-e8e9-4745-aabc-b926ffde7475", + publicName: "o3-2025-04-16", + displayName: "o3-2025-04-16", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "qwen3.5-397b-a17b", + arenaId: "019c6918-1d2a-7e3f-88ec-ada000b6ab16", + publicName: "qwen3.5-397b-a17b", + displayName: "qwen3.5-397b-a17b", + organization: "alibaba", + vision: true, + category: "Text", + }, + { + catalogId: "qwen3.7-max", + arenaId: "019e6530-f140-77b1-b6b8-5c859829d992", + publicName: "qwen3.7-max", + displayName: "qwen3.7-max", + organization: "alibaba", + vision: false, + category: "Text", + }, + { + catalogId: "qwen3.7-plus", + arenaId: "019e86fe-167d-77bd-94a8-df7aee4f4551", + publicName: "qwen3.7-plus", + displayName: "qwen3.7-plus", + organization: "alibaba", + vision: true, + category: "Text", + }, + { + catalogId: "ring-2.5-1t", + arenaId: "019c6e77-1b9f-7649-9136-43d07566c6c5", + publicName: "ring-2.5-1t", + displayName: "ring-2.5-1t", + organization: "ant-group", + vision: false, + category: "Text", + }, + { + catalogId: "step-3.5-flash", + arenaId: "019d22bb-fcf5-7866-9c07-de74fe05c9cc", + publicName: "step-3.5-flash", + displayName: "step-3.5-flash", + organization: "stepfun", + vision: false, + category: "Text", + }, + { + catalogId: "trinity-large-thinking", + arenaId: "019d50aa-447d-74d6-8661-405b4b6de5de", + publicName: "trinity-large-thinking", + displayName: "trinity-large-thinking", + organization: "arcee-ai", + vision: false, + category: "Text", + }, + { + catalogId: "cosmos3-super", + arenaId: "019f4270-5d10-723c-8a7e-dfd5dd2214e5", + publicName: "cosmos3-super", + displayName: "cosmos3-super", + organization: "nvidia", + vision: false, + category: "Image", + }, + { + catalogId: "cosmos3-super-agentic", + arenaId: "019f4270-9eea-79c3-b18f-71303ee41ff8", + publicName: "cosmos3-super-agentic", + displayName: "cosmos3-super-agentic", + organization: "nvidia", + vision: false, + category: "Image", + }, + { + catalogId: "flux-2-dev", + arenaId: "019b478d-74ae-7d19-9a8f-6cfde89ab4ca", + publicName: "flux-2-dev", + displayName: "flux-2-dev", + organization: "bfl", + vision: true, + category: "Image", + }, + { + catalogId: "flux-2-pro", + arenaId: "019b7541-5e4b-7ff7-a34b-b0255b6ca9aa", + publicName: "flux-2-pro", + displayName: "flux-2-pro", + organization: "bfl", + vision: true, + category: "Image", + }, + { + catalogId: "gemini-2.5-flash-image-preview (nano-banana)", + arenaId: "0199ef2a-583f-7088-b704-b75fd169401d", + publicName: "gemini-2.5-flash-image-preview (nano-banana)", + displayName: "gemini-2.5-flash-image-preview (nano-banana)", + organization: "google", + vision: true, + category: "Image", + }, + { + catalogId: "instant-ramen", + arenaId: "019ed3b3-c4f8-7e67-b72d-385d740096d0", + publicName: "instant-ramen", + displayName: "gemini-3.1-flash-lite-image (nano-banana-2-lite)", + organization: "google", + vision: true, + category: "Image", + }, + { + catalogId: "gpt-image-1", + arenaId: "6e855f13-55d7-4127-8656-9168a9f4dcc0", + publicName: "gpt-image-1", + displayName: "gpt-image-1", + organization: "openai", + vision: true, + category: "Image", + }, + { + catalogId: "blue-crab", + arenaId: "019e4271-4717-7dd9-a0e2-90783fbabd25", + publicName: "blue-crab", + displayName: "grok-imagine-image-quality (20260519)", + organization: "xai", + vision: true, + category: "Image", + }, + { + catalogId: "hidream-o1-image", + arenaId: "019e1cd7-5cc5-75d2-8b3e-275616dec624", + publicName: "hidream-o1-image", + displayName: "hidream-o1-image", + organization: "hidream", + vision: false, + category: "Image", + }, + { + catalogId: "sungod", + arenaId: "019bec2d-e92c-745d-ae46-c7166590237a", + publicName: "sungod", + displayName: "hunyuan-image-3.0-instruct", + organization: "tencent", + vision: true, + category: "Image", + }, + { + catalogId: "ideogram-v3-quality", + arenaId: "73378be5-cdba-49e7-b3d0-027949871aa6", + publicName: "ideogram-v3-quality", + displayName: "ideogram-v3-quality", + organization: "Ideogram", + vision: false, + category: "Image", + }, + { + catalogId: "krea-2-large", + arenaId: "019e8ebd-6cfb-7492-949c-a2c4a00301aa", + publicName: "krea-2-large", + displayName: "krea-2-large", + organization: "krea", + vision: false, + category: "Image", + }, + { + catalogId: "krea-2-turbo", + arenaId: "019f049d-7de4-7fae-8237-1c2103b9e730", + publicName: "krea-2-turbo", + displayName: "krea-2-turbo", + organization: "krea", + vision: false, + category: "Image", + }, + { + catalogId: "lucid-origin", + arenaId: "5a3b3520-c87d-481f-953c-1364687b6e8f", + publicName: "lucid-origin", + displayName: "lucid-origin", + organization: "leonardo-ai", + vision: false, + category: "Image", + }, + { + catalogId: "kakarot-v2", + arenaId: "019e80aa-37bf-7e89-8a28-41e4ab72ed9f", + publicName: "kakarot-v2", + displayName: "mai-image-2.5 (image-edit)", + organization: "microsoft-ai", + vision: true, + category: "Image", + }, + { + catalogId: "baryonyx", + arenaId: "019e530d-2a50-75e3-95d1-a5ef41d4c24c", + publicName: "baryonyx", + displayName: "mai-image-2.5 (text-to-image)", + organization: "microsoft-ai", + vision: false, + category: "Image", + }, + { + catalogId: "iron-bloom", + arenaId: "019ef780-25ef-7878-8b91-307f8f879d42", + publicName: "iron-bloom", + displayName: "muse-image", + organization: "meta", + vision: true, + category: "Image", + }, + { + catalogId: "photon", + arenaId: "e7c9fa2d-6f5d-40eb-8305-0980b11c7cab", + publicName: "photon", + displayName: "photon", + organization: "luma-ai", + vision: false, + category: "Image", + }, + { + catalogId: "qwen-image-2.0", + arenaId: "019d287c-4906-7f9c-8b78-8a2a86cf00a5", + publicName: "qwen-image-2.0", + displayName: "qwen-image-2.0", + organization: "alibaba", + vision: true, + category: "Image", + }, + { + catalogId: "qwen-image-2.0-pro", + arenaId: "019d287b-b718-7daa-ad65-502596d0813d", + publicName: "qwen-image-2.0-pro", + displayName: "qwen-image-2.0-pro", + organization: "alibaba", + vision: true, + category: "Image", + }, + { + catalogId: "recraft-v4", + arenaId: "019c6e76-a7c0-7b05-8dce-bbe3d52c8f4e", + publicName: "recraft-v4", + displayName: "recraft-v4", + organization: "Recraft", + vision: false, + category: "Image", + }, + { + catalogId: "avalon", + arenaId: "019e7091-f73f-7338-b5e2-4ab5fba37dc2", + publicName: "avalon", + displayName: "reve-2.0 (image-edit)", + organization: "reve", + vision: true, + category: "Image", + }, + { + catalogId: "babylon", + arenaId: "019e86c6-a3dc-73ac-9adc-8c5f304dc2fb", + publicName: "babylon", + displayName: "reve-2.0 (text-to-image)", + organization: "reve", + vision: false, + category: "Image", + }, + { + catalogId: "seedream-5.0-pro", + arenaId: "019f42b5-8c52-7793-9be8-de35eecf7ea9", + publicName: "seedream-5.0-pro", + displayName: "seedream-5.0-pro", + organization: "bytedance", + vision: true, + category: "Image", + }, + { + catalogId: "uni-1.1-max", + arenaId: "019ed208-69ca-7f3f-85ee-182d5f0ea08b", + publicName: "uni-1.1-max", + displayName: "uni-1.1-max", + organization: "luma-ai", + vision: true, + category: "Image", + }, + { + catalogId: "wan2.7-image-pro", + arenaId: "019db3f0-b024-7478-bd4d-55ea1ec1d421", + publicName: "wan2.7-image-pro", + displayName: "wan2.7-image-pro", + organization: "wan", + vision: true, + category: "Image", + }, + { + catalogId: "zen-bear-v3", + arenaId: "019f38c2-002d-7f0f-a391-db6df024b734", + publicName: "zen-bear-v3", + displayName: "zen-bear-v3", + organization: "alibaba", + vision: false, + category: "Image", + }, + { + catalogId: "claude-sonnet-5-search", + arenaId: "019f1a07-de72-7fbe-8d82-56dbd7348360", + publicName: "claude-sonnet-5-search", + displayName: "claude-sonnet-5-search", + organization: "anthropic", + vision: false, + category: "Search", + }, + { + catalogId: "gemini-2.5-pro-grounding", + arenaId: "b222be23-bd55-4b20-930b-a30cc84d3afd", + publicName: "gemini-2.5-pro-grounding", + displayName: "gemini-2.5-pro-grounding", + organization: "google", + vision: false, + category: "Search", + }, + { + catalogId: "gemini-3-flash-grounding", + arenaId: "019bda1f-3abc-783f-aac0-1ee102b247ba", + publicName: "gemini-3-flash-grounding", + displayName: "gemini-3-flash-grounding", + organization: "google", + vision: false, + category: "Search", + }, + { + catalogId: "gpt-5.2-search", + arenaId: "019b1448-f74a-72de-b25d-8666618f8c5a", + publicName: "gpt-5.2-search", + displayName: "gpt-5.2-search", + organization: "openai", + vision: false, + category: "Search", + }, + { + catalogId: "grok-4.3/search", + arenaId: "019de22d-1445-7296-9c88-a5877bc66ef8", + publicName: "grok-4.3", + displayName: "grok-4.3", + organization: "xai", + vision: false, + category: "Search", + }, + { + catalogId: "o3-search", + arenaId: "fbe08e9a-3805-4f9f-a085-7bc38e4b51d1", + publicName: "o3-search", + displayName: "o3-search", + organization: "openai", + vision: false, + category: "Search", + }, +] as LmarenaDirectModelEntry[]); + +/** Chat-completions catalog (Text + Search). Image rows are excluded. */ +export const LMARENA_DIRECT_CHAT_ENTRIES: readonly LmarenaDirectModelEntry[] = + LMARENA_DIRECT_MODEL_ENTRIES.filter((m) => m.category === "Text" || m.category === "Search"); + +/** Image-generation catalog rows (IMAGE_PROVIDERS). */ +export const LMARENA_DIRECT_IMAGE_ENTRIES: readonly LmarenaDirectModelEntry[] = + LMARENA_DIRECT_MODEL_ENTRIES.filter((m) => m.category === "Image"); + +export const LMARENA_DIRECT_MODELS: RegistryModel[] = LMARENA_DIRECT_CHAT_ENTRIES.map((m) => ({ + id: m.catalogId, + name: m.displayName, + ...(m.vision ? { supportsVision: true as const } : {}), +})); + +export const LMARENA_DIRECT_IMAGE_MODELS: Array<{ + id: string; + name: string; + inputModalities?: string[]; +}> = LMARENA_DIRECT_IMAGE_ENTRIES.map((m) => ({ + id: m.catalogId, + name: m.displayName, + inputModalities: m.vision ? ["text", "image"] : ["text"], +})); + +export function resolveLmarenaArenaId(catalogOrArenaId: string): string | null { + const raw = catalogOrArenaId.replace(/^(?:lmarena|lma|arena)\//i, "").trim(); + if (!raw) return null; + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (uuidRe.test(raw)) return raw; + const lower = raw.toLowerCase(); + const hit = LMARENA_DIRECT_MODEL_ENTRIES.find( + (m) => + m.catalogId === raw || + m.publicName === raw || + m.displayName === raw || + m.catalogId.toLowerCase() === lower || + m.publicName.toLowerCase() === lower || + m.displayName.toLowerCase() === lower + ); + return hit?.arenaId ?? null; +} diff --git a/open-sse/config/providers/registry/lmarena/index.ts b/open-sse/config/providers/registry/lmarena/index.ts new file mode 100644 index 0000000000..ab91a6a9e5 --- /dev/null +++ b/open-sse/config/providers/registry/lmarena/index.ts @@ -0,0 +1,18 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { LMARENA_DIRECT_MODELS } from "./directModels.ts"; + +/** + * Arena (formerly LMArena) web-session provider — arena.ai. + * Wire id remains `lmarena`. Model list is a static Direct-chat allowlist + * (no live arena.ai HTML scrape). + */ +export const lmarenaProvider: RegistryEntry = { + id: "lmarena", + alias: "lma", + format: "openai", + executor: "lmarena", + baseUrl: "https://arena.ai/nextjs-api/stream/create-evaluation", + authType: "apikey", + authHeader: "cookie", + models: LMARENA_DIRECT_MODELS, +}; diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 5489e9fbb9..494d750836 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -87,6 +87,9 @@ const ANTIGRAVITY_TRANSIENT_STATUSES = new Set([ HTTP_STATUS.SERVICE_UNAVAILABLE, HTTP_STATUS.GATEWAY_TIMEOUT, ]); +const ANTIGRAVITY_UNSUPPORTED_SAFETY_CATEGORIES = new Set([ + "HARM_CATEGORY_CIVIC_INTEGRITY", +]); // The upstream API uses plain model IDs (no -high/-low suffix). // Tier suffixes were speculative and caused 404 for gemini-3.x models — the // bare-Pro→Low normalization was retired (the set stayed empty, making the guard @@ -440,6 +443,14 @@ function asRecord(value: unknown): Record | null { : null; } +function getAntigravitySafetySettings(safetySettings: unknown): unknown[] { + const source = Array.isArray(safetySettings) ? safetySettings : DEFAULT_SAFETY_SETTINGS; + return source.filter((setting) => { + const category = asRecord(setting)?.category; + return typeof category !== "string" || !ANTIGRAVITY_UNSUPPORTED_SAFETY_CATEGORIES.has(category); + }); +} + function sanitizeAntigravityGeminiRequest( request: Record ): Record { @@ -687,12 +698,10 @@ export class AntigravityExecutor extends BaseExecutor { credentials, typeof normalizedRequest?.sessionId === "string" ? normalizedRequest.sessionId : undefined ), - // #5003: default to all-OFF safety for parity with the native Gemini paths - // (claude-to-gemini / openai-to-gemini both default to DEFAULT_SAFETY_SETTINGS). - // Previously this was `undefined`, which JSON.stringify drops, so Google Cloud Code - // applied its server-side defaults that false-flag benign technical prompts as - // `prohibited_content` (HTTP 200 + blocked body → terminal combo failover). - safetySettings: normalizedRequest?.safetySettings ?? DEFAULT_SAFETY_SETTINGS, + // #5003: send explicit all-OFF safety entries that Cloud Code accepts. Omitting the + // field lets Cloud Code apply server-side defaults that false-flag benign technical + // prompts as `prohibited_content`. + safetySettings: getAntigravitySafetySettings(normalizedRequest?.safetySettings), toolConfig: Array.isArray(normalizedRequest?.tools) && normalizedRequest.tools.length > 0 ? { functionCallingConfig: { mode: "VALIDATED" } } @@ -700,7 +709,9 @@ export class AntigravityExecutor extends BaseExecutor { }; const transformedRequest = isClaude - ? stripTrailingAntigravityAssistantTurn(sanitizeAntigravityGeminiRequest(rawTransformedRequest)) + ? stripTrailingAntigravityAssistantTurn( + sanitizeAntigravityGeminiRequest(rawTransformedRequest) + ) : rawTransformedRequest; // Obfuscate sensitive client names in user content (e.g. "OpenCode", "Cursor") diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index d5623aacdd..3dab64c395 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -19,6 +19,7 @@ import { type KiroThinkingState, } from "./kiroThinking.ts"; import { ByteQueue, TEXT_ENCODER, parseEventFrame } from "./kiro/eventstream.ts"; +import { kiroRuntimeHost, resolveKiroRuntimeRegion } from "../services/kiroRegion.ts"; type JsonRecord = Record; @@ -152,35 +153,28 @@ function ensureKiroUsage(state: KiroStreamState) { } /** - * Resolve the AWS region for a Kiro/CodeWhisperer connection. Enterprise AWS IAM Identity - * Center accounts are region-bound: the access token, the Q Developer profile ARN and the - * runtime endpoint must all match the region the IdC instance lives in (e.g. eu-central-1). - * A request signed for one region is rejected by another ("bearer token is invalid"), and a - * regional profileArn sent to us-east-1 fails with "Improperly formed request". Falls back to - * the region embedded in the profileArn, then us-east-1 (the AWS Builder ID default). + * Resolve the RUNTIME AWS region for a Kiro/CodeWhisperer connection. + * + * The runtime region is the region of the Amazon Q Developer profile (embedded in the + * profileArn — always us-east-1 or eu-central-1), NOT the IAM Identity Center / OIDC token + * region. An enterprise IdC instance may live in eu-north-1 (or any region), but the Q Developer + * profile that serves generateAssistantResponse only exists in us-east-1 / eu-central-1, so a + * runtime call must target the profileArn's region — routing to q.{idcRegion}.amazonaws.com + * (a host that does not exist) is what caused "no limits + 502 on every request". Delegates to + * the shared resolver (profileArn region → valid stored profile region → us-east-1). The IdC + * token region is used only for oidc.{region} token mint/refresh, elsewhere. */ export function resolveKiroRegion( credentials: { providerSpecificData?: unknown } | null | undefined ): string { - const psd = (credentials?.providerSpecificData || {}) as Record; - const region = typeof psd.region === "string" ? psd.region.trim().toLowerCase() : ""; - if (region) return region; - const arn = typeof psd.profileArn === "string" ? psd.profileArn.toLowerCase() : ""; - const match = arn.match(/^arn:aws:codewhisperer:([a-z0-9-]+):/); - return match ? match[1] : "us-east-1"; + return resolveKiroRuntimeRegion( + (credentials?.providerSpecificData || {}) as { region?: unknown; profileArn?: unknown } + ); } -/** - * CodeWhisperer/Amazon Q runtime host for a region. us-east-1 keeps the legacy - * codewhisperer.us-east-1 host (AWS Builder ID); other regions use the regional Amazon Q - * endpoint q.{region}.amazonaws.com — codewhisperer.{region}.amazonaws.com does not resolve - * for non-us-east-1 regions. - */ -export function kiroRuntimeHost(region: string): string { - return region === "us-east-1" - ? "https://codewhisperer.us-east-1.amazonaws.com" - : `https://q.${region}.amazonaws.com`; -} +// Re-exported from the shared region module so existing importers (and tests) that pull +// kiroRuntimeHost from this executor keep working. +export { kiroRuntimeHost }; /** * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) diff --git a/open-sse/executors/lmarena.ts b/open-sse/executors/lmarena.ts index 2f78d278db..3f28acf848 100644 --- a/open-sse/executors/lmarena.ts +++ b/open-sse/executors/lmarena.ts @@ -1,177 +1,71 @@ /** - * LMArenaExecutor — LMArena Web Session Provider + * LMArenaExecutor — Arena (formerly LMArena) web-session provider. * - * Routes requests through LMArena's web API using session credentials. - * LMArena is a model comparison platform with 100+ models (GPT, Claude, Gemini, Llama). + * Routes requests through arena.ai create-evaluation with session cookies. + * Upstream sits behind Cloudflare; traffic goes through tls-client-node Chrome + * impersonation (see services/lmarenaTlsClient.ts). * - * API Structure: - * Endpoint: https://arena.ai/nextjs-api/stream - * Method: POST - * Content-Type: application/json - * Accept: text/event-stream - * - * Auth pipeline (per request): - * 1. Extract session cookie from credentials - * 2. Build request with model and messages - * 3. Make authenticated POST request to LMArena API - * 4. Handle SSE response stream with custom prefixes (a0:, ag:, a3:, ae:, ad:) - * - * SSE Format: - * a0: - Text content (concatenate) - * ag: - Thinking/reasoning content - * a2: - Heartbeat (ignore) - * a3: - Model error - * ae: - Platform error - * ad: - Done marker + * Helpers: open-sse/executors/lmarena/{cookie,models,stream,response}.ts */ +import { v7 as uuidv7 } from "uuid"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; -import { sanitizeErrorMessage } from "../utils/error.ts"; +import { tlsFetchLMArena, TlsClientUnavailableError } from "../services/lmarenaTlsClient.ts"; +import { readLMArenaCookie, reconstructLMArenaCookie } from "./lmarena/cookie.ts"; +import { + LMARENA_STREAM_URL, + LMARENA_USER_AGENT, + buildLmarenaBrowserHeaders, + markLMArenaCatalogModelDead, + normalizeLMArenaModelsForCatalog, + parseLMArenaInitialModels, + pickLMArenaModelId, + resolveLMArenaModelId, + type LMArenaModelMetadata, +} from "./lmarena/models.ts"; +import { formatArenaPrompt, parseArenaSSE } from "./lmarena/stream.ts"; +import { + buildArenaUpstreamHttpResponse, + createOpenAIArenaStream, + handleNonStreamingArenaResponse, + mapFailedTlsResult, + mapNetworkError, + mapTlsUnavailable, + missingCookieResult, +} from "./lmarena/response.ts"; -const LMARENA_API_BASE = "https://arena.ai"; -const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream`; +export { + reconstructLMArenaCookie, + normalizeLMArenaModelsForCatalog, + parseLMArenaInitialModels, + pickLMArenaModelId, + parseArenaSSE, + markLMArenaCatalogModelDead, + LMARENA_USER_AGENT, +}; +export { clearLMArenaDeadCatalogModels } from "./lmarena/models.ts"; +export type { LMArenaModelMetadata }; -const LMARENA_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; - -const LMARENA_AUTH_COOKIE = "arena-auth-prod-v1"; - -interface ParsedCookie { - name: string; - value: string; +interface OpenAIMessage { + role?: string; + content?: unknown; } -/** - * Parse a raw `Cookie:`-style blob (`name=value; name2=value2; …`) into an - * ordered list of name/value pairs. Whitespace around names is trimmed; values - * are kept verbatim (they may legitimately contain `=`, e.g. base64 padding). - */ -function parseCookieBlob(blob: string): ParsedCookie[] { - const pairs: ParsedCookie[] = []; - for (const part of blob.split(";")) { - const eq = part.indexOf("="); - if (eq < 0) continue; - const name = part.slice(0, eq).trim(); - if (!name) continue; - const value = part.slice(eq + 1).trim(); - pairs.push({ name, value }); - } - return pairs; -} - -/** - * Reconstruct LMArena's single `arena-auth-prod-v1` auth cookie from the - * Supabase SSR chunked form. - * - * LMArena migrated to `@supabase/ssr`, which splits a large auth cookie across - * `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … (ascending). The single - * `arena-auth-prod-v1` cookie is then left empty. Following `@supabase/ssr`'s - * `combineChunks`, we read chunks in ascending numeric order until one is - * missing and `join("")` their raw values — NO base64-decode, NO JSON-parse. - * The joined value typically starts with the literal `base64-` prefix; we keep - * it verbatim (the upstream expects it). - * - * - If the blob already carries a non-empty `arena-auth-prod-v1=`, it is - * returned unchanged (back-compat with the pre-migration single cookie). - * - Otherwise the reconstructed `arena-auth-prod-v1=` is injected while - * every other cookie in the pasted jar is preserved. - * - If neither the single cookie nor any `.N` chunk has a value, the blob is - * returned as-is so the existing missing-cookie path still fires. - */ -export function reconstructLMArenaCookie(rawCookie: string): string { - if (!rawCookie || !rawCookie.trim()) return rawCookie; - - const pairs = parseCookieBlob(rawCookie); - - // Back-compat: a non-empty single cookie is already usable — forward verbatim. - const existing = pairs.find((p) => p.name === LMARENA_AUTH_COOKIE); - if (existing && existing.value) return rawCookie; - - // Collect chunk values keyed by their numeric index (`arena-auth-prod-v1.`). - const chunkPrefix = `${LMARENA_AUTH_COOKIE}.`; - const chunks = new Map(); - for (const { name, value } of pairs) { - if (!name.startsWith(chunkPrefix)) continue; - const idxRaw = name.slice(chunkPrefix.length); - if (!/^\d+$/.test(idxRaw)) continue; - chunks.set(Number(idxRaw), value); - } - - // Join in ascending order until a chunk is missing (combineChunks semantics). - const joinedParts: string[] = []; - for (let i = 0; chunks.has(i); i++) { - joinedParts.push(chunks.get(i) ?? ""); - } - const joined = joinedParts.join(""); - - // No usable session anywhere → return as-is so the missing-cookie path fires. - if (!joined) return rawCookie; - - // Inject the reconstructed single cookie while preserving the rest of the jar - // (drop the empty base cookie and the now-redundant chunks). - const preserved = pairs.filter( - (p) => p.name !== LMARENA_AUTH_COOKIE && !p.name.startsWith(chunkPrefix) - ); - const rebuilt = [ - `${LMARENA_AUTH_COOKIE}=${joined}`, - ...preserved.map((p) => `${p.name}=${p.value}`), - ]; - return rebuilt.join("; "); -} - -function readLMArenaCookie(credentials: unknown): string { - if (!credentials || typeof credentials !== "object") return ""; - const c = credentials as Record; - const direct = typeof c.cookie === "string" ? c.cookie : ""; - if (direct.trim()) return reconstructLMArenaCookie(direct); - const apiKey = typeof c.apiKey === "string" ? c.apiKey : ""; - if (apiKey.trim()) return reconstructLMArenaCookie(apiKey); - const psd = c.providerSpecificData; - if (psd && typeof psd === "object") { - const nested = (psd as Record).cookie; - if (typeof nested === "string" && nested.trim()) return reconstructLMArenaCookie(nested); - } - return ""; -} - -interface ArenaSSEEvent { - type: "text" | "thinking" | "error" | "done" | "heartbeat"; - content?: string; -} - -export function parseArenaSSE(line: string): ArenaSSEEvent | null { - if (line.startsWith("a0:")) { - try { - const content = JSON.parse(line.substring(3)); - return { type: "text", content: typeof content === "string" ? content : content.text || "" }; - } catch { - return null; +/** Optional browser-issued reCAPTCHA v3 token (operator-supplied). */ +function readRecaptchaToken(credentials: unknown, body: unknown): string | null { + const fromObj = (v: unknown): string | null => { + if (!v || typeof v !== "object") return null; + const rec = v as Record; + const direct = rec.recaptchaV3Token ?? rec.recaptchaToken; + if (typeof direct === "string" && direct.trim()) return direct.trim(); + const psd = rec.providerSpecificData; + if (psd && typeof psd === "object") { + const nested = psd as Record; + const t = nested.recaptchaV3Token ?? nested.recaptchaToken; + if (typeof t === "string" && t.trim()) return t.trim(); } - } else if (line.startsWith("ag:")) { - try { - const content = JSON.parse(line.substring(3)); - return { - type: "thinking", - content: typeof content === "string" ? content : content.thinking || "", - }; - } catch { - return null; - } - } else if (line.startsWith("a3:") || line.startsWith("ae:")) { - try { - const content = JSON.parse(line.substring(3)); - return { - type: "error", - content: typeof content === "string" ? content : content.error || JSON.stringify(content), - }; - } catch { - return { type: "error", content: line.substring(3) }; - } - } else if (line.startsWith("ad:")) { - return { type: "done" }; - } else if (line.startsWith("a2:")) { - return { type: "heartbeat" }; - } - return null; + return null; + }; + return fromObj(credentials) ?? fromObj(body); } export class LMArenaExecutor extends BaseExecutor { @@ -189,242 +83,135 @@ export class LMArenaExecutor extends BaseExecutor { _body: unknown ): Record { const cookie = readLMArenaCookie(credentials); - const headers: Record = { + const headers = buildLmarenaBrowserHeaders({ "Content-Type": "application/json", Accept: "text/event-stream", - "User-Agent": LMARENA_USER_AGENT, - Origin: LMARENA_API_BASE, - Referer: `${LMARENA_API_BASE}/`, - }; - - if (cookie) { - headers.Cookie = cookie; - } - + }); + if (cookie) headers.Cookie = cookie; return headers; } - protected transformRequest(body: unknown, model: string): unknown { - const openaiBody = body as Record; - const messages = openaiBody.messages as Array<{ role: string; content: string }>; - + protected transformRequest(body: unknown, model: string, credentials?: unknown): unknown { + const openaiBody = body && typeof body === "object" ? (body as Record) : {}; + const messages = Array.isArray(openaiBody.messages) + ? (openaiBody.messages as OpenAIMessage[]) + : []; return { - messages: messages.map((m) => ({ - role: m.role, - content: m.content, - })), - model, - stream: openaiBody.stream || false, + id: uuidv7(), + mode: "direct-battle", + modelAId: model, + userMessageId: uuidv7(), + modelAMessageId: uuidv7(), + userMessage: { + content: formatArenaPrompt(messages), + experimental_attachments: [], + metadata: {}, + }, + modality: "chat", + recaptchaV3Token: readRecaptchaToken(credentials, body), }; } async execute(input: ExecuteInput) { const { model, body, stream, credentials, signal, log } = input; - const url = this.buildUrl(model, credentials); const headers = this.buildHeaders(model, credentials, body); - const transformedBody = this.transformRequest(body, model); - const cookie = readLMArenaCookie(credentials); + if (!cookie) { - return { - response: new Response( - JSON.stringify({ - error: { - message: "LMArena requires a session cookie. Please provide cookie in credentials.", - type: "authentication_error", - code: "missing_cookie", - }, - }), - { status: 401, headers: { "Content-Type": "application/json" } } - ), - url, - headers, - transformedBody, - }; + return missingCookieResult(url, headers, this.transformRequest(body, model, credentials)); } - log?.info?.("LMArenaExecutor", `Executing request for model: ${model}`); + const arenaModelId = await resolveLMArenaModelId(model, log); + const transformedBody = this.transformRequest(body, arenaModelId, credentials) as Record< + string, + unknown + >; + + log?.info?.( + "LMArenaExecutor", + arenaModelId === model + ? `Executing request for model: ${model}` + : `Executing request for model: ${model} (${arenaModelId})` + ); try { - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(transformedBody), + return await this.dispatchTls(url, headers, transformedBody, { + model, + arenaModelId, + stream: !!stream, signal, + log, }); - - if (!response.ok) { - const errorText = await response.text(); - let errorMessage = `LMArena API error: ${response.status}`; - try { - const errorJson = JSON.parse(errorText); - errorMessage = errorJson.error?.message || errorJson.message || errorMessage; - } catch { - errorMessage = errorText || errorMessage; - } - - return { - response: new Response( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(errorMessage), - type: "api_error", - code: String(response.status), - }, - }), - { status: response.status, headers: { "Content-Type": "application/json" } } - ), - url, - headers, - transformedBody, - }; - } - - const upstreamResponse = stream - ? await this.handleStreamingResponse(response, model, log) - : await this.handleNonStreamingResponse(response, model, log); - - return { response: upstreamResponse, url, headers, transformedBody }; } catch (error) { + if (error instanceof TlsClientUnavailableError) { + log?.error?.("LMArenaExecutor", `TLS client unavailable: ${error.message}`); + return mapTlsUnavailable(error, url, headers, transformedBody); + } const message = error instanceof Error ? error.message : String(error); log?.error?.("LMArenaExecutor", `Request failed: ${message}`); - - return { - response: new Response( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(message), - type: "network_error", - code: "request_failed", - }, - }), - { status: 502, headers: { "Content-Type": "application/json" } } - ), - url, - headers, - transformedBody, - }; + return mapNetworkError(message, url, headers, transformedBody); } } + private async dispatchTls( + url: string, + headers: Record, + transformedBody: Record, + ctx: { + model: string; + arenaModelId: string; + stream: boolean; + signal?: AbortSignal; + log?: ExecuteInput["log"]; + } + ) { + const tlsResult = await tlsFetchLMArena(url, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal: ctx.signal, + stream: ctx.stream, + streamEofSymbol: "__OMNIROUTE_LMARENA_EOF_NEVER__", + }); + + const failed = mapFailedTlsResult({ + status: tlsResult.status, + text: tlsResult.text, + hasRecaptcha: transformedBody.recaptchaV3Token != null, + model: ctx.model, + arenaModelId: ctx.arenaModelId, + url, + headers, + transformedBody, + }); + if (failed) return failed; + + const upstream = buildArenaUpstreamHttpResponse({ + stream: ctx.stream, + status: tlsResult.status, + text: tlsResult.text, + body: tlsResult.body, + }); + + const response = ctx.stream + ? await this.handleStreamingResponse(upstream, ctx.model, ctx.signal, ctx.log) + : await handleNonStreamingArenaResponse(upstream, ctx.model); + + return { response, url, headers, transformedBody }; + } + private async handleStreamingResponse( response: Response, model: string, + signal?: AbortSignal, log?: ExecuteInput["log"] ): Promise { const reader = response.body?.getReader(); - if (!reader) { - throw new Error("No response body for streaming"); - } + if (!reader) throw new Error("No response body for streaming"); - const decoder = new TextDecoder(); - let buffer = ""; - let fullText = ""; - let fullThinking = ""; - - const stream = new ReadableStream({ - async start(controller) { - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.trim()) continue; - - const sseLine = line.startsWith("data: ") ? line.substring(6) : line; - const event = parseArenaSSE(sseLine); - - if (!event) continue; - - if (event.type === "text" && event.content) { - fullText += event.content; - const chunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: { content: event.content }, - finish_reason: null, - }, - ], - }; - controller.enqueue(`data: ${JSON.stringify(chunk)}\n\n`); - } else if (event.type === "thinking" && event.content) { - fullThinking += event.content; - } else if (event.type === "error") { - const errorChunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: "stop", - }, - ], - error: { message: event.content }, - }; - controller.enqueue(`data: ${JSON.stringify(errorChunk)}\n\n`); - controller.close(); - return; - } else if (event.type === "done") { - const finalChunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: "stop", - }, - ], - }; - controller.enqueue(`data: ${JSON.stringify(finalChunk)}\n\n`); - controller.enqueue("data: [DONE]\n\n"); - controller.close(); - return; - } - } - } - - const finalChunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: "stop", - }, - ], - }; - controller.enqueue(`data: ${JSON.stringify(finalChunk)}\n\n`); - controller.enqueue("data: [DONE]\n\n"); - controller.close(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log?.error?.("LMArenaExecutor", `Streaming error: ${message}`); - controller.error(error); - } - }, - }); - - return new Response(stream, { + const out = createOpenAIArenaStream({ reader, model, signal, log }); + return new Response(out, { status: 200, headers: { "Content-Type": "text/event-stream", @@ -433,79 +220,4 @@ export class LMArenaExecutor extends BaseExecutor { }, }); } - - private async handleNonStreamingResponse( - response: Response, - model: string, - log?: ExecuteInput["log"] - ): Promise { - const text = await response.text(); - const lines = text.split("\n"); - let fullText = ""; - let fullThinking = ""; - let error: string | null = null; - - for (const line of lines) { - if (!line.trim()) continue; - - const sseLine = line.startsWith("data: ") ? line.substring(6) : line; - const event = parseArenaSSE(sseLine); - - if (!event) continue; - - if (event.type === "text" && event.content) { - fullText += event.content; - } else if (event.type === "thinking" && event.content) { - fullThinking += event.content; - } else if (event.type === "error") { - error = event.content || "Unknown error"; - break; - } else if (event.type === "done") { - break; - } - } - - if (error) { - return new Response( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(error), - type: "api_error", - code: "lmarena_error", - }, - }), - { - status: 502, - headers: { "Content-Type": "application/json" }, - } - ); - } - - const result = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - message: { - role: "assistant", - content: fullText, - }, - finish_reason: "stop", - }, - ], - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }; - - return new Response(JSON.stringify(result), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } } diff --git a/open-sse/executors/lmarena/cookie.ts b/open-sse/executors/lmarena/cookie.ts new file mode 100644 index 0000000000..c1e62529a3 --- /dev/null +++ b/open-sse/executors/lmarena/cookie.ts @@ -0,0 +1,103 @@ +/** + * LMArena / arena.ai session cookie reconstruction. + * Supabase SSR splits `arena-auth-prod-v1` across `.0`, `.1`, … chunks. + */ + +export const LMARENA_AUTH_COOKIE = "arena-auth-prod-v1"; + +interface ParsedCookie { + name: string; + value: string; +} + +/** + * Parse a raw `Cookie:`-style blob (`name=value; name2=value2; …`) into an + * ordered list of name/value pairs. Whitespace around names is trimmed; values + * are kept verbatim (they may legitimately contain `=`, e.g. base64 padding). + */ +function parseCookieBlob(blob: string): ParsedCookie[] { + const pairs: ParsedCookie[] = []; + for (const part of blob.split(";")) { + const eq = part.indexOf("="); + if (eq < 0) continue; + const name = part.slice(0, eq).trim(); + if (!name) continue; + const value = part.slice(eq + 1).trim(); + pairs.push({ name, value }); + } + return pairs; +} + +/** + * Reconstruct LMArena's single `arena-auth-prod-v1` auth cookie from the + * Supabase SSR chunked form. + * + * - Non-empty single cookie → returned unchanged (pre-migration back-compat). + * - Otherwise join ascending `.N` chunks (no base64-decode / no JSON-parse). + * - Neither single nor chunks → raw blob returned for the missing-cookie path. + */ +export function reconstructLMArenaCookie(rawCookie: string): string { + if (!rawCookie || !rawCookie.trim()) return rawCookie; + + const pairs = parseCookieBlob(rawCookie); + + const existing = pairs.find((p) => p.name === LMARENA_AUTH_COOKIE); + if (existing && existing.value) return rawCookie; + + const chunkPrefix = `${LMARENA_AUTH_COOKIE}.`; + const chunks = new Map(); + for (const { name, value } of pairs) { + if (!name.startsWith(chunkPrefix)) continue; + const idxRaw = name.slice(chunkPrefix.length); + if (!/^\d+$/.test(idxRaw)) continue; + chunks.set(Number(idxRaw), value); + } + + const joinedParts: string[] = []; + for (let i = 0; chunks.has(i); i++) { + joinedParts.push(chunks.get(i) ?? ""); + } + const joined = joinedParts.join(""); + if (!joined) return rawCookie; + + const preserved = pairs.filter( + (p) => p.name !== LMARENA_AUTH_COOKIE && !p.name.startsWith(chunkPrefix) + ); + return [`${LMARENA_AUTH_COOKIE}=${joined}`, ...preserved.map((p) => `${p.name}=${p.value}`)].join( + "; " + ); +} + +function buildLMArenaCookieFromStoredFields(data: Record): string { + const pairs: string[] = []; + for (const [name, value] of Object.entries(data)) { + if (name !== LMARENA_AUTH_COOKIE && !name.startsWith(`${LMARENA_AUTH_COOKIE}.`)) { + continue; + } + if (typeof value !== "string" || !value.trim()) continue; + pairs.push(`${name}=${value.trim()}`); + } + + if (pairs.length === 0) return ""; + return reconstructLMArenaCookie(pairs.join("; ")); +} + +export function readLMArenaCookie(credentials: unknown): string { + if (!credentials || typeof credentials !== "object") return ""; + const c = credentials as Record; + const direct = typeof c.cookie === "string" ? c.cookie : ""; + if (direct.trim()) return reconstructLMArenaCookie(direct); + const apiKey = typeof c.apiKey === "string" ? c.apiKey : ""; + if (apiKey.trim()) return reconstructLMArenaCookie(apiKey); + const topLevelChunks = buildLMArenaCookieFromStoredFields(c); + if (topLevelChunks) return topLevelChunks; + const psd = c.providerSpecificData; + if (psd && typeof psd === "object") { + const nestedData = psd as Record; + const nested = nestedData.cookie; + if (typeof nested === "string" && nested.trim()) return reconstructLMArenaCookie(nested); + const nestedChunks = buildLMArenaCookieFromStoredFields(nestedData); + if (nestedChunks) return nestedChunks; + } + return ""; +} diff --git a/open-sse/executors/lmarena/models.ts b/open-sse/executors/lmarena/models.ts new file mode 100644 index 0000000000..fbfe277809 --- /dev/null +++ b/open-sse/executors/lmarena/models.ts @@ -0,0 +1,307 @@ +/** + * LMArena live model list parsing, catalog normalization, and name→UUID resolution. + */ + +export const LMARENA_API_BASE = "https://arena.ai"; +export const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream/create-evaluation`; +/** + * Current Chrome stable UA (header surface). + * TLS JA3 profile is separate: tls-client-node tops out at chrome_146 — see + * LMARENA_PROFILE in lmarenaTlsClient.ts. Headers track the live browser string; + * fingerprint stays at the newest native profile we can actually impersonate. + */ +export const LMARENA_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; +export const LMARENA_MODEL_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Browser-like CORS headers for arena.ai same-origin API calls. */ +export function buildLmarenaBrowserHeaders(extra?: Record): Record { + return { + Accept: "text/event-stream, application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + Pragma: "no-cache", + Origin: LMARENA_API_BASE, + Referer: `${LMARENA_API_BASE}/`, + "Sec-Ch-Ua": '"Chromium";v="150", "Google Chrome";v="150", "Not-A.Brand";v="24"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"Windows"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": LMARENA_USER_AGENT, + ...extra, + }; +} + +export interface LMArenaModelMetadata { + id?: string; + publicName?: string; + name?: string; + displayName?: string; + organization?: string; + provider?: string; + userSelectable?: boolean; + rank?: number; + rankByModality?: Record; + capabilities?: { + inputCapabilities?: Record; + outputCapabilities?: Record; + }; +} + +// Live arena.ai HTML discovery is intentionally disabled. Catalog + UUID map +// come from the Direct-chat scrape seed (registry/lmarena/directModels.ts). + +function stripLMArenaModelPrefix(model: string): string { + return model.replace(/^(?:lmarena|lma|arena)\//i, "").trim(); +} + +function normalizeModelName(model: string): string { + return model.trim().toLowerCase(); +} + +function hasLMArenaCapability( + entry: LMArenaModelMetadata, + direction: "input" | "output", + key: string +): boolean { + const capabilities = + direction === "input" + ? entry.capabilities?.inputCapabilities + : entry.capabilities?.outputCapabilities; + return capabilities?.[key] === true; +} + +/** + * Arena ships hundreds of initialModels rows; many are webdev-only, hidden, + * unranked sentinels (chat rank = MAX_SAFE_INTEGER), or UUID twins that 404 on + * create-evaluation. Keep the catalog to chat-usable, ranked, selectable rows. + */ +const LMARENA_MAX_REASONABLE_CHAT_RANK = 100_000; +/** Soft cap after dedupe — Arena UI only surfaces ~100–130 chat models. */ +export const LMARENA_CATALOG_SOFT_CAP = 120; + +const deadCatalogKeys = new Map(); +const DEAD_CATALOG_TTL_MS = 6 * 60 * 60 * 1000; + +function deadKey(value: string): string { + return value.trim().toLowerCase(); +} + +/** Remember a model id/publicName that 404/502'd so the next catalog import drops it. */ +export function markLMArenaCatalogModelDead(idOrPublicName: string): void { + if (!idOrPublicName?.trim()) return; + deadCatalogKeys.set(deadKey(idOrPublicName), Date.now() + DEAD_CATALOG_TTL_MS); +} + +export function clearLMArenaDeadCatalogModels(): void { + deadCatalogKeys.clear(); +} + +function isMarkedDead(entry: LMArenaModelMetadata, publicId: string): boolean { + const now = Date.now(); + for (const key of [publicId, entry.id, entry.publicName, entry.name, entry.displayName]) { + if (!key) continue; + const exp = deadCatalogKeys.get(deadKey(key)); + if (exp === undefined) continue; + if (exp <= now) { + deadCatalogKeys.delete(deadKey(key)); + continue; + } + return true; + } + return false; +} + +function isLMArenaChatCatalogModel(entry: LMArenaModelMetadata): boolean { + if (entry.userSelectable === false) return false; + // Must resolve to a real Arena UUID for create-evaluation. + if (typeof entry.id !== "string" || !LMARENA_MODEL_ID_RE.test(entry.id)) return false; + + const chatRank = entry.rankByModality?.chat; + if (typeof chatRank !== "number" || !Number.isFinite(chatRank)) return false; + // Unranked / placeholder rows use huge sentinels and commonly 404 when probed. + if (chatRank >= LMARENA_MAX_REASONABLE_CHAT_RANK) return false; + + if (!hasLMArenaCapability(entry, "input", "text")) return false; + if (!hasLMArenaCapability(entry, "output", "text")) return false; + + // Prefer rows with a stable human slug (not bare UUID as the only label). + const publicId = getLMArenaPublicModelId(entry).trim(); + if (!publicId) return false; + if (LMARENA_MODEL_ID_RE.test(publicId) && !entry.publicName && !entry.name) return false; + + return true; +} + +function lmarenaModelResolutionScore(entry: LMArenaModelMetadata): number { + let score = 0; + if (entry.userSelectable === false) score += 1_000_000; + if (!hasLMArenaCapability(entry, "input", "text")) score += 100_000; + if (!hasLMArenaCapability(entry, "output", "text")) score += 50_000; + + const chatRank = entry.rankByModality?.chat; + if (typeof chatRank === "number" && Number.isFinite(chatRank)) { + score += chatRank; + } else if (typeof entry.rank === "number" && Number.isFinite(entry.rank)) { + score += 10_000 + entry.rank; + } else { + score += 20_000; + } + + if (!entry.name) score += 500; + if (!entry.organization && !entry.provider) score += 100; + + return score; +} + +function getLMArenaPublicModelId(entry: LMArenaModelMetadata): string { + return entry.publicName || entry.displayName || entry.name || entry.id || ""; +} + +export function normalizeLMArenaModelsForCatalog(models: LMArenaModelMetadata[]): Array<{ + id: string; + name: string; + owned_by: string; + supportsVision?: boolean; + apiFormat: "chat-completions"; + supportedEndpoints: ["chat"]; +}> { + const bestByPublicId = new Map(); + + models.forEach((entry, index) => { + if (!isLMArenaChatCatalogModel(entry)) return; + const publicId = getLMArenaPublicModelId(entry).trim(); + if (!publicId) return; + if (isMarkedDead(entry, publicId)) return; + + const previous = bestByPublicId.get(publicId); + if ( + !previous || + lmarenaModelResolutionScore(entry) < lmarenaModelResolutionScore(previous.entry) + ) { + bestByPublicId.set(publicId, { entry, index }); + } + }); + + return Array.from(bestByPublicId.entries()) + .sort( + ([, a], [, b]) => + lmarenaModelResolutionScore(a.entry) - lmarenaModelResolutionScore(b.entry) || + a.index - b.index + ) + .slice(0, LMARENA_CATALOG_SOFT_CAP) + .map(([id, { entry }]) => ({ + id, + name: entry.displayName || entry.publicName || entry.name || id, + owned_by: entry.organization || entry.provider || "lmarena", + ...(hasLMArenaCapability(entry, "input", "image") ? { supportsVision: true } : {}), + apiFormat: "chat-completions" as const, + supportedEndpoints: ["chat"] as const, + })); +} + +export function pickLMArenaModelId(model: string, models: LMArenaModelMetadata[]): string { + const requested = stripLMArenaModelPrefix(model); + if (LMARENA_MODEL_ID_RE.test(requested)) return requested; + + const normalized = normalizeModelName(requested); + const matches = models + .map((entry, index) => ({ entry, index })) + // Only map onto chat-catalog-quality rows — avoids binding a public name to a + // webdev-only / unranked twin UUID that 404s on create-evaluation. + .filter(({ entry }) => isLMArenaChatCatalogModel(entry)) + .filter(({ entry }) => + [entry.id, entry.publicName, entry.name, entry.displayName].some( + (candidate) => typeof candidate === "string" && normalizeModelName(candidate) === normalized + ) + ); + const match = matches.sort( + (a, b) => + lmarenaModelResolutionScore(a.entry) - lmarenaModelResolutionScore(b.entry) || + a.index - b.index + )[0]?.entry; + + return match?.id || requested; +} + +export function parseLMArenaInitialModels(html: string): LMArenaModelMetadata[] { + const escapedMarker = '\\"initialModels\\":['; + const plainMarker = '"initialModels":['; + const marker = html.includes(escapedMarker) ? escapedMarker : plainMarker; + const markerIndex = html.indexOf(marker); + if (markerIndex < 0) return []; + + const arrayStart = markerIndex + marker.length - 1; + const escapedEnd = '],\\"initialModelAId\\"'; + const plainEnd = '],"initialModelAId"'; + const arrayEnd = html.indexOf(escapedEnd, arrayStart); + const fallbackEnd = html.indexOf(plainEnd, arrayStart); + const endIndex = arrayEnd >= 0 ? arrayEnd : fallbackEnd; + if (endIndex < 0 || endIndex < arrayStart) return []; + + const rawArray = html.slice(arrayStart, endIndex + 1).replace(/\\"/g, '"'); + try { + const parsed = JSON.parse(rawArray); + return Array.isArray(parsed) ? (parsed as LMArenaModelMetadata[]) : []; + } catch { + return []; + } +} + +type LogFn = { + debug?: (scope: string, msg: string) => void; + warn?: (scope: string, msg: string) => void; +}; + +/** Static Direct-chat allowlist only — no arena.ai network call. */ +export async function getLMArenaModels(log?: LogFn): Promise { + const { LMARENA_DIRECT_MODEL_ENTRIES } = + await import("../../config/providers/registry/lmarena/directModels.ts"); + // Chat path only — Image rows live in IMAGE_PROVIDERS (imageRegistry). + const models: LMArenaModelMetadata[] = LMARENA_DIRECT_MODEL_ENTRIES.filter( + (m) => m.category === "Text" || m.category === "Search" + ).map((m) => ({ + id: m.arenaId, + publicName: m.catalogId, + name: m.publicName, + displayName: m.displayName, + organization: m.organization, + userSelectable: true, + capabilities: { + inputCapabilities: { text: true, ...(m.vision ? { image: true } : {}) }, + outputCapabilities: { + text: true, + ...(m.category === "Search" ? { web: true } : {}), + }, + }, + rankByModality: { chat: 1 }, + })); + log?.debug?.( + "LMArenaExecutor", + `Using static Direct-chat catalog (${models.length} Text/Search models; Image in imageRegistry)` + ); + return models; +} + +export async function resolveLMArenaModelId(model: string, log?: LogFn): Promise { + const requested = stripLMArenaModelPrefix(model); + if (LMARENA_MODEL_ID_RE.test(requested)) return requested; + + try { + const { resolveLmarenaArenaId } = + await import("../../config/providers/registry/lmarena/directModels.ts"); + const fromSeed = resolveLmarenaArenaId(requested); + if (fromSeed) return fromSeed; + return pickLMArenaModelId(requested, await getLMArenaModels(log)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.warn?.( + "LMArenaExecutor", + `Using raw model id after static catalog lookup failed: ${message}` + ); + return requested; + } +} diff --git a/open-sse/executors/lmarena/response.ts b/open-sse/executors/lmarena/response.ts new file mode 100644 index 0000000000..64aef907c9 --- /dev/null +++ b/open-sse/executors/lmarena/response.ts @@ -0,0 +1,305 @@ +/** + * Response mapping helpers for the Arena (lmarena) executor — kept small so + * the executor methods stay under complexity / max-lines gates. + */ +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { isCloudflareChallenge } from "../../services/lmarenaTlsClient.ts"; +import { markLMArenaCatalogModelDead } from "./models.ts"; +import { parseArenaSSE } from "./stream.ts"; + +export function errorResponse( + status: number, + message: string, + type: string, + code: string +): Response { + return new Response( + JSON.stringify({ + error: { message: sanitizeErrorMessage(message), type, code }, + }), + { status, headers: { "Content-Type": "application/json" } } + ); +} + +export function missingCookieResult( + url: string, + headers: Record, + transformedBody: unknown +) { + return { + response: errorResponse( + 401, + "Arena requires a session cookie. Paste the full Cookie header from arena.ai (include arena-auth-prod-v1.* chunks and ideally cf_clearance).", + "authentication_error", + "missing_cookie" + ), + url, + headers, + transformedBody, + }; +} + +function parseArenaErrorBody(text: string | null | undefined, status: number): string { + const fallback = `Arena API error: ${status}`; + if (!text) return fallback; + try { + const errorJson = JSON.parse(text) as { error?: { message?: string }; message?: string }; + return errorJson.error?.message || errorJson.message || fallback; + } catch { + return text.slice(0, 500) || fallback; + } +} + +function isBotOrChallenge(status: number, text: string | null | undefined): boolean { + if (status === 403) return true; + if (isCloudflareChallenge(text)) return true; + return Boolean(text && text.trimStart().startsWith("; + transformedBody: unknown; +}) { + const { status, text, hasRecaptcha, model, arenaModelId, url, headers, transformedBody } = opts; + if (isBotOrChallenge(status, text)) { + return { + response: errorResponse( + status || 403, + botBlockMessage(text, hasRecaptcha, status), + "api_error", + "cloudflare_or_bot" + ), + url, + headers, + transformedBody, + }; + } + if (status >= 200 && status < 300) return null; + + if (status === 404 || status === 410 || status === 502) { + markLMArenaCatalogModelDead(model); + markLMArenaCatalogModelDead(arenaModelId); + } + return { + response: errorResponse(status, parseArenaErrorBody(text, status), "api_error", String(status)), + url, + headers, + transformedBody, + }; +} + +export function mapTlsUnavailable( + error: Error, + url: string, + headers: Record, + transformedBody: unknown +) { + return { + response: errorResponse( + 502, + `Arena TLS impersonation unavailable: ${error.message}. Install/repair tls-client-node native binary.`, + "upstream_error", + "TLS_CLIENT_UNAVAILABLE" + ), + url, + headers, + transformedBody, + }; +} + +export function mapNetworkError( + message: string, + url: string, + headers: Record, + transformedBody: unknown +) { + return { + response: errorResponse(502, message, "network_error", "request_failed"), + url, + headers, + transformedBody, + }; +} + +export function buildArenaUpstreamHttpResponse(opts: { + stream: boolean; + status: number; + text: string | null; + body: ReadableStream | null; +}): Response { + const { stream, status, text, body } = opts; + if (stream && body) { + return new Response(body, { + status, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return new Response(text ?? "", { + status, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +function baseChunk(model: string) { + return { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + }; +} + +function enqueueSse(controller: ReadableStreamDefaultController, chunk: Record) { + controller.enqueue(`data: ${JSON.stringify(chunk)}\n\n`); +} + +function emitStopAndDone(controller: ReadableStreamDefaultController, model: string) { + enqueueSse(controller, { + ...baseChunk(model), + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + controller.enqueue("data: [DONE]\n\n"); + controller.close(); +} + +/** Process one Arena SSE line into OpenAI chunk writes. Returns true if stream should end. */ +function handleArenaEventLine( + sseLine: string, + model: string, + controller: ReadableStreamDefaultController +): boolean { + const event = parseArenaSSE(sseLine); + if (!event) return false; + if (event.type === "text" && event.content) { + enqueueSse(controller, { + ...baseChunk(model), + choices: [{ index: 0, delta: { content: event.content }, finish_reason: null }], + }); + return false; + } + if (event.type === "error") { + enqueueSse(controller, { + ...baseChunk(model), + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + error: { message: sanitizeErrorMessage(event.content || "Unknown error") }, + }); + controller.close(); + return true; + } + if (event.type === "done") { + emitStopAndDone(controller, model); + return true; + } + return false; +} + +export function createOpenAIArenaStream(opts: { + reader: ReadableStreamDefaultReader; + model: string; + signal?: AbortSignal; + log?: { error?: (scope: string, msg: string) => void }; +}): ReadableStream { + const { reader, model, signal, log } = opts; + const decoder = new TextDecoder(); + let buffer = ""; + + const onAbort = () => { + void reader.cancel().catch(() => undefined); + }; + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + + return new ReadableStream({ + async start(controller) { + try { + while (true) { + if (signal?.aborted) { + await reader.cancel().catch(() => undefined); + controller.close(); + return; + } + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + const sseLine = line.startsWith("data: ") ? line.substring(6) : line; + if (handleArenaEventLine(sseLine, model, controller)) return; + } + } + emitStopAndDone(controller, model); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.error?.("LMArenaExecutor", `Streaming error: ${message}`); + controller.error(error); + } finally { + if (signal) signal.removeEventListener("abort", onAbort); + } + }, + cancel() { + void reader.cancel().catch(() => undefined); + if (signal) signal.removeEventListener("abort", onAbort); + }, + }); +} + +export async function handleNonStreamingArenaResponse( + response: Response, + model: string +): Promise { + const text = await response.text(); + let fullText = ""; + let error: string | null = null; + + for (const line of text.split("\n")) { + if (!line.trim()) continue; + const sseLine = line.startsWith("data: ") ? line.substring(6) : line; + const event = parseArenaSSE(sseLine); + if (!event) continue; + if (event.type === "text" && event.content) fullText += event.content; + else if (event.type === "error") { + error = event.content || "Unknown error"; + break; + } else if (event.type === "done") break; + } + + if (error) return errorResponse(502, error, "api_error", "lmarena_error"); + + return new Response( + JSON.stringify({ + id: `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: fullText }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); +} diff --git a/open-sse/executors/lmarena/stream.ts b/open-sse/executors/lmarena/stream.ts new file mode 100644 index 0000000000..0f30482e4e --- /dev/null +++ b/open-sse/executors/lmarena/stream.ts @@ -0,0 +1,132 @@ +/** + * Arena/AI-SDK SSE line parsing and OpenAI message → Arena prompt formatting. + */ + +export interface ArenaSSEEvent { + type: "text" | "thinking" | "error" | "done" | "heartbeat"; + content?: string; +} + +function parseJsonValue(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +function pickString(value: unknown, keys: string[]): string { + if (typeof value === "string") return value; + if (!value || typeof value !== "object") return ""; + const data = value as Record; + for (const key of keys) { + const candidate = data[key]; + if (typeof candidate === "string") return candidate; + } + return JSON.stringify(value); +} + +function normalizeArenaSSELine(payload: string): string { + const participantPrefixed = payload.match(/^[ab]([023dfg]):(.*)$/); + if (!participantPrefixed) return payload; + return `${participantPrefixed[1]}:${participantPrefixed[2]}`; +} + +export function parseArenaSSE(line: string): ArenaSSEEvent | null { + const trimmed = line.trim(); + const payload = trimmed.startsWith("data: ") ? trimmed.substring(6).trim() : trimmed; + if (!payload) return null; + + // Historical Arena platform errors used `ae:`. Current AI SDK `e:` is + // finish_step and not terminal, so only treat it as an error when it carries + // an obvious error payload. + const legacyError = payload.match(/^[ab]e:(.*)$/); + if (legacyError) { + const value = parseJsonValue(legacyError[1] ?? ""); + const content = pickString(value, ["error", "message"]); + return content ? { type: "error", content } : null; + } + + const normalized = normalizeArenaSSELine(payload); + const separator = normalized.indexOf(":"); + if (separator < 0) return null; + + const code = normalized.slice(0, separator); + const rawValue = normalized.slice(separator + 1); + const value = parseJsonValue(rawValue); + + switch (code) { + case "0": + return { type: "text", content: pickString(value, ["text", "textDelta"]) }; + case "g": + return { type: "thinking", content: pickString(value, ["thinking", "text", "textDelta"]) }; + case "2": + return { type: "heartbeat" }; + case "3": + return { type: "error", content: pickString(value, ["error", "message"]) }; + case "d": { + if ( + value && + typeof value === "object" && + (value as Record).finishReason === "error" + ) { + return { type: "error", content: "Arena stream finished with an error" }; + } + return { type: "done" }; + } + default: + return null; + } +} + +interface OpenAIMessage { + role?: string; + content?: unknown; +} + +function contentToText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") return part; + if (!part || typeof part !== "object") return ""; + const data = part as Record; + if (typeof data.text === "string") return data.text; + if (data.type === "image_url") return "[image]"; + return ""; + }) + .filter(Boolean) + .join("\n"); + } + if (content && typeof content === "object") { + const data = content as Record; + if (typeof data.text === "string") return data.text; + } + return content == null ? "" : String(content); +} + +export function formatArenaPrompt(messages: OpenAIMessage[]): string { + const rendered = messages + .map((message) => { + const text = contentToText(message.content).trim(); + if (!text) return ""; + const role = typeof message.role === "string" ? message.role : "user"; + const label = + role === "system" + ? "System" + : role === "assistant" + ? "Assistant" + : role === "developer" + ? "Developer" + : "User"; + return `${label}: ${text}`; + }) + .filter(Boolean); + + if (rendered.length === 1 && messages[0]?.role === "user") { + return contentToText(messages[0].content).trim(); + } + + return rendered.join("\n\n"); +} diff --git a/open-sse/handlers/chatCore/telemetryHelpers.ts b/open-sse/handlers/chatCore/telemetryHelpers.ts index c6b92e683c..805acbbd8c 100644 --- a/open-sse/handlers/chatCore/telemetryHelpers.ts +++ b/open-sse/handlers/chatCore/telemetryHelpers.ts @@ -2,7 +2,7 @@ import { fetchLiveProviderLimits } from "@/lib/usage/providerLimits"; import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage"; // #4604 — Lazy backoff for the best-effort live-WS sidecar bridge. In single-port -// deployments the sidecar (port 20129) is not running, so every compression event +// deployments the sidecar (port 20132) is not running, so every compression event // POST failed with ECONNREFUSED; because the global fetch is proxyFetch, each // failure logged a "[ProxyFetch] Undici dispatcher failed" warning (272× in 42min). // After a few consecutive failures we stop attempting for a cooldown window (then @@ -28,7 +28,7 @@ export async function forwardDashboardEventToLiveWs( // Skip while the bridge is in a cooldown window after repeated failures. if (liveWsDisabledUntil > now()) return; - const port = process.env.LIVE_WS_PORT || "20129"; + const port = process.env.LIVE_WS_PORT || "20132"; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 1_500); try { diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 92e7fb65b9..5a1e1888de 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -11,8 +11,10 @@ import { findMatchingErrorRule, matchErrorRuleByText, matchErrorRuleByStatus, + serviceSupervisorCooldown, } from "../config/errorConfig.ts"; import { getProviderErrorRuleMatch } from "../config/providerErrorRules.ts"; +import * as rot from "./rotationConfig.ts"; import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts"; import { DEFAULT_RESILIENCE_SETTINGS, @@ -1267,7 +1269,8 @@ export function checkFallbackError( provider: string | null = null, headers: Headers | Record | null = null, profileOverride: ProviderProfile | null = null, - structuredError?: { code?: string | null; type?: string | null } | null + structuredError?: { code?: string | null; type?: string | null } | null, + rotation?: { account?: unknown } | null ): { shouldFallback: boolean; cooldownMs: number; @@ -1287,27 +1290,10 @@ export function checkFallbackError( * caller can persist an explicit reset window instead of the engine's scaled cooldown. */ configuredCooldownMs?: number; } { - // G-02: detect embedded service supervisor failures (X-Omni-Fallback-Hint: connection_cooldown). - // These are NOT upstream AI provider failures — they are local supervisor state changes. - // Apply a short 5s connection cooldown without tripping the provider circuit breaker. - if (status === 503 && headers) { - const hintValue = - typeof (headers as Headers).get === "function" - ? (headers as Headers).get("x-omni-fallback-hint") - : (headers as Record)["x-omni-fallback-hint"] || - (headers as Record)["X-Omni-Fallback-Hint"]; - if (typeof hintValue === "string" && hintValue.toLowerCase() === "connection_cooldown") { - return { - shouldFallback: true, - cooldownMs: 5_000, - baseCooldownMs: 5_000, - newBackoffLevel: 0, - reason: "service_not_running", - skipProviderBreaker: true, - }; - } - } - + const svc = serviceSupervisorCooldown(status, headers); + if (svc) return svc; + const rg = rot.gateFor(status, rotation?.account); + if (rg) return rg; const errorStr = (errorText || "").toString(); const profile = profileOverride ?? (provider ? getProviderProfile(provider) : null); const maxBackoffSteps = profile?.maxBackoffSteps ?? BACKOFF_CONFIG.maxLevel; @@ -1396,6 +1382,8 @@ export function checkFallbackError( }; } + const ro = rot.overrideFor(reason, rotation?.account); + if (ro) return ro; const scaled = getScaledBaseCooldown(reason, backoffLevel); return { shouldFallback: true, @@ -1761,13 +1749,15 @@ export function resetAccountState( export function applyErrorState( account: T, status: number, - errorText: string | null, - provider: string | null = null + errText: string | null, + prov: string | null = null ): T | AccountState { if (!account) return account; - const backoffLevel = account.backoffLevel || 0; - const fallbackDecision = checkFallbackError(status, errorText, backoffLevel, null, provider); + const lvl = account.backoffLevel || 0; + const fallbackDecision = checkFallbackError(status, errText, lvl, null, prov, null, null, null, { + account, + }); const { cooldownMs, reason } = fallbackDecision; const newBackoffLevel = "newBackoffLevel" in fallbackDecision ? fallbackDecision.newBackoffLevel : undefined; @@ -1789,8 +1779,8 @@ export function applyErrorState( const nextState: T | AccountState = { ...account, rateLimitedUntil: effectiveCooldownMs > 0 ? getUnavailableUntil(effectiveCooldownMs) : null, - backoffLevel: newBackoffLevel ?? backoffLevel, - lastError: { status, message: errorText, timestamp: new Date().toISOString(), reason }, + backoffLevel: newBackoffLevel ?? lvl, + lastError: { status, message: errText, timestamp: new Date().toISOString(), reason }, status: "error", }; diff --git a/open-sse/services/kiroModels.ts b/open-sse/services/kiroModels.ts index 89680655d4..717e8bac66 100644 --- a/open-sse/services/kiroModels.ts +++ b/open-sse/services/kiroModels.ts @@ -27,6 +27,8 @@ import { createHash } from "node:crypto"; import { v4 as uuidv4 } from "uuid"; +import { resolveKiroRuntimeRegion } from "./kiroRegion.ts"; + type RawRecord = Record; const KIRO_RUNTIME_SDK_VERSION = "1.0.0"; @@ -185,20 +187,15 @@ function expandKiroModels(data: unknown): KiroModel[] { } /** - * Derive the AWS region for a Kiro connection. Mirrors getKiroUsage: prefer the - * stored region, then the region embedded in the profileArn, else us-east-1. + * Derive the RUNTIME AWS region for a Kiro connection's model discovery. Delegates to the shared + * resolver: the profileArn region wins (that is where the Q Developer profile + ListAvailableModels + * live — us-east-1 / eu-central-1), then a valid stored profile region, else us-east-1. The IdC + * token region (e.g. eu-north-1) is deliberately not used as a runtime region. */ export function resolveKiroRegion(providerSpecificData: unknown): string { - const psd = asRecord(providerSpecificData); - const explicit = toNonEmptyString(psd.region); - if (explicit) return explicit.toLowerCase(); - - const profileArn = toNonEmptyString(psd.profileArn); - const fromArn = profileArn - ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] - : undefined; - - return fromArn || "us-east-1"; + return resolveKiroRuntimeRegion( + asRecord(providerSpecificData) as { region?: unknown; profileArn?: unknown } + ); } /** diff --git a/open-sse/services/kiroRegion.ts b/open-sse/services/kiroRegion.ts new file mode 100644 index 0000000000..e87495cf33 --- /dev/null +++ b/open-sse/services/kiroRegion.ts @@ -0,0 +1,185 @@ +/** + * Shared Amazon Q Developer (Kiro / AWS CodeWhisperer) region resolution. + * + * TWO DISTINCT REGIONS — verified against the AWS docs "Amazon Q Developer Pro Region support" + * ("Supported Regions for the Q Developer console and Q Developer profile"): + * + * • IdC / OIDC / token region — `providerSpecificData.region`. May be ANY of the ~30 IdC- + * supported AWS regions (us-east-1, us-west-2, ca-central-1, sa-east-1, eu-west-1/2/3, + * eu-central-1/2, eu-north-1, eu-south-1/2, ap-south-1/2, ap-east-1/2, ap-southeast-1..7, + * ap-northeast-1/2/3, me-central-1, me-south-1, af-south-1, il-central-1, …). Used ONLY for + * `oidc.{region}.amazonaws.com` token mint/refresh (see tokenRefresh.ts / oauth providers). + * • Q Developer PROFILE / RUNTIME region — where the `profileArn` lives and every CodeWhisperer + * runtime call is served (generateAssistantResponse, GetUsageLimits, ListAvailableModels, + * ListAvailableProfiles). AWS currently hosts the profile ONLY in us-east-1 and eu-central-1, + * REGARDLESS of the IdC region ("Regardless of the IAM Identity Center Region, data is stored + * in the Region where you create the Amazon Q Developer profile"). The AWS docs' own example: + * an IdC in us-west-1 → profile in us-east-1. + * + * Consequences enforced here: + * • The RUNTIME region is the region embedded in the `profileArn` (authoritative — whatever + * region AWS actually hosts the profile in), NOT the IdC region. Routing a runtime call to + * `q.{idcRegion}.amazonaws.com` for a non-profile IdC region (e.g. q.eu-north-1, which does + * not exist as a Q Developer runtime endpoint) is the root cause of the "Kiro IAM shows no + * limits + every request returns 502" failure. + * • profileArn discovery works for an IdC in ANY region: it probes the known profile regions + * (us-east-1 / eu-central-1) with the cross-region SSO token, AND the IdC's own region as a + * forward-compatible fallback (in case AWS ever co-locates or expands profile regions). The + * discovered ARN's region then drives every runtime call. + */ + +// Canonical AWS region shape — kept local (identical to AWS_REGION_PATTERN in +// src/lib/oauth/constants/oauth.ts) so this open-sse module has no cross-tree import just to +// validate a string. Guards against SSRF via region injection (GHSA-6mwv-4mrm-5p3m): the value +// is interpolated into upstream URLs. +export const AWS_REGION_PATTERN = /^[a-z]{2}-[a-z]+-\d{1,2}$/; + +/** + * Regions where the Amazon Q Developer *profile* is currently hosted (AWS docs: "Supported + * Regions for the Q Developer console and Q Developer profile"). These are the guaranteed + * discovery targets and the only regions trusted as a runtime fallback when no profileArn is + * known. The profileArn's own region is always honored above this list, so a future AWS + * profile-region expansion works automatically once an ARN is discovered. + */ +export const KIRO_PROFILE_REGIONS = ["us-east-1", "eu-central-1"] as const; + +/** + * CodeWhisperer / Amazon Q runtime host for a region. us-east-1 keeps the legacy + * codewhisperer.us-east-1 host (AWS Builder ID home region); other regions use the regional + * Amazon Q endpoint `q.{region}.amazonaws.com` — codewhisperer.{region}.amazonaws.com does not + * resolve for non-us-east-1 regions. + */ +export function kiroRuntimeHost(region: string): string { + return region === "us-east-1" + ? "https://codewhisperer.us-east-1.amazonaws.com" + : `https://q.${region}.amazonaws.com`; +} + +/** Extract the region from a CodeWhisperer profile ARN (`arn:aws:codewhisperer:{region}:...`). */ +export function regionFromKiroProfileArn(profileArn?: string | null): string | undefined { + if (typeof profileArn !== "string") return undefined; + return profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1]; +} + +function normalizeRegion(region: unknown): string { + return typeof region === "string" ? region.trim().toLowerCase() : ""; +} + +/** + * Resolve the RUNTIME region for CodeWhisperer / Amazon Q calls. + * + * Priority: + * 1. The region embedded in the `profileArn` — authoritative, this is where the Q Developer + * profile (and thus the runtime) actually lives. + * 2. A stored region ONLY when it is a valid Q Developer profile region (us-east-1 / + * eu-central-1). A stored IdC region that is not a Q profile region (e.g. eu-north-1) is + * deliberately IGNORED for runtime — it is a token/OIDC region, not a runtime region. + * 3. us-east-1 (CodeWhisperer home region) as the final fallback. + */ +export function resolveKiroRuntimeRegion( + providerSpecificData: { region?: unknown; profileArn?: unknown } | null | undefined +): string { + const fromArn = regionFromKiroProfileArn( + typeof providerSpecificData?.profileArn === "string" + ? providerSpecificData.profileArn + : undefined + ); + if (fromArn) return fromArn; + + const stored = normalizeRegion(providerSpecificData?.region); + if (stored && (KIRO_PROFILE_REGIONS as readonly string[]).includes(stored)) return stored; + + return "us-east-1"; +} + +/** + * Build the ordered list of regions to probe for `ListAvailableProfiles`. + * + * The Amazon Q Developer profile (and thus every runtime endpoint) is currently hosted only in + * KIRO_PROFILE_REGIONS (us-east-1 / eu-central-1) regardless of the IdC region, so those are + * probed FIRST — EU-first when the IdC region is in EMEA (eu-, af-, me-, il- prefixes) to + * minimize latency. The IdC/stored region is then appended as a forward-compatible fallback: if + * AWS ever co-locates the profile with the IdC, or expands the profile-region list, a same-region + * probe still finds it. It is only appended when it is a valid AWS region distinct from the known + * profile regions; probing a region with no profile simply returns nothing and we fall through. + * This makes discovery work for an IdC in ANY region (us-west-2, ap-southeast-2, me-central-1, + * af-south-1, …), not just eu-north-1. + */ +export function buildKiroProfileDiscoveryRegions(storedRegion?: string | null): string[] { + const stored = normalizeRegion(storedRegion); + const preferEu = /^(eu|af|me|il)-/.test(stored); + const regions: string[] = preferEu + ? ["eu-central-1", "us-east-1"] + : ["us-east-1", "eu-central-1"]; + + if (stored && AWS_REGION_PATTERN.test(stored) && !regions.includes(stored)) { + regions.push(stored); + } + return regions; +} + +async function listKiroProfileArnForRegion( + accessToken: string, + region: string, + fetchImpl: typeof fetch +): Promise { + // Defensive: region comes from a hardcoded allowlist here, but validate before it is + // interpolated into the runtime host (SSRF guard, GHSA-6mwv-4mrm-5p3m). + if (!AWS_REGION_PATTERN.test(region)) return undefined; + try { + const response = await fetchImpl(`${kiroRuntimeHost(region)}/`, { + method: "POST", + headers: { + "Content-Type": "application/x-amz-json-1.0", + Accept: "application/json", + "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ maxResults: 10 }), + // Never let a hung/region-mismatched profile lookup block login or the quota refresh. + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) return undefined; + + const data = (await response.json()) as { profiles?: unknown }; + const profiles = Array.isArray(data?.profiles) ? data.profiles : []; + // Prefer a profile whose ARN region matches the region we queried; else take the first. + const matched = + profiles.find((profile: unknown) => { + const arn = (profile as { arn?: unknown })?.arn; + return typeof arn === "string" && regionFromKiroProfileArn(arn) === region; + }) || profiles[0]; + const arn = (matched as { arn?: unknown })?.arn; + return typeof arn === "string" && arn.length > 0 ? arn : undefined; + } catch { + return undefined; + } +} + +/** + * Discover a Kiro/CodeWhisperer profile ARN by probing the Q Developer profile regions + * (us-east-1 / eu-central-1) AND the IdC/stored region with the account's access token. The SSO + * bearer token minted from the IdC region works cross-region against the Q Developer profile's + * region (AWS's documented multi-region IdC ⇄ profile setup), so an IdC in ANY region resolves. + * Returns the first ARN found (its embedded region is the authoritative runtime region), or + * undefined when no profile is available (e.g. AWS Builder ID accounts, or an org/token with no + * Kiro entitlement). Best-effort: never throws. + */ +export async function discoverKiroProfileArnAcrossRegions( + accessToken: string | null | undefined, + storedRegion?: string | null, + fetchImpl?: typeof fetch +): Promise { + const token = typeof accessToken === "string" ? accessToken.trim() : ""; + if (!token) return undefined; + + // Resolve fetch at call time (not module-load) so callers/tests that swap globalThis.fetch + // are honored when no explicit implementation is injected. + const doFetch = fetchImpl ?? globalThis.fetch; + + for (const region of buildKiroProfileDiscoveryRegions(storedRegion)) { + const arn = await listKiroProfileArnForRegion(token, region, doFetch); + if (arn) return arn; + } + return undefined; +} diff --git a/open-sse/services/lmarenaTlsClient.ts b/open-sse/services/lmarenaTlsClient.ts new file mode 100644 index 0000000000..896a6e7462 --- /dev/null +++ b/open-sse/services/lmarenaTlsClient.ts @@ -0,0 +1,605 @@ +/** + * Browser-TLS-impersonating HTTP client for arena.ai. + * + * Why this exists: LMArena sits behind Cloudflare Enterprise which pins + * `cf_clearance` to the client's TLS fingerprint (JA3/JA4) + HTTP/2 SETTINGS + * frame ordering. Node's Undici fetch presents an obvious "not a browser" + * handshake and gets challenged with a 403 even with a valid arena session + * cookie (and often a browser-minted `cf_clearance`). This module wraps + * `tls-client-node` (bogdanfinn/tls-client) to send a Chrome handshake instead. + * + * Mirrors `grokTlsClient.ts` / `perplexityTlsClient.ts` as an independent + * module so changes here cannot regress those production paths. + * + * Note: Arena may still require a browser-issued reCAPTCHA v3 token on + * create-evaluation; TLS alone is necessary but not always sufficient. + */ + +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { mkdtemp, open, unlink, rmdir, stat } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; + +let clientPromise: Promise | null = null; +let exitHookInstalled = false; + +// Newest Chrome JA3 profile shipped by tls-client-node (no chrome_147+ yet). +// HTTP User-Agent / Sec-Ch-Ua track Chrome 150 separately in models.ts. +const LMARENA_PROFILE = "chrome_146"; +// Fixed timeouts (same defaults as other TLS sidecars). No extra env knobs — +// env-doc-sync must not grow for provider-local constants. +const DEFAULT_TIMEOUT_MS = 60_000; +// Grace period added to the binding's wire-level timeout before our JS-level +// hard timeout fires. Under healthy operation `tls-client-node` honors +// `timeoutMilliseconds` and rejects on its own; the JS-level race only wins +// when the koffi-loaded native library is wedged (which the binding's own +// timer can't escape). +const HARD_TIMEOUT_GRACE_MS = 10_000; + +function installExitHook(): void { + if (exitHookInstalled) return; + exitHookInstalled = true; + const stop = async () => { + if (clientPromise === null) return; + try { + const c = (await clientPromise) as { stop?: () => Promise }; + await c.stop?.(); + } catch { + // ignore + } + }; + process.once("beforeExit", stop); + process.once("SIGINT", () => { + void stop(); + }); + process.once("SIGTERM", () => { + void stop(); + }); +} + +/** + * Drop the cached client so the next `getClient()` call respawns it. Called + * when a request observes the native binding has wedged — releasing the + * reference lets a fresh TLSClient (and a fresh koffi load) take over without + * a process restart. + */ +function resetClientCache(): void { + clientPromise = null; +} + +export class TlsClientHangError extends Error { + constructor(message: string) { + super(message); + this.name = "TlsClientHangError"; + } +} + +/** + * Race a `client.request()` promise against (a) a JS-level hard timeout and + * (b) the caller's abort signal. The native binding's `timeoutMilliseconds` + * already covers the wire path; this guards the case where the koffi binding + * itself deadlocks (observed after sustained load), where neither the + * binding's own timer nor a post-call `signal.aborted` re-check can recover. + */ +async function raceWithTimeout( + promise: Promise, + timeoutMs: number, + signal: AbortSignal | null | undefined +): Promise { + let timer: ReturnType | null = null; + let abortListener: (() => void) | null = null; + try { + const racers: Promise[] = [ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new TlsClientHangError( + `tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked` + ) + ); + }, timeoutMs); + }), + ]; + if (signal) { + racers.push( + new Promise((_, reject) => { + if (signal.aborted) { + reject(makeAbortError(signal)); + return; + } + abortListener = () => reject(makeAbortError(signal)); + signal.addEventListener("abort", abortListener, { once: true }); + }) + ); + } + return await Promise.race(racers); + } finally { + if (timer) clearTimeout(timer); + if (signal && abortListener) signal.removeEventListener("abort", abortListener); + } +} + +async function getClient(): Promise<{ + request: (url: string, opts: Record) => Promise; +}> { + if (!clientPromise) { + clientPromise = (async () => { + try { + const mod = await import("tls-client-node"); + const TLSClient = (mod as { TLSClient: new (opts?: Record) => unknown }) + .TLSClient; + // Native mode loads the shared library directly via koffi, avoiding the + // managed sidecar's localhost HTTP calls that OmniRoute's global fetch + // proxy patch interferes with. + const client = new TLSClient({ runtimeMode: "native" }) as { + start: () => Promise; + request: (url: string, opts: Record) => Promise; + }; + await client.start(); + + installExitHook(); + return client; + } catch (err) { + clientPromise = null; + const msg = err instanceof Error ? err.message : String(err); + throw new TlsClientUnavailableError( + `TLS impersonation client failed to start: ${msg}. ` + + `Verify tls-client-node is installed and its native binary downloaded.` + ); + } + })(); + } + return clientPromise as Promise<{ + request: (url: string, opts: Record) => Promise; + }>; +} + +interface TlsResponseLike { + status: number; + headers: Record; + body: string; // for non-streaming requests, the full response body + cookies?: Record; + text: () => Promise; + bytes: () => Promise; + json: () => Promise; +} + +export class TlsClientUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "TlsClientUnavailableError"; + } +} + +export interface TlsFetchOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + headers?: Record; + body?: string; + timeoutMs?: number; + signal?: AbortSignal | null; + /** + * If true, the response body is streamed to a temp file and exposed as a + * ReadableStream. Use for NDJSON streaming responses (the + * LMArena conversation endpoint). Otherwise, the full body is read into memory. + */ + stream?: boolean; + /** EOF marker the upstream sends to signal end of stream (default: "[DONE]"). */ + streamEofSymbol?: string; + /** + * Optional upstream proxy URL (`http://user:pass@host:port` or + * `socks5://...`). When set, the request is tunneled through this proxy + * before reaching arena.ai. + * + * Resolution order: + * 1. `options.proxyUrl` (per-call override from caller) + * 2. `process.env.OMNIROUTE_TLS_PROXY_URL` (single-flag opt-in) + * 3. `process.env.HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` (POSIX-standard fallback) + * + * The native `tls-client-node` binding does **not** consult Go's + * `http.ProxyFromEnvironment`, so the env vars need to be plumbed in here at + * the JS layer. + */ + proxyUrl?: string; +} + +import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; + +/** + * Resolve the proxy URL for a tls-client request. Per-call value wins; + * otherwise we use the standard proxy fetch resolution which reads from + * the dashboard AsyncLocalStorage context or falls back to env vars. + * + * Fail-closed: if resolution throws (e.g. a configured socks5 proxy with + * ENABLE_SOCKS5_PROXY=false), this rethrows rather than returning undefined — + * undefined would let the native binding connect directly and leak the real IP. + */ +function resolveProxyUrl(perCall: string | undefined): string | undefined { + return resolveTlsClientProxyUrl("https://arena.ai", perCall, resolveProxyForRequest); +} + +export interface TlsFetchResult { + status: number; + headers: Headers; + /** Full response body as text — only populated for non-streaming requests. */ + text: string | null; + /** Streaming body — only populated when options.stream === true. */ + body: ReadableStream | null; +} + +// Test-only injection point. Tests call __setTlsFetchOverrideForTesting() +// to replace the real TLS client with a mock; production never touches this. +let testOverride: ((url: string, options: TlsFetchOptions) => Promise) | null = + null; + +export function __setTlsFetchOverrideForTesting(fn: typeof testOverride): void { + testOverride = fn; +} + +function throwIfAborted(signal: AbortSignal | null | undefined): void { + if (signal?.aborted) throw makeAbortError(signal); +} + +function buildTlsRequestOptions(options: TlsFetchOptions): Record { + return { + method: options.method || "GET", + headers: options.headers || {}, + body: options.body, + tlsClientIdentifier: LMARENA_PROFILE, + timeoutMilliseconds: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + followRedirects: true, + withRandomTLSExtensionOrder: true, + // Plumb proxy via options — tls-client-node does not read HTTP_PROXY env. + proxyUrl: resolveProxyUrl(options.proxyUrl), + }; +} + +function hardTimeoutMs(options: TlsFetchOptions): number { + return (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS; +} + +async function tlsFetchNonStreaming( + client: { request: (url: string, opts: Record) => Promise }, + url: string, + requestOptions: Record, + options: TlsFetchOptions +): Promise { + let tlsResponse: TlsResponseLike; + try { + tlsResponse = await raceWithTimeout( + client.request(url, requestOptions), + hardTimeoutMs(options), + options.signal ?? null + ); + } catch (err) { + if (err instanceof TlsClientHangError) resetClientCache(); + throw err; + } + throwIfAborted(options.signal); + return { + status: tlsResponse.status, + headers: toHeaders(tlsResponse.headers), + text: tlsResponse.body, + body: null, + }; +} + +/** + * Make a single HTTP request to arena.ai with a Chrome-like TLS fingerprint. + * Throws TlsClientUnavailableError if the native binary failed to load. + */ +export async function tlsFetchLMArena( + url: string, + options: TlsFetchOptions = {} +): Promise { + if (testOverride) return testOverride(url, options); + throwIfAborted(options.signal); + const client = await getClient(); + throwIfAborted(options.signal); + + const requestOptions = buildTlsRequestOptions(options); + if (options.stream) { + return tlsFetchStreaming( + client, + url, + requestOptions, + options.streamEofSymbol, + options.signal ?? null, + hardTimeoutMs(options) + ); + } + return tlsFetchNonStreaming(client, url, requestOptions, options); +} + +function makeAbortError(signal: AbortSignal): Error { + const reason = signal.reason; + if (reason instanceof Error) return reason; + const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + return err; +} + +function toHeaders(raw: Record): Headers { + const h = new Headers(); + for (const [k, vs] of Object.entries(raw || {})) { + for (const v of vs) h.append(k, v); + } + return h; +} + +/** + * Returns true if the response body is a Cloudflare challenge/interstitial page + * rather than a real LMArena response. From VPS/datacenter IPs a valid cookie + * still gets a 403 "Request rejected by anti-bot rules." JSON; distinguishing + * it from a genuine auth failure lets the caller surface an actionable error + * (issue #3180). + * + * Exported so the executor and the connection validator share one detector. + */ +export function isCloudflareChallenge(text: string | null | undefined): boolean { + if (!text) return false; + return /just a moment|window\._cf_chl_opt|challenges\.cloudflare\.com|attention required|cf-chl/i.test( + text + ); +} + +// ─── Streaming via temp file ──────────────────────────────────────────────── +// tls-client-node's streaming primitive writes the response body chunk-by-chunk +// to a file path, terminating when the upstream sends `streamOutputEOFSymbol`. +// We tail the file from a worker and surface the bytes as a ReadableStream. + +async function tlsFetchStreaming( + client: { request: (url: string, opts: Record) => Promise }, + url: string, + requestOptions: Record, + eofSymbol = "[DONE]", + signal: AbortSignal | null = null, + hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS +): Promise { + const dir = await mkdtemp(join(tmpdir(), "LMArena-stream-")); + const path = join(dir, `${randomUUID()}.ndjson`); + + const streamOpts = { + ...requestOptions, + streamOutputPath: path, + streamOutputBlockSize: 1024, + streamOutputEOFSymbol: eofSymbol, + }; + + // Kick off the request without awaiting — tls-client writes the body to + // `path` chunk-by-chunk while the call runs. The Promise resolves when the + // request fully completes (full body written). Wrapping in raceWithTimeout + // guarantees this promise eventually settles even if the koffi binding + // wedges; on hang we reset the singleton so the next request respawns. + let resetOnHang = true; + const requestPromise = raceWithTimeout( + client.request(url, streamOpts), + hardTimeoutMs, + signal + ).catch((err: unknown) => { + if (resetOnHang && err instanceof TlsClientHangError) { + resetClientCache(); + resetOnHang = false; + } + // Re-throw so downstream consumers (waitForContent, tailFile) observe + // the rejection and surface it instead of treating the stream as having + // ended cleanly. + throw err; + }); + + // Wait for the file to exist AND have at least one byte. + const ready = await waitForContent(path, 5_000, requestPromise); + if (!ready) { + const r = await requestPromise.catch( + (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike + ); + await cleanupTempPath(path); + return { + status: r.status, + headers: toHeaders(r.headers), + text: r.body, + body: null, + }; + } + + // Peek at the first bytes to distinguish a genuine NDJSON stream from a + // Cloudflare challenge page or an HTML error response that tls-client-node + // streamed to the temp file with a 200 status. + const peek = await readFirstBytes(path, 256); + if (isCloudflareChallenge(peek)) { + await cleanupTempPath(path); + return { + status: 403, + headers: new Headers({ "Content-Type": "text/html" }), + text: peek, + body: null, + }; + } + if (peek.trimStart().startsWith("<")) { + // HTML error page (not a challenge) — surface as a non-2xx so the executor + // can emit a proper SSE error chunk instead of feeding HTML to the NDJSON + // parser. + await cleanupTempPath(path); + return { + status: 502, + headers: new Headers({ "Content-Type": "text/html" }), + text: peek, + body: null, + }; + } + + // Looks like NDJSON — start tailing. The requestPromise will eventually + // resolve with the real upstream status; tailFile propagates non-2xx errors + // into the stream so the consumer sees them instead of a truncated success. + const stream = tailFile(path, eofSymbol, requestPromise, signal); + const headers = new Headers({ + "Content-Type": "application/x-ndjson", + "Cache-Control": "no-cache", + }); + return { status: 200, headers, text: null, body: stream }; +} + +async function cleanupTempPath(path: string): Promise { + await unlink(path).catch(() => {}); + await rmdir(dirname(path)).catch(() => {}); +} + +async function readFirstBytes(path: string, n: number): Promise { + const fd = await open(path, "r"); + try { + const buf = Buffer.alloc(n); + const { bytesRead } = await fd.read(buf, 0, n, 0); + return buf.subarray(0, bytesRead).toString("utf8"); + } finally { + await fd.close().catch(() => {}); + } +} + +/** + * Wait for the streaming output file to exist AND contain at least one byte. + * Returns false if the request settles before any bytes arrive (so the caller + * can drain `requestPromise` and surface the real upstream status). Returns + * true as soon as the file has data — even one byte is enough for the NDJSON + * heuristic to give a useful answer. + */ +async function waitForContent( + path: string, + timeoutMs: number, + requestPromise: Promise +): Promise { + let requestSettled = false; + requestPromise.then( + () => { + requestSettled = true; + }, + () => { + requestSettled = true; + } + ); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const s = await stat(path); + if (s.size > 0) return true; + } catch { + // file doesn't exist yet + } + // If the request finished without producing any bytes, no point waiting + // out the rest of the timeout — let the caller drain it. + if (requestSettled) return false; + await sleep(25); + } + return false; +} + +/** Enqueue chunk bytes, splitting off an EOF symbol when present. Returns true if closed. */ +function enqueueChunkMaybeEof( + controller: ReadableStreamDefaultController, + chunk: Buffer, + eofSymbol: string +): boolean { + const text = chunk.toString("utf8"); + if (!text.includes(eofSymbol)) { + controller.enqueue(Buffer.from(chunk)); + return false; + } + const beforeEof = text.substring(0, text.indexOf(eofSymbol)); + if (beforeEof) controller.enqueue(Buffer.from(beforeEof, "utf8")); + controller.close(); + return true; +} + +type FileHandle = Awaited>; + +async function drainRemaining( + fd: FileHandle, + buf: Buffer, + offsetRef: { offset: number }, + controller: ReadableStreamDefaultController, + eofSymbol: string +): Promise<"closed" | "drained"> { + while (true) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); + if (bytesRead === 0) return "drained"; + const chunk = buf.subarray(0, bytesRead); + offsetRef.offset += bytesRead; + if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return "closed"; + } +} + +function tailFile( + path: string, + eofSymbol: string, + done: Promise, + signal: AbortSignal | null = null +): ReadableStream { + return new ReadableStream({ + async start(controller) { + const fd = await open(path, "r"); + const buf = Buffer.alloc(64 * 1024); + const offsetRef = { offset: 0 }; + let finished = false; + let aborted = false; + let upstreamError: Error | null = null; + let errored = false; + + done.then( + () => { + finished = true; + }, + (err) => { + upstreamError = err instanceof Error ? err : new Error(String(err)); + finished = true; + } + ); + + const onAbort = () => { + aborted = true; + }; + if (signal) { + if (signal.aborted) aborted = true; + else signal.addEventListener("abort", onAbort, { once: true }); + } + + try { + while (!aborted) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); + if (bytesRead > 0) { + const chunk = buf.subarray(0, bytesRead); + offsetRef.offset += bytesRead; + if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return; + } + + if (!finished) { + await sleep(25); + continue; + } + + const drained = await drainRemaining(fd, buf, offsetRef, controller, eofSymbol); + if (drained === "closed") return; + if (upstreamError && !errored) { + errored = true; + controller.error(upstreamError); + return; + } + controller.close(); + return; + } + } catch (err) { + if (!errored) { + errored = true; + controller.error(err instanceof Error ? err : new Error(String(err))); + } + } finally { + await fd.close().catch(() => {}); + await cleanupTempPath(path); + if (signal) signal.removeEventListener("abort", onAbort); + } + }, + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/open-sse/services/reasoningTokenBuffer.ts b/open-sse/services/reasoningTokenBuffer.ts index bbb67da335..8cce846d14 100644 --- a/open-sse/services/reasoningTokenBuffer.ts +++ b/open-sse/services/reasoningTokenBuffer.ts @@ -1,4 +1,7 @@ -import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts"; +import { + getExplicitModelOutputCap, + getResolvedModelCapabilities, +} from "../../src/lib/modelCapabilities.ts"; /** * Below this caller-supplied `max_tokens`, the request is treated as a probe @@ -34,17 +37,14 @@ export function resolveReasoningBufferedMaxTokens( const capabilities = getResolvedModelCapabilities(modelStr); if (capabilities.supportsThinking !== true) return null; - const maxOutputTokens = toPositiveInteger(capabilities.maxOutputTokens); + const maxOutputTokens = toPositiveInteger(getExplicitModelOutputCap(modelStr)); if (maxOutputTokens === null) return null; if (current > maxOutputTokens) return maxOutputTokens; - if (current === maxOutputTokens) return current; // Issue #6274: a tiny explicit budget is a capability probe, not a reasoning // request. Respect it verbatim instead of inflating (e.g. 1 -> 1001). if (current < REASONING_BUFFER_MIN_TRIGGER) return current; const buffered = Math.max(current + 1000, Math.ceil(current * 1.5)); - if (buffered > maxOutputTokens) return current; - - return buffered; + return buffered > maxOutputTokens ? current : buffered; } diff --git a/open-sse/services/rotationConfig.ts b/open-sse/services/rotationConfig.ts new file mode 100644 index 0000000000..42c47a92e9 --- /dev/null +++ b/open-sse/services/rotationConfig.ts @@ -0,0 +1,390 @@ +/** + * Runtime rotation configuration. + * + * OmniRoute's account-fallback engine (accountFallback.ts) historically rotated accounts using + * only hardcoded constants (COOLDOWN_MS / BACKOFF_CONFIG / ERROR_RULES): every retryable error + * cooled the account down immediately, on a fixed exponential backoff, with no operator control. + * + * A front-end/orchestrator (e.g. the VibeProxy desktop app) that manages the SAME set of accounts + * needs the backend to rotate according to the operator's own rules. This module exposes those + * rules as a runtime config, sourced from environment variables (so a supervising process can set + * them per launch) with an optional per-connection override (read from a connection's + * `providerSpecificData.rotationOverrides`). + * + * Config surface (all optional — defaults preserve the pre-existing engine behavior): + * - master enable OMNIROUTE_ROTATION_ENABLED (default true) + * - rate-limit reset/cooldown seconds OMNIROUTE_ROTATION_RATE_LIMIT_RESET_SECONDS (0 => engine default) + * - per-status fallback enable OMNIROUTE_ROTATE_ON_{429,500,502,400} (429/500/502 default true, 400 default false) + * - per-status threshold (errors in window) OMNIROUTE_ROTATE_{status}_THRESHOLD (default 1 => immediate, current behavior) + * - per-status window seconds OMNIROUTE_ROTATE_{status}_WINDOW_SECONDS (default 120) + * + * Everything here is a pure function or a small in-memory sliding-window counter — no DB / IO on + * the hot path — so it is cheap to consult per request and trivially unit-testable. + */ + +import { RateLimitReason } from "../config/constants.ts"; +import { COOLDOWN_MS } from "../config/errorConfig.ts"; + +export interface RotationErrorClassConfig { + enabled: boolean; + /** Number of errors of this class (within the window) required before an account is rotated. */ + threshold: number; + /** Sliding window (ms) over which errors are counted. */ + windowMs: number; +} + +export interface RotationConfig { + /** Master switch. When false, none of the configurable error classes trigger account fallback. */ + enabled: boolean; + /** Cooldown (ms) applied to a rate-limited account when the upstream gives no explicit hint. 0 => engine default. */ + rateLimitResetMs: number; + /** Mirror of the front-end "don't tag as rate-limited without a reset time" preference. */ + disableTagWithoutReset: boolean; + rateLimit429: RotationErrorClassConfig; + serverError500: RotationErrorClassConfig; + badGateway502: RotationErrorClassConfig; + badRequest400: RotationErrorClassConfig; +} + +const GLOBAL_KEY = "__omniroute_rotation_config__"; +const DEFAULT_WINDOW_MS = 120_000; + +function envBool(name: string, dflt: boolean): boolean { + const raw = process.env[name]; + if (raw === undefined || raw === null || raw === "") return dflt; + const v = raw.trim().toLowerCase(); + if (v === "true" || v === "1" || v === "yes" || v === "on") return true; + if (v === "false" || v === "0" || v === "no" || v === "off") return false; + return dflt; +} + +function envInt(name: string, dflt: number, min = 0): number { + const raw = process.env[name]; + if (raw === undefined || raw === null || raw === "") return dflt; + const n = Number.parseInt(raw.trim(), 10); + if (!Number.isFinite(n)) return dflt; + return Math.max(min, n); +} + +function buildClass( + enableEnv: string, + thresholdEnv: string, + windowEnv: string, + enableDefault: boolean +): RotationErrorClassConfig { + return { + enabled: envBool(enableEnv, enableDefault), + threshold: envInt(thresholdEnv, 1, 1), + windowMs: envInt(windowEnv, DEFAULT_WINDOW_MS / 1000, 1) * 1000, + }; +} + +function buildFromEnv(): RotationConfig { + return { + enabled: envBool("OMNIROUTE_ROTATION_ENABLED", true), + rateLimitResetMs: envInt("OMNIROUTE_ROTATION_RATE_LIMIT_RESET_SECONDS", 0, 0) * 1000, + disableTagWithoutReset: envBool("OMNIROUTE_ROTATION_DISABLE_TAG_WITHOUT_RESET", true), + rateLimit429: buildClass( + "OMNIROUTE_ROTATE_ON_429", + "OMNIROUTE_ROTATE_429_THRESHOLD", + "OMNIROUTE_ROTATE_429_WINDOW_SECONDS", + true + ), + serverError500: buildClass( + "OMNIROUTE_ROTATE_ON_500", + "OMNIROUTE_ROTATE_500_THRESHOLD", + "OMNIROUTE_ROTATE_500_WINDOW_SECONDS", + true + ), + badGateway502: buildClass( + "OMNIROUTE_ROTATE_ON_502", + "OMNIROUTE_ROTATE_502_THRESHOLD", + "OMNIROUTE_ROTATE_502_WINDOW_SECONDS", + true + ), + badRequest400: buildClass( + "OMNIROUTE_ROTATE_ON_400", + "OMNIROUTE_ROTATE_400_THRESHOLD", + "OMNIROUTE_ROTATE_400_WINDOW_SECONDS", + false + ), + }; +} + +/** + * The global (env-derived) rotation config, parsed once and cached on `globalThis` so the Next.js + * app-route module graph and the startup graph share one instance (same pattern as the other + * runtime-config singletons in this codebase). + */ +export function getGlobalRotationConfig(): RotationConfig { + const g = globalThis as Record; + let cfg = g[GLOBAL_KEY] as RotationConfig | undefined; + if (!cfg) { + cfg = buildFromEnv(); + g[GLOBAL_KEY] = cfg; + } + return cfg; +} + +/** Test/reset hook: clears the cached global config so the next read re-parses env. */ +export function resetGlobalRotationConfigForTest(): void { + const g = globalThis as Record; + delete g[GLOBAL_KEY]; + clearRotationErrorCounters(); +} + +function coerceBool(v: unknown, dflt: boolean): boolean { + if (typeof v === "boolean") return v; + if (typeof v === "string") return envBoolFromString(v, dflt); + return dflt; +} + +function envBoolFromString(v: string, dflt: boolean): boolean { + const s = v.trim().toLowerCase(); + if (s === "true" || s === "1" || s === "yes" || s === "on") return true; + if (s === "false" || s === "0" || s === "no" || s === "off") return false; + return dflt; +} + +function coerceInt(v: unknown, dflt: number, min = 0): number { + const n = typeof v === "number" ? v : typeof v === "string" ? Number.parseInt(v, 10) : NaN; + if (!Number.isFinite(n)) return dflt; + return Math.max(min, Math.floor(n)); +} + +/** + * Merges a connection's per-connection overrides (from + * `providerSpecificData.rotationOverrides`) over the global env config. Any absent override key + * inherits the global value. Returns the global config unchanged when there are no overrides. + */ +export function resolveRotationConfig(overrides?: Record | null): RotationConfig { + const base = getGlobalRotationConfig(); + if (!overrides || typeof overrides !== "object") return base; + + const cls = ( + src: RotationErrorClassConfig, + enableKey: string, + thrKey: string, + winKey: string + ): RotationErrorClassConfig => ({ + enabled: enableKey in overrides ? coerceBool(overrides[enableKey], src.enabled) : src.enabled, + threshold: thrKey in overrides ? coerceInt(overrides[thrKey], src.threshold, 1) : src.threshold, + windowMs: + winKey in overrides ? coerceInt(overrides[winKey], src.windowMs / 1000, 1) * 1000 : src.windowMs, + }); + + return { + enabled: base.enabled, + rateLimitResetMs: + "rateLimitResetSeconds" in overrides + ? coerceInt(overrides.rateLimitResetSeconds, base.rateLimitResetMs / 1000, 0) * 1000 + : base.rateLimitResetMs, + disableTagWithoutReset: base.disableTagWithoutReset, + rateLimit429: cls(base.rateLimit429, "rotateOn429", "error429Threshold", "error429WindowSeconds"), + serverError500: cls(base.serverError500, "rotateOn500", "error500Threshold", "error500WindowSeconds"), + badGateway502: cls(base.badGateway502, "rotateOn502", "error502Threshold", "error502WindowSeconds"), + badRequest400: cls(base.badRequest400, "rotateOn400", "error400Threshold", "error400WindowSeconds"), + }; +} + +/** Maps an HTTP status to its configured error class (or null for statuses this config doesn't gate). */ +export function classForStatus(status: number, cfg: RotationConfig): RotationErrorClassConfig | null { + if (status === 429) return cfg.rateLimit429; + if (status === 502) return cfg.badGateway502; + if (status >= 500 && status < 600) return cfg.serverError500; + if (status === 400) return cfg.badRequest400; + return null; // 401/402/403/404/… are not gated by this config +} + +/** + * True when the operator config should BLOCK account fallback for this status. + * + * This is RESTRICTIVE and applies only to the default-enabled classes (429 / 502 / other 5xx): + * when the operator disables one, fallback for it is blocked (the error returns to the client + * instead of rotating). 400 is NEVER restrictively blocked here — it is handled additively by + * {@link shouldForceFallbackFor400}, so the engine's existing 400 behavior (a 400 carrying + * rate-limit/quota text still falls over; a plain malformed 400 does not) is fully preserved. + * Statuses this config does not gate (401/403/404/…) are never blocked. + */ +export function isFallbackBlockedForStatus(status: number, cfg: RotationConfig): boolean { + const c = classForStatus(status, cfg); + if (c === null) return false; // ungated statuses: engine default + if (c === cfg.badRequest400) return false; // 400 is additive, never restrictively blocked + if (!cfg.enabled) return true; // master off blocks the gated 429/500/502 classes + return !c.enabled; // per-class disable +} + +/** True when the operator opted IN to rotating on a 400 (bad request) — off by default. */ +export function shouldForceFallbackFor400(status: number, cfg: RotationConfig): boolean { + return status === 400 && cfg.enabled && cfg.badRequest400.enabled; +} + +/** Rate-limit cooldown override (ms) or null to use the engine default. */ +export function rateLimitCooldownOverrideMs(cfg: RotationConfig): number | null { + return cfg.rateLimitResetMs > 0 ? cfg.rateLimitResetMs : null; +} + +// ── Sliding-window per-key error counter (for threshold-based fallback) ────────────────────── + +const COUNTER_KEY = "__omniroute_rotation_counters__"; + +function counters(): Map { + const g = globalThis as Record; + let m = g[COUNTER_KEY] as Map | undefined; + if (!m) { + m = new Map(); + g[COUNTER_KEY] = m; + } + return m; +} + +export function clearRotationErrorCounters(): void { + counters().clear(); +} + +/** + * Records an error for (key, status) and returns true when the number of errors within the class + * window reaches the configured threshold (i.e. the account should now be rotated). When the + * threshold is 1 (default) this returns true on the first error — preserving the engine's + * historical "rotate immediately" behavior. `nowMs` is injectable for tests. + */ +export function recordErrorAndCheckThreshold( + key: string, + status: number, + cfg: RotationConfig, + nowMs: number = Date.now() +): boolean { + const cls = classForStatus(status, cfg); + if (cls === null) return true; // not gated => defer to engine (treat as immediate) + if (cls.threshold <= 1) return true; // immediate rotation (historical behavior) + + const bucketKey = `${key}::${status}`; + const list = counters().get(bucketKey) ?? []; + const windowStart = nowMs - cls.windowMs; + const pruned = list.filter((ts) => ts >= windowStart); + pruned.push(nowMs); + counters().set(bucketKey, pruned); + + if (pruned.length >= cls.threshold) { + counters().delete(bucketKey); // reset after reaching the threshold + return true; + } + return false; +} + +// ── accountFallback.ts integration helpers ────────────────────────────────────────────────── +// These encapsulate the "runtime rotation config" glue that `checkFallbackError` / +// `applyErrorState` (open-sse/services/accountFallback.ts, a size-frozen file) consult before +// falling back to their own hardcoded heuristics. Keeping the glue here (rather than inline in +// accountFallback.ts) keeps that file's line budget stable as this config surface grows. + +export interface RotationGateDecision { + shouldFallback: boolean; + cooldownMs: number; + baseCooldownMs?: number; + newBackoffLevel?: number; + reason?: string; +} + +/** + * Evaluates the runtime rotation config gate for a given status BEFORE the engine's own error + * classification runs. Returns a decision that should short-circuit `checkFallbackError` + * (block fallback, hold pending threshold/window, or force-fallback an opted-in 400), or `null` + * when the engine should proceed with its normal heuristics. + */ +export function evaluateRotationGate( + status: number, + rotationCfg: RotationConfig, + rotationKey?: string | null +): RotationGateDecision | null { + if (isFallbackBlockedForStatus(status, rotationCfg)) { + return { shouldFallback: false, cooldownMs: 0, reason: RateLimitReason.UNKNOWN }; + } + if ( + rotationKey && + classForStatus(status, rotationCfg) && + !recordErrorAndCheckThreshold(rotationKey, status, rotationCfg) + ) { + return { shouldFallback: false, cooldownMs: 0, reason: RateLimitReason.UNKNOWN }; + } + if (shouldForceFallbackFor400(status, rotationCfg)) { + const overrideMs = rateLimitCooldownOverrideMs(rotationCfg); + const cooldownMs = overrideMs ?? COOLDOWN_MS.rateLimit; + return { + shouldFallback: true, + cooldownMs, + baseCooldownMs: cooldownMs, + newBackoffLevel: 0, + reason: RateLimitReason.RATE_LIMIT_EXCEEDED, + }; + } + return null; +} + +export interface RotationRateLimitFallback { + shouldFallback: true; + cooldownMs: number; + baseCooldownMs: number; + newBackoffLevel: 0; + usedUpstreamRetryHint: false; + reason: string; +} + +/** + * Operator-configured rate-limit cooldown override (no upstream retry hint available). Applies + * only to the rate-limit reason so 5xx / capacity errors keep their scaled exponential backoff. + * Returns `null` when the reason isn't rate-limit or no override is configured, in which case + * the caller should fall through to its own scaled-backoff calculation. + */ +export function rotationRateLimitFallback( + reason: string, + rotationCfg: RotationConfig +): RotationRateLimitFallback | null { + if (reason !== RateLimitReason.RATE_LIMIT_EXCEEDED) return null; + const overrideMs = rateLimitCooldownOverrideMs(rotationCfg); + if (overrideMs === null) return null; + return { + shouldFallback: true, + cooldownMs: overrideMs, + baseCooldownMs: overrideMs, + newBackoffLevel: 0, + usedUpstreamRetryHint: false, + reason, + }; +} + +/** Combines extractRotationContext + resolveRotationConfig + evaluateRotationGate for an account. */ +export function gateFor(status: number, account?: unknown): RotationGateDecision | null { + const { rotationOverrides, rotationKey } = extractRotationContext(account); + return evaluateRotationGate(status, resolveRotationConfig(rotationOverrides), rotationKey); +} + +/** Combines extractRotationContext + resolveRotationConfig + rotationRateLimitFallback for an account. */ +export function overrideFor(reason: string, account?: unknown): RotationRateLimitFallback | null { + const { rotationOverrides } = extractRotationContext(account); + return rotationRateLimitFallback(reason, resolveRotationConfig(rotationOverrides)); +} + +/** + * Extracts a connection's per-connection rotation overrides and rotation key from its account + * state (`providerSpecificData.rotationOverrides` and `id`). Both are optional — absent => + * global env config / count-immediately. `account` is typed `unknown` here because callers pass + * a generic `AccountState`-shaped value; this only does structural checks, no behavior change. + */ +export function extractRotationContext(account: unknown): { + rotationOverrides: Record | null; + rotationKey: string | null; +} { + const rec = account && typeof account === "object" ? (account as Record) : null; + const psd = rec ? rec["providerSpecificData"] : undefined; + const rotationOverrides = + psd && + typeof psd === "object" && + (psd as Record).rotationOverrides && + typeof (psd as Record).rotationOverrides === "object" + ? ((psd as Record).rotationOverrides as Record) + : null; + const id = rec ? rec["id"] : undefined; + const rotationKey = typeof id === "string" && id.length > 0 ? id : null; + return { rotationOverrides, rotationKey }; +} diff --git a/open-sse/services/usage/kiro.ts b/open-sse/services/usage/kiro.ts index b96b5041ca..6ed5f72ed6 100644 --- a/open-sse/services/usage/kiro.ts +++ b/open-sse/services/usage/kiro.ts @@ -13,6 +13,11 @@ import { toRecord, toNumber } from "./scalars.ts"; import { type UsageQuota, parseResetTime } from "./quota.ts"; +import { + discoverKiroProfileArnAcrossRegions, + kiroRuntimeHost, + resolveKiroRuntimeRegion, +} from "../kiroRegion.ts"; import { isExternalIdpAuthMethod, KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER, @@ -158,10 +163,20 @@ export async function discoverKiroProfileArn( } /** - * The three GetUsageLimits attempts (regional GET, CodeWhisperer POST, Q GET) tried in + * The three GetUsageLimits attempts (CodeWhisperer POST, regional GET, Q GET) tried in * order by getKiroUsage — extracted so the auth-method header variants (api_key * `tokentype`, external_idp `TokenType`) stay in one authHeaders object and the parent * function stays under the function-length gate. + * + * The POST variant (x-amz-json-1.0 + `x-amz-target: ...GetUsageLimits`) is tried FIRST: it + * is the canonical AWS JSON-RPC shape used everywhere else in the Kiro integration + * (discoverKiroProfileArn's ListAvailableProfiles call, kiroModels.ts fingerprinting) and, + * critically, it is the only variant whose URL is built from the RUNTIME-region-resolved + * `usageBaseUrl` (see resolveKiroRuntimeRegion in getKiroUsage) with the profileArn in the + * payload — required for cross-region IAM Identity Center accounts (#6099) where the profile + * lives in a different region than the IdC/token region. The two GET variants are + * best-effort fallbacks (added for #6587 API-key auth) for accounts/regions where the POST + * shape is rejected. */ function buildKiroUsageAttempts(opts: { authHeaders: Record; @@ -173,18 +188,6 @@ function buildKiroUsageAttempts(opts: { }): Array<{ name: string; run: () => Promise }> { const { authHeaders, usageParams, qParams, payload, usageBaseUrl, qBaseUrl } = opts; return [ - { - name: "codewhisperer-get", - run: () => - fetch(`${CODEWHISPERER_BASE_URL}/getUsageLimits?${usageParams.toString()}`, { - method: "GET", - headers: { - ...authHeaders, - "x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE", - "user-agent": "aws-sdk-js/1.0.0 KiroIDE", - }, - }), - }, { name: "codewhisperer-post", run: () => @@ -198,6 +201,18 @@ function buildKiroUsageAttempts(opts: { body: JSON.stringify(payload), }), }, + { + name: "codewhisperer-get", + run: () => + fetch(`${CODEWHISPERER_BASE_URL}/getUsageLimits?${usageParams.toString()}`, { + method: "GET", + headers: { + ...authHeaders, + "x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE", + "user-agent": "aws-sdk-js/1.0.0 KiroIDE", + }, + }), + }, { name: "q-get", run: () => @@ -209,27 +224,6 @@ function buildKiroUsageAttempts(opts: { ]; } -/** - * Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and - * endpoint must all match the region. Derive the region from the stored region (preferred) - * or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the - * legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions). - */ -function resolveKiroUsageEndpoints(providerSpecificData?: JsonRecord, profileArn?: string) { - const regionFromArn = profileArn - ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] - : undefined; - const region = - (typeof providerSpecificData?.region === "string" && - providerSpecificData.region.trim().toLowerCase()) || - regionFromArn || - "us-east-1"; - const usageBaseUrl = - region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`; - const qBaseUrl = `https://q.${region}.amazonaws.com`; - return { region, usageBaseUrl, qBaseUrl }; -} - /** * Base auth headers for the usage endpoints, per auth method: long-lived API keys add * `tokentype: API_KEY`; enterprise / Microsoft Entra (external_idp) org accounts require @@ -308,22 +302,31 @@ export async function getKiroUsage(accessToken?: string, providerSpecificData?: ? providerSpecificData.profileArn : undefined; - const { region, usageBaseUrl, qBaseUrl } = resolveKiroUsageEndpoints( - providerSpecificData, - profileArn - ); + const storedRegion = + typeof providerSpecificData?.region === "string" + ? providerSpecificData.region.trim().toLowerCase() + : undefined; - // IAM Identity Center logins and kiro-cli imports frequently don't persist a profileArn, which - // previously caused the quota card to show nothing ("0 used"). Discover it on demand from - // ListAvailableProfiles (region-matched) so usage still resolves for those accounts. + // Enterprise IAM Identity Center logins and kiro-cli imports frequently don't persist a + // profileArn. Discover it by probing the Q Developer PROFILE regions (us-east-1 / eu-central-1) + // — NOT the IdC/token region. An IdC in eu-north-1 has no Q runtime host (q.eu-north-1 does not + // exist); its profile lives in eu-central-1 (or us-east-1) and the SSO token works cross-region + // against it. Without this, the quota card previously showed nothing ("no limits") for such + // accounts because the single-region lookup at q.{idcRegion} always failed. if (!profileArn && accessToken) { - profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region, authMethod); + profileArn = await discoverKiroProfileArnAcrossRegions(accessToken, storedRegion); } if (!profileArn && !isApiKey) { return { message: "Kiro connected. Profile ARN not available for quota tracking." }; } + // The RUNTIME region is the profileArn region (us-east-1 / eu-central-1), never the IdC token + // region. Route GetUsageLimits to that region's host so quota resolves for cross-region IdC. + const region = resolveKiroRuntimeRegion({ region: storedRegion, profileArn }); + const usageBaseUrl = region === "us-east-1" ? CODEWHISPERER_BASE_URL : kiroRuntimeHost(region); + const qBaseUrl = `https://q.${region}.amazonaws.com`; + const authHeaders = buildKiroAuthHeaders(accessToken, isApiKey, providerSpecificData); const usageParams = new URLSearchParams({ @@ -342,7 +345,7 @@ export async function getKiroUsage(accessToken?: string, providerSpecificData?: resourceType: "AGENTIC_REQUEST", }; -const attempts = buildKiroUsageAttempts({ + const attempts = buildKiroUsageAttempts({ authHeaders, usageParams, qParams, diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 15aea48194..2d3fb8811f 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -633,6 +633,7 @@ function wrapInCloudCodeEnvelope(model, cloudCodeRequest, credentials = null) { systemInstruction: cloudCodeRequest.systemInstruction, generationConfig: applyAntigravityGenerationDefaults(cloudCodeRequest.generationConfig), tools: cloudCodeRequest.tools, + safetySettings: cloudCodeRequest.safetySettings, }, model: cleanModel, userAgent: getAntigravityEnvelopeUserAgent(credentials), diff --git a/public/providers/arena-dark.svg b/public/providers/arena-dark.svg new file mode 100644 index 0000000000..0435f9ee24 --- /dev/null +++ b/public/providers/arena-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/arena-light.svg b/public/providers/arena-light.svg new file mode 100644 index 0000000000..6ae050e8f8 --- /dev/null +++ b/public/providers/arena-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index dae4b114f5..d91d7f52c0 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -64,6 +64,7 @@ export const INTENTIONALLY_INTERNAL = new Set([ "prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts "providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421) "providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts + "proxyLatency", // intentionally-internal: imported directly by src/lib/db/proxies.ts (anti-barrel, #6798) "recovery", // intentionally-internal: bin/cli/runtime.mjs (import() dinâmico) + tests "schemaColumns", // db-internal: importado só por db/core.ts (ensureProviderConnections/UsageHistory/CallLogsColumns + hasColumn/hasTable/getTableColumns — schema-column reconciliation split do core.ts, #4948) "secrets", // intentionally-internal: src/instrumentation-node.ts (import() dinâmico na inicialização) diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index eb59d9d50a..c96c9624ef 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -19,9 +19,7 @@ const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard; // TLS). Absent or misconfigured → null → identical plain-HTTP behavior as before. const tlsOptions = resolveTlsOptions(process.env); if (tlsOptions) { - console.log( - `[omniroute][tls] HTTPS enabled — terminating TLS with cert=${tlsOptions.certPath}` - ); + console.log(`[omniroute][tls] HTTPS enabled — terminating TLS with cert=${tlsOptions.certPath}`); } process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID(); @@ -51,8 +49,23 @@ function getProxy(server) { return proxy; } +function deriveLiveWsPath() { + const publicUrl = process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL; + if (!publicUrl) return "/live-ws"; + if (!publicUrl.startsWith("ws://") && !publicUrl.startsWith("wss://")) return "/live-ws"; + try { + const parsed = new URL(publicUrl); + const pathname = parsed.pathname; + return pathname && pathname !== "/" ? pathname : "/live-ws"; + } catch { + return "/live-ws"; + } +} + +const LIVE_WS_PATH = deriveLiveWsPath(); + function proxyLiveWs(req, socket, head) { - const targetPort = parseInt(process.env.LIVE_WS_PORT || "20129", 10); + const targetPort = parseInt(process.env.LIVE_WS_PORT || "20132", 10); const targetSocket = net.connect(targetPort, "127.0.0.1", () => { let rawRequest = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`; for (const [key, val] of Object.entries(req.headers)) { @@ -76,8 +89,16 @@ function proxyLiveWs(req, socket, head) { function wrapUpgradeListener(server, listener) { return async function responsesWsAwareUpgrade(req, socket, head) { try { + // If this server IS the LiveWS server (port 20132), the ws library's + // own upgrade handler should process the request directly — proxying + // /live-ws back to 127.0.0.1:20132 would create an infinite self-loop. + const liveWsPort = parseInt(process.env.LIVE_WS_PORT || "20132", 10); + if (getPort(server) === liveWsPort) { + return listener.call(this, req, socket, head); + } + const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); - if (url.pathname === "/live-ws" || url.pathname.startsWith("/live-ws")) { + if (url.pathname === LIVE_WS_PATH || url.pathname.startsWith(LIVE_WS_PATH + "/")) { proxyLiveWs(req, socket, head); return; } diff --git a/scripts/i18n/generate-multilang.mjs b/scripts/i18n/generate-multilang.mjs index 3ad9919f98..39812499e9 100644 --- a/scripts/i18n/generate-multilang.mjs +++ b/scripts/i18n/generate-multilang.mjs @@ -97,6 +97,15 @@ const LOCALE_SPECS = [ readmeName: "中文 (简体)", docsName: "中文 (简体)", }, + { + code: "zh-TW", + googleTl: "zh-TW", + label: "ZH-TW", + flag: "🇹🇼", + languageName: "中文 (繁體)", + readmeName: "中文 (繁體)", + docsName: "中文 (繁體)", + }, { code: "de", googleTl: "de", @@ -414,7 +423,7 @@ const LOCALE_SPECS = [ }, ]; -const EXISTING_README_CODES = new Set(["pt-BR", "es", "fr", "it", "ru", "zh-CN", "de"]); +const EXISTING_README_CODES = new Set(["pt-BR", "es", "fr", "it", "ru", "zh-CN", "zh-TW", "de"]); const RTL_LOCALES = new Set(["ar", "fa", "he", "ur"]); const URL_MAX_TEXT_LENGTH = 1800; diff --git a/scripts/start-ws-server.mjs b/scripts/start-ws-server.mjs index 9b0d1068c3..00b4a62377 100644 --- a/scripts/start-ws-server.mjs +++ b/scripts/start-ws-server.mjs @@ -7,9 +7,9 @@ * node scripts/start-ws-server.mjs * * Environment variables: - * LIVE_WS_PORT — WebSocket server port (default: 20129) + * LIVE_WS_PORT — WebSocket server port (default: 20132) * LIVE_WS_HOST — WebSocket server host (default: 127.0.0.1) - * OMNIROUTE_DISABLE_LIVE_WS — Set to "1" or "true" to disable + * OMNIROUTE_ENABLE_LIVE_WS — Set to "0" or "false" to disable */ import { spawnSync } from "node:child_process"; @@ -60,10 +60,10 @@ export function buildSidecarSpawn(scriptUrl, env = process.env) { async function main() { if ( - process.env.OMNIROUTE_DISABLE_LIVE_WS === "1" || - process.env.OMNIROUTE_DISABLE_LIVE_WS === "true" + process.env.OMNIROUTE_ENABLE_LIVE_WS === "0" || + process.env.OMNIROUTE_ENABLE_LIVE_WS?.toLowerCase() === "false" ) { - console.log("[LiveWS] Disabled via OMNIROUTE_DISABLE_LIVE_WS"); + console.log("[LiveWS] Disabled via OMNIROUTE_ENABLE_LIVE_WS"); process.exit(0); } @@ -80,7 +80,7 @@ async function main() { const { startLiveDashboardServer } = await import("../src/server/ws/liveServer.ts"); - const port = parseInt(process.env.LIVE_WS_PORT || "20129", 10); + const port = parseInt(process.env.LIVE_WS_PORT || "20132", 10); const host = process.env.LIVE_WS_HOST || "127.0.0.1"; console.log(`[LiveWS] Starting dashboard WebSocket server on ${host}:${port}...`); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index 43dc76981c..03da3e4797 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -215,27 +215,26 @@ export default function AddApiKeyModal({ "Session credential validation failed. Sign in again, copy a fresh credential, and try again." ) : t("apiKeyValidationFailed"); - - // Normalize the raw credential field(s) into the single value stored as `apiKey`. - // command-code providers extract a key from a pasted blob (#5088); Modal joins its - // Token ID + Token Secret into `id:secret` (#5446); everyone else uses the field verbatim. - const resolveCredentialInput = () => { - if (isCommandCode) return extractCommandCodeCredentialInput(formData.apiKey); - if (isModal) return combineModalCredential(formData.apiKey, formData.tokenSecret); - return formData.apiKey; - }; + const validationBadge = validationResult ? validationBadgeProps(validationResult) : null; + // Normalize raw credential field(s) into the single value stored as `apiKey` + // (#5088 command-code extract; #5446 Modal id:secret join; else verbatim). + const resolveCredentialInput = () => + isCommandCode + ? extractCommandCodeCredentialInput(formData.apiKey) + : isModal + ? combineModalCredential(formData.apiKey, formData.tokenSecret) + : formData.apiKey; const handleValidate = async () => { setValidating(true); setSaveError(null); try { - const credentialInput = resolveCredentialInput(); const res = await fetch("/api/providers/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, - apiKey: credentialInput, + apiKey: resolveCredentialInput(), validationModelId: formData.validationModelId || undefined, customUserAgent: formData.customUserAgent.trim() || undefined, baseUrl: formData.baseUrl.trim() || undefined, @@ -245,11 +244,10 @@ export default function AddApiKeyModal({ }); const data = await res.json(); const ok = !!data.valid; - setValidationResult(ok ? "success" : data.unsupported ? "unsupported" : "failed"); - // #5088: surface the detailed reason the backend returns (e.g. a TLS/EACCES - // environment error for claude-web/chatgpt-web) instead of only a bare - // "invalid" badge — otherwise the real cause is hidden and users are stuck. - if (!ok && typeof data.error === "string" && data.error) { + const unsupported = !!data.unsupported; + setValidationResult(ok ? "success" : unsupported ? "unsupported" : "failed"); + // #5088: surface backend reason (e.g. TLS/EACCES) instead of bare "invalid". + if (!ok && !unsupported && typeof data.error === "string" && data.error) { setSaveError(data.error); } } catch { @@ -759,9 +757,9 @@ export default function AddApiKeyModal({ hint={t("searchEngineIdHint")} /> )} - {validationResult && ( - - {t(validationBadgeProps(validationResult).labelKey)} + {validationBadge && ( + + {providerText(t, validationBadge.labelKey, validationBadge.fallback)} )} {saveError && ( diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index cb79879226..bbbbc99ba6 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -51,11 +51,7 @@ import { useOpenRouterPresetControl } from "../OpenRouterPresetInput"; import WebSessionCredentialGuide from "../WebSessionCredentialGuide"; import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields"; import { assignEditApiKeyProviderSpecificData } from "./connectionProviderSpecificData"; -import { - isM365TierCapableProvider, - normalizeM365TierValue, - type M365TierValue, -} from "./m365Tier"; +import { isM365TierCapableProvider, normalizeM365TierValue, type M365TierValue } from "./m365Tier"; import QuotaScrapingFields, { EMPTY_QUOTA_SCRAPING_FIELDS } from "./QuotaScrapingFields"; import GlmTeamQuotaFields, { EMPTY_GLM_TEAM_QUOTA_FIELDS } from "./GlmTeamQuotaFields"; @@ -99,6 +95,8 @@ export default function EditConnectionModal({ const t = useTranslations("providers"); const notify = useNotificationStore(); const provider = connection?.provider || providerId; + const connectionAuthType = connection?.authType; + const connectionProviderSpecificData = connection?.providerSpecificData; const showFreeModelsToggle = providerHasFreeModels(provider); const [formData, setFormData] = useState({ name: "", @@ -134,12 +132,12 @@ export default function EditConnectionModal({ antigravityClientProfile: "ide", blockExtraUsage: provider === "claude" - ? isClaudeExtraUsageBlockEnabled(provider, connection?.providerSpecificData) + ? isClaudeExtraUsageBlockEnabled(provider, connectionProviderSpecificData) : false, - passthroughModels: connection?.providerSpecificData?.passthroughModels === true, - disableCooling: connection?.providerSpecificData?.disableCooling === true, - importFreeModelsOnly: connection?.providerSpecificData?.importFreeModelsOnly === true, - m365Tier: normalizeM365TierValue(connection?.providerSpecificData?.tier) as M365TierValue, + passthroughModels: connectionProviderSpecificData?.passthroughModels === true, + disableCooling: connectionProviderSpecificData?.disableCooling === true, + importFreeModelsOnly: connectionProviderSpecificData?.importFreeModelsOnly === true, + m365Tier: normalizeM365TierValue(connectionProviderSpecificData?.tier) as M365TierValue, }); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); @@ -169,11 +167,11 @@ export default function EditConnectionModal({ // providerSpecificData.baseUrl. const isConfigurableBaseUrl = isBaseUrlConfigurableProvider(provider); const isBaseUrlOverrideEligible = - connection?.authType !== "oauth" && isBaseUrlOverrideEligibleProvider(provider); + !!connection && connectionAuthType !== "oauth" && isBaseUrlOverrideEligibleProvider(provider); const [showBaseUrlOverride, setShowBaseUrlOverride] = useState( () => - typeof connection?.providerSpecificData?.baseUrl === "string" && - connection.providerSpecificData.baseUrl.trim().length > 0 + typeof connectionProviderSpecificData?.baseUrl === "string" && + connectionProviderSpecificData.baseUrl.trim().length > 0 ); const usesBaseUrl = isConfigurableBaseUrl || (isBaseUrlOverrideEligible && showBaseUrlOverride); const defaultBaseUrl = getProviderBaseUrlDefault(provider); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index e35cae394a..2bd0b2d4a6 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -119,20 +119,16 @@ export function providerText( return fallback; } -/** - * #5442 — Badge variant + i18n label key for an add-credential validation result. - * A provider with no live validator returns `unsupported` (Save still succeeds); - * previously the modal only had success/failed states, so it rendered a red - * "Invalid" badge for those providers even though saving worked (LMArena, PiAPI…). - * "unsupported" now maps to a neutral `info` badge ("N/A"), not "Invalid". - */ +/** #5442 — badge for add-credential validation; unsupported → neutral N/A (not red Invalid). */ export function validationBadgeProps(result: string): { variant: "success" | "error" | "info"; labelKey: string; + fallback: string; } { - if (result === "success") return { variant: "success", labelKey: "valid" }; - if (result === "unsupported") return { variant: "info", labelKey: "notApplicable" }; - return { variant: "error", labelKey: "invalid" }; + if (result === "success") return { variant: "success", labelKey: "valid", fallback: "Valid" }; + if (result === "unsupported") + return { variant: "info", labelKey: "notApplicable", fallback: "N/A" }; + return { variant: "error", labelKey: "invalid", fallback: "Invalid" }; } /** A single model's outcome from a `/api/models/test-all` response. */ @@ -440,7 +436,8 @@ export function getWebSessionCredentialHint( return providerText( t, requirement.hintKey, - "Open the provider's web session in DevTools, copy the required credential(s), and paste them in the fields below.", + requirement.hintFallback ?? + "Open the provider's web session in DevTools, copy the required credential(s), and paste them in the fields below.", values ); } diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx index 9e064eeeee..191be4cc9d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -8,6 +8,7 @@ import { ProxyStatusBadge } from "./ProxyStatusBadge"; import { ProxyHealthCell } from "./ProxyHealthCell"; import { ProxyBatchActions } from "./ProxyBatchActions"; import { ProxyCheckboxCell } from "./ProxyCheckboxCell"; +import { POOL_STRATEGY_OPTIONS, isPoolStrategy, type PoolStrategy } from "./proxyStrategyOptions"; type ProxyItem = { id: string; @@ -180,9 +181,7 @@ export default function ProxyRegistryManager() { const [poolOpen, setPoolOpen] = useState(false); const [poolScope, setPoolScope] = useState("provider"); const [poolScopeId, setPoolScopeId] = useState(""); - const [poolStrategy, setPoolStrategy] = useState<"round-robin" | "random" | "sticky">( - "round-robin" - ); + const [poolStrategy, setPoolStrategy] = useState("round-robin"); const [poolMembers, setPoolMembers] = useState([]); const [poolAddProxyId, setPoolAddProxyId] = useState(""); const [poolLoading, setPoolLoading] = useState(false); @@ -569,11 +568,7 @@ export default function ProxyRegistryManager() { ? payload.members : []; setPoolMembers(members.map((m) => m.proxyId)); - setPoolStrategy( - ["round-robin", "random", "sticky"].includes(payload?.strategy) - ? payload.strategy - : "round-robin" - ); + setPoolStrategy(isPoolStrategy(payload?.strategy) ? payload.strategy : "round-robin"); setPoolLoaded(true); } catch (e: any) { setError(e?.message || t("poolLoadFailed")); @@ -638,7 +633,7 @@ export default function ProxyRegistryManager() { } }; - const handlePoolStrategyChange = async (strategy: "round-robin" | "random" | "sticky") => { + const handlePoolStrategyChange = async (strategy: PoolStrategy) => { const previous = poolStrategy; setPoolStrategy(strategy); setError(null); @@ -1151,7 +1146,9 @@ export default function ProxyRegistryManager() { {poolScope !== "global" && (
- +
- +

{t("poolStrategyHint")}

diff --git a/src/app/(dashboard)/dashboard/settings/components/proxyStrategyOptions.ts b/src/app/(dashboard)/dashboard/settings/components/proxyStrategyOptions.ts new file mode 100644 index 0000000000..3f7a74e576 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxyStrategyOptions.ts @@ -0,0 +1,17 @@ +// Pool rotation strategy options shared by ProxyRegistryManager's pool strategy +// selector. Extracted so the union type has a single source of truth and the +//