Merge branch 'release/v3.8.47' into docs/rename-merge-prs

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-11 04:26:34 -03:00
committed by GitHub
115 changed files with 15996 additions and 1253 deletions

View File

@@ -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

View File

@@ -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 <apiKey>` 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 <apiKey>` 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 |
---

View File

@@ -160,7 +160,7 @@
<tr>
<td width="33%" valign="top"><b>🔌 Every tool works</b><br/><sub>24+ coding agents — Claude Code, Codex, Cursor, Cline, Copilot, Antigravity — through one config.</sub></td>
<td width="33%" valign="top"><b>🧩 One endpoint</b><br/><sub>OpenAI ↔ Claude ↔ Gemini ↔ Responses API translation. Point any tool at <code>/v1</code> and it just works.</sub></td>
<td width="33%" valign="top"><b>🛡️ Production-grade</b><br/><sub>Circuit breakers, TLS stealth, MCP (95 tools), A2A, memory, guardrails, evals. 21,000+ tests.</sub></td>
<td width="33%" valign="top"><b>🛡️ Production-grade</b><br/><sub>Circuit breakers, TLS stealth, MCP (94 tools), A2A, memory, guardrails, evals. 21,000+ tests.</sub></td>
</tr>
</table>
@@ -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 🔗 |
<sub>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).</sub>
<sub>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).</sub>
##
@@ -315,9 +316,9 @@ Result: 4 layers of fallback = zero downtime
| -------------------------------------- | ------------------------------------------------------------------- | ------------- |
| 🌐 Providers | **248** | 20100 |
| 🆓 Free providers | **90+ (11 free forever)** | 15 |
| 🔀 Routing strategies | **17** (priority, weighted, cost-optimized, context-relay, fusion…) | 13 |
| 🔀 Routing strategies | **18** (priority, weighted, cost-optimized, context-relay, fusion…) | 13 |
| 🗜️ Token compression | **RTK + Caveman stacked (1595%)** | None / 2040% |
| 🧰 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 |

1262
bin/cli/locales/zh-TW.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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).

View File

@@ -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; 5970% 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:<reason>` techniques, preview (`stable: false`, off by default) (#6556). Dependency bumped to `omniglyph@^1.0.2` for upstream ReDoS fixes (#6661).

View File

@@ -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).

View File

@@ -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).

View File

@@ -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).

View File

@@ -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).

View File

@@ -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

View File

@@ -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).

View File

@@ -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).

View File

@@ -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).

View File

@@ -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).

View File

@@ -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).

View File

@@ -0,0 +1 @@
- **fix(providers):** removed obsolete/defunct providers from the catalog (glhf, kluster, cablyai, inclusionai) (#6675 — thanks @backryun).

View File

@@ -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).

View File

@@ -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

View File

@@ -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).

View File

@@ -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).

View File

@@ -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).

View File

@@ -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).

View File

@@ -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).

View File

@@ -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).

View File

@@ -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).

View File

@@ -0,0 +1 @@
- **docs(readme):** fix stale counts — 18 routing strategies (adds the missing `pipeline` row), 94 MCP tools, 12-factor Auto-Combo scoring.

View File

@@ -340,6 +340,14 @@
"native": "中文 (简体)",
"english": "Chinese (Simplified)",
"flag": "🇨🇳"
},
{
"code": "zh-TW",
"label": "ZH-TW",
"name": "中文 (繁體)",
"native": "中文 (繁體)",
"english": "Chinese (Traditional)",
"flag": "🇹🇼"
}
]
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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/

View File

@@ -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.

View File

@@ -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`. |
---

View File

@@ -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 (~25K100K 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 <token>', ~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 (~25K100K 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 <token>', ~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/<path>?…&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://<resource>.services.ai.azure.com/openai/v1/ or https://<resource>.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 <key>. 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 <token>. |
| `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/<id>. |
| `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 <key>. 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-05Opengateway 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 <key>. 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://<workspace>--<app>.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 <key>. OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu/<model>/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.<region>.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-<key>. 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 <key>. 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 <key>. Fully OpenAI-compatible. API base URL: https://ai.sumopod.com/v1. |
| `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) |
| `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — |
| `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 <key>. 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://<region>.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 <key>. 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 <key>. 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://<resource>.services.ai.azure.com/openai/v1/ or https://<resource>.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 <key>. 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 <token>. |
| `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/<id>. |
| `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 <key>. 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-06the 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 <key>. 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://<workspace>--<app>.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 <key>. OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu/<model>/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.<region>.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-<key>. 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 <key>. 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 <key>. Fully OpenAI-compatible. API base URL: https://ai.sumopod.com/v1. |
| `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) |
| `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — |
| `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 <key>. 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://<region>.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 <key>. 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 <key>. 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

View File

@@ -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<string, string> | 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<string, string>)["x-omni-fallback-hint"] ||
(headers as Record<string, string>)["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,
};
}

View File

@@ -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<string, ImageProviderConfig> = {
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<string, ImageProviderConfig> = {
],
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"],
},
};
/**

View File

@@ -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<string, RegistryEntry> = {
"glm-cn": glm_cnProvider,
trae: traeProvider,
"muse-spark-web": muse_spark_webProvider,
lmarena: lmarenaProvider,
kilocode: kilocodeProvider,
"github-models": github_modelsProvider,
github: githubProvider,

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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<string>([
"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<string, unknown> | 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<string, unknown>
): Record<string, unknown> {
@@ -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")

View File

@@ -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<string, unknown>;
@@ -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<string, unknown>;
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)

View File

@@ -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=<value>`, it is
* returned unchanged (back-compat with the pre-migration single cookie).
* - Otherwise the reconstructed `arena-auth-prod-v1=<joined>` 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.<N>`).
const chunkPrefix = `${LMARENA_AUTH_COOKIE}.`;
const chunks = new Map<number, string>();
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<string, unknown>;
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<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, string> {
const cookie = readLMArenaCookie(credentials);
const headers: Record<string, string> = {
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<string, unknown>;
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<string, unknown>) : {};
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<string, string>,
transformedBody: Record<string, unknown>,
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<Response> {
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<Response> {
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" },
});
}
}

View File

@@ -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<number, string>();
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, unknown>): 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<string, unknown>;
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<string, unknown>;
const nested = nestedData.cookie;
if (typeof nested === "string" && nested.trim()) return reconstructLMArenaCookie(nested);
const nestedChunks = buildLMArenaCookieFromStoredFields(nestedData);
if (nestedChunks) return nestedChunks;
}
return "";
}

View File

@@ -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<string, string>): Record<string, string> {
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<string, number>;
capabilities?: {
inputCapabilities?: Record<string, boolean>;
outputCapabilities?: Record<string, boolean>;
};
}
// 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 ~100130 chat models. */
export const LMARENA_CATALOG_SOFT_CAP = 120;
const deadCatalogKeys = new Map<string, number>();
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<string, { entry: LMArenaModelMetadata; index: number }>();
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<LMArenaModelMetadata[]> {
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<string> {
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;
}
}

View File

@@ -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<string, string>,
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("<!DOCTYPE"));
}
function botBlockMessage(text: string | null | undefined, hasRecaptcha: boolean, status: number) {
if (isCloudflareChallenge(text)) {
return "Arena blocked by Cloudflare bot management. Use a residential/browser-grade network if needed, paste a fresh full Cookie header (include cf_clearance / __cf_bm when present), and optionally set providerSpecificData.recaptchaV3Token from a live browser session.";
}
if (hasRecaptcha) return `Arena API error: ${status}`;
return `Arena API error: ${status}. If this persists, supply a browser reCAPTCHA v3 token via credentials.providerSpecificData.recaptchaV3Token (in addition to the session cookie).`;
}
/** Map non-2xx / CF TLS results to an executor failure payload, or null if OK. */
export function mapFailedTlsResult(opts: {
status: number;
text: string | null | undefined;
hasRecaptcha: boolean;
model: string;
arenaModelId: string;
url: string;
headers: Record<string, string>;
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<string, string>,
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<string, string>,
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<Uint8Array> | 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<string, unknown>) {
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<Uint8Array>;
model: string;
signal?: AbortSignal;
log?: { error?: (scope: string, msg: string) => void };
}): ReadableStream<Uint8Array | string> {
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<Response> {
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" } }
);
}

View File

@@ -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<string, unknown>;
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<string, unknown>).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<string, unknown>;
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<string, unknown>;
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");
}

View File

