mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 20:22:21 +03:00
Merge branch 'release/v3.8.47' into feat/3470-auto-combo-transparency-budget
This commit is contained in:
63
.env.example
63
.env.example
@@ -86,8 +86,8 @@ PORT=20128
|
||||
|
||||
# Port for the real-time WebSocket live monitoring server.
|
||||
# Used by: src/server/ws/liveServer.ts, src/app/api/v1/ws/route.ts
|
||||
# Default: 20129
|
||||
# LIVE_WS_PORT=20129
|
||||
# Default: 20132
|
||||
# LIVE_WS_PORT=20132
|
||||
|
||||
# Bind address for the live WebSocket server.
|
||||
# Default: 127.0.0.1 (loopback only). Set to 0.0.0.0 to expose on LAN —
|
||||
@@ -112,16 +112,14 @@ PORT=20128
|
||||
|
||||
# Public URL for the live dashboard WebSocket (client-side, browser only).
|
||||
# Set this when fronting the WS server with a reverse proxy or Cloudflare Tunnel.
|
||||
# The browser will connect to this URL instead of ws://hostname:20129.
|
||||
# The /live-ws path is already proxied from the main app (port 20128) to the
|
||||
# live WS server (port 20129) by scripts/dev/standalone-server-ws.mjs.
|
||||
# Used by: src/hooks/useLiveDashboard.ts
|
||||
# The browser will connect to this URL instead of ws://hostname:20132.
|
||||
# The path portion of this URL (e.g. ws://localhost:20132/live-ws -> /live-ws) is also used by the dev proxy
|
||||
# (scripts/dev/standalone-server-ws.mjs) and the handshake response to route
|
||||
# WebSocket upgrades. Default path: /live-ws.
|
||||
# Used by: src/hooks/useLiveDashboard.ts, src/app/api/v1/ws/route.ts,
|
||||
# scripts/dev/standalone-server-ws.mjs, and scripts/start-ws-server.mjs.
|
||||
# Example: NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=wss://ws.my-ai.com/live-ws
|
||||
# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=
|
||||
|
||||
# Disable the standalone live WebSocket helper used by scripts/start-ws-server.mjs.
|
||||
# Used by: scripts/start-ws-server.mjs (CI/embedded harness toggle).
|
||||
# OMNIROUTE_DISABLE_LIVE_WS=0
|
||||
# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=ws://localhost:20132/live-ws
|
||||
|
||||
# Enable the real-time dashboard WebSocket server.
|
||||
# Used by: src/server/ws/liveServer.ts, scripts/start-ws-server.mjs
|
||||
@@ -197,9 +195,9 @@ OMNIROUTE_USE_TURBOPACK=1
|
||||
# the machine name by bash/zsh. The .env loader cannot override it (first-wins
|
||||
# semantics). Use OMNIROUTE_SERVER_HOST instead for `omniroute serve`.
|
||||
# See: https://github.com/diegosouzapw/OmniRoute/issues/6194
|
||||
#HOST=0.0.0.0
|
||||
#HOSTNAME=127.0.0.1
|
||||
#OMNIROUTE_SERVER_HOST=0.0.0.0
|
||||
# HOST=0.0.0.0
|
||||
# HOSTNAME=127.0.0.1
|
||||
# OMNIROUTE_SERVER_HOST=0.0.0.0
|
||||
|
||||
# Environment mode — affects Next.js behavior, logging verbosity, and caching.
|
||||
# Values: production | development | Default: production
|
||||
@@ -1529,6 +1527,11 @@ APP_LOG_TO_FILE=true
|
||||
# Timeout for fast-fail health checks (ms). Default: 2000
|
||||
# PROXY_FAST_FAIL_TIMEOUT_MS=2000
|
||||
|
||||
# Time window (hours) for calculating the average latency of candidate proxies
|
||||
# in the latency-optimized pool strategy. Default: 3
|
||||
# Used by: src/lib/db/proxies.ts
|
||||
# PROXY_LATENCY_WINDOW_HOURS=3
|
||||
|
||||
# Health check result cache TTL (ms). Default: 30000 (30s)
|
||||
# PROXY_HEALTH_CACHE_TTL_MS=30000
|
||||
|
||||
@@ -1647,10 +1650,19 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: open-sse/utils/cursorImages.ts.
|
||||
# CURSOR_IMAGE_FETCH_TIMEOUT_MS=15000
|
||||
|
||||
# Cursor state DB path override (for cursor version detection).
|
||||
# Cursor state DB path override (for IDE cursor version detection).
|
||||
# Used by: open-sse/utils/cursorVersionDetector.ts. Default: probed automatically.
|
||||
# CURSOR_STATE_DB_PATH=
|
||||
|
||||
# Cursor Agent CLI build id for AgentService/Run impersonation (YYYY.MM.DD-<hash>).
|
||||
# Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: detect local install, else pin.
|
||||
# CURSOR_AGENT_CLI_VERSION=2026.07.08-0c04a8a
|
||||
|
||||
# Cursor Agent CLI data directory override (versions live under <dir>/versions/).
|
||||
# Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: ~/.local/share/cursor-agent (unix)
|
||||
# or %LOCALAPPDATA%\cursor-agent (win32). Official agent CLI also honors this var.
|
||||
# CURSOR_DATA_DIR=
|
||||
|
||||
# Direct Cursor bearer token used by scripts/ad-hoc/cursor-tap.cjs (developer tooling).
|
||||
# CURSOR_TOKEN=
|
||||
|
||||
@@ -2094,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
|
||||
|
||||
102
CHANGELOG.md
102
CHANGELOG.md
@@ -28,9 +28,10 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **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 +84,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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
23
README.md
23
README.md
@@ -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** | 20–100 |
|
||||
| 🆓 Free providers | **90+ (11 free forever)** | 1–5 |
|
||||
| 🔀 Routing strategies | **17** (priority, weighted, cost-optimized, context-relay, fusion…) | 1–3 |
|
||||
| 🔀 Routing strategies | **18** (priority, weighted, cost-optimized, context-relay, fusion…) | 1–3 |
|
||||
| 🗜️ Token compression | **RTK + Caveman stacked (15–95%)** | None / 20–40% |
|
||||
| 🧰 Built-in MCP server | **95 tools, 3 transports, 30 scopes** | Rare |
|
||||
| 🧰 Built-in MCP server | **94 tools, 3 transports, 30 scopes** | Rare |
|
||||
| 🤝 A2A agent protocol | **6 skills, JSON-RPC 2.0** | None |
|
||||
| 🧠 Memory (FTS5 + vector) | **Yes** | Rare |
|
||||
| 🛡️ Guardrails (PII, injection, vision) | **Yes** | Rare |
|
||||
@@ -543,7 +544,7 @@ Expose OmniRoute over **MCP** or **A2A** and any capable agent gets the keys to
|
||||
| Protocol | Endpoint | Use it for |
|
||||
| ------------------ | ----------------------------------------------- | ------------------------------------------------------ |
|
||||
| 🧰 **MCP (stdio)** | `omniroute --mcp` | Plug into Claude Desktop, Cursor, any MCP client |
|
||||
| 🌊 **MCP (HTTP)** | `http://localhost:20128/api/mcp/stream` | Remote MCP — **95 tools**, 30 scopes, full audit trail |
|
||||
| 🌊 **MCP (HTTP)** | `http://localhost:20128/api/mcp/stream` | Remote MCP — **94 tools**, 30 scopes, full audit trail |
|
||||
| 📡 **MCP (SSE)** | `http://localhost:20128/api/mcp/sse` | Streaming MCP transport |
|
||||
| 🤝 **A2A** | `http://localhost:20128/.well-known/agent.json` | Agent-to-agent, **JSON-RPC 2.0** + SSE, 6 skills |
|
||||
|
||||
@@ -882,7 +883,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
|
||||
**Routing:** 18 strategies · task-aware smart routing · thinking budget controls · wildcard routing · system prompt injection.
|
||||
**Compatibility:** OpenAI ↔ Claude ↔ Gemini ↔ Responses API · auto OAuth refresh (PKCE, 8 providers) · multi-account round-robin · Batch + Files API · live OpenAPI 3.0.
|
||||
**Protocols:** MCP (95 tools, 3 transports, 30 scopes) · A2A (JSON-RPC 2.0, SSE, 6 skills) · ACP · cloud agents (Codex, Cursor, Devin, Jules).
|
||||
**Protocols:** MCP (94 tools, 3 transports, 30 scopes) · A2A (JSON-RPC 2.0, SSE, 6 skills) · ACP · cloud agents (Codex, Cursor, Devin, Jules).
|
||||
**Plugins:** custom plugin marketplace (system-configured registry URL with SSRF-guarded fetch) · install / enable / disable · Notion + Obsidian knowledge-base integrations (WebDAV file server, vault search, note CRUD).
|
||||
**Embedded services:** one-click install & lifecycle management of local sidecar services (CLIProxy, NineRouter).
|
||||
**Quality & Ops:** built-in **Evals** (golden-set: exact/contains/regex/custom) · guardrails (PII, injection, vision) · health dashboard · p50/p95/p99 telemetry · webhooks · compliance audit.
|
||||
@@ -1027,7 +1028,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
| [Compression Rules Format](docs/compression/COMPRESSION_RULES_FORMAT.md) | JSON rule-pack schemas for Caveman and RTK filters |
|
||||
| [Compression Language Packs](docs/compression/COMPRESSION_LANGUAGE_PACKS.md) | Language detection and Caveman rule-pack authoring |
|
||||
| [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing |
|
||||
| [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) | 9-factor scoring, mode packs, self-healing |
|
||||
| [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) | 12-factor scoring, mode packs, self-healing |
|
||||
| [Proxy Guide](docs/ops/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD |
|
||||
| [Free Tiers](docs/reference/FREE_TIERS.md) | 25+ free API providers consolidated directory |
|
||||
| [Features Gallery](docs/guides/FEATURES.md) | Visual dashboard tour with screenshots |
|
||||
|
||||
@@ -48,7 +48,11 @@ export async function runHealthCommand(opts = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetch("/api/health", { retry: false, timeout: 5000, acceptNotOk: true });
|
||||
const res = await apiFetch("/api/monitoring/health", {
|
||||
retry: false,
|
||||
timeout: 5000,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
@@ -66,29 +70,22 @@ export async function runHealthCommand(opts = {}) {
|
||||
if (health.uptime) console.log(t("health.uptime", { uptime: health.uptime }));
|
||||
if (health.version) console.log(` Version: ${health.version}`);
|
||||
|
||||
if (health.requests !== undefined) {
|
||||
console.log(t("health.requests", { count: health.requests }));
|
||||
if (health.activeConnections !== undefined) {
|
||||
console.log(t("health.requests", { count: health.activeConnections }));
|
||||
}
|
||||
|
||||
if (health.breakers && opts.verbose) {
|
||||
if (health.circuitBreakers && opts.verbose) {
|
||||
console.log("\n \x1b[1mCircuit Breakers\x1b[0m");
|
||||
for (const [name, status] of Object.entries(health.breakers)) {
|
||||
const state =
|
||||
status.state === "closed" ? "\x1b[32m● closed\x1b[0m" : "\x1b[33m○ open\x1b[0m";
|
||||
console.log(` ${name.padEnd(20)} ${state}`);
|
||||
}
|
||||
const { open = 0, halfOpen = 0, closed = 0 } = health.circuitBreakers;
|
||||
console.log(` \x1b[32m● closed\x1b[0m ${closed}`);
|
||||
console.log(` \x1b[33m○ half-open\x1b[0m ${halfOpen}`);
|
||||
console.log(` \x1b[31m○ open\x1b[0m ${open}`);
|
||||
}
|
||||
|
||||
if (health.cache && opts.verbose) {
|
||||
console.log("\n \x1b[1mCache\x1b[0m");
|
||||
console.log(` Semantic hits: ${health.cache.semanticHits || 0}`);
|
||||
console.log(` Signature hits: ${health.cache.signatureHits || 0}`);
|
||||
}
|
||||
|
||||
if (opts.verbose && health.memory) {
|
||||
if (opts.verbose && health.memoryUsage) {
|
||||
console.log("\n \x1b[1mMemory\x1b[0m");
|
||||
console.log(` RSS: ${health.memory.rss || "N/A"}`);
|
||||
console.log(` Heap used: ${health.memory.heapUsed || "N/A"}`);
|
||||
console.log(` RSS: ${health.memoryUsage.rss || "N/A"}`);
|
||||
console.log(` Heap used: ${health.memoryUsage.heapUsed || "N/A"}`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
@@ -100,13 +97,17 @@ export async function runHealthCommand(opts = {}) {
|
||||
|
||||
export async function runHealthComponentsCommand(opts = {}) {
|
||||
try {
|
||||
const res = await apiFetch("/api/health", { retry: false, timeout: 5000, acceptNotOk: true });
|
||||
const res = await apiFetch("/api/monitoring/health", {
|
||||
retry: false,
|
||||
timeout: 5000,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`HTTP ${res.status}`);
|
||||
return 1;
|
||||
}
|
||||
const health = await res.json();
|
||||
const components = health.components || health.breakers || {};
|
||||
const components = health.components || health.circuitBreakers || {};
|
||||
for (const [name, info] of Object.entries(components)) {
|
||||
const status =
|
||||
typeof info === "object" ? info.state || info.status || "unknown" : String(info);
|
||||
|
||||
1262
bin/cli/locales/zh-TW.json
Normal file
1262
bin/cli/locales/zh-TW.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
1
changelog.d/features/6556-omniglyph-engine.md
Normal file
1
changelog.d/features/6556-omniglyph-engine.md
Normal 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; 59–70% end-to-end measured). Works stacked with RTK/Caveman (`stackPriority: 90`) or standalone (`mode: omniglyph`); restricted to Claude Fable 5 over the direct Anthropic route, fail-closed gates with `skip:<reason>` techniques, preview (`stable: false`, off by default) (#6556). Dependency bumped to `omniglyph@^1.0.2` for upstream ReDoS fixes (#6661).
|
||||
1
changelog.d/features/6611-native-container-runtimes.md
Normal file
1
changelog.d/features/6611-native-container-runtimes.md
Normal 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).
|
||||
1
changelog.d/features/6708-gemma4-thinkingconfig-guard.md
Normal file
1
changelog.d/features/6708-gemma4-thinkingconfig-guard.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(sse):** skip `thinkingConfig` for Gemma models on the OpenAI→Gemini path so OpenAI-shape clients no longer get a 400 from Vertex. (thanks @chy1211)
|
||||
1
changelog.d/features/6709-xai-responses-endpoint.md
Normal file
1
changelog.d/features/6709-xai-responses-endpoint.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(xai):** route xAI clients to Grok's native `/v1/responses` endpoint instead of the chat-completions bridge. (thanks @ryanngit)
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(models):** add a Settings → AI "Model Overrides" UI plus `/api/model-capability-overrides` CRUD and a `model_capability_overrides` table, letting operators set a manual max-output-token override per provider/model (#6727 — thanks @xz-dev).
|
||||
@@ -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).
|
||||
1
changelog.d/features/6774-cursor-grok-4-5-effort-fast.md
Normal file
1
changelog.d/features/6774-cursor-grok-4-5-effort-fast.md
Normal file
@@ -0,0 +1 @@
|
||||
- **chore(cursor): add Grok 4.5 effort/fast model IDs** (#6774 — thanks @andrewmunsell).
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(cursor):** register the Opus 4.8, Fable 5, and Sonnet 5 model families for the Cursor Agent provider so the latest Claude/Fable model ids route correctly (#6779 — thanks @andrewmunsell).
|
||||
@@ -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).
|
||||
1
changelog.d/features/6804-fusion-judge-own-knowledge.md
Normal file
1
changelog.d/features/6804-fusion-judge-own-knowledge.md
Normal 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).
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(dashboard):** search box on the Playground's raw model `<select>` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`.
|
||||
1
changelog.d/fixes/6280-lmarena-arena-modernize.md
Normal file
1
changelog.d/fixes/6280-lmarena-arena-modernize.md
Normal 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
|
||||
1
changelog.d/fixes/6308-web-model-discovery.md
Normal file
1
changelog.d/fixes/6308-web-model-discovery.md
Normal 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).
|
||||
1
changelog.d/fixes/6323-log-detail-stale-reopen.md
Normal file
1
changelog.d/fixes/6323-log-detail-stale-reopen.md
Normal 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).
|
||||
1
changelog.d/fixes/6330-sensenova-token-plan.md
Normal file
1
changelog.d/fixes/6330-sensenova-token-plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(providers):** update SenseNova Token Plan support — register the token-plan model ids/constants and adjust the SenseNova registry so token-plan accounts route correctly (#6330 — thanks @xz-dev).
|
||||
1
changelog.d/fixes/6538-tier-flow-svg-public.md
Normal file
1
changelog.d/fixes/6538-tier-flow-svg-public.md
Normal 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).
|
||||
1
changelog.d/fixes/6586-preserve-server-tool-names.md
Normal file
1
changelog.d/fixes/6586-preserve-server-tool-names.md
Normal 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).
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(db): break probe-failed/restore loop on large storage.sqlite** (#6632 — thanks @KooshaPari).
|
||||
1
changelog.d/fixes/6647-winget-claude-detect.md
Normal file
1
changelog.d/fixes/6647-winget-claude-detect.md
Normal 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).
|
||||
1
changelog.d/fixes/6675-remove-obsolete-providers.md
Normal file
1
changelog.d/fixes/6675-remove-obsolete-providers.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(providers):** removed obsolete/defunct providers from the catalog (glhf, kluster, cablyai, inclusionai) (#6675 — thanks @backryun).
|
||||
1
changelog.d/fixes/6698-count-gate-rejected-usage.md
Normal file
1
changelog.d/fixes/6698-count-gate-rejected-usage.md
Normal 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).
|
||||
1
changelog.d/fixes/6704-unwrap-bare-function-tool.md
Normal file
1
changelog.d/fixes/6704-unwrap-bare-function-tool.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(sse):** unwrap bare `{function:{…}}` tools so OpenAI-shape clients no longer have tools silently dropped in Claude translation. (thanks @samir-abis)
|
||||
1
changelog.d/fixes/6706-codex-oauth-bare-email-dedup.md
Normal file
1
changelog.d/fixes/6706-codex-oauth-bare-email-dedup.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(oauth):** stop merging distinct Codex OAuth logins that share an email but lack a verifiable account id, preventing silent token overwrite. (thanks @lucasjustinudin)
|
||||
1
changelog.d/fixes/6710-codex-200-sse-capacity-error.md
Normal file
1
changelog.d/fixes/6710-codex-200-sse-capacity-error.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(codex):** detect "model at capacity"/overloaded errors embedded in a 200-OK SSE stream and surface them as a real error so account fallback rotates, instead of passing them through as a successful response. (thanks @ryanngit)
|
||||
1
changelog.d/fixes/6712-volcengine-kimi-max-tokens.md
Normal file
1
changelog.d/fixes/6712-volcengine-kimi-max-tokens.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(volcengine):** clamp `max_tokens` to the VolcEngine Ark endpoint cap for the Kimi model so oversized values no longer 400. (thanks @whale9820)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(antigravity):** surface aborted/malformed Gemini tool calls (e.g. `MALFORMED_FUNCTION_CALL`) as an explicit non-`end_turn` finish reason instead of a silent clean completion. (thanks @anhdiepmmk)
|
||||
1
changelog.d/fixes/6714-reasoning-buffer-cap-aware.md
Normal file
1
changelog.d/fixes/6714-reasoning-buffer-cap-aware.md
Normal 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
|
||||
1
changelog.d/fixes/6717-cli-health-monitoring-route.md
Normal file
1
changelog.d/fixes/6717-cli-health-monitoring-route.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(api):** `omniroute health` (and `health components`/`health watch`) returned `Error: HTTP 404` ([#6677](https://github.com/diegosouzapw/OmniRoute/issues/6677)) — `bin/cli/commands/health.mjs` called `apiFetch("/api/health", ...)`, a route that was moved to `GET /api/monitoring/health` (`src/app/api/monitoring/health/route.ts`) without updating the CLI; `src/app/api/health/` on disk only has `degradation/route.ts` and `ping/route.ts`, no top-level handler. `runHealthCommand()`/`runHealthComponentsCommand()` now call `/api/monitoring/health` and read its actual payload shape (`activeConnections`, `circuitBreakers: {open, halfOpen, closed}`, `memoryUsage`) instead of the old, nonexistent `requests`/`breakers`/`cache`/`memory` fields. Regression guard: `tests/unit/cli-health-monitoring-route.test.ts`.
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(startup):** webpack build broke on case-insensitive filesystems (macOS APFS default, Windows) with a casing-collision warning plus "not exported" errors in `StudioConfigPane.tsx`/`ChatTab.tsx` (#6584) — `src/app/(dashboard)/dashboard/playground/components/ReasoningControls.tsx` (the component) and `reasoningControls.ts` (the utils module) shared the same lower-cased stem in the same directory, and two importers used the extensionless form `from "./reasoningControls"`, the exact resolution path that becomes ambiguous once casing is folded. Renamed the utils module to `reasoningControlUtils.ts` (no collision) and updated the 3 import sites. Regression guard: `tests/unit/case-collision-6584.test.ts` (scans `src/`/`open-sse/` for any same-directory, case-only filename collision). (#6584)
|
||||
1
changelog.d/fixes/6720-turbopack-agentskills-warning.md
Normal file
1
changelog.d/fixes/6720-turbopack-agentskills-warning.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(build):** Turbopack production build emitted an "Overly broad patterns can lead to build performance issues" warning per entry point importing `src/lib/agentSkills/generator.ts` (603 warnings reported on v3.8.46, up from 379 on v3.8.45) ([#6582](https://github.com/diegosouzapw/OmniRoute/issues/6582)) — `generator.ts`'s `outputBase` is built as `path.isAbsolute(outputDir) ? outputDir : path.join(process.cwd(), outputDir)`, where `outputDir` is a runtime function parameter, not a compile-time literal, so Turbopack's build-time file-tracing analyzer can't statically narrow the several dynamic `readdirSync`/`rmSync`/`readFileSync`/`writeFileSync` call sites a few lines below and falls back to a project-wide glob; #6366's commit message claimed to "anchor the base path with a literal" but the shipped code never did. Since this fs access is legitimate and bounded (`skills/<id>/SKILL.md`, ~48 known IDs), `next.config.mjs`'s `turbopack.ignoreIssue` (Next.js 16.2+) now suppresses this specific, known-benign diagnostic, mirroring the existing `webpack.ignoreWarnings`/`isNextIntlExtractorDynamicImportWarning` precedent already in the same file for the webpack path. Regression guard: `tests/unit/next-config.test.ts` (asserts the `turbopack.ignoreIssue` rule shape targeting `src/lib/agentSkills/**`).
|
||||
1
changelog.d/fixes/6721-codex-spark-image-drop.md
Normal file
1
changelog.d/fixes/6721-codex-spark-image-drop.md
Normal file
@@ -0,0 +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` (thanks @alltomatos for independently catching and fixing it via #6819).
|
||||
1
changelog.d/fixes/6722-quota-card-fixed-order.md
Normal file
1
changelog.d/fixes/6722-quota-card-fixed-order.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(providers):** the provider quota card's weekly/session bars re-sorted by remaining percentage instead of staying in a fixed, deterministic order ([#6687](https://github.com/diegosouzapw/OmniRoute/issues/6687)) — `QuotaCardExpanded.tsx`'s `sortQuotasByRemaining()` (added in #5977) was applied unconditionally via `useMemo(() => sortQuotasByRemaining(quotas), [quotas])`, undoing the deterministic `CODEX_QUOTA_ORDER`/`GLM_QUOTA_ORDER` window order `quotaParsing.ts`'s `sortCodexOrder()`/`sortGlmOrder()` (added in #6336) already established for Codex and the GLM family — since #6336 never touched `QuotaCardExpanded.tsx`, the two orderings never composed, so e.g. a Codex `session` window with less headroom than `weekly` rendered after it instead of staying first. A new `hasFixedQuotaOrder()` (`quotaParsing.ts`) and `resolveQuotaDisplayOrder()` (`QuotaCardExpanded.tsx`) now skip the remaining-% re-sort for providers with a fixed window order, threading `providerId` from `QuotaCard.tsx` through to the display layer; every other provider still gets the remaining-% sort. Regression guard: `tests/unit/quota-card-expanded-fixed-order-6687.test.ts`.
|
||||
1
changelog.d/fixes/6725-lazy-ioredis-mcp.md
Normal file
1
changelog.d/fixes/6725-lazy-ioredis-mcp.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(startup):** `omniroute --mcp` crashed at Node ESM link time with `ERR_MODULE_NOT_FOUND` for `ioredis` on installs where the published MCP bundle didn't happen to have `ioredis` rescued from a parent `node_modules` ([#6559](https://github.com/diegosouzapw/OmniRoute/issues/6559)) — `src/shared/utils/rateLimiter.ts` had a top-level static `import Redis from "ioredis"`; that module is only ever reached via a lazy `await import(...)` several call-sites deep in the MCP tool chain, but esbuild's `--packages=external` bundling of the MCP server (`scripts/build/prepublish.ts` Step 8.5) still hoisted rateLimiter.ts's own static import into a real top-level ESM import in the compiled `dist/open-sse/mcp-server/server.js`, forcing Node to resolve `ioredis` at module-link time — before any `--mcp` startup code runs — and `ioredis` is not guaranteed to ship in the MCP-only bundle's `node_modules`. `getRedisClient()` now lazily imports `ioredis` on first use (matching the established soft-dependency pattern in `src/lib/quota/redisQuotaStore.ts`) while still throwing synchronously when Redis isn't configured. Regression guard: `tests/unit/build/mcp-bundle-no-eager-ioredis.test.ts` (bundles the real MCP server entrypoint with the exact publish-time esbuild flags and asserts no top-level static `ioredis` import remains, while the pre-existing lazy `await import("ioredis")` in `redisQuotaStore.ts` stays intact).
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(providers):** Kiro sent the adaptive-thinking `additionalModelRequestFields` envelope for `claude-sonnet-4.5`/`claude-haiku-4.5`, which Kiro/CodeWhisperer rejects upstream with a raw `[400]: additionalModelRequestFields is not supported for this model` ([#6576](https://github.com/diegosouzapw/OmniRoute/issues/6576)) — `buildKiroPayload()` (`open-sse/translator/request/openai-to-kiro.ts`) gated the field on the generic Anthropic-API `supportsReasoning()` capability flag, which is `true` for both models on Anthropic's direct API but does not reflect what Kiro's CodeWhisperer backend actually accepts; only `claude-sonnet-5` is confirmed adaptive-thinking-capable there. A new Kiro-specific allowlist (`supportsKiroAdaptiveThinking()` in `open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts`) now gates the envelope instead. Regression guard: `tests/unit/repro-6576-kiro-thinking-unsupported-model.test.ts`.
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(translator):** Cursor's local **Subagent** tool call is no longer rejected with `cloud_base_branch may only be specified when environment equals cloud` — the Responses→Chat tool-arg cleanup (`stripEmptyOptionalToolArgs`) was scoped to Claude Code's `Read` tool only, so Cursor's `Subagent` tool passed through with the cloud-only `cloud_base_branch: ""` (Cursor treats an empty string as "specified" and rejects the call before starting the local subagent). The cleanup now covers an allowlist of `Read` + `Subagent`; arbitrary tools are still left untouched (empty strings/arrays can be valid payloads for them). Regression guard: `tests/unit/openai-responses-subagent-strip-2446.test.ts`. (thanks @like3213934360-lab)
|
||||
1
changelog.d/fixes/6730-glm-split-tool-name.md
Normal file
1
changelog.d/fixes/6730-glm-split-tool-name.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(translator):** GLM 5.2 (and other OpenAI-compatible upstreams that stream a tool call's `id` and `function.name` in **separate** SSE chunks) no longer produce an empty tool name / `No such tool available:` error through the Claude `/messages` path — the `openai-to-claude` streaming translator emitted `content_block_start` immediately on the id-only chunk with an empty `name`, and the Claude SSE protocol cannot patch a block after it is emitted, so the later name-only chunk was silently dropped. It now **defers** `content_block_start` until the tool name arrives (falling back to starting the block when arguments arrive first), so the emitted `tool_use` always carries the real name. Regression guard: `tests/unit/openai-to-claude-glm-split-tool-name-2077.test.ts`. (thanks @itiwant)
|
||||
1
changelog.d/fixes/6731-apikey-429-quota-exhausted.md
Normal file
1
changelog.d/fixes/6731-apikey-429-quota-exhausted.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(resilience):** OmniRoute didn't respect an exhausted Ollama Cloud (or any other apikey-category provider) quota — it retried the account seconds later instead of waiting out the real reset window ([#6638](https://github.com/diegosouzapw/OmniRoute/issues/6638)) — `shouldPreserveQuotaSignalsFor429()`/`checkFallbackError()` (`open-sse/services/accountFallback.ts`) only applied body-text quota classification (daily/monthly/weekly quota-exhausted detection) to OAuth-category providers; apikey-category 429s (Ollama Cloud, OpenAI, etc.) always fell through to the generic short rate-limit cooldown regardless of what the error body said, and `parseRetryFromErrorText()` also had no support for day-granularity reset hints ("Your quota will reset in 3 days.") — only Xh/Ym/Zs combos. An explicit quota-exhausted signal in the body (`looksLikeQuotaExhausted()`) now overrides the apikey-category default via the new `shouldPreserveQuotaSignals()` (`open-sse/services/quotaResetParsing.ts`), and `parseDayGranularityResetMs()` parses whole-day reset countdowns so the real multi-day window is honored instead of a few seconds of backoff. Regression guard: `tests/unit/issue-6638-ollama-quota.test.ts` + 2 aligned `tests/unit/account-fallback-service.test.ts` cases that previously asserted the buggy rate_limit_exceeded/undefined-dailyQuotaExhausted behavior for apikey-provider quota text.
|
||||
1
changelog.d/fixes/6732-fp-pinned-combo-resolve.md
Normal file
1
changelog.d/fixes/6732-fp-pinned-combo-resolve.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(resilience):** a combo step "pinned" to one fingerprint account (mimocode/mcode/opencode multi-account providers) never actually resolved to that account, so it couldn't fail over when the pinned account was depleted ([#6696](https://github.com/diegosouzapw/OmniRoute/issues/6696), relates #6612) — the combo builder UI encodes an account pin as a composite connectionId (`${rowId}|fp|${fingerprint}`, `src/lib/combos/builderOptions.ts`), but `expandTargetsByFingerprints()` (`open-sse/services/combo/fingerprintExpansion.ts`) looked that composite string up directly in `connectionById` (keyed by real DB row ids), got `undefined`, and passed the target through unchanged, still carrying the bogus composite id — so downstream credential resolution could never match it either. `expandTargetsByFingerprints()` now splits the `|fp|` composite id back into the real connection row id + the pinned fingerprint (new `splitFingerprintPin()` helper) before any lookup, resolving the target to the real connectionId (with the pinned fingerprint carried on the new `pinnedFingerprint` field) instead of the inert composite string. Regression guard: `tests/unit/combo-fingerprint-pin-6696.test.ts`.
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(api):** Responses passthrough emitted event-only SSE frames (no `data:` line) for every dropped commentary event, breaking the OpenAI Python SDK's `sse.json()` parser ([#6561](https://github.com/diegosouzapw/OmniRoute/issues/6561)), follow-up to #6199/#6232 — the commentary-drop `continue;` branches in `open-sse/utils/stream.ts` skipped the `data:` line for a dropped commentary event but never cleared the already-buffered `event:` line for that same frame, so the next blank line flushed the stale `event:` line alone. Both drop sites now call `clearPendingPassthroughEvent()` before `continue`, discarding the buffered prefix along with the dropped payload; the commentary-drop decision itself was extracted into a new `open-sse/utils/responsesCommentaryDrop.ts` so the fix does not grow the frozen `stream.ts`. Regression guard: `tests/unit/responses-commentary-event-frame-6561.test.ts` (realistic `event:\ndata:\n\n` frames — the existing #6199 test only used bare `data:` lines and never exercised this path).
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(compression):** `/api/compression/preview`'s top-level `originalTokens`/`compressedTokens` diverged from `engineBreakdown[0]`'s counts for the same single-engine run (tiktoken outer counts vs the `JSON.stringify(...).length/4` estimate per engine), worst on small inputs. A new `reconcileSingleEngineTokens()` overwrites the single-engine breakdown entry with the outer, more accurate figures; multi-step pipeline breakdowns are left untouched ([#6488](https://github.com/diegosouzapw/OmniRoute/issues/6488)). Regression guard: `tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts`.
|
||||
1
changelog.d/fixes/6742-quota-preflight-coverage.md
Normal file
1
changelog.d/fixes/6742-quota-preflight-coverage.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(resilience):** account selection could pick an account already out of quota upstream on every credentialed route except `chat`/`codex` ([#6686](https://github.com/diegosouzapw/OmniRoute/issues/6686)) — `getProviderCredentials()` (`src/sse/services/auth.ts`) only skips a connection when a *local cache* already flags it exhausted (`isQuotaExhaustedForRequest`/`src/domain/quotaCache.ts`); it never itself calls the registered upstream `QuotaFetcher`. Only `getProviderCredentialsWithQuotaPreflight()` performs that live upstream check, and it was wired into exactly 2 call sites (`src/sse/handlers/chat.ts`, `src/app/api/internal/codex-responses-ws/route.ts`) — every other credentialed route (`rerank`, `images/generations`, `images/edits`, `audio/transcriptions|speech|translations`, `videos/generations`, `music/generations`, `ocr`, `providers/[provider]/embeddings`, `providers/[provider]/images/generations`, `web/fetch`, `moderations`, `search`) called the plain, cache-only selector, so an account whose cache entry was never populated (e.g. its first request landed on one of these routes) could be selected even at 0% quota remaining. Those 14 call sites now go through `getProviderCredentialsWithQuotaPreflight()` instead, matching chat/codex coverage. Regression guard: `tests/unit/issue-6686-quota-preflight-coverage.test.ts` (static check that none of the routes call the plain selector anymore + a behavioral check that the preflight-aware selector blocks a 100%-used account).
|
||||
1
changelog.d/fixes/6743-reasoning-content-web-sse.md
Normal file
1
changelog.d/fixes/6743-reasoning-content-web-sse.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(api):** `reasoning_content` (extended-thinking text) was silently dropped from `/v1/chat/completions` SSE on the `claude-web` and `v0-vercel-web` executors ([#6662](https://github.com/diegosouzapw/OmniRoute/issues/6662)) — every chunk builder in both adapters hardcoded `delta: { content: ... }` with no reasoning path, unlike the established pattern already used by `default.ts`/`deepseek-web.ts`/`bedrock.ts` and the real-Anthropic-API `claude-to-openai.ts` translator (`thinking_delta` → `reasoning_content`). `v0-vercel-web.ts` now forwards an upstream `delta.reasoning_content` field (streaming and non-streaming) the same way `deepseek-web.ts` does. `claude-web.ts`'s `buildClaudeStreamingResponse` now maps a `content_block_start`(`type: "thinking"`)/`content_block_delta`(`delta.thinking`) pair onto `delta.reasoning_content`, and `claude-web/payload.ts`'s `transformToClaude()` no longer hardcodes `thinking_mode: "off"` — a new `wantsExtendedThinking()` derives it from the request's `reasoning_effort`/`reasoning.effort`/`thinking.type` signal, so extended thinking can actually be requested. Regression guard: `tests/unit/issue-6662-repro.test.ts` (RED→GREEN for both adapters).
|
||||
1
changelog.d/fixes/6757-rtk-enable-renderers-schema.md
Normal file
1
changelog.d/fixes/6757-rtk-enable-renderers-schema.md
Normal 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).
|
||||
1
changelog.d/fixes/6759-cookie-provider-apikey-cap.md
Normal file
1
changelog.d/fixes/6759-cookie-provider-apikey-cap.md
Normal 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).
|
||||
1
changelog.d/fixes/6769-i18n-translate-pt-dashboard.md
Normal file
1
changelog.d/fixes/6769-i18n-translate-pt-dashboard.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(i18n): translate hardcoded Portuguese dashboard strings to English (#6761, #6768)** (#6769 — thanks @chirag127).
|
||||
1
changelog.d/fixes/6790-gemini-pdf-video-attachments.md
Normal file
1
changelog.d/fixes/6790-gemini-pdf-video-attachments.md
Normal file
@@ -0,0 +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, with an independent report/fix from @samimozcan via #6762/#6753).
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(providers): ensure DeepSeek Web SSE emits [DONE] after FINISHED** (#6791 — thanks @Pitchfork-and-Torch).
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(api):** the compression config `PUT` schema (`stackedPipelineStepSchema`) now accepts every `ENGINE_CATALOG` id — the structural engines `session-dedup`/`ccr`/`headroom`/`relevance`/`llmlingua`/`omniglyph` and the `aggressive` `ultra` intensity — so a `GET`→`PUT` round-trip of a stacked pipeline no longer 400s on a valid engine the discriminated union had omitted (#6747 — thanks @Pitchfork-and-Torch).
|
||||
1
changelog.d/fixes/6795-cursor-client-version-build-id.md
Normal file
1
changelog.d/fixes/6795-cursor-client-version-build-id.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cursor):** send the Agent CLI build id as `x-cursor-client-version` so Cursor upstream accepts requests from the current CLI build instead of a stale hardcoded version (#6795 — thanks @andrewmunsell).
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(codex): strip include from compact responses requests** (#6805 — thanks @yinaoxiong).
|
||||
1
changelog.d/fixes/6812-request-count-by-provider-date.md
Normal file
1
changelog.d/fixes/6812-request-count-by-provider-date.md
Normal file
@@ -0,0 +1 @@
|
||||
- **Request count by provider & date**: Dashboard → Analytics now shows a dedicated table of request counts grouped by provider and calendar date (plus token totals), for providers that bill per-request rather than per-token — sortable columns and a single-date filter. New `getProviderDailyUsageRows()` query (`src/lib/db/usageAnalytics.ts`) and its own `GET /api/usage/requests-by-provider-date` route (kept separate from the frozen `/api/usage/analytics` route). Regression guard: `tests/unit/db-provider-daily-usage-4009.test.ts`. (#4009 — thanks @tjengbudi)
|
||||
1
changelog.d/fixes/6817-ollama-cloud-weekly-quota.md
Normal file
1
changelog.d/fixes/6817-ollama-cloud-weekly-quota.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(resilience):** an Ollama Cloud (or any apikey-category provider) account that hit a weekly usage cap kept getting retried every few minutes instead of backing off ([#3709](https://github.com/diegosouzapw/OmniRoute/issues/3709)) — the upstream 429 body ("you (\<account\>) have reached your weekly usage limit") was invisible to `checkFallbackError`'s existing subscription-quota-text classifier (Issue #2321) because that branch is gated by `shouldUseQuotaSignal`, which is oauth-only, so apikey providers like `ollama-cloud` fell through to the generic exponential backoff (~1s, capped at 2min) — one account took 285x429 in 48h. A new `isWeeklyUsageLimitText`/`buildWeeklyQuotaFallback` classifier (extracted, with the existing subscription-quota logic, into a new `open-sse/services/quotaTextCooldowns.ts` module so the frozen `accountFallback.ts` didn't have to grow) runs unconditionally and applies a 24h `QUOTA_EXHAUSTED` cooldown regardless of provider category. Regression guard: `tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts`.
|
||||
1
changelog.d/fixes/6821-budget-tokens-zero-gemini.md
Normal file
1
changelog.d/fixes/6821-budget-tokens-zero-gemini.md
Normal 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).
|
||||
1
changelog.d/fixes/6828-bootstrap-filter-empty-env.md
Normal file
1
changelog.d/fixes/6828-bootstrap-filter-empty-env.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(bootstrap):** filter empty `process.env` values before spawning embedded services so a blank env var no longer crashes the Docker bootstrap in a restart loop (#6828 — thanks @AndrianBalanescu).
|
||||
1
changelog.d/fixes/6829-classify-404-model-not-found.md
Normal file
1
changelog.d/fixes/6829-classify-404-model-not-found.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(providers):** classify upstream `404` responses as `MODEL_NOT_FOUND` (model lockout) instead of a retryable provider error, stopping the retry storm when a single model is missing (#6829 — thanks @AndrianBalanescu).
|
||||
1
changelog.d/maintenance/ci-vps-runner-sharding.md
Normal file
1
changelog.d/maintenance/ci-vps-runner-sharding.md
Normal 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).
|
||||
@@ -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).
|
||||
1
changelog.d/maintenance/readme-strategy-tool-counts.md
Normal file
1
changelog.d/maintenance/readme-strategy-tool-counts.md
Normal 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.
|
||||
@@ -340,6 +340,14 @@
|
||||
"native": "中文 (简体)",
|
||||
"english": "Chinese (Simplified)",
|
||||
"flag": "🇨🇳"
|
||||
},
|
||||
{
|
||||
"code": "zh-TW",
|
||||
"label": "ZH-TW",
|
||||
"name": "中文 (繁體)",
|
||||
"native": "中文 (繁體)",
|
||||
"english": "Chinese (Traditional)",
|
||||
"flag": "🇹🇼"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.",
|
||||
"count": 2053,
|
||||
"count": 2054,
|
||||
"_rebaseline_2026_07_10_v3847_merge_burst": "2053->2054 (+1). Drift herdado do merge burst do dia em release/v3.8.47 (campanha /implement-prs: ~36 PRs mergeados — órfãos, features do dono, ports). O check:complexity NÃO roda no fast-path PR->release, então o ramo acumulou o +1 sem rebaselinar (mesma família de todos os rebaselines abaixo). Trust-but-verify: medido 2054 no tip da release pós-burst; a única função flagada nova é pré-existente (getResolvedModelCapabilities em modelCapabilities.ts, já >teto antes de #6714). Nenhum PR órfão/feature introduz violação NOVA — os fixes deste ciclo são complexity-net-zero. Rebaseline aprovado pelo dono (2026-07-10) para destravar o FQG dos ~7 órfãos verdes-exceto-complexity. Tighten via --update next cycle.",
|
||||
"_rebaseline_2026_07_08_6556_inherited_drift": "2052->2053 (+1). PR #6556 (omniglyph engine): drift herdado do merge burst da base (a catraca nao roda no fast-path PR->release, mesmo padrao dos rebaselines v3.8.44/46). Trust-but-verify: o proprio codigo do PR e complexity-net-zero — as 2 violacoes que ele introduzia (runCompressionAsync complexity 17 apos o branch do modo omniglyph; OmniglyphContextPageClient 161 linhas) foram CORRIGIDAS por extracao real (engines/omniglyphSingleMode.ts + split do componente em section components), medido: 2055->2053 local; base pura origin/release/v3.8.47 mede 2053 identico. Tighten via --update next cycle.",
|
||||
"_rebaseline_2026_07_07_v3846_release_close": "2035->2050 (+15). v3.8.46 release close (generate-release Phase 0 pre-flight): drift herdado do merge burst do ciclo (39 commits do dia + campanha /review-*). Trust-but-verify: os fixes de base-red do captain (agentSkills path.resolve #6366, catalogo cache #6408, tipagem de teste no-explicit-any, MitmProxyTab suppression) sao complexity-net-zero — check:complexity mede 2050 identico com e sem os fixes (a catraca NAO roda no fast-path PR->release, entao o ramo acumulou sem rebaselinar). Tighten via --update next cycle.",
|
||||
"_rebaseline_2026_07_04_v3844_release_close": "2026->2028 (+2). v3.8.44 release close (generate-release Phase 0/1): drift residual do fim do ciclo medido no tip pos-#6155 (merge burst final: #6155 cooling-panel + #6104 Kenari + #6139/#6128 provider-limits). Trust-but-verify: os 2 fixes de codigo do release-captain (model.ts alias boundary, auggie.ts stdin error handlers) adicionam 0 violacoes NOVAS — eslint.complexity direto nos 2 arquivos flagra apenas funcoes que ja estouravam o limite antes (runStreaming/start ja >80 linhas; resolveModelByProviderInference/getModelInfoCore pre-existentes de #5918), e resolveProviderAlias segue abaixo de 15. Logo o +2 e drift herdado do burst. Tighten via --update next cycle.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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. |
|
||||
@@ -894,7 +894,9 @@ changing them requires a code edit, not an env var:
|
||||
| `CURSOR_STREAM_TIMEOUT_MS` | `300000` | `open-sse/executors/cursor.ts` | Stream idle timeout (ms) for the Cursor executor. |
|
||||
| `CURSOR_TOOL_DIRECTIVE` | enabled (`!== "0"`) | `open-sse/executors/cursor.ts` | Tool-commit directive that makes composer-2.5 reliably issue tool calls. Set `0` to disable. |
|
||||
| `CURSOR_IMAGE_FETCH_TIMEOUT_MS` | `15000` | `open-sse/utils/cursorImages.ts` | Per-image fetch timeout (ms) for remote `image_url` vision input. |
|
||||
| `CURSOR_STATE_DB_PATH` | _(probed)_ | `open-sse/utils/cursorVersionDetector.ts` | Override the Cursor state DB lookup used for version detection. |
|
||||
| `CURSOR_STATE_DB_PATH` | _(probed)_ | `open-sse/utils/cursorVersionDetector.ts` | Override the Cursor IDE state DB lookup used for IDE version detection. |
|
||||
| `CURSOR_AGENT_CLI_VERSION` | _(detect / pin)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Agent CLI build id (`YYYY.MM.DD-<hash>`) for `x-cursor-client-version: cli-…` on Agent Run. |
|
||||
| `CURSOR_DATA_DIR` | _(probed)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Override Cursor Agent CLI data dir (`…/versions/<id>`); same var the official agent uses. |
|
||||
| `CURSOR_TOKEN` | _(unset)_ | `scripts/ad-hoc/cursor-tap.cjs` | Direct Cursor bearer token used by developer tooling. |
|
||||
| `OMNIROUTE_LOG_REQUEST_SHAPE` | enabled (`!== "0"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads. Set `"0"` to silence. |
|
||||
| `DEBUG_RESPONSES_SSE_TO_JSON` | _(unset)_ | `open-sse/handlers/responseTranslator.ts` | Set `true` to log Responses API SSE→JSON translation details. |
|
||||
@@ -1092,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`. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
title: "Provider Reference"
|
||||
version: 3.8.47
|
||||
lastUpdated: 2026-07-08
|
||||
lastUpdated: 2026-07-10
|
||||
---
|
||||
|
||||
# Provider Reference
|
||||
|
||||
> **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand.
|
||||
> Regenerate with: `npm run gen:provider-reference`
|
||||
> **Last generated:** 2026-07-08
|
||||
> **Last generated:** 2026-07-10
|
||||
|
||||
Total providers: **248**. See category breakdown below.
|
||||
|
||||
@@ -31,298 +31,298 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
|
||||
|
||||
---
|
||||
|
||||
## OAuth Providers (21)
|
||||
## OAuth Providers (22)
|
||||
|
||||
| ID | Alias | Name | Tags | Website | Notes |
|
||||
| -------------- | ------------ | -------------------- | ----- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `agy` | `agy` | Antigravity CLI | OAuth | [link](https://antigravity.google) | Import your Antigravity CLI (`agy`) login (paste/upload its token file), auto-detect a local CLI login, or sign in with Google. Shares the Antigravity backend (incl. Claude models). |
|
||||
| `amazon-q` | `aq` | Amazon Q | OAuth | [link](https://aws.amazon.com/q/developer/) | Uses the same AWS Builder ID or imported refresh-token flow as Kiro, but keeps Amazon Q connections separate. |
|
||||
| `antigravity` | — | Antigravity | OAuth | — | — |
|
||||
| `claude` | `cc` | Claude Code | OAuth | — | — |
|
||||
| `cline` | `cl` | Cline | OAuth | — | — |
|
||||
| `codebuddy-cn` | `cbcn` | CodeBuddy CN | OAuth | [link](https://copilot.tencent.com) | Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan. |
|
||||
| `codex` | `cx` | OpenAI Codex | OAuth | — | — |
|
||||
| `cursor` | `cu` | Cursor IDE | OAuth | — | — |
|
||||
| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai |
|
||||
| `github` | `gh` | GitHub Copilot | OAuth | — | — |
|
||||
| `gitlab-duo` | `gitlab-duo` | GitLab Duo | OAuth | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance. |
|
||||
| `grok-cli` | `gc` | Grok Build | OAuth | — | Paste your ~/.grok/auth.json (or the JWT access token) from the Grok Build CLI; refresh_token is rotated automatically. |
|
||||
| `kilocode` | `kc` | Kilo Code | OAuth | — | — |
|
||||
| `kimi-coding` | `kmc` | Kimi Coding | OAuth | — | — |
|
||||
| `kiro` | `kr` | Kiro AI | OAuth | — | Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use. |
|
||||
| `qoder` | `if` | Qoder | OAuth | — | — |
|
||||
| `qwen` | `qw` | Qwen Code | OAuth | — | ⚠️ **DEPRECATED.** Qwen OAuth free tier was discontinued on 2026-04-15. Use 'bailian-coding-plan', 'alibaba', 'alibaba-cn', or 'openrouter' provider with API key instead. |
|
||||
| `trae` | `tr` | Trae | OAuth | [link](https://trae.ai) | Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT <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 (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use. |
|
||||
| `qoder` | `if` | Qoder | OAuth | — | — |
|
||||
| `qwen` | `qw` | Qwen Code | OAuth | — | ⚠️ **DEPRECATED.** Qwen OAuth free tier was discontinued on 2026-04-15. Use 'bailian-coding-plan', 'alibaba', 'alibaba-cn', or 'openrouter' provider with API key instead. |
|
||||
| `trae` | `tr` | Trae | OAuth | [link](https://trae.ai) | Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT <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-05 — Opengateway is now a pay-as-you-go credit gateway; no recurring free model. |
|
||||
| `gitlawb-gmi` | `glb-gmi` | Gitlawb Opengateway (GMI Cloud) | API key | [link](https://opengateway.gitlawb.com) | Free Nemotron promo ended 2026-06 — the GMI Cloud route is now pay-as-you-go credit only. |
|
||||
| `glm` | `glm` | GLM Coding | API key | [link](https://z.ai/subscribe) | — |
|
||||
| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — |
|
||||
| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — |
|
||||
| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card |
|
||||
| `hackclub` | `hc` | Hackclub AI | API key, aggregator | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. |
|
||||
| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api |
|
||||
| `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn |
|
||||
| `heroku` | `heroku` | Heroku AI | API key, enterprise | [link](https://www.heroku.com) | — |
|
||||
| `huggingface` | `hf` | HuggingFace | API key | [link](https://huggingface.co) | Free Inference API for thousands of models (Whisper, VITS, SDXL…) |
|
||||
| `hyperbolic` | `hyp` | Hyperbolic | API key | [link](https://hyperbolic.xyz) | $1-5 trial credits on signup for serverless inference |
|
||||
| `ideogram` | `ideo` | Ideogram | API key | [link](https://ideogram.ai) | Get API key at ideogram.ai/docs/api |
|
||||
| `iflytek` | `iflytek` | iFlytek Spark | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn |
|
||||
| `inference-net` | `inet` | Inference.net | API key | [link](https://inference.net) | $25 free credits on signup plus research grants available |
|
||||
| `jina-ai` | `jina` | Jina AI | API key, embed/rerank | [link](https://jina.ai) | Bearer API key for the Jina AI rerank API. |
|
||||
| `jina-reader` | `jr` | Jina Reader | API key | [link](https://jina.ai/reader) | — |
|
||||
| `kenari` | `kenari` | Kenari | API key | [link](https://kenari.id) | Use your Kenari API key (kn-...) in Authorization: Bearer <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-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. |
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -116,6 +116,24 @@ const nextConfig = {
|
||||
...mitmManagerAliasFor(process.env),
|
||||
...minimalBuildAliases,
|
||||
},
|
||||
// src/lib/agentSkills/generator.ts builds its fs base path from a runtime
|
||||
// `outputDir` parameter (`path.join(process.cwd(), outputDir)`), which is
|
||||
// NOT a compile-time literal, so Turbopack's build-time file-tracing
|
||||
// analyzer can't statically narrow the several dynamic readdirSync/rmSync/
|
||||
// readFileSync/writeFileSync call sites a few lines below and falls back
|
||||
// to an "Overly broad patterns... matches N files" warning — once per
|
||||
// Next.js entry point that imports the module (/api/agent-skills/generate,
|
||||
// /api/cli-tools/pi-settings). The fs access is legitimate and bounded
|
||||
// (skills/<id>/SKILL.md, ~48 known IDs), so this is a known-benign,
|
||||
// expected diagnostic — suppress it here rather than fight the analyzer,
|
||||
// mirroring the isNextIntlExtractorDynamicImportWarning precedent below
|
||||
// for the webpack path. (#6582)
|
||||
ignoreIssue: [
|
||||
{
|
||||
path: "**/src/lib/agentSkills/**",
|
||||
description: /Overly broad patterns can lead to build performance issues/,
|
||||
},
|
||||
],
|
||||
},
|
||||
output: "standalone",
|
||||
compress: true,
|
||||
|
||||
@@ -108,6 +108,7 @@ export const PROVIDER_MAX_TOKENS: Record<string, number> = {
|
||||
openai: 16384, // GPT-4/4o standard
|
||||
anthropic: 65536, // Claude models
|
||||
gemini: 65536, // Gemini Studio
|
||||
sensenova: 65536, // SenseNova Token Plan rejects MaxTokens outside [1, 65536]
|
||||
};
|
||||
|
||||
export const DEFAULT_PROVIDER_MAX_TOKENS = 32000;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -75,6 +75,49 @@ export const cursorProvider: RegistryEntry = {
|
||||
{ id: "gpt-5.2-xhigh", name: "GPT 5.2 XHigh" },
|
||||
{ id: "gpt-5.2-xhigh-fast", name: "GPT 5.2 XHigh Fast" },
|
||||
//
|
||||
{ id: "claude-opus-4-8-low", name: "Claude Opus 4.8 Low" },
|
||||
{ id: "claude-opus-4-8-low-fast", name: "Claude Opus 4.8 Low Fast" },
|
||||
{ id: "claude-opus-4-8-medium", name: "Claude Opus 4.8 Medium" },
|
||||
{ id: "claude-opus-4-8-medium-fast", name: "Claude Opus 4.8 Medium Fast" },
|
||||
{ id: "claude-opus-4-8-high", name: "Claude Opus 4.8 High" },
|
||||
{ id: "claude-opus-4-8-high-fast", name: "Claude Opus 4.8 High Fast" },
|
||||
{ id: "claude-opus-4-8-xhigh", name: "Claude Opus 4.8 XHigh" },
|
||||
{ id: "claude-opus-4-8-xhigh-fast", name: "Claude Opus 4.8 XHigh Fast" },
|
||||
{ id: "claude-opus-4-8-max", name: "Claude Opus 4.8 Max" },
|
||||
{ id: "claude-opus-4-8-max-fast", name: "Claude Opus 4.8 Max Fast" },
|
||||
{ id: "claude-opus-4-8-thinking-low", name: "Claude Opus 4.8 Thinking Low" },
|
||||
{ id: "claude-opus-4-8-thinking-low-fast", name: "Claude Opus 4.8 Thinking Low Fast" },
|
||||
{ id: "claude-opus-4-8-thinking-medium", name: "Claude Opus 4.8 Thinking Medium" },
|
||||
{ id: "claude-opus-4-8-thinking-medium-fast", name: "Claude Opus 4.8 Thinking Medium Fast" },
|
||||
{ id: "claude-opus-4-8-thinking-high", name: "Claude Opus 4.8 Thinking High" },
|
||||
{ id: "claude-opus-4-8-thinking-high-fast", name: "Claude Opus 4.8 Thinking High Fast" },
|
||||
{ id: "claude-opus-4-8-thinking-xhigh", name: "Claude Opus 4.8 Thinking XHigh" },
|
||||
{ id: "claude-opus-4-8-thinking-xhigh-fast", name: "Claude Opus 4.8 Thinking XHigh Fast" },
|
||||
{ id: "claude-opus-4-8-thinking-max", name: "Claude Opus 4.8 Thinking Max" },
|
||||
{ id: "claude-opus-4-8-thinking-max-fast", name: "Claude Opus 4.8 Thinking Max Fast" },
|
||||
//
|
||||
{ id: "claude-fable-5-low", name: "Claude Fable 5 Low" },
|
||||
{ id: "claude-fable-5-medium", name: "Claude Fable 5 Medium" },
|
||||
{ id: "claude-fable-5-high", name: "Claude Fable 5 High" },
|
||||
{ id: "claude-fable-5-xhigh", name: "Claude Fable 5 XHigh" },
|
||||
{ id: "claude-fable-5-max", name: "Claude Fable 5 Max" },
|
||||
{ id: "claude-fable-5-thinking-low", name: "Claude Fable 5 Thinking Low" },
|
||||
{ id: "claude-fable-5-thinking-medium", name: "Claude Fable 5 Thinking Medium" },
|
||||
{ id: "claude-fable-5-thinking-high", name: "Claude Fable 5 Thinking High" },
|
||||
{ id: "claude-fable-5-thinking-xhigh", name: "Claude Fable 5 Thinking XHigh" },
|
||||
{ id: "claude-fable-5-thinking-max", name: "Claude Fable 5 Thinking Max" },
|
||||
//
|
||||
{ id: "claude-sonnet-5-low", name: "Claude Sonnet 5 Low" },
|
||||
{ id: "claude-sonnet-5-medium", name: "Claude Sonnet 5 Medium" },
|
||||
{ id: "claude-sonnet-5-high", name: "Claude Sonnet 5 High" },
|
||||
{ id: "claude-sonnet-5-xhigh", name: "Claude Sonnet 5 XHigh" },
|
||||
{ id: "claude-sonnet-5-max", name: "Claude Sonnet 5 Max" },
|
||||
{ id: "claude-sonnet-5-thinking-low", name: "Claude Sonnet 5 Thinking Low" },
|
||||
{ id: "claude-sonnet-5-thinking-medium", name: "Claude Sonnet 5 Thinking Medium" },
|
||||
{ id: "claude-sonnet-5-thinking-high", name: "Claude Sonnet 5 Thinking High" },
|
||||
{ id: "claude-sonnet-5-thinking-xhigh", name: "Claude Sonnet 5 Thinking XHigh" },
|
||||
{ id: "claude-sonnet-5-thinking-max", name: "Claude Sonnet 5 Thinking Max" },
|
||||
//
|
||||
{ id: "claude-opus-4-7-low", name: "Claude Opus 4.7 Low" },
|
||||
{ id: "claude-opus-4-7-medium", name: "Claude Opus 4.7 Medium" },
|
||||
{ id: "claude-opus-4-7-high", name: "Claude Opus 4.7 High" },
|
||||
@@ -106,6 +149,13 @@ export const cursorProvider: RegistryEntry = {
|
||||
//
|
||||
{ id: "grok-4.3", name: "Grok 4.3" },
|
||||
//
|
||||
{ id: "grok-4.5-medium", name: "Grok 4.5 Medium" },
|
||||
{ id: "grok-4.5-fast-medium", name: "Grok 4.5 Fast Medium" },
|
||||
{ id: "grok-4.5-high", name: "Grok 4.5 High" },
|
||||
{ id: "grok-4.5-fast-high", name: "Grok 4.5 Fast High" },
|
||||
{ id: "grok-4.5-xhigh", name: "Grok 4.5 XHigh" },
|
||||
{ id: "grok-4.5-fast-xhigh", name: "Grok 4.5 Fast XHigh" },
|
||||
//
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
],
|
||||
};
|
||||
|
||||
737
open-sse/config/providers/registry/lmarena/directModels.ts
Normal file
737
open-sse/config/providers/registry/lmarena/directModels.ts
Normal 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;
|
||||
}
|
||||
18
open-sse/config/providers/registry/lmarena/index.ts
Normal file
18
open-sse/config/providers/registry/lmarena/index.ts
Normal 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,
|
||||
};
|
||||
@@ -5,23 +5,37 @@ export const sensenovaProvider: RegistryEntry = {
|
||||
alias: "sensenova",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.sensenova.cn/v1/chat/completions",
|
||||
baseUrl: "https://token.sensenova.cn/v1/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
// Sweep 2026-06-19: refreshed against the official SenseCore compatible-mode catalog.
|
||||
// V6.5-Pro is the heavyweight flagship; the 6.7 generation so far ships only flash-lite.
|
||||
// Note the casing split: V6.5 models are PascalCase-dotted, 6.7 is lowercase-dotted.
|
||||
// SenseNova Token Plan (validated 2026-07-06): the Token Plan endpoint is
|
||||
// OpenAI-compatible but enforces max_tokens in [1, 65536]. Its /models list
|
||||
// also currently advertises sensenova-u1-fast, but chat completions return
|
||||
// 404 "model is not found" for that model; U1 Fast belongs to image flows.
|
||||
models: [
|
||||
{ id: "SenseNova-V6.5-Pro", name: "SenseNova V6.5 Pro", contextLength: 131072 },
|
||||
{ id: "SenseNova-V6.5-Turbo", name: "SenseNova V6.5 Turbo", contextLength: 131072 },
|
||||
{ id: "sensenova-6.7-flash-lite", name: "SenseNova 6.7 Flash-Lite" },
|
||||
// DeepSeek V4 Flash is served on SenseNova's free Token Plan (9router#2233).
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
|
||||
{ id: "SenseChat-5", name: "SenseChat 5", contextLength: 131072 },
|
||||
{ id: "SenseChat-5-Cantonese", name: "SenseChat 5 Cantonese", contextLength: 32768 },
|
||||
{ id: "SenseChat-Turbo", name: "SenseChat Turbo", contextLength: 4096 },
|
||||
{ id: "SenseChat-Vision", name: "SenseChat Vision", contextLength: 4096 },
|
||||
{ id: "SenseChat-Character", name: "SenseChat Character", contextLength: 8192 },
|
||||
{ id: "sensechat", name: "SenseChat" },
|
||||
{
|
||||
id: "sensenova-6.7-flash-lite",
|
||||
name: "SenseNova 6.7 Flash-Lite",
|
||||
contextLength: 262144,
|
||||
maxOutputTokens: 65536,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportsReasoning: true,
|
||||
interleavedField: "reasoning_content",
|
||||
},
|
||||
{
|
||||
id: "glm-5.2",
|
||||
name: "GLM 5.2",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportsReasoning: true,
|
||||
interleavedField: "reasoning_content",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -6,12 +6,23 @@ export const xaiProvider: RegistryEntry = {
|
||||
format: "openai",
|
||||
executor: "xai",
|
||||
baseUrl: "https://api.x.ai/v1/chat/completions",
|
||||
// Port of decolua/9router#2439 (author: @ryanngit): xAI ships a native
|
||||
// `/v1/responses` endpoint alongside `/v1/chat/completions`. Consumed by
|
||||
// XaiExecutor.buildUrl (open-sse/executors/xai.ts) for models tagged
|
||||
// targetFormat: "openai-responses" below.
|
||||
responsesBaseUrl: "https://api.x.ai/v1/responses",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "grok-4.3", name: "Grok 4.3" },
|
||||
{ id: "grok-build-0.1", name: "Grok Build 0.1", contextLength: 256000 },
|
||||
{ id: "grok-4.20-multi-agent-0309", name: "Grok 4.20 Multi Agent" },
|
||||
// Responses-only per upstream 9router#2439: xAI serves this id exclusively
|
||||
// over its native /v1/responses endpoint.
|
||||
{
|
||||
id: "grok-4.20-multi-agent-0309",
|
||||
name: "Grok 4.20 Multi Agent",
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
{ id: "grok-4.20-0309-reasoning", name: "Grok 4.20 Reasoning" },
|
||||
{ id: "grok-4.20-0309-non-reasoning", name: "Grok 4.20" },
|
||||
],
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -319,14 +319,36 @@ async function buildClaudeStreamingResponse(
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
|
||||
|
||||
// Content block delta — contains the actual text.
|
||||
if (parsed.type === "content_block_delta") {
|
||||
// Content block start — signals the beginning of a thinking
|
||||
// block. Emit an empty reasoning_content chunk so clients that
|
||||
// key off the field's presence (not just its text) see the
|
||||
// thinking panel open immediately, mirroring the real-Anthropic
|
||||
// translator's content_block_start handling (#6662).
|
||||
if (parsed.type === "content_block_start") {
|
||||
const block = parsed.content_block as Record<string, unknown> | undefined;
|
||||
if (block?.type === "thinking") {
|
||||
const chunk = transformFromClaude("", model, undefined, "reasoning");
|
||||
const out = `data: ${JSON.stringify(chunk)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(out));
|
||||
}
|
||||
}
|
||||
// Content block delta — contains the actual text, or (for a
|
||||
// thinking block) the extended-thinking text. Claude's real SSE
|
||||
// shape uses `delta.text` for text_delta and `delta.thinking`
|
||||
// for thinking_delta — never both — so a plain field check is
|
||||
// enough to route each to the right OpenAI delta field.
|
||||
else if (parsed.type === "content_block_delta") {
|
||||
const delta = parsed.delta as Record<string, unknown> | undefined;
|
||||
const text = delta?.text as string | undefined;
|
||||
const thinking = delta?.thinking as string | undefined;
|
||||
if (text) {
|
||||
const chunk = transformFromClaude(text, model);
|
||||
const out = `data: ${JSON.stringify(chunk)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(out));
|
||||
} else if (thinking) {
|
||||
const chunk = transformFromClaude(thinking, model, undefined, "reasoning");
|
||||
const out = `data: ${JSON.stringify(chunk)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(out));
|
||||
}
|
||||
}
|
||||
// message_stop — final event from Claude.
|
||||
@@ -362,11 +384,17 @@ async function buildClaudeStreamingResponse(
|
||||
if (parsed.type === "content_block_delta") {
|
||||
const delta = parsed.delta as Record<string, unknown> | undefined;
|
||||
const text = delta?.text as string | undefined;
|
||||
const thinking = delta?.thinking as string | undefined;
|
||||
if (text) {
|
||||
const chunk = transformFromClaude(text, model);
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
);
|
||||
} else if (thinking) {
|
||||
const chunk = transformFromClaude(thinking, model, undefined, "reasoning");
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -152,6 +152,36 @@ export function getDefaultPersonalizedStyle(): ClaudeWebRequestPayload["personal
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether an OpenAI-shape request body signals a desire for
|
||||
* reasoning / extended thinking — a top-level `reasoning_effort` string,
|
||||
* a Responses-API-style `reasoning.effort`, or a native Claude
|
||||
* `thinking: { type: "enabled" }` passthrough. Mirrors the same
|
||||
* effort-extraction shape used by `sanitizeReasoningEffortForProvider`
|
||||
* (open-sse/executors/base/reasoningEffort.ts) so a client already setting
|
||||
* reasoning_effort for other providers gets the same signal here.
|
||||
*
|
||||
* Before this, `transformToClaude` hardcoded `thinking_mode: "off"` —
|
||||
* Claude Web could never be asked for extended thinking, and any
|
||||
* `thinking_delta` reasoning the upstream might otherwise emit was moot
|
||||
* because it was never requested in the first place (#6662).
|
||||
*/
|
||||
export function wantsExtendedThinking(body: Record<string, unknown>): boolean {
|
||||
const reasoning =
|
||||
body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning)
|
||||
? (body.reasoning as Record<string, unknown>)
|
||||
: null;
|
||||
const effort = body.reasoning_effort ?? reasoning?.effort;
|
||||
if (typeof effort === "string" && effort.trim() && effort.toLowerCase() !== "none") {
|
||||
return true;
|
||||
}
|
||||
const thinking = body.thinking;
|
||||
if (thinking && typeof thinking === "object" && !Array.isArray(thinking)) {
|
||||
if ((thinking as Record<string, unknown>).type === "enabled") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform OpenAI format to Claude Web format
|
||||
*/
|
||||
@@ -189,7 +219,7 @@ export function transformToClaude(
|
||||
files: [],
|
||||
sync_sources: [],
|
||||
rendering_mode: "messages",
|
||||
thinking_mode: "off",
|
||||
thinking_mode: wantsExtendedThinking(body) ? "on" : "off",
|
||||
create_conversation_params: {
|
||||
name: "",
|
||||
model: model || DEFAULT_CLAUDE_MODEL,
|
||||
@@ -204,13 +234,24 @@ export function transformToClaude(
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform Claude Web response to OpenAI format
|
||||
* Transform Claude Web response to OpenAI format.
|
||||
*
|
||||
* `kind` selects which delta field carries `claudeContent`: `"content"`
|
||||
* (default, preserves the original call sites) or `"reasoning"` — the
|
||||
* latter maps Claude's `thinking_delta` text onto `delta.reasoning_content`,
|
||||
* the same field the real-Anthropic-API translator uses
|
||||
* (open-sse/translator/response/claude-to-openai.ts) so downstream clients
|
||||
* (Claude Code, Cursor, etc.) render it as the thinking panel instead of
|
||||
* silently dropping it (#6662).
|
||||
*/
|
||||
export function transformFromClaude(
|
||||
claudeContent: string,
|
||||
model: string,
|
||||
stopReason?: string
|
||||
stopReason?: string,
|
||||
kind: "content" | "reasoning" = "content"
|
||||
): Record<string, unknown> {
|
||||
const delta: Record<string, string> =
|
||||
kind === "reasoning" ? { reasoning_content: claudeContent } : { content: claudeContent };
|
||||
return {
|
||||
id: `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
@@ -219,9 +260,7 @@ export function transformFromClaude(
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: claudeContent,
|
||||
},
|
||||
delta,
|
||||
finish_reason: stopReason === "end_turn" ? "stop" : null,
|
||||
logprobs: null,
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
CODEX_CHAT_DEFAULT_INSTRUCTIONS,
|
||||
CODEX_DEFAULT_INSTRUCTIONS,
|
||||
} from "../config/codexInstructions.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { HTTP_STATUS, PROVIDERS } from "../config/constants.ts";
|
||||
import {
|
||||
getCodexClientVersion,
|
||||
getCodexUserAgent,
|
||||
@@ -34,6 +34,7 @@ import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer
|
||||
import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts";
|
||||
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
|
||||
import { CORS_HEADERS } from "../utils/cors.ts";
|
||||
import { errorResponse } from "../utils/error.ts";
|
||||
import { normalizeCodexResponsesInput } from "../utils/responsesInputNormalization.ts";
|
||||
import * as prl from "../utils/providerRequestLogging.ts";
|
||||
import { createRequire } from "module";
|
||||
@@ -550,6 +551,145 @@ export function filterNonstandardCodexSse(response: Response): Response {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Sub-bug #3 of upstream decolua/9router#2452 (@ryanngit) ─────────────────
|
||||
// Codex sometimes answers with HTTP 200 and a text/event-stream body whose
|
||||
// payload carries a transient "model at capacity" / overloaded error mid-stream,
|
||||
// e.g. { "error": { "message": "Selected model is at capacity..." } },
|
||||
// server_is_overloaded, or service_unavailable_error. Left as a 200, this looks
|
||||
// like a successful response to every caller — no retry, no circuit breaker, no
|
||||
// combo/account fallback engages (open-sse/services/accountFallback.ts never
|
||||
// sees a failure status). Peek the first few SSE bytes; when a transient-error
|
||||
// signature is found, convert the response into a real 503 so account rotation
|
||||
// kicks in. Otherwise re-assemble the stream from the peeked prefix + the
|
||||
// remaining upstream body so the passthrough stays byte-identical.
|
||||
const CODEX_SSE_TRANSIENT_ERROR_PATTERNS = [
|
||||
"selected model is at capacity",
|
||||
"server_is_overloaded",
|
||||
"service_unavailable_error",
|
||||
] as const;
|
||||
// A capacity/overloaded rejection is delivered as the very first SSE event, so a
|
||||
// small peek window is enough — this bounds how much of a legitimate response we
|
||||
// buffer before giving up and passing the stream through unchanged.
|
||||
const CODEX_SSE_PEEK_MAX_BYTES = 8192;
|
||||
|
||||
/**
|
||||
* Best-effort extraction of the human-readable error message from a peeked SSE
|
||||
* chunk, so the resulting 503 body carries something more useful than the raw
|
||||
* pattern that matched. Falls back to the matched pattern when no structured
|
||||
* `data:` payload could be parsed.
|
||||
*/
|
||||
function extractCodexSseErrorMessage(text: string, fallback: string): string {
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice("data:".length).trim();
|
||||
if (!data || data === "[DONE]") continue;
|
||||
try {
|
||||
const parsed = JSON.parse(data) as Record<string, unknown>;
|
||||
const directError = parsed.error as Record<string, unknown> | undefined;
|
||||
const nestedError = (parsed.response as Record<string, unknown> | undefined)?.error as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const message =
|
||||
(typeof directError?.message === "string" && directError.message) ||
|
||||
(typeof nestedError?.message === "string" && nestedError.message) ||
|
||||
(typeof parsed.message === "string" && parsed.message);
|
||||
if (message) return message;
|
||||
} catch {
|
||||
// Non-JSON SSE data line — keep scanning subsequent lines.
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
type CodexSseTransientErrorPeek =
|
||||
| { matched: string; message: string; replacementBody: null }
|
||||
| { matched: null; message: null; replacementBody: ReadableStream<Uint8Array> | null };
|
||||
|
||||
/**
|
||||
* Peek the first bytes of a Codex SSE response body looking for a transient
|
||||
* error embedded in an otherwise 200-OK stream. Exported for unit testing.
|
||||
*/
|
||||
export async function peekCodexSseTransientError(
|
||||
response: Response
|
||||
): Promise<CodexSseTransientErrorPeek> {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!response.ok || !response.body || !contentType.includes("text/event-stream")) {
|
||||
return { matched: null, message: null, replacementBody: null };
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let text = "";
|
||||
let matched: string | null = null;
|
||||
|
||||
try {
|
||||
while (text.length < CODEX_SSE_PEEK_MAX_BYTES) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
text += decoder.decode(value, { stream: true });
|
||||
const lower = text.toLowerCase();
|
||||
const hit = CODEX_SSE_TRANSIENT_ERROR_PATTERNS.find((pattern) => lower.includes(pattern));
|
||||
if (hit) {
|
||||
matched = hit;
|
||||
break;
|
||||
}
|
||||
// A real content/completion event this early means the response is
|
||||
// healthy — stop peeking so we do not needlessly buffer a long stream.
|
||||
if (
|
||||
lower.includes('"type":"response.output_text.delta"') ||
|
||||
lower.includes('"type":"response.completed"')
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[codex] peekCodexSseTransientError: read error, passing stream through: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// Upstream socket may already be closing; nothing to clean up.
|
||||
}
|
||||
return { matched, message: extractCodexSseErrorMessage(text, matched), replacementBody: null };
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
|
||||
// Re-assemble the stream: peeked prefix chunks, then continue draining the
|
||||
// same underlying body so bytes downstream of the peek window are untouched.
|
||||
const upstreamReader = response.body.getReader();
|
||||
const replacementBody = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(chunk);
|
||||
},
|
||||
async pull(controller) {
|
||||
const { done, value } = await upstreamReader.read();
|
||||
if (done) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(value);
|
||||
},
|
||||
cancel(reason) {
|
||||
try {
|
||||
upstreamReader.cancel(reason);
|
||||
} catch {
|
||||
// noop — upstream socket may already be closing.
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { matched: null, message: null, replacementBody };
|
||||
}
|
||||
|
||||
export function encodeResponseSseEvent(raw: string): { sse: string; terminal: boolean } {
|
||||
let eventType = "message";
|
||||
let payload = raw;
|
||||
@@ -664,6 +804,26 @@ export class CodexExecutor extends BaseExecutor {
|
||||
(httpResult as { response: Response }).response = filterNonstandardCodexSse(resp);
|
||||
}
|
||||
}
|
||||
const resp = (httpResult as { response?: Response }).response;
|
||||
if (resp) {
|
||||
const peek = await peekCodexSseTransientError(resp);
|
||||
if (peek.matched) {
|
||||
input.log?.warn?.(
|
||||
"RETRY",
|
||||
`CODEX | 200-OK SSE carried transient error "${peek.matched}" — converting to 503 for account fallback`
|
||||
);
|
||||
(httpResult as { response: Response }).response = errorResponse(
|
||||
HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
peek.message
|
||||
);
|
||||
} else if (peek.replacementBody) {
|
||||
(httpResult as { response: Response }).response = new Response(peek.replacementBody, {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
headers: resp.headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
return httpResult;
|
||||
}
|
||||
|
||||
@@ -894,7 +1054,9 @@ export class CodexExecutor extends BaseExecutor {
|
||||
headers["chatgpt-account-id"] = workspaceId;
|
||||
}
|
||||
const clientIdentity = credentials?.providerSpecificData?.codexClientIdentity as
|
||||
CodexClientIdentity | null | undefined;
|
||||
| CodexClientIdentity
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
// Originator header — identifies the client type to the Codex backend.
|
||||
// Ref: openai/codex login/src/auth/default_client.rs DEFAULT_ORIGINATOR = "codex_cli_rs"
|
||||
@@ -1001,6 +1163,7 @@ export class CodexExecutor extends BaseExecutor {
|
||||
delete body.stream;
|
||||
delete body.stream_options;
|
||||
delete body.client_metadata;
|
||||
delete body.include;
|
||||
} else {
|
||||
body.stream = true;
|
||||
}
|
||||
@@ -1129,7 +1292,11 @@ export class CodexExecutor extends BaseExecutor {
|
||||
// Cursor may include custom tools (e.g. ApplyPatch) that work locally but are
|
||||
// invalid upstream, and translation bugs can leave orphaned/empty tool_choice names.
|
||||
normalizeCodexTools(body, {
|
||||
dropImageGeneration: isCodexFreePlan(credentials?.providerSpecificData),
|
||||
// gpt-5.3-codex-spark (and other Spark-scope models) reject image_generation
|
||||
// upstream even on paid-plan accounts, so drop it independent of plan (#6651).
|
||||
dropImageGeneration:
|
||||
isCodexFreePlan(credentials?.providerSpecificData) ||
|
||||
getCodexModelScope(model) === "spark",
|
||||
preserveCustomTools: nativeCodexPassthrough,
|
||||
});
|
||||
|
||||
@@ -1174,6 +1341,9 @@ export class CodexExecutor extends BaseExecutor {
|
||||
};
|
||||
}
|
||||
ensureCodexReasoningSummary(body);
|
||||
if (isCompactRequest) {
|
||||
delete body.include;
|
||||
}
|
||||
delete body.reasoning_effort;
|
||||
|
||||
// Remove unsupported token limit parameters BEFORE the passthrough return.
|
||||
@@ -1214,7 +1384,9 @@ export class CodexExecutor extends BaseExecutor {
|
||||
applyCodexClientMetadata(
|
||||
body,
|
||||
credentials?.providerSpecificData?.codexClientIdentity as
|
||||
CodexClientIdentity | null | undefined
|
||||
| CodexClientIdentity
|
||||
| null
|
||||
| undefined
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,10 @@ import {
|
||||
estimateOutputTokens,
|
||||
addBufferToUsage,
|
||||
} from "../utils/usageTracking.ts";
|
||||
import { getCursorVersion } from "../utils/cursorVersionDetector.ts";
|
||||
import {
|
||||
formatCursorAgentClientVersion,
|
||||
getCursorAgentCliVersion,
|
||||
} from "../utils/cursorAgentCliVersion.ts";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { generateToolCallId } from "../translator/helpers/toolCallHelper.ts";
|
||||
import {
|
||||
@@ -647,7 +650,7 @@ export class CursorExecutor extends BaseExecutor {
|
||||
traceparent: traceParent,
|
||||
"user-agent": "connect-es/1.6.1",
|
||||
"x-cursor-client-type": "cli",
|
||||
"x-cursor-client-version": `cli-${getCursorVersion()}`,
|
||||
"x-cursor-client-version": formatCursorAgentClientVersion(getCursorAgentCliVersion()),
|
||||
"x-ghost-mode": ghostMode ? "true" : "false",
|
||||
"x-original-request-id": requestId,
|
||||
"x-request-id": requestId,
|
||||
|
||||
68
open-sse/executors/deepseek-web-done-terminator.ts
Normal file
68
open-sse/executors/deepseek-web-done-terminator.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// ── DeepSeek Web SSE "done terminator" helpers ──────────────────────────
|
||||
//
|
||||
// Extracted from deepseek-web.ts (frozen line-count) so the drain/guard
|
||||
// state machine used to close the OpenAI-compatible SSE after DeepSeek's
|
||||
// `response/status=FINISHED` event can grow without touching the frozen
|
||||
// file. See #6777: upstreams that leave the HTTP body open hang OpenAI SDK
|
||||
// clients that wait for `data: [DONE]` after `finish_reason: stop`.
|
||||
|
||||
/** How long to wait after DeepSeek `response/status=FINISHED` for trailing
|
||||
* search_results before closing the OpenAI-compatible SSE. */
|
||||
export const DEEPSEEK_FINISHED_DRAIN_MS = 750;
|
||||
|
||||
/** Wraps a stream-finishing callback so it runs at most once and never
|
||||
* throws past a controller that the client already cancelled/closed. */
|
||||
export function createFinishOnceGuard(finish: () => void): {
|
||||
finishOnce: () => void;
|
||||
hasFinished: () => boolean;
|
||||
} {
|
||||
let streamFinished = false;
|
||||
return {
|
||||
finishOnce: () => {
|
||||
if (streamFinished) return;
|
||||
streamFinished = true;
|
||||
try {
|
||||
finish();
|
||||
} catch {
|
||||
// Controller may already be closed if the client cancelled.
|
||||
}
|
||||
},
|
||||
hasFinished: () => streamFinished,
|
||||
};
|
||||
}
|
||||
|
||||
/** Schedules `finishStream` after a short drain window following
|
||||
* `response/status=FINISHED`, so late `search_results` payloads still get
|
||||
* captured, while guaranteeing the stream always closes even if the
|
||||
* upstream body stays open past that window. */
|
||||
export function createFinishedDrainScheduler(
|
||||
finishStream: () => void,
|
||||
drainMs: number = DEEPSEEK_FINISHED_DRAIN_MS
|
||||
): {
|
||||
scheduleFinishAfterDrain: () => void;
|
||||
clearFinishedDrain: () => void;
|
||||
isDrainPending: () => boolean;
|
||||
} {
|
||||
let finishedDrainTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearFinishedDrain = () => {
|
||||
if (finishedDrainTimer) {
|
||||
clearTimeout(finishedDrainTimer);
|
||||
finishedDrainTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleFinishAfterDrain = () => {
|
||||
clearFinishedDrain();
|
||||
finishedDrainTimer = setTimeout(() => {
|
||||
finishedDrainTimer = null;
|
||||
finishStream();
|
||||
}, drainMs);
|
||||
};
|
||||
|
||||
return {
|
||||
scheduleFinishAfterDrain,
|
||||
clearFinishedDrain,
|
||||
isDrainPending: () => finishedDrainTimer !== null,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
appendSearchCitations,
|
||||
type DeepSeekSearchResult,
|
||||
} from "./deepseek-web/stream-format.ts";
|
||||
import {
|
||||
createFinishOnceGuard,
|
||||
createFinishedDrainScheduler,
|
||||
} from "./deepseek-web-done-terminator.ts";
|
||||
|
||||
export const DEEPSEEK_WEB_BASE = "https://chat.deepseek.com";
|
||||
const DEEPSEEK_API_BASE = `${DEEPSEEK_WEB_BASE}/api`;
|
||||
@@ -198,7 +202,7 @@ function transformSSE(deepseekStream: ReadableStream, model: string): ReadableSt
|
||||
}
|
||||
};
|
||||
|
||||
const finishStream = () => {
|
||||
const { finishOnce: finishStream, hasFinished } = createFinishOnceGuard(() => {
|
||||
const citations = appendSearchCitations(searchResults, streamModel);
|
||||
if (citations) {
|
||||
ensureRole();
|
||||
@@ -206,9 +210,16 @@ function transformSSE(deepseekStream: ReadableStream, model: string): ReadableSt
|
||||
}
|
||||
ensureRole();
|
||||
chunk({}, "stop");
|
||||
// OpenAI-compatible clients (SDK, OpenCode) hang without this terminator.
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
};
|
||||
});
|
||||
|
||||
// Do not close *immediately* on FINISHED — DeepSeek may still send
|
||||
// search_results afterward. Drain briefly, then always emit
|
||||
// stop + [DONE] so clients do not hang if the upstream body stays open.
|
||||
const { scheduleFinishAfterDrain, clearFinishedDrain, isDrainPending } =
|
||||
createFinishedDrainScheduler(finishStream);
|
||||
|
||||
const sendByPath = (raw: string) => {
|
||||
const text = formatStreamContent(raw, streamModel);
|
||||
@@ -324,19 +335,32 @@ function transformSSE(deepseekStream: ReadableStream, model: string): ReadableSt
|
||||
}
|
||||
}
|
||||
|
||||
// Do not close on FINISHED — DeepSeek may still send search_results afterward.
|
||||
if (p === "response/status" && v === "FINISHED") {
|
||||
scheduleFinishAfterDrain();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Any other post-FINISHED payload extends the drain window so we
|
||||
// still capture late search_results before closing.
|
||||
if (isDrainPending()) {
|
||||
scheduleFinishAfterDrain();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
clearFinishedDrain();
|
||||
if (!hasFinished()) {
|
||||
controller.error(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
finishStream();
|
||||
},
|
||||
cancel() {
|
||||
// Best-effort: cancel upstream reader if the client aborts mid-stream.
|
||||
// finishStream is not required here — the controller is already cancelled.
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 16384 }
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
103
open-sse/executors/lmarena/cookie.ts
Normal file
103
open-sse/executors/lmarena/cookie.ts
Normal 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 "";
|
||||
}
|
||||
307
open-sse/executors/lmarena/models.ts
Normal file
307
open-sse/executors/lmarena/models.ts
Normal 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 ~100–130 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;
|
||||
}
|
||||
}
|
||||
305
open-sse/executors/lmarena/response.ts
Normal file
305
open-sse/executors/lmarena/response.ts
Normal 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" } }
|
||||
);
|
||||
}
|
||||
132
open-sse/executors/lmarena/stream.ts
Normal file
132
open-sse/executors/lmarena/stream.ts
Normal 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");
|
||||
}
|
||||
@@ -67,10 +67,13 @@ export class V0VercelWebExecutor extends BaseExecutor {
|
||||
|
||||
if (!wantStream) {
|
||||
const data = (await upstream.json()) as Record<string, unknown>;
|
||||
const content =
|
||||
(data?.choices as Array<{ message?: { content?: string } }>)?.[0]?.message?.content ||
|
||||
(data?.content as string) ||
|
||||
"";
|
||||
const message = (data?.choices as Array<{ message?: Record<string, unknown> }>)?.[0]
|
||||
?.message;
|
||||
const content = (message?.content as string) || (data?.content as string) || "";
|
||||
const reasoningContent =
|
||||
(message?.reasoning_content as string) || (data?.reasoning_content as string) || "";
|
||||
const responseMessage: Record<string, unknown> = { role: "assistant", content };
|
||||
if (reasoningContent) responseMessage.reasoning_content = reasoningContent;
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
@@ -81,7 +84,7 @@ export class V0VercelWebExecutor extends BaseExecutor {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content },
|
||||
message: responseMessage,
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
@@ -123,14 +126,19 @@ export class V0VercelWebExecutor extends BaseExecutor {
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const text = parsed.choices?.[0]?.delta?.content || "";
|
||||
if (text) {
|
||||
const delta = parsed.choices?.[0]?.delta || {};
|
||||
const text = delta.content || "";
|
||||
const reasoningText = delta.reasoning_content || "";
|
||||
if (text || reasoningText) {
|
||||
const outDelta: Record<string, string> = {};
|
||||
if (reasoningText) outDelta.reasoning_content = reasoningText;
|
||||
if (text) outDelta.content = text;
|
||||
const chunk = {
|
||||
id: `chatcmpl-v0-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: modelId,
|
||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||
choices: [{ index: 0, delta: outDelta, finish_reason: null }],
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BaseExecutor, type ProviderCredentials } from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { getModelTargetFormat } from "../config/providerModels.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -51,6 +52,24 @@ export class XaiExecutor extends BaseExecutor {
|
||||
super("xai", PROVIDERS.xai);
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of decolua/9router#2439 (author: @ryanngit): xAI ships a native
|
||||
* `/v1/responses` endpoint alongside `/v1/chat/completions`. Models tagged
|
||||
* `targetFormat: "openai-responses"` in the registry (currently
|
||||
* grok-4.20-multi-agent-0309, per upstream) resolve to that endpoint instead
|
||||
* of the default chat-completions bridge. The per-model registry tag is the
|
||||
* single source of truth — it also drives chatCore's body translation — so
|
||||
* the URL stays in lockstep with the translated body, mirroring the gh
|
||||
* executor's targetFormat-driven routing (9router#102) and the "openai"
|
||||
* -pro heuristic in open-sse/executors/default.ts.
|
||||
*/
|
||||
buildUrl(model: string, _stream: boolean, _urlIndex = 0) {
|
||||
if (getModelTargetFormat("xai", model) === "openai-responses") {
|
||||
return this.config.responsesBaseUrl || this.config.baseUrl;
|
||||
}
|
||||
return this.config.baseUrl;
|
||||
}
|
||||
|
||||
transformRequest(
|
||||
model: string,
|
||||
body: unknown,
|
||||
|
||||
@@ -3306,6 +3306,16 @@ export async function handleChatCore({
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} project routing error (${statusCode}) — not banning`
|
||||
);
|
||||
} else if (errorType === PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND) {
|
||||
// 404 — model/endpoint does not exist upstream. Lock the model so the
|
||||
// retry/backoff loop stops hammering the dead endpoint (which would
|
||||
// otherwise degenerate into a 429 rate-limit storm). Connection stays
|
||||
// active since only the specific model is unavailable. (#6827)
|
||||
const notFoundCooldownMs = COOLDOWN_MS.notFound;
|
||||
lockModel(provider, errorConnectionId, currentModel, "model_not_found", notFoundCooldownMs);
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} model not found (${statusCode}) for ${currentModel} - locking model for ${Math.ceil(notFoundCooldownMs / 1000)}s (connection stays active)`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort state update; request flow should continue with fallback handling.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user