@@ -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 {

View File

@@ -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<string, string> | 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<string, string>)["x-omni-fallback-hint"] ||
(headers as Record<string, string>)["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<T extends AccountState | null | undefined>(
export function applyErrorState<T extends AccountState | null | undefined>(
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<T extends AccountState | null | undefined>(
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",
};

View File

@@ -27,6 +27,8 @@ import { createHash } from "node:crypto";
import { v4 as uuidv4 } from "uuid";
import { resolveKiroRuntimeRegion } from "./kiroRegion.ts";
type RawRecord = Record<string, unknown>;
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 }
);
}
/**

View File

@@ -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<string | undefined> {
// 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<string | undefined> {
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;
}

View File

@@ -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<unknown> | 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<unknown> };
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<T>(
promise: Promise<T>,
timeoutMs: number,
signal: AbortSignal | null | undefined
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
let abortListener: (() => void) | null = null;
try {
const racers: Promise<T>[] = [
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => {
reject(
new TlsClientHangError(
`tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked`
)
);
}, timeoutMs);
}),
];
if (signal) {
racers.push(
new Promise<T>((_, 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<string, unknown>) => Promise<TlsResponseLike>;
}> {
if (!clientPromise) {
clientPromise = (async () => {
try {
const mod = await import("tls-client-node");
const TLSClient = (mod as { TLSClient: new (opts?: Record<string, unknown>) => 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<void>;
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
};
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<string, unknown>) => Promise<TlsResponseLike>;
}>;
}
interface TlsResponseLike {
status: number;
headers: Record<string, string[]>;
body: string; // for non-streaming requests, the full response body
cookies?: Record<string, string>;
text: () => Promise<string>;
bytes: () => Promise<Uint8Array>;
json: <T = unknown>() => Promise<T>;
}
export class TlsClientUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "TlsClientUnavailableError";
}
}
export interface TlsFetchOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>;
body?: string;
timeoutMs?: number;
signal?: AbortSignal | null;
/**
* If true, the response body is streamed to a temp file and exposed as a
* ReadableStream<Uint8Array>. 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<Uint8Array> | 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<TlsFetchResult>) | 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<string, unknown> {
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<string, unknown>) => Promise<TlsResponseLike> },
url: string,
requestOptions: Record<string, unknown>,
options: TlsFetchOptions
): Promise<TlsFetchResult> {
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<TlsFetchResult> {
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<string, string[]>): 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<string, unknown>) => Promise<TlsResponseLike> },
url: string,
requestOptions: Record<string, unknown>,
eofSymbol = "[DONE]",
signal: AbortSignal | null = null,
hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS
): Promise<TlsFetchResult> {
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<void> {
await unlink(path).catch(() => {});
await rmdir(dirname(path)).catch(() => {});
}
async function readFirstBytes(path: string, n: number): Promise<string> {
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<TlsResponseLike>
): Promise<boolean> {
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<Uint8Array>,
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<ReturnType<typeof open>>;
async function drainRemaining(
fd: FileHandle,
buf: Buffer,
offsetRef: { offset: number },
controller: ReadableStreamDefaultController<Uint8Array>,
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<TlsResponseLike>,
signal: AbortSignal | null = null
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -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;
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown> | 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<string, number[]> {
const g = globalThis as Record<string, unknown>;
let m = g[COUNTER_KEY] as Map<string, number[]> | undefined;
if (!m) {
m = new Map<string, number[]>();
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<string, unknown> | null;
rotationKey: string | null;
} {
const rec = account && typeof account === "object" ? (account as Record<string, unknown>) : null;
const psd = rec ? rec["providerSpecificData"] : undefined;
const rotationOverrides =
psd &&
typeof psd === "object" &&
(psd as Record<string, unknown>).rotationOverrides &&
typeof (psd as Record<string, unknown>).rotationOverrides === "object"
? ((psd as Record<string, unknown>).rotationOverrides as Record<string, unknown>)
: null;
const id = rec ? rec["id"] : undefined;
const rotationKey = typeof id === "string" && id.length > 0 ? id : null;
return { rotationOverrides, rotationKey };
}

View File

@@ -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<string, string>;
@@ -173,18 +188,6 @@ function buildKiroUsageAttempts(opts: {
}): Array<{ name: string; run: () => Promise<Response> }> {
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,

View File

@@ -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),

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 121.75 91.97"><path fill="#fff" d="M42.03 39.58c-3.93 0-7.12 3.24-7.12 7.24v45.14h4.53V46.82c0-1.46 1.16-2.64 2.6-2.64s2.6 1.18 2.6 2.64v45.14h4.53V46.82c0-4-3.19-7.24-7.12-7.24h-.03Zm37.68 0c-3.93 0-7.12 3.24-7.12 7.24v45.14h4.53V46.82c0-1.46 1.17-2.64 2.6-2.64s2.6 1.18 2.6 2.64v45.14h4.53V46.82c0-4-3.19-7.24-7.12-7.24zm-18.83 0c-3.94 0-7.12 3.24-7.12 7.24v45.14h4.53V46.82c0-1.46 1.17-2.64 2.6-2.64s2.6 1.18 2.6 2.64v45.14h4.54V46.82c0-4-3.19-7.24-7.12-7.24h-.02Z"/><path fill="#fff" d="m105.04 20.81 3.14-5.54h7.29L121.76 0H0l6.29 15.27h7.29l3.12 5.54c-6.09 2.12-11.1 7.5-11.1 15.6s5.76 14.97 14.39 14.97c1.89 0 3.64-.4 5.2-1.1v41.68h4.52V46.6c1.42-1.96 2.26-4.4 2.32-7.16 0-.04.01-.09.01-.14 0-.04-.01-.06-.01-.1 0-.03.01-.05.01-.07 0-.04-.01-.08-.01-.12-.05-1.87-.54-3.61-1.4-5.09h60.5c-.86 1.48-1.35 3.22-1.4 5.09 0 .05-.01.09-.01.12v.17s.01.1.01.14c.06 2.76.89 5.21 2.32 7.16v45.36h4.52V50.28c1.63.73 3.41 1.1 5.2 1.1 8.62 0 14.39-7.16 14.39-14.97s-5.03-13.49-11.12-15.6M9.3 10.66 6.8 4.61h108.15l-2.5 6.05zm93.65 4.61-2.51 4.45H21.31l-2.51-4.45zm-1.19 31.51c-4.43 0-7.46-3.04-7.51-7.51.02-3.16 2.03-5.28 4.95-5.32h.09c1.83 0 3.32 1.53 3.32 3.14v.08c-.04.93-.77 1.66-1.69 1.66-1.25 0-2.27 1.03-2.27 2.3s1.02 2.3 2.27 2.3c3.43 0 6.24-2.83 6.24-6.34 0-.11-.03-.22-.04-.32-.18-4.12-3.5-7.41-7.89-7.41H60.57l-.31-.01H22.53c-4.39.01-7.71 3.3-7.89 7.42-.01.1-.04.21-.04.32 0 3.51 2.81 6.34 6.24 6.34 1.25 0 2.27-1.03 2.27-2.3s-1.02-2.3-2.27-2.3c-.92 0-1.66-.72-1.7-1.66 0-.03.01-.05.01-.08 0-1.61 1.48-3.14 3.32-3.14h.07c2.93.04 4.94 2.15 4.96 5.32-.05 4.47-3.1 7.51-7.51 7.51-5.81 0-9.85-4.77-9.85-10.36 0-7.55 5.82-12.1 13.02-12.1h75.45c7.19 0 13.02 4.56 13.02 12.1 0 5.59-4.04 10.36-9.85 10.36"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 121.75 91.97"><path d="M42.03 39.58c-3.93 0-7.12 3.24-7.12 7.24v45.14h4.53V46.82c0-1.46 1.16-2.64 2.6-2.64s2.6 1.18 2.6 2.64v45.14h4.53V46.82c0-4-3.19-7.24-7.12-7.24h-.03Zm37.68 0c-3.93 0-7.12 3.24-7.12 7.24v45.14h4.53V46.82c0-1.46 1.17-2.64 2.6-2.64s2.6 1.18 2.6 2.64v45.14h4.53V46.82c0-4-3.19-7.24-7.12-7.24zm-18.83 0c-3.94 0-7.12 3.24-7.12 7.24v45.14h4.53V46.82c0-1.46 1.17-2.64 2.6-2.64s2.6 1.18 2.6 2.64v45.14h4.54V46.82c0-4-3.19-7.24-7.12-7.24h-.02Z"/><path d="m105.04 20.81 3.14-5.54h7.29L121.76 0H0l6.29 15.27h7.29l3.12 5.54c-6.09 2.12-11.1 7.5-11.1 15.6s5.76 14.97 14.39 14.97c1.89 0 3.64-.4 5.2-1.1v41.68h4.52V46.6c1.42-1.96 2.26-4.4 2.32-7.16 0-.04.01-.09.01-.14 0-.04-.01-.06-.01-.1 0-.03.01-.05.01-.07 0-.04-.01-.08-.01-.12-.05-1.87-.54-3.61-1.4-5.09h60.5c-.86 1.48-1.35 3.22-1.4 5.09 0 .05-.01.09-.01.12v.17s.01.1.01.14c.06 2.76.89 5.21 2.32 7.16v45.36h4.52V50.28c1.63.73 3.41 1.1 5.2 1.1 8.62 0 14.39-7.16 14.39-14.97s-5.03-13.49-11.12-15.6M9.3 10.66 6.8 4.61h108.15l-2.5 6.05zm93.65 4.61-2.51 4.45H21.31l-2.51-4.45zm-1.19 31.51c-4.43 0-7.46-3.04-7.51-7.51.02-3.16 2.03-5.28 4.95-5.32h.09c1.83 0 3.32 1.53 3.32 3.14v.08c-.04.93-.77 1.66-1.69 1.66-1.25 0-2.27 1.03-2.27 2.3s1.02 2.3 2.27 2.3c3.43 0 6.24-2.83 6.24-6.34 0-.11-.03-.22-.04-.32-.18-4.12-3.5-7.41-7.89-7.41H60.57l-.31-.01H22.53c-4.39.01-7.71 3.3-7.89 7.42-.01.1-.04.21-.04.32 0 3.51 2.81 6.34 6.24 6.34 1.25 0 2.27-1.03 2.27-2.3s-1.02-2.3-2.27-2.3c-.92 0-1.66-.72-1.7-1.66 0-.03.01-.05.01-.08 0-1.61 1.48-3.14 3.32-3.14h.07c2.93.04 4.94 2.15 4.96 5.32-.05 4.47-3.1 7.51-7.51 7.51-5.81 0-9.85-4.77-9.85-10.36 0-7.55 5.82-12.1 13.02-12.1h75.45c7.19 0 13.02 4.56 13.02 12.1 0 5.59-4.04 10.36-9.85 10.36"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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}...`);

View File

@@ -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 && (
<Badge variant={validationBadgeProps(validationResult).variant}>
{t(validationBadgeProps(validationResult).labelKey)}
{validationBadge && (
<Badge variant={validationBadge.variant}>
{providerText(t, validationBadge.labelKey, validationBadge.fallback)}
</Badge>
)}
{saveError && (

View File

@@ -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);

View File

@@ -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
);
}

View File

@@ -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<PoolStrategy>("round-robin");
const [poolMembers, setPoolMembers] = useState<string[]>([]);
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() {
</div>
{poolScope !== "global" && (
<div>
<label className="text-xs text-text-muted mb-1 block">{t("poolScopeIdLabel")}</label>
<label className="text-xs text-text-muted mb-1 block">
{t("poolScopeIdLabel")}
</label>
<input
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={poolScopeId}
@@ -1183,20 +1180,20 @@ export default function ProxyRegistryManager() {
{poolLoaded && (
<>
<div>
<label className="text-xs text-text-muted mb-1 block">{t("poolStrategyLabel")}</label>
<label className="text-xs text-text-muted mb-1 block">
{t("poolStrategyLabel")}
</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={poolStrategy}
onChange={(e) =>
handlePoolStrategyChange(
e.target.value as "round-robin" | "random" | "sticky"
)
}
onChange={(e) => handlePoolStrategyChange(e.target.value as PoolStrategy)}
data-testid="proxy-registry-pool-strategy"
>
<option value="round-robin">{t("strategyRoundRobin")}</option>
<option value="random">{t("strategyRandom")}</option>
<option value="sticky">{t("strategySticky")}</option>
{POOL_STRATEGY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</select>
<p className="text-xs text-text-muted mt-1">{t("poolStrategyHint")}</p>
</div>

View File

@@ -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
// <option> list can be rendered from data instead of literal JSX (#6798).
export type PoolStrategy = "round-robin" | "random" | "sticky" | "latency";
export const POOL_STRATEGY_VALUES: PoolStrategy[] = ["round-robin", "random", "sticky", "latency"];
export const POOL_STRATEGY_OPTIONS: Array<{ value: PoolStrategy; labelKey: string }> = [
{ value: "round-robin", labelKey: "strategyRoundRobin" },
{ value: "random", labelKey: "strategyRandom" },
{ value: "sticky", labelKey: "strategySticky" },
{ value: "latency", labelKey: "strategyLatency" },
];
export function isPoolStrategy(value: unknown): value is PoolStrategy {
return POOL_STRATEGY_VALUES.includes(value as PoolStrategy);
}

View File

@@ -22,6 +22,15 @@ import * as log from "@/sse/utils/logger";
const PER_MODEL_TIMEOUT_MS = 20_000;
const CONSECUTIVE_RATE_LIMIT_STOP_THRESHOLD = 3;
/** Web-session providers (esp. Arena/CF) ban burst probes — pause between models. */
const SLOW_PROBE_PROVIDERS = new Set(["lmarena", "lma"]);
/** Fixed inter-model delay for SLOW_PROBE_PROVIDERS (no env — avoids doc-sync drift). */
const SLOW_PROBE_DELAY_MS = 3500;
const CONSECUTIVE_BOT_STOP_THRESHOLD = 2;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const testAllSchema = z.object({
providerId: z.string().min(1),
@@ -106,8 +115,11 @@ export async function POST(request: Request) {
const results: Record<string, BatchTestResultEntry> = {};
let consecutiveRateLimits = 0;
let consecutiveBotBlocks = 0;
let stoppedEarly = false;
let stopReason: "consecutive_rate_limits" | undefined;
let stopReason: "consecutive_rate_limits" | "consecutive_bot_blocks" | undefined;
const slowProbe = SLOW_PROBE_PROVIDERS.has(providerId);
let testedUpstream = 0;
for (const modelId of modelIds) {
// #6328: skip paid ids without dispatching; do not increment
@@ -126,6 +138,10 @@ export async function POST(request: Request) {
let entry: BatchTestResultEntry;
try {
// Space out Arena/CF probes — sequential alone still looks like a burst.
if (slowProbe && testedUpstream > 0 && SLOW_PROBE_DELAY_MS > 0) {
await sleep(SLOW_PROBE_DELAY_MS);
}
// `runSingleModelTest` only engages the Bottleneck rate limiter when
// `connectionId` is provided. To honor `respectRateLimit=false`, we
// omit the connectionId so the runner bypasses `withRateLimit`.
@@ -138,6 +154,7 @@ export async function POST(request: Request) {
timeoutMs: PER_MODEL_TIMEOUT_MS,
});
entry = toBatchEntry(result);
testedUpstream += 1;
} catch (error: unknown) {
log.error("MODEL_TEST_ALL", `Unexpected error testing model ${modelId}`, {
providerId,
@@ -157,6 +174,16 @@ export async function POST(request: Request) {
consecutiveRateLimits = 0;
}
const botBlocked =
entry.statusCode === 403 ||
(typeof entry.error === "string" &&
/cloudflare|bot management|recaptcha|cf-chl|just a moment/i.test(entry.error));
if (botBlocked) {
consecutiveBotBlocks += 1;
} else if (entry.status === "ok") {
consecutiveBotBlocks = 0;
}
if (autoHideFailed && entry.status === "error" && !entry.rateLimited && !entry.isTimeout) {
try {
await setModelIsHidden(providerId, modelId, true);
@@ -202,6 +229,21 @@ export async function POST(request: Request) {
);
break;
}
if (slowProbe && consecutiveBotBlocks >= CONSECUTIVE_BOT_STOP_THRESHOLD) {
stoppedEarly = true;
stopReason = "consecutive_bot_blocks";
log.warn(
"MODEL_TEST_ALL",
`Stopping batch early after ${consecutiveBotBlocks} consecutive bot/Cloudflare blocks (avoid session ban)`,
{
providerId,
testedCount: Object.keys(results).length,
totalCount: modelIds.length,
}
);
break;
}
}
log.info(

View File

@@ -458,6 +458,13 @@ export async function GET(
if (localCatalog) return localCatalog;
}
if (provider === "lmarena") {
// Direct-chat allowlist is the intended source — no arena.ai HTML scrape
// (avoids CF bot burn and thrashy initialModels rows).
const localCatalog = buildLocalCatalogResponse(undefined, true);
if (localCatalog) return localCatalog;
}
if (provider === "bedrock") {
const cachedResponse = maybeReturnCachedDiscovery();
if (cachedResponse) return cachedResponse;

View File

@@ -1,4 +1,5 @@
import { CORS_HEADERS } from "@/shared/utils/cors";
import { getLiveWsPath } from "@/shared/utils/wsPath";
import { authorizeWebSocketHandshake } from "@/lib/ws/handshake";
const WS_HANDSHAKE_HEADERS = {
@@ -26,9 +27,9 @@ function getWsProtocol() {
},
cancel: { type: "cancel", id: "req-1" },
live: {
port: parseInt(process.env.LIVE_WS_PORT || "20129", 10),
port: parseInt(process.env.LIVE_WS_PORT || "20132", 10),
publicUrl: getLivePublicUrl(),
path: "/live",
path: getLiveWsPath(),
protocol: "json",
channels: ["requests", "combo", "credentials"],
auth: "api-key",
@@ -82,9 +83,9 @@ export async function GET(request: Request) {
authType: auth.authType,
protocol: getWsProtocol(),
live: {
port: parseInt(process.env.LIVE_WS_PORT || "20129", 10),
port: parseInt(process.env.LIVE_WS_PORT || "20132", 10),
publicUrl: getLivePublicUrl(),
path: "/live",
path: getLiveWsPath(),
protocol: "json",
channels: ["requests", "combo", "credentials"],
auth: "api-key",

View File

@@ -173,7 +173,8 @@ export const OPENAPI_ENDPOINTS: OpenApiEndpoint[] = [
path: "/api/v1/providers/{provider}/models",
method: "GET",
summary: "List models for a specific provider",
description: "Returns only models for the selected provider with provider prefix removed from each model id.",
description:
"Returns only models for the selected provider with provider prefix removed from each model id.",
tag: "Models",
tags: ["Models"],
requiresAuth: true,
@@ -203,7 +204,8 @@ export const OPENAPI_ENDPOINTS: OpenApiEndpoint[] = [
path: "/api/v1/ws",
method: "GET",
summary: "Chat completion over WebSocket (handshake + upgrade)",
description: "OpenAI-compatible chat over a WebSocket connection. `GET` with `?handshake=1` returns the connection descriptor (auth path, message protocol and live-event channels) as JSON; a plain `GET` without an Upgrade returns `426 Upgrade Required`. After upgrading, the client exchanges JSON frames — `{type:\"request\", id, payload:{model, messages}}` to start a completion and `{type:\"cancel\", id}` to abort it. A separate live channel (default port `LIVE_WS_PORT=20129`, path `/live`) streams dashboard events on the `requests`, `combo` and `credentials` topics with a 15s heartbeat. Requires an API key.",
description:
'OpenAI-compatible chat over a WebSocket connection. `GET` with `?handshake=1` returns the connection descriptor (auth path, message protocol and live-event channels) as JSON; a plain `GET` without an Upgrade returns `426 Upgrade Required`. After upgrading, the client exchanges JSON frames — `{type:"request", id, payload:{model, messages}}` to start a completion and `{type:"cancel", id}` to abort it. A separate live channel (default port `LIVE_WS_PORT=20132`, path `/live`) streams dashboard events on the `requests`, `combo` and `credentials` topics with a 15s heartbeat. Requires an API key.',
tag: "Chat",
tags: ["Chat"],
requiresAuth: true,

View File

@@ -13,6 +13,7 @@
import { useEffect, useRef, useState, useCallback } from "react";
import type { DashboardChannel, DashboardEventName } from "@/lib/events/types";
import { deriveLiveWsPath } from "@/shared/utils/wsPath";
// ── Config ────────────────────────────────────────────────────────────────
@@ -27,21 +28,22 @@ function sanitizeWsPublicUrl(url: unknown): string | null {
// Build-time inlined value (Docker/npm prebuilt images won't have this — the
// runtime value is discovered via the /api/v1/ws?handshake=1 handshake below).
const BUILD_TIME_PUBLIC_WS_URL = sanitizeWsPublicUrl(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL);
const BUILD_TIME_WS_PATH = deriveLiveWsPath(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL);
function getDefaultWsUrl(): string {
if (BUILD_TIME_PUBLIC_WS_URL) return BUILD_TIME_PUBLIC_WS_URL;
if (typeof window === "undefined") return "ws://localhost:20129";
if (typeof window === "undefined") return `ws://localhost:20132${BUILD_TIME_WS_PATH}`;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const { hostname } = window.location;
// Bug #1 fix: Use the WS server's actual port (20129) for both loopback
// Bug #1 fix: Use the WS server's actual port (20132) for both loopback
// and non-loopback clients. Previously the non-loopback branch tried to
// upgrade the HTTP port (window.location.host) which has no upgrade
// handler in src/proxy.ts. If the user wants the upgrade to go through
// Next.js (same-origin), they should explicitly pass `wsUrl`.
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return `${protocol}//${hostname}:20129`;
return `${protocol}//${hostname}:20132${BUILD_TIME_WS_PATH}`;
}
return `${protocol}//${hostname}:20129`;
return `${protocol}//${hostname}:20132${BUILD_TIME_WS_PATH}`;
}
const DEFAULT_WS_URL = getDefaultWsUrl();
@@ -65,7 +67,7 @@ export interface DashboardConnectionState {
// ── Core Hook ─────────────────────────────────────────────────────────────
export interface UseLiveDashboardOptions {
/** WebSocket URL (default: ws://hostname:20129) */
/** WebSocket URL (default: ws://hostname:20132) */
wsUrl?: string;
/** Whether the WebSocket connection should be active (default: true) */
enabled?: boolean;
@@ -105,6 +107,7 @@ export function useLiveDashboard({
// Skipped when the caller passes an explicit wsUrl or the env was inlined.
const needsHandshake = !wsUrl && !BUILD_TIME_PUBLIC_WS_URL && typeof window !== "undefined";
const [handshakeUrl, setHandshakeUrl] = useState<string | null>(null);
const [handshakePath, setHandshakePath] = useState<string | null>(null);
const [wsUrlResolved, setWsUrlResolved] = useState(!needsHandshake);
useEffect(() => {
@@ -116,6 +119,9 @@ export function useLiveDashboard({
if (cancelled) return;
const publicUrl = sanitizeWsPublicUrl(body?.live?.publicUrl);
if (publicUrl) setHandshakeUrl(publicUrl);
if (typeof body?.live?.path === "string" && body.live.path.startsWith("/")) {
setHandshakePath(body.live.path);
}
})
.catch(() => {
// Handshake unavailable — fall back to the default URL.
@@ -128,7 +134,20 @@ export function useLiveDashboard({
};
}, [needsHandshake, wsUrlResolved]);
const effectiveWsUrl = wsUrl ?? handshakeUrl ?? DEFAULT_WS_URL;
const effectiveWsUrl = (() => {
if (wsUrl) return wsUrl;
if (handshakeUrl) return handshakeUrl;
if (handshakePath && handshakePath !== BUILD_TIME_WS_PATH) {
try {
const url = new URL(DEFAULT_WS_URL);
url.pathname = handshakePath;
return url.toString();
} catch {
return DEFAULT_WS_URL;
}
}
return DEFAULT_WS_URL;
})();
const [events, setEvents] = useState<WsEventPayload[]>([]);
const wsRef = useRef<WebSocket | null>(null);

View File

@@ -4886,7 +4886,8 @@
"doubaoWebDesc": "ByteDance AI chat via dola.com",
"overrideBaseUrlAdvanced": "Advanced: override base URL",
"overrideBaseUrlHint": "Advanced: point this built-in provider at a custom endpoint. Leave blank to use the default.",
"bulkAddFormatHintCloudflare": "One key per line. Format: name|accountId|apiKey (Cloudflare account ID + API token)."
"bulkAddFormatHintCloudflare": "One key per line. Format: name|accountId|apiKey (Cloudflare account ID + API token).",
"lmarenaWebCookieHint": "Open arena.ai, sign in, then copy the full Cookie header from a Network request. Include arena-auth-prod-v1.0 and arena-auth-prod-v1.1 (and further chunks if present), preferably with cf_clearance. Do not paste only the empty arena-auth-prod-v1 cookie. Optional: providerSpecificData.recaptchaV3Token if create-evaluation still returns 403."
},
"settings": {
"title": "Settings",
@@ -8018,11 +8019,12 @@
"poolLoad": "Load Pool",
"poolLoadFailed": "Failed to load the proxy pool",
"poolStrategyLabel": "Rotation strategy",
"poolStrategyHint": "round-robin cycles members in order; random picks uniformly; sticky holds one member for a window before advancing.",
"poolStrategyHint": "round-robin cycles members in order; random picks uniformly; sticky holds one member for a window; latency-optimized picks the fastest based on logs.",
"poolStrategyFailed": "Failed to update the rotation strategy",
"strategyRoundRobin": "Round-robin",
"strategyRandom": "Random",
"strategySticky": "Sticky",
"strategyLatency": "Latency-optimized",
"poolMembersLabel": "Pool members ({count})",
"poolNoMembers": "No proxies in this pool yet.",
"poolRemove": "Remove",

9077
src/i18n/messages/zh-TW.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -276,9 +276,8 @@ export async function registerNodejs(): Promise<void> {
// without this the dashboard mode (auto/custom/adaptive) silently reverts to
// the passthrough default on every restart. Previously this was only wired into
// the unused `server-init.ts`, so it never ran in production.
const { hydrateThinkingBudgetConfig } = await import(
"@omniroute/open-sse/services/thinkingBudget.ts"
);
const { hydrateThinkingBudgetConfig } =
await import("@omniroute/open-sse/services/thinkingBudget.ts");
if (hydrateThinkingBudgetConfig(settings)) {
console.log("[STARTUP] Thinking-Budget config restored from settings");
}
@@ -441,7 +440,7 @@ export async function registerNodejs(): Promise<void> {
console.warn("[STARTUP] memory decay sweep failed to start (non-fatal):", msg);
}
// Real-time dashboard WebSocket daemon (port 20129): powers Combo Studio Live,
// Real-time dashboard WebSocket daemon (port 20132): powers Combo Studio Live,
// the Home live-pulse, and Live Compression. liveServer.ts auto-starts the
// daemon on import (gated by OMNIROUTE_ENABLE_LIVE_WS, default ON) — but NOTHING
// imported it in the packaged standalone/PM2 runtime. Only the unused

View File

@@ -6,6 +6,7 @@
import { randomUUID, randomInt } from "crypto";
import { getDbInstance } from "./core";
import { backupDbFile } from "./backup";
import { pickByLatency } from "./proxyLatency";
import type {
JsonRecord,
ProxyScope,
@@ -19,10 +20,7 @@ import type {
LegacyProxyConfig,
ProxyRotationStrategy,
} from "./proxies/types";
import {
PROXY_ROTATION_STRATEGIES,
DEFAULT_PROXY_ROTATION_STRATEGY,
} from "./proxies/types";
import { PROXY_ROTATION_STRATEGIES, DEFAULT_PROXY_ROTATION_STRATEGY } from "./proxies/types";
import {
mapProxyRow,
mapAssignmentRow,
@@ -743,6 +741,8 @@ function pickFromCandidates<T>(
return candidates[randomInt(candidates.length)];
}
if (state.strategy === "latency") return pickByLatency(db, candidates);
if (state.strategy === "sticky") {
const windowMs = state.stickyWindowMinutes * 60_000;
const lastRotated = state.rotatedAt ? Date.parse(state.rotatedAt) : NaN;

View File

@@ -5,11 +5,12 @@ export type ProxyScope = "global" | "provider" | "account" | "combo";
// to `round-robin` (monotonic persisted cursor — never Math.random). `random`
// picks uniformly from the alive set; `sticky` holds the same member for a
// configurable window before advancing the cursor.
export type ProxyRotationStrategy = "round-robin" | "random" | "sticky";
export type ProxyRotationStrategy = "round-robin" | "random" | "sticky" | "latency";
export const PROXY_ROTATION_STRATEGIES: readonly ProxyRotationStrategy[] = [
"round-robin",
"random",
"sticky",
"latency",
];
export const DEFAULT_PROXY_ROTATION_STRATEGY: ProxyRotationStrategy = "round-robin";

View File

@@ -0,0 +1,55 @@
// Latency-based proxy rotation strategy: picks the candidate with the lowest
// average latency observed in `proxy_logs` over a trailing window. Extracted
// from proxies.ts to keep that frozen god-file under its line-count cap
// (imported directly by src/lib/db/proxies.ts, anti-barrel, #6798).
import { getDbInstance } from "./core";
const PROXY_LATENCY_WINDOW_HOURS = parseInt(process.env.PROXY_LATENCY_WINDOW_HOURS ?? "3", 10);
type LatencyLogRow = {
proxy_host: string;
proxy_port: number;
avg_latency: number | null;
};
// Builds a `"host:port" -> avg_latency_ms` map from proxy_logs rows recorded
// within the trailing PROXY_LATENCY_WINDOW_HOURS window.
function buildLatencyMap(db: ReturnType<typeof getDbInstance>): Map<string, number> {
const sinceIso = new Date(Date.now() - PROXY_LATENCY_WINDOW_HOURS * 60 * 60 * 1000).toISOString();
const latencyRows = db
.prepare(
`SELECT proxy_host, proxy_port, AVG(latency_ms) as avg_latency
FROM proxy_logs
WHERE timestamp >= ?
GROUP BY proxy_host, proxy_port`
)
.all(sinceIso) as LatencyLogRow[];
const latencyMap = new Map<string, number>();
for (const r of latencyRows) {
if (r.avg_latency !== null && r.avg_latency !== undefined) {
latencyMap.set(`${r.proxy_host}:${r.proxy_port}`, r.avg_latency);
}
}
return latencyMap;
}
// Picks the candidate with the lowest recorded average latency; candidates
// with no logged latency are treated as -1 (best/first) so untested proxies
// still get a chance to be selected and gather data.
export function pickByLatency<T>(db: ReturnType<typeof getDbInstance>, candidates: T[]): T {
const latencyMap = buildLatencyMap(db);
const sorted = [...candidates].sort((a, b) => {
const pA = a as { host: string; port: number };
const pB = b as { host: string; port: number };
const keyA = `${pA.host}:${pA.port}`;
const keyB = `${pB.host}:${pB.port}`;
const latA = latencyMap.has(keyA) ? latencyMap.get(keyA)! : -1;
const latB = latencyMap.has(keyB) ? latencyMap.get(keyB)! : -1;
return latA - latB;
});
return sorted[0];
}

View File

@@ -341,6 +341,22 @@ function resolveVisionCapability(
return null;
}
export function getExplicitModelOutputCap(input: CapabilityInput): number | null {
const resolved = resolveCapabilityInput(input);
const synced = getSyncedCapabilityForResolved(
resolved.provider,
resolved.model,
resolved.rawModel
);
if (synced && typeof synced.limit_output === "number") return synced.limit_output;
const registryModel = getRegistryModel(resolved.provider, resolved.model);
if (typeof registryModel?.maxOutputTokens === "number") return registryModel.maxOutputTokens;
const spec = getStaticSpec(resolved.model, resolved.rawModel);
return spec?.maxOutputTokens ?? null;
}
export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedModelCapabilities {
const resolved = resolveCapabilityInput(input);
const spec = getStaticSpec(resolved.model, resolved.rawModel);

View File

@@ -1,4 +1,5 @@
import { KIRO_CONFIG, AWS_REGION_PATTERN, assertValidAwsRegion } from "../constants/oauth";
import { discoverKiroProfileArnAcrossRegions } from "@omniroute/open-sse/services/kiroRegion.ts";
export const kiro = {
config: KIRO_CONFIG,
@@ -8,9 +9,7 @@ export const kiro = {
const candidateRegion = regionMatch?.[1] || "us-east-1";
// Region is sourced from KIRO_CONFIG.tokenUrl (trusted constant) but defensively
// re-validate before letting it influence later fetches (GHSA-6mwv-4mrm-5p3m).
const resolvedRegion = AWS_REGION_PATTERN.test(candidateRegion)
? candidateRegion
: "us-east-1";
const resolvedRegion = AWS_REGION_PATTERN.test(candidateRegion) ? candidateRegion : "us-east-1";
const registerPayload: {
clientName: string;
clientType: string;
@@ -131,45 +130,19 @@ export const kiro = {
},
// Enterprise IAM Identity Center accounts require a region-bound Q Developer profileArn on every
// CodeWhisperer call; without it AWS returns 403 "User is not authorized to make this call". The
// device-code flow does not return one, so discover it here via ListAvailableProfiles against the
// same regional endpoint the token was issued for. Best-effort: AWS Builder ID accounts have no
// profile and this simply yields none; failures never block login.
// device-code flow does not return one, so discover it here via ListAvailableProfiles.
//
// The IdC/token region (`_region`, e.g. eu-north-1) is NOT where the Q Developer profile lives —
// AWS only hosts the profile (and its runtime) in us-east-1 / eu-central-1. So probe those
// profile regions with the freshly-minted SSO token (which works cross-region against the
// profile's home region), NOT q.{idcRegion} which does not resolve. Best-effort: AWS Builder ID
// accounts have no profile and this simply yields none; failures never block login.
postExchange: async (tokenData) => {
const accessToken = tokenData?.access_token;
if (!accessToken) return null;
const region = String(tokenData?._region || "us-east-1").toLowerCase();
// Defensive: tokenData._region came from upstream JSON or extraData
// and is interpolated into the runtime host below (GHSA-6mwv-4mrm-5p3m).
if (!AWS_REGION_PATTERN.test(region)) return null;
const runtimeHost =
region === "us-east-1"
? "https://codewhisperer.us-east-1.amazonaws.com"
: `https://q.${region}.amazonaws.com`;
try {
const profRes = await fetch(`${runtimeHost}/`, {
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 }),
signal: AbortSignal.timeout(10000),
});
if (!profRes.ok) return null;
const profData = await profRes.json();
const profiles = Array.isArray(profData?.profiles) ? profData.profiles : [];
const matched =
profiles.find(
(p: { arn?: string }) => typeof p?.arn === "string" && p.arn.includes(`:${region}:`)
) || profiles[0];
const arn = matched && typeof matched.arn === "string" ? matched.arn : undefined;
return arn ? { profileArn: arn } : null;
} catch {
// Best-effort profile discovery — never block login on it.
return null;
}
const storedRegion = typeof tokenData?._region === "string" ? tokenData._region : undefined;
const arn = await discoverKiroProfileArnAcrossRegions(accessToken, storedRegion);
return arn ? { profileArn: arn } : null;
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,

View File

@@ -146,24 +146,43 @@ export function getStaticModelsForProvider(provider: string): LocalCatalogModel[
});
}
// Image / video: only fold into the provider specialty list for *media-only*
// providers (no chat registry models). Chat+image providers (openai, lmarena,
// xai, …) keep image rows exclusively in IMAGE_PROVIDERS so the provider page
// chat catalog is not polluted with flux-* / dalle ids.
const chatRegistry = getModelsByProviderId(provider);
const hasChatRegistry = Array.isArray(chatRegistry) && chatRegistry.length > 0;
const imageProvider = getImageProvider(provider);
if (imageProvider) {
appendModels(imageProvider.models);
if (imageProvider && !hasChatRegistry) {
appendModels(imageProvider.models, {
apiFormat: "images",
supportedEndpoints: ["images"],
});
}
const videoProvider = getVideoProvider(provider);
if (videoProvider) {
appendModels(videoProvider.models);
if (videoProvider && !hasChatRegistry) {
appendModels(videoProvider.models, {
apiFormat: "video",
supportedEndpoints: ["videos"],
});
}
const speechProvider = getSpeechProvider(provider);
if (speechProvider) {
appendModels(speechProvider.models);
appendModels(speechProvider.models, {
apiFormat: "audio",
supportedEndpoints: ["audio"],
});
}
const transcriptionProvider = getTranscriptionProvider(provider);
if (transcriptionProvider) {
appendModels(transcriptionProvider.models);
appendModels(transcriptionProvider.models, {
apiFormat: "audio",
supportedEndpoints: ["audio"],
});
}
return specialtyModels.length > 0 ? specialtyModels : undefined;

View File

@@ -227,7 +227,7 @@ async function proxyUpgrade(req: IncomingMessage, socket: net.Socket, head: Buff
*
* `EMBED_WS_PROXY_HOST` takes precedence, but we fall back to `LIVE_WS_HOST`
* so a single env var exposes BOTH WebSocket sockets (the Live dashboard server
* on :20129 and this embed proxy on :20131) in Docker / behind a reverse proxy
* on :20132 and this embed proxy on :20131) in Docker / behind a reverse proxy
* or tunnel. Without this fallback the embed proxy stayed bound to 127.0.0.1
* even when the operator set `LIVE_WS_HOST=0.0.0.0`, so the Live view was
* permanently "disconnected" in headless deployments (#5110). Defaults to

View File

@@ -1,7 +1,7 @@
/**
* Live Dashboard WebSocket Server
*
* Separate process (runs alongside Next.js on port 20129).
* Separate process (runs alongside Next.js on port 20132).
* Forwards EventBus events to subscribed dashboard clients.
*
* Protocol:
@@ -36,7 +36,7 @@ import {
// ── Config ────────────────────────────────────────────────────────────────
const DEFAULT_PORT = 20129;
const DEFAULT_PORT = 20132;
// Loopback by default. Opt-in to LAN exposure via LIVE_WS_HOST=0.0.0.0 — the
// caller is then responsible for fronting it with a TLS terminator + origin
// allow-list. Mirrors the route guard "local-only by default" posture.

View File

@@ -7,12 +7,13 @@
* 0. If `src` is set (operator-supplied remote icon URL, #2166), render it — this always
* wins over the resolution below. On load error, falls back to
* `fallbackText`/`fallbackColor` (a colored text badge) if provided, otherwise falls
* through to steps 1-5.
* 1. Try /providers/{id}.svg (local SVG assets — fastest, cached separately from JS bundle)
* 2. Try @lobehub/icons direct React components (no @lobehub/ui peer runtime)
* 3. Fall back to thesvg.org CDN (external SVG)
* 4. Fall back to /providers/{id}.png (legacy static assets)
* 5. Fall back to a generic AI icon
* through to steps 1-6.
* 1. Theme-aware static SVGs (`THEMED_SVGS`, e.g. arena-light/dark for lmarena)
* 2. Try /providers/{id}.svg (local SVG assets — fastest, cached separately from JS bundle)
* 3. Try @lobehub/icons direct React components (no @lobehub/ui peer runtime)
* 4. Fall back to thesvg.org CDN (external SVG)
* 5. Fall back to /providers/{id}.png (legacy static assets)
* 6. Fall back to a generic AI icon
*
* Usage:
* <ProviderIcon providerId="openai" size={24} />
@@ -23,6 +24,8 @@
import { createElement, memo, useState } from "react";
import Image from "next/image";
import { useTheme } from "@/shared/hooks/useTheme";
import { getLobeProviderIcon } from "./lobeProviderIcons";
interface ProviderIconProps {
@@ -216,6 +219,18 @@ const KNOWN_PNGS = new Set([
"zeroclaw",
]);
const THEMED_SVGS: Record<string, { light: string; dark: string }> = {
// Arena (formerly LMArena) — wire id stays `lmarena`; alias `lma` also accepted.
lmarena: {
light: "/providers/arena-light.svg",
dark: "/providers/arena-dark.svg",
},
lma: {
light: "/providers/arena-light.svg",
dark: "/providers/arena-dark.svg",
},
};
const ProviderIcon = memo(function ProviderIcon({
providerId,
size = 24,
@@ -227,18 +242,22 @@ const ProviderIcon = memo(function ProviderIcon({
fallbackText,
fallbackColor,
}: ProviderIconProps) {
const { isDark } = useTheme();
const normalizedId = providerId.toLowerCase();
const lobeIcon = getLobeProviderIcon(normalizedId, type);
const themedSvg = THEMED_SVGS[normalizedId];
const hasSvg = KNOWN_SVGS.has(normalizedId);
const hasPng = KNOWN_PNGS.has(normalizedId);
const [failedAssets, setFailedAssets] = useState<Record<string, true>>({});
const [remoteSrcFailed, setRemoteSrcFailed] = useState(false);
const themedKey = `${normalizedId}:themed`;
const svgKey = `${normalizedId}:svg`;
const pngKey = `${normalizedId}:png`;
const theSvgKey = `${normalizedId}:thesvg`;
const trimmedSrc = typeof src === "string" ? src.trim() : "";
const themedFailed = failedAssets[themedKey];
const svgFailed = failedAssets[svgKey];
const theSvgFailed = failedAssets[theSvgKey];
const pngFailed = failedAssets[pngKey];
@@ -287,7 +306,28 @@ const ProviderIcon = memo(function ProviderIcon({
);
}
// Tier 1: Local SVG — fastest, cached separately from the JS bundle
// Tier 1: Theme-aware local SVGs (e.g. Arena light/dark)
if (themedSvg && !themedFailed) {
const themedSrc = isDark ? themedSvg.dark : themedSvg.light;
return (
<span
className={className}
style={{ display: "inline-flex", alignItems: "center", ...style }}
>
<Image
src={themedSrc}
alt={providerId}
width={size}
height={size}
style={{ objectFit: "contain" }}
onError={() => setFailedAssets((current) => ({ ...current, [themedKey]: true }))}
unoptimized
/>
</span>
);
}
// Tier 2: Local SVG — fastest, cached separately from the JS bundle
if (hasSvg && !svgFailed) {
return (
<span
@@ -307,7 +347,7 @@ const ProviderIcon = memo(function ProviderIcon({
);
}
// Tier 2: LobeHub npm icons — only when no local SVG (or SVG failed to load)
// Tier 3: LobeHub npm icons — only when no local SVG (or SVG failed to load)
if (lobeIcon) {
return (
<span
@@ -323,7 +363,7 @@ const ProviderIcon = memo(function ProviderIcon({
);
}
// Tier 3: thesvg.org CDN — external SVG fallback for unknown providers
// Tier 4: thesvg.org CDN — external SVG fallback for unknown providers
if (!theSvgFailed) {
return (
<span
@@ -343,7 +383,7 @@ const ProviderIcon = memo(function ProviderIcon({
);
}
// Tier 4: Local PNG — last resort before generic icon
// Tier 5: Local PNG — last resort before generic icon
if (hasPng && !pngFailed) {
return (
<span
@@ -363,7 +403,7 @@ const ProviderIcon = memo(function ProviderIcon({
);
}
// Tier 5: Generic AI icon
// Tier 6: Generic AI icon
return (
<span className={className} style={{ display: "inline-flex", alignItems: "center", ...style }}>
<GenericProviderIcon size={size} />

View File

@@ -179,23 +179,15 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
const [refreshIntervalSec, setRefreshIntervalSec] = useState(DEFAULT_REFRESH_INTERVAL_SEC);
const intervalRef = useRef(null);
const refreshIntervalSecRef = useRef(DEFAULT_REFRESH_INTERVAL_SEC);
const detailRequestRef = useRef("");
const hasLoadedRef = useRef(false);
const logsSignatureRef = useRef("");
const scrollContainerRef = useRef(null);
const loadMoreSentinelRef = useRef(null);
// #4269: gates the infinite-scroll observer so a "ghost" loadMore can't fire on
// mount (sentinel visible when the first page doesn't fill the viewport), which
// grew the window past PAGE_SIZE and permanently paused auto-refresh.
const hasScrolledRef = useRef(false);
const [providerNodes, setProviderNodes] = useState([]);
// #4054: fail-open. The auto-refresh pause is event-driven — we start assuming
// the tab is visible (poll) and only flip to paused on a real `visibilitychange`
// → hidden transition. Seeding from a static `document.visibilityState` read froze
// polling forever in embedded/proxied hosts that report a permanent non-"visible"
// state without ever dispatching the event (Docker dashboard wrappers, webviews).
const visibleRef = useRef(true);
// Column visibility with localStorage persistence
const [visibleColumns, setVisibleColumns] = useState(() => {
const defaultVisible = Object.fromEntries(columns.map((c) => [c.key, true]));
if (globalThis.window === undefined) return defaultVisible;
@@ -543,6 +535,10 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
return;
}
const requestToken = `${logEntry.id}:${Date.now()}:${Math.random()}`;
detailRequestRef.current = requestToken;
const isCurrentDetailRequest = () => detailRequestRef.current === requestToken;
setSelectedLog(logEntry);
try {
const url = new URL(globalThis.location.href);
@@ -557,6 +553,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
const res = await fetch(`/api/logs/${logEntry.id}`, { cache: "no-store" });
if (res.ok) {
const data = await res.json();
if (!isCurrentDetailRequest()) return;
const dataHasPipeline =
data?.pipelinePayloads && Object.keys(data.pipelinePayloads || {}).length > 0;
setDetailData((prev: { pipelinePayloads: any }) => ({
@@ -576,6 +573,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
// A deep-linked id can legitimately 404 while the request is still
// finalizing. Keep the modal open and poll /api/logs/[id] instead of
// falling back to an in-memory active-request endpoint.
if (!isCurrentDetailRequest()) return;
if (res.status === 404) {
if (logEntry.pendingLookup || logEntry.active) {
setSelectedLog((prev: { method: any; path: any }) => ({
@@ -599,17 +597,19 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
// other errors: show a minimal error indicator by setting detailData to an error object
try {
const body = await res.text().catch(() => null);
if (!isCurrentDetailRequest()) return;
setDetailData({ error: `Failed to fetch log (status ${res.status})`, body });
} catch {}
}
} catch (error) {
console.error("Failed to fetch log detail:", error);
} finally {
setDetailLoading(false);
if (isCurrentDetailRequest()) setDetailLoading(false);
}
};
const closeDetail = () => {
detailRequestRef.current = "";
setSelectedLog(null);
setDetailData(null);
try {

View File

@@ -306,7 +306,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
key: "OMNIROUTE_ENABLE_LIVE_WS",
label: "Live Dashboard WebSocket",
description:
"Start the real-time dashboard WebSocket server on import (port 20129, loopback-bound by default). Default: enabled. Set to '0' or 'false' to disable. LAN exposure requires LIVE_WS_HOST=0.0.0.0 + LIVE_WS_ALLOWED_ORIGINS.",
"Start the real-time dashboard WebSocket server on import (port 20132, loopback-bound by default). Default: enabled. Set to '0' or 'false' to disable. LAN exposure requires LIVE_WS_HOST=0.0.0.0 + LIVE_WS_ALLOWED_ORIGINS.",
descriptionI18nKey: "featureFlagOmnirouteEnableLiveWsDescription",
category: "runtime",
defaultValue: "true",

View File

@@ -171,18 +171,20 @@ export const WEB_COOKIE_PROVIDERS = {
"Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies)",
},
lmarena: {
// Wire id stays `lmarena` for DB/combo/model-prefix back-compat.
// Product rebranded LMArena → Arena (arena.ai) in Jan 2026.
id: "lmarena",
alias: "lma",
name: "LMArena (Free)",
name: "Arena (Free)",
icon: "auto_awesome",
color: "#FF6B6B",
textIcon: "LMA",
website: "https://lmarena.ai",
textIcon: "AR",
website: "https://arena.ai",
hasFree: true,
freeNote:
"Free model comparison platform — 40+ models (GPT, Claude, Gemini, Llama). No subscription required.",
"Free model comparison platform (formerly LMArena) at arena.ai — Direct-chat catalog of chat models (GPT, Claude, Gemini, Llama, …). No subscription required.",
authHint:
"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.",
"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.",
riskNoticeVariant: "webCookie",
},
"yuanbao-web": {

View File

@@ -14,6 +14,7 @@ export type WebSessionCredentialRequirement =
* AND the Cookie header, so the one-line cookie hint reads circular).
*/
hintKey?: string;
hintFallback?: string;
}
| {
kind: "none";
@@ -229,16 +230,16 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = {
},
lmarena: {
kind: "cookie",
// lmarena.ai's auth cookie is `arena-auth-prod-v1` (the legacy hint said `session`,
// arena.ai's auth cookie is `arena-auth-prod-v1` (the legacy hint said `session`,
// which never matched the real cookie name and confused users). #3810
//
// #4271: LMArena migrated to Supabase SSR chunked cookies — the single
// `arena-auth-prod-v1` cookie is now empty and the session is split across
// `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … Users must paste the FULL
// Cookie header so the executor can reconstruct the single cookie from chunks.
credentialName: "arena-auth-prod-v1",
credentialName: "full Cookie header (arena-auth-prod-v1.0 + arena-auth-prod-v1.1)",
placeholder:
"Paste the full Cookie header from lmarena.ai (the session is now split across arena-auth-prod-v1.0, .1, …)",
"arena-auth-prod-v1.0=...; arena-auth-prod-v1.1=...; other=value (full Cookie header from arena.ai)",
acceptsFullCookieHeader: true,
storageKeys: [
"cookie",
@@ -247,6 +248,9 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = {
"arena-auth-prod-v1.1",
"session",
],
hintKey: "lmarenaWebCookieHint",
hintFallback:
"Open arena.ai, sign in, then copy the full Cookie header from a Network request. Include arena-auth-prod-v1.0 and arena-auth-prod-v1.1 (and further chunks if present), preferably with cf_clearance. Do not paste only the empty arena-auth-prod-v1 cookie. Optional: providerSpecificData.recaptchaV3Token if create-evaluation still returns 403.",
},
} satisfies Record<keyof typeof WEB_COOKIE_PROVIDERS, WebSessionCredentialRequirement>;

View File

@@ -0,0 +1,29 @@
/**
* Derive the live WebSocket path from `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL`.
*
* Only `ws://` or `wss://` URLs are accepted (mirrors the scheme guard in
* `getLivePublicUrl()`). The pathname is extracted and used as the WS upgrade
* path; if the URL has no pathname (or is `/`), falls back to `/live-ws`.
*
* Used by:
* - `src/app/api/v1/ws/route.ts` — handshake response `path` field
* - `src/hooks/useLiveDashboard.ts` — build-time path constant + runtime discovery
*
* No env var is introduced — this reads the existing `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL`.
*/
export function deriveLiveWsPath(publicUrl?: string): string {
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";
}
}
/** Convenience: read the env var at call time and derive the path. */
export function getLiveWsPath(): string {
return deriveLiveWsPath(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL);
}

View File

@@ -14,7 +14,6 @@ import {
} from "@/shared/constants/upstreamHeaders";
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
export const proxyConfigSchema = z
.object({
type: z
@@ -201,6 +200,7 @@ export const PROXY_POOL_ROTATION_STRATEGY_VALUES = [
"round-robin",
"random",
"sticky",
"latency",
] as const;
// Add/remove one proxy to/from a scope's pool. proxyId is REQUIRED (unlike the
@@ -240,4 +240,4 @@ export const proxyRotationStrategySchema = z
path: ["scopeId"],
});
}
});
});

View File

@@ -215,6 +215,7 @@
"tests/unit/rate-limit-manager.test.ts",
"tests/unit/rate-limit-queue-timeout-lockout.test.ts",
"tests/unit/responses-handler.test.ts",
"tests/unit/rotation-config-omniroute.test.ts",
"tests/unit/route-explainability.test.ts",
"tests/unit/route-guard-middleware-local-only.test.ts",
"tests/unit/route-guard-plugins-local-only.test.ts",

View File

@@ -2566,6 +2566,29 @@
"stream": "https://api.llm7.io/v1/chat/completions"
}
},
"lmarena": {
"format": "openai",
"headers": {
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
}
},
"url": {
"nonStream": "https://arena.ai/nextjs-api/stream/create-evaluation",
"stream": "https://arena.ai/nextjs-api/stream/create-evaluation"
}
},
"longcat": {
"format": "openai",
"headers": {

View File

@@ -3,13 +3,14 @@ import assert from "node:assert/strict";
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
import { DEFAULT_SAFETY_SETTINGS } from "../../open-sse/translator/helpers/geminiHelper.ts";
import { openaiToAntigravityRequest } from "../../open-sse/translator/request/openai-to-gemini.ts";
// Regression for #5003: the Antigravity (Google Cloud Code) request builder explicitly set
// `safetySettings: undefined`, which `JSON.stringify` drops entirely. With no safetySettings
// reaching Cloud Code, Google applies its server-side safety defaults that false-flag benign
// technical prompts as `prohibited_content` (HTTP 200 with a blocked body that combo failover
// treats as terminal). The native Gemini paths all default to all-OFF
// (DEFAULT_SAFETY_SETTINGS); Antigravity must match for parity.
// treats as terminal). Antigravity still needs explicit all-OFF safety settings,
// but Cloud Code rejects HARM_CATEGORY_CIVIC_INTEGRITY on the v1internal endpoint.
test("transformRequest defaults safetySettings to all-OFF when none supplied (#5003)", async () => {
const executor = new AntigravityExecutor();
@@ -26,17 +27,21 @@ test("transformRequest defaults safetySettings to all-OFF when none supplied (#5
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
const innerRequest = result.request as Record<string, unknown>;
const antigravitySafetySettings = DEFAULT_SAFETY_SETTINGS.filter(
(setting) => setting.category !== "HARM_CATEGORY_CIVIC_INTEGRITY"
);
assert.deepEqual(
innerRequest.safetySettings,
DEFAULT_SAFETY_SETTINGS,
"safetySettings must default to all-OFF for parity with native Gemini paths"
antigravitySafetySettings,
"safetySettings must default to all-OFF entries accepted by Cloud Code"
);
});
test("transformRequest honors a caller-supplied safetySettings (#5003)", async () => {
test("transformRequest honors caller-supplied safetySettings accepted by Cloud Code (#5003)", async () => {
const executor = new AntigravityExecutor();
const callerSafety = [
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
{ category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "OFF" },
];
const body = {
request: {
@@ -54,7 +59,34 @@ test("transformRequest honors a caller-supplied safetySettings (#5003)", async (
const innerRequest = result.request as Record<string, unknown>;
assert.deepEqual(
innerRequest.safetySettings,
callerSafety,
"a caller-supplied safetySettings must not be clobbered"
[{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" }],
"caller-supplied safetySettings should preserve accepted entries and drop rejected ones"
);
});
test("OpenAI Antigravity translation preserves caller-supplied safetySettings (#5003)", async () => {
const executor = new AntigravityExecutor();
const callerSafety = [
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
{ category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "OFF" },
];
const translated = openaiToAntigravityRequest(
"gemini-2.5-flash",
{
messages: [{ role: "user", content: "hi" }],
safetySettings: callerSafety,
},
true,
{ projectId: "project-1" }
);
const result = await executor.transformRequest("antigravity/gemini-2.5-flash", translated, true, {
projectId: "project-1",
});
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
const innerRequest = result.request as Record<string, unknown>;
assert.deepEqual(innerRequest.safetySettings, [
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
]);
});

View File

@@ -293,13 +293,26 @@ test("getStaticModelsForProvider returns undefined for non-static providers", ()
});
test("getStaticModelsForProvider returns local image catalogs for image-only providers", () => {
const models = getStaticModelsForProvider("xai");
// nanobanana has IMAGE_PROVIDERS rows but no chat registry models — specialty
// must still surface them. Chat+image providers (xai/lmarena/openai) keep
// image models exclusively in IMAGE_PROVIDERS (not the chat specialty list).
const models = getStaticModelsForProvider("nanobanana");
assert.ok(models, "xAI should expose local image models");
assert.deepEqual(
models.map((model) => model.id),
["grok-imagine-image-quality", "grok-imagine-image"]
);
assert.ok(models, "nanobanana should expose local image models");
assert.ok(models.length >= 1);
assert.ok(models.every((m) => m.supportedEndpoints?.includes("images")));
});
test("getStaticModelsForProvider does not dump IMAGE_PROVIDERS into chat specialty", () => {
for (const provider of ["lmarena", "openai", "xai"]) {
const models = getStaticModelsForProvider(provider) || [];
assert.ok(
!models.some((m) => m.supportedEndpoints?.includes("images")),
`${provider} chat specialty must not include image-only models`
);
}
const lmarena = getStaticModelsForProvider("lmarena") || [];
assert.ok(!lmarena.some((m) => String(m.id).includes("flux")));
});
test("getStaticModelsForProvider returns models for other static providers", () => {

View File

@@ -9,9 +9,8 @@ import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-telemetry-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const { forwardDashboardEventToLiveWs, maybeSyncClaudeExtraUsageState } = await import(
"../../open-sse/handlers/chatCore/telemetryHelpers.ts"
);
const { forwardDashboardEventToLiveWs, maybeSyncClaudeExtraUsageState } =
await import("../../open-sse/handlers/chatCore/telemetryHelpers.ts");
const core = await import("../../src/lib/db/core.ts");
const originalFetch = globalThis.fetch;
@@ -48,8 +47,8 @@ test("forwardDashboardEventToLiveWs POSTs event+payload+timestamp as JSON to the
await forwardDashboardEventToLiveWs("my-event", { foo: "bar" });
const after = Date.now();
// Default port is 20129 when LIVE_WS_PORT is unset.
assert.equal(capturedUrl, "http://127.0.0.1:20129/__omniroute_event");
// Default port is 20132 when LIVE_WS_PORT is unset.
assert.equal(capturedUrl, "http://127.0.0.1:20132/__omniroute_event");
assert.equal(capturedInit?.method, "POST");
assert.equal(
(capturedInit?.headers as Record<string, string>)["content-type"],

View File

@@ -121,7 +121,7 @@ test("INTENTIONALLY_INTERNAL is exported from check-db-rules.mjs", () => {
assert.ok(INTENTIONALLY_INTERNAL.size > 0, "INTENTIONALLY_INTERNAL must not be empty");
});
test("INTENTIONALLY_INTERNAL contains the expected 34 audited modules", () => {
test("INTENTIONALLY_INTERNAL contains the expected 35 audited modules", () => {
const expected = [
"_rowTypes",
"accessTokens",
@@ -148,6 +148,7 @@ test("INTENTIONALLY_INTERNAL contains the expected 34 audited modules", () => {
"prompts",
"providerNodeSelect",
"providerStats",
"proxyLatency",
"recovery",
"schemaColumns",
"secrets",

View File

@@ -0,0 +1,12 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
test("instrumentation-node.ts imports liveServer for in-process WS auto-start", () => {
const source = readFileSync(resolve("src/instrumentation-node.ts"), "utf8");
assert.ok(
source.includes("server/ws/liveServer"),
"instrumentation-node.ts should import @/server/ws/liveServer"
);
});

View File

@@ -0,0 +1,267 @@
/**
* Regression: Kiro enterprise IAM Identity Center accounts whose IdC instance lives OUTSIDE the
* two Amazon Q Developer profile regions (us-east-1 / eu-central-1) — e.g. eu-north-1 (Stockholm),
* start URL https://d-XXXX.awsapps.com/start.
*
* Root cause fixed here: the backend used the IdC/OIDC token region (eu-north-1) for every
* CodeWhisperer runtime call, hitting q.eu-north-1.amazonaws.com — a host that does not exist as a
* Q Developer runtime endpoint. Result: profileArn discovery failed (Limits showed nothing) and
* generateAssistantResponse failed (every request 502). AWS hosts the Q Developer PROFILE (and its
* runtime) only in us-east-1 / eu-central-1, regardless of the IdC region.
*
* The fix: the RUNTIME region is derived from the profileArn (us-east-1 / eu-central-1), and
* profileArn discovery probes those profile regions with the cross-region SSO token — never the
* IdC region.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
resolveKiroRuntimeRegion,
buildKiroProfileDiscoveryRegions,
discoverKiroProfileArnAcrossRegions,
kiroRuntimeHost,
regionFromKiroProfileArn,
KIRO_PROFILE_REGIONS,
} from "../../open-sse/services/kiroRegion.ts";
import { resolveKiroRegion as resolveExecutorRegion } from "../../open-sse/executors/kiro.ts";
import { kiro } from "@/lib/oauth/providers/kiro";
import { __testing } from "@omniroute/open-sse/services/usage.ts";
const { getKiroUsage } = __testing;
const EU_CENTRAL_ARN = "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ";
test("KIRO_PROFILE_REGIONS is exactly us-east-1 and eu-central-1", () => {
assert.deepEqual([...KIRO_PROFILE_REGIONS], ["us-east-1", "eu-central-1"]);
});
test("regionFromKiroProfileArn extracts the region from a CodeWhisperer ARN", () => {
assert.equal(regionFromKiroProfileArn(EU_CENTRAL_ARN), "eu-central-1");
assert.equal(
regionFromKiroProfileArn("arn:aws:codewhisperer:us-east-1:1:profile/X"),
"us-east-1"
);
assert.equal(regionFromKiroProfileArn(undefined), undefined);
assert.equal(regionFromKiroProfileArn("not-an-arn"), undefined);
});
test("resolveKiroRuntimeRegion: profileArn region beats the IdC (eu-north-1) stored region", () => {
// The exact failing scenario: IdC token region eu-north-1, profile in eu-central-1.
assert.equal(
resolveKiroRuntimeRegion({ region: "eu-north-1", profileArn: EU_CENTRAL_ARN }),
"eu-central-1"
);
});
test("resolveKiroRuntimeRegion: an IdC region that is not a Q profile region is ignored for runtime", () => {
// No profileArn yet, IdC region eu-north-1 → must NOT route to q.eu-north-1; fall back to us-east-1.
assert.equal(resolveKiroRuntimeRegion({ region: "eu-north-1" }), "us-east-1");
assert.equal(resolveKiroRuntimeRegion({ region: "us-west-1" }), "us-east-1");
});
test("resolveKiroRuntimeRegion: a valid stored profile region is honored, defaults to us-east-1", () => {
assert.equal(resolveKiroRuntimeRegion({ region: "eu-central-1" }), "eu-central-1");
assert.equal(resolveKiroRuntimeRegion({ region: "us-east-1" }), "us-east-1");
assert.equal(resolveKiroRuntimeRegion({}), "us-east-1");
assert.equal(resolveKiroRuntimeRegion(null), "us-east-1");
});
test("the executor's resolveKiroRegion routes an eu-north-1 IdC account to the profile region", () => {
assert.equal(
resolveExecutorRegion({
providerSpecificData: { region: "eu-north-1", profileArn: EU_CENTRAL_ARN },
}),
"eu-central-1"
);
// generateAssistantResponse must therefore target the real Q host, not q.eu-north-1.
assert.equal(kiroRuntimeHost("eu-central-1"), "https://q.eu-central-1.amazonaws.com");
});
test("buildKiroProfileDiscoveryRegions: EU IdC probes the profile regions first, then the IdC region", () => {
const regions = buildKiroProfileDiscoveryRegions("eu-north-1");
assert.deepEqual(regions, ["eu-central-1", "us-east-1", "eu-north-1"]);
// The profile regions (fast path) are tried BEFORE the IdC-region fallback.
assert.ok(regions.indexOf("eu-central-1") < regions.indexOf("eu-north-1"));
assert.ok(regions.indexOf("us-east-1") < regions.indexOf("eu-north-1"));
// Another EMEA IdC region → still EU-first, IdC region appended as fallback.
assert.deepEqual(buildKiroProfileDiscoveryRegions("me-central-1"), [
"eu-central-1",
"us-east-1",
"me-central-1",
]);
});
test("buildKiroProfileDiscoveryRegions: non-EU IdC probes us-east-1 first, then the IdC region", () => {
assert.deepEqual(buildKiroProfileDiscoveryRegions("us-west-2"), [
"us-east-1",
"eu-central-1",
"us-west-2",
]);
assert.deepEqual(buildKiroProfileDiscoveryRegions("ap-southeast-2"), [
"us-east-1",
"eu-central-1",
"ap-southeast-2",
]);
// No stored region → just the two profile regions.
assert.deepEqual(buildKiroProfileDiscoveryRegions(undefined), ["us-east-1", "eu-central-1"]);
});
test("buildKiroProfileDiscoveryRegions: a stored profile region is probed first", () => {
assert.deepEqual(buildKiroProfileDiscoveryRegions("eu-central-1"), ["eu-central-1", "us-east-1"]);
assert.deepEqual(buildKiroProfileDiscoveryRegions("us-east-1"), ["us-east-1", "eu-central-1"]);
});
test("discoverKiroProfileArnAcrossRegions: eu-north-1 IdC finds the eu-central-1 profile, skips q.eu-north-1", async () => {
const requested: string[] = [];
const fetchImpl = (async (input: RequestInfo | URL) => {
const url = String(input);
requested.push(url);
// Simulate reality: q.eu-north-1 does not exist (network failure); eu-central-1 hosts the profile.
if (url.includes("eu-north-1")) throw new Error("ENOTFOUND q.eu-north-1.amazonaws.com");
if (url.includes("eu-central-1")) {
return new Response(JSON.stringify({ profiles: [{ arn: EU_CENTRAL_ARN }] }), { status: 200 });
}
// us-east-1 has no profile for this identity.
return new Response(JSON.stringify({ profiles: [] }), { status: 200 });
}) as unknown as typeof fetch;
const arn = await discoverKiroProfileArnAcrossRegions("sso-token", "eu-north-1", fetchImpl);
assert.equal(arn, EU_CENTRAL_ARN);
assert.ok(
requested.every((u) => !u.includes("eu-north-1")),
`must never probe q.eu-north-1, got: ${JSON.stringify(requested)}`
);
assert.ok(
requested.some((u) => u.startsWith("https://q.eu-central-1.amazonaws.com/")),
"must probe the eu-central-1 Q Developer host"
);
});
test("discoverKiroProfileArnAcrossRegions: a non-EU (ap-southeast-2) IdC resolves a us-east-1 profile", async () => {
// Proves the fix is general, not eu-north-1-specific: an APAC IdC's profile lives in a Q
// profile region (us-east-1 here) and is found via the cross-region SSO token.
const US_EAST_ARN = "arn:aws:codewhisperer:us-east-1:111111111111:profile/APAC";
const requested: string[] = [];
const fetchImpl = (async (input: RequestInfo | URL) => {
const url = String(input);
requested.push(url);
if (url.includes("us-east-1")) {
return new Response(JSON.stringify({ profiles: [{ arn: US_EAST_ARN }] }), { status: 200 });
}
return new Response(JSON.stringify({ profiles: [] }), { status: 200 });
}) as unknown as typeof fetch;
const arn = await discoverKiroProfileArnAcrossRegions("sso-token", "ap-southeast-2", fetchImpl);
assert.equal(arn, US_EAST_ARN);
assert.equal(
resolveKiroRuntimeRegion({ region: "ap-southeast-2", profileArn: arn }),
"us-east-1"
);
// The us-east-1 profile region is probed before the ap-southeast-2 IdC-region fallback.
assert.ok(requested.some((u) => u.startsWith("https://codewhisperer.us-east-1.amazonaws.com/")));
});
test("discoverKiroProfileArnAcrossRegions: no token / no profile yields undefined without throwing", async () => {
assert.equal(await discoverKiroProfileArnAcrossRegions("", "eu-north-1"), undefined);
const emptyFetch = (async () =>
new Response(JSON.stringify({ profiles: [] }), { status: 200 })) as unknown as typeof fetch;
assert.equal(
await discoverKiroProfileArnAcrossRegions("tok", "eu-north-1", emptyFetch),
undefined
);
});
test("kiro.postExchange (login) discovers the eu-central-1 profile for an eu-north-1 IdC token", async () => {
const originalFetch = global.fetch;
const requested: string[] = [];
global.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
requested.push(url);
if (url.includes("eu-north-1")) throw new Error("ENOTFOUND");
if (url.includes("eu-central-1")) {
return new Response(JSON.stringify({ profiles: [{ arn: EU_CENTRAL_ARN }] }), { status: 200 });
}
return new Response(JSON.stringify({ profiles: [] }), { status: 200 });
}) as typeof fetch;
try {
const extra = await kiro.postExchange({ access_token: "sso-token", _region: "eu-north-1" });
assert.deepEqual(extra, { profileArn: EU_CENTRAL_ARN });
assert.ok(requested.every((u) => !u.includes("eu-north-1")));
} finally {
global.fetch = originalFetch;
}
});
test("kiro.mapTokens keeps region=eu-north-1 (for OIDC refresh) AND stores the eu-central-1 profileArn", () => {
const mapped = kiro.mapTokens(
{ access_token: "at", refresh_token: "rt", expires_in: 3600, _region: "eu-north-1" },
{ profileArn: EU_CENTRAL_ARN }
);
// region stays the IdC/OIDC region so token refresh hits oidc.eu-north-1.amazonaws.com …
assert.equal(mapped.providerSpecificData.region, "eu-north-1");
// … while the profileArn carries the eu-central-1 runtime region for CodeWhisperer calls.
assert.equal(mapped.providerSpecificData.profileArn, EU_CENTRAL_ARN);
});
test("getKiroUsage: eu-north-1 IdC account resolves quota via the eu-central-1 profile", async () => {
const originalFetch = globalThis.fetch;
const requested: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const target = String(
(init?.headers as Record<string, string> | undefined)?.["x-amz-target"] || ""
);
requested.push(`${target} ${url}`);
// q.eu-north-1 must never be contacted.
if (url.includes("eu-north-1")) throw new Error("ENOTFOUND q.eu-north-1");
if (target.endsWith("ListAvailableProfiles")) {
if (url.includes("eu-central-1")) {
return new Response(JSON.stringify({ profiles: [{ arn: EU_CENTRAL_ARN }] }), {
status: 200,
});
}
return new Response(JSON.stringify({ profiles: [] }), { status: 200 });
}
// GetUsageLimits at the eu-central-1 host → real IAM CREDIT breakdown.
return new Response(
JSON.stringify({
subscriptionInfo: { subscriptionTitle: "KIRO POWER" },
usageBreakdownList: [
{
resourceType: "CREDIT",
currentUsageWithPrecision: 12,
usageLimitWithPrecision: 1000,
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}) as typeof fetch;
try {
// No persisted profileArn (the broken state), IdC region eu-north-1.
const result = (await getKiroUsage("sso-token", {
authMethod: "idc",
region: "eu-north-1",
})) as {
plan?: string;
quotas?: Record<string, { used: number; total: number }>;
message?: string;
};
assert.ok(result.quotas, `expected quotas, got: ${JSON.stringify(result)}`);
assert.equal(result.plan, "KIRO POWER");
assert.equal(result.quotas!.credit.used, 12);
assert.equal(result.quotas!.credit.total, 1000);
// GetUsageLimits must have gone to the eu-central-1 runtime host, never q.eu-north-1.
assert.ok(
requested.some((r) => r.includes("GetUsageLimits") && r.includes("eu-central-1")),
`GetUsageLimits should hit eu-central-1, got: ${JSON.stringify(requested)}`
);
assert.ok(requested.every((r) => !r.includes("eu-north-1")));
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -68,7 +68,7 @@ test("handshake response includes publicUrl when NEXT_PUBLIC_LIVE_WS_PUBLIC_URL
);
assert.equal(response.status, 200);
const body = (await response.json()) as any;
const body = await response.json();
assert.equal(body.live.publicUrl, "wss://ws.my-ai.com/live-ws");
});
@@ -82,7 +82,7 @@ test("handshake response includes null publicUrl when NEXT_PUBLIC_LIVE_WS_PUBLIC
);
assert.equal(response.status, 200);
const body = (await response.json()) as any;
const body = await response.json();
assert.equal(body.live.publicUrl, null);
});
@@ -92,7 +92,7 @@ test("protocol.live.publicUrl reflects env set after module import (lazy read)",
const response = await wsRoute.GET(new Request("http://localhost/api/v1/ws"));
assert.equal(response.status, 426);
const body = (await response.json()) as any;
const body = await response.json();
assert.equal(body.protocol.live.publicUrl, "wss://custom.example.com/ws");
});
@@ -106,13 +106,13 @@ test("publicUrl with non-WebSocket scheme is rejected (null)", async () => {
);
assert.equal(response.status, 200);
const body = (await response.json()) as any;
const body = await response.json();
assert.equal(body.live.publicUrl, null);
assert.equal(body.protocol.live.publicUrl, null);
});
test("publicUrl with ws:// scheme is accepted", async () => {
process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "ws://lan-host:20129/live-ws";
process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "ws://lan-host:20132/live-ws";
const response = await wsRoute.GET(
new Request("http://localhost/api/v1/ws?handshake=1", {
@@ -121,6 +121,34 @@ test("publicUrl with ws:// scheme is accepted", async () => {
);
assert.equal(response.status, 200);
const body = (await response.json()) as any;
assert.equal(body.live.publicUrl, "ws://lan-host:20129/live-ws");
const body = await response.json();
assert.equal(body.live.publicUrl, "ws://lan-host:20132/live-ws");
});
test("handshake path is derived from NEXT_PUBLIC_LIVE_WS_PUBLIC_URL pathname", async () => {
process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "wss://ws.my-ai.com/my-custom-ws";
const response = await wsRoute.GET(
new Request("http://localhost/api/v1/ws?handshake=1", {
headers: { origin: "http://localhost" },
})
);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.live.path, "/my-custom-ws");
});
test("handshake path defaults to /live-ws when NEXT_PUBLIC_LIVE_WS_PUBLIC_URL is unset", async () => {
delete process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL;
const response = await wsRoute.GET(
new Request("http://localhost/api/v1/ws?handshake=1", {
headers: { origin: "http://localhost" },
})
);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.live.path, "/live-ws");
});

View File

@@ -6,7 +6,7 @@ import {
__resetLiveWsForwardingState,
} from "../../open-sse/handlers/chatCore/telemetryHelpers.ts";
// #4604 — In single-port Docker deployments the live-WS sidecar (port 20129) is
// #4604 — In single-port Docker deployments the live-WS sidecar (port 20132) is
// not running, but forwardDashboardEventToLiveWs POSTed to it on every compression
// event. Because the global fetch is proxyFetch, each ECONNREFUSED logged a
// "[ProxyFetch] Undici dispatcher failed" warning — 272 times in 42 minutes. The
@@ -35,7 +35,7 @@ test("backs off after consecutive failures and stops calling fetch", async () =>
let calls = 0;
const fail = async () => {
calls++;
throw new Error("connect ECONNREFUSED 127.0.0.1:20129");
throw new Error("connect ECONNREFUSED 127.0.0.1:20132");
};
const clock = makeClock();
// First N attempts go through (and fail); after the threshold the forwarder

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