diff --git a/.env.example b/.env.example index 31fe954c12..546cd4dd69 100644 --- a/.env.example +++ b/.env.example @@ -86,8 +86,8 @@ PORT=20128 # Port for the real-time WebSocket live monitoring server. # Used by: src/server/ws/liveServer.ts, src/app/api/v1/ws/route.ts -# Default: 20129 -# LIVE_WS_PORT=20129 +# Default: 20132 +# LIVE_WS_PORT=20132 # Bind address for the live WebSocket server. # Default: 127.0.0.1 (loopback only). Set to 0.0.0.0 to expose on LAN — @@ -112,16 +112,14 @@ PORT=20128 # Public URL for the live dashboard WebSocket (client-side, browser only). # Set this when fronting the WS server with a reverse proxy or Cloudflare Tunnel. -# The browser will connect to this URL instead of ws://hostname:20129. -# The /live-ws path is already proxied from the main app (port 20128) to the -# live WS server (port 20129) by scripts/dev/standalone-server-ws.mjs. -# Used by: src/hooks/useLiveDashboard.ts +# The browser will connect to this URL instead of ws://hostname:20132. +# The path portion of this URL (e.g. ws://localhost:20132/live-ws -> /live-ws) is also used by the dev proxy +# (scripts/dev/standalone-server-ws.mjs) and the handshake response to route +# WebSocket upgrades. Default path: /live-ws. +# Used by: src/hooks/useLiveDashboard.ts, src/app/api/v1/ws/route.ts, +# scripts/dev/standalone-server-ws.mjs, and scripts/start-ws-server.mjs. # Example: NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=wss://ws.my-ai.com/live-ws -# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL= - -# Disable the standalone live WebSocket helper used by scripts/start-ws-server.mjs. -# Used by: scripts/start-ws-server.mjs (CI/embedded harness toggle). -# OMNIROUTE_DISABLE_LIVE_WS=0 +# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=ws://localhost:20132/live-ws # Enable the real-time dashboard WebSocket server. # Used by: src/server/ws/liveServer.ts, scripts/start-ws-server.mjs @@ -197,9 +195,9 @@ OMNIROUTE_USE_TURBOPACK=1 # the machine name by bash/zsh. The .env loader cannot override it (first-wins # semantics). Use OMNIROUTE_SERVER_HOST instead for `omniroute serve`. # See: https://github.com/diegosouzapw/OmniRoute/issues/6194 -#HOST=0.0.0.0 -#HOSTNAME=127.0.0.1 -#OMNIROUTE_SERVER_HOST=0.0.0.0 +# HOST=0.0.0.0 +# HOSTNAME=127.0.0.1 +# OMNIROUTE_SERVER_HOST=0.0.0.0 # Environment mode — affects Next.js behavior, logging verbosity, and caching. # Values: production | development | Default: production @@ -1529,6 +1527,11 @@ APP_LOG_TO_FILE=true # Timeout for fast-fail health checks (ms). Default: 2000 # PROXY_FAST_FAIL_TIMEOUT_MS=2000 +# Time window (hours) for calculating the average latency of candidate proxies +# in the latency-optimized pool strategy. Default: 3 +# Used by: src/lib/db/proxies.ts +# PROXY_LATENCY_WINDOW_HOURS=3 + # Health check result cache TTL (ms). Default: 30000 (30s) # PROXY_HEALTH_CACHE_TTL_MS=30000 @@ -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-). +# 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 /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 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index d1e765cb79..04d69911ec 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -108,7 +108,26 @@ jobs: fi echo "Running impacted tests:"; echo "$SEL" mapfile -t FILES <<< "$SEL" - node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${FILES[@]}" + # Loader parity with test:unit:ci:shard (#6787): tests/unit/dashboard/** runs + # under `--import tsx` (CJS transform — required for ESM-only deep imports like + # @lobehub/icons/es/* reached via lobeProviderIcons.ts); everything else under + # `--import tsx/esm`. A single tsx/esm invocation false-reds every dashboard + # module-shape test the impact map selects ("Unexpected token 'export'"). + DASH=(); REST=() + for f in "${FILES[@]}"; do + case "$f" in + tests/unit/dashboard/*) DASH+=("$f") ;; + *) REST+=("$f") ;; + esac + done + RC=0 + if [ ${#REST[@]} -gt 0 ]; then + node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${REST[@]}" || RC=$? + fi + if [ ${#DASH[@]} -gt 0 ]; then + node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$? + fi + exit $RC fast-vitest: name: Vitest (fast-path) @@ -130,15 +149,17 @@ jobs: - run: npm run test:vitest fast-unit: - name: Unit Tests fast-path (${{ matrix.shard }}/2) + name: Unit Tests fast-path (${{ matrix.shard }}/4) # Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest). - # This is the heaviest fast-path job (~9min on ubuntu-latest); the 32-core VPS - # cuts it to ~2-3min when the flag is on. + # This is the heaviest fast-path job; 4-way sharding (was 2) halves the critical + # path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot runner box). + # Node's native --test-shard=N/total takes any denominator — only this matrix and + # the TEST_SHARD env below encode the shard count. runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} strategy: fail-fast: false matrix: - shard: [1, 2] + shard: [1, 2, 3, 4] env: JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-lint-api-key-secret-long @@ -157,7 +178,7 @@ jobs: # silenciosamente não rodavam no fast path) e o setupPolyfill não era importado. - run: npm run test:unit:ci:shard env: - TEST_SHARD: ${{ matrix.shard }}/2 + TEST_SHARD: ${{ matrix.shard }}/4 # ── Pacote 4 (plano mestre testes+CI, aprovado 2026-07-04) ───────────────────────── # No-new-warnings por PR via ESLint bulk suppressions nativo (>=9.24). O baseline diff --git a/CHANGELOG.md b/CHANGELOG.md index 8921b1e97d..1d58485e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (parallel-cycle model; cycle opened at the v3.8.46 release freeze). Finalized at the v3.8.47 release._ +- **9router Codex import**: the Codex bulk-import endpoint (`POST /api/oauth/codex/import`) now accepts 9router's camelCase account export (`accessToken`/`refreshToken`/`idToken`/`expiresAt` + nested `providerSpecificData`), not just snake_case — `normalizeCodexImportRecord` maps the camelCase aliases onto the existing snake_case keys, filling each only when absent so snake_case/mixed exports keep working unchanged ([#6665](https://github.com/diegosouzapw/OmniRoute/issues/6665)) — thanks @deadcoder0904. Regression guard: `tests/unit/codexBulkImport.test.ts` (9router camelCase record, pre-supplied `providerSpecificData` without an id_token, snake_case-not-overridden, and a full `{accounts:[...]}` flatten). + ### ✨ New Features - **Provider/model param filters**: config-driven parameter denylist/allowlist per provider/model with auto-learn from upstream 400s (#6649 — thanks @ThongAccount, closes #6625) @@ -20,12 +22,15 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **Kiro long-lived API key auth**: new `/api/oauth/kiro/api-key` route + `KiroService.validateApiKey` let a Kiro account be linked with a long-lived AWS CodeWhisperer/Kiro API key instead of the interactive OAuth device flow, with live per-account model discovery (`ListAvailableModels`, 5-minute cache) layered over the existing static registry fallback (#6587 — thanks @strangersp) - **Chaos Mode**: multi-model parallel/collaborative task execution — dispatches a task to every active provider connection at once (parallel) or chains outputs sequentially so each model builds on the previous one's answer (collaborative), configurable via Dashboard → Chaos Mode (`GET`/`PUT`/`DELETE /api/chaos/config`) and gated per-API-key via a new `chaosModeEnabled` permission (opt-in — disabled by default globally and per key). `POST /api/chaos/run` (dashboard session) and `POST /api/skills/collect/chaos` (external Bearer-token) delegate to a shared `executeChaosRun()` engine (`src/lib/chaos/chaosExecutor.ts`) that dispatches in-process via the established synthetic-Request/route-handler pattern (no network hop, no hardcoded port), with a concurrency cap (max 10 parallel), configurable `max_tokens` (256–128k), a clear error when `stream` is requested, and collaborative-chain info (provider order + input size). Fixes external Bearer-auth bypass and stale config-cache leakage. Regression guard: `tests/unit/chaos-config.test.ts`, `tests/unit/chaos-executor.test.ts`, `tests/unit/chaos-api-routes.test.ts`. ([#6728](https://github.com/diegosouzapw/OmniRoute/pull/6728) — thanks @Moseyuh333) - **feat(cli):** 2 new CLI tool integrations on Dashboard → CLI Tools — **omp** (Oh My Pi) and **letta** — each with binary detection, config apply/reset, and a settings card following the existing tool-card pattern. Both settings routes shell out to `which omp`/`which letta` to detect the local install, so they're loopback-gated (`LOCAL_ONLY_API_PREFIXES`, Hard Rules #15/#17) in addition to the shared `requireCliToolsAuth()` management-auth guard every cli-tools route requires, and route errors through `sanitizeErrorMessage()`; `src/lib/db/omp.ts` isolates the `omp` CLI's own local SQLite reads behind parameterized queries. (Note: the original PR also proposed **pi**, **codewhale**, and **jcode** integrations — those three had already shipped via a separate PR by the time this one was reconciled, so only omp+letta landed here.) Regression guard: `tests/unit/db/omp.test.ts`, `tests/unit/cli-tools-auth-hardening.test.ts`, `tests/integration/cli-settings-omp.test.ts`, `tests/integration/cli-settings-letta.test.ts`. ([#6318](https://github.com/diegosouzapw/OmniRoute/pull/6318) — thanks @hamsa0x7) +- **fix(providers):** register OpenRouter as a rerank provider so `openrouter/cohere/rerank-*` models resolve instead of erroring `Invalid rerank model` (#6574 — thanks @rafpigna) +- **fix(api):** `HEAD` requests no longer hang until client timeout on any route — valid, unknown, authed, or unauthed ([#6400](https://github.com/diegosouzapw/OmniRoute/issues/6400)), broader follow-up to the route-specific #6517 (`/v1/models`). Root cause: Next.js 16's App Router _route-handler_ pipeline (`next/dist/server/send-response.js`) correctly skips piping a `Response` body for `HEAD`, but its _page_-rendering pipeline (`next/dist/server/pipe-readable.js` → `pipeToNodeResponse`, used for every app-router page/layout render — including the `not-found` boundary any unmatched path falls through to) has no such check and always streams the full rendered body regardless of method; combined with Node's default keep-alive framing this left some clients unsure whether the (implicitly bodyless) `HEAD` response had actually finished. A new `scripts/dev/head-response-guard.cjs`, wired into both the dev/start custom server (`scripts/dev/run-next.mjs`) and the packaged standalone server (`scripts/dev/standalone-server-ws.mjs`) at the same tier as the existing `http-method-guard.cjs`/`peer-stamp.mjs` wrappers, discards any body bytes written for a `HEAD` request and forces `Connection: close` once `.end()` is called — independent of route existence or auth state, satisfying RFC 9110 §9.3.2. Regression guard: `tests/unit/head-request-closes-6400.test.ts`. ### 🐛 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 ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). +- **fix(auth):** an API key restricted via `allowedModels`/`allowedCombos` could bypass that restriction entirely over the Codex Responses-over-WebSocket bridge ([#6564](https://github.com/diegosouzapw/OmniRoute/issues/6564)) — `prepare()` in `src/app/api/internal/codex-responses-ws/route.ts` authenticated the WS bridge's API key (`authenticate()`/`authorizeWebSocketHandshake()`) and honored `allowedConnections`, but never called `enforceApiKeyPolicy()`, the same model/combo policy gate the HTTP `/v1/responses` path enforces via `handleChat()` — so a key scoped to e.g. `combo/model-1.0` could still reach a direct Codex model like `gpt-5.5` through this transport, as long as an eligible Codex OAuth connection existed. The bridge's WS auth token arrives via query params (`api_key`/`token`/`access_token`), not a normal `Authorization` header, so a new `enforceCodexWsApiKeyPolicy()` builds an equivalent `Request` carrying an explicit `Authorization: Bearer ` header and calls `enforceApiKeyPolicy()` against the CLIENT-requested model, before any Codex-specific model remapping or credential selection. Regression guard: `tests/unit/codex-ws-policy-enforcement-6564.test.ts` (a model-restricted key is rejected 403 before reaching credential selection; a combo-restricted key is rejected 403 requesting a disallowed combo; a key that DOES allow the requested model still proceeds past policy). (thanks @Squawk7777 for the report and an independent fix via #6565) - **fix(security):** loopback-gate `/api/middleware/*` so a leaked JWT over a tunnel can't install or trigger a middleware hook — middleware hooks compile + run arbitrary JS via `new vm.Script` on the request hot path (`src/lib/middleware/registry.ts`), the same RCE class as the already-gated `/api/plugins/*`; `/api/middleware/` is now in `LOCAL_ONLY_API_PREFIXES` so loopback enforcement runs unconditionally before any auth check (Hard Rules #15 + #17). Regression guard: `tests/unit/route-guard-middleware-local-only.test.ts`. ([#6541](https://github.com/diegosouzapw/OmniRoute/pull/6541)) — see PR. (thanks @developerjillur) - **fix(startup):** AgentBridge's MITM server no longer fails to start with `ROUTER_API_KEY is required` on a normal install ([#6403](https://github.com/diegosouzapw/OmniRoute/issues/6403)) — `POST /api/tools/agent-bridge/server` resolved the spawned MITM child's router key from only an explicit `apiKey` body field (never sent by the AgentBridge UI — the schema has no such field) and the `ROUTER_API_KEY` env var (unset by default), so `startMitm()` always received `""` and the child hard-exited, even though OmniRoute already had a usable API key in its own DB. A new `resolveRouterApiKey()` now falls back to `pickApiKeyForInternalUse()` (the same DB-backed selector the combo-health-check / cloud-sync internal probes use), resolving in order: explicit key → `ROUTER_API_KEY` env → an existing DB key. Regression guard: `tests/unit/agentbridge-mitm-router-key-6403.test.ts`. - **fix(providers):** deploying a Cloudflare relay Worker from Dashboard → System → Proxy pool → Cloudflare relay failed immediately with `Cloudflare Worker upload failed: Content-Type must be one of: application/javascript, text/javascript, multipart/form-data`, even with a valid token/account ([#6416](https://github.com/diegosouzapw/OmniRoute/issues/6416)) — the Worker-script upload built a native `FormData` and let `fetch` derive the multipart Content-Type automatically, but in production `globalThis.fetch` is patched with `node_modules/undici`'s own fetch (`open-sse/utils/proxyFetch.ts`), whose `FormData`/`Request` classes differ from the runtime's global `FormData` (same cross-realm class mismatch already fixed once for image edits in #3273); passing a native `FormData` instance through undici's patched fetch made it serialize the body as the literal string `"[object FormData]"` with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare rejects outright. `buildCloudflareWorkerUploadRequest()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now builds the multipart body as a raw `Buffer` with an explicit boundary and `Content-Type: multipart/form-data; boundary=…` header, accepted verbatim by any fetch implementation. Regression guard: `tests/unit/cloudflare-worker-upload-content-type-6416.test.ts` + updated `tests/unit/relay-deploy-5128.test.ts`. @@ -39,6 +44,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **fix(providers):** `fusion` combo strategy silently returned a panel member's raw answer instead of the configured `config.judgeModel` synthesis ([#6455](https://github.com/diegosouzapw/OmniRoute/issues/6455)) — `handleFusionChat()`'s single-survivor "degrade gracefully" path (added for #6454) returned the lone panel answer directly whenever only one panelist succeeded, regardless of whether an explicit `judgeModel` was configured; with the default `minPanel: 2` and a 2-model panel, any single flaky/rate-limited panelist forced this path on every request, so the configured judge (e.g. `auto/claude-opus`) was never invoked and the client-visible `.model` reflected whichever panelist happened to survive. The judge is now still invoked to synthesize a lone surviving answer whenever `judgeModel` is explicitly configured; the cheap direct-answer shortcut is kept only for the implicit case (no `judgeModel` set, where the "judge" is just `panel[0]`). Regression guard: `tests/unit/fusion-judge-model-6455.test.ts` + updated `tests/unit/combo-fusion-strategy.test.ts`. (thanks @chirag127) - **feat(combo):** sanitized diagnostic trace on an auto-combo terminal failure — instead of an opaque 503, a terminal combo failure now returns a whitelist-projected trace (candidate pool size, attempted count, excluded provider/reason codes, attempt order, and a terminal-reason code) via the new `errorResponseWithComboDiagnostics()`/`sanitizeComboDiagnostics()` in `open-sse/utils/error.ts` — provider/model ids and enumerated reason codes only, never keys/tokens/bodies, length- and count-capped. A reasoning-budget-exhausted panel now returns an actionable "increase max_tokens" message rather than a blind retry-limit 503. Regression guard: `tests/unit/combo-diagnostics-trace.test.ts`. ([#6545](https://github.com/diegosouzapw/OmniRoute/pull/6545)) — see PR. (thanks @developerjillur) - **fix(providers):** image/diffusion models discovered from an upstream catalog (e.g. HuggingFace's live `/v1/models`) are no longer advertised as chat models ([#6457](https://github.com/diegosouzapw/OmniRoute/issues/6457)) — the chat catalog builder defaulted synced models with no modality info to `endpoints: ["chat"]`, so `huggingface/stabilityai/stable-diffusion-xl-base-1.0` showed up in the chat `/v1/models` listing and returned `400 "not a chat model"` when called. `catalog.ts` now skips any synced model already registered as an image model for that provider (via the new `isRegisteredImageModel()`), leaving `getAllImageModels()` to list it with the correct `type: "image"`. Regression guard: `tests/unit/image-model-not-in-chat-catalog-6457.test.ts`. +- **fix(resilience):** combo session stickiness never released a pin on a credits-exhausted/banned/expired account, permanently defeating failover for that conversation ([#6692](https://github.com/diegosouzapw/OmniRoute/issues/6692)) — `applySessionStickiness()` (`open-sse/services/combo/sessionStickiness.ts`) gated the sticky pin only on 5h/weekly usage-percentage headroom, which is orthogonal to account availability: a `credits_exhausted`/`banned`/`expired` connection, or one still inside its `rateLimitedUntil` cooldown, reports perfectly healthy headroom, so the pin was force-promoted back to the front of the target list on every subsequent turn. `clearStickyBinding()` also had zero call sites in `combo.ts`'s failure paths, so a quality-validation-rejected 200 (a masked daily-cap refusal) never released the pin either. The gate now also resolves the bound connection's terminal status/cooldown via a new injectable fetcher seam (fail-open on lookup errors, mirroring the existing saturation fetcher), and `combo.ts`'s two dispatchers (`handleComboChat`/`handleRoundRobinCombo`) release the pin immediately at both their connection-exhaustion classification point and their quality-validation-failure branch via the new `releaseStickyPinOnFailure()`. Regression guard: `tests/unit/repro-6692-sticky-terminal.test.ts` + extended `tests/unit/combo-session-stickiness.test.ts`. - **fix(test):** replace the bare `expect(true).toBe(true)` tautology in `playground-api-tab.test.tsx`'s SSE test and close the `check:test-masking` gap that let it slip through for a full cycle ([#6404](https://github.com/diegosouzapw/OmniRoute/issues/6404)) — a prior pass (#6548) had already swapped the literal to `expect(sendBtn).toBeDefined()`, but that stayed just as vacuous: the test's fetch mock returned an empty `/v1/models` list, so `ApiTab`'s Send button is always `disabled` (`!selectedModel`) and the SSE branch never runs — the "SSE infra is verified" comment was never true. The test now mocks a real model, drives the model `` (#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`. diff --git a/changelog.d/fixes/.gitkeep b/changelog.d/fixes/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/changelog.d/fixes/6280-lmarena-arena-modernize.md b/changelog.d/fixes/6280-lmarena-arena-modernize.md new file mode 100644 index 0000000000..4ba53979bc --- /dev/null +++ b/changelog.d/fixes/6280-lmarena-arena-modernize.md @@ -0,0 +1 @@ +- **fix(providers):** modernize the `lmarena` provider for the Arena.ai rebrand — route chat through `arena.ai` create-evaluation with Chrome TLS impersonation, seed a static Direct-chat Text/Search + Image catalog, and keep the `lmarena`/`lma` wire id for back-compat ([#6280](https://github.com/diegosouzapw/OmniRoute/pull/6280)) — thanks @backryun diff --git a/changelog.d/fixes/6308-web-model-discovery.md b/changelog.d/fixes/6308-web-model-discovery.md new file mode 100644 index 0000000000..a452c1ae73 --- /dev/null +++ b/changelog.d/fixes/6308-web-model-discovery.md @@ -0,0 +1 @@ +- **fix(providers):** web-provider model discovery updated — qwen-web uses the slash-terminated models endpoint (avoiding a blocked 307 redirect), and kimi-web matches the current request shape (POST with bearer + `kimi-auth` cookie replay) with its catalog refreshed to the current non-agent models (#6308 — thanks @janeza2). diff --git a/changelog.d/fixes/6323-log-detail-stale-reopen.md b/changelog.d/fixes/6323-log-detail-stale-reopen.md new file mode 100644 index 0000000000..ec1c53ca2f --- /dev/null +++ b/changelog.d/fixes/6323-log-detail-stale-reopen.md @@ -0,0 +1 @@ +- **fix(logs):** the request-log detail modal no longer reopens by itself after being closed — a stale in-flight detail refresh resolved after close and re-triggered the modal open state (#6323 — thanks @xz-dev). diff --git a/changelog.d/fixes/6330-sensenova-token-plan.md b/changelog.d/fixes/6330-sensenova-token-plan.md new file mode 100644 index 0000000000..3ef26dea2b --- /dev/null +++ b/changelog.d/fixes/6330-sensenova-token-plan.md @@ -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). diff --git a/changelog.d/fixes/6538-tier-flow-svg-public.md b/changelog.d/fixes/6538-tier-flow-svg-public.md new file mode 100644 index 0000000000..e3d3f76cef --- /dev/null +++ b/changelog.d/fixes/6538-tier-flow-svg-public.md @@ -0,0 +1 @@ +- **fix(dashboard):** the onboarding tier-flow diagram rendered broken — its SVGs lived in the repo-root `images/` (not a served path); moved to `public/images/` so Next.js serves them (#6538 — thanks @ianriizky). diff --git a/changelog.d/fixes/6586-preserve-server-tool-names.md b/changelog.d/fixes/6586-preserve-server-tool-names.md new file mode 100644 index 0000000000..62ee75c234 --- /dev/null +++ b/changelog.d/fixes/6586-preserve-server-tool-names.md @@ -0,0 +1 @@ +- **fix(sse):** server-tool literal names (e.g. `web_search`) are preserved in message history and `tool_choice` instead of being namespaced/rewritten, so follow-up turns referencing those tools keep working (#6586 — thanks @MikeTuev). diff --git a/changelog.d/fixes/6632-probe-restore-loop-cycle-breaker.md b/changelog.d/fixes/6632-probe-restore-loop-cycle-breaker.md new file mode 100644 index 0000000000..45094d3742 --- /dev/null +++ b/changelog.d/fixes/6632-probe-restore-loop-cycle-breaker.md @@ -0,0 +1 @@ +- **fix(db): break probe-failed/restore loop on large storage.sqlite** (#6632 — thanks @KooshaPari). diff --git a/changelog.d/fixes/6647-winget-claude-detect.md b/changelog.d/fixes/6647-winget-claude-detect.md new file mode 100644 index 0000000000..c0ffef1324 --- /dev/null +++ b/changelog.d/fixes/6647-winget-claude-detect.md @@ -0,0 +1 @@ +- **fix(cli):** Claude Code installed via WinGet is now detected on Windows (the WinGet install path was missing from the binary lookup) (#6647 — thanks @enjoyer-hub). diff --git a/changelog.d/fixes/6675-remove-obsolete-providers.md b/changelog.d/fixes/6675-remove-obsolete-providers.md new file mode 100644 index 0000000000..4c4f4bcf1c --- /dev/null +++ b/changelog.d/fixes/6675-remove-obsolete-providers.md @@ -0,0 +1 @@ +- **fix(providers):** removed obsolete/defunct providers from the catalog (glhf, kluster, cablyai, inclusionai) (#6675 — thanks @backryun). diff --git a/changelog.d/fixes/6698-count-gate-rejected-usage.md b/changelog.d/fixes/6698-count-gate-rejected-usage.md new file mode 100644 index 0000000000..bb1288c407 --- /dev/null +++ b/changelog.d/fixes/6698-count-gate-rejected-usage.md @@ -0,0 +1 @@ +- **fix(sse):** requests rejected before `handleChatCore` (circuit-breaker/cooldown gate or combo with all targets exhausted) are now recorded in `usage_history` too, so a key whose traffic was entirely gate-rejected no longer shows "zero requests" in the per-API-key usage counter (#6698). diff --git a/changelog.d/fixes/6704-unwrap-bare-function-tool.md b/changelog.d/fixes/6704-unwrap-bare-function-tool.md new file mode 100644 index 0000000000..85b21b7c5b --- /dev/null +++ b/changelog.d/fixes/6704-unwrap-bare-function-tool.md @@ -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) diff --git a/changelog.d/fixes/6706-codex-oauth-bare-email-dedup.md b/changelog.d/fixes/6706-codex-oauth-bare-email-dedup.md new file mode 100644 index 0000000000..4eca2c7828 --- /dev/null +++ b/changelog.d/fixes/6706-codex-oauth-bare-email-dedup.md @@ -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) diff --git a/changelog.d/fixes/6710-codex-200-sse-capacity-error.md b/changelog.d/fixes/6710-codex-200-sse-capacity-error.md new file mode 100644 index 0000000000..e84653d7bd --- /dev/null +++ b/changelog.d/fixes/6710-codex-200-sse-capacity-error.md @@ -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) diff --git a/changelog.d/fixes/6712-volcengine-kimi-max-tokens.md b/changelog.d/fixes/6712-volcengine-kimi-max-tokens.md new file mode 100644 index 0000000000..29586d549c --- /dev/null +++ b/changelog.d/fixes/6712-volcengine-kimi-max-tokens.md @@ -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) diff --git a/changelog.d/fixes/6713-antigravity-abort-finish-reason.md b/changelog.d/fixes/6713-antigravity-abort-finish-reason.md new file mode 100644 index 0000000000..f94984faf6 --- /dev/null +++ b/changelog.d/fixes/6713-antigravity-abort-finish-reason.md @@ -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) diff --git a/changelog.d/fixes/6714-reasoning-buffer-cap-aware.md b/changelog.d/fixes/6714-reasoning-buffer-cap-aware.md new file mode 100644 index 0000000000..a67b36333c --- /dev/null +++ b/changelog.d/fixes/6714-reasoning-buffer-cap-aware.md @@ -0,0 +1 @@ +- **fix(routing):** the reasoning-token headroom buffer clamps to the model's explicit output cap instead of inflating past it, and `getExplicitModelOutputCap` falls through to the registry/spec cap when a synced capability row exists without a numeric `limit_output` ([#6714](https://github.com/diegosouzapw/OmniRoute/pull/6714)) — thanks @xz-dev diff --git a/changelog.d/fixes/6717-cli-health-monitoring-route.md b/changelog.d/fixes/6717-cli-health-monitoring-route.md new file mode 100644 index 0000000000..80e5ad7ec7 --- /dev/null +++ b/changelog.d/fixes/6717-cli-health-monitoring-route.md @@ -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`. diff --git a/changelog.d/fixes/6718-reasoningcontrols-case-collision.md b/changelog.d/fixes/6718-reasoningcontrols-case-collision.md new file mode 100644 index 0000000000..4c4449755f --- /dev/null +++ b/changelog.d/fixes/6718-reasoningcontrols-case-collision.md @@ -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) diff --git a/changelog.d/fixes/6720-turbopack-agentskills-warning.md b/changelog.d/fixes/6720-turbopack-agentskills-warning.md new file mode 100644 index 0000000000..aa9fc2e42b --- /dev/null +++ b/changelog.d/fixes/6720-turbopack-agentskills-warning.md @@ -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//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/**`). diff --git a/changelog.d/fixes/6721-codex-spark-image-drop.md b/changelog.d/fixes/6721-codex-spark-image-drop.md new file mode 100644 index 0000000000..d814d8bd39 --- /dev/null +++ b/changelog.d/fixes/6721-codex-spark-image-drop.md @@ -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). diff --git a/changelog.d/fixes/6722-quota-card-fixed-order.md b/changelog.d/fixes/6722-quota-card-fixed-order.md new file mode 100644 index 0000000000..ea2d2f7bbd --- /dev/null +++ b/changelog.d/fixes/6722-quota-card-fixed-order.md @@ -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`. diff --git a/changelog.d/fixes/6725-lazy-ioredis-mcp.md b/changelog.d/fixes/6725-lazy-ioredis-mcp.md new file mode 100644 index 0000000000..ba6238f5ec --- /dev/null +++ b/changelog.d/fixes/6725-lazy-ioredis-mcp.md @@ -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). diff --git a/changelog.d/fixes/6726-kiro-adaptive-thinking-allowlist.md b/changelog.d/fixes/6726-kiro-adaptive-thinking-allowlist.md new file mode 100644 index 0000000000..e31c103661 --- /dev/null +++ b/changelog.d/fixes/6726-kiro-adaptive-thinking-allowlist.md @@ -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`. diff --git a/changelog.d/fixes/6729-cursor-subagent-strip-cloud-base.md b/changelog.d/fixes/6729-cursor-subagent-strip-cloud-base.md new file mode 100644 index 0000000000..4cd79fd8a3 --- /dev/null +++ b/changelog.d/fixes/6729-cursor-subagent-strip-cloud-base.md @@ -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) diff --git a/changelog.d/fixes/6730-glm-split-tool-name.md b/changelog.d/fixes/6730-glm-split-tool-name.md new file mode 100644 index 0000000000..ad807f6272 --- /dev/null +++ b/changelog.d/fixes/6730-glm-split-tool-name.md @@ -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) diff --git a/changelog.d/fixes/6731-apikey-429-quota-exhausted.md b/changelog.d/fixes/6731-apikey-429-quota-exhausted.md new file mode 100644 index 0000000000..bdfabc45ef --- /dev/null +++ b/changelog.d/fixes/6731-apikey-429-quota-exhausted.md @@ -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. diff --git a/changelog.d/fixes/6732-fp-pinned-combo-resolve.md b/changelog.d/fixes/6732-fp-pinned-combo-resolve.md new file mode 100644 index 0000000000..47f790681c --- /dev/null +++ b/changelog.d/fixes/6732-fp-pinned-combo-resolve.md @@ -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`. diff --git a/changelog.d/fixes/6735-responses-commentary-event-frame.md b/changelog.d/fixes/6735-responses-commentary-event-frame.md new file mode 100644 index 0000000000..6dce5931c9 --- /dev/null +++ b/changelog.d/fixes/6735-responses-commentary-event-frame.md @@ -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). diff --git a/changelog.d/fixes/6741-compression-preview-token-reconcile.md b/changelog.d/fixes/6741-compression-preview-token-reconcile.md new file mode 100644 index 0000000000..122a354265 --- /dev/null +++ b/changelog.d/fixes/6741-compression-preview-token-reconcile.md @@ -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`. diff --git a/changelog.d/fixes/6742-quota-preflight-coverage.md b/changelog.d/fixes/6742-quota-preflight-coverage.md new file mode 100644 index 0000000000..45178354e4 --- /dev/null +++ b/changelog.d/fixes/6742-quota-preflight-coverage.md @@ -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). diff --git a/changelog.d/fixes/6743-reasoning-content-web-sse.md b/changelog.d/fixes/6743-reasoning-content-web-sse.md new file mode 100644 index 0000000000..089a53d995 --- /dev/null +++ b/changelog.d/fixes/6743-reasoning-content-web-sse.md @@ -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). diff --git a/changelog.d/fixes/6757-rtk-enable-renderers-schema.md b/changelog.d/fixes/6757-rtk-enable-renderers-schema.md new file mode 100644 index 0000000000..a6aa1c910e --- /dev/null +++ b/changelog.d/fixes/6757-rtk-enable-renderers-schema.md @@ -0,0 +1 @@ +- **fix(api):** the compression config PUT schema now accepts `enableRenderers` for the RTK engine instead of rejecting the documented option (#6703, #6757 — thanks @alltomatos, with an independent duplicate fix from @chirag127 via #6756). diff --git a/changelog.d/fixes/6759-cookie-provider-apikey-cap.md b/changelog.d/fixes/6759-cookie-provider-apikey-cap.md new file mode 100644 index 0000000000..56fa315e97 --- /dev/null +++ b/changelog.d/fixes/6759-cookie-provider-apikey-cap.md @@ -0,0 +1 @@ +- **fix(api):** raised the provider `apiKey` length cap for cookie-based web providers, whose session-cookie credentials legitimately exceed the previous limit (#6715, #6759 — thanks @alltomatos). diff --git a/changelog.d/fixes/6769-i18n-translate-pt-dashboard.md b/changelog.d/fixes/6769-i18n-translate-pt-dashboard.md new file mode 100644 index 0000000000..590224dc79 --- /dev/null +++ b/changelog.d/fixes/6769-i18n-translate-pt-dashboard.md @@ -0,0 +1 @@ +- **fix(i18n): translate hardcoded Portuguese dashboard strings to English (#6761, #6768)** (#6769 — thanks @chirag127). diff --git a/changelog.d/fixes/6788-tia-dashboard-loader.md b/changelog.d/fixes/6788-tia-dashboard-loader.md new file mode 100644 index 0000000000..50e6f4f06b --- /dev/null +++ b/changelog.d/fixes/6788-tia-dashboard-loader.md @@ -0,0 +1 @@ +- **fix(ci):** the blocking "Impacted unit tests (TIA)" step false-redded any PR whose impact graph reached a dashboard component — it ran every selected test under `--import tsx/esm`, but `tests/unit/dashboard/**` requires the `--import tsx` CJS transform (ESM-only deep imports like `@lobehub/icons/es/*`), exactly as the canonical `test:unit:ci:shard` already does per segment. The impacted selection is now split by segment with matching loaders (closes #6787). diff --git a/changelog.d/fixes/6790-gemini-pdf-video-attachments.md b/changelog.d/fixes/6790-gemini-pdf-video-attachments.md new file mode 100644 index 0000000000..ad4cf383d9 --- /dev/null +++ b/changelog.d/fixes/6790-gemini-pdf-video-attachments.md @@ -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). diff --git a/changelog.d/fixes/6791-deepseek-web-done-after-finished.md b/changelog.d/fixes/6791-deepseek-web-done-after-finished.md new file mode 100644 index 0000000000..f7b161c4fd --- /dev/null +++ b/changelog.d/fixes/6791-deepseek-web-done-after-finished.md @@ -0,0 +1 @@ +- **fix(providers): ensure DeepSeek Web SSE emits [DONE] after FINISHED** (#6791 — thanks @Pitchfork-and-Torch). diff --git a/changelog.d/fixes/6792-compression-put-all-catalog-engines.md b/changelog.d/fixes/6792-compression-put-all-catalog-engines.md new file mode 100644 index 0000000000..813620e098 --- /dev/null +++ b/changelog.d/fixes/6792-compression-put-all-catalog-engines.md @@ -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). diff --git a/changelog.d/fixes/6795-cursor-client-version-build-id.md b/changelog.d/fixes/6795-cursor-client-version-build-id.md new file mode 100644 index 0000000000..b82f2fbba2 --- /dev/null +++ b/changelog.d/fixes/6795-cursor-client-version-build-id.md @@ -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). diff --git a/changelog.d/fixes/6805-strip-include-compact-responses.md b/changelog.d/fixes/6805-strip-include-compact-responses.md new file mode 100644 index 0000000000..6c46a717a4 --- /dev/null +++ b/changelog.d/fixes/6805-strip-include-compact-responses.md @@ -0,0 +1 @@ +- **fix(codex): strip include from compact responses requests** (#6805 — thanks @yinaoxiong). diff --git a/changelog.d/fixes/6812-request-count-by-provider-date.md b/changelog.d/fixes/6812-request-count-by-provider-date.md new file mode 100644 index 0000000000..503897954e --- /dev/null +++ b/changelog.d/fixes/6812-request-count-by-provider-date.md @@ -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) diff --git a/changelog.d/fixes/6817-ollama-cloud-weekly-quota.md b/changelog.d/fixes/6817-ollama-cloud-weekly-quota.md new file mode 100644 index 0000000000..9ff2857cf5 --- /dev/null +++ b/changelog.d/fixes/6817-ollama-cloud-weekly-quota.md @@ -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 (\) 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`. diff --git a/changelog.d/fixes/6821-budget-tokens-zero-gemini.md b/changelog.d/fixes/6821-budget-tokens-zero-gemini.md new file mode 100644 index 0000000000..3886b4e6b3 --- /dev/null +++ b/changelog.d/fixes/6821-budget-tokens-zero-gemini.md @@ -0,0 +1 @@ +- **fix(providers):** an explicit `thinking.budget_tokens: 0` is now honored in the OpenAI→Gemini transform (thinking disabled) instead of being treated as unset (#6813, #6821 — thanks @alltomatos). diff --git a/changelog.d/fixes/6828-bootstrap-filter-empty-env.md b/changelog.d/fixes/6828-bootstrap-filter-empty-env.md new file mode 100644 index 0000000000..d4a6ca34ad --- /dev/null +++ b/changelog.d/fixes/6828-bootstrap-filter-empty-env.md @@ -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). diff --git a/changelog.d/fixes/6829-classify-404-model-not-found.md b/changelog.d/fixes/6829-classify-404-model-not-found.md new file mode 100644 index 0000000000..77a741377c --- /dev/null +++ b/changelog.d/fixes/6829-classify-404-model-not-found.md @@ -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). diff --git a/changelog.d/maintenance/.gitkeep b/changelog.d/maintenance/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/changelog.d/maintenance/6784-merge-train.md b/changelog.d/maintenance/6784-merge-train.md new file mode 100644 index 0000000000..e98586183f --- /dev/null +++ b/changelog.d/maintenance/6784-merge-train.md @@ -0,0 +1 @@ +- **Merge-train script** (`scripts/release/merge-train.sh`): batch-validates N queued PRs as ONE merged result on the runner box — merges every queued PR into a throwaway worktree cut from the release tip, runs the fast-gates parity suite once, and prints the `--admin` evidence block per PR (merge-gates §7). Replaces O(N²) per-PR CI re-runs in merge-storms. Regression guard: `tests/unit/merge-train-plan.test.ts`. diff --git a/changelog.d/maintenance/ci-vps-runner-sharding.md b/changelog.d/maintenance/ci-vps-runner-sharding.md new file mode 100644 index 0000000000..925ce479a4 --- /dev/null +++ b/changelog.d/maintenance/ci-vps-runner-sharding.md @@ -0,0 +1 @@ +- **ci:** unit fast-path sharding doubled 2→4 (halves the heaviest job's wall time) (#6781); the 3 heaviest fast-path jobs can route to the self-hosted VPS runner pool behind `USE_VPS_RUNNER` (#6691); `VPS_ALWAYS_ON` keeps the dedicated 24/7 CI host up across releases (teardown becomes a no-op) (#6693). diff --git a/changelog.d/maintenance/docs-strategy-count-skill-names.md b/changelog.d/maintenance/docs-strategy-count-skill-names.md new file mode 100644 index 0000000000..a14507159b --- /dev/null +++ b/changelog.d/maintenance/docs-strategy-count-skill-names.md @@ -0,0 +1 @@ +- **docs:** routing-strategy count reconciled to 18 across AUTO-COMBO.md, README and AGENTS.md, and `p2c` casing fixed to match `ROUTING_STRATEGY_VALUES` (#6643, #6644, #6646 — thanks @chirag127); CLAUDE.md updated with the renamed review/triage/implement skill-family names (#6663). diff --git a/changelog.d/maintenance/readme-strategy-tool-counts.md b/changelog.d/maintenance/readme-strategy-tool-counts.md new file mode 100644 index 0000000000..6e82bd00c1 --- /dev/null +++ b/changelog.d/maintenance/readme-strategy-tool-counts.md @@ -0,0 +1 @@ +- **docs(readme):** fix stale counts — 18 routing strategies (adds the missing `pipeline` row), 94 MCP tools, 12-factor Auto-Combo scoring. diff --git a/config/i18n.json b/config/i18n.json index 6e1492496b..2b44ee7aa8 100644 --- a/config/i18n.json +++ b/config/i18n.json @@ -340,6 +340,14 @@ "native": "中文 (简体)", "english": "Chinese (Simplified)", "flag": "🇨🇳" + }, + { + "code": "zh-TW", + "label": "ZH-TW", + "name": "中文 (繁體)", + "native": "中文 (繁體)", + "english": "Chinese (Traditional)", + "flag": "🇹🇼" } ] } diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index ed24860b31..3c02ee514b 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -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.", diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 9d3c854412..9065f5db48 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1502,16 +1502,6 @@ "count": 1 } }, - "tests/unit/live-ws-public-url.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, - "tests/unit/lmarena-provider.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, "tests/unit/lmarena-split-cookie-4271.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b442de5f10..3c995fa15e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -58,12 +58,16 @@ services: - PORT=${PORT:-20128} - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}} - API_PORT=${API_PORT:-20129} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:${PROD_DASHBOARD_PORT:-20130},http://127.0.0.1:${PROD_DASHBOARD_PORT:-20130}} - API_HOST=${API_HOST:-0.0.0.0} - HOSTNAME=0.0.0.0 - DATA_DIR=/app/data ports: - "${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}" - "${PROD_API_PORT:-20131}:${API_PORT:-20129}" + - "${PROD_LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" volumes: - omniroute-prod-data:/app/data healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index 2560644527..9b3add8ee3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,9 @@ x-common: &common - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - API_HOST=${API_HOST:-0.0.0.0} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128} - REDIS_URL=${REDIS_URL:-redis://redis:6379} volumes: - ./data:/app/data @@ -75,6 +78,7 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" profiles: - base @@ -92,6 +96,7 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" profiles: - web @@ -106,6 +111,7 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" volumes: - ./data:/app/data - /var/run/docker.sock:/var/run/docker.sock @@ -125,12 +131,16 @@ services: ports: - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" + - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" environment: - DATA_DIR=/app/data - PORT=${PORT:-20128} - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - API_HOST=${API_HOST:-0.0.0.0} + - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} + - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128} - CLI_MODE=host - CLI_EXTRA_PATHS=/host-local/bin:/host-node/bin - CLI_CONFIG_HOME=/host-home diff --git a/docs/README.md b/docs/README.md index d43de85363..9cb3895425 100644 --- a/docs/README.md +++ b/docs/README.md @@ -177,7 +177,7 @@ Mermaid sources and exported SVG/PNG diagrams referenced from the docs above. Se ## i18n/ -Translated mirrors of the documentation in 42 locales. See [i18n/README.md](i18n/README.md) for the supported language list. +Translated mirrors of the documentation in 43 locales. See [i18n/README.md](i18n/README.md) for the supported language list. ## screenshots/ diff --git a/docs/guides/I18N.md b/docs/guides/I18N.md index 355093f24f..ed684900b9 100644 --- a/docs/guides/I18N.md +++ b/docs/guides/I18N.md @@ -6,7 +6,7 @@ lastUpdated: 2026-06-28 # i18n — Internationalization Guide -OmniRoute supports **42 languages** with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew. +OmniRoute supports **43 languages** with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew. 🌐 **Languages:** 🇺🇸 [English](./I18N.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/guides/I18N.md) | 🇪🇸 [Español](../i18n/es/docs/guides/I18N.md) | 🇫🇷 [Français](../i18n/fr/docs/guides/I18N.md) | 🇩🇪 [Deutsch](../i18n/de/docs/guides/I18N.md) | 🇮🇹 [Italiano](../i18n/it/docs/guides/I18N.md) | 🇷🇺 [Русский](../i18n/ru/docs/guides/I18N.md) | 🇨🇳 [中文 (简体)](../i18n/zh-CN/docs/guides/I18N.md) | 🇯🇵 [日本語](../i18n/ja/docs/guides/I18N.md) | 🇰🇷 [한국어](../i18n/ko/docs/guides/I18N.md) | 🇸🇦 [العربية](../i18n/ar/docs/guides/I18N.md) | 🇮🇳 [हिन्दी](../i18n/hi/docs/guides/I18N.md) | 🇹🇭 [ไทย](../i18n/th/docs/guides/I18N.md) | 🇹🇷 [Türkçe](../i18n/tr/docs/guides/I18N.md) | 🇺🇦 [Українська](../i18n/uk-UA/docs/guides/I18N.md) | 🇻🇳 [Tiếng Việt](../i18n/vi/docs/guides/I18N.md) | 🇧🇬 [Български](../i18n/bg/docs/guides/I18N.md) | 🇩🇰 [Dansk](../i18n/da/docs/guides/I18N.md) | 🇫🇮 [Suomi](../i18n/fi/docs/guides/I18N.md) | 🇮🇱 [עברית](../i18n/he/docs/guides/I18N.md) | 🇭🇺 [Magyar](../i18n/hu/docs/guides/I18N.md) | 🇮🇩 [Bahasa Indonesia](../i18n/id/docs/guides/I18N.md) | 🇲🇾 [Bahasa Melayu](../i18n/ms/docs/guides/I18N.md) | 🇳🇱 [Nederlands](../i18n/nl/docs/guides/I18N.md) | 🇳🇴 [Norsk](../i18n/no/docs/guides/I18N.md) | 🇵🇹 [Português (Portugal)](../i18n/pt/docs/guides/I18N.md) | 🇷🇴 [Română](../i18n/ro/docs/guides/I18N.md) | 🇵🇱 [Polski](../i18n/pl/docs/guides/I18N.md) | 🇸🇰 [Slovenčina](../i18n/sk/docs/guides/I18N.md) | 🇸🇪 [Svenska](../i18n/sv/docs/guides/I18N.md) | 🇵🇭 [Filipino](../i18n/phi/docs/guides/I18N.md) | 🇨🇿 [Čeština](../i18n/cs/docs/guides/I18N.md) @@ -134,6 +134,7 @@ README variants) are not yet handled by the new pipeline and are still used. | `uk-UA` | Українська | No | `uk` | | `vi` | Tiếng Việt | No | `vi` | | `zh-CN` | 中文 (简体) | No | `zh-CN` | +| `zh-TW` | 中文 (繁體) | No | `zh-TW` | ## Adding a New Language @@ -241,7 +242,7 @@ python3 scripts/i18n/i18n_autotranslate.py \ - Scans `docs/i18n/` markdown files for English paragraphs - Skips code blocks, tables, and already-translated content - Sends paragraphs to LLM with technical translation system prompt -- Supports all 42 languages +- Supports all 43 languages ## CLI i18n @@ -250,7 +251,7 @@ The `omniroute` CLI has its own i18n layer separate from the Next.js dashboard. ### How it works - Every user-facing string in CLI commands goes through `t("module.key", vars)` from `bin/cli/i18n.mjs`. -- Catalogs are JSON files in `bin/cli/locales/` — 42 ship out-of-the-box. +- Catalogs are JSON files in `bin/cli/locales/` — 43 ship out-of-the-box. - Locale falls back to `en` for any missing key, so partial translations are valid. - The source of truth for available locales is `config/i18n.json` (shared with the dashboard). @@ -301,7 +302,7 @@ invocation. Use `config lang set` to persist. ### Available locales -42 locale files ship in `bin/cli/locales/`. Full translations: `en`, `pt-BR`. +43 locale files ship in `bin/cli/locales/`. Full translations: `en`, `pt-BR`. Scaffold-only (all keys fall back to `en`): `bn`, `gu`, `he`, `in`, `mr`, `ms`, `phi`, `sw`, `ta`, `te`, `ur`. All other 29 locales have `common` + `program` keys translated. @@ -348,12 +349,13 @@ python3 scripts/i18n/validate_translation.py -l cs - **Placeholder mismatches** — ICU placeholders that don't match between source and translation **Exit codes:** -| Code | Meaning | -|------|---------| -| 0 | OK | -| 1 | Generic error | -| 2 | Missing strings (hard error) | -| 3 | Untranslated warning (soft) | + +| Code | Meaning | +| ---- | ---------------------------- | +| 0 | OK | +| 1 | Generic error | +| 2 | Missing strings (hard error) | +| 3 | Untranslated warning (soft) | **Environment:** Set `TRANSLATION_LANG=cs` or use `-l cs` flag. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 3bc0371570..6bad9d7969 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -115,33 +115,32 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari ## 3. Network & Ports -| Variable | Default | Source File | Description | -| ------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | -| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs` | URL subpath for serving OmniRoute behind a reverse proxy under a subpath (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. | -| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | -| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | -| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | -| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. | -| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. | -| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. | -| `LIVE_WS_PORT` | `20129` | `src/server/ws/liveServer.ts` | Port for the real-time WebSocket live monitoring server. | -| `LIVE_WS_HOST` | `127.0.0.1` | `src/server/ws/liveServer.ts` | Bind address for the live WebSocket server. Set to `0.0.0.0` to expose on LAN (also configure `LIVE_WS_ALLOWED_ORIGINS`). | -| `LIVE_WS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/ws/liveServer.ts` | Comma-separated extra origins allowed to open a live WebSocket. Loopback dashboard origins are already permitted by default. | -| `LIVE_WS_ALLOWED_HOSTS` | _(unset)_ | `src/server/ws/liveServerAllowList.ts` | Comma-separated extra hostnames allowed for live WebSocket origins. Unlike `LIVE_WS_ALLOWED_ORIGINS` (full origin URLs), matches only the host portion — useful for LAN/Tailscale setups. | -| `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` | _(unset)_ | `src/hooks/useLiveDashboard.ts` | Public URL for the live dashboard WebSocket (browser-side). Set when fronting the WS server with a reverse proxy or Cloudflare Tunnel (e.g. `wss://ws.my-ai.com/live-ws`); the browser connects there instead of `ws://hostname:20129`. | -| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` | Set to `0` or `false` to disable the real-time WebSocket server (enabled by default, loopback-bound). | -| `OMNIROUTE_DISABLE_LIVE_WS` | `false` | `scripts/start-ws-server.mjs` | CI/harness toggle that disables the standalone live WebSocket helper script. | -| `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | Per-(token, IP) relay rate limit, requests/minute. In-memory, per instance. `0` or negative disables the IP-dimension gate (per-token DB limit still applies). | -| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. | -| `OMNIROUTE_USE_TURBOPACK` | `1` (Turbopack — code default) | `package.json` / Next.js 16 | Turbopack is the default bundler for `npm run dev` and `npm run build` (2-3× faster builds, benchmarked). Set to `0` to fall back to webpack on Windows or when running into native binding / bundler-compat incompatibilities. | -| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(unset)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | Set to `1` to skip the SQLite integrity health check on startup. Useful for faster boot on large databases. | -| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | Interval (ms) for the background credential health check scheduler. Minimum: 10000 (10s). | -| `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | TTL (ms) for cached credential health status. | -| `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | Set to `1` or `true` to disable background periodic testing of provider connections. | -| `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Bind address for the Next.js dev/start server. Overrides the default `0.0.0.0` when set. | -| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Bind address used by the Playwright runner when launching Next.js. Defaults to `127.0.0.1` for hermetic tests. **Do not use for `omniroute serve`** — use `OMNIROUTE_SERVER_HOST` instead (POSIX shells auto-set `HOSTNAME` to the machine name; `.env` cannot override it). | -| `OMNIROUTE_SERVER_HOST` | `0.0.0.0` | `bin/cli/commands/serve.mjs` | Bind address for `omniroute serve`. Avoids collision with the POSIX shell `HOSTNAME` variable (always set to the machine name by bash/zsh). Falls back to `0.0.0.0` when unset. (#6194) | +| Variable | Default | Source File | Description | +| ------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | +| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs` | URL subpath for serving OmniRoute behind a reverse proxy under a subpath (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. | +| `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | +| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | +| `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | +| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. | +| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. | +| `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. | +| `LIVE_WS_PORT` | `20129` | `src/server/ws/liveServer.ts` | Port for the real-time WebSocket live monitoring server. | +| `LIVE_WS_HOST` | `127.0.0.1` | `src/server/ws/liveServer.ts` | Bind address for the live WebSocket server. Set to `0.0.0.0` to expose on LAN (also configure `LIVE_WS_ALLOWED_ORIGINS`). | +| `LIVE_WS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/ws/liveServer.ts` | Comma-separated extra origins allowed to open a live WebSocket. Loopback dashboard origins are already permitted by default. | +| `LIVE_WS_ALLOWED_HOSTS` | _(unset)_ | `src/server/ws/liveServerAllowList.ts` | Comma-separated extra hostnames allowed for live WebSocket origins. Unlike `LIVE_WS_ALLOWED_ORIGINS` (full origin URLs), matches only the host portion — useful for LAN/Tailscale setups. | +| `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` | _(unset)_ | `src/hooks/useLiveDashboard.ts` | Public URL for the live dashboard WebSocket (browser-side). Set when fronting the WS server with a reverse proxy or Cloudflare Tunnel (e.g. `wss://ws.my-ai.com/live-ws`); the browser connects there instead of `ws://hostname:20132`. The pathname portion is also used as the WebSocket upgrade path (default: `/live-ws`). | +| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` and `scripts/start-ws-server.mjs` | Set to `0` or `false` to disable the real-time WebSocket server (enabled by default, loopback-bound). CI/harness toggle that disables the standalone live WebSocket helper script. | +| `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | Per-(token, IP) relay rate limit, requests/minute. In-memory, per instance. `0` or negative disables the IP-dimension gate (per-token DB limit still applies). | +| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. | +| `OMNIROUTE_USE_TURBOPACK` | `1` (Turbopack — code default) | `package.json` / Next.js 16 | Turbopack is the default bundler for `npm run dev` and `npm run build` (2-3× faster builds, benchmarked). Set to `0` to fall back to webpack on Windows or when running into native binding / bundler-compat incompatibilities. | +| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(unset)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | Set to `1` to skip the SQLite integrity health check on startup. Useful for faster boot on large databases. | +| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | Interval (ms) for the background credential health check scheduler. Minimum: 10000 (10s). | +| `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | TTL (ms) for cached credential health status. | +| `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | Set to `1` or `true` to disable background periodic testing of provider connections. | +| `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Bind address for the Next.js dev/start server. Overrides the default `0.0.0.0` when set. | +| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Bind address used by the Playwright runner when launching Next.js. Defaults to `127.0.0.1` for hermetic tests. **Do not use for `omniroute serve`** — use `OMNIROUTE_SERVER_HOST` instead (POSIX shells auto-set `HOSTNAME` to the machine name; `.env` cannot override it). | +| `OMNIROUTE_SERVER_HOST` | `0.0.0.0` | `bin/cli/commands/serve.mjs` | Bind address for `omniroute serve`. Avoids collision with the POSIX shell `HOSTNAME` variable (always set to the machine name by bash/zsh). Falls back to `0.0.0.0` when unset. (#6194) | ### Port Modes @@ -836,6 +835,7 @@ Anthropic-compatible provider instead. | Variable | Default | Source File | Description | | ----------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. | +| `PROXY_LATENCY_WINDOW_HOURS` | `3` | `src/lib/db/proxies.ts` | Time window (hours) for calculating the average latency of candidate proxies in the latency-optimized pool strategy. | | `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. | | `PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS` | `2000` | `src/lib/proxyHealth.ts` | Cache TTL for failed proxy health probes. Keep this shorter than `PROXY_HEALTH_CACHE_TTL_MS` so transient proxy timeouts under high concurrency retry quickly without disabling fast-fail for truly dead proxies. | | `PROXY_HEALTH_ENABLED` | `true` | `src/lib/proxyHealth/scheduler.ts` | Set `false` to disable the background proxy health scheduler that periodically probes registered proxies. | @@ -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-`) 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/`); 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`. | --- diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index e26558e11b..361f69eca2 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,14 +1,14 @@ --- title: "Provider Reference" version: 3.8.47 -lastUpdated: 2026-07-08 +lastUpdated: 2026-07-10 --- # Provider Reference > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` -> **Last generated:** 2026-07-08 +> **Last generated:** 2026-07-10 Total providers: **248**. See category breakdown below. @@ -31,298 +31,298 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each --- -## OAuth Providers (21) +## OAuth Providers (22) -| ID | Alias | Name | Tags | Website | Notes | -| -------------- | ------------ | -------------------- | ----- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `agy` | `agy` | Antigravity CLI | OAuth | [link](https://antigravity.google) | Import your Antigravity CLI (`agy`) login (paste/upload its token file), auto-detect a local CLI login, or sign in with Google. Shares the Antigravity backend (incl. Claude models). | -| `amazon-q` | `aq` | Amazon Q | OAuth | [link](https://aws.amazon.com/q/developer/) | Uses the same AWS Builder ID or imported refresh-token flow as Kiro, but keeps Amazon Q connections separate. | -| `antigravity` | — | Antigravity | OAuth | — | — | -| `claude` | `cc` | Claude Code | OAuth | — | — | -| `cline` | `cl` | Cline | OAuth | — | — | -| `codebuddy-cn` | `cbcn` | CodeBuddy CN | OAuth | [link](https://copilot.tencent.com) | Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan. | -| `codex` | `cx` | OpenAI Codex | OAuth | — | — | -| `cursor` | `cu` | Cursor IDE | OAuth | — | — | -| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai | -| `github` | `gh` | GitHub Copilot | OAuth | — | — | -| `gitlab-duo` | `gitlab-duo` | GitLab Duo | OAuth | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance. | -| `grok-cli` | `gc` | Grok Build | OAuth | — | Paste your ~/.grok/auth.json (or the JWT access token) from the Grok Build CLI; refresh_token is rotated automatically. | -| `kilocode` | `kc` | Kilo Code | OAuth | — | — | -| `kimi-coding` | `kmc` | Kimi Coding | OAuth | — | — | -| `kiro` | `kr` | Kiro AI | OAuth | — | Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use. | -| `qoder` | `if` | Qoder | OAuth | — | — | -| `qwen` | `qw` | Qwen Code | OAuth | — | ⚠️ **DEPRECATED.** Qwen OAuth free tier was discontinued on 2026-04-15. Use 'bailian-coding-plan', 'alibaba', 'alibaba-cn', or 'openrouter' provider with API key instead. | -| `trae` | `tr` | Trae | OAuth | [link](https://trae.ai) | Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT ', ~14-day lifetime) as the access token; web_id/biz_user_id/user_unique_id/scope/tenant/region propagate via providerSpecificData. No headless refresh for pasted tokens — re-paste on expiry. | -| `windsurf` | `ws` | Windsurf (Devin CLI) | OAuth | [link](https://windsurf.com) | In the Windsurf / VS Code IDE, open the command palette and run `Windsurf: Provide Auth Token` (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it here. Note: opening windsurf.com/show-auth-token directly only renders a "Redirecting" page — the IDE must initiate the flow (it adds a `?state=...` param) for the token to appear. | -| `zed` | `zd` | Zed IDE | OAuth | [link](https://zed.dev) | Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically. | -| `zed-hosted` | — | Zed Hosted Models | OAuth | [link](https://zed.dev) | Sign in with your Zed account (native-app sign-in). OmniRoute generates a one-time RSA keypair and opens zed.dev to authorize it — on a remote/headless install, copy the resulting 127.0.0.1 callback URL from your browser's address bar and paste it back here. Distinct from the 'Zed IDE' credential-import entry above: this proxies chat completions through Zed's own hosted model aggregator (cloud.zed.dev), fronting Anthropic/OpenAI/Google/xAI models under your Zed plan. | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `agy` | `agy` | Antigravity CLI | OAuth | [link](https://antigravity.google) | Import your Antigravity CLI (`agy`) login (paste/upload its token file), auto-detect a local CLI login, or sign in with Google. Shares the Antigravity backend (incl. Claude models). | +| `amazon-q` | `aq` | Amazon Q | OAuth | [link](https://aws.amazon.com/q/developer/) | Uses the same AWS Builder ID or imported refresh-token flow as Kiro, but keeps Amazon Q connections separate. | +| `antigravity` | — | Antigravity | OAuth | — | — | +| `claude` | `cc` | Claude Code | OAuth | — | — | +| `cline` | `cl` | Cline | OAuth | — | — | +| `clinepass` | `cp` | ClinePass | OAuth | [link](https://cline.bot/clinepass) | ClinePass is Cline's $9.99/mo subscription bundling 10 open coding models. Sign in with your Cline account (same login as the Cline CLI/IDE), or paste a direct ClinePass API key (app.cline.bot → Settings → API Keys). A ClinePass subscription unlocks the cline-pass/* models. Reuses the Cline WorkOS OAuth flow. | +| `codebuddy-cn` | `cbcn` | CodeBuddy CN | OAuth | [link](https://copilot.tencent.com) | Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan. | +| `codex` | `cx` | OpenAI Codex | OAuth | — | — | +| `cursor` | `cu` | Cursor IDE | OAuth | — | — | +| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai | +| `github` | `gh` | GitHub Copilot | OAuth | — | — | +| `gitlab-duo` | `gitlab-duo` | GitLab Duo | OAuth | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance. | +| `grok-cli` | `gc` | Grok Build | OAuth | — | Paste your ~/.grok/auth.json (or the JWT access token) from the Grok Build CLI; refresh_token is rotated automatically. | +| `kilocode` | `kc` | Kilo Code | OAuth | — | — | +| `kimi-coding` | `kmc` | Kimi Coding | OAuth | — | — | +| `kiro` | `kr` | Kiro AI | OAuth | — | Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use. | +| `qoder` | `if` | Qoder | OAuth | — | — | +| `qwen` | `qw` | Qwen Code | OAuth | — | ⚠️ **DEPRECATED.** Qwen OAuth free tier was discontinued on 2026-04-15. Use 'bailian-coding-plan', 'alibaba', 'alibaba-cn', or 'openrouter' provider with API key instead. | +| `trae` | `tr` | Trae | OAuth | [link](https://trae.ai) | Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT ', ~14-day lifetime) as the access token; web_id/biz_user_id/user_unique_id/scope/tenant/region propagate via providerSpecificData. No headless refresh for pasted tokens — re-paste on expiry. | +| `windsurf` | `ws` | Windsurf (Devin CLI) | OAuth | [link](https://windsurf.com) | In the Windsurf / VS Code IDE, open the command palette and run `Windsurf: Provide Auth Token` (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it here. Note: opening windsurf.com/show-auth-token directly only renders a "Redirecting" page — the IDE must initiate the flow (it adds a `?state=...` param) for the token to appear. | +| `zed` | `zd` | Zed IDE | OAuth | [link](https://zed.dev) | Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically. | +| `zed-hosted` | — | Zed Hosted Models | OAuth | [link](https://zed.dev) | Sign in with your Zed account (native-app sign-in). OmniRoute generates a one-time RSA keypair and opens zed.dev to authorize it — on a remote/headless install, copy the resulting 127.0.0.1 callback URL from your browser's address bar and paste it back here. Distinct from the 'Zed IDE' credential-import entry above: this proxies chat completions through Zed's own hosted model aggregator (cloud.zed.dev), fronting Anthropic/OpenAI/Google/xAI models under your Zed plan. | ## Web Cookie Providers (24) -| ID | Alias | Name | Tags | Website | Notes | -| ------------------ | ------------- | ------------------------------- | ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `adapta-web` | `adp-web` | Adapta.org (Adapta One Web) | Web cookie | [link](https://agent.adapta.one) | Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies) | -| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai | -| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your __Secure-next-auth.session-token cookie value from chatgpt.com | -| `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `adapta-web` | `adp-web` | Adapta.org (Adapta One Web) | Web cookie | [link](https://agent.adapta.one) | Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies) | +| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai | +| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your __Secure-next-auth.session-token cookie value from chatgpt.com | +| `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | | `copilot-m365-web` | `m365copilot` | Microsoft 365 Copilot (BizChat) | Web cookie | [link](https://m365.cloud.microsoft/chat) | Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. | -| `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste your access_token from copilot.microsoft.com (or export a .har file from DevTools while logged in) | -| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | -| `doubao-web` | `db` | Dola Web (ByteDance) | Web cookie | [link](https://www.dola.com) | Paste the full Cookie header from www.dola.com. It should include sessionid, ttwid, and s_v_web_id. If s_v_web_id is unavailable, fp=verify_... from a chat/completion request URL can be used as a fallback. | -| `gemini-business` | `gembiz` | Gemini Business (Enterprise) | Web cookie | [link](https://business.gemini.google) | From your enterprise account: open business.gemini.google/home/cid/{your-cid}, then copy __Secure-1PSID and __Secure-1PSIDTS cookies from DevTools → Application → Cookies. Paste as a cookie header below. | -| `gemini-web` | `gweb` | Gemini Web (Free) | Web cookie | [link](https://gemini.google.com) | Paste your __Secure-1PSID cookie value from gemini.google.com. Optionally add __Secure-1PSIDTS separated by semicolon. | -| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste the full grok.com cookie line from DevTools → Application → Cookies. Include both `sso` and `sso-rw` (e.g. `sso=...; sso-rw=...`) — Grok's anti-bot rejects `sso` on its own. | -| `huggingchat` | `huggingchat` | HuggingChat (Free) | Web cookie | [link](https://huggingface.co/chat) | Paste the full Cookie header from huggingface.co/chat (DevTools → Network → /chat/conversation → Request Headers → Cookie). It should include hf-chat and may also include token / aws-waf-token. | -| `inner-ai` | `in-ai` | Inner.ai (Subscription) | Web cookie | [link](https://app.innerai.com) | Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com | -| `kimi-web` | `kimi-web` | Kimi Web (Moonshot AI) | Web cookie | [link](https://www.kimi.com) | Paste your Cookie header from www.kimi.com (must contain kimi-auth=...). Find it via DevTools → Network → request → Cookie. | -| `lmarena` | `lma` | LMArena (Free) | Web cookie | [link](https://lmarena.ai) | Paste the full Cookie header from lmarena.ai (DevTools → Network → request → Cookie). The session is now split across arena-auth-prod-v1.0, .1, … — copy the whole header. Optional — works with free tier for basic comparisons. | -| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess value or full cookie header from meta.ai | -| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your __Secure-next-auth.session-token cookie value from perplexity.ai | -| `poe-web` | `poe` | Poe Web (Subscription) | Web cookie | [link](https://poe.com) | Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b) | -| `qwen-web` | `qwen-web` | Qwen Web (Free) | Web cookie | [link](https://chat.qwen.ai) | Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token). | -| `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Open t3.chat in your browser, log in, then open DevTools → Application → Local Storage → https://t3.chat. Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. Paste both values here. See provider setup docs for a step-by-step guide. | -| `v0-vercel-web` | `v0` | v0 Vercel Web (Code Gen) | Web cookie | [link](https://v0.dev) | Paste your session cookie from v0.dev (DevTools → Application → Cookies) | -| `venice-web` | `ven` | Venice Web (Privacy) | Web cookie | [link](https://venice.ai) | Paste your session cookie from venice.ai (DevTools → Application → Cookies) | -| `yuanbao-web` | `ybw` | Tencent Yuanbao (Free) | Web cookie | [link](https://yuanbao.tencent.com) | Log in to yuanbao.tencent.com, then paste the full Cookie header (DevTools → Network → any /api request → Request Headers → Cookie). It must contain hy_user and hy_token. | -| `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | +| `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste your access_token from copilot.microsoft.com (or export a .har file from DevTools while logged in) | +| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | +| `doubao-web` | `db` | Dola Web (ByteDance) | Web cookie | [link](https://www.dola.com) | Paste the full Cookie header from www.dola.com. It should include sessionid, ttwid, and s_v_web_id. If s_v_web_id is unavailable, fp=verify_... from a chat/completion request URL can be used as a fallback. | +| `gemini-business` | `gembiz` | Gemini Business (Enterprise) | Web cookie | [link](https://business.gemini.google) | From your enterprise account: open business.gemini.google/home/cid/{your-cid}, then copy __Secure-1PSID and __Secure-1PSIDTS cookies from DevTools → Application → Cookies. Paste as a cookie header below. | +| `gemini-web` | `gweb` | Gemini Web (Free) | Web cookie | [link](https://gemini.google.com) | Paste your __Secure-1PSID cookie value from gemini.google.com. Optionally add __Secure-1PSIDTS separated by semicolon. | +| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste the full grok.com cookie line from DevTools → Application → Cookies. Include both `sso` and `sso-rw` (e.g. `sso=...; sso-rw=...`) — Grok's anti-bot rejects `sso` on its own. | +| `huggingchat` | `huggingchat` | HuggingChat (Free) | Web cookie | [link](https://huggingface.co/chat) | Paste the full Cookie header from huggingface.co/chat (DevTools → Network → /chat/conversation → Request Headers → Cookie). It should include hf-chat and may also include token / aws-waf-token. | +| `inner-ai` | `in-ai` | Inner.ai (Subscription) | Web cookie | [link](https://app.innerai.com) | Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com | +| `kimi-web` | `kimi-web` | Kimi Web (Moonshot AI) | Web cookie | [link](https://www.kimi.com) | Paste your Cookie header from www.kimi.com (must contain kimi-auth=...). Find it via DevTools → Network → request → Cookie. | +| `lmarena` | `lma` | Arena (Free) | Web cookie | [link](https://arena.ai) | Paste the full Cookie header from arena.ai (DevTools → Network → request → Cookie). Include arena-auth-prod-v1.0/.1… and cf_clearance/__cf_bm when present. OmniRoute uses Chrome TLS impersonation; if Arena still 403s, set providerSpecificData.recaptchaV3Token from a live browser session. | +| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess value or full cookie header from meta.ai | +| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your __Secure-next-auth.session-token cookie value from perplexity.ai | +| `poe-web` | `poe` | Poe Web (Subscription) | Web cookie | [link](https://poe.com) | Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b) | +| `qwen-web` | `qwen-web` | Qwen Web (Free) | Web cookie | [link](https://chat.qwen.ai) | Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token). | +| `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Open t3.chat in your browser, log in, then open DevTools → Application → Local Storage → https://t3.chat. Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. Paste both values here. See provider setup docs for a step-by-step guide. | +| `v0-vercel-web` | `v0` | v0 Vercel Web (Code Gen) | Web cookie | [link](https://v0.dev) | Paste your session cookie from v0.dev (DevTools → Application → Cookies) | +| `venice-web` | `ven` | Venice Web (Privacy) | Web cookie | [link](https://venice.ai) | Paste your session cookie from venice.ai (DevTools → Application → Cookies) | +| `yuanbao-web` | `ybw` | Tencent Yuanbao (Free) | Web cookie | [link](https://yuanbao.tencent.com) | Log in to yuanbao.tencent.com, then paste the full Cookie header (DevTools → Network → any /api request → Request Headers → Cookie). It must contain hy_user and hy_token. | +| `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | -## API Key Providers (paid / paid-with-free-credits) (167) +## API Key Providers (paid / paid-with-free-credits) (166) -| ID | Alias | Name | Tags | Website | Notes | -| --------------------- | -------------- | ------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `360ai` | `360ai` | 360 AI | API key | [link](https://ai.360.cn) | Get API key at ai.360.cn | -| `agentrouter` | `agentrouter` | AgentRouter | API key, aggregator | [link](https://agentrouter.org) | $200 free credits on signup - multi-model routing gateway | -| `ai21` | `ai21` | AI21 Labs | API key | [link](https://www.ai21.com) | $10 trial credits on signup (valid 3 months), no credit card required | -| `aimlapi` | `aiml` | AI/ML API | API key, aggregator | [link](https://aimlapi.com) | Free tier paused (2026) — AI/ML API is now pay-as-you-go only (min $20 top-up); no recurring free credits. | -| `alibaba` | `ali` | Alibaba | API key | [link](https://bailian.console.alibabacloud.com/) | — | -| `alibaba-cn` | `ali-cn` | Alibaba (China) | API key | [link](https://dashscope.console.aliyun.com/) | — | -| `anthropic` | `anthropic` | Anthropic | API key | [link](https://platform.claude.com) | — | -| `api-airforce` | `af` | Api.airforce | API key | [link](https://api.airforce) | 55 free tier models including Grok-3, Claude 3.7, Qwen3, Kimi-K2, Gemini 2.5 Flash, DeepSeek-V3 | -| `arcee-ai` | `arcee` | Arcee AI | API key | [link](https://arcee.ai) | Get API key at arcee.ai | -| `azure-ai` | `azure-ai` | Azure AI Foundry | API key, enterprise | [link](https://learn.microsoft.com/azure/ai-foundry) | Use your Azure AI Foundry key. Base URL can be https://.services.ai.azure.com/openai/v1/ or https://.openai.azure.com/openai/v1/. | -| `azure-openai` | `azure` | Azure OpenAI | API key, enterprise | [link](https://azure.microsoft.com/products/ai-services/openai-service) | Use your Azure OpenAI API key. Base URL should be your resource endpoint, for example https://my-resource.openai.azure.com. | -| `bai` | `bai` | b.ai | API key | [link](https://b.ai) | Bearer API key for the b.ai OpenAI-compatible LLM gateway (distinct from TheB.AI). Create a key at https://docs.b.ai, then use https://api.b.ai/v1 as the OpenAI-compatible base URL. | -| `baichuan` | `baichuan` | Baichuan | API key | [link](https://baichuan.com) | Get API key at platform.baichuan-ai.com | -| `baidu` | `baidu` | Baidu (ERNIE) | API key | [link](https://yiyan.baidu.com) | Get API key at console.bce.baidu.com | -| `bailian-coding-plan` | `bcp` | Alibaba Coding Plan | API key | [link](https://www.alibabacloud.com/help/en/model-studio/coding-plan) | — | -| `baseten` | `baseten` | Baseten | API key | [link](https://baseten.co) | $30 free trial credits for GPU inference | -| `bazaarlink` | `bzl` | BazaarLink | API key | [link](https://bazaarlink.ai) | Use your BazaarLink API key (starts with sk-bl-) in Authorization: Bearer . OpenAI SDK works with base URL https://bazaarlink.ai/api/v1. Models use provider/model-name format. | -| `bedrock` | `bedrock` | Amazon Bedrock | API key, enterprise | [link](https://aws.amazon.com/bedrock) | Use your Amazon Bedrock API key and configure the AWS region where your models are enabled (for example eu-west-2). OmniRoute calls Bedrock's native Converse API directly. | -| `black-forest-labs` | `bfl` | Black Forest Labs | API key, image | [link](https://blackforestlabs.ai) | — | -| `blackbox` | `bb` | Blackbox AI | API key | [link](https://blackbox.ai) | Free tier: unlimited basic chat plus Minimax-M2.5, no credit card required | -| `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 | -| `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — | -| `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks | -| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. | -| `charm-hyper` | `charm-hyper` | Charm Hyper | API key | [link](https://hyper.charm.land) | 100 free monthly Hypercredits on signup | -| `chutes` | `chutes` | Chutes.ai | API key, aggregator | [link](https://chutes.ai) | Bearer API key for the Chutes OpenAI-compatible gateway. | -| `clarifai` | `clarifai` | Clarifai | API key, enterprise | [link](https://docs.clarifai.com) | Use your Clarifai PAT or app-specific API key. OmniRoute targets the OpenAI-compatible endpoint at https://api.clarifai.com/v2/ext/openai/v1 and authenticates with Authorization: Key . | -| `clinepass` | `clinepass` | ClinePass | API key | [link](https://cline.bot) | — | -| `cloudflare-ai` | `cf` | Cloudflare Workers AI | API key | [link](https://developers.cloudflare.com/workers-ai) | Requires API Token AND Account ID (found at dash.cloudflare.com) | -| `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — | -| `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required | -| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. | -| `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api | -| `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — | -| `databricks` | `databricks` | Databricks | API key, enterprise | [link](https://www.databricks.com) | — | -| `datarobot` | `datarobot` | DataRobot | API key, enterprise | [link](https://docs.datarobot.com) | Use your DataRobot API token. Optional Base URL can be the account root (for LLM Gateway) or a deployment URL under /api/v2/deployments/. | -| `deepinfra` | `deepinfra` | DeepInfra | API key | [link](https://deepinfra.com) | Free signup credits for API testing and model exploration | -| `deepseek` | `ds` | DeepSeek | API key | [link](https://platform.deepseek.com) | 5M free tokens on signup - no credit card required | -| `dgrid` | `dgrid` | DGrid | API key | [link](https://dgrid.ai) | DGrid Free Models Router: 10 requests/minute and 100 requests/day. A $5 lifetime top-up unlocks up to 20 requests/minute and 1,000 requests/day. | -| `dify` | `dify` | Dify | API key | [link](https://dify.ai) | Get API key from your Dify instance. | -| `digitalocean` | `digitalocean` | DigitalOcean | API key | [link](https://docs.digitalocean.com/products/ai-platform/) | — | -| `dit` | `dai` | DIT.ai | API key | [link](https://dit.ai) | Use your dit.ai API key in Authorization: Bearer . Fully OpenAI-compatible — a drop-in replacement, just change the base URL to https://api.dit.ai/v1. | -| `doubao` | `doubao` | Doubao | API key | [link](https://doubao.com) | Get API key at console.volcengine.com | -| `empower` | `empower` | Empower | API key, aggregator | [link](https://docs.empower.dev) | Bearer API key for the Empower OpenAI-compatible endpoint. | -| `factory` | `factory` | Factory | API key | [link](https://factory.ai) | Bearer API key for the Factory OpenAI-compatible gateway. | -| `fal-ai` | `fal` | Fal.ai | API key, image | [link](https://fal.ai) | — | -| `featherless-ai` | `featherless` | Featherless AI | API key | [link](https://featherless.ai) | Free tier available — no credit card required | -| `fenayai` | `fenayai` | FenayAI | API key, aggregator | [link](https://fenayai.com) | Bearer API key for the FenayAI OpenAI-compatible gateway. | -| `firecrawl` | `fc` | Firecrawl | API key | [link](https://firecrawl.dev) | — | -| `fireworks` | `fireworks` | Fireworks AI | API key | [link](https://fireworks.ai) | $1 free starter credits on signup for API testing | -| `freeaiapikey` | `faik` | FreeAIAPIKey | API key | [link](https://freeaiapikey.com) | — | -| `freemodel-dev` | `fmd` | FreeModel.dev | API key | [link](https://freemodel.dev) | $300 free credits on signup — no credit card required. Access GPT-5.4 and GPT-5.5 (OpenAI's latest flagship models) through an OpenAI-compatible API. | -| `friendliai` | `friendli` | FriendliAI | API key | [link](https://friendli.ai) | Free tier for serverless inference — no credit card required | -| `galadriel` | `galadriel` | Galadriel | API key | [link](https://galadriel.com) | ⚠️ **DEPRECATED.** api.galadriel.ai no longer resolves (sweep 2026-06-19); the inference API appears discontinued. | -| `gemini` | `gemini` | Gemini (Google AI Studio) | API key | [link](https://aistudio.google.com) | Free forever: 1,500 req/day for Gemini 2.5 Flash — no credit card, get key at aistudio.google.com | -| `getgoapi` | `ggo` | GoAPI | API key, aggregator | [link](https://api.getgoapi.com) | — | -| `gigachat` | `gigachat` | GigaChat (Sber) | API key | [link](https://developers.sber.ru) | — | -| `github-models` | `ghm` | GitHub Models | API key | [link](https://github.com/marketplace/models) | Create a GitHub PAT with 'models: read' scope at github.com/settings/tokens | -| `gitlab` | `gitlab` | GitLab Duo PAT | API key | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | GitLab personal access token for the public Code Suggestions API. Configure a self-hosted base URL when not using gitlab.com. | -| `gitlawb` | `glb` | Gitlawb Opengateway (MiMo) | API key | [link](https://opengateway.gitlawb.com) | Free MiMo (xiaomi/mimo-v2.5) revoked 2026-05 — Opengateway is now a pay-as-you-go credit gateway; no recurring free model. | -| `gitlawb-gmi` | `glb-gmi` | Gitlawb Opengateway (GMI Cloud) | API key | [link](https://opengateway.gitlawb.com) | Free Nemotron promo ended 2026-06 — the GMI Cloud route is now pay-as-you-go credit only. | -| `glm` | `glm` | GLM Coding | API key | [link](https://z.ai/subscribe) | — | -| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — | -| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — | -| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card | -| `hackclub` | `hc` | Hackclub AI | API key, aggregator | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. | -| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api | -| `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn | -| `heroku` | `heroku` | Heroku AI | API key, enterprise | [link](https://www.heroku.com) | — | -| `huggingface` | `hf` | HuggingFace | API key | [link](https://huggingface.co) | Free Inference API for thousands of models (Whisper, VITS, SDXL…) | -| `hyperbolic` | `hyp` | Hyperbolic | API key | [link](https://hyperbolic.xyz) | $1-5 trial credits on signup for serverless inference | -| `ideogram` | `ideo` | Ideogram | API key | [link](https://ideogram.ai) | Get API key at ideogram.ai/docs/api | -| `iflytek` | `iflytek` | iFlytek Spark | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | -| `inference-net` | `inet` | Inference.net | API key | [link](https://inference.net) | $25 free credits on signup plus research grants available | -| `jina-ai` | `jina` | Jina AI | API key, embed/rerank | [link](https://jina.ai) | Bearer API key for the Jina AI rerank API. | -| `jina-reader` | `jr` | Jina Reader | API key | [link](https://jina.ai/reader) | — | -| `kenari` | `kenari` | Kenari | API key | [link](https://kenari.id) | Use your Kenari API key (kn-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://kenari.id/v1. | -| `kie` | `kie` | KIE.AI | API key | [link](https://kie.ai) | — | -| `kilo-gateway` | `kg` | Kilo Gateway | API key, aggregator | [link](https://kilo.ai) | — | -| `kimi` | `kimi` | Kimi | API key | [link](https://platform.moonshot.ai) | — | -| `kimi-coding-apikey` | `kmca` | Kimi Coding (API Key) | API key | [link](https://www.kimi.com/code) | — | -| `lambda-ai` | `lambda` | Lambda AI | API key | [link](https://lambda.ai) | — | -| `laozhang` | `lz` | LaoZhang AI | API key, aggregator | [link](https://api.laozhang.ai) | — | -| `leonardo` | `leo` | Leonardo AI | API key, video | [link](https://leonardo.ai) | Get API key at leonardo.ai/developer | -| `liquid` | `liquid` | Liquid AI | API key | [link](https://liquid.ai) | Get API key at liquid.ai | -| `llamagate` | `llamagate` | LlamaGate | API key | [link](https://llamagate.ai) | — | -| `llm7` | `llm7` | LLM7.io | API key | [link](https://llm7.io) | No signup required - 2 req/s, 20 RPM, 100 req/hr free tier | -| `longcat` | `lc` | LongCat AI | API key | [link](https://longcat.chat/platform/docs) | Free: one-time 10M-token grant after account signup + KYC verification (LongCat-2.0). One-time only — not a recurring daily/monthly allowance. | -| `maritalk` | `maritalk` | Maritalk | API key | [link](https://www.maritaca.ai) | — | -| `meta-llama` | `meta` | Meta Llama API | API key | [link](https://llama.developer.meta.com) | — | -| `minimax` | `minimax` | Minimax Coding | API key, video | [link](https://www.minimax.io) | — | -| `minimax-cn` | `minimax-cn` | Minimax (China) | API key | [link](https://www.minimaxi.com) | — | -| `mistral` | `mistral` | Mistral | API key | [link](https://mistral.ai) | Free Experiment tier: rate-limited access to all models, no credit card required | -| `modal` | `mdl` | Modal | API key, enterprise | [link](https://modal.com/docs) | Use the bearer token that protects your Modal deployment, if enabled. Base URL should point to your OpenAI-compatible Modal app, for example https://--.modal.run/v1. | -| `modelscope` | `ms` | ModelScope | API key | [link](https://modelscope.cn) | Free tier via ModelScope API-Inference — Alibaba account required. | -| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | Get API key at monsterapi.ai | -| `moonshot` | `moonshot` | Moonshot AI | API key | [link](https://platform.moonshot.ai) | — | -| `morph` | `morph` | Morph | API key | [link](https://morphllm.com) | Free tier: 250K credits/month, $0 | -| `nanogpt` | `nanogpt` | NanoGPT | API key | [link](https://nano-gpt.com) | — | -| `nebius` | `nebius` | Nebius AI | API key | [link](https://nebius.com) | ~$1 trial credits on signup for API testing | -| `nlpcloud` | `nlpc` | NLP Cloud | API key | [link](https://docs.nlpcloud.com) | Use your NLP Cloud API key in Authorization: Token . OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu//chatbot by default. | -| `nomic` | `nomic` | Nomic | API key | [link](https://nomic.ai) | Get API key at atlas.nomic.ai | -| `nous-research` | `nous` | Nous Research | API key | [link](https://portal.nousresearch.com/help) | Use your Nous Portal API key. OmniRoute targets the official OpenAI-compatible inference endpoint at https://inference-api.nousresearch.com/v1. | -| `novita` | `novita` | Novita AI | API key, aggregator | [link](https://novita.ai) | $0.50 trial credits on signup (valid about 1 year) | -| `nscale` | `nscale` | nScale | API key | [link](https://nscale.com) | $5 free credits on signup for inference testing | -| `nube` | `nube` | Nube.sh | API key | [link](https://nube.sh) | — | -| `nvidia` | `nvidia` | NVIDIA NIM | API key | [link](https://build.nvidia.com) | Free dev access: ~40 RPM, 70+ models (Kimi K2.5, GLM 4.7, DeepSeek V3.2...) | -| `oci` | `oci` | OCI Generative AI | API key, enterprise | [link](https://www.oracle.com/artificial-intelligence/generative-ai) | Use your OCI Generative AI API key or IAM bearer token. Base URL can be https://inference.generativeai..oci.oraclecloud.com/openai/v1/. | -| `ollama-cloud` | `ollamacloud` | Ollama Cloud | API key | [link](https://ollama.com/settings/keys) | — | -| `openadapter` | `oad` | OpenAdapter | API key | [link](https://openadapter.dev) | Use your OpenAdapter API key in Authorization: Bearer sk-cv-. Fully OpenAI-compatible. API base URL: https://api.openadapter.in/v1. | -| `openai` | `openai` | OpenAI | API key | [link](https://platform.openai.com) | — | -| `opencode-go` | `opencode-go` | OpenCode Go | API key | [link](https://opencode.ai/go) | — | -| `opencode-zen` | `opencode-zen` | OpenCode Zen | API key | [link](https://opencode.ai/zen) | — | -| `openrouter` | `openrouter` | OpenRouter | API key, aggregator | [link](https://openrouter.ai) | Free models at $0/token with :free suffix - 20 RPM / 200 RPD | -| `orcarouter` | `orcarouter` | OrcaRouter | API key | [link](https://www.orcarouter.ai) | — | -| `ovhcloud` | `ovh` | OVHcloud AI | API key | [link](https://www.ovhcloud.com) | — | -| `perplexity` | `pplx` | Perplexity | API key | [link](https://www.perplexity.ai) | — | -| `piapi` | `pi` | PiAPI | API key, aggregator | [link](https://piapi.ai) | — | -| `pioneer` | `pn` | Pioneer AI | API key | [link](https://pioneer.ai) | $75 free usage credits — no credit card required | -| `poe` | `poe` | Poe | API key, aggregator | [link](https://creator.poe.com/api-reference) | Bearer API key for the Poe OpenAI-compatible API. | -| `pollinations` | `pol` | Pollinations AI | API key, video | [link](https://pollinations.ai) | Free keyless tier: openai, openai-fast, openai-large, qwen-coder, mistral, deepseek, grok, gemini-flash-lite-3.1, perplexity-fast, perplexity-reasoning. Premium models (claude, gemini, midijourney) require a Pollinations API key from enter.pollinations.ai. | -| `predibase` | `predibase` | Predibase | API key | [link](https://predibase.com) | ⚠️ **DEPRECATED.** serving.app.predibase.com no longer resolves (sweep 2026-06-19); the managed serving API appears discontinued. | -| `publicai` | `publicai` | PublicAI | API key | [link](https://publicai.co) | Requires an API key — one-time signup credit, then paid | -| `puter` | `pu` | Puter AI | API key | [link](https://puter.com) | Get token at puter.com/dashboard → Copy Auth Token | -| `qianfan` | `qianfan` | Baidu Qianfan | API key | [link](https://cloud.baidu.com/product/wenxinworkshop) | — | -| `qiniu` | `qiniu` | Qiniu | API key | [link](https://www.qiniu.com) | — | -| `recraft` | `recraft` | Recraft | API key, image | [link](https://recraft.ai) | — | -| `reka` | `reka` | Reka | API key | [link](https://docs.reka.ai/chat/overview) | Use your Reka API key. OmniRoute supports the OpenAI-compatible base URL https://api.reka.ai/v1 and sends both Authorization and X-Api-Key headers for compatibility. | -| `requesty` | `requesty` | Requesty | API key | [link](https://requesty.ai) | Free tier ~200 requests/day - multi-model routing gateway (300+ models) | -| `runwayml` | `runway` | Runway | API key, video | [link](https://docs.dev.runwayml.com) | Use your Runway API key in Authorization: Bearer . OmniRoute targets the current Runway API at https://api.dev.runwayml.com/v1 and sends the required X-Runway-Version header automatically. | -| `sambanova` | `samba` | SambaNova | API key | [link](https://sambanova.ai) | $5 free credits on signup (30-day validity), no credit card required | -| `sap` | `sap` | SAP Generative AI Hub | API key, enterprise | [link](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/generative-ai-hub-in-sap-ai-core) | Use your SAP AI Core bearer token. Base URL can be your AI_API_URL root or a deploymentUrl from Generative AI Hub. | -| `scaleway` | `scw` | Scaleway AI | API key | [link](https://www.scaleway.com/en/docs/ai-data/generative-apis/) | 1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B | -| `sensenova` | `sensenova` | SenseNova | API key | [link](https://platform.sensenova.cn) | Get API key at platform.sensenova.cn | -| `siliconflow` | `siliconflow` | SiliconFlow | API key | [link](https://cloud.siliconflow.com) | $1 free credits plus permanently free models after identity verification | -| `snowflake` | `snowflake` | Snowflake Cortex | API key, enterprise | [link](https://www.snowflake.com) | — | -| `sparkdesk` | `sparkdesk` | SparkDesk | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | -| `stability-ai` | `stability` | Stability AI | API key, image | [link](https://stability.ai) | — | -| `stepfun` | `stepfun` | StepFun | API key | [link](https://stepfun.com) | Get API key at platform.stepfun.com | -| `sumopod` | `sumopod` | SumoPod | API key | [link](https://ai.sumopod.com) | Use your SumoPod API key (sk-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://ai.sumopod.com/v1. | -| `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) | -| `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — | -| `tencent` | `tencent` | Tencent Hunyuan | API key | [link](https://hunyuan.tencent.com) | Get API key at console.cloud.tencent.com | -| `thebai` | `thebai` | TheB.AI | API key, aggregator | [link](https://theb.ai) | Bearer API key for the TheB.AI OpenAI-compatible gateway. | -| `tinyfish` | `tf` | TinyFish Fetch | API key | [link](https://docs.tinyfish.ai/fetch-api) | X-API-Key from agent.tinyfish.ai/api-keys | -| `together` | `together` | Together AI | API key, video | [link](https://www.together.ai) | — | -| `tokenrouter` | `trk` | TokenRouter | API key | [link](https://tokenrouter.com) | Use your TokenRouter API key in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.tokenrouter.com/v1. | -| `topaz` | `topaz` | Topaz | API key, image | [link](https://topazlabs.com) | — | -| `udio` | `udio` | Udio | API key | [link](https://udio.com) | Paste session cookie from udio.com (Supabase auth) | -| `uncloseai` | `unc` | UncloseAI | API key | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. | -| `upstage` | `upstage` | Upstage | API key | [link](https://www.upstage.ai) | — | -| `v0-vercel` | `v0` | v0 (Vercel) | API key | [link](https://v0.dev) | — | -| `venice` | `venice` | Venice.ai | API key | [link](https://venice.ai) | — | -| `vercel-ai-gateway` | `vag` | Vercel AI Gateway | API key, aggregator | [link](https://vercel.com/docs/ai-gateway) | — | -| `vertex` | `vertex` | Vertex AI | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide Service Account JSON or OAuth access_token | -| `vertex-partner` | `vp` | Vertex AI Partners | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide the same Service Account JSON used for Vertex AI partner models. | -| `volcengine` | `volcengine` | Volcengine | API key | [link](https://www.volcengine.com) | — | -| `voyage-ai` | `voyage` | Voyage AI | API key, embed/rerank | [link](https://www.voyageai.com) | Bearer API key for Voyage AI embeddings and rerank APIs. | -| `wafer` | `wafer` | Wafer AI | API key | [link](https://wafer.ai) | — | -| `wandb` | `wandb` | Weights & Biases Inference | API key | [link](https://wandb.ai) | — | -| `watsonx` | `watsonx` | IBM watsonx.ai Gateway | API key, enterprise | [link](https://www.ibm.com/products/watsonx-ai) | Use your watsonx bearer token. Base URL can be https://.ml.cloud.ibm.com/ml/gateway/v1/ or a self-managed /ml/gateway/v1 endpoint. | -| `x5lab` | `x5lab` | X5Lab | API key | [link](https://x5lab.dev) | Use your X5Lab API key (x5-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.x5lab.dev/v1. | -| `xai` | `xai` | xAI (Grok) | API key | [link](https://x.ai) | — | -| `xiaomi-mimo` | `mimo` | Xiaomi MiMo | API key | [link](https://mimo.mi.com) | — | -| `yi` | `yi` | Yi (01.AI) | API key | [link](https://01.ai) | Get API key at platform.lingyiwanwu.com | -| `zai` | `zai` | Z.AI | API key | [link](https://open.bigmodel.cn) | — | -| `zenmux` | `zm` | ZenMux | API key | [link](https://zenmux.ai) | Use your ZenMux API key in Authorization: Bearer . ZenMux is fully OpenAI-compatible. Base URL: https://zenmux.ai/api/v1. | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `360ai` | `360ai` | 360 AI | API key | [link](https://ai.360.cn) | Get API key at ai.360.cn | +| `agentrouter` | `agentrouter` | AgentRouter | API key, aggregator | [link](https://agentrouter.org) | $200 free credits on signup - multi-model routing gateway | +| `ai21` | `ai21` | AI21 Labs | API key | [link](https://www.ai21.com) | $10 trial credits on signup (valid 3 months), no credit card required | +| `aimlapi` | `aiml` | AI/ML API | API key, aggregator | [link](https://aimlapi.com) | Free tier paused (2026) — AI/ML API is now pay-as-you-go only (min $20 top-up); no recurring free credits. | +| `alibaba` | `ali` | Alibaba | API key | [link](https://bailian.console.alibabacloud.com/) | — | +| `alibaba-cn` | `ali-cn` | Alibaba (China) | API key | [link](https://dashscope.console.aliyun.com/) | — | +| `anthropic` | `anthropic` | Anthropic | API key | [link](https://platform.claude.com) | — | +| `api-airforce` | `af` | Api.airforce | API key | [link](https://api.airforce) | 55 free tier models including Grok-3, Claude 3.7, Qwen3, Kimi-K2, Gemini 2.5 Flash, DeepSeek-V3 | +| `arcee-ai` | `arcee` | Arcee AI | API key | [link](https://arcee.ai) | Get API key at arcee.ai | +| `azure-ai` | `azure-ai` | Azure AI Foundry | API key, enterprise | [link](https://learn.microsoft.com/azure/ai-foundry) | Use your Azure AI Foundry key. Base URL can be https://.services.ai.azure.com/openai/v1/ or https://.openai.azure.com/openai/v1/. | +| `azure-openai` | `azure` | Azure OpenAI | API key, enterprise | [link](https://azure.microsoft.com/products/ai-services/openai-service) | Use your Azure OpenAI API key. Base URL should be your resource endpoint, for example https://my-resource.openai.azure.com. | +| `bai` | `bai` | b.ai | API key | [link](https://b.ai) | Bearer API key for the b.ai OpenAI-compatible LLM gateway (distinct from TheB.AI). Create a key at https://docs.b.ai, then use https://api.b.ai/v1 as the OpenAI-compatible base URL. | +| `baichuan` | `baichuan` | Baichuan | API key | [link](https://baichuan.com) | Get API key at platform.baichuan-ai.com | +| `baidu` | `baidu` | Baidu (ERNIE) | API key | [link](https://yiyan.baidu.com) | Get API key at console.bce.baidu.com | +| `bailian-coding-plan` | `bcp` | Alibaba Coding Plan | API key | [link](https://www.alibabacloud.com/help/en/model-studio/coding-plan) | — | +| `baseten` | `baseten` | Baseten | API key | [link](https://baseten.co) | $30 free trial credits for GPU inference | +| `bazaarlink` | `bzl` | BazaarLink | API key | [link](https://bazaarlink.ai) | Use your BazaarLink API key (starts with sk-bl-) in Authorization: Bearer . OpenAI SDK works with base URL https://bazaarlink.ai/api/v1. Models use provider/model-name format. | +| `bedrock` | `bedrock` | Amazon Bedrock | API key, enterprise | [link](https://aws.amazon.com/bedrock) | Use your Amazon Bedrock API key and configure the AWS region where your models are enabled (for example eu-west-2). OmniRoute calls Bedrock's native Converse API directly. | +| `black-forest-labs` | `bfl` | Black Forest Labs | API key, image | [link](https://blackforestlabs.ai) | — | +| `blackbox` | `bb` | Blackbox AI | API key | [link](https://blackbox.ai) | Free tier: unlimited basic chat plus Minimax-M2.5, no credit card required | +| `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 | +| `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — | +| `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks | +| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. | +| `charm-hyper` | `charm-hyper` | Charm Hyper | API key | [link](https://hyper.charm.land) | 100 free monthly Hypercredits on signup | +| `chutes` | `chutes` | Chutes.ai | API key, aggregator | [link](https://chutes.ai) | Bearer API key for the Chutes OpenAI-compatible gateway. | +| `clarifai` | `clarifai` | Clarifai | API key, enterprise | [link](https://docs.clarifai.com) | Use your Clarifai PAT or app-specific API key. OmniRoute targets the OpenAI-compatible endpoint at https://api.clarifai.com/v2/ext/openai/v1 and authenticates with Authorization: Key . | +| `cloudflare-ai` | `cf` | Cloudflare Workers AI | API key | [link](https://developers.cloudflare.com/workers-ai) | Requires API Token AND Account ID (found at dash.cloudflare.com) | +| `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — | +| `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required | +| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. | +| `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api | +| `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — | +| `databricks` | `databricks` | Databricks | API key, enterprise | [link](https://www.databricks.com) | — | +| `datarobot` | `datarobot` | DataRobot | API key, enterprise | [link](https://docs.datarobot.com) | Use your DataRobot API token. Optional Base URL can be the account root (for LLM Gateway) or a deployment URL under /api/v2/deployments/. | +| `deepinfra` | `deepinfra` | DeepInfra | API key | [link](https://deepinfra.com) | Free signup credits for API testing and model exploration | +| `deepseek` | `ds` | DeepSeek | API key | [link](https://platform.deepseek.com) | 5M free tokens on signup - no credit card required | +| `dgrid` | `dgrid` | DGrid | API key | [link](https://dgrid.ai) | DGrid Free Models Router: 10 requests/minute and 100 requests/day. A $5 lifetime top-up unlocks up to 20 requests/minute and 1,000 requests/day. | +| `dify` | `dify` | Dify | API key | [link](https://dify.ai) | Get API key from your Dify instance. | +| `digitalocean` | `digitalocean` | DigitalOcean | API key | [link](https://docs.digitalocean.com/products/ai-platform/) | — | +| `dit` | `dai` | DIT.ai | API key | [link](https://dit.ai) | Use your dit.ai API key in Authorization: Bearer . Fully OpenAI-compatible — a drop-in replacement, just change the base URL to https://api.dit.ai/v1. | +| `doubao` | `doubao` | Doubao | API key | [link](https://doubao.com) | Get API key at console.volcengine.com | +| `empower` | `empower` | Empower | API key, aggregator | [link](https://docs.empower.dev) | Bearer API key for the Empower OpenAI-compatible endpoint. | +| `factory` | `factory` | Factory | API key | [link](https://factory.ai) | Bearer API key for the Factory OpenAI-compatible gateway. | +| `fal-ai` | `fal` | Fal.ai | API key, image | [link](https://fal.ai) | — | +| `featherless-ai` | `featherless` | Featherless AI | API key | [link](https://featherless.ai) | Free tier available — no credit card required | +| `fenayai` | `fenayai` | FenayAI | API key, aggregator | [link](https://fenayai.com) | Bearer API key for the FenayAI OpenAI-compatible gateway. | +| `firecrawl` | `fc` | Firecrawl | API key | [link](https://firecrawl.dev) | — | +| `fireworks` | `fireworks` | Fireworks AI | API key | [link](https://fireworks.ai) | $1 free starter credits on signup for API testing | +| `freeaiapikey` | `faik` | FreeAIAPIKey | API key | [link](https://freeaiapikey.com) | — | +| `freemodel-dev` | `fmd` | FreeModel.dev | API key | [link](https://freemodel.dev) | $300 free credits on signup — no credit card required. Access GPT-5.4 and GPT-5.5 (OpenAI's latest flagship models) through an OpenAI-compatible API. | +| `friendliai` | `friendli` | FriendliAI | API key | [link](https://friendli.ai) | Free tier for serverless inference — no credit card required | +| `galadriel` | `galadriel` | Galadriel | API key | [link](https://galadriel.com) | ⚠️ **DEPRECATED.** api.galadriel.ai no longer resolves (sweep 2026-06-19); the inference API appears discontinued. | +| `gemini` | `gemini` | Gemini (Google AI Studio) | API key | [link](https://aistudio.google.com) | Free forever: 1,500 req/day for Gemini 2.5 Flash — no credit card, get key at aistudio.google.com | +| `getgoapi` | `ggo` | GoAPI | API key, aggregator | [link](https://api.getgoapi.com) | — | +| `gigachat` | `gigachat` | GigaChat (Sber) | API key | [link](https://developers.sber.ru) | — | +| `github-models` | `ghm` | GitHub Models | API key | [link](https://github.com/marketplace/models) | Create a GitHub PAT with 'models: read' scope at github.com/settings/tokens | +| `gitlab` | `gitlab` | GitLab Duo PAT | API key | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | GitLab personal access token for the public Code Suggestions API. Configure a self-hosted base URL when not using gitlab.com. | +| `gitlawb` | `glb` | Gitlawb Opengateway (MiMo) | API key | [link](https://opengateway.gitlawb.com) | Free MiMo (xiaomi/mimo-v2.5) revoked 2026-05 — Opengateway is now a pay-as-you-go credit gateway; no recurring free model. | +| `gitlawb-gmi` | `glb-gmi` | Gitlawb Opengateway (GMI Cloud) | API key | [link](https://opengateway.gitlawb.com) | Free Nemotron promo ended 2026-06 — the GMI Cloud route is now pay-as-you-go credit only. | +| `glm` | `glm` | GLM Coding | API key | [link](https://z.ai/subscribe) | — | +| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — | +| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — | +| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card | +| `hackclub` | `hc` | Hackclub AI | API key, aggregator | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. | +| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api | +| `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn | +| `heroku` | `heroku` | Heroku AI | API key, enterprise | [link](https://www.heroku.com) | — | +| `huggingface` | `hf` | HuggingFace | API key | [link](https://huggingface.co) | Free Inference API for thousands of models (Whisper, VITS, SDXL…) | +| `hyperbolic` | `hyp` | Hyperbolic | API key | [link](https://hyperbolic.xyz) | $1-5 trial credits on signup for serverless inference | +| `ideogram` | `ideo` | Ideogram | API key | [link](https://ideogram.ai) | Get API key at ideogram.ai/docs/api | +| `iflytek` | `iflytek` | iFlytek Spark | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | +| `inference-net` | `inet` | Inference.net | API key | [link](https://inference.net) | $25 free credits on signup plus research grants available | +| `jina-ai` | `jina` | Jina AI | API key, embed/rerank | [link](https://jina.ai) | Bearer API key for the Jina AI rerank API. | +| `jina-reader` | `jr` | Jina Reader | API key | [link](https://jina.ai/reader) | — | +| `kenari` | `kenari` | Kenari | API key | [link](https://kenari.id) | Use your Kenari API key (kn-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://kenari.id/v1. | +| `kie` | `kie` | KIE.AI | API key | [link](https://kie.ai) | — | +| `kilo-gateway` | `kg` | Kilo Gateway | API key, aggregator | [link](https://kilo.ai) | — | +| `kimi` | `kimi` | Kimi | API key | [link](https://platform.moonshot.ai) | — | +| `kimi-coding-apikey` | `kmca` | Kimi Coding (API Key) | API key | [link](https://www.kimi.com/code) | — | +| `lambda-ai` | `lambda` | Lambda AI | API key | [link](https://lambda.ai) | — | +| `laozhang` | `lz` | LaoZhang AI | API key, aggregator | [link](https://api.laozhang.ai) | — | +| `leonardo` | `leo` | Leonardo AI | API key, video | [link](https://leonardo.ai) | Get API key at leonardo.ai/developer | +| `liquid` | `liquid` | Liquid AI | API key | [link](https://liquid.ai) | Get API key at liquid.ai | +| `llamagate` | `llamagate` | LlamaGate | API key | [link](https://llamagate.ai) | — | +| `llm7` | `llm7` | LLM7.io | API key | [link](https://llm7.io) | No signup required - 2 req/s, 20 RPM, 100 req/hr free tier | +| `longcat` | `lc` | LongCat AI | API key | [link](https://longcat.chat/platform/docs) | Free: one-time 10M-token grant after account signup + KYC verification (LongCat-2.0). One-time only — not a recurring daily/monthly allowance. | +| `maritalk` | `maritalk` | Maritalk | API key | [link](https://www.maritaca.ai) | — | +| `meta-llama` | `meta` | Meta Llama API | API key | [link](https://llama.developer.meta.com) | — | +| `minimax` | `minimax` | Minimax Coding | API key, video | [link](https://www.minimax.io) | — | +| `minimax-cn` | `minimax-cn` | Minimax (China) | API key | [link](https://www.minimaxi.com) | — | +| `mistral` | `mistral` | Mistral | API key | [link](https://mistral.ai) | Free Experiment tier: rate-limited access to all models, no credit card required | +| `modal` | `mdl` | Modal | API key, enterprise | [link](https://modal.com/docs) | Use the bearer token that protects your Modal deployment, if enabled. Base URL should point to your OpenAI-compatible Modal app, for example https://--.modal.run/v1. | +| `modelscope` | `ms` | ModelScope | API key | [link](https://modelscope.cn) | Free tier via ModelScope API-Inference — Alibaba account required. | +| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | Get API key at monsterapi.ai | +| `moonshot` | `moonshot` | Moonshot AI | API key | [link](https://platform.moonshot.ai) | — | +| `morph` | `morph` | Morph | API key | [link](https://morphllm.com) | Free tier: 250K credits/month, $0 | +| `nanogpt` | `nanogpt` | NanoGPT | API key | [link](https://nano-gpt.com) | — | +| `nebius` | `nebius` | Nebius AI | API key | [link](https://nebius.com) | ~$1 trial credits on signup for API testing | +| `nlpcloud` | `nlpc` | NLP Cloud | API key | [link](https://docs.nlpcloud.com) | Use your NLP Cloud API key in Authorization: Token . OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu//chatbot by default. | +| `nomic` | `nomic` | Nomic | API key | [link](https://nomic.ai) | Get API key at atlas.nomic.ai | +| `nous-research` | `nous` | Nous Research | API key | [link](https://portal.nousresearch.com/help) | Use your Nous Portal API key. OmniRoute targets the official OpenAI-compatible inference endpoint at https://inference-api.nousresearch.com/v1. | +| `novita` | `novita` | Novita AI | API key, aggregator | [link](https://novita.ai) | $0.50 trial credits on signup (valid about 1 year) | +| `nscale` | `nscale` | nScale | API key | [link](https://nscale.com) | $5 free credits on signup for inference testing | +| `nube` | `nube` | Nube.sh | API key | [link](https://nube.sh) | — | +| `nvidia` | `nvidia` | NVIDIA NIM | API key | [link](https://build.nvidia.com) | Free dev access: ~40 RPM, 70+ models (Kimi K2.5, GLM 4.7, DeepSeek V3.2...) | +| `oci` | `oci` | OCI Generative AI | API key, enterprise | [link](https://www.oracle.com/artificial-intelligence/generative-ai) | Use your OCI Generative AI API key or IAM bearer token. Base URL can be https://inference.generativeai..oci.oraclecloud.com/openai/v1/. | +| `ollama-cloud` | `ollamacloud` | Ollama Cloud | API key | [link](https://ollama.com/settings/keys) | — | +| `openadapter` | `oad` | OpenAdapter | API key | [link](https://openadapter.dev) | Use your OpenAdapter API key in Authorization: Bearer sk-cv-. Fully OpenAI-compatible. API base URL: https://api.openadapter.in/v1. | +| `openai` | `openai` | OpenAI | API key | [link](https://platform.openai.com) | — | +| `opencode-go` | `opencode-go` | OpenCode Go | API key | [link](https://opencode.ai/go) | — | +| `opencode-zen` | `opencode-zen` | OpenCode Zen | API key | [link](https://opencode.ai/zen) | — | +| `openrouter` | `openrouter` | OpenRouter | API key, aggregator | [link](https://openrouter.ai) | Free models at $0/token with :free suffix - 20 RPM / 200 RPD | +| `orcarouter` | `orcarouter` | OrcaRouter | API key | [link](https://www.orcarouter.ai) | — | +| `ovhcloud` | `ovh` | OVHcloud AI | API key | [link](https://www.ovhcloud.com) | — | +| `perplexity` | `pplx` | Perplexity | API key | [link](https://www.perplexity.ai) | — | +| `piapi` | `pi` | PiAPI | API key, aggregator | [link](https://piapi.ai) | — | +| `pioneer` | `pn` | Pioneer AI | API key | [link](https://pioneer.ai) | $75 free usage credits — no credit card required | +| `poe` | `poe` | Poe | API key, aggregator | [link](https://creator.poe.com/api-reference) | Bearer API key for the Poe OpenAI-compatible API. | +| `pollinations` | `pol` | Pollinations AI | API key, video | [link](https://pollinations.ai) | Free keyless tier: openai, openai-fast, openai-large, qwen-coder, mistral, deepseek, grok, gemini-flash-lite-3.1, perplexity-fast, perplexity-reasoning. Premium models (claude, gemini, midijourney) require a Pollinations API key from enter.pollinations.ai. | +| `predibase` | `predibase` | Predibase | API key | [link](https://predibase.com) | ⚠️ **DEPRECATED.** serving.app.predibase.com no longer resolves (sweep 2026-06-19); the managed serving API appears discontinued. | +| `publicai` | `publicai` | PublicAI | API key | [link](https://publicai.co) | Requires an API key — one-time signup credit, then paid | +| `puter` | `pu` | Puter AI | API key | [link](https://puter.com) | Get token at puter.com/dashboard → Copy Auth Token | +| `qianfan` | `qianfan` | Baidu Qianfan | API key | [link](https://cloud.baidu.com/product/wenxinworkshop) | — | +| `qiniu` | `qiniu` | Qiniu | API key | [link](https://www.qiniu.com) | — | +| `recraft` | `recraft` | Recraft | API key, image | [link](https://recraft.ai) | — | +| `reka` | `reka` | Reka | API key | [link](https://docs.reka.ai/chat/overview) | Use your Reka API key. OmniRoute supports the OpenAI-compatible base URL https://api.reka.ai/v1 and sends both Authorization and X-Api-Key headers for compatibility. | +| `requesty` | `requesty` | Requesty | API key | [link](https://requesty.ai) | Free tier ~200 requests/day - multi-model routing gateway (300+ models) | +| `runwayml` | `runway` | Runway | API key, video | [link](https://docs.dev.runwayml.com) | Use your Runway API key in Authorization: Bearer . OmniRoute targets the current Runway API at https://api.dev.runwayml.com/v1 and sends the required X-Runway-Version header automatically. | +| `sambanova` | `samba` | SambaNova | API key | [link](https://sambanova.ai) | $5 free credits on signup (30-day validity), no credit card required | +| `sap` | `sap` | SAP Generative AI Hub | API key, enterprise | [link](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/generative-ai-hub-in-sap-ai-core) | Use your SAP AI Core bearer token. Base URL can be your AI_API_URL root or a deploymentUrl from Generative AI Hub. | +| `scaleway` | `scw` | Scaleway AI | API key | [link](https://www.scaleway.com/en/docs/ai-data/generative-apis/) | 1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B | +| `sensenova` | `sensenova` | SenseNova | API key | [link](https://platform.sensenova.cn) | Get API key at platform.sensenova.cn | +| `siliconflow` | `siliconflow` | SiliconFlow | API key | [link](https://cloud.siliconflow.com) | $1 free credits plus permanently free models after identity verification | +| `snowflake` | `snowflake` | Snowflake Cortex | API key, enterprise | [link](https://www.snowflake.com) | — | +| `sparkdesk` | `sparkdesk` | SparkDesk | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | +| `stability-ai` | `stability` | Stability AI | API key, image | [link](https://stability.ai) | — | +| `stepfun` | `stepfun` | StepFun | API key | [link](https://stepfun.com) | Get API key at platform.stepfun.com | +| `sumopod` | `sumopod` | SumoPod | API key | [link](https://ai.sumopod.com) | Use your SumoPod API key (sk-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://ai.sumopod.com/v1. | +| `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) | +| `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — | +| `tencent` | `tencent` | Tencent Hunyuan | API key | [link](https://hunyuan.tencent.com) | Get API key at console.cloud.tencent.com | +| `thebai` | `thebai` | TheB.AI | API key, aggregator | [link](https://theb.ai) | Bearer API key for the TheB.AI OpenAI-compatible gateway. | +| `tinyfish` | `tf` | TinyFish Fetch | API key | [link](https://docs.tinyfish.ai/fetch-api) | X-API-Key from agent.tinyfish.ai/api-keys | +| `together` | `together` | Together AI | API key, video | [link](https://www.together.ai) | — | +| `tokenrouter` | `trk` | TokenRouter | API key | [link](https://tokenrouter.com) | Use your TokenRouter API key in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.tokenrouter.com/v1. | +| `topaz` | `topaz` | Topaz | API key, image | [link](https://topazlabs.com) | — | +| `udio` | `udio` | Udio | API key | [link](https://udio.com) | Paste session cookie from udio.com (Supabase auth) | +| `uncloseai` | `unc` | UncloseAI | API key | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. | +| `upstage` | `upstage` | Upstage | API key | [link](https://www.upstage.ai) | — | +| `v0-vercel` | `v0` | v0 (Vercel) | API key | [link](https://v0.dev) | — | +| `venice` | `venice` | Venice.ai | API key | [link](https://venice.ai) | — | +| `vercel-ai-gateway` | `vag` | Vercel AI Gateway | API key, aggregator | [link](https://vercel.com/docs/ai-gateway) | — | +| `vertex` | `vertex` | Vertex AI | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide Service Account JSON or OAuth access_token | +| `vertex-partner` | `vp` | Vertex AI Partners | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide the same Service Account JSON used for Vertex AI partner models. | +| `volcengine` | `volcengine` | Volcengine | API key | [link](https://www.volcengine.com) | — | +| `voyage-ai` | `voyage` | Voyage AI | API key, embed/rerank | [link](https://www.voyageai.com) | Bearer API key for Voyage AI embeddings and rerank APIs. | +| `wafer` | `wafer` | Wafer AI | API key | [link](https://wafer.ai) | — | +| `wandb` | `wandb` | Weights & Biases Inference | API key | [link](https://wandb.ai) | — | +| `watsonx` | `watsonx` | IBM watsonx.ai Gateway | API key, enterprise | [link](https://www.ibm.com/products/watsonx-ai) | Use your watsonx bearer token. Base URL can be https://.ml.cloud.ibm.com/ml/gateway/v1/ or a self-managed /ml/gateway/v1 endpoint. | +| `x5lab` | `x5lab` | X5Lab | API key | [link](https://x5lab.dev) | Use your X5Lab API key (x5-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.x5lab.dev/v1. | +| `xai` | `xai` | xAI (Grok) | API key | [link](https://x.ai) | — | +| `xiaomi-mimo` | `mimo` | Xiaomi MiMo | API key | [link](https://mimo.mi.com) | — | +| `yi` | `yi` | Yi (01.AI) | API key | [link](https://01.ai) | Get API key at platform.lingyiwanwu.com | +| `zai` | `zai` | Z.AI | API key | [link](https://open.bigmodel.cn) | — | +| `zenmux` | `zm` | ZenMux | API key | [link](https://zenmux.ai) | Use your ZenMux API key in Authorization: Bearer . ZenMux is fully OpenAI-compatible. Base URL: https://zenmux.ai/api/v1. | ## Local Providers (12) -| ID | Alias | Name | Tags | Website | Notes | -| --------------------- | ------------ | ------------------- | ------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `comfyui` | `comfyui` | ComfyUI | Local | [link](https://github.com/comfyanonymous/ComfyUI) | No API key required. Configure the local ComfyUI base URL (default: http://localhost:8188). | -| `docker-model-runner` | `dmr` | Docker Model Runner | Local, self-hosted | [link](https://docs.docker.com/ai/model-runner/) | API key optional. Configure the local Docker Model Runner OpenAI-compatible base URL (default: http://localhost:12434/v1). | -| `lemonade` | `lemonade` | Lemonade Server | Local, self-hosted | [link](https://lemonade-server.ai) | API key optional. Configure the local Lemonade OpenAI-compatible base URL (default: http://localhost:13305/api/v1). | -| `llama-cpp` | `llamacpp` | llama.cpp | Local, self-hosted | [link](https://github.com/ggml-org/llama.cpp) | API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port. | -| `llamafile` | `llamafile` | Llamafile | Local, self-hosted | [link](https://github.com/Mozilla-Ocho/llamafile) | API key optional. Configure the local Llamafile OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). | -| `lm-studio` | `lmstudio` | LM Studio | Local, self-hosted | [link](https://lmstudio.ai) | API key optional. Configure the local LM Studio OpenAI-compatible base URL (default: http://localhost:1234/v1). | -| `ollama-local` | `ollama` | Ollama | Local, self-hosted | [link](https://ollama.com) | No API key required. Ollama runs locally — configure its OpenAI-compatible base URL (default: http://localhost:11434/v1) and make sure Ollama is running before connecting. | -| `oobabooga` | `ooba` | oobabooga | Local, self-hosted | [link](https://github.com/oobabooga/text-generation-webui) | API key optional. Configure the local oobabooga OpenAI-compatible base URL (default: http://localhost:5000/v1). | -| `sdwebui` | `sdwebui` | SD WebUI | Local | [link](https://github.com/AUTOMATIC1111/stable-diffusion-webui) | No API key required. Configure the local WebUI base URL (default: http://localhost:7860). | -| `triton` | `triton` | NVIDIA Triton | Local, self-hosted | [link](https://developer.nvidia.com/triton-inference-server) | API key optional. Configure the Triton OpenAI-compatible base URL (default: http://localhost:8000/v1). | -| `vllm` | `vllm` | vLLM | Local, self-hosted | [link](https://github.com/vllm-project/vllm) | API key optional. Configure the local vLLM OpenAI-compatible base URL (default: http://localhost:8000/v1). | -| `xinference` | `xinference` | XInference | Local, self-hosted | [link](https://inference.readthedocs.io) | API key optional. Configure the local XInference OpenAI-compatible base URL (default: http://localhost:9997/v1). | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `comfyui` | `comfyui` | ComfyUI | Local | [link](https://github.com/comfyanonymous/ComfyUI) | No API key required. Configure the local ComfyUI base URL (default: http://localhost:8188). | +| `docker-model-runner` | `dmr` | Docker Model Runner | Local, self-hosted | [link](https://docs.docker.com/ai/model-runner/) | API key optional. Configure the local Docker Model Runner OpenAI-compatible base URL (default: http://localhost:12434/v1). | +| `lemonade` | `lemonade` | Lemonade Server | Local, self-hosted | [link](https://lemonade-server.ai) | API key optional. Configure the local Lemonade OpenAI-compatible base URL (default: http://localhost:13305/api/v1). | +| `llama-cpp` | `llamacpp` | llama.cpp | Local, self-hosted | [link](https://github.com/ggml-org/llama.cpp) | API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port. | +| `llamafile` | `llamafile` | Llamafile | Local, self-hosted | [link](https://github.com/Mozilla-Ocho/llamafile) | API key optional. Configure the local Llamafile OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). | +| `lm-studio` | `lmstudio` | LM Studio | Local, self-hosted | [link](https://lmstudio.ai) | API key optional. Configure the local LM Studio OpenAI-compatible base URL (default: http://localhost:1234/v1). | +| `ollama-local` | `ollama` | Ollama | Local, self-hosted | [link](https://ollama.com) | No API key required. Ollama runs locally — configure its OpenAI-compatible base URL (default: http://localhost:11434/v1) and make sure Ollama is running before connecting. | +| `oobabooga` | `ooba` | oobabooga | Local, self-hosted | [link](https://github.com/oobabooga/text-generation-webui) | API key optional. Configure the local oobabooga OpenAI-compatible base URL (default: http://localhost:5000/v1). | +| `sdwebui` | `sdwebui` | SD WebUI | Local | [link](https://github.com/AUTOMATIC1111/stable-diffusion-webui) | No API key required. Configure the local WebUI base URL (default: http://localhost:7860). | +| `triton` | `triton` | NVIDIA Triton | Local, self-hosted | [link](https://developer.nvidia.com/triton-inference-server) | API key optional. Configure the Triton OpenAI-compatible base URL (default: http://localhost:8000/v1). | +| `vllm` | `vllm` | vLLM | Local, self-hosted | [link](https://github.com/vllm-project/vllm) | API key optional. Configure the local vLLM OpenAI-compatible base URL (default: http://localhost:8000/v1). | +| `xinference` | `xinference` | XInference | Local, self-hosted | [link](https://inference.readthedocs.io) | API key optional. Configure the local XInference OpenAI-compatible base URL (default: http://localhost:9997/v1). | ## Search Providers (11) -| ID | Alias | Name | Tags | Website | Notes | -| ------------------- | --------------- | -------------------------- | ------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `brave-search` | `brave-search` | Brave Search | Search | [link](https://brave.com/search/api) | Subscription token from Brave Search API dashboard | -| `exa-search` | `exa-search` | Exa Search | Search | [link](https://exa.ai) | API key from dashboard.exa.ai | -| `google-pse-search` | `google-pse` | Google Programmable Search | Search | [link](https://developers.google.com/custom-search/v1/overview) | Requires a Google API key and your Programmable Search Engine ID (cx) | -| `linkup-search` | `linkup` | Linkup Search | Search | [link](https://docs.linkup.so) | Bearer API key from the Linkup dashboard | -| `ollama-search` | `ollama-search` | Ollama Search | Search | [link](https://ollama.com/settings/keys) | Same API key as Ollama Cloud (from ollama.com/settings/keys) | -| `perplexity-search` | `pplx-search` | Perplexity Search | Search | [link](https://docs.perplexity.ai/guides/search-quickstart) | Same API key as Perplexity (pplx-...) | -| `searchapi-search` | `searchapi` | SearchAPI | Search | [link](https://www.searchapi.io/docs/google) | API key from SearchAPI (query param or Bearer auth) | -| `searxng-search` | `searxng` | SearXNG Search | Search | [link](https://docs.searxng.org) | API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access. | -| `serper-search` | `serper-search` | Serper Search | Search | [link](https://serper.dev) | API key from serper.dev dashboard | -| `tavily-search` | `tavily-search` | Tavily Search | Search | [link](https://tavily.com) | API key from app.tavily.com (format: tvly-...) | -| `youcom-search` | `youcom-search` | You.com Search | Search | [link](https://you.com/business/api/) | X-API-Key from the You.com platform dashboard | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `brave-search` | `brave-search` | Brave Search | Search | [link](https://brave.com/search/api) | Subscription token from Brave Search API dashboard | +| `exa-search` | `exa-search` | Exa Search | Search | [link](https://exa.ai) | API key from dashboard.exa.ai | +| `google-pse-search` | `google-pse` | Google Programmable Search | Search | [link](https://developers.google.com/custom-search/v1/overview) | Requires a Google API key and your Programmable Search Engine ID (cx) | +| `linkup-search` | `linkup` | Linkup Search | Search | [link](https://docs.linkup.so) | Bearer API key from the Linkup dashboard | +| `ollama-search` | `ollama-search` | Ollama Search | Search | [link](https://ollama.com/settings/keys) | Same API key as Ollama Cloud (from ollama.com/settings/keys) | +| `perplexity-search` | `pplx-search` | Perplexity Search | Search | [link](https://docs.perplexity.ai/guides/search-quickstart) | Same API key as Perplexity (pplx-...) | +| `searchapi-search` | `searchapi` | SearchAPI | Search | [link](https://www.searchapi.io/docs/google) | API key from SearchAPI (query param or Bearer auth) | +| `searxng-search` | `searxng` | SearXNG Search | Search | [link](https://docs.searxng.org) | API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access. | +| `serper-search` | `serper-search` | Serper Search | Search | [link](https://serper.dev) | API key from serper.dev dashboard | +| `tavily-search` | `tavily-search` | Tavily Search | Search | [link](https://tavily.com) | API key from app.tavily.com (format: tvly-...) | +| `youcom-search` | `youcom-search` | You.com Search | Search | [link](https://you.com/business/api/) | X-API-Key from the You.com platform dashboard | ## Audio-only Providers (7) -| ID | Alias | Name | Tags | Website | Notes | -| ------------ | ---------- | ---------- | ----- | ------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `assemblyai` | `aai` | AssemblyAI | Audio | [link](https://assemblyai.com) | — | -| `aws-polly` | `polly` | AWS Polly | Audio | [link](https://aws.amazon.com/polly/) | Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region. | -| `cartesia` | `cartesia` | Cartesia | Audio | [link](https://cartesia.ai) | — | -| `deepgram` | `dg` | Deepgram | Audio | [link](https://deepgram.com) | — | -| `elevenlabs` | `el` | ElevenLabs | Audio | [link](https://elevenlabs.io) | — | -| `inworld` | `inworld` | Inworld | Audio | [link](https://inworld.ai) | — | -| `playht` | `playht` | PlayHT | Audio | [link](https://play.ht) | — | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `assemblyai` | `aai` | AssemblyAI | Audio | [link](https://assemblyai.com) | — | +| `aws-polly` | `polly` | AWS Polly | Audio | [link](https://aws.amazon.com/polly/) | Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region. | +| `cartesia` | `cartesia` | Cartesia | Audio | [link](https://cartesia.ai) | — | +| `deepgram` | `dg` | Deepgram | Audio | [link](https://deepgram.com) | — | +| `elevenlabs` | `el` | ElevenLabs | Audio | [link](https://elevenlabs.io) | — | +| `inworld` | `inworld` | Inworld | Audio | [link](https://inworld.ai) | — | +| `playht` | `playht` | PlayHT | Audio | [link](https://play.ht) | — | ## Upstream Proxy Providers (2) -| ID | Alias | Name | Tags | Website | Notes | -| ------------- | ----- | ----------- | -------------- | ---------------------------------------------------- | ----- | -| `9router` | `nr` | 9router | Upstream proxy | [link](https://www.npmjs.com/package/9router) | — | -| `cliproxyapi` | `cpa` | CLIProxyAPI | Upstream proxy | [link](https://github.com/router-for-me/CLIProxyAPI) | — | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `9router` | `nr` | 9router | Upstream proxy | [link](https://www.npmjs.com/package/9router) | — | +| `cliproxyapi` | `cpa` | CLIProxyAPI | Upstream proxy | [link](https://github.com/router-for-me/CLIProxyAPI) | — | ## Cloud Agent Providers (3) -| ID | Alias | Name | Tags | Website | Notes | -| ------------- | ------------- | ------------ | ----------- | -------------------------------- | ----------------------------------------------------------- | -| `codex-cloud` | `codex-cloud` | Codex Cloud | Cloud agent | [link](https://openai.com/codex) | OpenAI API key with Codex Cloud task access. | -| `devin` | `devin` | Devin | Cloud agent | [link](https://devin.ai) | Devin API key for cloud agent sessions. | -| `jules` | `jules` | Google Jules | Cloud agent | [link](https://jules.google) | Jules API key for creating and managing cloud coding tasks. | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `codex-cloud` | `codex-cloud` | Codex Cloud | Cloud agent | [link](https://openai.com/codex) | OpenAI API key with Codex Cloud task access. | +| `devin` | `devin` | Devin | Cloud agent | [link](https://devin.ai) | Devin API key for cloud agent sessions. | +| `jules` | `jules` | Google Jules | Cloud agent | [link](https://jules.google) | Jules API key for creating and managing cloud coding tasks. | ## System Providers (1) -| ID | Alias | Name | Tags | Website | Notes | -| ------ | ------ | ------------------ | ------ | ------- | ----- | -| `auto` | `auto` | Auto (Zero-Config) | System | — | — | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `auto` | `auto` | Auto (Zero-Config) | System | — | — | ## Sources of truth diff --git a/next.config.mjs b/next.config.mjs index 5f1d884f74..ac7197503c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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//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, diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index 881ce9fbd2..381dd9fe9b 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -108,6 +108,7 @@ export const PROVIDER_MAX_TOKENS: Record = { 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; diff --git a/open-sse/config/errorConfig.ts b/open-sse/config/errorConfig.ts index e685fe60a8..f5ac29fde3 100644 --- a/open-sse/config/errorConfig.ts +++ b/open-sse/config/errorConfig.ts @@ -199,3 +199,41 @@ export function matchErrorRuleByStatus(statusCode: number): ErrorRule | null { export function findMatchingErrorRule(statusCode: number, message: unknown): ErrorRule | null { return matchErrorRuleByText(message) || matchErrorRuleByStatus(statusCode); } + +export interface ServiceSupervisorCooldown { + shouldFallback: true; + cooldownMs: number; + baseCooldownMs: number; + newBackoffLevel: 0; + reason: string; + skipProviderBreaker: true; +} + +/** + * G-02: detect embedded service supervisor failures (X-Omni-Fallback-Hint: connection_cooldown). + * These are NOT upstream AI provider failures — they are local supervisor state changes. Returns + * a short 5s connection-cooldown decision (no provider circuit-breaker trip), or null when the + * status/header don't match. + */ +export function serviceSupervisorCooldown( + status: number, + headers: Headers | Record | null +): ServiceSupervisorCooldown | null { + if (status !== 503 || !headers) return null; + const hintValue = + typeof (headers as Headers).get === "function" + ? (headers as Headers).get("x-omni-fallback-hint") + : (headers as Record)["x-omni-fallback-hint"] || + (headers as Record)["X-Omni-Fallback-Hint"]; + if (typeof hintValue !== "string" || hintValue.toLowerCase() !== "connection_cooldown") { + return null; + } + return { + shouldFallback: true, + cooldownMs: 5_000, + baseCooldownMs: 5_000, + newBackoffLevel: 0, + reason: "service_not_running", + skipProviderBreaker: true, + }; +} diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 97f1dc60ce..0c0acf4e8d 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -5,6 +5,8 @@ * Each provider has its own request format and endpoint. */ +import { LMARENA_DIRECT_IMAGE_MODELS } from "./providers/registry/lmarena/directModels.ts"; + interface ImageModelEntry { id: string; name: string; @@ -576,7 +578,11 @@ export const IMAGE_PROVIDERS: Record = { authHeader: "bearer", format: "nvidia-nim", models: [ - { id: "black-forest-labs/flux.1-dev", name: "FLUX.1 Dev", inputModalities: ["text", "image"] }, + { + id: "black-forest-labs/flux.1-dev", + name: "FLUX.1 Dev", + inputModalities: ["text", "image"], + }, { id: "black-forest-labs/flux.1-schnell", name: "FLUX.1 Schnell" }, { id: "black-forest-labs/flux.1-kontext-dev", @@ -625,6 +631,20 @@ export const IMAGE_PROVIDERS: Record = { ], supportedSizes: ["1024x1024"], }, + + // Arena (formerly LMArena) Direct-chat Image category (static scrape 2026-07-09). + // Not listed in the chat registry — image catalog only. Generation path still + // uses cookie session auth via the lmarena provider connection (stable wire id). + lmarena: { + id: "lmarena", + alias: "lma", + baseUrl: "https://arena.ai/nextjs-api/stream/create-evaluation", + authType: "apikey", + authHeader: "cookie", + format: "openai", + models: LMARENA_DIRECT_IMAGE_MODELS, + supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], + }, }; /** diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index a7e0fa51eb..110091d04a 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -21,6 +21,7 @@ import { glmtProvider } from "./registry/glm/t/index.ts"; import { glm_cnProvider } from "./registry/glm/cn/index.ts"; import { traeProvider } from "./registry/trae/index.ts"; import { muse_spark_webProvider } from "./registry/muse-spark-web/index.ts"; +import { lmarenaProvider } from "./registry/lmarena/index.ts"; import { kilocodeProvider } from "./registry/kilocode/index.ts"; import { github_modelsProvider } from "./registry/github/models/index.ts"; import { githubProvider } from "./registry/github/index.ts"; @@ -204,6 +205,7 @@ export const REGISTRY: Record = { "glm-cn": glm_cnProvider, trae: traeProvider, "muse-spark-web": muse_spark_webProvider, + lmarena: lmarenaProvider, kilocode: kilocodeProvider, "github-models": github_modelsProvider, github: githubProvider, diff --git a/open-sse/config/providers/registry/cursor/index.ts b/open-sse/config/providers/registry/cursor/index.ts index 945838c20c..ee9054cd0d 100644 --- a/open-sse/config/providers/registry/cursor/index.ts +++ b/open-sse/config/providers/registry/cursor/index.ts @@ -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" }, ], }; diff --git a/open-sse/config/providers/registry/lmarena/directModels.ts b/open-sse/config/providers/registry/lmarena/directModels.ts new file mode 100644 index 0000000000..3e75bc7597 --- /dev/null +++ b/open-sse/config/providers/registry/lmarena/directModels.ts @@ -0,0 +1,737 @@ +/** + * Arena (formerly LMArena) Direct-chat model allowlist (scraped 2026-07-09). + * - Text + Search → chat registry (providers/registry/lmarena, wire id unchanged) + * - Image → IMAGE_PROVIDERS in imageRegistry.ts (not chat catalog) + * Live HTML discovery is disabled. Scrape JSON stays local/desktop only — not shipped. + */ +import type { RegistryModel } from "../../shared.ts"; + +export interface LmarenaDirectModelEntry { + catalogId: string; + arenaId: string; + publicName: string; + displayName: string; + organization: string; + vision: boolean; + category: string; +} + +export const LMARENA_DIRECT_MODEL_ENTRIES: readonly LmarenaDirectModelEntry[] = Object.freeze([ + { + catalogId: "amazon.nova-pro-v1:0", + arenaId: "a14546b5-d78d-4cf6-bb61-ab5b8510a9d6", + publicName: "amazon.nova-pro-v1:0", + displayName: "amazon.nova-pro-v1:0", + organization: "amazon", + vision: true, + category: "Text", + }, + { + catalogId: "claude-haiku-4-5-20251001", + arenaId: "0199e8e9-01ed-73e0-96ba-cf43b286bf10", + publicName: "claude-haiku-4-5-20251001", + displayName: "claude-haiku-4-5-20251001", + organization: "anthropic", + vision: false, + category: "Text", + }, + { + catalogId: "claude-sonnet-5", + arenaId: "019f19f2-41f1-7c6d-9891-48d02fd9952c", + publicName: "claude-sonnet-5", + displayName: "claude-sonnet-5-high", + organization: "anthropic", + vision: true, + category: "Text", + }, + { + catalogId: "deepseek-v4-pro-thinking", + arenaId: "019dc1c1-c62d-7b70-85a1-e0565e29fce1", + publicName: "deepseek-v4-pro-thinking", + displayName: "deepseek-v4-pro-thinking", + organization: "deepseek", + vision: false, + category: "Text", + }, + { + catalogId: "dola-seed-2.0-preview-vision", + arenaId: "019c6453-641a-74f8-a689-a8a67175a359", + publicName: "dola-seed-2.0-preview-vision", + displayName: "dola-seed-2.0-preview-vision", + organization: "bytedance", + vision: true, + category: "Text", + }, + { + catalogId: "ernie-5.0-preview-1220", + arenaId: "019d44f1-26da-729f-a4ea-ddddfe7b4eae", + publicName: "ernie-5.0-preview-1220", + displayName: "ernie-5.0-preview-1220", + organization: "baidu", + vision: true, + category: "Text", + }, + { + catalogId: "gemini-3.1-flash-lite", + arenaId: "019f408a-186c-7f3a-9595-4e079e42a613", + publicName: "gemini-3.1-flash-lite", + displayName: "gemini-3.1-flash-lite", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "gemini-3.1-pro-preview", + arenaId: "019c7820-5480-78b6-9fef-04c0d7004054", + publicName: "gemini-3.1-pro-preview", + displayName: "gemini-3.1-pro-preview", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "gemini-3.5-flash-high", + arenaId: "019f406f-fc33-7b9d-9571-7b8443bc7ca0", + publicName: "gemini-3.5-flash-high", + displayName: "gemini-3.5-flash-high", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "significant-otter", + arenaId: "019d2cd3-2641-7628-94bd-67ecb0a7134e", + publicName: "significant-otter", + displayName: "gemma-4-26b-a4b", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "pteronura", + arenaId: "019d2cd2-dd83-75ab-a421-d0ba2e22b1e3", + publicName: "pteronura", + displayName: "gemma-4-31b", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "glm-5.1", + arenaId: "019ebf6a-94d4-7649-b704-1dbbd5eb0942", + publicName: "glm-5.1", + displayName: "glm-5.2 (max)", + organization: "zai", + vision: false, + category: "Text", + }, + { + catalogId: "glm-5v-turbo", + arenaId: "019d4a09-9651-78cb-86ea-bb0fa5ec77f4", + publicName: "glm-5v-turbo", + displayName: "glm-5v-turbo", + organization: "zai", + vision: true, + category: "Text", + }, + { + catalogId: "gpt-oss-120b", + arenaId: "6ee9f901-17b5-4fbe-9cc2-13c16497c23b", + publicName: "gpt-oss-120b", + displayName: "gpt-oss-120b", + organization: "openai", + vision: false, + category: "Text", + }, + { + catalogId: "gpt-5.2-high", + arenaId: "019b1449-0313-7911-b836-419e2ed79b2e", + publicName: "gpt-5.2-high", + displayName: "gpt-5.2-high", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "gpt-5.4-mini-high", + arenaId: "019cfcdd-5426-777e-8314-04619cb92cc4", + publicName: "gpt-5.4-mini-high", + displayName: "gpt-5.4-mini-high", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "gpt-5.4-nano-high", + arenaId: "019cfcdd-0bca-706f-92b5-a4c4cbd022d8", + publicName: "gpt-5.4-nano-high", + displayName: "gpt-5.4-nano-high", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "gpt-5.5-instant", + arenaId: "019e71ea-1e1d-740f-9c2d-dab5869ff108", + publicName: "gpt-5.5-instant", + displayName: "gpt-5.5-instant", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "grok-4.3/text", + arenaId: "019f42aa-9c3b-76d1-8bdf-2e883b1ca227", + publicName: "grok-4.3", + displayName: "grok-4.5", + organization: "xai", + vision: true, + category: "Text", + }, + { + catalogId: "hunyuan-vision-1.5-thinking", + arenaId: "6a3a1e04-050e-4cb4-9052-b9ac4bec0c38", + publicName: "hunyuan-vision-1.5-thinking", + displayName: "hunyuan-vision-1.5-thinking", + organization: "tencent", + vision: true, + category: "Text", + }, + { + catalogId: "hy3", + arenaId: "019f3911-ba1c-7e36-b631-01893e557290", + publicName: "hy3", + displayName: "hy3", + organization: "tencent", + vision: false, + category: "Text", + }, + { + catalogId: "kimi-k2.6", + arenaId: "019dac54-e8a4-7c54-904a-ff0ecd82af42", + publicName: "kimi-k2.6", + displayName: "kimi-k2.6", + organization: "moonshot", + vision: true, + category: "Text", + }, + { + catalogId: "ling-2.5-1t", + arenaId: "019c6e76-fbbc-7e92-b0ba-784c7ef3ad8b", + publicName: "ling-2.5-1t", + displayName: "ling-2.5-1t", + organization: "ant-group", + vision: false, + category: "Text", + }, + { + catalogId: "longcat-2.0", + arenaId: "019f3a0a-bd19-7b19-9eed-a98453759b48", + publicName: "longcat-2.0", + displayName: "longcat-2.0", + organization: "meituan", + vision: false, + category: "Text", + }, + { + catalogId: "mercury-2", + arenaId: "019cc65f-c1e3-7574-b332-898ab71c8211", + publicName: "mercury-2", + displayName: "mercury-2", + organization: "inception-ai", + vision: false, + category: "Text", + }, + { + catalogId: "mimo-v2.5", + arenaId: "019db651-bd2f-7d80-ab12-d69c6bb623df", + publicName: "mimo-v2.5", + displayName: "mimo-v2.5", + organization: "xiaomi", + vision: true, + category: "Text", + }, + { + catalogId: "mimo-v2.5-pro", + arenaId: "019db650-909d-7dec-8711-1907d7233cd4", + publicName: "mimo-v2.5-pro", + displayName: "mimo-v2.5-pro", + organization: "xiaomi", + vision: false, + category: "Text", + }, + { + catalogId: "minimax-m3", + arenaId: "019e809d-f62d-7192-bb7f-1657e066b5f2", + publicName: "minimax-m3", + displayName: "minimax-m3", + organization: "minimax", + vision: true, + category: "Text", + }, + { + catalogId: "mistral-large-3", + arenaId: "019acbac-df7c-73dc-9716-ebe040daaa4e", + publicName: "mistral-large-3", + displayName: "mistral-large-3", + organization: "mistral", + vision: true, + category: "Text", + }, + { + catalogId: "mistral-medium-3.5", + arenaId: "019f30a4-044d-7a14-9d3b-2e7299159e36", + publicName: "mistral-medium-3.5", + displayName: "mistral-medium-3.5", + organization: "mistral", + vision: true, + category: "Text", + }, + { + catalogId: "mistral-small-2603", + arenaId: "019cf983-532b-73fa-a057-7658e1e1c5ee", + publicName: "mistral-small-2603", + displayName: "mistral-small-2603", + organization: "mistral", + vision: false, + category: "Text", + }, + { + catalogId: "nova-2-lite", + arenaId: "019ae300-83b7-7717-a1e0-31accd1ff6fa", + publicName: "nova-2-lite", + displayName: "nova-2-lite", + organization: "amazon", + vision: false, + category: "Text", + }, + { + catalogId: "nvidia-nemotron-3-nano-30b-a3b-bf16", + arenaId: "019b0aa7-334a-78e8-b2a8-885f31f4fc0c", + publicName: "nvidia-nemotron-3-nano-30b-a3b-bf16", + displayName: "nvidia-nemotron-3-nano-30b-a3b-bf16", + organization: "nvidia", + vision: false, + category: "Text", + }, + { + catalogId: "march26-chatbot1-public", + arenaId: "019cd9e3-c3ff-7225-92f2-c392259b1fbe", + publicName: "march26-chatbot1-public", + displayName: "nvidia-nemotron-3-super-120b-a12b", + organization: "nvidia", + vision: false, + category: "Text", + }, + { + catalogId: "may26-chatbot4-public", + arenaId: "019e8ea8-2052-7f2e-b1b6-59bd94be5203", + publicName: "may26-chatbot4-public", + displayName: "nvidia-nemotron-3-ultra-550b-a55b-nvfp4", + organization: "nvidia", + vision: false, + category: "Text", + }, + { + catalogId: "o3-2025-04-16", + arenaId: "cb0f1e24-e8e9-4745-aabc-b926ffde7475", + publicName: "o3-2025-04-16", + displayName: "o3-2025-04-16", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "qwen3.5-397b-a17b", + arenaId: "019c6918-1d2a-7e3f-88ec-ada000b6ab16", + publicName: "qwen3.5-397b-a17b", + displayName: "qwen3.5-397b-a17b", + organization: "alibaba", + vision: true, + category: "Text", + }, + { + catalogId: "qwen3.7-max", + arenaId: "019e6530-f140-77b1-b6b8-5c859829d992", + publicName: "qwen3.7-max", + displayName: "qwen3.7-max", + organization: "alibaba", + vision: false, + category: "Text", + }, + { + catalogId: "qwen3.7-plus", + arenaId: "019e86fe-167d-77bd-94a8-df7aee4f4551", + publicName: "qwen3.7-plus", + displayName: "qwen3.7-plus", + organization: "alibaba", + vision: true, + category: "Text", + }, + { + catalogId: "ring-2.5-1t", + arenaId: "019c6e77-1b9f-7649-9136-43d07566c6c5", + publicName: "ring-2.5-1t", + displayName: "ring-2.5-1t", + organization: "ant-group", + vision: false, + category: "Text", + }, + { + catalogId: "step-3.5-flash", + arenaId: "019d22bb-fcf5-7866-9c07-de74fe05c9cc", + publicName: "step-3.5-flash", + displayName: "step-3.5-flash", + organization: "stepfun", + vision: false, + category: "Text", + }, + { + catalogId: "trinity-large-thinking", + arenaId: "019d50aa-447d-74d6-8661-405b4b6de5de", + publicName: "trinity-large-thinking", + displayName: "trinity-large-thinking", + organization: "arcee-ai", + vision: false, + category: "Text", + }, + { + catalogId: "cosmos3-super", + arenaId: "019f4270-5d10-723c-8a7e-dfd5dd2214e5", + publicName: "cosmos3-super", + displayName: "cosmos3-super", + organization: "nvidia", + vision: false, + category: "Image", + }, + { + catalogId: "cosmos3-super-agentic", + arenaId: "019f4270-9eea-79c3-b18f-71303ee41ff8", + publicName: "cosmos3-super-agentic", + displayName: "cosmos3-super-agentic", + organization: "nvidia", + vision: false, + category: "Image", + }, + { + catalogId: "flux-2-dev", + arenaId: "019b478d-74ae-7d19-9a8f-6cfde89ab4ca", + publicName: "flux-2-dev", + displayName: "flux-2-dev", + organization: "bfl", + vision: true, + category: "Image", + }, + { + catalogId: "flux-2-pro", + arenaId: "019b7541-5e4b-7ff7-a34b-b0255b6ca9aa", + publicName: "flux-2-pro", + displayName: "flux-2-pro", + organization: "bfl", + vision: true, + category: "Image", + }, + { + catalogId: "gemini-2.5-flash-image-preview (nano-banana)", + arenaId: "0199ef2a-583f-7088-b704-b75fd169401d", + publicName: "gemini-2.5-flash-image-preview (nano-banana)", + displayName: "gemini-2.5-flash-image-preview (nano-banana)", + organization: "google", + vision: true, + category: "Image", + }, + { + catalogId: "instant-ramen", + arenaId: "019ed3b3-c4f8-7e67-b72d-385d740096d0", + publicName: "instant-ramen", + displayName: "gemini-3.1-flash-lite-image (nano-banana-2-lite)", + organization: "google", + vision: true, + category: "Image", + }, + { + catalogId: "gpt-image-1", + arenaId: "6e855f13-55d7-4127-8656-9168a9f4dcc0", + publicName: "gpt-image-1", + displayName: "gpt-image-1", + organization: "openai", + vision: true, + category: "Image", + }, + { + catalogId: "blue-crab", + arenaId: "019e4271-4717-7dd9-a0e2-90783fbabd25", + publicName: "blue-crab", + displayName: "grok-imagine-image-quality (20260519)", + organization: "xai", + vision: true, + category: "Image", + }, + { + catalogId: "hidream-o1-image", + arenaId: "019e1cd7-5cc5-75d2-8b3e-275616dec624", + publicName: "hidream-o1-image", + displayName: "hidream-o1-image", + organization: "hidream", + vision: false, + category: "Image", + }, + { + catalogId: "sungod", + arenaId: "019bec2d-e92c-745d-ae46-c7166590237a", + publicName: "sungod", + displayName: "hunyuan-image-3.0-instruct", + organization: "tencent", + vision: true, + category: "Image", + }, + { + catalogId: "ideogram-v3-quality", + arenaId: "73378be5-cdba-49e7-b3d0-027949871aa6", + publicName: "ideogram-v3-quality", + displayName: "ideogram-v3-quality", + organization: "Ideogram", + vision: false, + category: "Image", + }, + { + catalogId: "krea-2-large", + arenaId: "019e8ebd-6cfb-7492-949c-a2c4a00301aa", + publicName: "krea-2-large", + displayName: "krea-2-large", + organization: "krea", + vision: false, + category: "Image", + }, + { + catalogId: "krea-2-turbo", + arenaId: "019f049d-7de4-7fae-8237-1c2103b9e730", + publicName: "krea-2-turbo", + displayName: "krea-2-turbo", + organization: "krea", + vision: false, + category: "Image", + }, + { + catalogId: "lucid-origin", + arenaId: "5a3b3520-c87d-481f-953c-1364687b6e8f", + publicName: "lucid-origin", + displayName: "lucid-origin", + organization: "leonardo-ai", + vision: false, + category: "Image", + }, + { + catalogId: "kakarot-v2", + arenaId: "019e80aa-37bf-7e89-8a28-41e4ab72ed9f", + publicName: "kakarot-v2", + displayName: "mai-image-2.5 (image-edit)", + organization: "microsoft-ai", + vision: true, + category: "Image", + }, + { + catalogId: "baryonyx", + arenaId: "019e530d-2a50-75e3-95d1-a5ef41d4c24c", + publicName: "baryonyx", + displayName: "mai-image-2.5 (text-to-image)", + organization: "microsoft-ai", + vision: false, + category: "Image", + }, + { + catalogId: "iron-bloom", + arenaId: "019ef780-25ef-7878-8b91-307f8f879d42", + publicName: "iron-bloom", + displayName: "muse-image", + organization: "meta", + vision: true, + category: "Image", + }, + { + catalogId: "photon", + arenaId: "e7c9fa2d-6f5d-40eb-8305-0980b11c7cab", + publicName: "photon", + displayName: "photon", + organization: "luma-ai", + vision: false, + category: "Image", + }, + { + catalogId: "qwen-image-2.0", + arenaId: "019d287c-4906-7f9c-8b78-8a2a86cf00a5", + publicName: "qwen-image-2.0", + displayName: "qwen-image-2.0", + organization: "alibaba", + vision: true, + category: "Image", + }, + { + catalogId: "qwen-image-2.0-pro", + arenaId: "019d287b-b718-7daa-ad65-502596d0813d", + publicName: "qwen-image-2.0-pro", + displayName: "qwen-image-2.0-pro", + organization: "alibaba", + vision: true, + category: "Image", + }, + { + catalogId: "recraft-v4", + arenaId: "019c6e76-a7c0-7b05-8dce-bbe3d52c8f4e", + publicName: "recraft-v4", + displayName: "recraft-v4", + organization: "Recraft", + vision: false, + category: "Image", + }, + { + catalogId: "avalon", + arenaId: "019e7091-f73f-7338-b5e2-4ab5fba37dc2", + publicName: "avalon", + displayName: "reve-2.0 (image-edit)", + organization: "reve", + vision: true, + category: "Image", + }, + { + catalogId: "babylon", + arenaId: "019e86c6-a3dc-73ac-9adc-8c5f304dc2fb", + publicName: "babylon", + displayName: "reve-2.0 (text-to-image)", + organization: "reve", + vision: false, + category: "Image", + }, + { + catalogId: "seedream-5.0-pro", + arenaId: "019f42b5-8c52-7793-9be8-de35eecf7ea9", + publicName: "seedream-5.0-pro", + displayName: "seedream-5.0-pro", + organization: "bytedance", + vision: true, + category: "Image", + }, + { + catalogId: "uni-1.1-max", + arenaId: "019ed208-69ca-7f3f-85ee-182d5f0ea08b", + publicName: "uni-1.1-max", + displayName: "uni-1.1-max", + organization: "luma-ai", + vision: true, + category: "Image", + }, + { + catalogId: "wan2.7-image-pro", + arenaId: "019db3f0-b024-7478-bd4d-55ea1ec1d421", + publicName: "wan2.7-image-pro", + displayName: "wan2.7-image-pro", + organization: "wan", + vision: true, + category: "Image", + }, + { + catalogId: "zen-bear-v3", + arenaId: "019f38c2-002d-7f0f-a391-db6df024b734", + publicName: "zen-bear-v3", + displayName: "zen-bear-v3", + organization: "alibaba", + vision: false, + category: "Image", + }, + { + catalogId: "claude-sonnet-5-search", + arenaId: "019f1a07-de72-7fbe-8d82-56dbd7348360", + publicName: "claude-sonnet-5-search", + displayName: "claude-sonnet-5-search", + organization: "anthropic", + vision: false, + category: "Search", + }, + { + catalogId: "gemini-2.5-pro-grounding", + arenaId: "b222be23-bd55-4b20-930b-a30cc84d3afd", + publicName: "gemini-2.5-pro-grounding", + displayName: "gemini-2.5-pro-grounding", + organization: "google", + vision: false, + category: "Search", + }, + { + catalogId: "gemini-3-flash-grounding", + arenaId: "019bda1f-3abc-783f-aac0-1ee102b247ba", + publicName: "gemini-3-flash-grounding", + displayName: "gemini-3-flash-grounding", + organization: "google", + vision: false, + category: "Search", + }, + { + catalogId: "gpt-5.2-search", + arenaId: "019b1448-f74a-72de-b25d-8666618f8c5a", + publicName: "gpt-5.2-search", + displayName: "gpt-5.2-search", + organization: "openai", + vision: false, + category: "Search", + }, + { + catalogId: "grok-4.3/search", + arenaId: "019de22d-1445-7296-9c88-a5877bc66ef8", + publicName: "grok-4.3", + displayName: "grok-4.3", + organization: "xai", + vision: false, + category: "Search", + }, + { + catalogId: "o3-search", + arenaId: "fbe08e9a-3805-4f9f-a085-7bc38e4b51d1", + publicName: "o3-search", + displayName: "o3-search", + organization: "openai", + vision: false, + category: "Search", + }, +] as LmarenaDirectModelEntry[]); + +/** Chat-completions catalog (Text + Search). Image rows are excluded. */ +export const LMARENA_DIRECT_CHAT_ENTRIES: readonly LmarenaDirectModelEntry[] = + LMARENA_DIRECT_MODEL_ENTRIES.filter((m) => m.category === "Text" || m.category === "Search"); + +/** Image-generation catalog rows (IMAGE_PROVIDERS). */ +export const LMARENA_DIRECT_IMAGE_ENTRIES: readonly LmarenaDirectModelEntry[] = + LMARENA_DIRECT_MODEL_ENTRIES.filter((m) => m.category === "Image"); + +export const LMARENA_DIRECT_MODELS: RegistryModel[] = LMARENA_DIRECT_CHAT_ENTRIES.map((m) => ({ + id: m.catalogId, + name: m.displayName, + ...(m.vision ? { supportsVision: true as const } : {}), +})); + +export const LMARENA_DIRECT_IMAGE_MODELS: Array<{ + id: string; + name: string; + inputModalities?: string[]; +}> = LMARENA_DIRECT_IMAGE_ENTRIES.map((m) => ({ + id: m.catalogId, + name: m.displayName, + inputModalities: m.vision ? ["text", "image"] : ["text"], +})); + +export function resolveLmarenaArenaId(catalogOrArenaId: string): string | null { + const raw = catalogOrArenaId.replace(/^(?:lmarena|lma|arena)\//i, "").trim(); + if (!raw) return null; + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (uuidRe.test(raw)) return raw; + const lower = raw.toLowerCase(); + const hit = LMARENA_DIRECT_MODEL_ENTRIES.find( + (m) => + m.catalogId === raw || + m.publicName === raw || + m.displayName === raw || + m.catalogId.toLowerCase() === lower || + m.publicName.toLowerCase() === lower || + m.displayName.toLowerCase() === lower + ); + return hit?.arenaId ?? null; +} diff --git a/open-sse/config/providers/registry/lmarena/index.ts b/open-sse/config/providers/registry/lmarena/index.ts new file mode 100644 index 0000000000..ab91a6a9e5 --- /dev/null +++ b/open-sse/config/providers/registry/lmarena/index.ts @@ -0,0 +1,18 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { LMARENA_DIRECT_MODELS } from "./directModels.ts"; + +/** + * Arena (formerly LMArena) web-session provider — arena.ai. + * Wire id remains `lmarena`. Model list is a static Direct-chat allowlist + * (no live arena.ai HTML scrape). + */ +export const lmarenaProvider: RegistryEntry = { + id: "lmarena", + alias: "lma", + format: "openai", + executor: "lmarena", + baseUrl: "https://arena.ai/nextjs-api/stream/create-evaluation", + authType: "apikey", + authHeader: "cookie", + models: LMARENA_DIRECT_MODELS, +}; diff --git a/open-sse/config/providers/registry/sensenova/index.ts b/open-sse/config/providers/registry/sensenova/index.ts index 9407e4ef29..37e95acc32 100644 --- a/open-sse/config/providers/registry/sensenova/index.ts +++ b/open-sse/config/providers/registry/sensenova/index.ts @@ -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", + }, ], }; diff --git a/open-sse/config/providers/registry/xai/index.ts b/open-sse/config/providers/registry/xai/index.ts index 501fa4fcbf..f33e247080 100644 --- a/open-sse/config/providers/registry/xai/index.ts +++ b/open-sse/config/providers/registry/xai/index.ts @@ -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" }, ], diff --git a/open-sse/config/rerankRegistry.ts b/open-sse/config/rerankRegistry.ts index e44efb16e1..7b8a79801c 100644 --- a/open-sse/config/rerankRegistry.ts +++ b/open-sse/config/rerankRegistry.ts @@ -89,6 +89,24 @@ export const RERANK_PROVIDERS = { ], }, + // OpenRouter exposes a separate, Cohere-compatible POST /api/v1/rerank endpoint + // (not surfaced by its live /v1/models feed, which contains 0 rerank ids — confirmed + // by direct curl). Model IDs keep their vendor slash (e.g. "cohere/rerank-4-pro"); + // parseRerankModel splits on the FIRST slash, so 3-segment ids resolve safely, same + // as siliconflow above. Seeded by hand and must be maintained here as OpenRouter adds + // more rerank models (#6574). + openrouter: { + id: "openrouter", + baseUrl: "https://openrouter.ai/api/v1/rerank", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "cohere/rerank-4-pro", name: "Cohere Rerank 4 Pro (via OpenRouter)" }, + { id: "cohere/rerank-4-fast", name: "Cohere Rerank 4 Fast (via OpenRouter)" }, + { id: "cohere/rerank-v3.5", name: "Cohere Rerank v3.5 (via OpenRouter)" }, + ], + }, + // DeepInfra rerank is NOT Cohere-shaped: POST /v1/inference/ with {queries:[q],documents} // returning {scores:[…]} (one score per document, positional). The `deepinfra` format adapter in // open-sse/handlers/rerank.ts builds the per-model URL and maps scores → Cohere results (#5332). diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 5489e9fbb9..494d750836 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -87,6 +87,9 @@ const ANTIGRAVITY_TRANSIENT_STATUSES = new Set([ HTTP_STATUS.SERVICE_UNAVAILABLE, HTTP_STATUS.GATEWAY_TIMEOUT, ]); +const ANTIGRAVITY_UNSUPPORTED_SAFETY_CATEGORIES = new Set([ + "HARM_CATEGORY_CIVIC_INTEGRITY", +]); // The upstream API uses plain model IDs (no -high/-low suffix). // Tier suffixes were speculative and caused 404 for gemini-3.x models — the // bare-Pro→Low normalization was retired (the set stayed empty, making the guard @@ -440,6 +443,14 @@ function asRecord(value: unknown): Record | null { : null; } +function getAntigravitySafetySettings(safetySettings: unknown): unknown[] { + const source = Array.isArray(safetySettings) ? safetySettings : DEFAULT_SAFETY_SETTINGS; + return source.filter((setting) => { + const category = asRecord(setting)?.category; + return typeof category !== "string" || !ANTIGRAVITY_UNSUPPORTED_SAFETY_CATEGORIES.has(category); + }); +} + function sanitizeAntigravityGeminiRequest( request: Record ): Record { @@ -687,12 +698,10 @@ export class AntigravityExecutor extends BaseExecutor { credentials, typeof normalizedRequest?.sessionId === "string" ? normalizedRequest.sessionId : undefined ), - // #5003: default to all-OFF safety for parity with the native Gemini paths - // (claude-to-gemini / openai-to-gemini both default to DEFAULT_SAFETY_SETTINGS). - // Previously this was `undefined`, which JSON.stringify drops, so Google Cloud Code - // applied its server-side defaults that false-flag benign technical prompts as - // `prohibited_content` (HTTP 200 + blocked body → terminal combo failover). - safetySettings: normalizedRequest?.safetySettings ?? DEFAULT_SAFETY_SETTINGS, + // #5003: send explicit all-OFF safety entries that Cloud Code accepts. Omitting the + // field lets Cloud Code apply server-side defaults that false-flag benign technical + // prompts as `prohibited_content`. + safetySettings: getAntigravitySafetySettings(normalizedRequest?.safetySettings), toolConfig: Array.isArray(normalizedRequest?.tools) && normalizedRequest.tools.length > 0 ? { functionCallingConfig: { mode: "VALIDATED" } } @@ -700,7 +709,9 @@ export class AntigravityExecutor extends BaseExecutor { }; const transformedRequest = isClaude - ? stripTrailingAntigravityAssistantTurn(sanitizeAntigravityGeminiRequest(rawTransformedRequest)) + ? stripTrailingAntigravityAssistantTurn( + sanitizeAntigravityGeminiRequest(rawTransformedRequest) + ) : rawTransformedRequest; // Obfuscate sensitive client names in user content (e.g. "OpenCode", "Cursor") diff --git a/open-sse/executors/claude-web.ts b/open-sse/executors/claude-web.ts index 599814d072..7e26777339 100644 --- a/open-sse/executors/claude-web.ts +++ b/open-sse/executors/claude-web.ts @@ -319,14 +319,36 @@ async function buildClaudeStreamingResponse( try { const parsed = JSON.parse(jsonStr) as Record; - // 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 | 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 | 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 | 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 { diff --git a/open-sse/executors/claude-web/payload.ts b/open-sse/executors/claude-web/payload.ts index cc0dcc3787..fb1e873e64 100644 --- a/open-sse/executors/claude-web/payload.ts +++ b/open-sse/executors/claude-web/payload.ts @@ -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): boolean { + const reasoning = + body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning) + ? (body.reasoning as Record) + : 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).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 { + const delta: Record = + 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, }, diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 6bca470eb5..6712c3eb4c 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -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; + const directError = parsed.error as Record | undefined; + const nestedError = (parsed.response as Record | undefined)?.error as + | Record + | 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 | 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 { + 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({ + 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 ); } diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index ad7a9b68a6..d05d3b5ee5 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -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, diff --git a/open-sse/executors/deepseek-web-done-terminator.ts b/open-sse/executors/deepseek-web-done-terminator.ts new file mode 100644 index 0000000000..1303cc4b39 --- /dev/null +++ b/open-sse/executors/deepseek-web-done-terminator.ts @@ -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 | 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, + }; +} diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 7b6e5e0167..99fda068af 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -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 } ); diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index d5623aacdd..3dab64c395 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -19,6 +19,7 @@ import { type KiroThinkingState, } from "./kiroThinking.ts"; import { ByteQueue, TEXT_ENCODER, parseEventFrame } from "./kiro/eventstream.ts"; +import { kiroRuntimeHost, resolveKiroRuntimeRegion } from "../services/kiroRegion.ts"; type JsonRecord = Record; @@ -152,35 +153,28 @@ function ensureKiroUsage(state: KiroStreamState) { } /** - * Resolve the AWS region for a Kiro/CodeWhisperer connection. Enterprise AWS IAM Identity - * Center accounts are region-bound: the access token, the Q Developer profile ARN and the - * runtime endpoint must all match the region the IdC instance lives in (e.g. eu-central-1). - * A request signed for one region is rejected by another ("bearer token is invalid"), and a - * regional profileArn sent to us-east-1 fails with "Improperly formed request". Falls back to - * the region embedded in the profileArn, then us-east-1 (the AWS Builder ID default). + * Resolve the RUNTIME AWS region for a Kiro/CodeWhisperer connection. + * + * The runtime region is the region of the Amazon Q Developer profile (embedded in the + * profileArn — always us-east-1 or eu-central-1), NOT the IAM Identity Center / OIDC token + * region. An enterprise IdC instance may live in eu-north-1 (or any region), but the Q Developer + * profile that serves generateAssistantResponse only exists in us-east-1 / eu-central-1, so a + * runtime call must target the profileArn's region — routing to q.{idcRegion}.amazonaws.com + * (a host that does not exist) is what caused "no limits + 502 on every request". Delegates to + * the shared resolver (profileArn region → valid stored profile region → us-east-1). The IdC + * token region is used only for oidc.{region} token mint/refresh, elsewhere. */ export function resolveKiroRegion( credentials: { providerSpecificData?: unknown } | null | undefined ): string { - const psd = (credentials?.providerSpecificData || {}) as Record; - const region = typeof psd.region === "string" ? psd.region.trim().toLowerCase() : ""; - if (region) return region; - const arn = typeof psd.profileArn === "string" ? psd.profileArn.toLowerCase() : ""; - const match = arn.match(/^arn:aws:codewhisperer:([a-z0-9-]+):/); - return match ? match[1] : "us-east-1"; + return resolveKiroRuntimeRegion( + (credentials?.providerSpecificData || {}) as { region?: unknown; profileArn?: unknown } + ); } -/** - * CodeWhisperer/Amazon Q runtime host for a region. us-east-1 keeps the legacy - * codewhisperer.us-east-1 host (AWS Builder ID); other regions use the regional Amazon Q - * endpoint q.{region}.amazonaws.com — codewhisperer.{region}.amazonaws.com does not resolve - * for non-us-east-1 regions. - */ -export function kiroRuntimeHost(region: string): string { - return region === "us-east-1" - ? "https://codewhisperer.us-east-1.amazonaws.com" - : `https://q.${region}.amazonaws.com`; -} +// Re-exported from the shared region module so existing importers (and tests) that pull +// kiroRuntimeHost from this executor keep working. +export { kiroRuntimeHost }; /** * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) diff --git a/open-sse/executors/lmarena.ts b/open-sse/executors/lmarena.ts index 2f78d278db..3f28acf848 100644 --- a/open-sse/executors/lmarena.ts +++ b/open-sse/executors/lmarena.ts @@ -1,177 +1,71 @@ /** - * LMArenaExecutor — LMArena Web Session Provider + * LMArenaExecutor — Arena (formerly LMArena) web-session provider. * - * Routes requests through LMArena's web API using session credentials. - * LMArena is a model comparison platform with 100+ models (GPT, Claude, Gemini, Llama). + * Routes requests through arena.ai create-evaluation with session cookies. + * Upstream sits behind Cloudflare; traffic goes through tls-client-node Chrome + * impersonation (see services/lmarenaTlsClient.ts). * - * API Structure: - * Endpoint: https://arena.ai/nextjs-api/stream - * Method: POST - * Content-Type: application/json - * Accept: text/event-stream - * - * Auth pipeline (per request): - * 1. Extract session cookie from credentials - * 2. Build request with model and messages - * 3. Make authenticated POST request to LMArena API - * 4. Handle SSE response stream with custom prefixes (a0:, ag:, a3:, ae:, ad:) - * - * SSE Format: - * a0: - Text content (concatenate) - * ag: - Thinking/reasoning content - * a2: - Heartbeat (ignore) - * a3: - Model error - * ae: - Platform error - * ad: - Done marker + * Helpers: open-sse/executors/lmarena/{cookie,models,stream,response}.ts */ +import { v7 as uuidv7 } from "uuid"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; -import { sanitizeErrorMessage } from "../utils/error.ts"; +import { tlsFetchLMArena, TlsClientUnavailableError } from "../services/lmarenaTlsClient.ts"; +import { readLMArenaCookie, reconstructLMArenaCookie } from "./lmarena/cookie.ts"; +import { + LMARENA_STREAM_URL, + LMARENA_USER_AGENT, + buildLmarenaBrowserHeaders, + markLMArenaCatalogModelDead, + normalizeLMArenaModelsForCatalog, + parseLMArenaInitialModels, + pickLMArenaModelId, + resolveLMArenaModelId, + type LMArenaModelMetadata, +} from "./lmarena/models.ts"; +import { formatArenaPrompt, parseArenaSSE } from "./lmarena/stream.ts"; +import { + buildArenaUpstreamHttpResponse, + createOpenAIArenaStream, + handleNonStreamingArenaResponse, + mapFailedTlsResult, + mapNetworkError, + mapTlsUnavailable, + missingCookieResult, +} from "./lmarena/response.ts"; -const LMARENA_API_BASE = "https://arena.ai"; -const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream`; +export { + reconstructLMArenaCookie, + normalizeLMArenaModelsForCatalog, + parseLMArenaInitialModels, + pickLMArenaModelId, + parseArenaSSE, + markLMArenaCatalogModelDead, + LMARENA_USER_AGENT, +}; +export { clearLMArenaDeadCatalogModels } from "./lmarena/models.ts"; +export type { LMArenaModelMetadata }; -const LMARENA_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; - -const LMARENA_AUTH_COOKIE = "arena-auth-prod-v1"; - -interface ParsedCookie { - name: string; - value: string; +interface OpenAIMessage { + role?: string; + content?: unknown; } -/** - * Parse a raw `Cookie:`-style blob (`name=value; name2=value2; …`) into an - * ordered list of name/value pairs. Whitespace around names is trimmed; values - * are kept verbatim (they may legitimately contain `=`, e.g. base64 padding). - */ -function parseCookieBlob(blob: string): ParsedCookie[] { - const pairs: ParsedCookie[] = []; - for (const part of blob.split(";")) { - const eq = part.indexOf("="); - if (eq < 0) continue; - const name = part.slice(0, eq).trim(); - if (!name) continue; - const value = part.slice(eq + 1).trim(); - pairs.push({ name, value }); - } - return pairs; -} - -/** - * Reconstruct LMArena's single `arena-auth-prod-v1` auth cookie from the - * Supabase SSR chunked form. - * - * LMArena migrated to `@supabase/ssr`, which splits a large auth cookie across - * `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … (ascending). The single - * `arena-auth-prod-v1` cookie is then left empty. Following `@supabase/ssr`'s - * `combineChunks`, we read chunks in ascending numeric order until one is - * missing and `join("")` their raw values — NO base64-decode, NO JSON-parse. - * The joined value typically starts with the literal `base64-` prefix; we keep - * it verbatim (the upstream expects it). - * - * - If the blob already carries a non-empty `arena-auth-prod-v1=`, it is - * returned unchanged (back-compat with the pre-migration single cookie). - * - Otherwise the reconstructed `arena-auth-prod-v1=` is injected while - * every other cookie in the pasted jar is preserved. - * - If neither the single cookie nor any `.N` chunk has a value, the blob is - * returned as-is so the existing missing-cookie path still fires. - */ -export function reconstructLMArenaCookie(rawCookie: string): string { - if (!rawCookie || !rawCookie.trim()) return rawCookie; - - const pairs = parseCookieBlob(rawCookie); - - // Back-compat: a non-empty single cookie is already usable — forward verbatim. - const existing = pairs.find((p) => p.name === LMARENA_AUTH_COOKIE); - if (existing && existing.value) return rawCookie; - - // Collect chunk values keyed by their numeric index (`arena-auth-prod-v1.`). - const chunkPrefix = `${LMARENA_AUTH_COOKIE}.`; - const chunks = new Map(); - for (const { name, value } of pairs) { - if (!name.startsWith(chunkPrefix)) continue; - const idxRaw = name.slice(chunkPrefix.length); - if (!/^\d+$/.test(idxRaw)) continue; - chunks.set(Number(idxRaw), value); - } - - // Join in ascending order until a chunk is missing (combineChunks semantics). - const joinedParts: string[] = []; - for (let i = 0; chunks.has(i); i++) { - joinedParts.push(chunks.get(i) ?? ""); - } - const joined = joinedParts.join(""); - - // No usable session anywhere → return as-is so the missing-cookie path fires. - if (!joined) return rawCookie; - - // Inject the reconstructed single cookie while preserving the rest of the jar - // (drop the empty base cookie and the now-redundant chunks). - const preserved = pairs.filter( - (p) => p.name !== LMARENA_AUTH_COOKIE && !p.name.startsWith(chunkPrefix) - ); - const rebuilt = [ - `${LMARENA_AUTH_COOKIE}=${joined}`, - ...preserved.map((p) => `${p.name}=${p.value}`), - ]; - return rebuilt.join("; "); -} - -function readLMArenaCookie(credentials: unknown): string { - if (!credentials || typeof credentials !== "object") return ""; - const c = credentials as Record; - const direct = typeof c.cookie === "string" ? c.cookie : ""; - if (direct.trim()) return reconstructLMArenaCookie(direct); - const apiKey = typeof c.apiKey === "string" ? c.apiKey : ""; - if (apiKey.trim()) return reconstructLMArenaCookie(apiKey); - const psd = c.providerSpecificData; - if (psd && typeof psd === "object") { - const nested = (psd as Record).cookie; - if (typeof nested === "string" && nested.trim()) return reconstructLMArenaCookie(nested); - } - return ""; -} - -interface ArenaSSEEvent { - type: "text" | "thinking" | "error" | "done" | "heartbeat"; - content?: string; -} - -export function parseArenaSSE(line: string): ArenaSSEEvent | null { - if (line.startsWith("a0:")) { - try { - const content = JSON.parse(line.substring(3)); - return { type: "text", content: typeof content === "string" ? content : content.text || "" }; - } catch { - return null; +/** Optional browser-issued reCAPTCHA v3 token (operator-supplied). */ +function readRecaptchaToken(credentials: unknown, body: unknown): string | null { + const fromObj = (v: unknown): string | null => { + if (!v || typeof v !== "object") return null; + const rec = v as Record; + const direct = rec.recaptchaV3Token ?? rec.recaptchaToken; + if (typeof direct === "string" && direct.trim()) return direct.trim(); + const psd = rec.providerSpecificData; + if (psd && typeof psd === "object") { + const nested = psd as Record; + const t = nested.recaptchaV3Token ?? nested.recaptchaToken; + if (typeof t === "string" && t.trim()) return t.trim(); } - } else if (line.startsWith("ag:")) { - try { - const content = JSON.parse(line.substring(3)); - return { - type: "thinking", - content: typeof content === "string" ? content : content.thinking || "", - }; - } catch { - return null; - } - } else if (line.startsWith("a3:") || line.startsWith("ae:")) { - try { - const content = JSON.parse(line.substring(3)); - return { - type: "error", - content: typeof content === "string" ? content : content.error || JSON.stringify(content), - }; - } catch { - return { type: "error", content: line.substring(3) }; - } - } else if (line.startsWith("ad:")) { - return { type: "done" }; - } else if (line.startsWith("a2:")) { - return { type: "heartbeat" }; - } - return null; + return null; + }; + return fromObj(credentials) ?? fromObj(body); } export class LMArenaExecutor extends BaseExecutor { @@ -189,242 +83,135 @@ export class LMArenaExecutor extends BaseExecutor { _body: unknown ): Record { const cookie = readLMArenaCookie(credentials); - const headers: Record = { + const headers = buildLmarenaBrowserHeaders({ "Content-Type": "application/json", Accept: "text/event-stream", - "User-Agent": LMARENA_USER_AGENT, - Origin: LMARENA_API_BASE, - Referer: `${LMARENA_API_BASE}/`, - }; - - if (cookie) { - headers.Cookie = cookie; - } - + }); + if (cookie) headers.Cookie = cookie; return headers; } - protected transformRequest(body: unknown, model: string): unknown { - const openaiBody = body as Record; - const messages = openaiBody.messages as Array<{ role: string; content: string }>; - + protected transformRequest(body: unknown, model: string, credentials?: unknown): unknown { + const openaiBody = body && typeof body === "object" ? (body as Record) : {}; + const messages = Array.isArray(openaiBody.messages) + ? (openaiBody.messages as OpenAIMessage[]) + : []; return { - messages: messages.map((m) => ({ - role: m.role, - content: m.content, - })), - model, - stream: openaiBody.stream || false, + id: uuidv7(), + mode: "direct-battle", + modelAId: model, + userMessageId: uuidv7(), + modelAMessageId: uuidv7(), + userMessage: { + content: formatArenaPrompt(messages), + experimental_attachments: [], + metadata: {}, + }, + modality: "chat", + recaptchaV3Token: readRecaptchaToken(credentials, body), }; } async execute(input: ExecuteInput) { const { model, body, stream, credentials, signal, log } = input; - const url = this.buildUrl(model, credentials); const headers = this.buildHeaders(model, credentials, body); - const transformedBody = this.transformRequest(body, model); - const cookie = readLMArenaCookie(credentials); + if (!cookie) { - return { - response: new Response( - JSON.stringify({ - error: { - message: "LMArena requires a session cookie. Please provide cookie in credentials.", - type: "authentication_error", - code: "missing_cookie", - }, - }), - { status: 401, headers: { "Content-Type": "application/json" } } - ), - url, - headers, - transformedBody, - }; + return missingCookieResult(url, headers, this.transformRequest(body, model, credentials)); } - log?.info?.("LMArenaExecutor", `Executing request for model: ${model}`); + const arenaModelId = await resolveLMArenaModelId(model, log); + const transformedBody = this.transformRequest(body, arenaModelId, credentials) as Record< + string, + unknown + >; + + log?.info?.( + "LMArenaExecutor", + arenaModelId === model + ? `Executing request for model: ${model}` + : `Executing request for model: ${model} (${arenaModelId})` + ); try { - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(transformedBody), + return await this.dispatchTls(url, headers, transformedBody, { + model, + arenaModelId, + stream: !!stream, signal, + log, }); - - if (!response.ok) { - const errorText = await response.text(); - let errorMessage = `LMArena API error: ${response.status}`; - try { - const errorJson = JSON.parse(errorText); - errorMessage = errorJson.error?.message || errorJson.message || errorMessage; - } catch { - errorMessage = errorText || errorMessage; - } - - return { - response: new Response( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(errorMessage), - type: "api_error", - code: String(response.status), - }, - }), - { status: response.status, headers: { "Content-Type": "application/json" } } - ), - url, - headers, - transformedBody, - }; - } - - const upstreamResponse = stream - ? await this.handleStreamingResponse(response, model, log) - : await this.handleNonStreamingResponse(response, model, log); - - return { response: upstreamResponse, url, headers, transformedBody }; } catch (error) { + if (error instanceof TlsClientUnavailableError) { + log?.error?.("LMArenaExecutor", `TLS client unavailable: ${error.message}`); + return mapTlsUnavailable(error, url, headers, transformedBody); + } const message = error instanceof Error ? error.message : String(error); log?.error?.("LMArenaExecutor", `Request failed: ${message}`); - - return { - response: new Response( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(message), - type: "network_error", - code: "request_failed", - }, - }), - { status: 502, headers: { "Content-Type": "application/json" } } - ), - url, - headers, - transformedBody, - }; + return mapNetworkError(message, url, headers, transformedBody); } } + private async dispatchTls( + url: string, + headers: Record, + transformedBody: Record, + ctx: { + model: string; + arenaModelId: string; + stream: boolean; + signal?: AbortSignal; + log?: ExecuteInput["log"]; + } + ) { + const tlsResult = await tlsFetchLMArena(url, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal: ctx.signal, + stream: ctx.stream, + streamEofSymbol: "__OMNIROUTE_LMARENA_EOF_NEVER__", + }); + + const failed = mapFailedTlsResult({ + status: tlsResult.status, + text: tlsResult.text, + hasRecaptcha: transformedBody.recaptchaV3Token != null, + model: ctx.model, + arenaModelId: ctx.arenaModelId, + url, + headers, + transformedBody, + }); + if (failed) return failed; + + const upstream = buildArenaUpstreamHttpResponse({ + stream: ctx.stream, + status: tlsResult.status, + text: tlsResult.text, + body: tlsResult.body, + }); + + const response = ctx.stream + ? await this.handleStreamingResponse(upstream, ctx.model, ctx.signal, ctx.log) + : await handleNonStreamingArenaResponse(upstream, ctx.model); + + return { response, url, headers, transformedBody }; + } + private async handleStreamingResponse( response: Response, model: string, + signal?: AbortSignal, log?: ExecuteInput["log"] ): Promise { const reader = response.body?.getReader(); - if (!reader) { - throw new Error("No response body for streaming"); - } + if (!reader) throw new Error("No response body for streaming"); - const decoder = new TextDecoder(); - let buffer = ""; - let fullText = ""; - let fullThinking = ""; - - const stream = new ReadableStream({ - async start(controller) { - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.trim()) continue; - - const sseLine = line.startsWith("data: ") ? line.substring(6) : line; - const event = parseArenaSSE(sseLine); - - if (!event) continue; - - if (event.type === "text" && event.content) { - fullText += event.content; - const chunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: { content: event.content }, - finish_reason: null, - }, - ], - }; - controller.enqueue(`data: ${JSON.stringify(chunk)}\n\n`); - } else if (event.type === "thinking" && event.content) { - fullThinking += event.content; - } else if (event.type === "error") { - const errorChunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: "stop", - }, - ], - error: { message: event.content }, - }; - controller.enqueue(`data: ${JSON.stringify(errorChunk)}\n\n`); - controller.close(); - return; - } else if (event.type === "done") { - const finalChunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: "stop", - }, - ], - }; - controller.enqueue(`data: ${JSON.stringify(finalChunk)}\n\n`); - controller.enqueue("data: [DONE]\n\n"); - controller.close(); - return; - } - } - } - - const finalChunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: "stop", - }, - ], - }; - controller.enqueue(`data: ${JSON.stringify(finalChunk)}\n\n`); - controller.enqueue("data: [DONE]\n\n"); - controller.close(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log?.error?.("LMArenaExecutor", `Streaming error: ${message}`); - controller.error(error); - } - }, - }); - - return new Response(stream, { + const out = createOpenAIArenaStream({ reader, model, signal, log }); + return new Response(out, { status: 200, headers: { "Content-Type": "text/event-stream", @@ -433,79 +220,4 @@ export class LMArenaExecutor extends BaseExecutor { }, }); } - - private async handleNonStreamingResponse( - response: Response, - model: string, - log?: ExecuteInput["log"] - ): Promise { - const text = await response.text(); - const lines = text.split("\n"); - let fullText = ""; - let fullThinking = ""; - let error: string | null = null; - - for (const line of lines) { - if (!line.trim()) continue; - - const sseLine = line.startsWith("data: ") ? line.substring(6) : line; - const event = parseArenaSSE(sseLine); - - if (!event) continue; - - if (event.type === "text" && event.content) { - fullText += event.content; - } else if (event.type === "thinking" && event.content) { - fullThinking += event.content; - } else if (event.type === "error") { - error = event.content || "Unknown error"; - break; - } else if (event.type === "done") { - break; - } - } - - if (error) { - return new Response( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(error), - type: "api_error", - code: "lmarena_error", - }, - }), - { - status: 502, - headers: { "Content-Type": "application/json" }, - } - ); - } - - const result = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - message: { - role: "assistant", - content: fullText, - }, - finish_reason: "stop", - }, - ], - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }; - - return new Response(JSON.stringify(result), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } } diff --git a/open-sse/executors/lmarena/cookie.ts b/open-sse/executors/lmarena/cookie.ts new file mode 100644 index 0000000000..c1e62529a3 --- /dev/null +++ b/open-sse/executors/lmarena/cookie.ts @@ -0,0 +1,103 @@ +/** + * LMArena / arena.ai session cookie reconstruction. + * Supabase SSR splits `arena-auth-prod-v1` across `.0`, `.1`, … chunks. + */ + +export const LMARENA_AUTH_COOKIE = "arena-auth-prod-v1"; + +interface ParsedCookie { + name: string; + value: string; +} + +/** + * Parse a raw `Cookie:`-style blob (`name=value; name2=value2; …`) into an + * ordered list of name/value pairs. Whitespace around names is trimmed; values + * are kept verbatim (they may legitimately contain `=`, e.g. base64 padding). + */ +function parseCookieBlob(blob: string): ParsedCookie[] { + const pairs: ParsedCookie[] = []; + for (const part of blob.split(";")) { + const eq = part.indexOf("="); + if (eq < 0) continue; + const name = part.slice(0, eq).trim(); + if (!name) continue; + const value = part.slice(eq + 1).trim(); + pairs.push({ name, value }); + } + return pairs; +} + +/** + * Reconstruct LMArena's single `arena-auth-prod-v1` auth cookie from the + * Supabase SSR chunked form. + * + * - Non-empty single cookie → returned unchanged (pre-migration back-compat). + * - Otherwise join ascending `.N` chunks (no base64-decode / no JSON-parse). + * - Neither single nor chunks → raw blob returned for the missing-cookie path. + */ +export function reconstructLMArenaCookie(rawCookie: string): string { + if (!rawCookie || !rawCookie.trim()) return rawCookie; + + const pairs = parseCookieBlob(rawCookie); + + const existing = pairs.find((p) => p.name === LMARENA_AUTH_COOKIE); + if (existing && existing.value) return rawCookie; + + const chunkPrefix = `${LMARENA_AUTH_COOKIE}.`; + const chunks = new Map(); + for (const { name, value } of pairs) { + if (!name.startsWith(chunkPrefix)) continue; + const idxRaw = name.slice(chunkPrefix.length); + if (!/^\d+$/.test(idxRaw)) continue; + chunks.set(Number(idxRaw), value); + } + + const joinedParts: string[] = []; + for (let i = 0; chunks.has(i); i++) { + joinedParts.push(chunks.get(i) ?? ""); + } + const joined = joinedParts.join(""); + if (!joined) return rawCookie; + + const preserved = pairs.filter( + (p) => p.name !== LMARENA_AUTH_COOKIE && !p.name.startsWith(chunkPrefix) + ); + return [`${LMARENA_AUTH_COOKIE}=${joined}`, ...preserved.map((p) => `${p.name}=${p.value}`)].join( + "; " + ); +} + +function buildLMArenaCookieFromStoredFields(data: Record): string { + const pairs: string[] = []; + for (const [name, value] of Object.entries(data)) { + if (name !== LMARENA_AUTH_COOKIE && !name.startsWith(`${LMARENA_AUTH_COOKIE}.`)) { + continue; + } + if (typeof value !== "string" || !value.trim()) continue; + pairs.push(`${name}=${value.trim()}`); + } + + if (pairs.length === 0) return ""; + return reconstructLMArenaCookie(pairs.join("; ")); +} + +export function readLMArenaCookie(credentials: unknown): string { + if (!credentials || typeof credentials !== "object") return ""; + const c = credentials as Record; + const direct = typeof c.cookie === "string" ? c.cookie : ""; + if (direct.trim()) return reconstructLMArenaCookie(direct); + const apiKey = typeof c.apiKey === "string" ? c.apiKey : ""; + if (apiKey.trim()) return reconstructLMArenaCookie(apiKey); + const topLevelChunks = buildLMArenaCookieFromStoredFields(c); + if (topLevelChunks) return topLevelChunks; + const psd = c.providerSpecificData; + if (psd && typeof psd === "object") { + const nestedData = psd as Record; + const nested = nestedData.cookie; + if (typeof nested === "string" && nested.trim()) return reconstructLMArenaCookie(nested); + const nestedChunks = buildLMArenaCookieFromStoredFields(nestedData); + if (nestedChunks) return nestedChunks; + } + return ""; +} diff --git a/open-sse/executors/lmarena/models.ts b/open-sse/executors/lmarena/models.ts new file mode 100644 index 0000000000..fbfe277809 --- /dev/null +++ b/open-sse/executors/lmarena/models.ts @@ -0,0 +1,307 @@ +/** + * LMArena live model list parsing, catalog normalization, and name→UUID resolution. + */ + +export const LMARENA_API_BASE = "https://arena.ai"; +export const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream/create-evaluation`; +/** + * Current Chrome stable UA (header surface). + * TLS JA3 profile is separate: tls-client-node tops out at chrome_146 — see + * LMARENA_PROFILE in lmarenaTlsClient.ts. Headers track the live browser string; + * fingerprint stays at the newest native profile we can actually impersonate. + */ +export const LMARENA_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; +export const LMARENA_MODEL_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Browser-like CORS headers for arena.ai same-origin API calls. */ +export function buildLmarenaBrowserHeaders(extra?: Record): Record { + return { + Accept: "text/event-stream, application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + Pragma: "no-cache", + Origin: LMARENA_API_BASE, + Referer: `${LMARENA_API_BASE}/`, + "Sec-Ch-Ua": '"Chromium";v="150", "Google Chrome";v="150", "Not-A.Brand";v="24"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"Windows"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": LMARENA_USER_AGENT, + ...extra, + }; +} + +export interface LMArenaModelMetadata { + id?: string; + publicName?: string; + name?: string; + displayName?: string; + organization?: string; + provider?: string; + userSelectable?: boolean; + rank?: number; + rankByModality?: Record; + capabilities?: { + inputCapabilities?: Record; + outputCapabilities?: Record; + }; +} + +// Live arena.ai HTML discovery is intentionally disabled. Catalog + UUID map +// come from the Direct-chat scrape seed (registry/lmarena/directModels.ts). + +function stripLMArenaModelPrefix(model: string): string { + return model.replace(/^(?:lmarena|lma|arena)\//i, "").trim(); +} + +function normalizeModelName(model: string): string { + return model.trim().toLowerCase(); +} + +function hasLMArenaCapability( + entry: LMArenaModelMetadata, + direction: "input" | "output", + key: string +): boolean { + const capabilities = + direction === "input" + ? entry.capabilities?.inputCapabilities + : entry.capabilities?.outputCapabilities; + return capabilities?.[key] === true; +} + +/** + * Arena ships hundreds of initialModels rows; many are webdev-only, hidden, + * unranked sentinels (chat rank = MAX_SAFE_INTEGER), or UUID twins that 404 on + * create-evaluation. Keep the catalog to chat-usable, ranked, selectable rows. + */ +const LMARENA_MAX_REASONABLE_CHAT_RANK = 100_000; +/** Soft cap after dedupe — Arena UI only surfaces ~100–130 chat models. */ +export const LMARENA_CATALOG_SOFT_CAP = 120; + +const deadCatalogKeys = new Map(); +const DEAD_CATALOG_TTL_MS = 6 * 60 * 60 * 1000; + +function deadKey(value: string): string { + return value.trim().toLowerCase(); +} + +/** Remember a model id/publicName that 404/502'd so the next catalog import drops it. */ +export function markLMArenaCatalogModelDead(idOrPublicName: string): void { + if (!idOrPublicName?.trim()) return; + deadCatalogKeys.set(deadKey(idOrPublicName), Date.now() + DEAD_CATALOG_TTL_MS); +} + +export function clearLMArenaDeadCatalogModels(): void { + deadCatalogKeys.clear(); +} + +function isMarkedDead(entry: LMArenaModelMetadata, publicId: string): boolean { + const now = Date.now(); + for (const key of [publicId, entry.id, entry.publicName, entry.name, entry.displayName]) { + if (!key) continue; + const exp = deadCatalogKeys.get(deadKey(key)); + if (exp === undefined) continue; + if (exp <= now) { + deadCatalogKeys.delete(deadKey(key)); + continue; + } + return true; + } + return false; +} + +function isLMArenaChatCatalogModel(entry: LMArenaModelMetadata): boolean { + if (entry.userSelectable === false) return false; + // Must resolve to a real Arena UUID for create-evaluation. + if (typeof entry.id !== "string" || !LMARENA_MODEL_ID_RE.test(entry.id)) return false; + + const chatRank = entry.rankByModality?.chat; + if (typeof chatRank !== "number" || !Number.isFinite(chatRank)) return false; + // Unranked / placeholder rows use huge sentinels and commonly 404 when probed. + if (chatRank >= LMARENA_MAX_REASONABLE_CHAT_RANK) return false; + + if (!hasLMArenaCapability(entry, "input", "text")) return false; + if (!hasLMArenaCapability(entry, "output", "text")) return false; + + // Prefer rows with a stable human slug (not bare UUID as the only label). + const publicId = getLMArenaPublicModelId(entry).trim(); + if (!publicId) return false; + if (LMARENA_MODEL_ID_RE.test(publicId) && !entry.publicName && !entry.name) return false; + + return true; +} + +function lmarenaModelResolutionScore(entry: LMArenaModelMetadata): number { + let score = 0; + if (entry.userSelectable === false) score += 1_000_000; + if (!hasLMArenaCapability(entry, "input", "text")) score += 100_000; + if (!hasLMArenaCapability(entry, "output", "text")) score += 50_000; + + const chatRank = entry.rankByModality?.chat; + if (typeof chatRank === "number" && Number.isFinite(chatRank)) { + score += chatRank; + } else if (typeof entry.rank === "number" && Number.isFinite(entry.rank)) { + score += 10_000 + entry.rank; + } else { + score += 20_000; + } + + if (!entry.name) score += 500; + if (!entry.organization && !entry.provider) score += 100; + + return score; +} + +function getLMArenaPublicModelId(entry: LMArenaModelMetadata): string { + return entry.publicName || entry.displayName || entry.name || entry.id || ""; +} + +export function normalizeLMArenaModelsForCatalog(models: LMArenaModelMetadata[]): Array<{ + id: string; + name: string; + owned_by: string; + supportsVision?: boolean; + apiFormat: "chat-completions"; + supportedEndpoints: ["chat"]; +}> { + const bestByPublicId = new Map(); + + models.forEach((entry, index) => { + if (!isLMArenaChatCatalogModel(entry)) return; + const publicId = getLMArenaPublicModelId(entry).trim(); + if (!publicId) return; + if (isMarkedDead(entry, publicId)) return; + + const previous = bestByPublicId.get(publicId); + if ( + !previous || + lmarenaModelResolutionScore(entry) < lmarenaModelResolutionScore(previous.entry) + ) { + bestByPublicId.set(publicId, { entry, index }); + } + }); + + return Array.from(bestByPublicId.entries()) + .sort( + ([, a], [, b]) => + lmarenaModelResolutionScore(a.entry) - lmarenaModelResolutionScore(b.entry) || + a.index - b.index + ) + .slice(0, LMARENA_CATALOG_SOFT_CAP) + .map(([id, { entry }]) => ({ + id, + name: entry.displayName || entry.publicName || entry.name || id, + owned_by: entry.organization || entry.provider || "lmarena", + ...(hasLMArenaCapability(entry, "input", "image") ? { supportsVision: true } : {}), + apiFormat: "chat-completions" as const, + supportedEndpoints: ["chat"] as const, + })); +} + +export function pickLMArenaModelId(model: string, models: LMArenaModelMetadata[]): string { + const requested = stripLMArenaModelPrefix(model); + if (LMARENA_MODEL_ID_RE.test(requested)) return requested; + + const normalized = normalizeModelName(requested); + const matches = models + .map((entry, index) => ({ entry, index })) + // Only map onto chat-catalog-quality rows — avoids binding a public name to a + // webdev-only / unranked twin UUID that 404s on create-evaluation. + .filter(({ entry }) => isLMArenaChatCatalogModel(entry)) + .filter(({ entry }) => + [entry.id, entry.publicName, entry.name, entry.displayName].some( + (candidate) => typeof candidate === "string" && normalizeModelName(candidate) === normalized + ) + ); + const match = matches.sort( + (a, b) => + lmarenaModelResolutionScore(a.entry) - lmarenaModelResolutionScore(b.entry) || + a.index - b.index + )[0]?.entry; + + return match?.id || requested; +} + +export function parseLMArenaInitialModels(html: string): LMArenaModelMetadata[] { + const escapedMarker = '\\"initialModels\\":['; + const plainMarker = '"initialModels":['; + const marker = html.includes(escapedMarker) ? escapedMarker : plainMarker; + const markerIndex = html.indexOf(marker); + if (markerIndex < 0) return []; + + const arrayStart = markerIndex + marker.length - 1; + const escapedEnd = '],\\"initialModelAId\\"'; + const plainEnd = '],"initialModelAId"'; + const arrayEnd = html.indexOf(escapedEnd, arrayStart); + const fallbackEnd = html.indexOf(plainEnd, arrayStart); + const endIndex = arrayEnd >= 0 ? arrayEnd : fallbackEnd; + if (endIndex < 0 || endIndex < arrayStart) return []; + + const rawArray = html.slice(arrayStart, endIndex + 1).replace(/\\"/g, '"'); + try { + const parsed = JSON.parse(rawArray); + return Array.isArray(parsed) ? (parsed as LMArenaModelMetadata[]) : []; + } catch { + return []; + } +} + +type LogFn = { + debug?: (scope: string, msg: string) => void; + warn?: (scope: string, msg: string) => void; +}; + +/** Static Direct-chat allowlist only — no arena.ai network call. */ +export async function getLMArenaModels(log?: LogFn): Promise { + const { LMARENA_DIRECT_MODEL_ENTRIES } = + await import("../../config/providers/registry/lmarena/directModels.ts"); + // Chat path only — Image rows live in IMAGE_PROVIDERS (imageRegistry). + const models: LMArenaModelMetadata[] = LMARENA_DIRECT_MODEL_ENTRIES.filter( + (m) => m.category === "Text" || m.category === "Search" + ).map((m) => ({ + id: m.arenaId, + publicName: m.catalogId, + name: m.publicName, + displayName: m.displayName, + organization: m.organization, + userSelectable: true, + capabilities: { + inputCapabilities: { text: true, ...(m.vision ? { image: true } : {}) }, + outputCapabilities: { + text: true, + ...(m.category === "Search" ? { web: true } : {}), + }, + }, + rankByModality: { chat: 1 }, + })); + log?.debug?.( + "LMArenaExecutor", + `Using static Direct-chat catalog (${models.length} Text/Search models; Image in imageRegistry)` + ); + return models; +} + +export async function resolveLMArenaModelId(model: string, log?: LogFn): Promise { + const requested = stripLMArenaModelPrefix(model); + if (LMARENA_MODEL_ID_RE.test(requested)) return requested; + + try { + const { resolveLmarenaArenaId } = + await import("../../config/providers/registry/lmarena/directModels.ts"); + const fromSeed = resolveLmarenaArenaId(requested); + if (fromSeed) return fromSeed; + return pickLMArenaModelId(requested, await getLMArenaModels(log)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.warn?.( + "LMArenaExecutor", + `Using raw model id after static catalog lookup failed: ${message}` + ); + return requested; + } +} diff --git a/open-sse/executors/lmarena/response.ts b/open-sse/executors/lmarena/response.ts new file mode 100644 index 0000000000..64aef907c9 --- /dev/null +++ b/open-sse/executors/lmarena/response.ts @@ -0,0 +1,305 @@ +/** + * Response mapping helpers for the Arena (lmarena) executor — kept small so + * the executor methods stay under complexity / max-lines gates. + */ +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { isCloudflareChallenge } from "../../services/lmarenaTlsClient.ts"; +import { markLMArenaCatalogModelDead } from "./models.ts"; +import { parseArenaSSE } from "./stream.ts"; + +export function errorResponse( + status: number, + message: string, + type: string, + code: string +): Response { + return new Response( + JSON.stringify({ + error: { message: sanitizeErrorMessage(message), type, code }, + }), + { status, headers: { "Content-Type": "application/json" } } + ); +} + +export function missingCookieResult( + url: string, + headers: Record, + transformedBody: unknown +) { + return { + response: errorResponse( + 401, + "Arena requires a session cookie. Paste the full Cookie header from arena.ai (include arena-auth-prod-v1.* chunks and ideally cf_clearance).", + "authentication_error", + "missing_cookie" + ), + url, + headers, + transformedBody, + }; +} + +function parseArenaErrorBody(text: string | null | undefined, status: number): string { + const fallback = `Arena API error: ${status}`; + if (!text) return fallback; + try { + const errorJson = JSON.parse(text) as { error?: { message?: string }; message?: string }; + return errorJson.error?.message || errorJson.message || fallback; + } catch { + return text.slice(0, 500) || fallback; + } +} + +function isBotOrChallenge(status: number, text: string | null | undefined): boolean { + if (status === 403) return true; + if (isCloudflareChallenge(text)) return true; + return Boolean(text && text.trimStart().startsWith("; + transformedBody: unknown; +}) { + const { status, text, hasRecaptcha, model, arenaModelId, url, headers, transformedBody } = opts; + if (isBotOrChallenge(status, text)) { + return { + response: errorResponse( + status || 403, + botBlockMessage(text, hasRecaptcha, status), + "api_error", + "cloudflare_or_bot" + ), + url, + headers, + transformedBody, + }; + } + if (status >= 200 && status < 300) return null; + + if (status === 404 || status === 410 || status === 502) { + markLMArenaCatalogModelDead(model); + markLMArenaCatalogModelDead(arenaModelId); + } + return { + response: errorResponse(status, parseArenaErrorBody(text, status), "api_error", String(status)), + url, + headers, + transformedBody, + }; +} + +export function mapTlsUnavailable( + error: Error, + url: string, + headers: Record, + transformedBody: unknown +) { + return { + response: errorResponse( + 502, + `Arena TLS impersonation unavailable: ${error.message}. Install/repair tls-client-node native binary.`, + "upstream_error", + "TLS_CLIENT_UNAVAILABLE" + ), + url, + headers, + transformedBody, + }; +} + +export function mapNetworkError( + message: string, + url: string, + headers: Record, + transformedBody: unknown +) { + return { + response: errorResponse(502, message, "network_error", "request_failed"), + url, + headers, + transformedBody, + }; +} + +export function buildArenaUpstreamHttpResponse(opts: { + stream: boolean; + status: number; + text: string | null; + body: ReadableStream | null; +}): Response { + const { stream, status, text, body } = opts; + if (stream && body) { + return new Response(body, { + status, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return new Response(text ?? "", { + status, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +function baseChunk(model: string) { + return { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + }; +} + +function enqueueSse(controller: ReadableStreamDefaultController, chunk: Record) { + controller.enqueue(`data: ${JSON.stringify(chunk)}\n\n`); +} + +function emitStopAndDone(controller: ReadableStreamDefaultController, model: string) { + enqueueSse(controller, { + ...baseChunk(model), + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + controller.enqueue("data: [DONE]\n\n"); + controller.close(); +} + +/** Process one Arena SSE line into OpenAI chunk writes. Returns true if stream should end. */ +function handleArenaEventLine( + sseLine: string, + model: string, + controller: ReadableStreamDefaultController +): boolean { + const event = parseArenaSSE(sseLine); + if (!event) return false; + if (event.type === "text" && event.content) { + enqueueSse(controller, { + ...baseChunk(model), + choices: [{ index: 0, delta: { content: event.content }, finish_reason: null }], + }); + return false; + } + if (event.type === "error") { + enqueueSse(controller, { + ...baseChunk(model), + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + error: { message: sanitizeErrorMessage(event.content || "Unknown error") }, + }); + controller.close(); + return true; + } + if (event.type === "done") { + emitStopAndDone(controller, model); + return true; + } + return false; +} + +export function createOpenAIArenaStream(opts: { + reader: ReadableStreamDefaultReader; + model: string; + signal?: AbortSignal; + log?: { error?: (scope: string, msg: string) => void }; +}): ReadableStream { + const { reader, model, signal, log } = opts; + const decoder = new TextDecoder(); + let buffer = ""; + + const onAbort = () => { + void reader.cancel().catch(() => undefined); + }; + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + + return new ReadableStream({ + async start(controller) { + try { + while (true) { + if (signal?.aborted) { + await reader.cancel().catch(() => undefined); + controller.close(); + return; + } + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + const sseLine = line.startsWith("data: ") ? line.substring(6) : line; + if (handleArenaEventLine(sseLine, model, controller)) return; + } + } + emitStopAndDone(controller, model); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.error?.("LMArenaExecutor", `Streaming error: ${message}`); + controller.error(error); + } finally { + if (signal) signal.removeEventListener("abort", onAbort); + } + }, + cancel() { + void reader.cancel().catch(() => undefined); + if (signal) signal.removeEventListener("abort", onAbort); + }, + }); +} + +export async function handleNonStreamingArenaResponse( + response: Response, + model: string +): Promise { + const text = await response.text(); + let fullText = ""; + let error: string | null = null; + + for (const line of text.split("\n")) { + if (!line.trim()) continue; + const sseLine = line.startsWith("data: ") ? line.substring(6) : line; + const event = parseArenaSSE(sseLine); + if (!event) continue; + if (event.type === "text" && event.content) fullText += event.content; + else if (event.type === "error") { + error = event.content || "Unknown error"; + break; + } else if (event.type === "done") break; + } + + if (error) return errorResponse(502, error, "api_error", "lmarena_error"); + + return new Response( + JSON.stringify({ + id: `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: fullText }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); +} diff --git a/open-sse/executors/lmarena/stream.ts b/open-sse/executors/lmarena/stream.ts new file mode 100644 index 0000000000..0f30482e4e --- /dev/null +++ b/open-sse/executors/lmarena/stream.ts @@ -0,0 +1,132 @@ +/** + * Arena/AI-SDK SSE line parsing and OpenAI message → Arena prompt formatting. + */ + +export interface ArenaSSEEvent { + type: "text" | "thinking" | "error" | "done" | "heartbeat"; + content?: string; +} + +function parseJsonValue(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +function pickString(value: unknown, keys: string[]): string { + if (typeof value === "string") return value; + if (!value || typeof value !== "object") return ""; + const data = value as Record; + for (const key of keys) { + const candidate = data[key]; + if (typeof candidate === "string") return candidate; + } + return JSON.stringify(value); +} + +function normalizeArenaSSELine(payload: string): string { + const participantPrefixed = payload.match(/^[ab]([023dfg]):(.*)$/); + if (!participantPrefixed) return payload; + return `${participantPrefixed[1]}:${participantPrefixed[2]}`; +} + +export function parseArenaSSE(line: string): ArenaSSEEvent | null { + const trimmed = line.trim(); + const payload = trimmed.startsWith("data: ") ? trimmed.substring(6).trim() : trimmed; + if (!payload) return null; + + // Historical Arena platform errors used `ae:`. Current AI SDK `e:` is + // finish_step and not terminal, so only treat it as an error when it carries + // an obvious error payload. + const legacyError = payload.match(/^[ab]e:(.*)$/); + if (legacyError) { + const value = parseJsonValue(legacyError[1] ?? ""); + const content = pickString(value, ["error", "message"]); + return content ? { type: "error", content } : null; + } + + const normalized = normalizeArenaSSELine(payload); + const separator = normalized.indexOf(":"); + if (separator < 0) return null; + + const code = normalized.slice(0, separator); + const rawValue = normalized.slice(separator + 1); + const value = parseJsonValue(rawValue); + + switch (code) { + case "0": + return { type: "text", content: pickString(value, ["text", "textDelta"]) }; + case "g": + return { type: "thinking", content: pickString(value, ["thinking", "text", "textDelta"]) }; + case "2": + return { type: "heartbeat" }; + case "3": + return { type: "error", content: pickString(value, ["error", "message"]) }; + case "d": { + if ( + value && + typeof value === "object" && + (value as Record).finishReason === "error" + ) { + return { type: "error", content: "Arena stream finished with an error" }; + } + return { type: "done" }; + } + default: + return null; + } +} + +interface OpenAIMessage { + role?: string; + content?: unknown; +} + +function contentToText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") return part; + if (!part || typeof part !== "object") return ""; + const data = part as Record; + if (typeof data.text === "string") return data.text; + if (data.type === "image_url") return "[image]"; + return ""; + }) + .filter(Boolean) + .join("\n"); + } + if (content && typeof content === "object") { + const data = content as Record; + if (typeof data.text === "string") return data.text; + } + return content == null ? "" : String(content); +} + +export function formatArenaPrompt(messages: OpenAIMessage[]): string { + const rendered = messages + .map((message) => { + const text = contentToText(message.content).trim(); + if (!text) return ""; + const role = typeof message.role === "string" ? message.role : "user"; + const label = + role === "system" + ? "System" + : role === "assistant" + ? "Assistant" + : role === "developer" + ? "Developer" + : "User"; + return `${label}: ${text}`; + }) + .filter(Boolean); + + if (rendered.length === 1 && messages[0]?.role === "user") { + return contentToText(messages[0].content).trim(); + } + + return rendered.join("\n\n"); +} diff --git a/open-sse/executors/v0-vercel-web.ts b/open-sse/executors/v0-vercel-web.ts index eb6d276b00..1e97108716 100644 --- a/open-sse/executors/v0-vercel-web.ts +++ b/open-sse/executors/v0-vercel-web.ts @@ -67,10 +67,13 @@ export class V0VercelWebExecutor extends BaseExecutor { if (!wantStream) { const data = (await upstream.json()) as Record; - const content = - (data?.choices as Array<{ message?: { content?: string } }>)?.[0]?.message?.content || - (data?.content as string) || - ""; + const message = (data?.choices as Array<{ message?: Record }>)?.[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 = { 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 = {}; + 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`)); } diff --git a/open-sse/executors/xai.ts b/open-sse/executors/xai.ts index 9f806e2c5a..e5de5c037a 100644 --- a/open-sse/executors/xai.ts +++ b/open-sse/executors/xai.ts @@ -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; @@ -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, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 676292a4c5..8319e5ba80 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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. diff --git a/open-sse/handlers/chatCore/telemetryHelpers.ts b/open-sse/handlers/chatCore/telemetryHelpers.ts index c6b92e683c..805acbbd8c 100644 --- a/open-sse/handlers/chatCore/telemetryHelpers.ts +++ b/open-sse/handlers/chatCore/telemetryHelpers.ts @@ -2,7 +2,7 @@ import { fetchLiveProviderLimits } from "@/lib/usage/providerLimits"; import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage"; // #4604 — Lazy backoff for the best-effort live-WS sidecar bridge. In single-port -// deployments the sidecar (port 20129) is not running, so every compression event +// deployments the sidecar (port 20132) is not running, so every compression event // POST failed with ECONNREFUSED; because the global fetch is proxyFetch, each // failure logged a "[ProxyFetch] Undici dispatcher failed" warning (272× in 42min). // After a few consecutive failures we stop attempting for a cooldown window (then @@ -28,7 +28,7 @@ export async function forwardDashboardEventToLiveWs( // Skip while the bridge is in a cooldown window after repeated failures. if (liveWsDisabledUntil > now()) return; - const port = process.env.LIVE_WS_PORT || "20129"; + const port = process.env.LIVE_WS_PORT || "20132"; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 1_500); try { diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index d37acc6752..5a1e1888de 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -11,8 +11,10 @@ import { findMatchingErrorRule, matchErrorRuleByText, matchErrorRuleByStatus, + serviceSupervisorCooldown, } from "../config/errorConfig.ts"; import { getProviderErrorRuleMatch } from "../config/providerErrorRules.ts"; +import * as rot from "./rotationConfig.ts"; import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts"; import { DEFAULT_RESILIENCE_SETTINGS, @@ -35,6 +37,12 @@ import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts"; import { isRpdExhausted, isRpmExhausted } from "./geminiRateLimitTracker.ts"; import { setConnectionRateLimitUntil } from "@/lib/db/providers"; import { parseRetryHintFromJsonBody } from "./retryAfterJson.ts"; +import { + isSubscriptionQuotaText, + buildSubscriptionQuotaFallback, + buildWeeklyQuotaFallback, +} from "./quotaTextCooldowns.ts"; +import { parseDayGranularityResetMs, shouldPreserveQuotaSignals } from "./quotaResetParsing.ts"; export type ProviderProfile = { baseCooldownMs: number; @@ -364,11 +372,6 @@ export function getProviderProfile(provider: string): ProviderProfile { return buildProviderProfile(category); } -function shouldPreserveQuotaSignalsFor429(provider: string | null | undefined): boolean { - if (!provider) return true; - return getProviderCategory(provider) === "oauth"; -} - export async function getRuntimeProviderProfile(provider: string | null | undefined) { try { const { getCachedSettings } = await import("@/lib/db/readCache"); @@ -676,7 +679,7 @@ export function shouldMarkAccountExhaustedFrom429( // without making this one look quota-depleted for 5 minutes. if (failureKind === "rate_limit" || failureKind === "transient") return false; return ( - shouldPreserveQuotaSignalsFor429(provider) && + shouldPreserveQuotaSignals(provider) && !hasPerModelQuota(provider, model, connectionPassthroughModels) ); } @@ -1071,7 +1074,7 @@ export function parseRetryFromErrorText(errorText: unknown): number | null { return computeDurationMs(resetsInMatch); } - return null; + return parseDayGranularityResetMs(msg, MAX_PROVIDER_COOLDOWN_MS); } /** @@ -1088,16 +1091,6 @@ function computeDurationMs(match: RegExpMatchArray): number | null { return totalMs > 0 ? Math.min(totalMs, MAX_PROVIDER_COOLDOWN_MS) : null; } -function isSubscriptionQuotaText(lower: string): boolean { - return ( - lower.includes("usage limit reached") || - lower.includes("usage limit has been") || - lower.includes("claude pro usage limit") || - lower.includes("you've reached your usage limit") || - lower.includes("you have reached your usage limit") - ); -} - // ─── Error Classification ─────────────────────────────────────────────────── /** @@ -1276,7 +1269,8 @@ export function checkFallbackError( provider: string | null = null, headers: Headers | Record | null = null, profileOverride: ProviderProfile | null = null, - structuredError?: { code?: string | null; type?: string | null } | null + structuredError?: { code?: string | null; type?: string | null } | null, + rotation?: { account?: unknown } | null ): { shouldFallback: boolean; cooldownMs: number; @@ -1296,27 +1290,10 @@ export function checkFallbackError( * caller can persist an explicit reset window instead of the engine's scaled cooldown. */ configuredCooldownMs?: number; } { - // G-02: detect embedded service supervisor failures (X-Omni-Fallback-Hint: connection_cooldown). - // These are NOT upstream AI provider failures — they are local supervisor state changes. - // Apply a short 5s connection cooldown without tripping the provider circuit breaker. - if (status === 503 && headers) { - const hintValue = - typeof (headers as Headers).get === "function" - ? (headers as Headers).get("x-omni-fallback-hint") - : (headers as Record)["x-omni-fallback-hint"] || - (headers as Record)["X-Omni-Fallback-Hint"]; - if (typeof hintValue === "string" && hintValue.toLowerCase() === "connection_cooldown") { - return { - shouldFallback: true, - cooldownMs: 5_000, - baseCooldownMs: 5_000, - newBackoffLevel: 0, - reason: "service_not_running", - skipProviderBreaker: true, - }; - } - } - + const svc = serviceSupervisorCooldown(status, headers); + if (svc) return svc; + const rg = rot.gateFor(status, rotation?.account); + if (rg) return rg; const errorStr = (errorText || "").toString(); const profile = profileOverride ?? (provider ? getProviderProfile(provider) : null); const maxBackoffSteps = profile?.maxBackoffSteps ?? BACKOFF_CONFIG.maxLevel; @@ -1405,6 +1382,8 @@ export function checkFallbackError( }; } + const ro = rot.overrideFor(reason, rotation?.account); + if (ro) return ro; const scaled = getScaledBaseCooldown(reason, backoffLevel); return { shouldFallback: true, @@ -1417,7 +1396,7 @@ export function checkFallbackError( } const isRateLimitStatus = status === HTTP_STATUS.RATE_LIMITED; - const preserveQuota429 = shouldPreserveQuotaSignalsFor429(provider); + const preserveQuota429 = shouldPreserveQuotaSignals(provider, errorText); const shouldUseQuotaSignal = !isRateLimitStatus || preserveQuota429; // Check error message FIRST - specific patterns take priority over status codes @@ -1455,37 +1434,23 @@ export function checkFallbackError( }; } - // Issue #2321: Anthropic OAuth (Claude Pro/Team) returns 429 with - // "Usage Limit Reached" for the 5-hour subscription quota. The - // pattern-based classifier now flags these as QUOTA_EXHAUSTED, but - // without a dedicated branch the request would still fall through to - // the generic 429 retry path (~5s base cooldown). Honor upstream - // Retry-After / reset hints only when the profile enables them; - // otherwise apply a local 1h cooldown so all Pro accounts on the same - // subscription tier stop cycling through tight retries without letting - // upstream-provided windows bypass the operator setting. (We - // deliberately do not use COOLDOWN_MS.paymentRequired here — that - // constant is 2 minutes, which is shorter than the recovery time of a - // subscription quota.) - if ( - shouldUseQuotaSignal && - !isCreditsExhausted(errorStr) && - !isDailyQuotaExhausted(errorStr) && - isSubscriptionQuotaText(errorStr.toLowerCase()) - ) { - // getUpstreamRetryHintMs() gates both headers and body reset text on - // profile.useUpstreamRetryHints. - const hintMs = getUpstreamRetryHintMs(); - const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour - const bodyHint = parseRetryFromErrorText(errorStr); - return { - shouldFallback: true, - cooldownMs: hintMs ?? SUBSCRIPTION_QUOTA_COOLDOWN_MS, - reason: RateLimitReason.QUOTA_EXHAUSTED, - usedUpstreamRetryHint: Boolean(hintMs), - quotaResetHintMs: bodyHint ?? undefined, - }; + // Issue #2321 (5h subscription quota) + Issue #3709 (ollama-cloud weekly + // cap): both classifiers live in quotaTextCooldowns.ts (this file is + // frozen at its file-size-baseline cap). The weekly check runs + // UNCONDITIONALLY (not gated by shouldUseQuotaSignal) because it targets + // apikey-category providers like ollama-cloud, which the oauth-only + // shouldUseQuotaSignal gate deliberately excludes from the subscription + // check above. + if (shouldUseQuotaSignal && !isCreditsExhausted(errorStr) && !isDailyQuotaExhausted(errorStr)) { + const subResult = buildSubscriptionQuotaFallback( + errorStr, + getUpstreamRetryHintMs, + parseRetryFromErrorText + ); + if (subResult) return subResult; } + const weeklyResult = buildWeeklyQuotaFallback(errorStr); + if (weeklyResult) return weeklyResult; const quotaResetHintMs = parseRetryFromErrorText(errorStr); if ( @@ -1784,13 +1749,15 @@ export function resetAccountState( export function applyErrorState( account: T, status: number, - errorText: string | null, - provider: string | null = null + errText: string | null, + prov: string | null = null ): T | AccountState { if (!account) return account; - const backoffLevel = account.backoffLevel || 0; - const fallbackDecision = checkFallbackError(status, errorText, backoffLevel, null, provider); + const lvl = account.backoffLevel || 0; + const fallbackDecision = checkFallbackError(status, errText, lvl, null, prov, null, null, null, { + account, + }); const { cooldownMs, reason } = fallbackDecision; const newBackoffLevel = "newBackoffLevel" in fallbackDecision ? fallbackDecision.newBackoffLevel : undefined; @@ -1812,8 +1779,8 @@ export function applyErrorState( const nextState: T | AccountState = { ...account, rateLimitedUntil: effectiveCooldownMs > 0 ? getUnavailableUntil(effectiveCooldownMs) : null, - backoffLevel: newBackoffLevel ?? backoffLevel, - lastError: { status, message: errorText, timestamp: new Date().toISOString(), reason }, + backoffLevel: newBackoffLevel ?? lvl, + lastError: { status, message: errText, timestamp: new Date().toISOString(), reason }, status: "error", }; diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 9ba5e7bd66..0beb8bfb79 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -67,6 +67,8 @@ import { getSessionConnection } from "./sessionManager.ts"; import { applySessionStickiness, recordStickyBinding, + clearStickyBinding, + peekStickyConnectionId, resolveDisableSessionStickiness, } from "./combo/sessionStickiness.ts"; import { selectQuotaShareTarget } from "./combo/quotaShareStrategy.ts"; @@ -205,6 +207,26 @@ export { validateComboDAG, } from "./combo/comboStructure.ts"; +/** + * #6692: release a session-stickiness pin the moment its bound connection is + * the one that just failed. applySessionStickiness() only re-checks health on + * the NEXT turn (lazily) — without this, a terminal/quality-rejected + * connection stays pinned until that lazy recheck fires, and a masked + * daily-cap 200-body rejection never trips the lazy recheck's DB-backed + * testStatus gate at all (the connection row itself isn't marked unhealthy). + * Exported for the two failure branches in handleComboChat + handleRoundRobinCombo. + * peekStickyConnectionId guards against clearing an unrelated pin when the + * failing target isn't actually the currently sticky-bound connection. + */ +export function releaseStickyPinOnFailure( + messageHash: string | null | undefined, + failedConnectionId: string | null | undefined +): void { + if (!messageHash || !failedConnectionId) return; + if (peekStickyConnectionId(messageHash) !== failedConnectionId) return; + clearStickyBinding(messageHash); +} + const DEFAULT_MODEL_P95_MS: Record = { "grok-4-fast-non-reasoning": 1143, "grok-4-1-fast-non-reasoning": 1244, @@ -1669,6 +1691,10 @@ export async function handleComboChat({ "COMBO", `Model ${modelStr} returned 200 but failed quality check: ${quality.reason}` ); + // #6692: a quality-rejected 200 never marks the connection row + // unhealthy, so the sticky pin's lazy headroom recheck would never + // catch it either — release it here, on the failing response. + releaseStickyPinOnFailure(_sticky.messageHash, effectiveConnectionId); recordComboRequest(combo.name, modelStr, { success: false, latencyMs: Date.now() - startTime, @@ -2027,6 +2053,10 @@ export async function handleComboChat({ exhaustedLogLevel: "info", structuredError, }); + // #6692: this connection was just classified as provider/connection-level + // exhausted — if it's the currently sticky-bound one, release the pin now + // rather than waiting for the next turn's lazy headroom/status recheck. + releaseStickyPinOnFailure(_sticky.messageHash, targetWithConnection.connectionId); // #2101: Prevent infinite fallback loops with 400 Bad Request errors that are genuinely // body-specific (malformed JSON, bad format, missing required fields). @@ -2758,6 +2788,19 @@ async function handleRoundRobinCombo({ "COMBO-RR", `${modelStr} returned 200 but failed quality check: ${quality.reason}` ); + // #6692: same rationale as handleComboChat's quality-fail branch — + // a quality-rejected 200 never marks the connection row unhealthy, + // so release the sticky pin here rather than on the next turn. + { + const rrSelectedConnectionId = + result.headers?.get("X-OmniRoute-Selected-Connection-Id") || + result.headers?.get("x-omniroute-selected-connection-id") || + undefined; + releaseStickyPinOnFailure( + _rrSessionSticky.messageHash, + rrSelectedConnectionId || target.connectionId + ); + } recordComboRequest(combo.name, modelStr, { success: false, latencyMs: Date.now() - startTime, @@ -2984,6 +3027,8 @@ async function handleRoundRobinCombo({ exhaustedLogLevel: "debug", structuredError, }); + // #6692: mirrors handleComboChat's exhaustion-point release above. + releaseStickyPinOnFailure(_rrSessionSticky.messageHash, targetWithConnection.connectionId); // Transient errors → mark in semaphore so round-robin stops stampeding this target. if ( diff --git a/open-sse/services/combo/fingerprintExpansion.ts b/open-sse/services/combo/fingerprintExpansion.ts index c96bb3ad05..be3d511509 100644 --- a/open-sse/services/combo/fingerprintExpansion.ts +++ b/open-sse/services/combo/fingerprintExpansion.ts @@ -17,11 +17,35 @@ import type { ResolvedComboTarget } from "./types.ts"; /** Providers whose `providerSpecificData.fingerprints` array should be expanded. */ const FINGERPRINT_PROVIDERS: ReadonlySet = new Set(["mimocode", "mcode", "opencode"]); +/** Separator the combo builder UI uses to encode an account pin (#6087). */ +const FP_PIN_SEPARATOR = "|fp|"; + /** Check whether a provider uses fingerprint-based multi-account. */ export function isFingerprintProvider(provider: string): boolean { return FINGERPRINT_PROVIDERS.has(provider); } +/** + * Split a combo builder "pinned account" connectionId (`${rowId}|fp|${fingerprint}`, + * produced by `expandConnectionOptions` in `src/lib/combos/builderOptions.ts`) back + * into the real DB connection row id and the pinned fingerprint (#6696). + * + * Returns `null` when `connectionId` does not carry the pin separator, so callers can + * fall through to the unpinned resolution path unchanged. + */ +export function splitFingerprintPin( + connectionId: string +): { realConnectionId: string; pinnedFingerprint: string } | null { + const separatorIndex = connectionId.indexOf(FP_PIN_SEPARATOR); + if (separatorIndex === -1) return null; + + const realConnectionId = connectionId.slice(0, separatorIndex); + const pinnedFingerprint = connectionId.slice(separatorIndex + FP_PIN_SEPARATOR.length); + if (!realConnectionId || !pinnedFingerprint) return null; + + return { realConnectionId, pinnedFingerprint }; +} + /** Safely extract the fingerprints array from a connection record. */ export function getConnectionFingerprints( connection: Record | undefined | null @@ -84,6 +108,27 @@ export function expandTargetsByFingerprints( continue; } + // #6696: the combo builder UI pins a specific account by encoding it as + // `${rowId}|fp|${fingerprint}`. That composite string never matches a real + // DB row id in `connectionById`, so resolve it back to the real + // connectionId + the pinned fingerprint here before any other lookup — + // otherwise the pin is silently inert and credential resolution can never + // find the connection at all. + const pin = splitFingerprintPin(connectionId); + if (pin) { + result.push({ + ...target, + connectionId: pin.realConnectionId, + pinnedFingerprint: pin.pinnedFingerprint, + executionKey: buildFingerprintExecutionKey( + target.executionKey, + pin.pinnedFingerprint, + false + ), + }); + continue; + } + const connection = connectionById.get(connectionId); const fingerprints = getConnectionFingerprints(connection); diff --git a/open-sse/services/combo/sessionStickiness.ts b/open-sse/services/combo/sessionStickiness.ts index 5bb8779a94..2b9f746133 100644 --- a/open-sse/services/combo/sessionStickiness.ts +++ b/open-sse/services/combo/sessionStickiness.ts @@ -27,6 +27,14 @@ * orderTargetsByHeadroom (quotaStrategies.ts), so the open-sse leaf has no * static edge into src/lib/quota. For tests the fetcher is injected via * __setStickinessHeadroomFetcherForTests. + * • Terminal-status gate (#6692): headroom alone is orthogonal to account + * availability — a credits_exhausted/banned/expired connection (or one still + * inside its rate-limit window) reports perfectly healthy 5h/weekly + * utilization, so the headroom-only gate kept re-promoting a dead connection + * forever. The connection's testStatus/rateLimitedUntil is now resolved via + * the same dynamic-import-with-injectable-override seam (fail-open on lookup + * errors, mirroring resolveSaturation) and gates the pin alongside headroom. + * For tests the fetcher is injected via __setStickinessConnectionFetcherForTests. * * No barrel import — consistent with the other combo/* helpers. * @@ -79,6 +87,83 @@ export function __setStickinessHeadroomFetcherForTests(fetcher: SaturationFetche _fetcherOverride = fetcher; } +// ─── Connection terminal-status gate (#6692) ───────────────────────────────── + +/** Minimal connection health shape the terminal-status gate needs. */ +export interface StickyConnectionHealth { + testStatus?: string | null; + rateLimitedUntil?: string | null; +} + +/** + * Injectable connection-health fetcher seam (for unit tests). + * Returns StickyConnectionHealth or undefined when unknown/lookup failed. + */ +export type ConnectionHealthFetcher = ( + connectionId: string, + provider: string +) => Promise; + +/** Overrides the default connection-health fetcher for tests; null = use production fetcher. */ +let _connectionFetcherOverride: ConnectionHealthFetcher | null = null; + +/** Test-only: inject the connection-health fetcher; pass null to restore default. */ +export function __setStickinessConnectionFetcherForTests( + fetcher: ConnectionHealthFetcher | null +): void { + _connectionFetcherOverride = fetcher; +} + +/** + * Statuses that mean the account is DURABLY dead, not just transiently rate + * limited — mirrors TERMINAL_PIN_STATUSES used by the LKGP/context-cache pin + * (combo.ts:558). Duplicated here (rather than imported) so this leaf keeps no + * static edge into combo.ts, which itself imports this module. + */ +const TERMINAL_STICKY_STATUSES = new Set(["credits_exhausted", "banned", "expired"]); + +/** + * Resolve the sticky-bound connection's health by fetching its provider_connections + * row. Uses the same dynamic-import pattern as resolveSaturation so this leaf has + * no static dependency on src/lib/db. Fail-open (undefined) on any error. + */ +async function resolveConnectionHealth( + connectionId: string, + provider: string +): Promise { + if (_connectionFetcherOverride) return _connectionFetcherOverride(connectionId, provider); + + try { + const mod = await import("../../../src/lib/db/providers"); + const getProviderConnections = mod.getProviderConnections as ( + filter: Record + ) => Promise; + const connections = (await getProviderConnections({ + provider, + isActive: true, + })) as Array; + return connections.find((c) => c.id === connectionId); + } catch { + return undefined; + } +} + +/** + * Pure: is the sticky-bound connection durably unhealthy right now? Fail-open + * (false) when the connection is unknown — an unresolved lookup must never drop + * a healthy pin. + */ +export function isStickyConnectionTerminallyUnhealthy( + conn: StickyConnectionHealth | undefined, + now: number +): boolean { + if (!conn) return false; + const status = typeof conn.testStatus === "string" ? conn.testStatus : ""; + if (TERMINAL_STICKY_STATUSES.has(status)) return true; + const rl = conn.rateLimitedUntil ? new Date(String(conn.rateLimitedUntil)).getTime() : 0; + return Number.isFinite(rl) && rl > now; +} + /** * Resolve the HeadroomSaturation for a connection by fetching both the 5h and * weekly utilisation signals. Uses the same dynamic-import pattern as @@ -186,6 +271,17 @@ export function clearStickyBinding(messageHash: string): void { stickyMap.delete(messageHash); } +/** + * Read-only peek at the connectionId currently bound to `messageHash`, without + * mutating the store or checking TTL/health. Lets combo.ts's failure paths + * confirm a just-failed target is the ACTUAL sticky-bound connection before + * calling clearStickyBinding (#6692) — clearing on an unrelated target's + * failure would drop a still-healthy pin. + */ +export function peekStickyConnectionId(messageHash: string): string | null { + return stickyMap.get(messageHash)?.connectionId ?? null; +} + /** Reset the entire store (for testing). */ export function clearAllStickyBindings(): void { stickyMap.clear(); @@ -228,9 +324,10 @@ export interface ApplyStickinessResult { * Algorithm: * 1. Derive the message hash from the first user message. * 2. Look up the sticky binding for that hash. - * 3. If found, fetch saturation for that connection. - * 4. If headroom > threshold → move the matching target to index 0. - * Otherwise → clear the binding (rebind on next success). + * 3. If found, fetch saturation AND connection health for that connection. + * 4. If headroom > threshold AND the connection is not durably unhealthy + * (#6692: terminal testStatus / still rate-limited) → move the matching + * target to index 0. Otherwise → clear the binding (rebind on next success). * 5. On any error → fall through unchanged (fail-open). * * In production the saturation fetcher is resolved via dynamic import of @@ -272,13 +369,22 @@ export async function applySessionStickiness( return { targets: orderedTargets, messageHash, stuck: false }; } - // Gate: headroom must be above threshold + // Gate: headroom must be above threshold AND the connection must not be + // durably unhealthy (#6692 — credits_exhausted/banned/expired/rate-limited + // accounts report healthy 5h/weekly utilization, so headroom alone never + // catches them). const stickyTarget = orderedTargets[stickyIdx]; - const sat = await resolveSaturation(connectionId, stickyTarget.provider); + const [sat, connHealth] = await Promise.all([ + resolveSaturation(connectionId, stickyTarget.provider), + resolveConnectionHealth(connectionId, stickyTarget.provider), + ]); const headroom = computeHeadroom(sat); - if (headroom <= STICKINESS_HEADROOM_THRESHOLD) { - // Connection saturated — rebind on next success + if ( + headroom <= STICKINESS_HEADROOM_THRESHOLD || + isStickyConnectionTerminallyUnhealthy(connHealth, Date.now()) + ) { + // Connection saturated or durably unhealthy — rebind on next success clearStickyBinding(messageHash); return { targets: orderedTargets, messageHash, stuck: false }; } diff --git a/open-sse/services/combo/types.ts b/open-sse/services/combo/types.ts index 290b0964d7..ec3bac65b9 100644 --- a/open-sse/services/combo/types.ts +++ b/open-sse/services/combo/types.ts @@ -151,6 +151,14 @@ export type ResolvedComboTarget = { label: string | null; failoverBeforeRetry?: unknown; trafficType?: "production" | "shadow"; + /** + * Fingerprint-based account pin resolved from a combo builder composite + * connectionId (`${rowId}|fp|${fingerprint}`, see + * `expandTargetsByFingerprints` in `./fingerprintExpansion.ts`, #6696). + * Set only for fingerprint-provider targets (mimocode/mcode/opencode) that + * were pinned to one specific account. + */ + pinnedFingerprint?: string; }; export type ShadowRoutingConfig = { diff --git a/open-sse/services/compression/engineBreakdown.ts b/open-sse/services/compression/engineBreakdown.ts index 99d3bfaf16..4f512ab05f 100644 --- a/open-sse/services/compression/engineBreakdown.ts +++ b/open-sse/services/compression/engineBreakdown.ts @@ -27,3 +27,40 @@ export function ensureEngineBreakdown(stats: CompressionStats): EngineBreakdownE }, ]; } + +/** + * #6488 — Reconcile the single-engine breakdown entry's token counts with the response's + * authoritative outer counts. + * + * The outer `originalTokens`/`compressedTokens` fields (computed by the API route with a real + * tiktoken-based counter over the extracted message text) and each `engineBreakdown[]` entry's + * `originalTokens`/`compressedTokens` (computed internally by `estimateCompressionTokens`, a + * crude `JSON.stringify(requestBody).length / 4` estimate over the whole request-body object) + * use two different, unreconciled token-counting methodologies. They diverge most on + * small/degenerate inputs where JSON structural overhead (braces, quotes, `role`/`content` + * keys) dominates the char count. + * + * When the breakdown has exactly one entry, that entry represents the *same* before/after + * transformation as the overall response (single-engine dispatch, or a 1-step pipeline) — so + * its counts are safe to overwrite with the outer, more accurate figures. Multi-step + * breakdowns are left untouched: each intermediate step legitimately operates on the previous + * step's (already-compressed) output, so its "before" state is not the overall original input + * and reconciling it against the overall counts would be incorrect. + */ +export function reconcileSingleEngineTokens( + breakdown: EngineBreakdownEntry[], + outerOriginalTokens: number, + outerCompressedTokens: number, + outerSavingsPercent: number +): EngineBreakdownEntry[] { + if (breakdown.length !== 1) return breakdown; + const [entry] = breakdown; + return [ + { + ...entry, + originalTokens: outerOriginalTokens, + compressedTokens: outerCompressedTokens, + savingsPercent: outerSavingsPercent, + }, + ]; +} diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index fa973fd44e..9bba7973b2 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -39,6 +39,7 @@ export type CompressionEngineId = | "headroom" | "ccr" | "llmlingua" + | "relevance" | "omniglyph"; export interface CavemanRule { diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index f68bebea3d..3d978fec4c 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -76,6 +76,7 @@ export const PROVIDER_ERROR_TYPES = { CONTEXT_OVERFLOW: "context_overflow", OAUTH_INVALID_TOKEN: "oauth_invalid_token", EMPTY_CONTENT: "empty_content", + MODEL_NOT_FOUND: "model_not_found", }; export const CONTEXT_OVERFLOW_SIGNALS = [ @@ -144,6 +145,15 @@ export function classifyProviderError( return PROVIDER_ERROR_TYPES.RATE_LIMITED; } + // 404 — model or endpoint not found. Without classification the error + // falls through to `return null`, so no cooldown/lockout is applied and the + // retry/backoff loop keeps hammering the dead endpoint until the upstream + // rate-limits it (404 + 429 storm). Classify as MODEL_NOT_FOUND so the model + // gets locked via the cooldown layer and retries stop. (#6827) + if (statusCode === 404) { + return PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND; + } + if (statusCode === 401) { if (oauthInvalid) { return PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN; diff --git a/open-sse/services/fusion.ts b/open-sse/services/fusion.ts index 55ec0ce7d8..d089186823 100644 --- a/open-sse/services/fusion.ts +++ b/open-sse/services/fusion.ts @@ -122,7 +122,11 @@ export function buildJudgePrompt(answers: Array<{ text: string }>): string { "", "Do NOT mention that multiple models were used, and do NOT refer to the sources. Produce ONE authoritative final answer addressed directly to the user.", "", - "First, internally analyze the panel along these dimensions: consensus (points most sources agree on — treat as higher-confidence), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed. Then write the best possible final answer grounded in that analysis — more complete and correct than any single response, with no filler.", + "First, internally analyze the panel along these dimensions: consensus (points most sources agree on — usually higher-confidence, but NOT automatically correct), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed.", + "", + "You are not a vote-counter, and the panel is not a ceiling — treat it as strong evidence, not as the limit of what you may say. Apply your OWN reasoning and knowledge as a full participant: if the consensus is wrong, incomplete, or outdated, override it and state what is correct; if every source missed something you know, add it; if a lone source is right against the majority, side with it. Do not water down a correct answer to match panel agreement. The only hard limit is honesty — do not assert facts you are not confident about.", + "", + "Then write the best possible final answer — more complete and correct than any single response, and than the panel as a whole — with no filler.", "", "=== PANEL RESPONSES ===", panel, diff --git a/open-sse/services/kiroModels.ts b/open-sse/services/kiroModels.ts index 89680655d4..717e8bac66 100644 --- a/open-sse/services/kiroModels.ts +++ b/open-sse/services/kiroModels.ts @@ -27,6 +27,8 @@ import { createHash } from "node:crypto"; import { v4 as uuidv4 } from "uuid"; +import { resolveKiroRuntimeRegion } from "./kiroRegion.ts"; + type RawRecord = Record; const KIRO_RUNTIME_SDK_VERSION = "1.0.0"; @@ -185,20 +187,15 @@ function expandKiroModels(data: unknown): KiroModel[] { } /** - * Derive the AWS region for a Kiro connection. Mirrors getKiroUsage: prefer the - * stored region, then the region embedded in the profileArn, else us-east-1. + * Derive the RUNTIME AWS region for a Kiro connection's model discovery. Delegates to the shared + * resolver: the profileArn region wins (that is where the Q Developer profile + ListAvailableModels + * live — us-east-1 / eu-central-1), then a valid stored profile region, else us-east-1. The IdC + * token region (e.g. eu-north-1) is deliberately not used as a runtime region. */ export function resolveKiroRegion(providerSpecificData: unknown): string { - const psd = asRecord(providerSpecificData); - const explicit = toNonEmptyString(psd.region); - if (explicit) return explicit.toLowerCase(); - - const profileArn = toNonEmptyString(psd.profileArn); - const fromArn = profileArn - ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] - : undefined; - - return fromArn || "us-east-1"; + return resolveKiroRuntimeRegion( + asRecord(providerSpecificData) as { region?: unknown; profileArn?: unknown } + ); } /** diff --git a/open-sse/services/kiroRegion.ts b/open-sse/services/kiroRegion.ts new file mode 100644 index 0000000000..e87495cf33 --- /dev/null +++ b/open-sse/services/kiroRegion.ts @@ -0,0 +1,185 @@ +/** + * Shared Amazon Q Developer (Kiro / AWS CodeWhisperer) region resolution. + * + * TWO DISTINCT REGIONS — verified against the AWS docs "Amazon Q Developer Pro Region support" + * ("Supported Regions for the Q Developer console and Q Developer profile"): + * + * • IdC / OIDC / token region — `providerSpecificData.region`. May be ANY of the ~30 IdC- + * supported AWS regions (us-east-1, us-west-2, ca-central-1, sa-east-1, eu-west-1/2/3, + * eu-central-1/2, eu-north-1, eu-south-1/2, ap-south-1/2, ap-east-1/2, ap-southeast-1..7, + * ap-northeast-1/2/3, me-central-1, me-south-1, af-south-1, il-central-1, …). Used ONLY for + * `oidc.{region}.amazonaws.com` token mint/refresh (see tokenRefresh.ts / oauth providers). + * • Q Developer PROFILE / RUNTIME region — where the `profileArn` lives and every CodeWhisperer + * runtime call is served (generateAssistantResponse, GetUsageLimits, ListAvailableModels, + * ListAvailableProfiles). AWS currently hosts the profile ONLY in us-east-1 and eu-central-1, + * REGARDLESS of the IdC region ("Regardless of the IAM Identity Center Region, data is stored + * in the Region where you create the Amazon Q Developer profile"). The AWS docs' own example: + * an IdC in us-west-1 → profile in us-east-1. + * + * Consequences enforced here: + * • The RUNTIME region is the region embedded in the `profileArn` (authoritative — whatever + * region AWS actually hosts the profile in), NOT the IdC region. Routing a runtime call to + * `q.{idcRegion}.amazonaws.com` for a non-profile IdC region (e.g. q.eu-north-1, which does + * not exist as a Q Developer runtime endpoint) is the root cause of the "Kiro IAM shows no + * limits + every request returns 502" failure. + * • profileArn discovery works for an IdC in ANY region: it probes the known profile regions + * (us-east-1 / eu-central-1) with the cross-region SSO token, AND the IdC's own region as a + * forward-compatible fallback (in case AWS ever co-locates or expands profile regions). The + * discovered ARN's region then drives every runtime call. + */ + +// Canonical AWS region shape — kept local (identical to AWS_REGION_PATTERN in +// src/lib/oauth/constants/oauth.ts) so this open-sse module has no cross-tree import just to +// validate a string. Guards against SSRF via region injection (GHSA-6mwv-4mrm-5p3m): the value +// is interpolated into upstream URLs. +export const AWS_REGION_PATTERN = /^[a-z]{2}-[a-z]+-\d{1,2}$/; + +/** + * Regions where the Amazon Q Developer *profile* is currently hosted (AWS docs: "Supported + * Regions for the Q Developer console and Q Developer profile"). These are the guaranteed + * discovery targets and the only regions trusted as a runtime fallback when no profileArn is + * known. The profileArn's own region is always honored above this list, so a future AWS + * profile-region expansion works automatically once an ARN is discovered. + */ +export const KIRO_PROFILE_REGIONS = ["us-east-1", "eu-central-1"] as const; + +/** + * CodeWhisperer / Amazon Q runtime host for a region. us-east-1 keeps the legacy + * codewhisperer.us-east-1 host (AWS Builder ID home region); other regions use the regional + * Amazon Q endpoint `q.{region}.amazonaws.com` — codewhisperer.{region}.amazonaws.com does not + * resolve for non-us-east-1 regions. + */ +export function kiroRuntimeHost(region: string): string { + return region === "us-east-1" + ? "https://codewhisperer.us-east-1.amazonaws.com" + : `https://q.${region}.amazonaws.com`; +} + +/** Extract the region from a CodeWhisperer profile ARN (`arn:aws:codewhisperer:{region}:...`). */ +export function regionFromKiroProfileArn(profileArn?: string | null): string | undefined { + if (typeof profileArn !== "string") return undefined; + return profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1]; +} + +function normalizeRegion(region: unknown): string { + return typeof region === "string" ? region.trim().toLowerCase() : ""; +} + +/** + * Resolve the RUNTIME region for CodeWhisperer / Amazon Q calls. + * + * Priority: + * 1. The region embedded in the `profileArn` — authoritative, this is where the Q Developer + * profile (and thus the runtime) actually lives. + * 2. A stored region ONLY when it is a valid Q Developer profile region (us-east-1 / + * eu-central-1). A stored IdC region that is not a Q profile region (e.g. eu-north-1) is + * deliberately IGNORED for runtime — it is a token/OIDC region, not a runtime region. + * 3. us-east-1 (CodeWhisperer home region) as the final fallback. + */ +export function resolveKiroRuntimeRegion( + providerSpecificData: { region?: unknown; profileArn?: unknown } | null | undefined +): string { + const fromArn = regionFromKiroProfileArn( + typeof providerSpecificData?.profileArn === "string" + ? providerSpecificData.profileArn + : undefined + ); + if (fromArn) return fromArn; + + const stored = normalizeRegion(providerSpecificData?.region); + if (stored && (KIRO_PROFILE_REGIONS as readonly string[]).includes(stored)) return stored; + + return "us-east-1"; +} + +/** + * Build the ordered list of regions to probe for `ListAvailableProfiles`. + * + * The Amazon Q Developer profile (and thus every runtime endpoint) is currently hosted only in + * KIRO_PROFILE_REGIONS (us-east-1 / eu-central-1) regardless of the IdC region, so those are + * probed FIRST — EU-first when the IdC region is in EMEA (eu-, af-, me-, il- prefixes) to + * minimize latency. The IdC/stored region is then appended as a forward-compatible fallback: if + * AWS ever co-locates the profile with the IdC, or expands the profile-region list, a same-region + * probe still finds it. It is only appended when it is a valid AWS region distinct from the known + * profile regions; probing a region with no profile simply returns nothing and we fall through. + * This makes discovery work for an IdC in ANY region (us-west-2, ap-southeast-2, me-central-1, + * af-south-1, …), not just eu-north-1. + */ +export function buildKiroProfileDiscoveryRegions(storedRegion?: string | null): string[] { + const stored = normalizeRegion(storedRegion); + const preferEu = /^(eu|af|me|il)-/.test(stored); + const regions: string[] = preferEu + ? ["eu-central-1", "us-east-1"] + : ["us-east-1", "eu-central-1"]; + + if (stored && AWS_REGION_PATTERN.test(stored) && !regions.includes(stored)) { + regions.push(stored); + } + return regions; +} + +async function listKiroProfileArnForRegion( + accessToken: string, + region: string, + fetchImpl: typeof fetch +): Promise { + // Defensive: region comes from a hardcoded allowlist here, but validate before it is + // interpolated into the runtime host (SSRF guard, GHSA-6mwv-4mrm-5p3m). + if (!AWS_REGION_PATTERN.test(region)) return undefined; + try { + const response = await fetchImpl(`${kiroRuntimeHost(region)}/`, { + method: "POST", + headers: { + "Content-Type": "application/x-amz-json-1.0", + Accept: "application/json", + "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ maxResults: 10 }), + // Never let a hung/region-mismatched profile lookup block login or the quota refresh. + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) return undefined; + + const data = (await response.json()) as { profiles?: unknown }; + const profiles = Array.isArray(data?.profiles) ? data.profiles : []; + // Prefer a profile whose ARN region matches the region we queried; else take the first. + const matched = + profiles.find((profile: unknown) => { + const arn = (profile as { arn?: unknown })?.arn; + return typeof arn === "string" && regionFromKiroProfileArn(arn) === region; + }) || profiles[0]; + const arn = (matched as { arn?: unknown })?.arn; + return typeof arn === "string" && arn.length > 0 ? arn : undefined; + } catch { + return undefined; + } +} + +/** + * Discover a Kiro/CodeWhisperer profile ARN by probing the Q Developer profile regions + * (us-east-1 / eu-central-1) AND the IdC/stored region with the account's access token. The SSO + * bearer token minted from the IdC region works cross-region against the Q Developer profile's + * region (AWS's documented multi-region IdC ⇄ profile setup), so an IdC in ANY region resolves. + * Returns the first ARN found (its embedded region is the authoritative runtime region), or + * undefined when no profile is available (e.g. AWS Builder ID accounts, or an org/token with no + * Kiro entitlement). Best-effort: never throws. + */ +export async function discoverKiroProfileArnAcrossRegions( + accessToken: string | null | undefined, + storedRegion?: string | null, + fetchImpl?: typeof fetch +): Promise { + const token = typeof accessToken === "string" ? accessToken.trim() : ""; + if (!token) return undefined; + + // Resolve fetch at call time (not module-load) so callers/tests that swap globalThis.fetch + // are honored when no explicit implementation is injected. + const doFetch = fetchImpl ?? globalThis.fetch; + + for (const region of buildKiroProfileDiscoveryRegions(storedRegion)) { + const arn = await listKiroProfileArnForRegion(token, region, doFetch); + if (arn) return arn; + } + return undefined; +} diff --git a/open-sse/services/lmarenaTlsClient.ts b/open-sse/services/lmarenaTlsClient.ts new file mode 100644 index 0000000000..896a6e7462 --- /dev/null +++ b/open-sse/services/lmarenaTlsClient.ts @@ -0,0 +1,605 @@ +/** + * Browser-TLS-impersonating HTTP client for arena.ai. + * + * Why this exists: LMArena sits behind Cloudflare Enterprise which pins + * `cf_clearance` to the client's TLS fingerprint (JA3/JA4) + HTTP/2 SETTINGS + * frame ordering. Node's Undici fetch presents an obvious "not a browser" + * handshake and gets challenged with a 403 even with a valid arena session + * cookie (and often a browser-minted `cf_clearance`). This module wraps + * `tls-client-node` (bogdanfinn/tls-client) to send a Chrome handshake instead. + * + * Mirrors `grokTlsClient.ts` / `perplexityTlsClient.ts` as an independent + * module so changes here cannot regress those production paths. + * + * Note: Arena may still require a browser-issued reCAPTCHA v3 token on + * create-evaluation; TLS alone is necessary but not always sufficient. + */ + +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { mkdtemp, open, unlink, rmdir, stat } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; + +let clientPromise: Promise | null = null; +let exitHookInstalled = false; + +// Newest Chrome JA3 profile shipped by tls-client-node (no chrome_147+ yet). +// HTTP User-Agent / Sec-Ch-Ua track Chrome 150 separately in models.ts. +const LMARENA_PROFILE = "chrome_146"; +// Fixed timeouts (same defaults as other TLS sidecars). No extra env knobs — +// env-doc-sync must not grow for provider-local constants. +const DEFAULT_TIMEOUT_MS = 60_000; +// Grace period added to the binding's wire-level timeout before our JS-level +// hard timeout fires. Under healthy operation `tls-client-node` honors +// `timeoutMilliseconds` and rejects on its own; the JS-level race only wins +// when the koffi-loaded native library is wedged (which the binding's own +// timer can't escape). +const HARD_TIMEOUT_GRACE_MS = 10_000; + +function installExitHook(): void { + if (exitHookInstalled) return; + exitHookInstalled = true; + const stop = async () => { + if (clientPromise === null) return; + try { + const c = (await clientPromise) as { stop?: () => Promise }; + await c.stop?.(); + } catch { + // ignore + } + }; + process.once("beforeExit", stop); + process.once("SIGINT", () => { + void stop(); + }); + process.once("SIGTERM", () => { + void stop(); + }); +} + +/** + * Drop the cached client so the next `getClient()` call respawns it. Called + * when a request observes the native binding has wedged — releasing the + * reference lets a fresh TLSClient (and a fresh koffi load) take over without + * a process restart. + */ +function resetClientCache(): void { + clientPromise = null; +} + +export class TlsClientHangError extends Error { + constructor(message: string) { + super(message); + this.name = "TlsClientHangError"; + } +} + +/** + * Race a `client.request()` promise against (a) a JS-level hard timeout and + * (b) the caller's abort signal. The native binding's `timeoutMilliseconds` + * already covers the wire path; this guards the case where the koffi binding + * itself deadlocks (observed after sustained load), where neither the + * binding's own timer nor a post-call `signal.aborted` re-check can recover. + */ +async function raceWithTimeout( + promise: Promise, + timeoutMs: number, + signal: AbortSignal | null | undefined +): Promise { + let timer: ReturnType | null = null; + let abortListener: (() => void) | null = null; + try { + const racers: Promise[] = [ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new TlsClientHangError( + `tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked` + ) + ); + }, timeoutMs); + }), + ]; + if (signal) { + racers.push( + new Promise((_, reject) => { + if (signal.aborted) { + reject(makeAbortError(signal)); + return; + } + abortListener = () => reject(makeAbortError(signal)); + signal.addEventListener("abort", abortListener, { once: true }); + }) + ); + } + return await Promise.race(racers); + } finally { + if (timer) clearTimeout(timer); + if (signal && abortListener) signal.removeEventListener("abort", abortListener); + } +} + +async function getClient(): Promise<{ + request: (url: string, opts: Record) => Promise; +}> { + if (!clientPromise) { + clientPromise = (async () => { + try { + const mod = await import("tls-client-node"); + const TLSClient = (mod as { TLSClient: new (opts?: Record) => unknown }) + .TLSClient; + // Native mode loads the shared library directly via koffi, avoiding the + // managed sidecar's localhost HTTP calls that OmniRoute's global fetch + // proxy patch interferes with. + const client = new TLSClient({ runtimeMode: "native" }) as { + start: () => Promise; + request: (url: string, opts: Record) => Promise; + }; + await client.start(); + + installExitHook(); + return client; + } catch (err) { + clientPromise = null; + const msg = err instanceof Error ? err.message : String(err); + throw new TlsClientUnavailableError( + `TLS impersonation client failed to start: ${msg}. ` + + `Verify tls-client-node is installed and its native binary downloaded.` + ); + } + })(); + } + return clientPromise as Promise<{ + request: (url: string, opts: Record) => Promise; + }>; +} + +interface TlsResponseLike { + status: number; + headers: Record; + body: string; // for non-streaming requests, the full response body + cookies?: Record; + text: () => Promise; + bytes: () => Promise; + json: () => Promise; +} + +export class TlsClientUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "TlsClientUnavailableError"; + } +} + +export interface TlsFetchOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + headers?: Record; + body?: string; + timeoutMs?: number; + signal?: AbortSignal | null; + /** + * If true, the response body is streamed to a temp file and exposed as a + * ReadableStream. Use for NDJSON streaming responses (the + * LMArena conversation endpoint). Otherwise, the full body is read into memory. + */ + stream?: boolean; + /** EOF marker the upstream sends to signal end of stream (default: "[DONE]"). */ + streamEofSymbol?: string; + /** + * Optional upstream proxy URL (`http://user:pass@host:port` or + * `socks5://...`). When set, the request is tunneled through this proxy + * before reaching arena.ai. + * + * Resolution order: + * 1. `options.proxyUrl` (per-call override from caller) + * 2. `process.env.OMNIROUTE_TLS_PROXY_URL` (single-flag opt-in) + * 3. `process.env.HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` (POSIX-standard fallback) + * + * The native `tls-client-node` binding does **not** consult Go's + * `http.ProxyFromEnvironment`, so the env vars need to be plumbed in here at + * the JS layer. + */ + proxyUrl?: string; +} + +import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; + +/** + * Resolve the proxy URL for a tls-client request. Per-call value wins; + * otherwise we use the standard proxy fetch resolution which reads from + * the dashboard AsyncLocalStorage context or falls back to env vars. + * + * Fail-closed: if resolution throws (e.g. a configured socks5 proxy with + * ENABLE_SOCKS5_PROXY=false), this rethrows rather than returning undefined — + * undefined would let the native binding connect directly and leak the real IP. + */ +function resolveProxyUrl(perCall: string | undefined): string | undefined { + return resolveTlsClientProxyUrl("https://arena.ai", perCall, resolveProxyForRequest); +} + +export interface TlsFetchResult { + status: number; + headers: Headers; + /** Full response body as text — only populated for non-streaming requests. */ + text: string | null; + /** Streaming body — only populated when options.stream === true. */ + body: ReadableStream | null; +} + +// Test-only injection point. Tests call __setTlsFetchOverrideForTesting() +// to replace the real TLS client with a mock; production never touches this. +let testOverride: ((url: string, options: TlsFetchOptions) => Promise) | null = + null; + +export function __setTlsFetchOverrideForTesting(fn: typeof testOverride): void { + testOverride = fn; +} + +function throwIfAborted(signal: AbortSignal | null | undefined): void { + if (signal?.aborted) throw makeAbortError(signal); +} + +function buildTlsRequestOptions(options: TlsFetchOptions): Record { + return { + method: options.method || "GET", + headers: options.headers || {}, + body: options.body, + tlsClientIdentifier: LMARENA_PROFILE, + timeoutMilliseconds: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + followRedirects: true, + withRandomTLSExtensionOrder: true, + // Plumb proxy via options — tls-client-node does not read HTTP_PROXY env. + proxyUrl: resolveProxyUrl(options.proxyUrl), + }; +} + +function hardTimeoutMs(options: TlsFetchOptions): number { + return (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS; +} + +async function tlsFetchNonStreaming( + client: { request: (url: string, opts: Record) => Promise }, + url: string, + requestOptions: Record, + options: TlsFetchOptions +): Promise { + let tlsResponse: TlsResponseLike; + try { + tlsResponse = await raceWithTimeout( + client.request(url, requestOptions), + hardTimeoutMs(options), + options.signal ?? null + ); + } catch (err) { + if (err instanceof TlsClientHangError) resetClientCache(); + throw err; + } + throwIfAborted(options.signal); + return { + status: tlsResponse.status, + headers: toHeaders(tlsResponse.headers), + text: tlsResponse.body, + body: null, + }; +} + +/** + * Make a single HTTP request to arena.ai with a Chrome-like TLS fingerprint. + * Throws TlsClientUnavailableError if the native binary failed to load. + */ +export async function tlsFetchLMArena( + url: string, + options: TlsFetchOptions = {} +): Promise { + if (testOverride) return testOverride(url, options); + throwIfAborted(options.signal); + const client = await getClient(); + throwIfAborted(options.signal); + + const requestOptions = buildTlsRequestOptions(options); + if (options.stream) { + return tlsFetchStreaming( + client, + url, + requestOptions, + options.streamEofSymbol, + options.signal ?? null, + hardTimeoutMs(options) + ); + } + return tlsFetchNonStreaming(client, url, requestOptions, options); +} + +function makeAbortError(signal: AbortSignal): Error { + const reason = signal.reason; + if (reason instanceof Error) return reason; + const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + return err; +} + +function toHeaders(raw: Record): Headers { + const h = new Headers(); + for (const [k, vs] of Object.entries(raw || {})) { + for (const v of vs) h.append(k, v); + } + return h; +} + +/** + * Returns true if the response body is a Cloudflare challenge/interstitial page + * rather than a real LMArena response. From VPS/datacenter IPs a valid cookie + * still gets a 403 "Request rejected by anti-bot rules." JSON; distinguishing + * it from a genuine auth failure lets the caller surface an actionable error + * (issue #3180). + * + * Exported so the executor and the connection validator share one detector. + */ +export function isCloudflareChallenge(text: string | null | undefined): boolean { + if (!text) return false; + return /just a moment|window\._cf_chl_opt|challenges\.cloudflare\.com|attention required|cf-chl/i.test( + text + ); +} + +// ─── Streaming via temp file ──────────────────────────────────────────────── +// tls-client-node's streaming primitive writes the response body chunk-by-chunk +// to a file path, terminating when the upstream sends `streamOutputEOFSymbol`. +// We tail the file from a worker and surface the bytes as a ReadableStream. + +async function tlsFetchStreaming( + client: { request: (url: string, opts: Record) => Promise }, + url: string, + requestOptions: Record, + eofSymbol = "[DONE]", + signal: AbortSignal | null = null, + hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS +): Promise { + const dir = await mkdtemp(join(tmpdir(), "LMArena-stream-")); + const path = join(dir, `${randomUUID()}.ndjson`); + + const streamOpts = { + ...requestOptions, + streamOutputPath: path, + streamOutputBlockSize: 1024, + streamOutputEOFSymbol: eofSymbol, + }; + + // Kick off the request without awaiting — tls-client writes the body to + // `path` chunk-by-chunk while the call runs. The Promise resolves when the + // request fully completes (full body written). Wrapping in raceWithTimeout + // guarantees this promise eventually settles even if the koffi binding + // wedges; on hang we reset the singleton so the next request respawns. + let resetOnHang = true; + const requestPromise = raceWithTimeout( + client.request(url, streamOpts), + hardTimeoutMs, + signal + ).catch((err: unknown) => { + if (resetOnHang && err instanceof TlsClientHangError) { + resetClientCache(); + resetOnHang = false; + } + // Re-throw so downstream consumers (waitForContent, tailFile) observe + // the rejection and surface it instead of treating the stream as having + // ended cleanly. + throw err; + }); + + // Wait for the file to exist AND have at least one byte. + const ready = await waitForContent(path, 5_000, requestPromise); + if (!ready) { + const r = await requestPromise.catch( + (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike + ); + await cleanupTempPath(path); + return { + status: r.status, + headers: toHeaders(r.headers), + text: r.body, + body: null, + }; + } + + // Peek at the first bytes to distinguish a genuine NDJSON stream from a + // Cloudflare challenge page or an HTML error response that tls-client-node + // streamed to the temp file with a 200 status. + const peek = await readFirstBytes(path, 256); + if (isCloudflareChallenge(peek)) { + await cleanupTempPath(path); + return { + status: 403, + headers: new Headers({ "Content-Type": "text/html" }), + text: peek, + body: null, + }; + } + if (peek.trimStart().startsWith("<")) { + // HTML error page (not a challenge) — surface as a non-2xx so the executor + // can emit a proper SSE error chunk instead of feeding HTML to the NDJSON + // parser. + await cleanupTempPath(path); + return { + status: 502, + headers: new Headers({ "Content-Type": "text/html" }), + text: peek, + body: null, + }; + } + + // Looks like NDJSON — start tailing. The requestPromise will eventually + // resolve with the real upstream status; tailFile propagates non-2xx errors + // into the stream so the consumer sees them instead of a truncated success. + const stream = tailFile(path, eofSymbol, requestPromise, signal); + const headers = new Headers({ + "Content-Type": "application/x-ndjson", + "Cache-Control": "no-cache", + }); + return { status: 200, headers, text: null, body: stream }; +} + +async function cleanupTempPath(path: string): Promise { + await unlink(path).catch(() => {}); + await rmdir(dirname(path)).catch(() => {}); +} + +async function readFirstBytes(path: string, n: number): Promise { + const fd = await open(path, "r"); + try { + const buf = Buffer.alloc(n); + const { bytesRead } = await fd.read(buf, 0, n, 0); + return buf.subarray(0, bytesRead).toString("utf8"); + } finally { + await fd.close().catch(() => {}); + } +} + +/** + * Wait for the streaming output file to exist AND contain at least one byte. + * Returns false if the request settles before any bytes arrive (so the caller + * can drain `requestPromise` and surface the real upstream status). Returns + * true as soon as the file has data — even one byte is enough for the NDJSON + * heuristic to give a useful answer. + */ +async function waitForContent( + path: string, + timeoutMs: number, + requestPromise: Promise +): Promise { + let requestSettled = false; + requestPromise.then( + () => { + requestSettled = true; + }, + () => { + requestSettled = true; + } + ); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const s = await stat(path); + if (s.size > 0) return true; + } catch { + // file doesn't exist yet + } + // If the request finished without producing any bytes, no point waiting + // out the rest of the timeout — let the caller drain it. + if (requestSettled) return false; + await sleep(25); + } + return false; +} + +/** Enqueue chunk bytes, splitting off an EOF symbol when present. Returns true if closed. */ +function enqueueChunkMaybeEof( + controller: ReadableStreamDefaultController, + chunk: Buffer, + eofSymbol: string +): boolean { + const text = chunk.toString("utf8"); + if (!text.includes(eofSymbol)) { + controller.enqueue(Buffer.from(chunk)); + return false; + } + const beforeEof = text.substring(0, text.indexOf(eofSymbol)); + if (beforeEof) controller.enqueue(Buffer.from(beforeEof, "utf8")); + controller.close(); + return true; +} + +type FileHandle = Awaited>; + +async function drainRemaining( + fd: FileHandle, + buf: Buffer, + offsetRef: { offset: number }, + controller: ReadableStreamDefaultController, + eofSymbol: string +): Promise<"closed" | "drained"> { + while (true) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); + if (bytesRead === 0) return "drained"; + const chunk = buf.subarray(0, bytesRead); + offsetRef.offset += bytesRead; + if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return "closed"; + } +} + +function tailFile( + path: string, + eofSymbol: string, + done: Promise, + signal: AbortSignal | null = null +): ReadableStream { + return new ReadableStream({ + async start(controller) { + const fd = await open(path, "r"); + const buf = Buffer.alloc(64 * 1024); + const offsetRef = { offset: 0 }; + let finished = false; + let aborted = false; + let upstreamError: Error | null = null; + let errored = false; + + done.then( + () => { + finished = true; + }, + (err) => { + upstreamError = err instanceof Error ? err : new Error(String(err)); + finished = true; + } + ); + + const onAbort = () => { + aborted = true; + }; + if (signal) { + if (signal.aborted) aborted = true; + else signal.addEventListener("abort", onAbort, { once: true }); + } + + try { + while (!aborted) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); + if (bytesRead > 0) { + const chunk = buf.subarray(0, bytesRead); + offsetRef.offset += bytesRead; + if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return; + } + + if (!finished) { + await sleep(25); + continue; + } + + const drained = await drainRemaining(fd, buf, offsetRef, controller, eofSymbol); + if (drained === "closed") return; + if (upstreamError && !errored) { + errored = true; + controller.error(upstreamError); + return; + } + controller.close(); + return; + } + } catch (err) { + if (!errored) { + errored = true; + controller.error(err instanceof Error ? err : new Error(String(err))); + } + } finally { + await fd.close().catch(() => {}); + await cleanupTempPath(path); + if (signal) signal.removeEventListener("abort", onAbort); + } + }, + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/open-sse/services/quotaResetParsing.ts b/open-sse/services/quotaResetParsing.ts new file mode 100644 index 0000000000..b3606b02a2 --- /dev/null +++ b/open-sse/services/quotaResetParsing.ts @@ -0,0 +1,44 @@ +import { looksLikeQuotaExhausted } from "../../src/shared/utils/classify429"; +import { getProviderCategory } from "../config/providerRegistry.ts"; + +/** + * Issue #6638 — Ollama Cloud (and any other apikey-category provider) 429s + * skip body-text quota classification by default: a bare 429 usually just + * means "too many requests/min" for these providers, so a short exponential + * backoff applies instead of the long cooldown reserved for genuine + * daily/monthly/weekly quota exhaustion. + * + * That default is correct for plain rate limiting, but it must not swallow + * an EXPLICIT quota-exhausted signal in the body (see `looksLikeQuotaExhausted` + * / QUOTA_PATTERNS) — otherwise the account looks "available" again seconds + * after a multi-day quota was exhausted, and combo routing retries it right + * away (the reported symptom). OAuth-category providers always preserve + * quota signals; apikey-category providers only do when the body explicitly + * says a long-period cap was hit. + */ +export function shouldPreserveQuotaSignals( + provider: string | null | undefined, + errorText?: string | null +): boolean { + if (!provider) return true; + if (getProviderCategory(provider) === "oauth") return true; + return Boolean(errorText) && looksLikeQuotaExhausted(errorText); +} + +/** + * Parse a day-granularity quota reset countdown ("Your quota will reset in + * 3 days.", "Resets in 13 days") out of an upstream 429 body. + * + * Companion to the Xh/Ym/Zs countdown parsing already handled inline by + * `parseRetryFromErrorText` — none of those patterns match when the upstream + * expresses the reset window in whole days rather than hours/minutes/seconds, + * so a multi-day quota reset previously parsed to `null` and fell back to the + * engine's ~seconds-scale default cooldown. + */ +export function parseDayGranularityResetMs(msg: string, maxMs: number): number | null { + const dayMatch = /reset(?:s)?\s+in\s+(\d+)\s*day(?:s)?/i.exec(msg); + if (!dayMatch) return null; + const days = Number.parseInt(dayMatch[1], 10); + if (!Number.isFinite(days) || days <= 0) return null; + return Math.min(days * 24 * 3600 * 1000, maxMs); +} diff --git a/open-sse/services/quotaTextCooldowns.ts b/open-sse/services/quotaTextCooldowns.ts new file mode 100644 index 0000000000..0146d72ea5 --- /dev/null +++ b/open-sse/services/quotaTextCooldowns.ts @@ -0,0 +1,105 @@ +/** + * Text-based quota-exhaustion classifiers for the account-fallback engine. + * + * Extracted out of `accountFallback.ts` (frozen at its file-size-baseline + * cap — see `config/quality/file-size-baseline.json`) so a new quota-text + * signal (Issue #3709) could be added without growing that file. These are + * pure functions with no dependency back on `accountFallback.ts`, so there + * is no circular import (`npm run check:cycles`). + * + * @module services/quotaTextCooldowns + */ + +import { RateLimitReason } from "../config/constants.ts"; + +type RateLimitReasonValue = (typeof RateLimitReason)[keyof typeof RateLimitReason]; + +export interface QuotaTextFallback { + shouldFallback: true; + cooldownMs: number; + reason: RateLimitReasonValue; + usedUpstreamRetryHint?: boolean; + quotaResetHintMs?: number; +} + +// ─── Issue #2321 — Subscription (5h) usage-limit text ────────────────────── +// +// Anthropic OAuth (Claude Pro/Team) returns 429 with "Usage Limit Reached" +// for the 5-hour subscription quota. Without a dedicated branch the request +// falls through to the generic 429 retry path (~5s base cooldown). + +export function isSubscriptionQuotaText(lower: string): boolean { + return ( + lower.includes("usage limit reached") || + lower.includes("usage limit has been") || + lower.includes("claude pro usage limit") || + lower.includes("you've reached your usage limit") || + lower.includes("you have reached your usage limit") + ); +} + +const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour + +/** + * Builds the QUOTA_EXHAUSTED fallback for the subscription-quota text above. + * Honor upstream Retry-After / reset hints only when the caller's profile + * enables them (via `getUpstreamRetryHintMs`); otherwise apply a local 1h + * cooldown so all Pro accounts on the same subscription tier stop cycling + * through tight retries. (We deliberately do not use COOLDOWN_MS.paymentRequired + * — that constant is 2 minutes, shorter than the recovery time of a + * subscription quota.) + * + * `getUpstreamRetryHintMs`/`parseRetryFromErrorText` are injected by the + * caller (accountFallback.ts) to avoid importing back into that file. + */ +export function buildSubscriptionQuotaFallback( + errorStr: string, + getUpstreamRetryHintMs: () => number | null, + parseRetryFromErrorText: (text: string) => number | null +): QuotaTextFallback | null { + if (!isSubscriptionQuotaText(errorStr.toLowerCase())) return null; + const hintMs = getUpstreamRetryHintMs(); + const bodyHint = parseRetryFromErrorText(errorStr); + return { + shouldFallback: true, + cooldownMs: hintMs ?? SUBSCRIPTION_QUOTA_COOLDOWN_MS, + reason: RateLimitReason.QUOTA_EXHAUSTED, + usedUpstreamRetryHint: Boolean(hintMs), + quotaResetHintMs: bodyHint ?? undefined, + }; +} + +// ─── Issue #3709 — Ollama Cloud weekly usage cap ─────────────────────────── +// +// Ollama Cloud free-tier accounts have a hard WEEKLY request cap. On cap the +// upstream returns 429 "you () have reached your weekly usage +// limit". ollama-cloud is an apikey-category provider (not oauth), so the +// `shouldUseQuotaSignal` gate in `checkFallbackError` (oauth-only) skips the +// subscription-quota-text branch above for its 429s — without a dedicated, +// ungated check the account fell through to the generic 429 backoff +// (~1s, capped at 2min) and got retried every few minutes for the rest of +// the week (one account took 285x429 in 48h — issue #3709). +// +// The exact weekly reset anchor (UTC Monday? rolling 7d from first request?) +// is not publicly documented by Ollama, so this uses a fixed 24h cooldown — +// short enough to recover promptly once the real window resets, long enough +// to stop the every-5-minute retry storm. The phrase match is generic (not +// ollama-specific), so any other provider using the same wording benefits. +const WEEKLY_QUOTA_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours + +export function isWeeklyUsageLimitText(lower: string): boolean { + return ( + lower.includes("weekly usage limit") || + lower.includes("weekly limit reached") || + lower.includes("reached your weekly") + ); +} + +export function buildWeeklyQuotaFallback(errorStr: string): QuotaTextFallback | null { + if (!isWeeklyUsageLimitText(errorStr.toLowerCase())) return null; + return { + shouldFallback: true, + cooldownMs: WEEKLY_QUOTA_COOLDOWN_MS, + reason: RateLimitReason.QUOTA_EXHAUSTED, + }; +} diff --git a/open-sse/services/reasoningTokenBuffer.ts b/open-sse/services/reasoningTokenBuffer.ts index bbb67da335..8cce846d14 100644 --- a/open-sse/services/reasoningTokenBuffer.ts +++ b/open-sse/services/reasoningTokenBuffer.ts @@ -1,4 +1,7 @@ -import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts"; +import { + getExplicitModelOutputCap, + getResolvedModelCapabilities, +} from "../../src/lib/modelCapabilities.ts"; /** * Below this caller-supplied `max_tokens`, the request is treated as a probe @@ -34,17 +37,14 @@ export function resolveReasoningBufferedMaxTokens( const capabilities = getResolvedModelCapabilities(modelStr); if (capabilities.supportsThinking !== true) return null; - const maxOutputTokens = toPositiveInteger(capabilities.maxOutputTokens); + const maxOutputTokens = toPositiveInteger(getExplicitModelOutputCap(modelStr)); if (maxOutputTokens === null) return null; if (current > maxOutputTokens) return maxOutputTokens; - if (current === maxOutputTokens) return current; // Issue #6274: a tiny explicit budget is a capability probe, not a reasoning // request. Respect it verbatim instead of inflating (e.g. 1 -> 1001). if (current < REASONING_BUFFER_MIN_TRIGGER) return current; const buffered = Math.max(current + 1000, Math.ceil(current * 1.5)); - if (buffered > maxOutputTokens) return current; - - return buffered; + return buffered > maxOutputTokens ? current : buffered; } diff --git a/open-sse/services/rotationConfig.ts b/open-sse/services/rotationConfig.ts new file mode 100644 index 0000000000..42c47a92e9 --- /dev/null +++ b/open-sse/services/rotationConfig.ts @@ -0,0 +1,390 @@ +/** + * Runtime rotation configuration. + * + * OmniRoute's account-fallback engine (accountFallback.ts) historically rotated accounts using + * only hardcoded constants (COOLDOWN_MS / BACKOFF_CONFIG / ERROR_RULES): every retryable error + * cooled the account down immediately, on a fixed exponential backoff, with no operator control. + * + * A front-end/orchestrator (e.g. the VibeProxy desktop app) that manages the SAME set of accounts + * needs the backend to rotate according to the operator's own rules. This module exposes those + * rules as a runtime config, sourced from environment variables (so a supervising process can set + * them per launch) with an optional per-connection override (read from a connection's + * `providerSpecificData.rotationOverrides`). + * + * Config surface (all optional — defaults preserve the pre-existing engine behavior): + * - master enable OMNIROUTE_ROTATION_ENABLED (default true) + * - rate-limit reset/cooldown seconds OMNIROUTE_ROTATION_RATE_LIMIT_RESET_SECONDS (0 => engine default) + * - per-status fallback enable OMNIROUTE_ROTATE_ON_{429,500,502,400} (429/500/502 default true, 400 default false) + * - per-status threshold (errors in window) OMNIROUTE_ROTATE_{status}_THRESHOLD (default 1 => immediate, current behavior) + * - per-status window seconds OMNIROUTE_ROTATE_{status}_WINDOW_SECONDS (default 120) + * + * Everything here is a pure function or a small in-memory sliding-window counter — no DB / IO on + * the hot path — so it is cheap to consult per request and trivially unit-testable. + */ + +import { RateLimitReason } from "../config/constants.ts"; +import { COOLDOWN_MS } from "../config/errorConfig.ts"; + +export interface RotationErrorClassConfig { + enabled: boolean; + /** Number of errors of this class (within the window) required before an account is rotated. */ + threshold: number; + /** Sliding window (ms) over which errors are counted. */ + windowMs: number; +} + +export interface RotationConfig { + /** Master switch. When false, none of the configurable error classes trigger account fallback. */ + enabled: boolean; + /** Cooldown (ms) applied to a rate-limited account when the upstream gives no explicit hint. 0 => engine default. */ + rateLimitResetMs: number; + /** Mirror of the front-end "don't tag as rate-limited without a reset time" preference. */ + disableTagWithoutReset: boolean; + rateLimit429: RotationErrorClassConfig; + serverError500: RotationErrorClassConfig; + badGateway502: RotationErrorClassConfig; + badRequest400: RotationErrorClassConfig; +} + +const GLOBAL_KEY = "__omniroute_rotation_config__"; +const DEFAULT_WINDOW_MS = 120_000; + +function envBool(name: string, dflt: boolean): boolean { + const raw = process.env[name]; + if (raw === undefined || raw === null || raw === "") return dflt; + const v = raw.trim().toLowerCase(); + if (v === "true" || v === "1" || v === "yes" || v === "on") return true; + if (v === "false" || v === "0" || v === "no" || v === "off") return false; + return dflt; +} + +function envInt(name: string, dflt: number, min = 0): number { + const raw = process.env[name]; + if (raw === undefined || raw === null || raw === "") return dflt; + const n = Number.parseInt(raw.trim(), 10); + if (!Number.isFinite(n)) return dflt; + return Math.max(min, n); +} + +function buildClass( + enableEnv: string, + thresholdEnv: string, + windowEnv: string, + enableDefault: boolean +): RotationErrorClassConfig { + return { + enabled: envBool(enableEnv, enableDefault), + threshold: envInt(thresholdEnv, 1, 1), + windowMs: envInt(windowEnv, DEFAULT_WINDOW_MS / 1000, 1) * 1000, + }; +} + +function buildFromEnv(): RotationConfig { + return { + enabled: envBool("OMNIROUTE_ROTATION_ENABLED", true), + rateLimitResetMs: envInt("OMNIROUTE_ROTATION_RATE_LIMIT_RESET_SECONDS", 0, 0) * 1000, + disableTagWithoutReset: envBool("OMNIROUTE_ROTATION_DISABLE_TAG_WITHOUT_RESET", true), + rateLimit429: buildClass( + "OMNIROUTE_ROTATE_ON_429", + "OMNIROUTE_ROTATE_429_THRESHOLD", + "OMNIROUTE_ROTATE_429_WINDOW_SECONDS", + true + ), + serverError500: buildClass( + "OMNIROUTE_ROTATE_ON_500", + "OMNIROUTE_ROTATE_500_THRESHOLD", + "OMNIROUTE_ROTATE_500_WINDOW_SECONDS", + true + ), + badGateway502: buildClass( + "OMNIROUTE_ROTATE_ON_502", + "OMNIROUTE_ROTATE_502_THRESHOLD", + "OMNIROUTE_ROTATE_502_WINDOW_SECONDS", + true + ), + badRequest400: buildClass( + "OMNIROUTE_ROTATE_ON_400", + "OMNIROUTE_ROTATE_400_THRESHOLD", + "OMNIROUTE_ROTATE_400_WINDOW_SECONDS", + false + ), + }; +} + +/** + * The global (env-derived) rotation config, parsed once and cached on `globalThis` so the Next.js + * app-route module graph and the startup graph share one instance (same pattern as the other + * runtime-config singletons in this codebase). + */ +export function getGlobalRotationConfig(): RotationConfig { + const g = globalThis as Record; + let cfg = g[GLOBAL_KEY] as RotationConfig | undefined; + if (!cfg) { + cfg = buildFromEnv(); + g[GLOBAL_KEY] = cfg; + } + return cfg; +} + +/** Test/reset hook: clears the cached global config so the next read re-parses env. */ +export function resetGlobalRotationConfigForTest(): void { + const g = globalThis as Record; + delete g[GLOBAL_KEY]; + clearRotationErrorCounters(); +} + +function coerceBool(v: unknown, dflt: boolean): boolean { + if (typeof v === "boolean") return v; + if (typeof v === "string") return envBoolFromString(v, dflt); + return dflt; +} + +function envBoolFromString(v: string, dflt: boolean): boolean { + const s = v.trim().toLowerCase(); + if (s === "true" || s === "1" || s === "yes" || s === "on") return true; + if (s === "false" || s === "0" || s === "no" || s === "off") return false; + return dflt; +} + +function coerceInt(v: unknown, dflt: number, min = 0): number { + const n = typeof v === "number" ? v : typeof v === "string" ? Number.parseInt(v, 10) : NaN; + if (!Number.isFinite(n)) return dflt; + return Math.max(min, Math.floor(n)); +} + +/** + * Merges a connection's per-connection overrides (from + * `providerSpecificData.rotationOverrides`) over the global env config. Any absent override key + * inherits the global value. Returns the global config unchanged when there are no overrides. + */ +export function resolveRotationConfig(overrides?: Record | null): RotationConfig { + const base = getGlobalRotationConfig(); + if (!overrides || typeof overrides !== "object") return base; + + const cls = ( + src: RotationErrorClassConfig, + enableKey: string, + thrKey: string, + winKey: string + ): RotationErrorClassConfig => ({ + enabled: enableKey in overrides ? coerceBool(overrides[enableKey], src.enabled) : src.enabled, + threshold: thrKey in overrides ? coerceInt(overrides[thrKey], src.threshold, 1) : src.threshold, + windowMs: + winKey in overrides ? coerceInt(overrides[winKey], src.windowMs / 1000, 1) * 1000 : src.windowMs, + }); + + return { + enabled: base.enabled, + rateLimitResetMs: + "rateLimitResetSeconds" in overrides + ? coerceInt(overrides.rateLimitResetSeconds, base.rateLimitResetMs / 1000, 0) * 1000 + : base.rateLimitResetMs, + disableTagWithoutReset: base.disableTagWithoutReset, + rateLimit429: cls(base.rateLimit429, "rotateOn429", "error429Threshold", "error429WindowSeconds"), + serverError500: cls(base.serverError500, "rotateOn500", "error500Threshold", "error500WindowSeconds"), + badGateway502: cls(base.badGateway502, "rotateOn502", "error502Threshold", "error502WindowSeconds"), + badRequest400: cls(base.badRequest400, "rotateOn400", "error400Threshold", "error400WindowSeconds"), + }; +} + +/** Maps an HTTP status to its configured error class (or null for statuses this config doesn't gate). */ +export function classForStatus(status: number, cfg: RotationConfig): RotationErrorClassConfig | null { + if (status === 429) return cfg.rateLimit429; + if (status === 502) return cfg.badGateway502; + if (status >= 500 && status < 600) return cfg.serverError500; + if (status === 400) return cfg.badRequest400; + return null; // 401/402/403/404/… are not gated by this config +} + +/** + * True when the operator config should BLOCK account fallback for this status. + * + * This is RESTRICTIVE and applies only to the default-enabled classes (429 / 502 / other 5xx): + * when the operator disables one, fallback for it is blocked (the error returns to the client + * instead of rotating). 400 is NEVER restrictively blocked here — it is handled additively by + * {@link shouldForceFallbackFor400}, so the engine's existing 400 behavior (a 400 carrying + * rate-limit/quota text still falls over; a plain malformed 400 does not) is fully preserved. + * Statuses this config does not gate (401/403/404/…) are never blocked. + */ +export function isFallbackBlockedForStatus(status: number, cfg: RotationConfig): boolean { + const c = classForStatus(status, cfg); + if (c === null) return false; // ungated statuses: engine default + if (c === cfg.badRequest400) return false; // 400 is additive, never restrictively blocked + if (!cfg.enabled) return true; // master off blocks the gated 429/500/502 classes + return !c.enabled; // per-class disable +} + +/** True when the operator opted IN to rotating on a 400 (bad request) — off by default. */ +export function shouldForceFallbackFor400(status: number, cfg: RotationConfig): boolean { + return status === 400 && cfg.enabled && cfg.badRequest400.enabled; +} + +/** Rate-limit cooldown override (ms) or null to use the engine default. */ +export function rateLimitCooldownOverrideMs(cfg: RotationConfig): number | null { + return cfg.rateLimitResetMs > 0 ? cfg.rateLimitResetMs : null; +} + +// ── Sliding-window per-key error counter (for threshold-based fallback) ────────────────────── + +const COUNTER_KEY = "__omniroute_rotation_counters__"; + +function counters(): Map { + const g = globalThis as Record; + let m = g[COUNTER_KEY] as Map | undefined; + if (!m) { + m = new Map(); + g[COUNTER_KEY] = m; + } + return m; +} + +export function clearRotationErrorCounters(): void { + counters().clear(); +} + +/** + * Records an error for (key, status) and returns true when the number of errors within the class + * window reaches the configured threshold (i.e. the account should now be rotated). When the + * threshold is 1 (default) this returns true on the first error — preserving the engine's + * historical "rotate immediately" behavior. `nowMs` is injectable for tests. + */ +export function recordErrorAndCheckThreshold( + key: string, + status: number, + cfg: RotationConfig, + nowMs: number = Date.now() +): boolean { + const cls = classForStatus(status, cfg); + if (cls === null) return true; // not gated => defer to engine (treat as immediate) + if (cls.threshold <= 1) return true; // immediate rotation (historical behavior) + + const bucketKey = `${key}::${status}`; + const list = counters().get(bucketKey) ?? []; + const windowStart = nowMs - cls.windowMs; + const pruned = list.filter((ts) => ts >= windowStart); + pruned.push(nowMs); + counters().set(bucketKey, pruned); + + if (pruned.length >= cls.threshold) { + counters().delete(bucketKey); // reset after reaching the threshold + return true; + } + return false; +} + +// ── accountFallback.ts integration helpers ────────────────────────────────────────────────── +// These encapsulate the "runtime rotation config" glue that `checkFallbackError` / +// `applyErrorState` (open-sse/services/accountFallback.ts, a size-frozen file) consult before +// falling back to their own hardcoded heuristics. Keeping the glue here (rather than inline in +// accountFallback.ts) keeps that file's line budget stable as this config surface grows. + +export interface RotationGateDecision { + shouldFallback: boolean; + cooldownMs: number; + baseCooldownMs?: number; + newBackoffLevel?: number; + reason?: string; +} + +/** + * Evaluates the runtime rotation config gate for a given status BEFORE the engine's own error + * classification runs. Returns a decision that should short-circuit `checkFallbackError` + * (block fallback, hold pending threshold/window, or force-fallback an opted-in 400), or `null` + * when the engine should proceed with its normal heuristics. + */ +export function evaluateRotationGate( + status: number, + rotationCfg: RotationConfig, + rotationKey?: string | null +): RotationGateDecision | null { + if (isFallbackBlockedForStatus(status, rotationCfg)) { + return { shouldFallback: false, cooldownMs: 0, reason: RateLimitReason.UNKNOWN }; + } + if ( + rotationKey && + classForStatus(status, rotationCfg) && + !recordErrorAndCheckThreshold(rotationKey, status, rotationCfg) + ) { + return { shouldFallback: false, cooldownMs: 0, reason: RateLimitReason.UNKNOWN }; + } + if (shouldForceFallbackFor400(status, rotationCfg)) { + const overrideMs = rateLimitCooldownOverrideMs(rotationCfg); + const cooldownMs = overrideMs ?? COOLDOWN_MS.rateLimit; + return { + shouldFallback: true, + cooldownMs, + baseCooldownMs: cooldownMs, + newBackoffLevel: 0, + reason: RateLimitReason.RATE_LIMIT_EXCEEDED, + }; + } + return null; +} + +export interface RotationRateLimitFallback { + shouldFallback: true; + cooldownMs: number; + baseCooldownMs: number; + newBackoffLevel: 0; + usedUpstreamRetryHint: false; + reason: string; +} + +/** + * Operator-configured rate-limit cooldown override (no upstream retry hint available). Applies + * only to the rate-limit reason so 5xx / capacity errors keep their scaled exponential backoff. + * Returns `null` when the reason isn't rate-limit or no override is configured, in which case + * the caller should fall through to its own scaled-backoff calculation. + */ +export function rotationRateLimitFallback( + reason: string, + rotationCfg: RotationConfig +): RotationRateLimitFallback | null { + if (reason !== RateLimitReason.RATE_LIMIT_EXCEEDED) return null; + const overrideMs = rateLimitCooldownOverrideMs(rotationCfg); + if (overrideMs === null) return null; + return { + shouldFallback: true, + cooldownMs: overrideMs, + baseCooldownMs: overrideMs, + newBackoffLevel: 0, + usedUpstreamRetryHint: false, + reason, + }; +} + +/** Combines extractRotationContext + resolveRotationConfig + evaluateRotationGate for an account. */ +export function gateFor(status: number, account?: unknown): RotationGateDecision | null { + const { rotationOverrides, rotationKey } = extractRotationContext(account); + return evaluateRotationGate(status, resolveRotationConfig(rotationOverrides), rotationKey); +} + +/** Combines extractRotationContext + resolveRotationConfig + rotationRateLimitFallback for an account. */ +export function overrideFor(reason: string, account?: unknown): RotationRateLimitFallback | null { + const { rotationOverrides } = extractRotationContext(account); + return rotationRateLimitFallback(reason, resolveRotationConfig(rotationOverrides)); +} + +/** + * Extracts a connection's per-connection rotation overrides and rotation key from its account + * state (`providerSpecificData.rotationOverrides` and `id`). Both are optional — absent => + * global env config / count-immediately. `account` is typed `unknown` here because callers pass + * a generic `AccountState`-shaped value; this only does structural checks, no behavior change. + */ +export function extractRotationContext(account: unknown): { + rotationOverrides: Record | null; + rotationKey: string | null; +} { + const rec = account && typeof account === "object" ? (account as Record) : null; + const psd = rec ? rec["providerSpecificData"] : undefined; + const rotationOverrides = + psd && + typeof psd === "object" && + (psd as Record).rotationOverrides && + typeof (psd as Record).rotationOverrides === "object" + ? ((psd as Record).rotationOverrides as Record) + : null; + const id = rec ? rec["id"] : undefined; + const rotationKey = typeof id === "string" && id.length > 0 ? id : null; + return { rotationOverrides, rotationKey }; +} diff --git a/open-sse/services/usage/kiro.ts b/open-sse/services/usage/kiro.ts index b96b5041ca..6ed5f72ed6 100644 --- a/open-sse/services/usage/kiro.ts +++ b/open-sse/services/usage/kiro.ts @@ -13,6 +13,11 @@ import { toRecord, toNumber } from "./scalars.ts"; import { type UsageQuota, parseResetTime } from "./quota.ts"; +import { + discoverKiroProfileArnAcrossRegions, + kiroRuntimeHost, + resolveKiroRuntimeRegion, +} from "../kiroRegion.ts"; import { isExternalIdpAuthMethod, KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER, @@ -158,10 +163,20 @@ export async function discoverKiroProfileArn( } /** - * The three GetUsageLimits attempts (regional GET, CodeWhisperer POST, Q GET) tried in + * The three GetUsageLimits attempts (CodeWhisperer POST, regional GET, Q GET) tried in * order by getKiroUsage — extracted so the auth-method header variants (api_key * `tokentype`, external_idp `TokenType`) stay in one authHeaders object and the parent * function stays under the function-length gate. + * + * The POST variant (x-amz-json-1.0 + `x-amz-target: ...GetUsageLimits`) is tried FIRST: it + * is the canonical AWS JSON-RPC shape used everywhere else in the Kiro integration + * (discoverKiroProfileArn's ListAvailableProfiles call, kiroModels.ts fingerprinting) and, + * critically, it is the only variant whose URL is built from the RUNTIME-region-resolved + * `usageBaseUrl` (see resolveKiroRuntimeRegion in getKiroUsage) with the profileArn in the + * payload — required for cross-region IAM Identity Center accounts (#6099) where the profile + * lives in a different region than the IdC/token region. The two GET variants are + * best-effort fallbacks (added for #6587 API-key auth) for accounts/regions where the POST + * shape is rejected. */ function buildKiroUsageAttempts(opts: { authHeaders: Record; @@ -173,18 +188,6 @@ function buildKiroUsageAttempts(opts: { }): Array<{ name: string; run: () => Promise }> { const { authHeaders, usageParams, qParams, payload, usageBaseUrl, qBaseUrl } = opts; return [ - { - name: "codewhisperer-get", - run: () => - fetch(`${CODEWHISPERER_BASE_URL}/getUsageLimits?${usageParams.toString()}`, { - method: "GET", - headers: { - ...authHeaders, - "x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE", - "user-agent": "aws-sdk-js/1.0.0 KiroIDE", - }, - }), - }, { name: "codewhisperer-post", run: () => @@ -198,6 +201,18 @@ function buildKiroUsageAttempts(opts: { body: JSON.stringify(payload), }), }, + { + name: "codewhisperer-get", + run: () => + fetch(`${CODEWHISPERER_BASE_URL}/getUsageLimits?${usageParams.toString()}`, { + method: "GET", + headers: { + ...authHeaders, + "x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE", + "user-agent": "aws-sdk-js/1.0.0 KiroIDE", + }, + }), + }, { name: "q-get", run: () => @@ -209,27 +224,6 @@ function buildKiroUsageAttempts(opts: { ]; } -/** - * Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and - * endpoint must all match the region. Derive the region from the stored region (preferred) - * or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the - * legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions). - */ -function resolveKiroUsageEndpoints(providerSpecificData?: JsonRecord, profileArn?: string) { - const regionFromArn = profileArn - ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] - : undefined; - const region = - (typeof providerSpecificData?.region === "string" && - providerSpecificData.region.trim().toLowerCase()) || - regionFromArn || - "us-east-1"; - const usageBaseUrl = - region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`; - const qBaseUrl = `https://q.${region}.amazonaws.com`; - return { region, usageBaseUrl, qBaseUrl }; -} - /** * Base auth headers for the usage endpoints, per auth method: long-lived API keys add * `tokentype: API_KEY`; enterprise / Microsoft Entra (external_idp) org accounts require @@ -308,22 +302,31 @@ export async function getKiroUsage(accessToken?: string, providerSpecificData?: ? providerSpecificData.profileArn : undefined; - const { region, usageBaseUrl, qBaseUrl } = resolveKiroUsageEndpoints( - providerSpecificData, - profileArn - ); + const storedRegion = + typeof providerSpecificData?.region === "string" + ? providerSpecificData.region.trim().toLowerCase() + : undefined; - // IAM Identity Center logins and kiro-cli imports frequently don't persist a profileArn, which - // previously caused the quota card to show nothing ("0 used"). Discover it on demand from - // ListAvailableProfiles (region-matched) so usage still resolves for those accounts. + // Enterprise IAM Identity Center logins and kiro-cli imports frequently don't persist a + // profileArn. Discover it by probing the Q Developer PROFILE regions (us-east-1 / eu-central-1) + // — NOT the IdC/token region. An IdC in eu-north-1 has no Q runtime host (q.eu-north-1 does not + // exist); its profile lives in eu-central-1 (or us-east-1) and the SSO token works cross-region + // against it. Without this, the quota card previously showed nothing ("no limits") for such + // accounts because the single-region lookup at q.{idcRegion} always failed. if (!profileArn && accessToken) { - profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region, authMethod); + profileArn = await discoverKiroProfileArnAcrossRegions(accessToken, storedRegion); } if (!profileArn && !isApiKey) { return { message: "Kiro connected. Profile ARN not available for quota tracking." }; } + // The RUNTIME region is the profileArn region (us-east-1 / eu-central-1), never the IdC token + // region. Route GetUsageLimits to that region's host so quota resolves for cross-region IdC. + const region = resolveKiroRuntimeRegion({ region: storedRegion, profileArn }); + const usageBaseUrl = region === "us-east-1" ? CODEWHISPERER_BASE_URL : kiroRuntimeHost(region); + const qBaseUrl = `https://q.${region}.amazonaws.com`; + const authHeaders = buildKiroAuthHeaders(accessToken, isApiKey, providerSpecificData); const usageParams = new URLSearchParams({ @@ -342,7 +345,7 @@ export async function getKiroUsage(accessToken?: string, providerSpecificData?: resourceType: "AGENTIC_REQUEST", }; -const attempts = buildKiroUsageAttempts({ + const attempts = buildKiroUsageAttempts({ authHeaders, usageParams, qParams, diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 5fd500b2da..7f45cf329d 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -172,17 +172,35 @@ export function convertOpenAIContentToParts(content: unknown): JsonRecord[] { // 3. Handle raw data strings (e.g. {"type": "file", "data": "JVBER...", "mime_type": "..."}). // Also accept the Responses-API shape {"type":"input_file","file_data":"JVBER...","filename":...} - // so PDFs sent as `input_file` reach Gemini instead of being silently dropped (#2515). + // AND the OpenAI Chat Completions shape + // {"type":"file","file":{"filename":...,"file_data":"data:;base64,..."}} so PDFs and + // videos reach Gemini instead of being silently dropped (#2515). Gemini reads + // application/pdf and video/* natively via inlineData, exactly like images. const file = toRecord(rec.file); const doc = toRecord(rec.document); - const rawDataStr = rec.data || rec.file_data || file?.data || doc?.data; - const mimeTypeFallback = - rec.mime_type || rec.media_type || file?.mime_type || doc?.mime_type || "application/pdf"; + const rawDataStr = + rec.data || rec.file_data || file?.data || file?.file_data || doc?.data || doc?.file_data; if (typeof rawDataStr === "string" && !rawDataStr.startsWith("http")) { + // Prefer the mime embedded in the data: URI (e.g. application/pdf, video/mp4) so + // documents and videos are not mislabeled as the fallback; the fallback applies + // only to bare base64 that carries no data: prefix. + let mimeType = + rec.mime_type || + rec.media_type || + file?.mime_type || + doc?.mime_type || + "application/pdf"; + if (rawDataStr.startsWith("data:")) { + const commaIndex = rawDataStr.indexOf(","); + if (commaIndex !== -1) { + const parsedMime = rawDataStr.substring(5, commaIndex).split(";")[0]; + if (parsedMime) mimeType = parsedMime; + } + } const rawData = rawDataStr.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""); parts.push({ inlineData: { - mimeType: String(mimeTypeFallback), + mimeType: String(mimeType), data: rawData, }, }); diff --git a/open-sse/translator/paramSupport.ts b/open-sse/translator/paramSupport.ts index 3b42fc4f2e..974e1ef311 100644 --- a/open-sse/translator/paramSupport.ts +++ b/open-sse/translator/paramSupport.ts @@ -6,19 +6,29 @@ // - `provider` (optional) limits the rule to a single provider id. // - `match` is a RegExp tested against the model id OR a predicate (model -> boolean). // - `drop` is the list of param keys to remove when the rule fires. +// - `clampToModelMaxOutput` clamps max_tokens/max_completion_tokens/max_output_tokens +// down to the model's catalog `maxOutputTokens` ceiling, when one is set. +// - `maxOutputCap` clamps the same keys down to a fixed endpoint-imposed ceiling +// (independent of the model's own advertised ceiling). When both are present on +// the same rule, the lower of the two wins. // // A param is removed only when it is present (!== undefined). The helper never // introduces new keys and never throws on null/undefined bodies — call sites // can chain it without extra guards. import { getParamFilterConfig, ModelParamFilter, ProviderParamFilter } from "@/lib/db/paramFilters"; +import { getProviderModel } from "../config/providerModels.ts"; type StripRule = { provider?: string; match: RegExp | ((model: string) => boolean); - drop: string[]; + drop?: string[]; + clampToModelMaxOutput?: boolean; + maxOutputCap?: number; }; +const MAX_OUTPUT_TOKEN_KEYS = ["max_tokens", "max_completion_tokens", "max_output_tokens"] as const; + const STRIP_RULES: StripRule[] = [ // claude-opus-4 series: temperature is deprecated (Anthropic returns 400). #1748 { match: /claude-opus-4/i, drop: ["temperature"] }, @@ -40,12 +50,56 @@ const STRIP_RULES: StripRule[] = [ // (format:"openai") does not accept the Claude-style `thinking` body field // and returns 400 "Unsupported parameter(s): thinking". Upstream #2268. { provider: "nvidia", match: /minimax-m2\.7/i, drop: ["thinking"] }, + // VolcEngine Ark caps the Kimi coding-plan endpoint at max_tokens <= 32768 + // server-side ("integer above maximum value, expected a value <= 32768"), + // independent of the model's own catalog ceiling. Confirmed against two + // independent live-endpoint reports hitting the same Ark endpoint for both + // kimi-k2.5 and kimi-k2.7-code (NousResearch/hermes-agent#51773, + // MoonshotAI/kimi-cli#1124), and by upstream decolua/9router#2460. Scoped to + // OmniRoute's actual volcengine Kimi id (not a broad /kimi/i regex) so it + // never clamps an unrelated future Kimi listing whose Ark cap may differ. + { provider: "volcengine", match: /^kimi-k2-5-260127$/, maxOutputCap: 32768, clampToModelMaxOutput: true }, ]; function matches(rule: StripRule, model: string): boolean { return typeof rule.match === "function" ? rule.match(model) : rule.match.test(model); } +/** + * When a rule requests it, clamp the max-output-token family of params down to + * the lowest applicable ceiling: the model's own catalog `maxOutputTokens` + * (`clampToModelMaxOutput`) and/or a fixed endpoint cap (`maxOutputCap`). Only + * clamps keys that are present and numeric; never introduces a new key. + */ +function applyMaxOutputClamp( + rule: StripRule, + provider: string | null | undefined, + model: string, + body: Record +): void { + if (!rule.clampToModelMaxOutput && !Number.isFinite(rule.maxOutputCap)) return; + + const candidates: number[] = []; + if (rule.clampToModelMaxOutput) { + const modelCeiling = getProviderModel(provider ?? "", model)?.maxOutputTokens; + if (Number.isFinite(modelCeiling) && (modelCeiling as number) > 0) { + candidates.push(modelCeiling as number); + } + } + if (Number.isFinite(rule.maxOutputCap) && (rule.maxOutputCap as number) > 0) { + candidates.push(rule.maxOutputCap as number); + } + if (candidates.length === 0) return; + + const ceiling = Math.min(...candidates); + for (const key of MAX_OUTPUT_TOKEN_KEYS) { + const value = body[key]; + if (typeof value === "number" && Number.isFinite(value) && value > ceiling) { + body[key] = ceiling; + } + } +} + /** * Remove unsupported params from `body` in place. Returns the same reference * (or `body` unchanged when it is not a plain object / model is empty). @@ -65,9 +119,10 @@ export function stripUnsupportedParams( for (const rule of STRIP_RULES) { if (rule.provider && rule.provider !== provider) continue; if (!matches(rule, model)) continue; - for (const key of rule.drop) { + for (const key of rule.drop ?? []) { if (rec[key] !== undefined) delete rec[key]; } + applyMaxOutputClamp(rule, provider, model, rec); } // Phase 2: Config-driven rules from DB diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 1c380b5f33..ff5dc4b6d5 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -363,7 +363,15 @@ export function openaiToClaudeRequest(model, body, stream) { if (body.tools && Array.isArray(body.tools)) { result.tools = body.tools .map((tool) => { - const toolData = tool.type === "function" && tool.function ? tool.function : tool; + // Function-shaped tools arrive in two flavors from real clients: + // (a) openai-spec: { type: "function", function: { name, ... } } + // (b) bare/loose: { function: { name, ... } } (no parent `type`) + // Unwrap `tool.function` whenever it is present, regardless of the + // parent `type` field — some OpenAI-shape clients omit the wrapper's + // `type: "function"` entirely. Previously that bare shape fell + // through to `toolData = tool` (the wrapper itself, with no `.name`), + // producing an empty `originalName` and silently dropping the tool. + const toolData = tool.function ?? tool; const originalName = typeof toolData.name === "string" ? toolData.name.trim() : ""; if (!originalName) { @@ -545,6 +553,36 @@ function getContentBlocksFromMessage( } else if (url.trim()) { blocks.push({ type: "image", source: { type: "url", url } }); } + } else if (part.type === "file" && (part.file?.file_data || part.file?.data)) { + // OpenAI Chat Completions file block: + // {type:"file", file:{filename, file_data:"data:;base64,..."}}. + // Map PDFs to a Claude document block and image mimes to an image block so the + // attachment reaches the model instead of being silently dropped. Claude has no + // native video input, so non-pdf/non-image files are skipped here. + const fileData = part.file.file_data || part.file.data; + const fmatch = + typeof fileData === "string" ? fileData.match(/^data:([^;]+);base64,(.+)$/) : null; + if (fmatch) { + const mediaType = fmatch[1]; + if (mediaType === "application/pdf") { + blocks.push({ + type: "document", + source: { type: "base64", media_type: mediaType, data: fmatch[2] }, + ...(part.file.filename ? { title: part.file.filename } : {}), + }); + } else if (mediaType.startsWith("image/")) { + blocks.push({ + type: "image", + source: { type: "base64", media_type: mediaType, data: fmatch[2] }, + }); + } + } else if (typeof fileData === "string" && /^https?:\/\//i.test(fileData)) { + blocks.push({ + type: "document", + source: { type: "url", url: fileData }, + ...(part.file.filename ? { title: part.file.filename } : {}), + }); + } } } } @@ -622,7 +660,12 @@ function getContentBlocksFromMessage( (b) => b.type === "thinking" || b.type === "redacted_thinking" ); const hasToolUseBlock = blocks.some((b) => b.type === "tool_use"); - if (msg.reasoning_content && thinkingEnabledForRequest && hasToolUseBlock && !hasThinkingBlock) { + if ( + msg.reasoning_content && + thinkingEnabledForRequest && + hasToolUseBlock && + !hasThinkingBlock + ) { blocks.unshift({ type: "redacted_thinking", data: DEFAULT_THINKING_CLAUDE_SIGNATURE, diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 703f2cbe10..2d3fb8811f 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -188,35 +188,47 @@ function openaiToGeminiBase( } // Thinking / Reasoning support (Google Gemini 2.0+ Thinking models) - // 1. OpenAI format: reasoning_effort (low/medium/high/auto/max/xhigh) - // "auto", "max", and "xhigh" are clamped to the high-tier budget because Gemini - // does not accept these strings directly. "auto" signals "use max reasonable effort" - // which maps to high. "max"/"xhigh" exceed Gemini's accepted range and are clamped. - // Port of decolua/9router#2043 by @nguyenxvotanminh3. - if (body.reasoning_effort) { - const highBudget = capThinkingBudget(model, 32768); - const budgetMap: Record = { - low: 1024, - medium: getDefaultThinkingBudget(model) || 8192, - high: highBudget, - auto: highBudget, - max: highBudget, - xhigh: highBudget, - }; - const budget = - budgetMap[body.reasoning_effort as string] ?? getDefaultThinkingBudget(model) ?? 8192; - result.generationConfig.thinkingConfig = { - thinkingBudget: budget, - includeThoughts: true, - }; - } - // 2. Claude format: thinking (type: enabled, budget_tokens) - const thinking = body.thinking as { type?: string; budget_tokens?: number } | undefined; - if (thinking?.type === "enabled" && thinking.budget_tokens) { - result.generationConfig.thinkingConfig = { - thinkingBudget: thinking.budget_tokens, - includeThoughts: true, - }; + // gemma-4 models return - 400: Thinking budget is not supported for this model. + // Mirrors the same guard in claude-to-gemini.ts. Port of the thinkingConfig + // guard half of decolua/9router#2480 (the signature-replay half of that PR + // is out of scope and not ported here). + if (model.startsWith("gemma-4")) { + // gemma-4 models returns - 400: Thinking budget is not supported for this model + } else { + // 1. OpenAI format: reasoning_effort (low/medium/high/auto/max/xhigh) + // "auto", "max", and "xhigh" are clamped to the high-tier budget because Gemini + // does not accept these strings directly. "auto" signals "use max reasonable effort" + // which maps to high. "max"/"xhigh" exceed Gemini's accepted range and are clamped. + // Port of decolua/9router#2043 by @nguyenxvotanminh3. + if (body.reasoning_effort) { + const highBudget = capThinkingBudget(model, 32768); + const budgetMap: Record = { + low: 1024, + medium: getDefaultThinkingBudget(model) || 8192, + high: highBudget, + auto: highBudget, + max: highBudget, + xhigh: highBudget, + }; + const budget = + budgetMap[body.reasoning_effort as string] ?? getDefaultThinkingBudget(model) ?? 8192; + result.generationConfig.thinkingConfig = { + thinkingBudget: budget, + includeThoughts: true, + }; + } + // 2. Claude format: thinking (type: enabled, budget_tokens) + // Use an explicit numeric check (not truthy) so an explicit `budget_tokens: 0` — the + // natural way to disable thinking — is honored as thinkingBudget 0 instead of being + // dropped and falling through to the default injection below (#6813). A zero budget + // yields no thoughts, so includeThoughts is only set for a non-zero budget. + const thinking = body.thinking as { type?: string; budget_tokens?: number } | undefined; + if (thinking?.type === "enabled" && typeof thinking.budget_tokens === "number") { + result.generationConfig.thinkingConfig = { + thinkingBudget: thinking.budget_tokens, + includeThoughts: thinking.budget_tokens !== 0, + }; + } } // 3. Default: all modern Gemini models (2.5+) have thinking capability. @@ -621,6 +633,7 @@ function wrapInCloudCodeEnvelope(model, cloudCodeRequest, credentials = null) { systemInstruction: cloudCodeRequest.systemInstruction, generationConfig: applyAntigravityGenerationDefaults(cloudCodeRequest.generationConfig), tools: cloudCodeRequest.tools, + safetySettings: cloudCodeRequest.safetySettings, }, model: cleanModel, userAgent: getAntigravityEnvelopeUserAgent(credentials), diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index d5d6d7f825..3da0a59340 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -5,12 +5,13 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { v4 as uuidv4, v5 as uuidv5 } from "uuid"; -import { capMaxOutputTokens, capThinkingBudget, supportsReasoning } from "@/lib/modelCapabilities"; +import { capMaxOutputTokens, capThinkingBudget } from "@/lib/modelCapabilities"; import { parseToolInput, normalizeKiroToolSchema, serializeToolResultContent, } from "./openai-to-kiro/messageHelpers.ts"; +import { supportsKiroAdaptiveThinking } from "./openai-to-kiro/adaptiveThinking.ts"; /** * Anthropic's direct-provider `[1m]` context-1m beta suffix. Kiro is AWS @@ -858,15 +859,14 @@ export function buildKiroPayload(model, body, stream, credentials) { // Thinking mode for Claude models on Kiro (ported from javargasm/pi-kiro). // Two coordinated signals steer reasoning on the CodeWhisperer surface: // 1. a `enabledN` - // directive prepended to the current user message — makes Claude emit its - // reasoning INLINE as ``, which the Kiro executor - // splits back into the OpenAI `reasoning_content` channel (kiroThinking.ts); + // directive prepended to the user message — makes Claude emit reasoning + // INLINE, split back into `reasoning_content` by the executor (kiroThinking.ts); // 2. top-level `additionalModelRequestFields` (output_config.effort + // thinking:{type:"adaptive"} + a clamped max_tokens), forwarded to AWS by - // the Kiro executor's transformRequest allowlist — this is the graded - // effort lever. Gated on models that advertise thinking support. + // the Kiro executor's transformRequest allowlist — the graded effort lever, + // gated on Kiro's adaptive-thinking allowlist (#6576), not supportsReasoning(). const requestedEffort = resolveKiroEffort(body) || (modelRequestsThinking ? "high" : ""); - const kiroEffort = supportsReasoning(normalizedModel) ? requestedEffort : ""; + const kiroEffort = supportsKiroAdaptiveThinking(normalizedModel) ? requestedEffort : ""; if (kiroEffort) { // `` / `` are Kiro/CodeWhisperer prompt // conventions (NOT Anthropic API params); the length is a soft hint (the hard diff --git a/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts b/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts new file mode 100644 index 0000000000..ba8fefdc97 --- /dev/null +++ b/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts @@ -0,0 +1,19 @@ +/** + * Kiro/AWS CodeWhisperer only accepts the adaptive-thinking + * `additionalModelRequestFields` envelope for a narrow allowlist of models — + * NOT the same set the generic Anthropic-API capability table + * (`supportsReasoning()` in `@/lib/modelCapabilities`) marks as + * thinking-capable. That table is correct for Anthropic's own API, but Kiro + * rejects the field for `claude-sonnet-4.5` and `claude-haiku-4.5` with a raw + * upstream 400 (`additionalModelRequestFields is not supported for this + * model`, issue #6576) even though both ARE thinking-capable on Anthropic's + * direct API. Only `claude-sonnet-5` is confirmed to accept the adaptive + * envelope on Kiro today — keep this allowlist in sync with + * `open-sse/config/providers/registry/kiro/index.ts` if Kiro's catalog or + * upstream behavior changes. + */ +const KIRO_ADAPTIVE_THINKING_MODELS = new Set(["claude-sonnet-5"]); + +export function supportsKiroAdaptiveThinking(normalizedModel: string): boolean { + return KIRO_ADAPTIVE_THINKING_MODELS.has(normalizedModel); +} diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index 2019c46100..f2c96bf137 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -1,5 +1,6 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; +import { isAbortFinishReason } from "../../utils/finishReason.ts"; /** * Direct Gemini → Claude response translator. @@ -178,6 +179,14 @@ export function geminiToClaudeResponse(chunk, state) { // reason has already been emitted to the client — this is unavoidable in // SSE streaming. Map to end_turn (Claude has no "content blocked" reason). stopReason = "end_turn"; + } else if (isAbortFinishReason(reason)) { + // Aborted/malformed tool call (e.g. MALFORMED_FUNCTION_CALL, + // UNEXPECTED_TOOL_CALL). Surface as tool_use rather than a clean end_turn + // so the client sees the turn did not complete normally. Same fix as the + // hub path (openai-to-claude.ts) — this direct Gemini→Claude translator is + // the one Claude Code hits through an antigravity/Gemini-routed model. + // Port of decolua/9router#2462 by @anhdiepmmk. + stopReason = "tool_use"; } else { stopReason = "end_turn"; } diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 9d8a8629fe..464a3feee3 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -729,6 +729,12 @@ export function geminiToOpenAIResponse(chunk, state) { // normalizeOpenAICompatibleFinishReasonString lowercases, maps max_tokens→length, // and folds Gemini safety reasons (safety/recitation/blocklist/...) → content_filter // so downstream clients can distinguish a blocked completion from a normal stop. + // Abort reasons (MALFORMED_FUNCTION_CALL, UNEXPECTED_TOOL_CALL, ...) are NOT in + // either mapped set, so they surface here unchanged (e.g. raw + // "malformed_function_call") rather than being folded into a misleading "stop" — + // isAbortFinishReason() (finishReason.ts) is what the openai→claude hub step + // uses downstream to recognize this raw value and keep it off a clean end_turn + // (9router#2462 sub-bug #2). let finishReason = normalizeOpenAICompatibleFinishReasonString(candidate.finishReason); if (finishReason === "stop" && state.toolCalls.size > 0) { finishReason = "tool_calls"; diff --git a/open-sse/translator/response/openai-responses/pureHelpers.ts b/open-sse/translator/response/openai-responses/pureHelpers.ts index 3ed559fb55..31f1abb73f 100644 --- a/open-sse/translator/response/openai-responses/pureHelpers.ts +++ b/open-sse/translator/response/openai-responses/pureHelpers.ts @@ -5,13 +5,20 @@ export function normalizeToolName(value) { return typeof value === "string" ? value.trim() : ""; } +// Tools whose empty-string/empty-array optional args are safe to strip. Arbitrary +// tools are left untouched because an empty string/array can be a valid payload. +// - "Read": Claude Code's Read tool (empty `pages`) — #2937. +// - "Subagent": Cursor's local subagent tool emits a cloud-only `cloud_base_branch: ""`, +// which Cursor rejects unless environment is cloud — ported from decolua/9router#2446. +const STRIPPABLE_EMPTY_ARG_TOOLS = new Set(["Read", "Subagent"]); + export function stripEmptyOptionalToolArgs(value, toolName) { if (value == null) return value; if (typeof value === "string") { - // JSON-string cleanup is intentionally scoped to Claude Code's Read tool. + // JSON-string cleanup is intentionally scoped to the allowlisted tools above. // For arbitrary tools, empty strings/arrays may be valid user payloads. - if (toolName !== "Read") return value; + if (!STRIPPABLE_EMPTY_ARG_TOOLS.has(toolName)) return value; try { const parsed = JSON.parse(value); if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) return value; diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index e899778d4c..a2f77461af 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -3,6 +3,7 @@ import { FORMATS } from "../formats.ts"; import { CLAUDE_OAUTH_TOOL_PREFIX } from "../request/openai-to-claude.ts"; import { hasToolCallShim, applyToolCallShimToBuffer } from "../helpers/toolCallShim.ts"; import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; +import { isAbortFinishReason } from "../../utils/finishReason.ts"; // Helper: stop thinking block if started function stopThinkingBlock(state, results) { @@ -154,42 +155,62 @@ export function openaiToClaudeResponse(chunk, state) { for (const tc of delta.tool_calls) { const idx = tc.index ?? 0; - if (tc.id) { + // Strip the Claude OAuth prefix from an incoming tool name (if any). + const incomingName = (() => { + let n = tc.function?.name || ""; + if (n.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) n = n.slice(CLAUDE_OAUTH_TOOL_PREFIX.length); + return n; + })(); + + // A tool call is identified by its id. Some OpenAI-compatible upstreams + // (GLM 5.2) stream the id and function.name in SEPARATE SSE chunks. The + // Claude protocol cannot patch a content_block_start after it is emitted, + // so we register the tool call on the id chunk but DEFER content_block_start + // until the name arrives (#2077 / decolua/9router#2077). + if (tc.id && !state.toolCalls.has(idx)) { stopThinkingBlock(state, results); stopTextBlock(state, results); - const toolBlockIndex = state.nextBlockIndex++; - - // Strip prefix from tool name for response - let toolName = tc.function?.name || ""; - if (toolName.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) { - toolName = toolName.slice(CLAUDE_OAUTH_TOOL_PREFIX.length); - } - state.toolCalls.set(idx, { id: tc.id, - name: toolName, - blockIndex: toolBlockIndex, + name: incomingName, + blockIndex: state.nextBlockIndex++, // Shimmed tools buffer their raw args and emit a single corrected // input_json_delta at content_block_stop time (see finish handler). - shimmed: hasToolCallShim(toolName), + shimmed: incomingName ? hasToolCallShim(incomingName) : false, argBuffer: "", - }); - - results.push({ - type: "content_block_start", - index: toolBlockIndex, - content_block: { - type: "tool_use", - id: tc.id, - name: toolName, - input: {}, - }, + startEmitted: false, }); } + const toolInfo = state.toolCalls.get(idx); + if (toolInfo) { + // Capture a late-arriving id or name (streamed after the initial chunk). + if (tc.id && !toolInfo.id) toolInfo.id = tc.id; + if (incomingName && !toolInfo.startEmitted && !toolInfo.name) { + toolInfo.name = incomingName; + toolInfo.shimmed = hasToolCallShim(incomingName); + } + + // Emit content_block_start once we have a name. If arguments arrive before + // any name was ever seen, start the block anyway with the (empty) name so + // the input_json_delta stays well-formed. + if (!toolInfo.startEmitted && (toolInfo.name || tc.function?.arguments != null)) { + toolInfo.startEmitted = true; + results.push({ + type: "content_block_start", + index: toolInfo.blockIndex, + content_block: { + type: "tool_use", + id: toolInfo.id, + name: toolInfo.name || "", + input: {}, + }, + }); + } + } + if (tc.function?.arguments) { - const toolInfo = state.toolCalls.get(idx); if (toolInfo) { // Always buffer the raw stream so shimmed tools can re-emit a // corrected JSON at stop time. @@ -238,6 +259,18 @@ export function openaiToClaudeResponse(chunk, state) { stopTextBlock(state, results); for (const [, toolInfo] of state.toolCalls) { + // A tool call whose name/args never arrived (only an id chunk was seen) + // still has a reserved block index but no content_block_start. Emit it now + // so the terminal content_block_stop is not orphaned (#2077 edge case). + if (!toolInfo.startEmitted) { + toolInfo.startEmitted = true; + results.push({ + type: "content_block_start", + index: toolInfo.blockIndex, + content_block: { type: "tool_use", id: toolInfo.id, name: toolInfo.name || "", input: {} }, + }); + } + // For shimmed tools, emit one corrective input_json_delta with the // fully patched JSON before closing the block. if (toolInfo.shimmed) { @@ -281,7 +314,16 @@ function convertFinishReason(reason) { case "tool_calls": return "tool_use"; default: - return "end_turn"; + // Gemini/Antigravity abort reasons (e.g. MALFORMED_FUNCTION_CALL, + // UNEXPECTED_TOOL_CALL — see isAbortFinishReason) reach here unrecognized + // after the OpenAI hub normalization. Collapsing them to a clean + // "end_turn" presents an aborted tool call to the client as a successful + // completion (9router#2462 sub-bug #2). Surface them as "tool_use" — + // the same non-clean-stop signal already used for real tool_calls above — + // so the client does not treat the turn as done. Genuinely unknown future + // reasons still fall back to "end_turn" so a benign new value does not + // start misreporting every Gemini-family turn as an unfinished tool call. + return isAbortFinishReason(reason) ? "tool_use" : "end_turn"; } } diff --git a/open-sse/utils/cursorAgentCliVersion.ts b/open-sse/utils/cursorAgentCliVersion.ts new file mode 100644 index 0000000000..2a65df051b --- /dev/null +++ b/open-sse/utils/cursorAgentCliVersion.ts @@ -0,0 +1,124 @@ +/** + * Cursor Agent CLI version for AgentService/Run impersonation. + * + * Wire header: `x-cursor-client-version: cli-${id}` where `id` is a dated + * build like `2026.07.08-0c04a8a` (not the IDE `3.x` semver). + * + * Resolution: CURSOR_AGENT_CLI_VERSION env → local install detect → pin. + */ + +import { existsSync, lstatSync, readdirSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** + * Pinned Agent CLI build id used when no local install is found (typical + * headless OmniRoute). Bump when refreshing Cursor CLI impersonation. + */ +export const CURSOR_AGENT_CLI_VERSION = "2026.07.08-0c04a8a"; + +const VERSION_ID_RE = /^\d{4}\.\d{2}\.\d{2}-[0-9a-f]+$/; +const CACHE_TTL_MS = 60 * 60 * 1000; + +let cachedVersion: string | null = null; +let cachedAt = 0; + +export function isCursorAgentCliVersionId(value: string): boolean { + return VERSION_ID_RE.test(value); +} + +export function formatCursorAgentClientVersion(id: string): string { + return `cli-${id}`; +} + +/** Extract `versions/` from a resolved agent binary path. */ +export function extractVersionIdFromResolvedPath(resolvedPath: string): string | null { + const parts = resolvedPath.split(/[/\\]/); + const versionsIdx = parts.lastIndexOf("versions"); + if (versionsIdx < 0 || versionsIdx + 1 >= parts.length) return null; + const id = parts[versionsIdx + 1]; + return isCursorAgentCliVersionId(id) ? id : null; +} + +export function newestVersionInDir(versionsDir: string): string | null { + try { + if (!existsSync(versionsDir)) return null; + const matches = readdirSync(versionsDir) + .filter((name) => { + if (!isCursorAgentCliVersionId(name)) return false; + try { + return lstatSync(join(versionsDir, name)).isDirectory(); + } catch { + return false; + } + }) + .sort(); + return matches.length > 0 ? matches[matches.length - 1] : null; + } catch { + return null; + } +} + +function versionFromShim(shimPath: string): string | null { + try { + if (!existsSync(shimPath)) return null; + const resolved = realpathSync(shimPath); + return extractVersionIdFromResolvedPath(resolved); + } catch { + return null; + } +} + +function defaultVersionsDir(home: string): string { + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA || join(home, "AppData", "Local"); + return join(localAppData, "cursor-agent", "versions"); + } + return join(home, ".local", "share", "cursor-agent", "versions"); +} + +/** + * Detect an installed Agent CLI build id from the filesystem. + * @param home - injectable home for tests (defaults to os.homedir()) + */ +export function detectCursorAgentCliVersionFromFs(home: string = homedir()): string | null { + const localBin = join(home, ".local", "bin"); + for (const name of ["agent", "cursor-agent"]) { + const fromShim = versionFromShim(join(localBin, name)); + if (fromShim) return fromShim; + } + + const dataDir = process.env.CURSOR_DATA_DIR; + const versionsDir = dataDir ? join(dataDir, "versions") : defaultVersionsDir(home); + return newestVersionInDir(versionsDir); +} + +export function getCursorAgentCliVersion(): string { + const now = Date.now(); + if (cachedVersion && now - cachedAt < CACHE_TTL_MS) { + return cachedVersion; + } + + const fromEnv = process.env.CURSOR_AGENT_CLI_VERSION?.trim(); + if (fromEnv && isCursorAgentCliVersionId(fromEnv)) { + cachedVersion = fromEnv; + cachedAt = now; + return cachedVersion; + } + + const home = process.env.HOME || process.env.USERPROFILE || homedir(); + const fromFs = detectCursorAgentCliVersionFromFs(home); + if (fromFs) { + cachedVersion = fromFs; + cachedAt = now; + return cachedVersion; + } + + return CURSOR_AGENT_CLI_VERSION; +} + +/** Exposed for testing: reset the in-memory cache. */ +export function resetCursorAgentCliVersionCache(): void { + cachedVersion = null; + cachedAt = 0; +} diff --git a/open-sse/utils/finishReason.ts b/open-sse/utils/finishReason.ts index 5d8ab33667..5bb0835adc 100644 --- a/open-sse/utils/finishReason.ts +++ b/open-sse/utils/finishReason.ts @@ -16,6 +16,31 @@ const SAFETY_FINISH_REASONS = new Set([ "malformed_response", ]); +// Gemini/Antigravity finish reasons that mean the model ABORTED the turn before +// completing it — most commonly a tool call the model started narrating but +// Gemini could not parse/execute (MALFORMED_FUNCTION_CALL, UNEXPECTED_TOOL_CALL). +// Distinct from SAFETY_FINISH_REASONS: those are deliberate, deterministic +// content blocks; these are execution failures mid tool-call. Left un-mapped +// here (still passed through raw, e.g. "malformed_function_call") so an +// OpenAI-format client at least sees a non-standard-but-honest value instead of +// a misleading "stop" — downstream Claude translation classifies them via +// isAbortFinishReason() so it does not collapse them to a clean "end_turn" +// (9router#2462 sub-bug #2: an aborted tool call must not present to the client +// as a successful completion). +const ABORT_FINISH_REASONS = new Set([ + "malformed_function_call", + "unexpected_tool_call", + "finish_reason_unspecified", + "other", + "language", + "no_image", +]); + +export function isAbortFinishReason(value: unknown): boolean { + if (typeof value !== "string") return false; + return ABORT_FINISH_REASONS.has(value.toLowerCase()); +} + export function normalizeOpenAICompatibleFinishReason(value: unknown): unknown { if (typeof value !== "string") return value; diff --git a/open-sse/utils/responsesCommentaryDrop.ts b/open-sse/utils/responsesCommentaryDrop.ts new file mode 100644 index 0000000000..35ac68e8ec --- /dev/null +++ b/open-sse/utils/responsesCommentaryDrop.ts @@ -0,0 +1,98 @@ +// open-sse/utils/responsesCommentaryDrop.ts +// +// #6199 / #6561 — statefully decide whether a Responses SSE event belongs to +// an internal "commentary" phase item and must be dropped from the +// passthrough stream. The `response.output_item.added` event announces the +// phase; the follow-up delta/done events only carry `item_id`/`output_index`, +// so we key off those (tracked across calls in the two Sets the caller owns). +// +// Extracted out of `stream.ts` (a frozen file — see +// config/quality/file-size-baseline.json) so the #6561 fix (clearing the +// buffered `event:` line alongside every drop) does not grow that file. +import { isResponsesCommentaryMessageItem } from "../handlers/responseSanitizer.ts"; + +type JsonRecord = Record; + +function extractEventItem(parsed: JsonRecord): JsonRecord | null { + return parsed.item && typeof parsed.item === "object" && !Array.isArray(parsed.item) + ? (parsed.item as JsonRecord) + : null; +} + +function extractEventItemId(parsed: JsonRecord, eventItem: JsonRecord | null): string | null { + if (typeof parsed.item_id === "string") return parsed.item_id; + if (eventItem && typeof eventItem.id === "string") return eventItem.id; + return null; +} + +function extractEventOutputIndex(parsed: JsonRecord): number | null { + return typeof parsed.output_index === "number" ? parsed.output_index : null; +} + +// The `response.output_item.added` event that announces a new commentary-phase +// item. Records its identifiers so follow-up delta/done events are recognized. +function isCommentaryStart( + eventType: string, + parsed: JsonRecord, + eventItemId: string | null, + eventOutputIndex: number | null, + commentaryItemIds: Set, + commentaryIndexes: Set +): boolean { + const isAddedEvent = eventType === "response.output_item.added"; + if (!isAddedEvent || !isResponsesCommentaryMessageItem(parsed.item)) return false; + + if (eventItemId) commentaryItemIds.add(eventItemId); + if (eventOutputIndex !== null) commentaryIndexes.add(eventOutputIndex); + return true; +} + +// A follow-up delta/done event for an item already tracked as commentary. +// Untracks the item once its `output_item.done` event is seen. +function isCommentaryContinuation( + eventType: string, + eventItemId: string | null, + eventOutputIndex: number | null, + commentaryItemIds: Set, + commentaryIndexes: Set +): boolean { + const belongsToCommentary = + (eventItemId !== null && commentaryItemIds.has(eventItemId)) || + (eventOutputIndex !== null && commentaryIndexes.has(eventOutputIndex)); + if (!belongsToCommentary) return false; + + if (eventType === "response.output_item.done") { + if (eventItemId) commentaryItemIds.delete(eventItemId); + if (eventOutputIndex !== null) commentaryIndexes.delete(eventOutputIndex); + } + return true; +} + +export function shouldDropResponsesCommentaryEvent( + parsed: JsonRecord, + commentaryItemIds: Set, + commentaryIndexes: Set +): boolean { + const eventType = parsed.type as string; + const eventItem = extractEventItem(parsed); + const eventItemId = extractEventItemId(parsed, eventItem); + const eventOutputIndex = extractEventOutputIndex(parsed); + + return ( + isCommentaryStart( + eventType, + parsed, + eventItemId, + eventOutputIndex, + commentaryItemIds, + commentaryIndexes + ) || + isCommentaryContinuation( + eventType, + eventItemId, + eventOutputIndex, + commentaryItemIds, + commentaryIndexes + ) + ); +} diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 120644ac9d..0ed9f3aac6 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -30,9 +30,9 @@ import { STREAM_IDLE_TIMEOUT_MS, FETCH_BODY_TIMEOUT_MS, HTTP_STATUS } from "../c import { OMIT_STREAMING_CHUNK_MARKER, sanitizeStreamingChunk, - isResponsesCommentaryMessageItem, } from "../handlers/responseSanitizer.ts"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; +import { shouldDropResponsesCommentaryEvent } from "./responsesCommentaryDrop.ts"; import { buildErrorBody } from "./error.ts"; import { parseTextualToolCallCandidate, isValidToolCallHeaderPrefix } from "./textualToolCall.ts"; import { recordToolLatency } from "../services/toolLatencyTracker.ts"; @@ -1308,48 +1308,19 @@ export function createSSEStream(options: StreamOptions = {}) { parsed.type === "error"); if (isResponsesSSE) { - // #6199 — statefully drop internal commentary-phase output. The - // `response.output_item.added` announces the phase; the follow-up - // delta/done events only carry `item_id`/`output_index`, so we key - // off those. Happy-path (non-commentary) events are untouched. - if (shouldDropResponsesCommentary) { - const responsesEventType = parsed.type as string; - const eventOutputIndex = - typeof parsed.output_index === "number" ? parsed.output_index : null; - const eventItem = - parsed.item && typeof parsed.item === "object" && !Array.isArray(parsed.item) - ? (parsed.item as JsonRecord) - : null; - const eventItemId = - typeof parsed.item_id === "string" - ? parsed.item_id - : eventItem && typeof eventItem.id === "string" - ? eventItem.id - : null; - - if ( - responsesEventType === "response.output_item.added" && - isResponsesCommentaryMessageItem(parsed.item) - ) { - if (eventItemId) passthroughResponsesCommentaryItemIds.add(eventItemId); - if (eventOutputIndex !== null) - passthroughResponsesCommentaryIndexes.add(eventOutputIndex); - continue; - } - - const belongsToCommentary = - (eventItemId !== null && - passthroughResponsesCommentaryItemIds.has(eventItemId)) || - (eventOutputIndex !== null && - passthroughResponsesCommentaryIndexes.has(eventOutputIndex)); - if (belongsToCommentary) { - if (responsesEventType === "response.output_item.done") { - if (eventItemId) passthroughResponsesCommentaryItemIds.delete(eventItemId); - if (eventOutputIndex !== null) - passthroughResponsesCommentaryIndexes.delete(eventOutputIndex); - } - continue; - } + // #6199/#6561 — statefully drop internal commentary-phase output (see + // ./responsesCommentaryDrop.ts) and clear the buffered `event:` line + // for the same frame, or it flushes alone as an event-only SSE frame. + if ( + shouldDropResponsesCommentary && + shouldDropResponsesCommentaryEvent( + parsed as JsonRecord, + passthroughResponsesCommentaryItemIds, + passthroughResponsesCommentaryIndexes + ) + ) { + clearPendingPassthroughEvent(); + continue; } const responsesIdsNormalized = normalizeResponsesSseIds(parsed as JsonRecord); diff --git a/package.json b/package.json index c898b34204..92d759d7eb 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "check:test-masking": "node scripts/check/check-test-masking.mjs", "check:test-runner-api": "node scripts/check/check-test-runner-api.mjs", "check:changelog-integrity": "node scripts/check/check-changelog-integrity.mjs", + "changelog:aggregate": "node scripts/release/aggregate-changelog.mjs", "check:agent-skills-sync": "node --import tsx/esm scripts/skills/generate-agent-skills.mjs", "check:build-scope": "node scripts/check/check-build-scope.mjs", "check:error-helper": "node scripts/check/check-error-helper.mjs", diff --git a/public/providers/arena-dark.svg b/public/providers/arena-dark.svg new file mode 100644 index 0000000000..0435f9ee24 --- /dev/null +++ b/public/providers/arena-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/arena-light.svg b/public/providers/arena-light.svg new file mode 100644 index 0000000000..6ae050e8f8 --- /dev/null +++ b/public/providers/arena-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/ad-hoc/cursor-tap.cjs b/scripts/ad-hoc/cursor-tap.cjs index 0145d6162c..3bb3f94ae4 100644 --- a/scripts/ad-hoc/cursor-tap.cjs +++ b/scripts/ad-hoc/cursor-tap.cjs @@ -130,7 +130,7 @@ const req = client.request({ traceparent: traceParent, "user-agent": "connect-es/1.6.1", "x-cursor-client-type": "cli", - "x-cursor-client-version": "cli-2025.10.21-b2dfaef", + "x-cursor-client-version": "cli-2026.07.08-0c04a8a", "x-ghost-mode": "true", "x-original-request-id": requestId, "x-request-id": requestId, diff --git a/scripts/build/bootstrap-env.mjs b/scripts/build/bootstrap-env.mjs index 90ee46ddd6..e3bdd673ec 100644 --- a/scripts/build/bootstrap-env.mjs +++ b/scripts/build/bootstrap-env.mjs @@ -173,7 +173,14 @@ export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) { const preferredEnvFiltered = Object.fromEntries( Object.entries(preferredEnv).filter(([, v]) => typeof v === "string" && v.length > 0) ); - const merged = { ...persisted, ...preferredEnvFiltered, ...process.env }; + // Filter empty strings from process.env so that Docker `-e KEY=` (which sets an + // empty string) does not override real values persisted in server.env or set + // in .env. Only shell/Docker vars that the operator actually set should win. + // Mirrors the filtering already applied to preferredEnv above. (fixes #6824) + const processEnvFiltered = Object.fromEntries( + Object.entries(process.env).filter(([, v]) => typeof v === "string" && v.length > 0) + ); + const merged = { ...persisted, ...preferredEnvFiltered, ...processEnvFiltered }; // ── Auto-generate required secrets ──────────────────────────────────────── let needsPersist = false; diff --git a/scripts/check/check-changelog-integrity.mjs b/scripts/check/check-changelog-integrity.mjs index b07c3f99f4..edf3ac4442 100644 --- a/scripts/check/check-changelog-integrity.mjs +++ b/scripts/check/check-changelog-integrity.mjs @@ -26,12 +26,15 @@ // env ALLOW_CHANGELOG_REMOVALS=1 report-only (never fails) import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const CHANGELOG = "CHANGELOG.md"; +const FRAGMENTS_DIR = "changelog.d"; +const FRAGMENT_SECTIONS = ["features", "fixes", "maintenance"]; +const FRAGMENT_SKIP = new Set(["README.md", ".gitkeep"]); /** Extract the set of bullet lines (trimmed) from a CHANGELOG text. */ export function extractBullets(text) { @@ -56,6 +59,49 @@ export function findLostBullets(baseText, headText) { return lost; } +/** + * Validate changelog FRAGMENTS (changelog.d/
/*.md — see changelog.d/README.md). + * A fragment must be a well-formed markdown bullet ("- ...") with no merge-conflict + * markers, and must live in a known section dir. Returns [{file, error}]. Pure over + * the filesystem — unit-tested via a tmp root. + */ +export function findInvalidFragments(root = ROOT) { + const invalid = []; + const base = join(root, FRAGMENTS_DIR); + if (!existsSync(base)) return invalid; + const entries = readdirSync(base, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + if (!FRAGMENT_SKIP.has(entry.name)) { + invalid.push({ + file: `${FRAGMENTS_DIR}/${entry.name}`, + error: `fragments live in a section dir (${FRAGMENT_SECTIONS.join("|")}), not at changelog.d root`, + }); + } + continue; + } + if (!FRAGMENT_SECTIONS.includes(entry.name)) { + invalid.push({ + file: `${FRAGMENTS_DIR}/${entry.name}/`, + error: `unknown section dir (expected ${FRAGMENT_SECTIONS.join("|")})`, + }); + continue; + } + for (const f of readdirSync(join(base, entry.name))) { + if (FRAGMENT_SKIP.has(f) || !f.endsWith(".md")) continue; + const file = `${FRAGMENTS_DIR}/${entry.name}/${f}`; + const text = readFileSync(join(base, entry.name, f), "utf8"); + const firstContent = text.split("\n").find((l) => l.trim().length > 0); + if (!firstContent) invalid.push({ file, error: "empty fragment" }); + else if (!firstContent.trimStart().startsWith("- ")) + invalid.push({ file, error: 'fragment must start with a markdown bullet ("- ")' }); + else if (/^(<{7}|={7}|>{7})/m.test(text)) + invalid.push({ file, error: "fragment contains merge-conflict markers" }); + } + } + return invalid; +} + function git(args) { return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); } @@ -77,6 +123,16 @@ function resolveBaseRef() { } function main() { + // Fragment well-formedness first (changelog.d/ — the fragments pattern makes the + // eat-guard below structurally unnecessary for PRs that stop editing CHANGELOG.md). + const invalidFragments = findInvalidFragments(); + if (invalidFragments.length > 0) { + console.error(`[changelog-integrity] ${invalidFragments.length} invalid changelog fragment(s):`); + for (const { file, error } of invalidFragments) console.error(` ✗ ${file}: ${error}`); + console.error("\nSee changelog.d/README.md for the fragment convention."); + return 1; + } + const baseRef = resolveBaseRef(); if (!baseRef) { console.log("[changelog-integrity] SKIP — could not resolve a base ref (offline/fresh clone)."); diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index 6bcfe69c25..d91d7f52c0 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -56,6 +56,7 @@ export const INTENTIONALLY_INTERNAL = new Set([ "healthCheck", // db-internal: importado por db/core.ts (runDbHealthCheck) "jsonMigration", // intentionally-internal: src/app/api/settings/import-json/route.ts "migrationRunner", // db-internal: importado por db/core.ts (runMigrations ao inicializar o DB) + "modelCapabilityOverrides", // intentionally-internal: src/app/api/model-capability-overrides/route.ts via import direto "@/lib/db/modelCapabilityOverrides" (#6727 — evita empurrar localDb.ts para o cap de 800 linhas) "notion", // intentionally-internal: settings/notion API route + open-sse/mcp-server/tools/notionTools.ts "obsidian", // intentionally-internal: src/lib/obsidianSync.ts + settings/obsidian route + MCP obsidianTools.ts "optimizationSettings", // db-internal: imported by db/core.ts for SQLite PRAGMA application helpers that require the live adapter @@ -63,6 +64,7 @@ export const INTENTIONALLY_INTERNAL = new Set([ "prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts "providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421) "providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts + "proxyLatency", // intentionally-internal: imported directly by src/lib/db/proxies.ts (anti-barrel, #6798) "recovery", // intentionally-internal: bin/cli/runtime.mjs (import() dinâmico) + tests "schemaColumns", // db-internal: importado só por db/core.ts (ensureProviderConnections/UsageHistory/CallLogsColumns + hasColumn/hasTable/getTableColumns — schema-column reconciliation split do core.ts, #4948) "secrets", // intentionally-internal: src/instrumentation-node.ts (import() dinâmico na inicialização) diff --git a/scripts/dev/head-response-guard.cjs b/scripts/dev/head-response-guard.cjs new file mode 100644 index 0000000000..7ecb460439 --- /dev/null +++ b/scripts/dev/head-response-guard.cjs @@ -0,0 +1,104 @@ +"use strict"; + +/** + * HEAD response guard (#6400). + * + * RFC 9110 §9.3.2 requires a HEAD response to carry the same headers/status a + * GET would, with ZERO body, and the connection should not leave the client + * guessing about when the (bodyless) response is actually finished. + * + * Next.js 16 handles this correctly for App Router *route handlers* + * (`route.ts` exporting `GET`) — `next/dist/server/send-response.js` explicitly + * skips piping the `Response.body` when `req.method === 'HEAD'`. But Next's + * *page*-rendering pipeline (`next/dist/server/pipe-readable.js` -> + * `pipeToNodeResponse`, used for every app-router page/layout render — the + * root page, the `not-found` boundary that unmatched paths fall through to, + * dashboard pages, etc.) has NO such check: it always pipes the fully + * rendered body to the HTTP response regardless of method. Combined with + * Node's default keep-alive framing, a HEAD request to any page-rendered path + * ends up with the socket only settling once that render finishes — on a + * client that doesn't special-case a HEAD response's implicit zero-length + * body (observed on Windows/curl in #6400), this reads as "headers arrive, + * then it hangs" instead of the RFC-mandated "closes immediately". + * + * Fix: for every inbound HEAD request, before Next ever sees it, wrap the + * Node `ServerResponse` so: + * - Any body bytes written by Next (route handler OR page render) are + * discarded — status code and headers Next computed (auth 401s, 404s, + * 200s, etc.) are preserved untouched. + * - The connection is force-closed right after headers flush + * (`Connection: close`), removing any keep-alive ambiguity a client could + * have about whether more bytes are coming. + * + * This applies globally (valid routes, unmatched/404 paths, authed and + * unauthed) because it operates at the Node HTTP transport layer shared by + * every request — the same tier as the existing `http-method-guard.cjs` / + * `peer-stamp.mjs` wrappers — never inside Next's per-route code. + * See: https://github.com/diegosouzapw/OmniRoute/issues/6400 + */ + +function isHeadRequest(req) { + return typeof req?.method === "string" && req.method.toUpperCase() === "HEAD"; +} + +/** + * Mutates `res` in place so any body write is discarded and the response + * ends (closing the connection) as soon as `.end()` is called, regardless of + * what body argument was passed to it. + * + * @param {import("node:http").ServerResponse} res + */ +function suppressBodyAndForceClose(res) { + try { + // Never leave the client guessing whether the (bodyless) response has + // more bytes coming — closing the socket is the unambiguous signal. + res.setHeader("Connection", "close"); + } catch { + // Headers may already be flushed in rare re-entrant cases — the write/end + // overrides below still guarantee an empty, prompt HEAD response. + } + + const originalEnd = res.end.bind(res); + let ended = false; + + res.write = function headSuppressedWrite(_chunk, encodingOrCb, cb) { + // Discard the body but keep the writable-stream contract: report the + // write as flushed (no backpressure) so callers like Next's + // `pipeToNodeResponse` never block waiting on a `drain` that would + // otherwise never fire, and invoke whichever callback form was passed. + if (typeof encodingOrCb === "function") encodingOrCb(); + else if (typeof cb === "function") cb(); + return true; + }; + + res.end = function headSuppressedEnd(chunk, encoding, cb) { + if (ended) return res; + ended = true; + if (typeof chunk === "function") return originalEnd(chunk); + if (typeof encoding === "function") return originalEnd(encoding); + if (typeof cb === "function") return originalEnd(cb); + return originalEnd(); + }; +} + +/** + * Wrap a Node request listener so every inbound HEAD request gets the + * body-suppression + forced-close treatment before the wrapped listener + * (eventually Next.js) runs. + * + * @param {(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => unknown} listener + */ +function wrapRequestListenerWithHeadResponseGuard(listener) { + return function headResponseGuardRequestHandler(req, res) { + if (isHeadRequest(req)) { + suppressBodyAndForceClose(res); + } + return listener.call(this, req, res); + }; +} + +module.exports = { + isHeadRequest, + suppressBodyAndForceClose, + wrapRequestListenerWithHeadResponseGuard, +}; diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index 4a402650a8..0ff02837f0 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -10,6 +10,7 @@ import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs"; import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs"; import methodGuard from "./http-method-guard.cjs"; +import headResponseGuard from "./head-response-guard.cjs"; import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs"; import { isTurbopackCacheCorruption, @@ -18,6 +19,7 @@ import { import { randomUUID } from "node:crypto"; const { maybeHandleDisallowedMethod } = methodGuard; +const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard; // Pre-read DATA_DIR from local .env before bootstrap resolves paths if (!process.env.DATA_DIR) { @@ -143,13 +145,15 @@ async function start() { baseUrl: `http://127.0.0.1:${dashboardPort}`, }); - const server = http.createServer((req, res) => { - if (maybeHandleDisallowedMethod(req, res)) return; - // Stamp the real TCP peer IP before Next sees the request, so the authz - // middleware can decide LOCAL_ONLY locality without trusting the Host header. - stampPeerIp(req); - return requestHandler(req, res); - }); + const server = http.createServer( + wrapRequestListenerWithHeadResponseGuard((req, res) => { + if (maybeHandleDisallowedMethod(req, res)) return; + // Stamp the real TCP peer IP before Next sees the request, so the authz + // middleware can decide LOCAL_ONLY locality without trusting the Host header. + stampPeerIp(req); + return requestHandler(req, res); + }) + ); server.on("upgrade", async (req, socket, head) => { try { const responsesWsHandled = await responsesWsProxy.handleUpgrade(req, socket, head); diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index 890caee40f..c96c9624ef 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -5,11 +5,13 @@ import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs"; import { maybeHandleWebdav } from "./webdav-handler.mjs"; import methodGuard from "./http-method-guard.cjs"; +import headResponseGuard from "./head-response-guard.cjs"; import { resolveTlsOptions, createServerListener } from "./tls-options.mjs"; const originalCreateServer = http.createServer.bind(http); const proxiesByPort = new Map(); const { wrapRequestListenerWithMethodGuard } = methodGuard; +const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard; // Opt-in native HTTPS (#5242). Resolved once at boot: when both OMNIROUTE_TLS_CERT // and OMNIROUTE_TLS_KEY point at readable files we terminate TLS on the same @@ -17,9 +19,7 @@ const { wrapRequestListenerWithMethodGuard } = methodGuard; // TLS). Absent or misconfigured → null → identical plain-HTTP behavior as before. const tlsOptions = resolveTlsOptions(process.env); if (tlsOptions) { - console.log( - `[omniroute][tls] HTTPS enabled — terminating TLS with cert=${tlsOptions.certPath}` - ); + console.log(`[omniroute][tls] HTTPS enabled — terminating TLS with cert=${tlsOptions.certPath}`); } process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID(); @@ -49,8 +49,23 @@ function getProxy(server) { return proxy; } +function deriveLiveWsPath() { + const publicUrl = process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL; + if (!publicUrl) return "/live-ws"; + if (!publicUrl.startsWith("ws://") && !publicUrl.startsWith("wss://")) return "/live-ws"; + try { + const parsed = new URL(publicUrl); + const pathname = parsed.pathname; + return pathname && pathname !== "/" ? pathname : "/live-ws"; + } catch { + return "/live-ws"; + } +} + +const LIVE_WS_PATH = deriveLiveWsPath(); + function proxyLiveWs(req, socket, head) { - const targetPort = parseInt(process.env.LIVE_WS_PORT || "20129", 10); + const targetPort = parseInt(process.env.LIVE_WS_PORT || "20132", 10); const targetSocket = net.connect(targetPort, "127.0.0.1", () => { let rawRequest = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`; for (const [key, val] of Object.entries(req.headers)) { @@ -74,8 +89,16 @@ function proxyLiveWs(req, socket, head) { function wrapUpgradeListener(server, listener) { return async function responsesWsAwareUpgrade(req, socket, head) { try { + // If this server IS the LiveWS server (port 20132), the ws library's + // own upgrade handler should process the request directly — proxying + // /live-ws back to 127.0.0.1:20132 would create an infinite self-loop. + const liveWsPort = parseInt(process.env.LIVE_WS_PORT || "20132", 10); + if (getPort(server) === liveWsPort) { + return listener.call(this, req, socket, head); + } + const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); - if (url.pathname === "/live-ws" || url.pathname.startsWith("/live-ws")) { + if (url.pathname === LIVE_WS_PATH || url.pathname.startsWith(LIVE_WS_PATH + "/")) { proxyLiveWs(req, socket, head); return; } @@ -114,8 +137,12 @@ http.createServer = function createServerWithResponsesWs(...args) { const lastFnIdx = args.map((a) => typeof a === "function").lastIndexOf(true); if (lastFnIdx >= 0) { // Method guard runs before Next because Next 16 rejects TRACE while constructing requests. - args[lastFnIdx] = wrapRequestListenerWithMethodGuard( - wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(args[lastFnIdx])) + // Head-response guard wraps outermost so it sees (and can force-close) every + // HEAD request regardless of which inner layer ends up handling it (#6400). + args[lastFnIdx] = wrapRequestListenerWithHeadResponseGuard( + wrapRequestListenerWithMethodGuard( + wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(args[lastFnIdx])) + ) ); } @@ -134,8 +161,10 @@ http.createServer = function createServerWithResponsesWs(...args) { if (eventName === "request" && typeof listener === "function") { return originalOn( eventName, - wrapRequestListenerWithMethodGuard( - wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener)) + wrapRequestListenerWithHeadResponseGuard( + wrapRequestListenerWithMethodGuard( + wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener)) + ) ) ); } @@ -149,8 +178,10 @@ http.createServer = function createServerWithResponsesWs(...args) { if (eventName === "request" && typeof listener === "function") { return originalAddListener( eventName, - wrapRequestListenerWithMethodGuard( - wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener)) + wrapRequestListenerWithHeadResponseGuard( + wrapRequestListenerWithMethodGuard( + wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener)) + ) ) ); } diff --git a/scripts/i18n/generate-multilang.mjs b/scripts/i18n/generate-multilang.mjs index 3ad9919f98..39812499e9 100644 --- a/scripts/i18n/generate-multilang.mjs +++ b/scripts/i18n/generate-multilang.mjs @@ -97,6 +97,15 @@ const LOCALE_SPECS = [ readmeName: "中文 (简体)", docsName: "中文 (简体)", }, + { + code: "zh-TW", + googleTl: "zh-TW", + label: "ZH-TW", + flag: "🇹🇼", + languageName: "中文 (繁體)", + readmeName: "中文 (繁體)", + docsName: "中文 (繁體)", + }, { code: "de", googleTl: "de", @@ -414,7 +423,7 @@ const LOCALE_SPECS = [ }, ]; -const EXISTING_README_CODES = new Set(["pt-BR", "es", "fr", "it", "ru", "zh-CN", "de"]); +const EXISTING_README_CODES = new Set(["pt-BR", "es", "fr", "it", "ru", "zh-CN", "zh-TW", "de"]); const RTL_LOCALES = new Set(["ar", "fa", "he", "ur"]); const URL_MAX_TEXT_LENGTH = 1800; diff --git a/scripts/release/aggregate-changelog.mjs b/scripts/release/aggregate-changelog.mjs new file mode 100644 index 0000000000..7d490045f6 --- /dev/null +++ b/scripts/release/aggregate-changelog.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// scripts/release/aggregate-changelog.mjs +// +// Changelog FRAGMENTS aggregator (towncrier/changesets pattern, adopted 2026-07-09). +// +// Why: during a release cycle every PR used to edit the same few lines at the top of +// CHANGELOG.md (its bullet). In a merge-storm each merge conflicted every sibling +// (CHANGELOG-eat / DIRTY cascade), forcing a re-sync push + full CI re-run per PR per +// merge — O(N²) CI runs for N queued PRs. With fragments, a PR adds ONE NEW FILE under +// changelog.d/
/ instead, so two PRs never touch the same file: no conflicts, +// no eat, no re-sync. This script is the single place fragments become CHANGELOG.md +// bullets — run by the release captain (or /generate-release) at reconciliation, and +// safe to run mid-cycle whenever a consolidated view is wanted. +// +// Convention: +// changelog.d/features/-.md → appended to "### ✨ New Features" +// changelog.d/fixes/-.md → appended to "### 🐛 Bug Fixes" +// changelog.d/maintenance/-.md → appended to "### 📝 Maintenance" +// File content = the exact bullet line(s), starting with "- " (continuation lines +// allowed). Credit format stays the repo norm: "(#PR — thanks @user)". +// +// Usage: +// node scripts/release/aggregate-changelog.mjs [--dry-run] +// --dry-run print the would-be CHANGELOG.md to stdout and list fragments; +// touch nothing. +// +// On a real run, aggregated fragment files are DELETED (leaving README.md and the +// .gitkeep placeholders) — the caller commits both the CHANGELOG.md update and the +// deletions in one commit. + +import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const FRAGMENTS_DIR = "changelog.d"; + +/** Section subdir → the CHANGELOG heading its bullets are appended under. */ +export const SECTIONS = Object.freeze({ + features: "### ✨ New Features", + fixes: "### 🐛 Bug Fixes", + maintenance: "### 📝 Maintenance", +}); + +const SKIP_FILES = new Set(["README.md", ".gitkeep"]); + +/** + * Validate one fragment's text. Returns null when OK, or a human-readable error. + * Pure — unit-tested. + */ +export function validateFragmentText(text) { + const body = String(text || "").replace(/^/, ""); + const lines = body.split("\n"); + const firstContent = lines.find((l) => l.trim().length > 0); + if (!firstContent) return "empty fragment"; + if (!firstContent.trimStart().startsWith("- ")) { + return 'fragment must start with a markdown bullet ("- ")'; + } + if (/^(<{7}|={7}|>{7})/m.test(body)) return "fragment contains merge-conflict markers"; + return null; +} + +/** + * Collect fragments from /changelog.d, sorted by filename per section for a + * deterministic output order. Returns { features: [...], fixes: [...], + * maintenance: [...], invalid: [{file, error}] } where each valid entry is + * { file, text } (text trimmed of trailing whitespace). + */ +export function collectFragments(root) { + const out = { features: [], fixes: [], maintenance: [], invalid: [] }; + const base = join(root, FRAGMENTS_DIR); + if (!existsSync(base)) return out; + for (const section of Object.keys(SECTIONS)) { + const dir = join(base, section); + if (!existsSync(dir)) continue; + const files = readdirSync(dir) + .filter((f) => f.endsWith(".md") && !SKIP_FILES.has(f)) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + for (const f of files) { + const file = join(dir, f); + const text = readFileSync(file, "utf8").replace(/\s+$/, ""); + const error = validateFragmentText(text); + if (error) out.invalid.push({ file: relative(root, file), error }); + else out[section].push({ file: relative(root, file), text }); + } + } + return out; +} + +/** + * Append bullets at the END of a living-section heading's bullet block (before the + * next "##"/"###" heading). Operates on the FIRST occurrence of the heading — in this + * repo's CHANGELOG the living cycle section always appears first. Pure — unit-tested. + * Throws when a needed heading is missing (the release captain adds the heading; the + * script never invents structure). + */ +export function insertBullets(changelogText, bulletsBySection) { + let lines = changelogText.split("\n"); + for (const [section, heading] of Object.entries(SECTIONS)) { + const bullets = (bulletsBySection[section] || []).map((b) => b.text ?? b); + if (bullets.length === 0) continue; + const headIdx = lines.findIndex((l) => l.trim() === heading); + if (headIdx === -1) { + throw new Error( + `heading "${heading}" not found in CHANGELOG.md — add it to the living section before aggregating ${section} fragments` + ); + } + // End of this section's block: last non-empty line before the next heading. + let nextHead = lines.length; + for (let i = headIdx + 1; i < lines.length; i++) { + if (/^##/.test(lines[i])) { + nextHead = i; + break; + } + } + let insertAt = nextHead; + while (insertAt > headIdx + 1 && lines[insertAt - 1].trim() === "") insertAt--; + const block = bullets.flatMap((b) => b.split("\n")); + lines = [...lines.slice(0, insertAt), ...block, ...lines.slice(insertAt)]; + } + return lines.join("\n"); +} + +/** + * Aggregate fragments into CHANGELOG.md. Returns a summary object. When dryRun is + * true nothing is written or deleted. + */ +export function aggregate({ root = ROOT, dryRun = false } = {}) { + const collected = collectFragments(root); + if (collected.invalid.length > 0) { + const detail = collected.invalid.map((i) => ` ✗ ${i.file}: ${i.error}`).join("\n"); + throw new Error(`invalid changelog fragments:\n${detail}`); + } + const total = collected.features.length + collected.fixes.length + collected.maintenance.length; + const changelogPath = join(root, "CHANGELOG.md"); + const before = readFileSync(changelogPath, "utf8"); + const after = total === 0 ? before : insertBullets(before, collected); + if (!dryRun && total > 0) { + writeFileSync(changelogPath, after); + for (const section of Object.keys(SECTIONS)) { + for (const { file } of collected[section]) unlinkSync(join(root, file)); + } + } + return { total, collected, changed: total > 0, after }; +} + +function main() { + const dryRun = process.argv.includes("--dry-run"); + const result = aggregate({ dryRun }); + if (result.total === 0) { + console.log("[aggregate-changelog] no fragments to aggregate — nothing to do."); + return 0; + } + for (const section of Object.keys(SECTIONS)) { + for (const { file } of result.collected[section]) { + console.log(`[aggregate-changelog] ${dryRun ? "would aggregate" : "aggregated"} ${file}`); + } + } + console.log( + `[aggregate-changelog] ${result.total} fragment(s) → CHANGELOG.md${dryRun ? " (dry-run, nothing written)" : " (fragments deleted — commit CHANGELOG.md + deletions together)"}` + ); + return 0; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + process.exit(main()); +} diff --git a/scripts/release/merge-train.sh b/scripts/release/merge-train.sh new file mode 100755 index 0000000000..217a66212e --- /dev/null +++ b/scripts/release/merge-train.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# scripts/release/merge-train.sh — batch-validate N queued PRs as ONE merged result. +# +# Why: in a merge-storm, waiting for each PR's CI after each sibling merge costs +# O(N²) CI runs. The train merges every queued PR into a throwaway worktree cut from +# the release tip, runs the full fast-gates parity suite ONCE on the final result, and +# prints the evidence block that authorizes `gh pr merge --squash --admin` for each +# train member (merge-gates.md §7 — owner-approved policy extension of §4, 2026-07-09). +# +# Designed for the 32-core runner box (192.168.0.113) or any checkout with +# node_modules. It only READS from origin — it never pushes, never merges PRs, never +# touches other worktrees, and never uses `git stash` (Hard Rule #22a). +# +# Usage: +# scripts/release/merge-train.sh [--plan] [...] +# --plan print the planned steps and exit 0 (no worktree, no network) — used by +# the unit test and for a quick sanity read. +# +# Exit codes: 0 = suite green (evidence printed); 1 = usage error; 2 = suite red; +# PRs whose merge conflicts are EJECTED (reported, train continues). +set -euo pipefail + +PLAN=0 +if [ "${1:-}" = "--plan" ]; then + PLAN=1 + shift +fi + +if [ $# -lt 2 ]; then + echo "usage: $0 [--plan] [...]" >&2 + exit 1 +fi + +BASE="$1" +shift +PRS=("$@") +for N in "${PRS[@]}"; do + case "$N" in + ''|*[!0-9]*) echo "error: PR number '$N' is not numeric" >&2; exit 1 ;; + esac +done + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +SUITE=( + "npm run typecheck:core" + "node scripts/check/check-file-size.mjs" + "node scripts/check/check-complexity.mjs" + "node scripts/check/check-cognitive-complexity.mjs" + "node scripts/check/check-changelog-integrity.mjs" + "TEST_SHARD=1/2 npm run test:unit:ci:shard" + "TEST_SHARD=2/2 npm run test:unit:ci:shard" + "npm run test:vitest" +) + +if [ "$PLAN" = "1" ]; then + echo "[merge-train] PLAN — base=origin/${BASE} prs=${PRS[*]}" + echo "[merge-train] 1. worktree add .claude/worktrees/merge-train- --detach origin/${BASE}" + for N in "${PRS[@]}"; do + echo "[merge-train] 2. fetch origin pull/${N}/head && merge (conflict → EJECT #${N}, continue)" + done + i=3 + for c in "${SUITE[@]}"; do + echo "[merge-train] ${i}. ${c}" + i=$((i + 1)) + done + echo "[merge-train] ${i}. green → print --admin evidence per PR; red → exit 2 (bisect + eject)" + echo "[merge-train] ${i}. teardown: git worktree remove --force (trap EXIT)" + exit 0 +fi + +if [ -z "$ROOT" ]; then + echo "error: not inside a git checkout" >&2 + exit 1 +fi + +TS="$(date +%Y%m%d-%H%M%S)" +WT="$ROOT/.claude/worktrees/merge-train-$TS" +LOG="$WT-suite.log" + +cleanup() { + git -C "$ROOT" worktree remove --force "$WT" 2>/dev/null || true +} +trap cleanup EXIT + +echo "[merge-train] fetching origin/${BASE}…" +git -C "$ROOT" fetch origin "$BASE" --quiet +git -C "$ROOT" worktree add --detach "$WT" "origin/$BASE" --quiet +# reuse the main checkout's node_modules (same convention as dev worktrees) +[ -e "$WT/node_modules" ] || ln -s "$ROOT/node_modules" "$WT/node_modules" + +EJECTED=() +BOARDED=() +for N in "${PRS[@]}"; do + echo "[merge-train] boarding #${N}…" + if ! git -C "$WT" fetch origin "pull/${N}/head" --quiet; then + echo "[merge-train] ✗ #${N} EJECTED — could not fetch pull/${N}/head" + EJECTED+=("$N") + continue + fi + if git -C "$WT" merge FETCH_HEAD --no-edit --quiet >/dev/null 2>&1; then + BOARDED+=("$N") + else + git -C "$WT" merge --abort 2>/dev/null || true + echo "[merge-train] ✗ #${N} EJECTED — merge conflict vs the train (route it through the normal §5 path)" + EJECTED+=("$N") + fi +done + +if [ ${#BOARDED[@]} -eq 0 ]; then + echo "[merge-train] no PR boarded — nothing to validate." >&2 + exit 1 +fi + +TIP="$(git -C "$WT" rev-parse HEAD)" +EJ_MSG="" +[ ${#EJECTED[@]} -gt 0 ] && EJ_MSG=" — ejected: ${EJECTED[*]}" +echo "[merge-train] train tip ${TIP} — boarded: ${BOARDED[*]}${EJ_MSG}" +echo "[merge-train] running parity suite (log: ${LOG})…" + +for c in "${SUITE[@]}"; do + echo "[merge-train] ▶ ${c}" + if ! (cd "$WT" && eval "$c") >>"$LOG" 2>&1; then + echo "[merge-train] ✗ SUITE RED at: ${c}" >&2 + echo "[merge-train] tail of ${LOG}:" >&2 + tail -30 "$LOG" >&2 + echo "[merge-train] bisect: re-run the failing gate on intermediate train commits, eject the offender, re-run." >&2 + exit 2 + fi +done + +echo "[merge-train] ✅ SUITE GREEN on ${TIP}" +echo "[merge-train] evidence line for each PR (paste before gh pr merge --squash --admin):" +for N in "${BOARDED[@]}"; do + echo " #${N}: Validated in local merge-train ${LOG} on $(hostname) @ ${TIP} (suite green)" +done +[ ${#EJECTED[@]} -gt 0 ] && echo "[merge-train] ejected (need the normal path): ${EJECTED[*]}" +exit 0 diff --git a/scripts/start-ws-server.mjs b/scripts/start-ws-server.mjs index 9b0d1068c3..00b4a62377 100644 --- a/scripts/start-ws-server.mjs +++ b/scripts/start-ws-server.mjs @@ -7,9 +7,9 @@ * node scripts/start-ws-server.mjs * * Environment variables: - * LIVE_WS_PORT — WebSocket server port (default: 20129) + * LIVE_WS_PORT — WebSocket server port (default: 20132) * LIVE_WS_HOST — WebSocket server host (default: 127.0.0.1) - * OMNIROUTE_DISABLE_LIVE_WS — Set to "1" or "true" to disable + * OMNIROUTE_ENABLE_LIVE_WS — Set to "0" or "false" to disable */ import { spawnSync } from "node:child_process"; @@ -60,10 +60,10 @@ export function buildSidecarSpawn(scriptUrl, env = process.env) { async function main() { if ( - process.env.OMNIROUTE_DISABLE_LIVE_WS === "1" || - process.env.OMNIROUTE_DISABLE_LIVE_WS === "true" + process.env.OMNIROUTE_ENABLE_LIVE_WS === "0" || + process.env.OMNIROUTE_ENABLE_LIVE_WS?.toLowerCase() === "false" ) { - console.log("[LiveWS] Disabled via OMNIROUTE_DISABLE_LIVE_WS"); + console.log("[LiveWS] Disabled via OMNIROUTE_ENABLE_LIVE_WS"); process.exit(0); } @@ -80,7 +80,7 @@ async function main() { const { startLiveDashboardServer } = await import("../src/server/ws/liveServer.ts"); - const port = parseInt(process.env.LIVE_WS_PORT || "20129", 10); + const port = parseInt(process.env.LIVE_WS_PORT || "20132", 10); const host = process.env.LIVE_WS_HOST || "127.0.0.1"; console.log(`[LiveWS] Starting dashboard WebSocket server on ${host}:${port}...`); diff --git a/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx b/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx index df4bf66169..985e1800ef 100644 --- a/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx +++ b/src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx @@ -115,7 +115,7 @@ export function CompareView({ text }: CompareViewProps) { - + {rows.map((r) => { diff --git a/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx b/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx index 9c3977a7b1..0cadf7a356 100644 --- a/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx +++ b/src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx @@ -20,7 +20,7 @@ export function PlaygroundInput({ text, onText, active, onToggleActive, onRun, l
EngineSavingsRetençãoOut tokFidelidade
EngineSavingsRetentionOut tokFidelity
+ + + + + + + + + + {rows.map((row, i) => ( + + + + + + + ))} + +
onToggleSort("date")} + > + {dateLabel} + onToggleSort("provider")} + > + {providerLabel} + onToggleSort("requests")} + > + {requestsLabel} + onToggleSort("totalTokens")} + > + {totalLabel} +
{row.date} +
+ + {row.provider} +
+
+ {fmtFull(row.requests)} + + {fmt(row.totalTokens)} +
+ + ); +} diff --git a/src/shared/components/analytics/index.tsx b/src/shared/components/analytics/index.tsx index 41e82ca084..a9eb9d34ee 100644 --- a/src/shared/components/analytics/index.tsx +++ b/src/shared/components/analytics/index.tsx @@ -26,3 +26,4 @@ export { DailyTrendChart, ModelOverTimeChart } from "./rechartsUsageCharts"; export { default as ApiKeyFilterDropdown } from "./ApiKeyFilterDropdown"; export { default as CustomRangePicker } from "./CustomRangePicker"; +export { default as RequestCountByProviderDateTable } from "./RequestCountByProviderDateTable"; diff --git a/src/shared/components/analytics/requestCountSort.ts b/src/shared/components/analytics/requestCountSort.ts new file mode 100644 index 0000000000..fa83588f9d --- /dev/null +++ b/src/shared/components/analytics/requestCountSort.ts @@ -0,0 +1,24 @@ +/** + * requestCountSort — pure sort helper for #4009's request-count-by-provider-date table. + * Split out of RequestCountByProviderDateTable to keep that container under the + * max-lines-per-function complexity gate. + */ + +import type { ProviderDailyUsageRow, RequestCountSortField } from "./RequestCountTable"; + +export function sortProviderDailyUsageRows( + rows: ProviderDailyUsageRow[], + sortBy: RequestCountSortField, + sortOrder: "asc" | "desc" +): ProviderDailyUsageRow[] { + const arr = [...rows]; + arr.sort((a, b) => { + const va = a[sortBy]; + const vb = b[sortBy]; + if (typeof va === "string" && typeof vb === "string") { + return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va); + } + return sortOrder === "asc" ? Number(va) - Number(vb) : Number(vb) - Number(va); + }); + return arr; +} diff --git a/src/shared/components/analytics/useProviderDailyUsage.ts b/src/shared/components/analytics/useProviderDailyUsage.ts new file mode 100644 index 0000000000..da2a5265f3 --- /dev/null +++ b/src/shared/components/analytics/useProviderDailyUsage.ts @@ -0,0 +1,46 @@ +"use client"; + +/** + * useProviderDailyUsage — data hook for #4009's request-count-by-provider-date table. + * Split out of RequestCountByProviderDateTable to keep that container under the + * max-lines-per-function complexity gate. + */ + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { readFetchErrorMessage } from "@/shared/utils/fetchError"; +import type { ProviderDailyUsageRow } from "./RequestCountTable"; + +export function useProviderDailyUsage(range: string, dateFilter: string) { + const tCommon = useTranslations("common"); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchRows = useCallback(async () => { + try { + setLoading(true); + const params = new URLSearchParams(); + if (dateFilter) { + params.set("date", dateFilter); + } else { + params.set("range", range); + } + const res = await fetch(`/api/usage/requests-by-provider-date?${params.toString()}`); + if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error"))); + const data = await res.json(); + setRows(Array.isArray(data.rows) ? data.rows : []); + setError(null); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }, [range, dateFilter, tCommon]); + + useEffect(() => { + fetchRows(); + }, [fetchRows]); + + return { rows, loading, error }; +} diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index b42c26506a..166338f321 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -306,7 +306,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ key: "OMNIROUTE_ENABLE_LIVE_WS", label: "Live Dashboard WebSocket", description: - "Start the real-time dashboard WebSocket server on import (port 20129, loopback-bound by default). Default: enabled. Set to '0' or 'false' to disable. LAN exposure requires LIVE_WS_HOST=0.0.0.0 + LIVE_WS_ALLOWED_ORIGINS.", + "Start the real-time dashboard WebSocket server on import (port 20132, loopback-bound by default). Default: enabled. Set to '0' or 'false' to disable. LAN exposure requires LIVE_WS_HOST=0.0.0.0 + LIVE_WS_ALLOWED_ORIGINS.", descriptionI18nKey: "featureFlagOmnirouteEnableLiveWsDescription", category: "runtime", defaultValue: "true", diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index 1d8e767189..b06e1f7679 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -171,18 +171,20 @@ export const WEB_COOKIE_PROVIDERS = { "Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies)", }, lmarena: { + // Wire id stays `lmarena` for DB/combo/model-prefix back-compat. + // Product rebranded LMArena → Arena (arena.ai) in Jan 2026. id: "lmarena", alias: "lma", - name: "LMArena (Free)", + name: "Arena (Free)", icon: "auto_awesome", color: "#FF6B6B", - textIcon: "LMA", - website: "https://lmarena.ai", + textIcon: "AR", + website: "https://arena.ai", hasFree: true, freeNote: - "Free model comparison platform — 40+ models (GPT, Claude, Gemini, Llama). No subscription required.", + "Free model comparison platform (formerly LMArena) at arena.ai — Direct-chat catalog of chat models (GPT, Claude, Gemini, Llama, …). No subscription required.", authHint: - "Paste the full Cookie header from lmarena.ai (DevTools → Network → request → Cookie). The session is now split across arena-auth-prod-v1.0, .1, … — copy the whole header. Optional — works with free tier for basic comparisons.", + "Paste the full Cookie header from arena.ai (DevTools → Network → request → Cookie). Include arena-auth-prod-v1.0/.1… and cf_clearance/__cf_bm when present. OmniRoute uses Chrome TLS impersonation; if Arena still 403s, set providerSpecificData.recaptchaV3Token from a live browser session.", riskNoticeVariant: "webCookie", }, "yuanbao-web": { diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 77d3d3ca5e..22b422f79e 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -14,6 +14,7 @@ export type WebSessionCredentialRequirement = * AND the Cookie header, so the one-line cookie hint reads circular). */ hintKey?: string; + hintFallback?: string; } | { kind: "none"; @@ -229,16 +230,16 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { }, lmarena: { kind: "cookie", - // lmarena.ai's auth cookie is `arena-auth-prod-v1` (the legacy hint said `session`, + // arena.ai's auth cookie is `arena-auth-prod-v1` (the legacy hint said `session`, // which never matched the real cookie name and confused users). #3810 // // #4271: LMArena migrated to Supabase SSR chunked cookies — the single // `arena-auth-prod-v1` cookie is now empty and the session is split across // `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … Users must paste the FULL // Cookie header so the executor can reconstruct the single cookie from chunks. - credentialName: "arena-auth-prod-v1", + credentialName: "full Cookie header (arena-auth-prod-v1.0 + arena-auth-prod-v1.1)", placeholder: - "Paste the full Cookie header from lmarena.ai (the session is now split across arena-auth-prod-v1.0, .1, …)", + "arena-auth-prod-v1.0=...; arena-auth-prod-v1.1=...; other=value (full Cookie header from arena.ai)", acceptsFullCookieHeader: true, storageKeys: [ "cookie", @@ -247,6 +248,9 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { "arena-auth-prod-v1.1", "session", ], + hintKey: "lmarenaWebCookieHint", + hintFallback: + "Open arena.ai, sign in, then copy the full Cookie header from a Network request. Include arena-auth-prod-v1.0 and arena-auth-prod-v1.1 (and further chunks if present), preferably with cf_clearance. Do not paste only the empty arena-auth-prod-v1 cookie. Optional: providerSpecificData.recaptchaV3Token if create-evaluation still returns 403.", }, } satisfies Record; diff --git a/src/shared/services/cliInstallFallback.ts b/src/shared/services/cliInstallFallback.ts new file mode 100644 index 0000000000..dfe57c3bd8 --- /dev/null +++ b/src/shared/services/cliInstallFallback.ts @@ -0,0 +1,69 @@ +import fsSync from "fs"; + +/** + * #6701 — 9router-parity fallback for CLI install detection. + * + * `getCliRuntimeStatus()` in `cliRuntime.ts` determines `installed` from + * binary resolution alone (known install paths + a `where`/`which` PATH + * search). If the binary is not currently resolvable — stale PATH inherited + * by a long-running/background OmniRoute process, the binary having moved, + * or an install method we don't enumerate yet — it used to unconditionally + * report `installed:false`, even when the tool's own settings/config file on + * disk proves it was installed and used before. + * + * Upstream 9router's equivalent route + * (`src/app/api/cli-tools/claude-settings/route.js::checkClaudeInstalled()`) + * has a second-chance fallback: when `where`/`which` fails, it still reports + * `installed:true` if the settings file exists. This restores that fallback + * for any CLI tool that declares a `settings` config path (currently + * `claude` and `droid` — see `CLI_TOOLS` in `cliRuntime.ts`). + * + * Only applies when the lookup's own reason is "not_found" — i.e. the binary + * genuinely couldn't be located on PATH/known install paths. Deliberate + * security rejections (unsafe/relative env override paths, symlink escapes, + * suspicious file sizes, etc.) must stay `installed:false` regardless of + * whether a settings file happens to exist. + */ +export interface NotInstalledResult { + installed: false; + runnable: boolean; + command: string | null; + commandPath: string | null; + reason: string; + runtimeMode: string; + requiresBinary: boolean; +} + +export interface SettingsFallbackResult { + installed: true; + runnable: false; + command: string | null; + commandPath: null; + reason: "settings_found_binary_unresolved"; + runtimeMode: string; + requiresBinary: boolean; +} + +/** + * Given the resolved settings-file path for a tool (or undefined if the tool + * has none) and the "not installed" result the binary lookup already + * produced, return a settings-fallback result when the settings file exists + * on disk, or the original "not installed" result unchanged otherwise. + */ +export const withSettingsFallback = ( + settingsPath: string | undefined, + notInstalledResult: NotInstalledResult +): NotInstalledResult | SettingsFallbackResult => { + if (notInstalledResult.reason !== "not_found") return notInstalledResult; + if (!settingsPath || !fsSync.existsSync(settingsPath)) return notInstalledResult; + + return { + installed: true, + runnable: false, + command: notInstalledResult.command, + commandPath: null, + reason: "settings_found_binary_unresolved", + runtimeMode: notInstalledResult.runtimeMode, + requiresBinary: notInstalledResult.requiresBinary, + }; +}; diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index 5ccfd27221..c7e778a9a0 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -5,7 +5,7 @@ import path from "path"; import { spawn, execFileSync } from "child_process"; import { getHermesHome } from "@/lib/cli-helper/config-generator/hermesHome"; import { getCachedLoginShellPath, mergeShellPath } from "./loginShellPath"; - +import { withSettingsFallback } from "./cliInstallFallback"; const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]); const FALSE_VALUES = new Set(["0", "false", "no", "off"]); @@ -1085,7 +1085,7 @@ export const getCliRuntimeStatus = async (toolId: string) => { const command = located.command; if (!located.installed) { - return { + return withSettingsFallback(getCliConfigPaths(toolId)?.settings, { installed: false, runnable: false, command, @@ -1093,7 +1093,7 @@ export const getCliRuntimeStatus = async (toolId: string) => { reason: located.reason || "not_found", runtimeMode, requiresBinary, - }; + }); } if (located.reason === "not_executable") { diff --git a/src/shared/utils/rateLimiter.ts b/src/shared/utils/rateLimiter.ts index a4da29cf88..bb268ba5f0 100644 --- a/src/shared/utils/rateLimiter.ts +++ b/src/shared/utils/rateLimiter.ts @@ -1,4 +1,4 @@ -import Redis from "ioredis"; +import type Redis from "ioredis"; // Redis is optional. When REDIS_URL is unset, use a process-local fallback // instead of probing localhost on every API request. @@ -7,7 +7,17 @@ if (process.env.NODE_ENV === "production" && !REDIS_URL) { console.warn("[REDIS] REDIS_URL is not set in production. Using in-memory rate limiting."); } -let redisClient: Redis | null = null; +// #6559 — `ioredis` must stay a LAZY dependency here. This module is bundled +// (via esbuild --packages=external) into the MCP server output, and esbuild +// hoists any static top-level `import ... from "ioredis"` into a real +// top-level ESM import in the compiled bundle — even though this module is +// only ever reached through a dynamic `await import(...)` several call-sites +// deep (apiKeys.ts -> mcpCallerIdentity.ts -> compressionTools.ts -> server.ts). +// A static import forces 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. Mirrors the established +// soft-dependency pattern in src/lib/quota/redisQuotaStore.ts. +let redisClientPromise: Promise | null = null; export function isRedisConfigured(): boolean { return REDIS_URL.length > 0; @@ -43,29 +53,41 @@ export function _createRedisLogThrottleForTests() { return createRedisLogThrottle(); } -export function getRedisClient() { +/** + * Return the singleton Redis client, creating it (lazily importing `ioredis`) + * on first call. Throws SYNCHRONOUSLY (not a rejected Promise) when Redis is + * not configured — callers rely on this to fail fast without an `await`. + * Otherwise returns a Promise that resolves once the client is constructed. + */ +export function getRedisClient(): Promise { if (!isRedisConfigured()) { throw new Error("Redis is not configured"); } - if (!redisClient) { - redisClient = new Redis(REDIS_URL, { - maxRetriesPerRequest: 3, - enableReadyCheck: false, - retryStrategy(times) { - return Math.min(times * 50, 2000); // Exponential backoff - }, - }); - redisClient.on("error", (err) => { - // Throttle: log once per error-state change instead of on every retry (#4878). - if (redisLogThrottle.shouldLog(err.message)) { - console.error("[REDIS] Error:", err.message); - } - }); - // A successful connection resets the throttle so the next failure logs again. - redisClient.on("ready", () => redisLogThrottle.reset()); + if (!redisClientPromise) { + redisClientPromise = (async () => { + // Lazy dynamic import — see the #6559 note above the singleton declaration. + const mod = await import("ioredis"); + const RedisCtor = (mod.default ?? mod) as typeof Redis; + const client = new RedisCtor(REDIS_URL, { + maxRetriesPerRequest: 3, + enableReadyCheck: false, + retryStrategy(times) { + return Math.min(times * 50, 2000); // Exponential backoff + }, + }); + client.on("error", (err) => { + // Throttle: log once per error-state change instead of on every retry (#4878). + if (redisLogThrottle.shouldLog(err.message)) { + console.error("[REDIS] Error:", err.message); + } + }); + // A successful connection resets the throttle so the next failure logs again. + client.on("ready", () => redisLogThrottle.reset()); + return client; + })(); } - return redisClient; + return redisClientPromise; } export interface RateLimitRule { @@ -216,7 +238,7 @@ export async function checkRateLimit( return checkInMemoryRateLimit(FALLBACK_MEMORY_STORE, keyId, rules); } - const redis = getRedisClient(); + const redis = await getRedisClient(); const args: (string | number)[] = [Math.floor(Date.now() / 1000)]; diff --git a/src/shared/utils/wsPath.ts b/src/shared/utils/wsPath.ts new file mode 100644 index 0000000000..b1a47d47db --- /dev/null +++ b/src/shared/utils/wsPath.ts @@ -0,0 +1,29 @@ +/** + * Derive the live WebSocket path from `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL`. + * + * Only `ws://` or `wss://` URLs are accepted (mirrors the scheme guard in + * `getLivePublicUrl()`). The pathname is extracted and used as the WS upgrade + * path; if the URL has no pathname (or is `/`), falls back to `/live-ws`. + * + * Used by: + * - `src/app/api/v1/ws/route.ts` — handshake response `path` field + * - `src/hooks/useLiveDashboard.ts` — build-time path constant + runtime discovery + * + * No env var is introduced — this reads the existing `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL`. + */ +export function deriveLiveWsPath(publicUrl?: string): string { + if (!publicUrl) return "/live-ws"; + if (!publicUrl.startsWith("ws://") && !publicUrl.startsWith("wss://")) return "/live-ws"; + try { + const parsed = new URL(publicUrl); + const pathname = parsed.pathname; + return pathname && pathname !== "/" ? pathname : "/live-ws"; + } catch { + return "/live-ws"; + } +} + +/** Convenience: read the env var at call time and derive the path. */ +export function getLiveWsPath(): string { + return deriveLiveWsPath(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL); +} diff --git a/src/shared/validation/compressionConfigSchemas.ts b/src/shared/validation/compressionConfigSchemas.ts index a9472c4227..c57be7dd7d 100644 --- a/src/shared/validation/compressionConfigSchemas.ts +++ b/src/shared/validation/compressionConfigSchemas.ts @@ -61,6 +61,7 @@ export const rtkConfigSchema = z groupingThreshold: z.number().int().min(2).max(100).optional(), stripCodeComments: z.boolean().optional(), preserveDocstrings: z.boolean().optional(), + enableRenderers: z.boolean().optional(), }) .strict(); @@ -140,6 +141,19 @@ export const ultraConfigSchema = z const noConfigSchema = z.object({}).strict(); +// Structural engines (session-dedup / ccr / headroom / relevance / llmlingua) do not +// expose a fixed intensity enum in ENGINE_CATALOG — accept optional free-form intensity +// and a loose config bag so GET→PUT round-trips of stackedPipeline succeed (#6747). +const structuralStepConfigSchema = z.record(z.string(), z.unknown()).optional(); + +/** + * Writable stacked-pipeline step shape for PUT /api/settings/compression and + * PUT /api/context/combos/[id]. MUST accept every engine id in ENGINE_CATALOG / + * GET /api/compression/engines and every engine the DB normalizer keeps + * (src/lib/db/compression.ts STACKED_PIPELINE_ENGINE_IDS). Issue #6747: a 5-engine + * discriminator rejected session-dedup/ccr/headroom/relevance/llmlingua on write even + * though GET returned them. + */ export const stackedPipelineStepSchema = z.discriminatedUnion("engine", [ z .object({ @@ -158,7 +172,8 @@ export const stackedPipelineStepSchema = z.discriminatedUnion("engine", [ z .object({ engine: z.literal("aggressive"), - intensity: z.literal("standard").optional(), + // #6747: previously only "standard"; GET / engines.level may echo "ultra" + intensity: z.enum(["standard", "ultra"]).optional(), config: aggressiveConfigSchema.optional(), }) .strict(), @@ -176,6 +191,48 @@ export const stackedPipelineStepSchema = z.discriminatedUnion("engine", [ config: rtkConfigSchema.optional(), }) .strict(), + z + .object({ + engine: z.literal("session-dedup"), + intensity: z.string().optional(), + config: structuralStepConfigSchema, + }) + .strict(), + z + .object({ + engine: z.literal("ccr"), + intensity: z.string().optional(), + config: structuralStepConfigSchema, + }) + .strict(), + z + .object({ + engine: z.literal("headroom"), + intensity: z.string().optional(), + config: structuralStepConfigSchema, + }) + .strict(), + z + .object({ + engine: z.literal("relevance"), + intensity: z.string().optional(), + config: structuralStepConfigSchema, + }) + .strict(), + z + .object({ + engine: z.literal("llmlingua"), + intensity: z.string().optional(), + config: structuralStepConfigSchema, + }) + .strict(), + z + .object({ + engine: z.literal("omniglyph"), + intensity: z.string().optional(), + config: structuralStepConfigSchema, + }) + .strict(), ]); /** @@ -184,17 +241,23 @@ export const stackedPipelineStepSchema = z.discriminatedUnion("engine", [ * dropdowns and `stackedPipelineStepSchema`: every engine/intensity offered here is, * by construction, accepted by the API update schema. * - * Do NOT add an engine here that is not a branch of `stackedPipelineStepSchema` — the - * `PUT /api/context/combos/[id]` route validates against that discriminated union and - * would reject the payload with HTTP 400 (#4955: the UI previously offered `headroom`, - * `session-dedup`, `ccr`, `llmlingua`, none of which the union accepts, so selecting - * one silently failed the save). The parity is guarded by a unit test. + * Every ENGINE_CATALOG id must appear here (empty intensity list = no level selector). + * Parity with `stackedPipelineStepSchema` is guarded by unit tests (#4955 / #6747). + * #4955 fixed a UI/schema drift by shrinking the UI; #6747 expands the schema so the + * full catalog (and GET stackedPipeline) can round-trip on PUT. */ export const STACKED_PIPELINE_ENGINE_INTENSITIES: Record = { - rtk: ["minimal", "standard", "aggressive"], - caveman: ["lite", "full", "ultra"], + // Order matches ENGINE_CATALOG stackPriority for readability + "session-dedup": [], + ccr: [], lite: ["lite"], - aggressive: ["standard"], + rtk: ["minimal", "standard", "aggressive"], + headroom: [], + relevance: [], + caveman: ["lite", "full", "ultra"], + aggressive: ["standard", "ultra"], + llmlingua: [], + omniglyph: [], ultra: ["ultra"], }; diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index bd854cae80..dcf9457217 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -41,10 +41,21 @@ const providerNodeIconUrlSchema = z }) .optional(); +// #6715: the `apiKey` field is reused as the raw `Cookie:` header value for +// cookie-based web providers (Gemini Business, Copilot M365, ChatGPT Web, +// Claude Web, …). Real multi-cookie session headers (many `__Secure-*` entries, +// large session tokens) legitimately exceed the old 10,000-char cap, so saving +// a cookie that the provider's own `validate` check (validateProviderApiKeySchema, +// uncapped) had already accepted failed with HTTP 400 "Too big …<=10000". Raised +// to a still-bounded ceiling — well under the 10 MB default request-body limit and +// the unconstrained SQLite TEXT column — so garbage input is still rejected. +// Same fix shape as #6562 (priority cap raised to 100_000). +export const MAX_PROVIDER_CREDENTIAL_LENGTH = 100_000; + export const createProviderSchema = z .object({ provider: z.string().min(1).max(100), - apiKey: z.string().max(10000).optional(), + apiKey: z.string().max(MAX_PROVIDER_CREDENTIAL_LENGTH).optional(), name: z.string().min(1).max(200), priority: z.number().int().min(1).max(100).optional(), globalPriority: z.number().int().min(1).max(100).nullable().optional(), @@ -91,7 +102,7 @@ export const bulkCreateProviderSchema = z .array( z.object({ name: z.string().min(1).max(200), - apiKey: z.string().min(1).max(10000), + apiKey: z.string().min(1).max(MAX_PROVIDER_CREDENTIAL_LENGTH), // Per-key account id — required for cloudflare-ai (enforced in superRefine below). accountId: z.string().min(1).max(200).optional(), }) @@ -304,7 +315,7 @@ export const updateProviderConnectionSchema = z globalPriority: z.union([z.coerce.number().int().min(1).max(100_000), z.null()]).optional(), defaultModel: z.union([z.string().max(200), z.null()]).optional(), isActive: z.boolean().optional(), - apiKey: z.string().max(10000).optional(), + apiKey: z.string().max(MAX_PROVIDER_CREDENTIAL_LENGTH).optional(), testStatus: z.string().max(50).optional(), lastError: z.union([z.string(), z.null()]).optional(), lastErrorAt: z.union([z.string(), z.null()]).optional(), diff --git a/src/shared/validation/schemas/proxy.ts b/src/shared/validation/schemas/proxy.ts index b6673c15e6..db3e009a74 100644 --- a/src/shared/validation/schemas/proxy.ts +++ b/src/shared/validation/schemas/proxy.ts @@ -14,7 +14,6 @@ import { } from "@/shared/constants/upstreamHeaders"; import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts"; - export const proxyConfigSchema = z .object({ type: z @@ -201,6 +200,7 @@ export const PROXY_POOL_ROTATION_STRATEGY_VALUES = [ "round-robin", "random", "sticky", + "latency", ] as const; // Add/remove one proxy to/from a scope's pool. proxyId is REQUIRED (unlike the @@ -240,4 +240,4 @@ export const proxyRotationStrategySchema = z path: ["scopeId"], }); } - }); \ No newline at end of file + }); diff --git a/stryker.conf.json b/stryker.conf.json index dcdcb45189..d443617d97 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -174,6 +174,8 @@ "tests/unit/headroom-codex-quota-snapshot-6379.test.ts", "tests/unit/headroom-proxy-lifecycle.test.ts", "tests/unit/idempotency-fusion-collision.test.ts", + "tests/unit/issue-6638-ollama-quota.test.ts", + "tests/unit/issue-6686-quota-preflight-coverage.test.ts", "tests/unit/livews-forward-backoff-4604.test.ts", "tests/unit/management-auth-hardening.test.ts", "tests/unit/mark-account-unavailable-numeric-epoch-guard.test.ts", @@ -192,6 +194,7 @@ "tests/unit/oauth-redirect-uri-mismatch.test.ts", "tests/unit/observability-fase04.test.ts", "tests/unit/observability-payloads.test.ts", + "tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts", "tests/unit/openapi-security-tiers.test.ts", "tests/unit/persist-429-cooldown-account-fallback.test.ts", "tests/unit/plan3-p0.test.ts", @@ -212,6 +215,7 @@ "tests/unit/rate-limit-manager.test.ts", "tests/unit/rate-limit-queue-timeout-lockout.test.ts", "tests/unit/responses-handler.test.ts", + "tests/unit/rotation-config-omniroute.test.ts", "tests/unit/route-explainability.test.ts", "tests/unit/route-guard-middleware-local-only.test.ts", "tests/unit/route-guard-plugins-local-only.test.ts", diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 46e7afe185..6a1bf66eb6 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -2566,6 +2566,29 @@ "stream": "https://api.llm7.io/v1/chat/completions" } }, + "lmarena": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "https://arena.ai/nextjs-api/stream/create-evaluation", + "stream": "https://arena.ai/nextjs-api/stream/create-evaluation" + } + }, "longcat": { "format": "openai", "headers": { @@ -3706,8 +3729,8 @@ } }, "url": { - "nonStream": "https://api.sensenova.cn/v1/chat/completions", - "stream": "https://api.sensenova.cn/v1/chat/completions" + "nonStream": "https://token.sensenova.cn/v1/chat/completions", + "stream": "https://token.sensenova.cn/v1/chat/completions" } }, "siliconflow": { diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 02fd6ed006..768f21f231 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -202,11 +202,11 @@ test("checkFallbackError preserves OAuth 429 exhausted-credit semantics", () => assert.equal(result.cooldownMs, COOLDOWN_MS.paymentRequired ?? 3600 * 1000); }); -test("checkFallbackError keeps API-key 429 quota text on the status-based resilience path", () => { +test("#6638: checkFallbackError classifies API-key 429 explicit quota text as quota_exhausted", () => { const result = checkFallbackError(429, "quota exceeded", 0, null, "openai", null, makeProfile()); assert.equal(result.shouldFallback, true); - assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); assert.equal(result.cooldownMs, 125); }); @@ -844,7 +844,7 @@ test("checkFallbackError routes API-key 429 'try again tomorrow' through resilie assert.equal(result.cooldownMs, 125); }); -test("checkFallbackError routes API-key 429 'daily quota' text through resilience cooldown", () => { +test("#6638: checkFallbackError routes API-key 429 'daily quota' text as quota_exhausted", () => { const result = checkFallbackError( 429, "You have exceeded your daily quota", @@ -855,8 +855,8 @@ test("checkFallbackError routes API-key 429 'daily quota' text through resilienc makeProfile() ); assert.equal(result.shouldFallback, true); - assert.equal(result.dailyQuotaExhausted, undefined); - assert.equal(result.cooldownMs, 125); + assert.equal(result.dailyQuotaExhausted, true); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); }); test("checkFallbackError preserves OAuth 429 daily quota semantics", () => { diff --git a/tests/unit/antigravity-safetysettings-5003.test.ts b/tests/unit/antigravity-safetysettings-5003.test.ts index 152a5eb3ff..f4cc058d15 100644 --- a/tests/unit/antigravity-safetysettings-5003.test.ts +++ b/tests/unit/antigravity-safetysettings-5003.test.ts @@ -3,13 +3,14 @@ import assert from "node:assert/strict"; import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; import { DEFAULT_SAFETY_SETTINGS } from "../../open-sse/translator/helpers/geminiHelper.ts"; +import { openaiToAntigravityRequest } from "../../open-sse/translator/request/openai-to-gemini.ts"; // Regression for #5003: the Antigravity (Google Cloud Code) request builder explicitly set // `safetySettings: undefined`, which `JSON.stringify` drops entirely. With no safetySettings // reaching Cloud Code, Google applies its server-side safety defaults that false-flag benign // technical prompts as `prohibited_content` (HTTP 200 with a blocked body that combo failover -// treats as terminal). The native Gemini paths all default to all-OFF -// (DEFAULT_SAFETY_SETTINGS); Antigravity must match for parity. +// treats as terminal). Antigravity still needs explicit all-OFF safety settings, +// but Cloud Code rejects HARM_CATEGORY_CIVIC_INTEGRITY on the v1internal endpoint. test("transformRequest defaults safetySettings to all-OFF when none supplied (#5003)", async () => { const executor = new AntigravityExecutor(); @@ -26,17 +27,21 @@ test("transformRequest defaults safetySettings to all-OFF when none supplied (#5 if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); const innerRequest = result.request as Record; + const antigravitySafetySettings = DEFAULT_SAFETY_SETTINGS.filter( + (setting) => setting.category !== "HARM_CATEGORY_CIVIC_INTEGRITY" + ); assert.deepEqual( innerRequest.safetySettings, - DEFAULT_SAFETY_SETTINGS, - "safetySettings must default to all-OFF for parity with native Gemini paths" + antigravitySafetySettings, + "safetySettings must default to all-OFF entries accepted by Cloud Code" ); }); -test("transformRequest honors a caller-supplied safetySettings (#5003)", async () => { +test("transformRequest honors caller-supplied safetySettings accepted by Cloud Code (#5003)", async () => { const executor = new AntigravityExecutor(); const callerSafety = [ { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" }, + { category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "OFF" }, ]; const body = { request: { @@ -54,7 +59,34 @@ test("transformRequest honors a caller-supplied safetySettings (#5003)", async ( const innerRequest = result.request as Record; assert.deepEqual( innerRequest.safetySettings, - callerSafety, - "a caller-supplied safetySettings must not be clobbered" + [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" }], + "caller-supplied safetySettings should preserve accepted entries and drop rejected ones" ); }); + +test("OpenAI Antigravity translation preserves caller-supplied safetySettings (#5003)", async () => { + const executor = new AntigravityExecutor(); + const callerSafety = [ + { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" }, + { category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "OFF" }, + ]; + const translated = openaiToAntigravityRequest( + "gemini-2.5-flash", + { + messages: [{ role: "user", content: "hi" }], + safetySettings: callerSafety, + }, + true, + { projectId: "project-1" } + ); + + const result = await executor.transformRequest("antigravity/gemini-2.5-flash", translated, true, { + projectId: "project-1", + }); + + if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); + const innerRequest = result.request as Record; + assert.deepEqual(innerRequest.safetySettings, [ + { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" }, + ]); +}); diff --git a/tests/unit/bailian-coding-plan-provider.test.ts b/tests/unit/bailian-coding-plan-provider.test.ts index 2492071a86..2dcbe63e55 100644 --- a/tests/unit/bailian-coding-plan-provider.test.ts +++ b/tests/unit/bailian-coding-plan-provider.test.ts @@ -293,13 +293,26 @@ test("getStaticModelsForProvider returns undefined for non-static providers", () }); test("getStaticModelsForProvider returns local image catalogs for image-only providers", () => { - const models = getStaticModelsForProvider("xai"); + // nanobanana has IMAGE_PROVIDERS rows but no chat registry models — specialty + // must still surface them. Chat+image providers (xai/lmarena/openai) keep + // image models exclusively in IMAGE_PROVIDERS (not the chat specialty list). + const models = getStaticModelsForProvider("nanobanana"); - assert.ok(models, "xAI should expose local image models"); - assert.deepEqual( - models.map((model) => model.id), - ["grok-imagine-image-quality", "grok-imagine-image"] - ); + assert.ok(models, "nanobanana should expose local image models"); + assert.ok(models.length >= 1); + assert.ok(models.every((m) => m.supportedEndpoints?.includes("images"))); +}); + +test("getStaticModelsForProvider does not dump IMAGE_PROVIDERS into chat specialty", () => { + for (const provider of ["lmarena", "openai", "xai"]) { + const models = getStaticModelsForProvider(provider) || []; + assert.ok( + !models.some((m) => m.supportedEndpoints?.includes("images")), + `${provider} chat specialty must not include image-only models` + ); + } + const lmarena = getStaticModelsForProvider("lmarena") || []; + assert.ok(!lmarena.some((m) => String(m.id).includes("flux"))); }); test("getStaticModelsForProvider returns models for other static providers", () => { diff --git a/tests/unit/bootstrap-env.test.ts b/tests/unit/bootstrap-env.test.ts index 45d2911776..204ceea672 100644 --- a/tests/unit/bootstrap-env.test.ts +++ b/tests/unit/bootstrap-env.test.ts @@ -120,6 +120,30 @@ test("bootstrapEnv fails closed when existing database cannot be inspected", () }); }); +test("bootstrapEnv ignores blank process.env values that would override persisted secrets (#6824)", () => { + withTempEnv(({ dataDir }) => { + process.env.DATA_DIR = dataDir; + fs.mkdirSync(dataDir, { recursive: true }); + + // Persisted secrets in server.env + fs.writeFileSync( + path.join(dataDir, "server.env"), + "STORAGE_ENCRYPTION_KEY=persisted-key\nJWT_SECRET=persisted-jwt\n", + "utf8" + ); + + // Simulate Docker `-e STORAGE_ENCRYPTION_KEY=` — sets an empty string + process.env.STORAGE_ENCRYPTION_KEY = ""; + process.env.JWT_SECRET = ""; + + const env = bootstrapEnv({ quiet: true }); + + // Empty process.env values must NOT override persisted secrets + assert.equal(env.STORAGE_ENCRYPTION_KEY, "persisted-key"); + assert.equal(env.JWT_SECRET, "persisted-jwt"); + }); +}); + test("bootstrapEnv ignores blank dataDirOverride values", () => { withTempEnv(({ dataDir }) => { process.env.DATA_DIR = dataDir; diff --git a/tests/unit/build/mcp-bundle-no-eager-ioredis.test.ts b/tests/unit/build/mcp-bundle-no-eager-ioredis.test.ts new file mode 100644 index 0000000000..ab382c6a83 --- /dev/null +++ b/tests/unit/build/mcp-bundle-no-eager-ioredis.test.ts @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// #6559 — `omniroute --mcp` crashed at Node ESM link time with +// ERR_MODULE_NOT_FOUND for 'ioredis'. +// +// Root cause: src/shared/utils/rateLimiter.ts had a top-level static +// `import Redis from "ioredis"`. esbuild's `--packages=external` bundling of +// the MCP server (scripts/build/prepublish.ts Step 8.5) hoists that into a +// real top-level ESM import in the bundled output, even though rateLimiter.ts +// is only ever reached via a dynamic `await import(...)` several call-sites +// deep. `ioredis` is not guaranteed to ship in the MCP-only bundle's +// node_modules, so the eager import can crash at link time before any +// `--mcp` startup code runs. +// +// This test reproduces the exact bundling step used at publish time and +// asserts the compiled output never contains a top-level static `ioredis` +// import — it must stay a lazy `await import("ioredis")`, matching the +// established soft-dependency pattern in src/lib/quota/redisQuotaStore.ts. + +const repoRoot = process.cwd(); + +test("MCP server bundle has no top-level static import of ioredis", () => { + const outDir = mkdtempSync(join(tmpdir(), "mcp-bundle-ioredis-")); + const outFile = join(outDir, "server.js"); + + try { + execFileSync( + "npx", + [ + "esbuild", + "open-sse/mcp-server/server.ts", + "--bundle", + "--platform=node", + "--packages=external", + "--format=esm", + `--outfile=${outFile}`, + ], + { cwd: repoRoot, stdio: "pipe" } + ); + + const bundled = readFileSync(outFile, "utf8"); + + // A static/hoisted ESM import resolves at module-link time and would + // crash the MCP server before startup if ioredis isn't in dist/node_modules. + assert.doesNotMatch( + bundled, + /^import\s+.*["']ioredis["'];?\s*$/m, + "MCP bundle must not eagerly (statically) import 'ioredis' at the top level — " + + "it must stay a lazy `await import(\"ioredis\")` (see src/lib/quota/redisQuotaStore.ts)" + ); + + // The lazy dynamic import from redisQuotaStore.ts must still be present — + // proves the assertion above isn't vacuously true (e.g. ioredis missing entirely). + assert.match( + bundled, + /await import\(\s*["']ioredis["']\s*\)/, + "expected the existing lazy dynamic import of ioredis to remain in the bundle" + ); + } finally { + rmSync(outDir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/case-collision-6584.test.ts b/tests/unit/case-collision-6584.test.ts new file mode 100644 index 0000000000..b3c3931785 --- /dev/null +++ b/tests/unit/case-collision-6584.test.ts @@ -0,0 +1,78 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Regression guard for issue #6584: webpack (OMNIROUTE_USE_TURBOPACK=0) build breaks on +// case-insensitive filesystems (macOS APFS default, Windows) because two source +// files in the same directory differ only by casing: +// src/app/(dashboard)/dashboard/playground/components/ReasoningControls.tsx +// src/app/(dashboard)/dashboard/playground/components/reasoningControls.ts +// On a case-sensitive FS (Linux, this sandbox) both files resolve fine, so this +// test does not literally reproduce the webpack warning/build error. Instead it +// encodes the same-directory case-collision root cause as a filesystem-level +// invariant check that fails deterministically on ANY OS: two distinct files +// whose relative path is identical after lower-casing (stem, ignoring extension, +// since extensionless imports are what collide) must not coexist in the same +// directory tree. + +const ROOT = path.resolve(__dirname, "..", ".."); +const SCAN_DIRS = ["src", "open-sse"]; +const EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]); +const IGNORE_DIR_NAMES = new Set(["node_modules", ".next", "dist", "build", ".git"]); + +function walk(dir: string, out: string[]): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (IGNORE_DIR_NAMES.has(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, out); + } else if (EXTENSIONS.has(path.extname(entry.name))) { + out.push(full); + } + } +} + +test("#6584: no two source files in the same directory differ only by casing", () => { + const files: string[] = []; + for (const d of SCAN_DIRS) { + const abs = path.join(ROOT, d); + if (fs.existsSync(abs)) walk(abs, files); + } + + // Group by (directory, lower-cased basename WITHOUT extension). Extensionless + // imports (e.g. `from "./reasoningControls"`) are resolved by webpack/Node's + // module resolver trying several extensions; on a case-insensitive filesystem + // that resolution collapses "ReasoningControls.tsx" and "reasoningControls.ts" + // into the same candidate, so the stem — not the full filename — is what must + // stay unique per directory regardless of extension. + const byDirLowerStem = new Map(); + for (const f of files) { + const dir = path.dirname(f); + const ext = path.extname(f); + const stem = path.basename(f, ext); + const lowerStem = stem.toLowerCase(); + const key = `${dir}::${lowerStem}`; + const arr = byDirLowerStem.get(key) ?? []; + arr.push(f); + byDirLowerStem.set(key, arr); + } + + const collisions: string[][] = []; + for (const arr of byDirLowerStem.values()) { + const distinctStems = new Set(arr.map((f) => path.basename(f, path.extname(f)))); + if (distinctStems.size > 1) { + collisions.push(arr); + } + } + + assert.deepEqual( + collisions, + [], + `Found case-only filename collisions (breaks webpack builds on case-insensitive filesystems): ${JSON.stringify(collisions, null, 2)}` + ); +}); diff --git a/tests/unit/changelog-fragments.test.ts b/tests/unit/changelog-fragments.test.ts new file mode 100644 index 0000000000..6be69c0a4d --- /dev/null +++ b/tests/unit/changelog-fragments.test.ts @@ -0,0 +1,184 @@ +// Guards the changelog FRAGMENTS pipeline (changelog.d/ → CHANGELOG.md), adopted +// 2026-07-09 to kill the CHANGELOG-eat / DIRTY merge-storm cascade: a PR adds ONE new +// file under changelog.d/
/ instead of editing CHANGELOG.md, so sibling PRs +// never conflict. Covers the aggregator (scripts/release/aggregate-changelog.mjs) and +// the fragment validation wired into the merge-integrity gate +// (scripts/check/check-changelog-integrity.mjs::findInvalidFragments). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const { SECTIONS, validateFragmentText, collectFragments, insertBullets, aggregate } = + await import("../../scripts/release/aggregate-changelog.mjs"); +const { findInvalidFragments } = await import("../../scripts/check/check-changelog-integrity.mjs"); + +const CHANGELOG_FIXTURE = `# Changelog + +## [Unreleased] + +## [3.8.47] — TBD + +_Living section — bullets land here as PRs merge._ + +### ✨ New Features + +- **existing feature**: already here (#1 — thanks @a) + +### 🐛 Bug Fixes + +- **fix(x):** existing fix (#2 — thanks @b) + +### 📝 Maintenance + +- chore: existing maintenance (#3) + +## [3.8.46] - 2026-07-04 + +### ✨ New Features + +- **old feature**: shipped (#0) +`; + +function makeRoot({ fragments = {} } = {}) { + const root = mkdtempSync(join(tmpdir(), "chfrag-")); + writeFileSync(join(root, "CHANGELOG.md"), CHANGELOG_FIXTURE); + mkdirSync(join(root, "changelog.d"), { recursive: true }); + for (const [rel, text] of Object.entries(fragments)) { + const abs = join(root, "changelog.d", rel); + mkdirSync(join(abs, ".."), { recursive: true }); + writeFileSync(abs, text); + } + return root; +} + +test("validateFragmentText accepts a bullet and rejects garbage", () => { + assert.equal(validateFragmentText("- **fix:** ok (#9 — thanks @x)"), null); + assert.equal(validateFragmentText("- multi\n continuation line"), null); + assert.match(validateFragmentText(""), /empty/); + assert.match(validateFragmentText("not a bullet"), /must start/); + assert.match(validateFragmentText("- ok\n<<<<<<< HEAD"), /conflict markers/); +}); + +test("collectFragments reads sections sorted and flags invalid files", () => { + const root = makeRoot({ + fragments: { + "fixes/6700-b.md": "- fix B (#6700)", + "fixes/6496-a.md": "- fix A (#6496)", + "features/6728-chaos.md": "- feat chaos (#6728)", + "features/bad.md": "no bullet here", + }, + }); + const c = collectFragments(root); + assert.deepEqual( + c.fixes.map((f) => f.text), + ["- fix A (#6496)", "- fix B (#6700)"] + ); + assert.equal(c.features.length, 1); + assert.equal(c.invalid.length, 1); + assert.match(c.invalid[0].file, /bad\.md/); + rmSync(root, { recursive: true, force: true }); +}); + +test("insertBullets appends at the END of each living section", () => { + const out = insertBullets(CHANGELOG_FIXTURE, { + features: [{ text: "- NEW feature bullet (#10)" }], + fixes: [{ text: "- NEW fix bullet (#11)" }], + maintenance: [{ text: "- NEW maintenance bullet (#12)" }], + }); + const lines = out.split("\n"); + const featIdx = lines.indexOf("- NEW feature bullet (#10)"); + const bugHeadIdx = lines.indexOf("### 🐛 Bug Fixes"); + const fixIdx = lines.indexOf("- NEW fix bullet (#11)"); + const maintHeadIdx = lines.indexOf("### 📝 Maintenance"); + const maintIdx = lines.indexOf("- NEW maintenance bullet (#12)"); + // Each new bullet lands after its own existing bullets, before the next heading. + assert.ok(featIdx > lines.indexOf("- **existing feature**: already here (#1 — thanks @a)")); + assert.ok(featIdx < bugHeadIdx, "feature bullet must stay inside the features section"); + assert.ok(fixIdx > bugHeadIdx && fixIdx < maintHeadIdx); + assert.ok(maintIdx > maintHeadIdx && maintIdx < lines.indexOf("## [3.8.46] - 2026-07-04")); + // Only the FIRST (living) occurrence of a heading is touched — the shipped 3.8.46 + // section is byte-identical. + assert.ok(out.includes("## [3.8.46] - 2026-07-04\n\n### ✨ New Features\n\n- **old feature**: shipped (#0)")); + // No existing bullet lost. + for (const existing of ["#1 — thanks @a", "existing fix (#2", "existing maintenance (#3"]) { + assert.ok(out.includes(existing)); + } +}); + +test("insertBullets throws when a needed heading is missing", () => { + const noMaint = CHANGELOG_FIXTURE.replace("### 📝 Maintenance\n\n- chore: existing maintenance (#3)\n", ""); + assert.throws( + () => insertBullets(noMaint, { maintenance: [{ text: "- x" }] }), + /📝 Maintenance.*not found/s + ); +}); + +test("aggregate dry-run touches nothing; real run writes and deletes fragments", () => { + const root = makeRoot({ + fragments: { "fixes/6800-real.md": "- real aggregated fix (#6800 — thanks @c)" }, + }); + const dry = aggregate({ root, dryRun: true }); + assert.equal(dry.total, 1); + assert.ok(!readFileSync(join(root, "CHANGELOG.md"), "utf8").includes("#6800")); + assert.ok(existsSync(join(root, "changelog.d/fixes/6800-real.md"))); + + const real = aggregate({ root }); + assert.equal(real.total, 1); + const after = readFileSync(join(root, "CHANGELOG.md"), "utf8"); + assert.ok(after.includes("- real aggregated fix (#6800 — thanks @c)")); + assert.ok(!existsSync(join(root, "changelog.d/fixes/6800-real.md")), "fragment must be deleted"); + + // Idempotence: nothing left → second run is a no-op. + const again = aggregate({ root }); + assert.equal(again.total, 0); + assert.equal(readFileSync(join(root, "CHANGELOG.md"), "utf8"), after); + rmSync(root, { recursive: true, force: true }); +}); + +test("aggregate refuses invalid fragments loudly", () => { + const root = makeRoot({ fragments: { "features/oops.md": "forgot the dash" } }); + assert.throws(() => aggregate({ root }), /invalid changelog fragments/); + rmSync(root, { recursive: true, force: true }); +}); + +test("gate findInvalidFragments: clean tree passes, bad placement/content fail", () => { + const clean = makeRoot({ fragments: { "maintenance/1-ok.md": "- ok (#1)" } }); + assert.deepEqual(findInvalidFragments(clean), []); + rmSync(clean, { recursive: true, force: true }); + + const dirty = makeRoot({ + fragments: { + "stray.md": "- misplaced at root", + "unknown-section/2-x.md": "- wrong dir", + "fixes/3-bad.md": "missing dash", + }, + }); + const invalid = findInvalidFragments(dirty); + const files = invalid.map((i) => i.file).sort(); + assert.equal(invalid.length, 3); + assert.ok(files.some((f) => f.includes("stray.md"))); + assert.ok(files.some((f) => f.includes("unknown-section"))); + assert.ok(files.some((f) => f.includes("3-bad.md"))); + rmSync(dirty, { recursive: true, force: true }); +}); + +test("gate skips README.md and .gitkeep; absent changelog.d is fine", () => { + const root = makeRoot(); + writeFileSync(join(root, "changelog.d/README.md"), "# docs, not a fragment"); + mkdirSync(join(root, "changelog.d/fixes"), { recursive: true }); + writeFileSync(join(root, "changelog.d/fixes/.gitkeep"), ""); + assert.deepEqual(findInvalidFragments(root), []); + rmSync(root, { recursive: true, force: true }); + + const bare = mkdtempSync(join(tmpdir(), "chfrag-bare-")); + assert.deepEqual(findInvalidFragments(bare), []); + rmSync(bare, { recursive: true, force: true }); +}); + +test("SECTIONS maps every dir to a real living-section heading in the fixture", () => { + for (const heading of Object.values(SECTIONS)) { + assert.ok(CHANGELOG_FIXTURE.includes(heading), `fixture must contain ${heading}`); + } +}); diff --git a/tests/unit/chatcore-telemetry-helpers.test.ts b/tests/unit/chatcore-telemetry-helpers.test.ts index a155da8f7c..e7bc72debc 100644 --- a/tests/unit/chatcore-telemetry-helpers.test.ts +++ b/tests/unit/chatcore-telemetry-helpers.test.ts @@ -9,9 +9,8 @@ import path from "node:path"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-telemetry-")); process.env.DATA_DIR = TEST_DATA_DIR; -const { forwardDashboardEventToLiveWs, maybeSyncClaudeExtraUsageState } = await import( - "../../open-sse/handlers/chatCore/telemetryHelpers.ts" -); +const { forwardDashboardEventToLiveWs, maybeSyncClaudeExtraUsageState } = + await import("../../open-sse/handlers/chatCore/telemetryHelpers.ts"); const core = await import("../../src/lib/db/core.ts"); const originalFetch = globalThis.fetch; @@ -48,8 +47,8 @@ test("forwardDashboardEventToLiveWs POSTs event+payload+timestamp as JSON to the await forwardDashboardEventToLiveWs("my-event", { foo: "bar" }); const after = Date.now(); - // Default port is 20129 when LIVE_WS_PORT is unset. - assert.equal(capturedUrl, "http://127.0.0.1:20129/__omniroute_event"); + // Default port is 20132 when LIVE_WS_PORT is unset. + assert.equal(capturedUrl, "http://127.0.0.1:20132/__omniroute_event"); assert.equal(capturedInit?.method, "POST"); assert.equal( (capturedInit?.headers as Record)["content-type"], diff --git a/tests/unit/check-db-rules-classification.test.ts b/tests/unit/check-db-rules-classification.test.ts index dc1b901a78..ab775e30bf 100644 --- a/tests/unit/check-db-rules-classification.test.ts +++ b/tests/unit/check-db-rules-classification.test.ts @@ -121,7 +121,7 @@ test("INTENTIONALLY_INTERNAL is exported from check-db-rules.mjs", () => { assert.ok(INTENTIONALLY_INTERNAL.size > 0, "INTENTIONALLY_INTERNAL must not be empty"); }); -test("INTENTIONALLY_INTERNAL contains the expected 33 audited modules", () => { +test("INTENTIONALLY_INTERNAL contains the expected 35 audited modules", () => { const expected = [ "_rowTypes", "accessTokens", @@ -140,6 +140,7 @@ test("INTENTIONALLY_INTERNAL contains the expected 33 audited modules", () => { "healthCheck", "jsonMigration", "migrationRunner", + "modelCapabilityOverrides", "notion", "obsidian", "optimizationSettings", @@ -147,6 +148,7 @@ test("INTENTIONALLY_INTERNAL contains the expected 33 audited modules", () => { "prompts", "providerNodeSelect", "providerStats", + "proxyLatency", "recovery", "schemaColumns", "secrets", diff --git a/tests/unit/cli-health-monitoring-route.test.ts b/tests/unit/cli-health-monitoring-route.test.ts new file mode 100644 index 0000000000..f2168ba400 --- /dev/null +++ b/tests/unit/cli-health-monitoring-route.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { runHealthCommand } from "../../bin/cli/commands/health.mjs"; + +// Regression test for GH #6677: `omniroute health` calls GET /api/health, but the +// real server only implements GET /api/monitoring/health (plus the sub-routes +// /api/health/degradation and /api/health/ping). This stub server mimics that +// exact real-world shape: /api/monitoring/health responds 200 with a healthy +// payload, everything else (including /api/health) 404s. +// +// Expectation once fixed: runHealthCommand() should hit /api/monitoring/health +// and return exit code 0. + +let server: http.Server; +let baseUrl: string; + +test.before(async () => { + server = http.createServer((req, res) => { + if (req.url === "/api/monitoring/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + status: "healthy", + version: "3.8.47", + uptime: 123, + activeConnections: 0, + circuitBreakers: { open: 0, halfOpen: 0, closed: 3 }, + memoryUsage: { rss: 1000, heapUsed: 500 }, + }) + ); + return; + } + // Everything else, including the legacy /api/health the CLI used to call, + // 404s — matching the real deployed route tree (only + // app/api/health/degradation and app/api/health/ping exist on disk). + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "Not Found" })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + if (address && typeof address === "object") { + baseUrl = `http://127.0.0.1:${address.port}`; + } + process.env.OMNIROUTE_BASE_URL = baseUrl; +}); + +test.after(async () => { + delete process.env.OMNIROUTE_BASE_URL; + await new Promise((resolve) => server.close(() => resolve())); +}); + +test("GH #6677: omniroute health should succeed against a server that only implements /api/monitoring/health", async () => { + const originalError = console.error; + const originalLog = console.log; + const errors: string[] = []; + console.error = (...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }; + console.log = () => {}; + + let exitCode: number; + try { + exitCode = await runHealthCommand({}); + } finally { + console.error = originalError; + console.log = originalLog; + } + + assert.equal( + exitCode, + 0, + `runHealthCommand() should return 0 against a live server that implements ` + + `/api/monitoring/health, but got exit code ${exitCode}. Captured stderr: ${errors.join(" | ")}` + ); +}); diff --git a/tests/unit/codex-compact-strip-include-6805.test.ts b/tests/unit/codex-compact-strip-include-6805.test.ts new file mode 100644 index 0000000000..91e0d080c3 --- /dev/null +++ b/tests/unit/codex-compact-strip-include-6805.test.ts @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { CodexExecutor } from "../../open-sse/executors/codex.ts"; + +// #6805: compact Codex requests must not forward `include` (e.g. +// "reasoning.encrypted_content") — the compact endpoint rejects it. Kept in a +// standalone file so the frozen executor-codex.test.ts does not grow past its cap. +test("CodexExecutor.transformRequest strips include from compact requests (#6805)", () => { + const executor = new CodexExecutor(); + const result = executor.transformRequest( + "gpt-5.3-codex", + { + _nativeCodexPassthrough: true, + include: ["reasoning.encrypted_content"], + instructions: "keep this", + stream: false, + }, + false, + { + requestEndpointPath: "/responses/compact", + providerSpecificData: { requestDefaults: { serviceTier: "priority" } }, + } + ); + assert.equal(result.include, undefined); + assert.equal(result._nativeCodexPassthrough, undefined); + assert.equal(result.instructions, "keep this"); +}); diff --git a/tests/unit/codex-spark-image-generation.test.ts b/tests/unit/codex-spark-image-generation.test.ts new file mode 100644 index 0000000000..7d2671b866 --- /dev/null +++ b/tests/unit/codex-spark-image-generation.test.ts @@ -0,0 +1,61 @@ +/** + * #6651 — Codex Desktop injects the `image_generation` hosted tool into every + * Responses API request. OmniRoute only dropped it for free-plan Codex + * accounts (isCodexFreePlan). It did NOT drop it for gpt-5.3-codex-spark (and + * other Spark-scope models), which reject `image_generation` upstream even on + * paid-plan accounts, producing: + * [400]: Tool 'image_generation' is not supported with gpt-5.3-codex-spark. + * + * Fix: CodexExecutor.transformRequest now also drops image_generation when + * the target model resolves to the Spark quota scope + * (getCodexModelScope(model) === "spark"), independent of plan. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { CodexExecutor } = await import("../../open-sse/executors/codex.ts"); + +function buildBody() { + return { + _nativeCodexPassthrough: true, + tools: [ + { type: "image_generation", output_format: "png" }, + { type: "function", name: "foo", parameters: { type: "object" } }, + ], + }; +} + +test("#6651: CodexExecutor.transformRequest drops image_generation for gpt-5.3-codex-spark even on a paid-plan account", () => { + const executor = new CodexExecutor(); + + // Paid-plan account (not free) — isCodexFreePlan() alone returns false, so + // the fix must rely on the model-scope check to still drop the tool. + const result = executor.transformRequest("gpt-5.3-codex-spark", buildBody(), false, { + providerSpecificData: { workspacePlanType: "team" }, + }) as { tools: Array<{ type?: string }> }; + + assert.equal( + result.tools.some((t) => t.type === "image_generation"), + false, + "image_generation must be dropped for gpt-5.3-codex-spark regardless of account plan (#6651)" + ); + assert.equal( + result.tools.some((t) => t.type === "function"), + true, + "the function tool must survive" + ); +}); + +test("#6651: CodexExecutor.transformRequest still preserves image_generation for non-Spark models on paid plans", () => { + const executor = new CodexExecutor(); + + const result = executor.transformRequest("gpt-5", buildBody(), false, { + providerSpecificData: { workspacePlanType: "team" }, + }) as { tools: Array<{ type?: string }> }; + + assert.equal( + result.tools.some((t) => t.type === "image_generation"), + true, + "image_generation must still be preserved for non-Spark models on paid plans" + ); +}); diff --git a/tests/unit/codex-sse-capacity-fallback.test.ts b/tests/unit/codex-sse-capacity-fallback.test.ts new file mode 100644 index 0000000000..2316e1cd02 --- /dev/null +++ b/tests/unit/codex-sse-capacity-fallback.test.ts @@ -0,0 +1,157 @@ +// 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. Left unhandled, the 200 +// status makes this look like a successful response — no retry, no circuit +// breaker, no combo/account fallback engages, so the client either hangs or gets +// a truncated stream while a healthy account sits idle. This must be detected and +// converted into a real error Response (503) so accountFallback.ts / combo +// routing rotates to another account. +import test from "node:test"; +import assert from "node:assert/strict"; + +import { CodexExecutor, __setCodexWebSocketTransportForTesting } from "../../open-sse/executors/codex.ts"; + +test.afterEach(() => { + __setCodexWebSocketTransportForTesting(undefined); +}); + +function sseStreamFromChunks(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i >= chunks.length) { + controller.close(); + return; + } + controller.enqueue(encoder.encode(chunks[i])); + i++; + }, + }); +} + +test("CodexExecutor.execute converts a 200-OK SSE stream carrying a model-at-capacity error into a 503 Response", async () => { + const executor = new CodexExecutor(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => + new Response( + sseStreamFromChunks([ + 'event: error\ndata: {"error":{"message":"Selected model is at capacity. Please try a different model."}}\n\n', + ]), + { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + } + ); + + try { + const result = await executor.execute({ + model: "gpt-5.5", + body: { model: "gpt-5.5", input: [{ role: "user", content: "hello" }] }, + stream: true, + credentials: { accessToken: "codex-token" }, + }); + + assert.notEqual(result.response.status, 200); + assert.equal(result.response.status, 503); + const body = await result.response.json(); + assert.match(body.error.message, /at capacity/i); + // Hard Rule #12: never leak raw stack/paths in the sanitized error body. + assert.equal(body.error.message.includes("at /"), false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("CodexExecutor.execute converts server_is_overloaded / service_unavailable_error SSE payloads into a 503 Response", async () => { + const executor = new CodexExecutor(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => + new Response( + sseStreamFromChunks([ + 'event: error\ndata: {"error":{"type":"server_is_overloaded","message":"The server is overloaded. Please retry later."}}\n\n', + ]), + { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + } + ); + + try { + const result = await executor.execute({ + model: "gpt-5.5", + body: { model: "gpt-5.5", input: [{ role: "user", content: "hello" }] }, + stream: true, + credentials: { accessToken: "codex-token" }, + }); + + assert.equal(result.response.status, 503); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("CodexExecutor.execute reassembles a normal 200-OK SSE stream byte-intact after peeking for transient errors", async () => { + const executor = new CodexExecutor(); + const originalFetch = globalThis.fetch; + + const normalSse = + 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Hello"}\n\n' + + 'event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\n'; + + globalThis.fetch = async () => + new Response(sseStreamFromChunks([normalSse]), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + + try { + const result = await executor.execute({ + model: "gpt-5.5", + body: { model: "gpt-5.5", input: [{ role: "user", content: "hello" }] }, + stream: true, + credentials: { accessToken: "codex-token" }, + }); + + assert.equal(result.response.status, 200); + const text = await result.response.text(); + assert.equal(text, normalSse); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("CodexExecutor.execute reassembles a normal SSE stream split across multiple network chunks", async () => { + const executor = new CodexExecutor(); + const originalFetch = globalThis.fetch; + + const chunks = [ + 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Hel', + 'lo"}\n\n', + 'event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\n', + ]; + const expected = chunks.join(""); + + globalThis.fetch = async () => + new Response(sseStreamFromChunks(chunks), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + + try { + const result = await executor.execute({ + model: "gpt-5.5", + body: { model: "gpt-5.5", input: [{ role: "user", content: "hello" }] }, + stream: true, + credentials: { accessToken: "codex-token" }, + }); + + assert.equal(result.response.status, 200); + const text = await result.response.text(); + assert.equal(text, expected); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/codexBulkImport.test.ts b/tests/unit/codexBulkImport.test.ts index 7551fe330b..9576b83109 100644 --- a/tests/unit/codexBulkImport.test.ts +++ b/tests/unit/codexBulkImport.test.ts @@ -243,3 +243,86 @@ describe("flattenCodexImportPayload", () => { assert.equal(flattenCodexImportPayload(null).ok, false); }); }); + +// ── 9router camelCase export (#6665) ──────────────────────────────────────────── + +describe("9router camelCase Codex export (#6665)", () => { + // The exact shape a 9router Codex account export produces (camelCase fields + + // nested providerSpecificData), as pasted in issue #6665. + const NINEROUTER_RECORD = { + accessToken: "access-9r", + refreshToken: "refresh-9r", + idToken: makeIdToken({ + email: "jwt-9r@example.com", + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-jwt-9r", + chatgpt_plan_type: "plus", + }, + }), + email: "top-9r@example.com", + expiresAt: "2026-07-17T13:18:27.000Z", + expiresIn: 849516, + providerSpecificData: { + chatgptAccountId: "acct-psd-9r", + chatgptPlanType: "pro", + }, + testStatus: "active", + isActive: true, + lastRefreshAt: "2026-07-07T17:19:51.150Z", + }; + + test("normalizes a 9router camelCase record", () => { + const result = normalizeCodexImportRecord(NINEROUTER_RECORD); + assert.equal(result.ok, true); + if (!result.ok) return; + const { payload } = result; + assert.equal(payload.accessToken, "access-9r"); + assert.equal(payload.refreshToken, "refresh-9r"); + assert.equal(payload.idToken, NINEROUTER_RECORD.idToken); + // JWT-derived account info wins over the pre-supplied providerSpecificData. + assert.equal(payload.email, "jwt-9r@example.com"); + assert.deepEqual(payload.providerSpecificData, { + chatgptAccountId: "acct-jwt-9r", + chatgptPlanType: "plus", + }); + // camelCase `expiresAt` is honored as the expiry source. + assert.equal(Date.parse(payload.expiresAt), Date.parse(NINEROUTER_RECORD.expiresAt)); + }); + + test("uses pre-supplied providerSpecificData when there is no id_token", () => { + const result = normalizeCodexImportRecord({ + accessToken: "a", + refreshToken: "r", + email: "no-jwt-9r@example.com", + providerSpecificData: { chatgptAccountId: "acct-psd", chatgptPlanType: "team" }, + }); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.payload.email, "no-jwt-9r@example.com"); + assert.deepEqual(result.payload.providerSpecificData, { + chatgptAccountId: "acct-psd", + chatgptPlanType: "team", + }); + }); + + test("an explicit snake_case field is NOT overridden by a camelCase alias", () => { + const result = normalizeCodexImportRecord({ + access_token: "snake-wins", + accessToken: "camel-loses", + refresh_token: "r", + email: "mixed@example.com", + }); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.payload.accessToken, "snake-wins"); + }); + + test("a full 9router {accounts:[...]} export flattens to its records", () => { + const flat = flattenCodexImportPayload([NINEROUTER_RECORD]); + assert.equal(flat.ok, true); + if (!flat.ok) return; + assert.equal(flat.records.length, 1); + const norm = normalizeCodexImportRecord(flat.records[0]); + assert.equal(norm.ok, true); + }); +}); diff --git a/tests/unit/combo-fingerprint-pin-6696.test.ts b/tests/unit/combo-fingerprint-pin-6696.test.ts new file mode 100644 index 0000000000..6a683f49e8 --- /dev/null +++ b/tests/unit/combo-fingerprint-pin-6696.test.ts @@ -0,0 +1,123 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #6696 — the combo builder's "pin a specific account" feature for fingerprint +// providers (mimocode/mcode/opencode) builds a composite connectionId of the +// form `${rowId}|fp|${fingerprint}` (src/lib/combos/builderOptions.ts:251), but +// nothing in the combo execution path ever splits that composite id back into +// a real rowId + a selected fingerprint. This test proves the pin is inert: +// once a combo step is configured with the composite id produced by the +// builder, `expandTargetsByFingerprints` (the function combo.ts calls right +// before target resolution/credential lookup) cannot find the connection in +// `connectionById` (which is keyed by the real DB row id) and silently passes +// the target through UNCHANGED, still carrying the bogus composite +// connectionId. Downstream, `getProviderCredentials`'s `forcedConnectionId` +// filter (src/sse/services/auth.ts) also can never match `conn.id === +// "|fp|"` against a real row id — so the pinned target +// never resolves to real credentials for the intended (or ANY) account and +// the combo step is effectively dead weight instead of a working, fail-over- +// capable target. + +const { expandTargetsByFingerprints } = await import( + "../../open-sse/services/combo/fingerprintExpansion.ts" +); + +function makeTarget(overrides: Record = {}) { + return { + kind: "model" as const, + stepId: "step-0", + executionKey: "step-0", + modelStr: "mimocode/mimo-auto", + provider: "mimocode", + providerId: null, + connectionId: "conn-1", + weight: 0, + label: null, + ...overrides, + }; +} + +test("#6696: fp-pinned composite connectionId is never resolved to the real connection + fingerprint", () => { + const realConnectionId = "conn-1"; + const conn = { + id: realConnectionId, + provider: "mimocode", + providerSpecificData: { fingerprints: ["fp-aaa", "fp-bbb"] }, + }; + const connById = new Map([[realConnectionId, conn]]); + + // Exactly what the combo builder UI persists for a step pinned to + // "Account 1" — src/lib/combos/builderOptions.ts:251: + // id: `${connection.id}|fp|${fingerprints[i]}` + const pinnedFingerprint = "fp-aaa"; + const compositeConnectionId = `${realConnectionId}|fp|${pinnedFingerprint}`; + + const targets = [makeTarget({ connectionId: compositeConnectionId })]; + + const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); + + assert.equal(result.length, 1, "pin should resolve to exactly one target"); + + // This is what SHOULD hold once fixed: connectionId fed downstream must be + // the real DB row id, not the UI-only composite string. + assert.equal( + result[0].connectionId, + realConnectionId, + "fp-pinned target must resolve to the real connection id for credential lookup to succeed" + ); + + // The selected fingerprint must be threaded through so downstream execution + // (and future account-scoped cooldown/lockout) can still tell which account + // was pinned, instead of losing that information once the composite id is + // unwrapped. + assert.equal( + (result[0] as Record).pinnedFingerprint, + pinnedFingerprint, + "the pinned fingerprint must survive resolution so downstream execution can target that account" + ); +}); + +test("#6696: composite connectionId never matches connectionById (root cause of the inert pin)", () => { + const realConnectionId = "conn-1"; + const conn = { + id: realConnectionId, + provider: "mimocode", + providerSpecificData: { fingerprints: ["fp-aaa", "fp-bbb"] }, + }; + const connById = new Map([[realConnectionId, conn]]); + const compositeConnectionId = `${realConnectionId}|fp|fp-aaa`; + + assert.equal( + connById.get(compositeConnectionId), + undefined, + "composite fp-pin id must not resolve directly against connectionById" + ); +}); + +test("#6696: a pin to an unknown connection does not crash and leaves the target inert but not thrown away", () => { + const connById = new Map(); + const targets = [makeTarget({ connectionId: "missing-conn|fp|fp-zzz" })]; + + const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); + + assert.equal(result.length, 1); + assert.equal(result[0].connectionId, "missing-conn"); + assert.equal((result[0] as Record).pinnedFingerprint, "fp-zzz"); +}); + +test("#6696: non-fingerprint providers are unaffected by the |fp| split", () => { + const connById = new Map([ + ["conn-1", { id: "conn-1", provider: "openai", providerSpecificData: {} }], + ]); + const targets = [ + makeTarget({ provider: "openai", modelStr: "openai/gpt-4", connectionId: "conn-1|fp|fp-aaa" }), + ]; + + const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); + + assert.equal(result.length, 1); + // Non-fingerprint providers are passed through unchanged — the literal + // string (however unusual) is left alone since this provider never goes + // through the fingerprint-pin UI flow. + assert.equal(result[0].connectionId, "conn-1|fp|fp-aaa"); +}); diff --git a/tests/unit/combo-session-stickiness.test.ts b/tests/unit/combo-session-stickiness.test.ts index ede8def020..39f486836f 100644 --- a/tests/unit/combo-session-stickiness.test.ts +++ b/tests/unit/combo-session-stickiness.test.ts @@ -13,12 +13,14 @@ * - applySessionStickiness: no user message → normal ordering, no crash * - applySessionStickiness: different hashes → can map to different connections * - applySessionStickiness: saturation fetch error → fail-open - * - recordStickyBinding / clearStickyBinding lifecycle + * - applySessionStickiness: terminal connection status / rateLimitedUntil → rebind (#6692) + * - recordStickyBinding / clearStickyBinding / peekStickyConnectionId lifecycle */ import test from "node:test"; import assert from "node:assert/strict"; import type { HeadroomSaturation } from "../../open-sse/services/combo/headroomRanking.ts"; +import type { StickyConnectionHealth } from "../../open-sse/services/combo/sessionStickiness.ts"; const mod = await import("../../open-sse/services/combo/sessionStickiness.ts"); const { @@ -27,7 +29,9 @@ const { recordStickyBinding, clearStickyBinding, clearAllStickyBindings, + peekStickyConnectionId, __setStickinessHeadroomFetcherForTests, + __setStickinessConnectionFetcherForTests, STICKINESS_HEADROOM_THRESHOLD, } = mod; @@ -51,16 +55,26 @@ function injectSat(sat: HeadroomSaturation | undefined): void { __setStickinessHeadroomFetcherForTests(async (_id: string) => sat); } +function injectConnectionHealth(byId: Record): void { + __setStickinessConnectionFetcherForTests(async (connectionId: string) => byId[connectionId]); +} + // ─── Test lifecycle ─────────────────────────────────────────────────────────── test.beforeEach(() => { clearAllStickyBindings(); // Default: healthy connection (headroom = 0.5) injectSat({ util5h: 0.3, util7d: 0.2 }); + // Default: unknown to the connection-health fetcher (fail-open, never terminal). + // Without this override every test would hit the real dynamic-import → DB path + // (resolveConnectionHealth's production branch), which is slow and non-deterministic + // for a unit suite that otherwise makes zero DB calls. + injectConnectionHealth({}); }); test.after(() => { __setStickinessHeadroomFetcherForTests(null); + __setStickinessConnectionFetcherForTests(null); }); // ─── deriveMessageHash ─────────────────────────────────────────────────────── @@ -320,3 +334,105 @@ test("messageHash is returned in result even when no binding exists", async () = assert.ok(r.messageHash !== null, "hash should be derivable and returned"); assert.match(r.messageHash!, /^[a-f0-9]{16}$/); }); + +// ─── Terminal connection-status gate (#6692) ───────────────────────────────── +// +// Root cause: headroom (5h/weekly usage %) is orthogonal to account +// availability — a credits_exhausted/banned/expired connection, or one still +// inside its rateLimitedUntil cooldown, reports perfectly healthy headroom, +// so the pre-fix headroom-only gate re-promoted a durably dead connection +// forever. See tests/unit/repro-6692-sticky-terminal.test.ts for the full +// end-to-end repro. + +test("#6692: credits_exhausted sticky connection is rebound even with full headroom", async () => { + injectSat({ util5h: 0.0, util7d: 0.0 }); // full headroom — would pass the old gate + injectConnectionHealth({ "conn-A": { testStatus: "credits_exhausted" } }); + + const targets = [makeTarget("conn-A"), makeTarget("conn-B")]; + const messages = [{ role: "user", content: "Terminal status test" }]; + const hash = deriveMessageHash(messages)!; + recordStickyBinding(hash, "conn-A"); + + const result = await applySessionStickiness(targets, messages); + assert.equal(result.stuck, false, "credits_exhausted must release the pin"); + + // Binding must actually be cleared (not just skipped this call). + assert.equal(peekStickyConnectionId(hash), null); +}); + +test("#6692: banned / expired statuses also release the pin", async () => { + for (const status of ["banned", "expired"]) { + clearAllStickyBindings(); + injectSat({ util5h: 0.0, util7d: 0.0 }); + injectConnectionHealth({ "conn-A": { testStatus: status } }); + + const targets = [makeTarget("conn-A"), makeTarget("conn-B")]; + const messages = [{ role: "user", content: `Status ${status}` }]; + const hash = deriveMessageHash(messages)!; + recordStickyBinding(hash, "conn-A"); + + const result = await applySessionStickiness(targets, messages); + assert.equal(result.stuck, false, `${status} must release the pin`); + } +}); + +test("#6692: connection still inside rateLimitedUntil window releases the pin", async () => { + injectSat({ util5h: 0.0, util7d: 0.0 }); + const future = new Date(Date.now() + 60_000).toISOString(); + injectConnectionHealth({ "conn-A": { rateLimitedUntil: future } }); + + const targets = [makeTarget("conn-A"), makeTarget("conn-B")]; + const messages = [{ role: "user", content: "Cooling down" }]; + const hash = deriveMessageHash(messages)!; + recordStickyBinding(hash, "conn-A"); + + const result = await applySessionStickiness(targets, messages); + assert.equal(result.stuck, false, "an in-window rateLimitedUntil must release the pin"); +}); + +test("#6692: rateLimitedUntil in the past does NOT release the pin", async () => { + injectSat({ util5h: 0.0, util7d: 0.0 }); + const past = new Date(Date.now() - 60_000).toISOString(); + injectConnectionHealth({ "conn-A": { rateLimitedUntil: past } }); + + const targets = [makeTarget("conn-A"), makeTarget("conn-B")]; + const messages = [{ role: "user", content: "Cooldown expired" }]; + const hash = deriveMessageHash(messages)!; + recordStickyBinding(hash, "conn-A"); + + const result = await applySessionStickiness(targets, messages); + assert.ok(result.stuck, "an expired rateLimitedUntil must not block reuse"); + assert.equal(result.targets[0].connectionId, "conn-A"); +}); + +test("#6692: connection-health fetch error → fail-open (pin preserved)", async () => { + injectSat({ util5h: 0.0, util7d: 0.0 }); + __setStickinessConnectionFetcherForTests(async () => { + throw new Error("db unavailable"); + }); + + const targets = [makeTarget("conn-A"), makeTarget("conn-B")]; + const messages = [{ role: "user", content: "Fetch error path" }]; + const hash = deriveMessageHash(messages)!; + recordStickyBinding(hash, "conn-A"); + + // The production resolveConnectionHealth catches internally and returns + // undefined (never throws) — mirrors resolveSaturation's own internal + // try/catch. This test injects a THROWING override to prove + // applySessionStickiness's outer try/catch also fails open end-to-end + // (same behavior as the existing "saturation fetch error" case above: + // total fail-open/no-op, never a crash). + const result = await applySessionStickiness(targets, messages); + assert.equal(result.stuck, false, "a connection-health fetch error must fail open, not crash"); +}); + +test("peekStickyConnectionId: reflects the current binding without mutating it", () => { + const messages = [{ role: "user", content: "Peek test" }]; + const hash = deriveMessageHash(messages)!; + + assert.equal(peekStickyConnectionId(hash), null, "no binding yet"); + recordStickyBinding(hash, "conn-peek"); + assert.equal(peekStickyConnectionId(hash), "conn-peek"); + // Peeking again must not clear or otherwise mutate the binding. + assert.equal(peekStickyConnectionId(hash), "conn-peek"); +}); diff --git a/tests/unit/compression/combos-engine-ui-schema-parity-4955.test.ts b/tests/unit/compression/combos-engine-ui-schema-parity-4955.test.ts index 2162dd765d..4855faa60b 100644 --- a/tests/unit/compression/combos-engine-ui-schema-parity-4955.test.ts +++ b/tests/unit/compression/combos-engine-ui-schema-parity-4955.test.ts @@ -3,15 +3,17 @@ import assert from "node:assert/strict"; import { STACKED_PIPELINE_ENGINE_INTENSITIES, + compressionSettingsUpdateSchema, stackedPipelineStepSchema, } from "../../../src/shared/validation/compressionConfigSchemas.ts"; +import { ENGINE_IDS } from "../../../open-sse/services/compression/engineCatalog.ts"; -// Regression guard for #4955: the Engine Combos pipeline editor used to offer engines -// (headroom, session-dedup, ccr, llmlingua) that `stackedPipelineStepSchema` rejects, so -// selecting one made `PUT /api/context/combos/[id]` fail with HTTP 400 and the UI swallowed -// it. The fix routes the dropdown through STACKED_PIPELINE_ENGINE_INTENSITIES, which MUST stay -// in lockstep with the discriminated union below. -describe("Engine Combos UI ↔ stackedPipelineStepSchema parity (#4955)", () => { +// Regression guard for #4955 / #6747: +// - #4955: UI and API schema must stay in lockstep (no engines the UI offers that PUT rejects). +// - #6747: PUT must accept every ENGINE_CATALOG / GET stackedPipeline engine so GET→PUT +// round-trips of compression settings succeed (session-dedup, ccr, headroom, relevance, +// llmlingua were previously rejected by a 5-engine discriminator). +describe("Engine Combos UI ↔ stackedPipelineStepSchema parity (#4955 / #6747)", () => { const unionEngines = stackedPipelineStepSchema.options .map((option: { shape: { engine: { value: string } } }) => option.shape.engine.value) .sort(); @@ -21,8 +23,18 @@ describe("Engine Combos UI ↔ stackedPipelineStepSchema parity (#4955)", () => assert.deepEqual(uiEngines, unionEngines); }); + it("covers every ENGINE_CATALOG id (GET /api/compression/engines parity)", () => { + assert.deepEqual([...ENGINE_IDS].sort(), unionEngines); + }); + it("every (engine, intensity) the UI can emit is accepted by the schema", () => { for (const [engine, intensities] of Object.entries(STACKED_PIPELINE_ENGINE_INTENSITIES)) { + // Engines with no level selector still need bare { engine } accepted + assert.equal( + stackedPipelineStepSchema.safeParse({ engine }).success, + true, + `expected bare { engine: "${engine}" } to be accepted` + ); for (const intensity of intensities) { const result = stackedPipelineStepSchema.safeParse({ engine, intensity }); assert.equal( @@ -34,18 +46,37 @@ describe("Engine Combos UI ↔ stackedPipelineStepSchema parity (#4955)", () => } }); - it("the engines removed from the UI in #4955 are indeed rejected by the schema", () => { - for (const engine of ["headroom", "session-dedup", "ccr", "llmlingua"]) { + it("accepts structural catalog engines that #4955 had temporarily dropped from the UI (#6747)", () => { + for (const engine of ["headroom", "session-dedup", "ccr", "llmlingua", "relevance"]) { assert.equal( - stackedPipelineStepSchema.safeParse({ engine, intensity: "standard" }).success, - false, - `engine "${engine}" must not be a valid stacked-pipeline step` + stackedPipelineStepSchema.safeParse({ engine }).success, + true, + `engine "${engine}" must be a valid stacked-pipeline step` ); - assert.equal( - STACKED_PIPELINE_ENGINE_INTENSITIES[engine], - undefined, - `engine "${engine}" must not be offered by the combos UI` + assert.ok( + Object.prototype.hasOwnProperty.call(STACKED_PIPELINE_ENGINE_INTENSITIES, engine), + `engine "${engine}" must be offered by the combos UI` ); } }); + + it("accepts a full GET-shaped stackedPipeline on settings update (#6747)", () => { + const result = compressionSettingsUpdateSchema.safeParse({ + stackedPipeline: [ + { engine: "session-dedup" }, + { engine: "ccr" }, + { engine: "lite", intensity: "lite" }, + { engine: "rtk", intensity: "standard" }, + { engine: "headroom" }, + { engine: "relevance" }, + { engine: "caveman", intensity: "full" }, + { engine: "aggressive", intensity: "ultra" }, + { engine: "llmlingua" }, + { engine: "ultra", intensity: "ultra" }, + ], + }); + assert.equal(result.success, true, () => + result.success ? "" : JSON.stringify(result.error.issues) + ); + }); }); diff --git a/tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts b/tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts new file mode 100644 index 0000000000..0d7d01da9b --- /dev/null +++ b/tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "preview-reconcile-6488-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "test-secret-32-chars-min-aaaaaaaa"; +delete process.env.INITIAL_PASSWORD; +const core = await import("../../../src/lib/db/core.ts"); +const route = await import("../../../src/app/api/compression/preview/route.ts"); + +function makeReq(body: unknown) { + return new Request("http://localhost/api/compression/preview", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.beforeEach(() => core.resetDbInstance()); +test.after(() => { + core.resetDbInstance(); + rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// Regression for #6488: outer originalTokens/compressedTokens (real tiktoken counter over +// extracted message text) and engineBreakdown[].originalTokens/compressedTokens (internal +// JSON.stringify(body).length/4 estimate) used to diverge on small/degenerate input because +// they measured different things. For a single-engine breakdown, the entry represents the +// exact same before/after transformation as the overall response, so it must be reconciled +// to match the outer counts exactly. +test("degenerate input with pipeline=['lite']: engineBreakdown[0] matches outer token counts", async () => { + const res = await route.POST( + makeReq({ + messages: [{ role: "user", content: "user: " }], + pipeline: ["lite"], + }) + ); + const body = await res.json(); + assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(body)}`); + + const engines = (body.engineBreakdown ?? []).map((e: { engine: string }) => e.engine); + assert.ok( + engines.every((e: string) => e === "lite"), + `expected engineBreakdown to only contain 'lite', got ${JSON.stringify(engines)}` + ); + + assert.equal(body.engineBreakdown.length, 1); + const [step] = body.engineBreakdown; + assert.equal( + step.originalTokens, + body.originalTokens, + `outer originalTokens=${body.originalTokens} vs engine ${step.engine} originalTokens=${step.originalTokens}` + ); + assert.equal( + step.compressedTokens, + body.compressedTokens, + `outer compressedTokens=${body.compressedTokens} vs engine ${step.engine} compressedTokens=${step.compressedTokens}` + ); +}); + +// Same reconciliation must hold for the single-engine (non-pipeline) dispatch path, where +// engineBreakdown is synthesized by ensureEngineBreakdown from the overall stats. +test("single-engine dispatch (engineId='rtk'): engineBreakdown[0] matches outer token counts", async () => { + const res = await route.POST( + makeReq({ + messages: [{ role: "user", content: "a" }], + engineId: "rtk", + }) + ); + const body = await res.json(); + assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(body)}`); + assert.equal(body.engineBreakdown.length, 1); + const [step] = body.engineBreakdown; + assert.equal(step.originalTokens, body.originalTokens); + assert.equal(step.compressedTokens, body.compressedTokens); +}); diff --git a/tests/unit/cursor-agent-cli-version.test.ts b/tests/unit/cursor-agent-cli-version.test.ts new file mode 100644 index 0000000000..85bb4e9ecf --- /dev/null +++ b/tests/unit/cursor-agent-cli-version.test.ts @@ -0,0 +1,165 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const { + CURSOR_AGENT_CLI_VERSION, + detectCursorAgentCliVersionFromFs, + extractVersionIdFromResolvedPath, + formatCursorAgentClientVersion, + getCursorAgentCliVersion, + newestVersionInDir, + resetCursorAgentCliVersionCache, +} = await import("../../open-sse/utils/cursorAgentCliVersion.ts"); + +function withEnv(vars: Record, fn: () => void) { + const saved: Record = {}; + for (const key of Object.keys(vars)) { + saved[key] = process.env[key]; + const next = vars[key]; + if (next === undefined) delete process.env[key]; + else process.env[key] = next; + } + try { + fn(); + } finally { + for (const key of Object.keys(saved)) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + } +} + +test("formatCursorAgentClientVersion prefixes cli-", () => { + assert.equal(formatCursorAgentClientVersion("2026.07.08-0c04a8a"), "cli-2026.07.08-0c04a8a"); +}); + +test("extractVersionIdFromResolvedPath reads versions/", () => { + assert.equal( + extractVersionIdFromResolvedPath( + "/home/u/.local/share/cursor-agent/versions/2026.07.08-0c04a8a/cursor-agent" + ), + "2026.07.08-0c04a8a" + ); + assert.equal(extractVersionIdFromResolvedPath("/tmp/not-an-agent"), null); +}); + +test("newestVersionInDir picks lexicographically newest matching child", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-cli-ver-dir-")); + try { + fs.mkdirSync(path.join(tmp, "2026.05.24-dda726e")); + fs.mkdirSync(path.join(tmp, "2026.07.08-0c04a8a")); + fs.writeFileSync(path.join(tmp, "not-a-version"), "x"); + fs.mkdirSync(path.join(tmp, "3.9.0")); + assert.equal(newestVersionInDir(tmp), "2026.07.08-0c04a8a"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("detectCursorAgentCliVersionFromFs uses shim realpath under versions/", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-cli-home-shim-")); + try { + const id = "2026.06.01-abcdef0"; + const versionDir = path.join(home, ".local", "share", "cursor-agent", "versions", id); + fs.mkdirSync(versionDir, { recursive: true }); + const binary = path.join(versionDir, "cursor-agent"); + fs.writeFileSync(binary, "#!/bin/sh\n"); + const binDir = path.join(home, ".local", "bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.symlinkSync(binary, path.join(binDir, "agent")); + + withEnv({ CURSOR_DATA_DIR: undefined, CURSOR_AGENT_CLI_VERSION: undefined }, () => { + assert.equal(detectCursorAgentCliVersionFromFs(home), id); + }); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("detectCursorAgentCliVersionFromFs uses CURSOR_DATA_DIR versions when no shim", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-cli-home-empty-")); + const data = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-cli-data-")); + try { + const id = "2026.04.01-deadbeef"; + fs.mkdirSync(path.join(data, "versions", id), { recursive: true }); + withEnv({ CURSOR_DATA_DIR: data }, () => { + assert.equal(detectCursorAgentCliVersionFromFs(home), id); + }); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(data, { recursive: true, force: true }); + } +}); + +test("getCursorAgentCliVersion env override wins", () => { + withEnv({ CURSOR_AGENT_CLI_VERSION: "2026.01.02-abc1234" }, () => { + resetCursorAgentCliVersionCache(); + assert.equal(getCursorAgentCliVersion(), "2026.01.02-abc1234"); + }); + resetCursorAgentCliVersionCache(); +}); + +test("getCursorAgentCliVersion ignores invalid env and uses pin when FS empty", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-cli-home-pin-")); + try { + withEnv( + { + HOME: home, + USERPROFILE: home, + CURSOR_AGENT_CLI_VERSION: "3.9", + CURSOR_DATA_DIR: undefined, + }, + () => { + resetCursorAgentCliVersionCache(); + assert.equal(getCursorAgentCliVersion(), CURSOR_AGENT_CLI_VERSION); + } + ); + } finally { + resetCursorAgentCliVersionCache(); + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("getCursorAgentCliVersion caches until reset", () => { + withEnv({ CURSOR_AGENT_CLI_VERSION: "2026.02.03-111aaaa" }, () => { + resetCursorAgentCliVersionCache(); + assert.equal(getCursorAgentCliVersion(), "2026.02.03-111aaaa"); + process.env.CURSOR_AGENT_CLI_VERSION = "2026.02.03-222bbbb"; + assert.equal(getCursorAgentCliVersion(), "2026.02.03-111aaaa", "cached"); + resetCursorAgentCliVersionCache(); + assert.equal(getCursorAgentCliVersion(), "2026.02.03-222bbbb"); + }); + resetCursorAgentCliVersionCache(); +}); + +test("getCursorAgentCliVersion reads CURSOR_DATA_DIR via isolated HOME", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-cli-home-get-")); + const data = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-cli-data-get-")); + try { + const id = "2026.03.15-cafebabe"; + fs.mkdirSync(path.join(data, "versions", id), { recursive: true }); + withEnv( + { + HOME: home, + USERPROFILE: home, + CURSOR_DATA_DIR: data, + CURSOR_AGENT_CLI_VERSION: undefined, + }, + () => { + resetCursorAgentCliVersionCache(); + assert.equal(getCursorAgentCliVersion(), id); + assert.equal( + formatCursorAgentClientVersion(getCursorAgentCliVersion()), + `cli-${id}` + ); + } + ); + } finally { + resetCursorAgentCliVersionCache(); + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(data, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/cursor-agent-models.test.ts b/tests/unit/cursor-agent-models.test.ts index 3893d2b160..a59076f3ff 100644 --- a/tests/unit/cursor-agent-models.test.ts +++ b/tests/unit/cursor-agent-models.test.ts @@ -36,7 +36,18 @@ test("humanizeCursorModelId pretty-prints common patterns", () => { humanizeCursorModelId("claude-opus-4-7-thinking-high"), "Claude Opus 4.7 Thinking High" ); + assert.equal( + humanizeCursorModelId("claude-opus-4-8-thinking-high-fast"), + "Claude Opus 4.8 Thinking High Fast" + ); + assert.equal(humanizeCursorModelId("claude-fable-5-thinking-xhigh"), "Claude Fable 5 Thinking XHigh"); + assert.equal(humanizeCursorModelId("claude-sonnet-5-max"), "Claude Sonnet 5 Max"); assert.equal(humanizeCursorModelId("kimi-k2.5"), "Kimi K2.5"); assert.equal(humanizeCursorModelId("gemini-3.1-pro"), "Gemini 3.1 Pro"); assert.equal(humanizeCursorModelId("claude-4-sonnet-thinking"), "Claude 4 Sonnet Thinking"); + // Grok 4.5 uses infix -fast- (unlike GPT's trailing -fast) + assert.equal(humanizeCursorModelId("grok-4.5-medium"), "Grok 4.5 Medium"); + assert.equal(humanizeCursorModelId("grok-4.5-fast-medium"), "Grok 4.5 Fast Medium"); + assert.equal(humanizeCursorModelId("grok-4.5-xhigh"), "Grok 4.5 XHigh"); + assert.equal(humanizeCursorModelId("grok-4.5-fast-xhigh"), "Grok 4.5 Fast XHigh"); }); diff --git a/tests/unit/cursor-registry-claude-families.test.ts b/tests/unit/cursor-registry-claude-families.test.ts new file mode 100644 index 0000000000..58df088b38 --- /dev/null +++ b/tests/unit/cursor-registry-claude-families.test.ts @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { cursorProvider } from "../../open-sse/config/providers/registry/cursor/index.ts"; + +const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; + +function modelIds(): Set { + return new Set(cursorProvider.models.map((m) => m.id)); +} + +test("cursor registry includes Claude Opus 4.8 effort + thinking + fast variants", () => { + const ids = modelIds(); + for (const effort of EFFORTS) { + assert.ok(ids.has(`claude-opus-4-8-${effort}`), `missing claude-opus-4-8-${effort}`); + assert.ok(ids.has(`claude-opus-4-8-${effort}-fast`), `missing claude-opus-4-8-${effort}-fast`); + assert.ok( + ids.has(`claude-opus-4-8-thinking-${effort}`), + `missing claude-opus-4-8-thinking-${effort}` + ); + assert.ok( + ids.has(`claude-opus-4-8-thinking-${effort}-fast`), + `missing claude-opus-4-8-thinking-${effort}-fast` + ); + } +}); + +test("cursor registry includes Claude Fable 5 effort + thinking variants", () => { + const ids = modelIds(); + for (const effort of EFFORTS) { + assert.ok(ids.has(`claude-fable-5-${effort}`), `missing claude-fable-5-${effort}`); + assert.ok( + ids.has(`claude-fable-5-thinking-${effort}`), + `missing claude-fable-5-thinking-${effort}` + ); + } +}); + +test("cursor registry includes Claude Sonnet 5 effort + thinking variants", () => { + const ids = modelIds(); + for (const effort of EFFORTS) { + assert.ok(ids.has(`claude-sonnet-5-${effort}`), `missing claude-sonnet-5-${effort}`); + assert.ok( + ids.has(`claude-sonnet-5-thinking-${effort}`), + `missing claude-sonnet-5-thinking-${effort}` + ); + } +}); diff --git a/tests/unit/db-provider-daily-usage-4009.test.ts b/tests/unit/db-provider-daily-usage-4009.test.ts new file mode 100644 index 0000000000..9c19f747a3 --- /dev/null +++ b/tests/unit/db-provider-daily-usage-4009.test.ts @@ -0,0 +1,137 @@ +/** + * #4009 — Request count log per provider, per date. + * + * Some providers bill by request rather than by token, so operators need a + * plain per-provider, per-date request count breakdown. Verifies + * `getProviderDailyUsageRows` (src/lib/db/usageAnalytics.ts) groups + * `usage_history` rows correctly by DATE(timestamp) + provider. + * + * Seeds an in-memory temp SQLite DB and releases the handle in test.after + * (CLAUDE.md PII/Stream Learnings #3 — otherwise node:test hangs). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-db-provider-daily-4009-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const mod = await import("../../src/lib/db/usageAnalytics.ts"); + +function insertUsageHistory(row: Record) { + const db = core.getDbInstance(); + const full = { + provider: "openai", + model: "gpt-4.1", + tokens_input: 10, + tokens_output: 20, + tokens_cache_read: 0, + tokens_cache_creation: 0, + tokens_reasoning: 0, + service_tier: "standard", + success: 1, + latency_ms: 100, + connection_id: null, + api_key_id: null, + api_key_name: null, + ...row, + timestamp: row.timestamp ?? new Date().toISOString(), + }; + db.prepare( + `INSERT INTO usage_history ( + timestamp, provider, model, + tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning, + service_tier, success, latency_ms, connection_id, api_key_id, api_key_name + ) VALUES ( + @timestamp, @provider, @model, + @tokens_input, @tokens_output, @tokens_cache_read, @tokens_cache_creation, @tokens_reasoning, + @service_tier, @success, @latency_ms, @connection_id, @api_key_id, @api_key_name + )` + ).run(full); +} + +test.before(() => { + core.resetDbInstance(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#4009 getProviderDailyUsageRows is exported as a function", () => { + assert.equal(typeof mod.getProviderDailyUsageRows, "function"); +}); + +test("#4009 getProviderDailyUsageRows — groups requests by date + provider", () => { + const rawCutoffDate = "2020-01-01"; + const day1 = "2026-02-10T09:00:00.000Z"; + const day2 = "2026-02-11T09:00:00.000Z"; + + // 3 requests for openai on day1, 1 for anthropic on day1, 2 for openai on day2 + insertUsageHistory({ timestamp: day1, provider: "openai", tokens_input: 10, tokens_output: 20 }); + insertUsageHistory({ timestamp: day1, provider: "openai", tokens_input: 5, tokens_output: 15 }); + insertUsageHistory({ timestamp: day1, provider: "openai", tokens_input: 8, tokens_output: 12 }); + insertUsageHistory({ + timestamp: day1, + provider: "anthropic", + tokens_input: 100, + tokens_output: 50, + }); + insertUsageHistory({ timestamp: day2, provider: "openai", tokens_input: 1, tokens_output: 1 }); + insertUsageHistory({ timestamp: day2, provider: "openai", tokens_input: 2, tokens_output: 2 }); + + const { unifiedSource, unifiedParams } = mod.buildUnifiedSource({ + sinceIso: "2026-02-10T00:00:00.000Z", + untilIso: "2026-02-11T23:59:59.000Z", + rawCutoffDate, + apiKeyWhere: "", + apiKeyParams: {}, + }); + + const rows = mod.getProviderDailyUsageRows(unifiedSource, unifiedParams); + + const openaiDay1 = rows.find((r) => r.date === "2026-02-10" && r.provider === "openai"); + const anthropicDay1 = rows.find((r) => r.date === "2026-02-10" && r.provider === "anthropic"); + const openaiDay2 = rows.find((r) => r.date === "2026-02-11" && r.provider === "openai"); + + assert.ok(openaiDay1, "openai/day1 row present"); + assert.equal(openaiDay1!.requests, 3, "3 openai requests on day1"); + assert.equal(openaiDay1!.promptTokens, 23, "10+5+8 input tokens summed"); + assert.equal(openaiDay1!.completionTokens, 47, "20+15+12 output tokens summed"); + assert.equal(openaiDay1!.totalTokens, 70, "23+47 total tokens"); + + assert.ok(anthropicDay1, "anthropic/day1 row present"); + assert.equal(anthropicDay1!.requests, 1, "1 anthropic request on day1"); + + assert.ok(openaiDay2, "openai/day2 row present"); + assert.equal(openaiDay2!.requests, 2, "2 openai requests on day2 (separate from day1)"); + + // provider+date pairing must not conflate different dates for the same provider + assert.notEqual(openaiDay1!.requests, openaiDay2!.requests); +}); + +test("#4009 getProviderDailyUsageRows — lowercases provider for consistent grouping", () => { + const rawCutoffDate = "2020-01-01"; + const ts = "2026-03-01T09:00:00.000Z"; + + insertUsageHistory({ timestamp: ts, provider: "OpenAI", tokens_input: 1, tokens_output: 1 }); + insertUsageHistory({ timestamp: ts, provider: "openai", tokens_input: 1, tokens_output: 1 }); + + const { unifiedSource, unifiedParams } = mod.buildUnifiedSource({ + sinceIso: "2026-03-01T00:00:00.000Z", + untilIso: "2026-03-01T23:59:59.000Z", + rawCutoffDate, + apiKeyWhere: "", + apiKeyParams: {}, + }); + + const rows = mod.getProviderDailyUsageRows(unifiedSource, unifiedParams); + const openaiRows = rows.filter((r) => r.date === "2026-03-01" && r.provider === "openai"); + + assert.equal(openaiRows.length, 1, "mixed-case provider values fold into one group"); + assert.equal(openaiRows[0].requests, 2, "both rows counted in the single lowercase group"); +}); diff --git a/tests/unit/db-providers-crud.test.ts b/tests/unit/db-providers-crud.test.ts index 638af3b50d..38babb66b1 100644 --- a/tests/unit/db-providers-crud.test.ts +++ b/tests/unit/db-providers-crud.test.ts @@ -142,6 +142,37 @@ test("codex workspace uniqueness uses workspaceId alongside email", async () => ]); }); +test("codex logins without a workspaceId are not merged on bare email match", async () => { + const loginA = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "shared@example.com", + accessToken: "token-account-a", + refreshToken: "refresh-account-a", + providerSpecificData: { chatgptUserId: "user-a" }, + }); + const loginB = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "shared@example.com", + accessToken: "token-account-b", + refreshToken: "refresh-account-b", + providerSpecificData: { chatgptUserId: "user-b" }, + }); + + const rows = await providersDb.getProviderConnections({ provider: "codex" }); + + // Two distinct Codex accounts sharing an email but lacking a verifiable + // workspaceId must NOT collapse into a single row — that would silently + // overwrite the first account's token pair on the second login. + assert.notEqual(loginB.id, loginA.id); + assert.equal(rows.length, 2); + + const rowA = rows.find((row) => row.id === loginA.id); + assert.equal(rowA?.accessToken, "token-account-a"); + assert.equal(rowA?.refreshToken, "refresh-account-a"); +}); + test("updateProviderConnection reorders priorities and returns decrypted payloads", async () => { const first = await providersDb.createProviderConnection({ provider: "openai", diff --git a/tests/unit/error-classifier.test.ts b/tests/unit/error-classifier.test.ts index c3a2ea1589..ef9b740955 100644 --- a/tests/unit/error-classifier.test.ts +++ b/tests/unit/error-classifier.test.ts @@ -120,3 +120,22 @@ test("classifyProviderError: OAuth provider 429 with daily quota signal => QUOTA ); assert.equal(result, PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED); }); + +// #6827 — 404 must be classified as MODEL_NOT_FOUND, not fall through to null. +// Without this, no cooldown/lockout is applied and the retry loop keeps hitting +// the dead endpoint until the upstream rate-limits it (404 + 429 storm). +test("classifyProviderError: 404 => MODEL_NOT_FOUND", () => { + const result = classifyProviderError(404, { + error: { message: "model v0-1.5-md not found" }, + }); + assert.equal(result, PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND); +}); + +test("classifyProviderError: 404 with provider => MODEL_NOT_FOUND", () => { + const result = classifyProviderError( + 404, + { error: { message: "Not Found" } }, + "v0-vercel" + ); + assert.equal(result, PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND); +}); diff --git a/tests/unit/executor-xai.test.ts b/tests/unit/executor-xai.test.ts index df5d096ecd..f8a6031e5e 100644 --- a/tests/unit/executor-xai.test.ts +++ b/tests/unit/executor-xai.test.ts @@ -92,3 +92,21 @@ test("leaves a plain, unlisted model id and body unchanged (no suffix, not allow assert.equal(out.reasoning_effort, undefined); assert.deepEqual(out.messages, body.messages); }); + +// Port of decolua/9router#2439 (author: @ryanngit): xAI ships a native +// `/v1/responses` endpoint. grok-4.20-multi-agent-0309 is tagged +// targetFormat: "openai-responses" in the registry (upstream's own tag) — it +// must resolve to xAI's native Responses URL, not the chat-completions +// bridge, mirroring the gh executor's targetFormat-driven routing (9router#102) +// and the "openai" -pro heuristic in open-sse/executors/default.ts. +test("XaiExecutor.buildUrl routes the Responses-tagged model (grok-4.20-multi-agent-0309) to xAI's native /v1/responses endpoint", () => { + const executor = new XaiExecutor(); + const url = executor.buildUrl("grok-4.20-multi-agent-0309", true); + assert.equal(url, "https://api.x.ai/v1/responses"); +}); + +test("XaiExecutor.buildUrl keeps a plain chat model (grok-4.3) on /v1/chat/completions", () => { + const executor = new XaiExecutor(); + const url = executor.buildUrl("grok-4.3", true); + assert.equal(url, "https://api.x.ai/v1/chat/completions"); +}); diff --git a/tests/unit/executors-strip-unsupported-params.test.ts b/tests/unit/executors-strip-unsupported-params.test.ts index e9169d731f..9e98a9f9de 100644 --- a/tests/unit/executors-strip-unsupported-params.test.ts +++ b/tests/unit/executors-strip-unsupported-params.test.ts @@ -6,6 +6,10 @@ // 2. github + gpt-5.4: temperature unsupported. // 3. github + Claude (except opus/sonnet 4.6): thinking + reasoning_effort rejected. // 4. nvidia + z-ai/glm-5.2: reasoning rejected → NVIDIA 400. +// 5. volcengine + kimi-k2-5-260127: max_tokens clamped to the Ark endpoint cap +// (32768), confirmed independently against two live-endpoint reports for the +// same Volcengine Ark Kimi coding-plan endpoint (decolua/9router#2460; +// NousResearch/hermes-agent#51773; MoonshotAI/kimi-cli#1124). import { test } from "node:test"; import assert from "node:assert/strict"; @@ -154,10 +158,46 @@ test("stripUnsupportedParams: nvidia non-glm-5 model keeps reasoning", () => { assert.ok(body.reasoning !== undefined, "reasoning must survive for non-glm-5 nvidia model"); }); -test("STRIP_RULES is non-empty and every rule has a drop list", () => { +test("STRIP_RULES is non-empty and every rule has a drop list or a clamp mechanism", () => { assert.ok(__STRIP_RULES_FOR_TEST.length > 0); for (const rule of __STRIP_RULES_FOR_TEST) { - assert.ok(Array.isArray(rule.drop) && rule.drop.length > 0); + const hasDrop = Array.isArray(rule.drop) && rule.drop.length > 0; + const hasClamp = rule.clampToModelMaxOutput === true || Number.isFinite(rule.maxOutputCap); + assert.ok(hasDrop || hasClamp, "rule must either drop params or clamp max output"); assert.ok(typeof rule.match === "function" || rule.match instanceof RegExp); } }); + +test("stripUnsupportedParams: volcengine kimi-k2-5-260127 clamps max_tokens above the Ark endpoint cap (32768)", () => { + const body: Record = { max_tokens: 65536 }; + stripUnsupportedParams("volcengine", "kimi-k2-5-260127", body); + assert.equal(body.max_tokens, 32768, "oversized max_tokens must be clamped to the Ark cap"); +}); + +test("stripUnsupportedParams: volcengine kimi-k2-5-260127 leaves max_tokens under the cap unchanged", () => { + const body: Record = { max_tokens: 8000 }; + stripUnsupportedParams("volcengine", "kimi-k2-5-260127", body); + assert.equal(body.max_tokens, 8000, "max_tokens under the cap must not be modified"); +}); + +test("stripUnsupportedParams: volcengine kimi-k2-5-260127 also clamps max_completion_tokens/max_output_tokens", () => { + const body: Record = { + max_completion_tokens: 100000, + max_output_tokens: 50000, + }; + stripUnsupportedParams("volcengine", "kimi-k2-5-260127", body); + assert.equal(body.max_completion_tokens, 32768); + assert.equal(body.max_output_tokens, 32768); +}); + +test("stripUnsupportedParams: volcengine non-kimi model (glm-4-7-251222) is NOT clamped by the kimi rule", () => { + const body: Record = { max_tokens: 65536 }; + stripUnsupportedParams("volcengine", "glm-4-7-251222", body); + assert.equal(body.max_tokens, 65536, "kimi-specific cap must not apply to other volcengine models"); +}); + +test("stripUnsupportedParams: kimi rule is provider-scoped (no-op for non-volcengine providers)", () => { + const body: Record = { max_tokens: 65536 }; + stripUnsupportedParams("kimi", "kimi-k2-5-260127", body); + assert.equal(body.max_tokens, 65536, "the Ark-specific cap must not leak to other kimi-hosting providers"); +}); diff --git a/tests/unit/fusion-judge-own-intelligence.test.ts b/tests/unit/fusion-judge-own-intelligence.test.ts new file mode 100644 index 0000000000..52c6317b80 --- /dev/null +++ b/tests/unit/fusion-judge-own-intelligence.test.ts @@ -0,0 +1,30 @@ +// ABOUTME: buildJudgePrompt must license the judge to use its own knowledge and override +// ABOUTME: the panel — not just synthesize within it — while still embedding panel responses. +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildJudgePrompt } from "../../open-sse/services/fusion.ts"; + +test("judge prompt embeds all panel answers, anonymized by source", () => { + const prompt = buildJudgePrompt([ + { text: "answer-alpha" }, + { text: "answer-beta" }, + ]); + assert.match(prompt, /\[Source 1\]/); + assert.match(prompt, /\[Source 2\]/); + assert.match(prompt, /answer-alpha/); + assert.match(prompt, /answer-beta/); + assert.match(prompt, /2 expert models/); +}); + +test("judge is licensed to use its own intelligence and override the panel", () => { + const prompt = buildJudgePrompt([{ text: "x" }]); + // Must NOT cap the judge at panel content ("grounded in that analysis" was the old ceiling). + assert.doesNotMatch(prompt, /grounded in that analysis/); + // Must explicitly grant own-reasoning + override authority. + assert.match(prompt, /OWN reasoning and knowledge/); + assert.match(prompt, /override/i); + assert.match(prompt, /not a vote-counter/i); + // Must keep the honesty guard so it doesn't fabricate. + assert.match(prompt, /not confident about/i); +}); diff --git a/tests/unit/gemini-helper.test.ts b/tests/unit/gemini-helper.test.ts index 479a9d3fa9..e7a14cd858 100644 --- a/tests/unit/gemini-helper.test.ts +++ b/tests/unit/gemini-helper.test.ts @@ -20,7 +20,7 @@ test("DEFAULT_SAFETY_SETTINGS is an array", () => { test("tryParseJSON parses valid JSON", () => { assert.deepEqual(gemini.tryParseJSON('{"a":1}'), { a: 1 }); - assert.deepEqual(gemini.tryParseJSON('[1,2,3]'), [1, 2, 3]); + assert.deepEqual(gemini.tryParseJSON("[1,2,3]"), [1, 2, 3]); assert.equal(gemini.tryParseJSON('"hello"'), "hello"); assert.equal(gemini.tryParseJSON("42"), 42); assert.equal(gemini.tryParseJSON("true"), true); @@ -130,3 +130,37 @@ test("cleanJSONSchemaForAntigravity handles nested schema", () => { const result = gemini.cleanJSONSchemaForAntigravity(schema); assert.ok(typeof result === "object"); }); + +test("convertOpenAIContentToParts maps OpenAI Chat Completions file (PDF) to inlineData", () => { + const content = [ + { type: "text", text: "read this" }, + { + type: "file", + file: { filename: "doc.pdf", file_data: "data:application/pdf;base64,JVBERiAtMQ==" }, + }, + ]; + const parts = gemini.convertOpenAIContentToParts(content); + const inline = parts.find((p) => p.inlineData); + assert.ok(inline, "PDF file part must be converted to inlineData, not dropped"); + assert.equal(inline.inlineData.mimeType, "application/pdf"); + assert.equal(inline.inlineData.data, "JVBERiAtMQ=="); +}); + +test("convertOpenAIContentToParts keeps the real mime for a video file_data", () => { + const content = [ + { type: "file", file: { filename: "clip.mp4", file_data: "data:video/mp4;base64,AAAAIGZ0" } }, + ]; + const parts = gemini.convertOpenAIContentToParts(content); + const inline = parts.find((p) => p.inlineData); + assert.ok(inline, "video file part must be converted to inlineData"); + assert.equal(inline.inlineData.mimeType, "video/mp4"); + assert.equal(inline.inlineData.data, "AAAAIGZ0"); +}); + +test("convertOpenAIContentToParts still maps image_url data URIs (regression)", () => { + const content = [{ type: "image_url", image_url: { url: "data:image/png;base64,iVBORw0KGgo=" } }]; + const parts = gemini.convertOpenAIContentToParts(content); + const inline = parts.find((p) => p.inlineData); + assert.ok(inline, "image_url must still convert to inlineData"); + assert.equal(inline.inlineData.mimeType, "image/png"); +}); diff --git a/tests/unit/gemini-malformed-function-call-finish-reason-2462.test.ts b/tests/unit/gemini-malformed-function-call-finish-reason-2462.test.ts new file mode 100644 index 0000000000..4cda9fa761 --- /dev/null +++ b/tests/unit/gemini-malformed-function-call-finish-reason-2462.test.ts @@ -0,0 +1,156 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Upstream: decolua/9router#2462 sub-bug #2 (@anhdiepmmk). +// +// Gemini/Antigravity aborts a turn mid tool-call with finishReason +// MALFORMED_FUNCTION_CALL (or a sibling abort reason like UNEXPECTED_TOOL_CALL) +// instead of completing it cleanly. Before this fix: +// - open-sse/utils/finishReason.ts had no notion of these reasons, so they +// passed through the OpenAI hub unchanged (harmless on their own). +// - open-sse/translator/response/openai-to-claude.ts's convertFinishReason() +// collapsed ANY unrecognized OpenAI finish_reason to a clean "end_turn" in +// its default case — presenting an aborted tool call to the Claude client +// as a successful completion. +// This regression guard chains the real Gemini -> OpenAI -> Claude translator +// pipeline (mirroring translateResponse's hub-and-spoke Step 1 + Step 2) and +// asserts the Claude stop_reason is never a silent "end_turn" for these +// abort/error finish reasons, while a genuine clean STOP still maps to +// "end_turn" (no regression). + +const { geminiToOpenAIResponse } = await import( + "../../open-sse/translator/response/gemini-to-openai.ts" +); +const { openaiToClaudeResponse } = await import( + "../../open-sse/translator/response/openai-to-claude.ts" +); +const { geminiToClaudeResponse } = await import( + "../../open-sse/translator/response/gemini-to-claude.ts" +); + +// Direct Gemini -> Claude translator (the path Claude Code hits through an +// antigravity/Gemini-routed model — sourceFormat=CLAUDE, targetFormat=GEMINI — +// which bypasses the OpenAI hub). Its finishReason classifier had the identical +// bug: any unrecognized reason (incl. MALFORMED_FUNCTION_CALL) fell through to +// a clean "end_turn". +function runDirectGeminiToClaude(finishReason: string) { + const state: Record = {}; + const events = + geminiToClaudeResponse( + { + responseId: "resp-direct", + modelVersion: "gemini-2.5-pro", + candidates: [{ content: { parts: [{ text: "partial" }] }, finishReason, index: 0 }], + }, + state + ) || []; + const messageDelta = (events as Array>).find( + (event) => event.type === "message_delta" + ); + return (messageDelta?.delta as { stop_reason?: string } | undefined)?.stop_reason; +} + +function runGeminiToClaude(geminiChunk) { + const geminiState: { toolCalls: Map } = { toolCalls: new Map() }; + const openaiEvents = geminiToOpenAIResponse(geminiChunk, geminiState) || []; + + const claudeState: { toolCalls: Map } = { toolCalls: new Map() }; + const claudeEvents: Array> = []; + for (const chunk of openaiEvents) { + const converted = openaiToClaudeResponse(chunk, claudeState); + if (converted) claudeEvents.push(...converted); + } + return { openaiEvents, claudeEvents }; +} + +test("Gemini MALFORMED_FUNCTION_CALL does not surface as a clean Claude end_turn", () => { + const { openaiEvents, claudeEvents } = runGeminiToClaude({ + responseId: "resp-malformed", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ text: "partial text" }] }, + finishReason: "MALFORMED_FUNCTION_CALL", + index: 0, + }, + ], + }); + + // Sanity: the OpenAI hub must not silently rewrite it to a clean "stop" either. + const openaiFinish = openaiEvents.at(-1)?.choices?.[0]?.finish_reason; + assert.notEqual(openaiFinish, "stop"); + + const messageDelta = claudeEvents.find((event) => event.type === "message_delta"); + assert.ok(messageDelta, "expected a Claude message_delta terminal event"); + const stopReason = (messageDelta.delta as { stop_reason?: string }).stop_reason; + assert.notEqual(stopReason, "end_turn"); +}); + +test("Gemini UNEXPECTED_TOOL_CALL does not surface as a clean Claude end_turn", () => { + const { claudeEvents } = runGeminiToClaude({ + responseId: "resp-unexpected", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ text: "partial text" }] }, + finishReason: "UNEXPECTED_TOOL_CALL", + index: 0, + }, + ], + }); + + const messageDelta = claudeEvents.find((event) => event.type === "message_delta"); + assert.ok(messageDelta, "expected a Claude message_delta terminal event"); + const stopReason = (messageDelta.delta as { stop_reason?: string }).stop_reason; + assert.notEqual(stopReason, "end_turn"); +}); + +test("Gemini clean STOP still maps to Claude end_turn (no regression)", () => { + const { claudeEvents } = runGeminiToClaude({ + responseId: "resp-clean", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ text: "All done." }] }, + finishReason: "STOP", + index: 0, + }, + ], + }); + + const messageDelta = claudeEvents.find((event) => event.type === "message_delta"); + assert.ok(messageDelta, "expected a Claude message_delta terminal event"); + const stopReason = (messageDelta.delta as { stop_reason?: string }).stop_reason; + assert.equal(stopReason, "end_turn"); +}); + +test("direct Gemini->Claude: MALFORMED_FUNCTION_CALL does not surface as a clean end_turn", () => { + assert.notEqual(runDirectGeminiToClaude("MALFORMED_FUNCTION_CALL"), "end_turn"); +}); + +test("direct Gemini->Claude: UNEXPECTED_TOOL_CALL does not surface as a clean end_turn", () => { + assert.notEqual(runDirectGeminiToClaude("UNEXPECTED_TOOL_CALL"), "end_turn"); +}); + +test("direct Gemini->Claude: clean STOP still maps to end_turn (no regression)", () => { + assert.equal(runDirectGeminiToClaude("STOP"), "end_turn"); +}); + +test("Gemini MAX_TOKENS still maps to Claude max_tokens (no regression)", () => { + const { claudeEvents } = runGeminiToClaude({ + responseId: "resp-length", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ text: "Truncated" }] }, + finishReason: "MAX_TOKENS", + index: 0, + }, + ], + }); + + const messageDelta = claudeEvents.find((event) => event.type === "message_delta"); + assert.ok(messageDelta, "expected a Claude message_delta terminal event"); + const stopReason = (messageDelta.delta as { stop_reason?: string }).stop_reason; + assert.equal(stopReason, "max_tokens"); +}); diff --git a/tests/unit/gemini-thinking-budget-zero-6813.test.ts b/tests/unit/gemini-thinking-budget-zero-6813.test.ts new file mode 100644 index 0000000000..4402c7ed85 --- /dev/null +++ b/tests/unit/gemini-thinking-budget-zero-6813.test.ts @@ -0,0 +1,50 @@ +/** + * #6813 (defect 1) — the openai->gemini transform forwards the Claude-style + * `thinking.budget_tokens` into `generationConfig.thinkingConfig.thinkingBudget`, but the + * presence check was truthy (`&& thinking.budget_tokens`). An explicit `budget_tokens: 0` + * (the natural "disable thinking" request) is falsy, so it was dropped and the request fell + * through to the default thinkingConfig injection — the model thought despite an explicit + * request for zero. A `budget_tokens: 0` must be honored as thinkingBudget: 0. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToGeminiRequest } = await import( + "../../open-sse/translator/request/openai-to-gemini.ts" +); + +test("thinking.budget_tokens: 0 is honored as thinkingBudget 0 (not dropped) (#6813)", () => { + const result = openaiToGeminiRequest( + "gemini-2.5-flash", + { + messages: [{ role: "user", content: "hi" }], + thinking: { type: "enabled", budget_tokens: 0 }, + }, + false + ) as { generationConfig: { thinkingConfig?: { thinkingBudget: number; includeThoughts: boolean } } }; + + assert.equal( + result.generationConfig.thinkingConfig?.thinkingBudget, + 0, + "explicit budget_tokens: 0 must map to thinkingBudget: 0" + ); + assert.equal( + result.generationConfig.thinkingConfig?.includeThoughts, + false, + "with a zero budget there are no thoughts to include" + ); +}); + +test("thinking.budget_tokens: positive value still maps through with includeThoughts true (#6813 no-regression)", () => { + const result = openaiToGeminiRequest( + "gemini-2.5-flash", + { + messages: [{ role: "user", content: "hi" }], + thinking: { type: "enabled", budget_tokens: 2048 }, + }, + false + ) as { generationConfig: { thinkingConfig?: { thinkingBudget: number; includeThoughts: boolean } } }; + + assert.equal(result.generationConfig.thinkingConfig?.thinkingBudget, 2048); + assert.equal(result.generationConfig.thinkingConfig?.includeThoughts, true); +}); diff --git a/tests/unit/head-request-closes-6400.test.ts b/tests/unit/head-request-closes-6400.test.ts new file mode 100644 index 0000000000..f10180911c --- /dev/null +++ b/tests/unit/head-request-closes-6400.test.ts @@ -0,0 +1,237 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { EventEmitter } from "node:events"; +import { createRequire } from "node:module"; +import type { AddressInfo } from "node:net"; + +const require = createRequire(import.meta.url); +const headResponseGuard = require("../../scripts/dev/head-response-guard.cjs") as { + wrapRequestListenerWithHeadResponseGuard: ( + listener: (req: http.IncomingMessage, res: http.ServerResponse) => unknown + ) => (req: http.IncomingMessage, res: http.ServerResponse) => unknown; + suppressBodyAndForceClose: (res: http.ServerResponse) => void; +}; + +const { wrapRequestListenerWithHeadResponseGuard, suppressBodyAndForceClose } = headResponseGuard; + +/** + * Regression test for issue #6400 — "HEAD requests hang ~6s — response never + * closes after headers", reported across EVERY route (valid, unknown, authed, + * unauthed). + * + * Root cause: Next.js 16's App Router route-handler pipeline + * (`next/dist/server/send-response.js`) correctly skips piping a `Response` + * body for HEAD requests, but its *page*-rendering pipeline + * (`next/dist/server/pipe-readable.js` -> `pipeToNodeResponse`, used for every + * app-router page/layout render — including the `not-found` boundary that + * unmatched paths fall through to) has NO such check: it always streams the + * full rendered body to the HTTP response regardless of method. Combined with + * Node's default keep-alive framing this leaves clients (observed on + * Windows/curl) unsure whether the — implicitly bodyless — HEAD response has + * actually finished. + * + * Fix: `scripts/dev/head-response-guard.cjs` wraps the Node request listener + * (wired into both the dev/start custom server `scripts/dev/run-next.mjs` and + * the packaged standalone server `scripts/dev/standalone-server-ws.mjs`) so + * that, for every inbound HEAD request, any body bytes the inner handler + * tries to write are discarded (never blocking on backpressure) and the + * connection is force-closed (`Connection: close`) as soon as `.end()` is + * called — independent of route existence or auth state. + */ +describe("issue #6400 — HEAD response guard (unit)", () => { + function makeMockResponse() { + const emitter = new EventEmitter() as EventEmitter & { + headers: Record; + ended: boolean; + writeCalls: unknown[][]; + endCalls: unknown[][]; + write: (...args: unknown[]) => boolean; + end: (...args: unknown[]) => unknown; + setHeader: (name: string, value: string) => void; + }; + emitter.headers = {}; + emitter.ended = false; + emitter.writeCalls = []; + emitter.endCalls = []; + emitter.setHeader = (name: string, value: string) => { + emitter.headers[name.toLowerCase()] = value; + }; + emitter.write = (...args: unknown[]) => { + emitter.writeCalls.push(args); + return true; + }; + emitter.end = (...args: unknown[]) => { + emitter.endCalls.push(args); + emitter.ended = true; + return emitter; + }; + return emitter; + } + + it("sets Connection: close on the response", () => { + const res = makeMockResponse(); + suppressBodyAndForceClose(res as unknown as http.ServerResponse); + assert.equal(res.headers.connection, "close"); + }); + + it("discards any body written via res.write() but still reports success (no backpressure stall)", () => { + const res = makeMockResponse(); + const originalWrite = res.write; + suppressBodyAndForceClose(res as unknown as http.ServerResponse); + + const ok = res.write("this body must never reach the socket"); + assert.equal(ok, true, "write must report success so callers never block on a drain event"); + assert.equal( + res.writeCalls.length, + 0, + "the original write() must never be called — the body must be fully discarded" + ); + assert.notEqual(res.write, originalWrite); + }); + + it("res.end() forwards to the original end with NO body argument, and is idempotent", () => { + const res = makeMockResponse(); + suppressBodyAndForceClose(res as unknown as http.ServerResponse); + + res.end("this must be dropped"); + res.end("second call must be a no-op"); + + assert.equal(res.endCalls.length, 1, "end() must only forward to the original once"); + assert.deepEqual( + res.endCalls[0], + [], + "the discarded body must never be forwarded to the real end()" + ); + assert.equal(res.ended, true); + }); + + it("wrapRequestListenerWithHeadResponseGuard only guards HEAD requests, GET/POST pass through untouched", () => { + let receivedReq: { method?: string } | null = null; + let receivedRes: unknown = null; + const listener = (req: { method?: string }, res: unknown) => { + receivedReq = req; + receivedRes = res; + }; + const guarded = wrapRequestListenerWithHeadResponseGuard( + listener as unknown as (req: http.IncomingMessage, res: http.ServerResponse) => unknown + ); + + const getRes = makeMockResponse(); + guarded({ method: "GET" } as unknown as http.IncomingMessage, getRes as unknown as http.ServerResponse); + assert.equal(getRes.headers.connection, undefined, "GET must not be forced to close"); + + const headRes = makeMockResponse(); + guarded( + { method: "HEAD" } as unknown as http.IncomingMessage, + headRes as unknown as http.ServerResponse + ); + assert.equal(headRes.headers.connection, "close", "HEAD must be force-closed"); + + // Sanity: the inner listener was actually invoked in both cases (guard + // must not swallow the request — routing/auth/status-code logic still + // runs, only the body write path is intercepted). + assert.ok(receivedReq); + assert.ok(receivedRes); + }); +}); + +/** + * End-to-end confirmation over a real TCP socket: a HEAD request through the + * guarded listener gets an empty body and a closed connection even when the + * underlying handler writes a large body synchronously (the shape that, on + * an un-guarded pipe, streams the full page/RSC payload for HEAD exactly as + * Next's `pipeToNodeResponse` does today). + */ +describe("issue #6400 — HEAD response guard (integration, real socket)", () => { + const servers: http.Server[] = []; + + after(() => { + for (const server of servers) server.close(); + }); + + function startGuardedServer( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void + ): Promise<{ port: number }> { + const server = http.createServer(wrapRequestListenerWithHeadResponseGuard(handler)); + servers.push(server); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve({ port: (server.address() as AddressInfo).port })); + }); + } + + function rawRequest( + port: number, + method: string + ): Promise<{ statusCode: number; headers: Record; body: string }> { + return new Promise((resolvePromise, reject) => { + const req = http.request( + { host: "127.0.0.1", port, method, path: "/", headers: { Connection: "keep-alive" } }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + resolvePromise({ + statusCode: res.statusCode ?? 0, + headers: Object.fromEntries( + Object.entries(res.headers).map(([k, v]) => [k, String(v)]) + ), + body: Buffer.concat(chunks).toString("utf8"), + }); + }); + } + ); + req.on("error", reject); + req.end(); + }); + } + + it("HEAD to a handler that writes a large body synchronously still returns an empty body + Connection: close", async () => { + const largeBody = "x".repeat(1_000_000); + const { port } = await startGuardedServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/html" }); + res.write(largeBody); + res.end(); + }); + + const result = await rawRequest(port, "HEAD"); + + assert.equal(result.statusCode, 200); + assert.equal(result.body, "", "HEAD body must be empty per RFC 9110 §9.3.2"); + assert.equal( + result.headers.connection, + "close", + "HEAD response must force Connection: close so the client never has to guess" + ); + }); + + it("GET through the SAME guarded listener is unaffected (still streams the full body)", async () => { + const { port } = await startGuardedServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("hello world"); + }); + + const result = await rawRequest(port, "GET"); + + assert.equal(result.statusCode, 200); + assert.equal(result.body, "hello world"); + assert.notEqual( + result.headers.connection, + "close", + "GET requests must not be forced to close — only HEAD is guarded" + ); + }); + + it("HEAD to a 404/unknown-route-shaped handler also closes immediately with an empty body", async () => { + const { port } = await startGuardedServer((req, res) => { + res.writeHead(404, { "Content-Type": "text/html" }); + res.end("not found"); + }); + + const result = await rawRequest(port, "HEAD"); + + assert.equal(result.statusCode, 404); + assert.equal(result.body, ""); + assert.equal(result.headers.connection, "close"); + }); +}); diff --git a/tests/unit/i18n-hardcoded-pt-dashboard-6761.test.ts b/tests/unit/i18n-hardcoded-pt-dashboard-6761.test.ts new file mode 100644 index 0000000000..75d723d047 --- /dev/null +++ b/tests/unit/i18n-hardcoded-pt-dashboard-6761.test.ts @@ -0,0 +1,44 @@ +// ABOUTME: Guard test — the 4 dashboard files translated in #6761/#6768 must stay free of +// ABOUTME: hardcoded Portuguese UI strings (regression guard for the i18n cleanup). +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +// Files cleaned of hardcoded Portuguese in #6769 (issues #6761, #6768). +const FILES = [ + "src/app/(dashboard)/dashboard/compression/studio/CompareView.tsx", + "src/app/(dashboard)/dashboard/compression/studio/PlaygroundInput.tsx", + "src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx", + "src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx", +]; + +// Portuguese-only words that appeared as hardcoded UI copy before the fix. +// Distinct from English + from shared tech terms, so a hit means real PT regression. +const PT_MARKERS = [ + "Retenção", + "Fidelidade", + "Compressão", + "Proteger conteúdo", + "delegada ao provedor", + "Deixa o próprio provedor", + "Hoje disponível apenas", + "Técnicas:", + "Não afeta", + "reescrevemos", +]; + +for (const rel of FILES) { + test(`no hardcoded Portuguese in ${rel}`, () => { + const src = readFileSync(join(repoRoot, rel), "utf8"); + const hits = PT_MARKERS.filter((m) => src.includes(m)); + assert.deepEqual( + hits, + [], + `Hardcoded Portuguese found in ${rel}: ${hits.join(", ")} — translate to English or route through t().` + ); + }); +} diff --git a/tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts b/tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts new file mode 100644 index 0000000000..57041bd9a1 --- /dev/null +++ b/tests/unit/i18n-provider-visibility-filter-keys-6694.test.ts @@ -0,0 +1,66 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Regression guard for #6694. + * + * On the provider detail page the visibility + free/paid filter row + * (`providers.filterVisible/filterHidden/freeFilterAll/freeFilterFreeOnly/ + * freeFilterPaidOnly/showVisibleOnly/showHiddenOnly/filterByVisibility/ + * hideAllModels`) rendered as the literal `__MISSING__:` sentinel in + * 15 locales (including pt-BR) because those keys were mirrored by + * scripts/i18n/sync-ui-keys.mjs but never translated. + * + * This is a DISJOINT key set from #6290 (filterAll/filterActive/filterError/ + * filterBanned/filterCreditsExhausted, guarded by + * tests/unit/i18n-provider-filter-keys-6290.test.ts). + */ + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MESSAGES_DIR = path.resolve(__dirname, "..", "..", "src", "i18n", "messages"); +const PLACEHOLDER_PREFIX = "__MISSING__:"; +const FILTER_KEYS = [ + "filterVisible", + "filterHidden", + "freeFilterAll", + "freeFilterFreeOnly", + "freeFilterPaidOnly", + "showVisibleOnly", + "showHiddenOnly", + "filterByVisibility", + "hideAllModels", +] as const; + +function localeFiles(): string[] { + return readdirSync(MESSAGES_DIR) + .filter((f) => f.endsWith(".json")) + .sort(); +} + +test("every shipped locale has real (non-__MISSING__) providers visibility filter labels (#6694)", () => { + const offenders: string[] = []; + + for (const file of localeFiles()) { + const locale = file.replace(/\.json$/, ""); + const json = JSON.parse(readFileSync(path.join(MESSAGES_DIR, file), "utf8")); + const providers = json.providers ?? {}; + + for (const key of FILTER_KEYS) { + const value = providers[key]; + // Absent is harmless here (t.has() is false -> clean English fallback fires). + if (value === undefined || value === null) continue; + if (typeof value === "string" && value.startsWith(PLACEHOLDER_PREFIX)) { + offenders.push(`${locale}: providers.${key} is sentinel "${value}"`); + } + } + } + + assert.equal( + offenders.length, + 0, + `Untranslated provider visibility filter labels (#6694 regression):\n${offenders.join("\n")}` + ); +}); diff --git a/tests/unit/instrumentation-live-ws.test.ts b/tests/unit/instrumentation-live-ws.test.ts new file mode 100644 index 0000000000..ef8cb8462b --- /dev/null +++ b/tests/unit/instrumentation-live-ws.test.ts @@ -0,0 +1,12 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +test("instrumentation-node.ts imports liveServer for in-process WS auto-start", () => { + const source = readFileSync(resolve("src/instrumentation-node.ts"), "utf8"); + assert.ok( + source.includes("server/ws/liveServer"), + "instrumentation-node.ts should import @/server/ws/liveServer" + ); +}); diff --git a/tests/unit/issue-6638-ollama-quota.test.ts b/tests/unit/issue-6638-ollama-quota.test.ts new file mode 100644 index 0000000000..79eb8fc4ca --- /dev/null +++ b/tests/unit/issue-6638-ollama-quota.test.ts @@ -0,0 +1,33 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { checkFallbackError } from "../../open-sse/services/accountFallback.ts"; + +// Repro for GitHub issue #6638: "OmniRoute doesn't respect exhausted quotas" +test("#6638: Ollama Cloud weekly-quota-exhausted 429 must NOT get a short generic rate-limit cooldown", () => { + const errorText = JSON.stringify({ + error: "You have exceeded your weekly usage quota. Your quota will reset in 3 days.", + }); + + const result = checkFallbackError( + 429, + errorText, + 0, + "deepseek-v4-pro", + "ollama-cloud", + null, + null, + undefined + ); + + console.log("checkFallbackError result:", result); + + assert.equal( + result.reason, + "quota_exhausted", + `expected reason "quota_exhausted" but got "${result.reason}" — quota text is being ignored for apikey-category 429s` + ); + assert.ok( + result.cooldownMs > 60 * 60 * 1000, + `expected a long (>1h) cooldown reflecting the weekly quota reset, got ${result.cooldownMs}ms` + ); +}); diff --git a/tests/unit/issue-6662-repro.test.ts b/tests/unit/issue-6662-repro.test.ts new file mode 100644 index 0000000000..6f058d7789 --- /dev/null +++ b/tests/unit/issue-6662-repro.test.ts @@ -0,0 +1,152 @@ +import { describe, it, mock, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +// Repro for #6662: reasoning_content dropped from /v1/chat/completions SSE +// deltas on the v0-vercel-web and claude-web executors. +// +// v0-vercel-web: its upstream (v0.dev/api/chat) speaks an OpenAI-compatible +// SSE format. When the upstream chunk carries `delta.reasoning_content` +// (as OpenAI-compatible reasoning-capable backends do — see the DeepSeek +// pattern already handled at open-sse/executors/deepseek-web.ts:221), the +// v0 executor's stream transform only ever reads `delta?.content` and +// silently drops any `reasoning_content` field instead of forwarding it. +// +// claude-web: its upstream (claude.ai's real chat_conversations/.../completion +// endpoint) speaks the native Anthropic SSE shape — `content_block_delta` +// events with `delta.type === "thinking_delta"` / `delta.thinking` for +// extended-thinking text (the same shape the real-Anthropic-API translator +// at open-sse/translator/response/claude-to-openai.ts already maps to +// `reasoning_content`). Before the fix, `buildClaudeStreamingResponse` in +// open-sse/executors/claude-web.ts only ever read `delta.text`, so any +// `thinking_delta` event was silently dropped instead of forwarded. + +const mod = await import("../../open-sse/executors/v0-vercel-web.ts"); +const { ClaudeWebExecutor } = await import("../../open-sse/executors/claude-web.ts"); +const { __setTlsFetchOverrideForTesting } = await import( + "../../open-sse/services/claudeTlsClient.ts" +); + +function sseUpstream(events: string[]): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const e of events) { + controller.enqueue(encoder.encode(`data: ${e}\n\n`)); + } + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +describe("#6662 repro — v0-vercel-web drops reasoning_content", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("forwards reasoning_content deltas from the upstream SSE stream (RED on current code)", async () => { + const upstreamEvents = [ + JSON.stringify({ + choices: [{ delta: { reasoning_content: "Let me think about 17*23..." } }], + }), + JSON.stringify({ + choices: [{ delta: { content: "391" } }], + }), + ]; + + globalThis.fetch = mock.fn(async () => sseUpstream(upstreamEvents)) as unknown as typeof fetch; + + const executor = new mod.V0VercelWebExecutor(); + const result = await executor.execute({ + model: "v0-default", + body: { messages: [{ role: "user", content: "Solve 17*23" }] }, + stream: true, + credentials: { apiKey: "fake-cookie" }, + signal: null, + }); + + assert.ok(result.response instanceof Response); + const text = await result.response.text(); + + assert.ok( + text.includes("reasoning_content"), + `expected translated SSE stream to carry a reasoning_content field, got:\n${text}` + ); + }); +}); + +describe("#6662 repro — claude-web drops thinking_delta reasoning_content", () => { + afterEach(() => { + __setTlsFetchOverrideForTesting(null); + }); + + function claudeSseStream(events: Array>): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const e of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(e)}\n\n`)); + } + controller.close(); + }, + }); + } + + it("forwards thinking_delta text as reasoning_content in the translated SSE stream (RED on current code)", async () => { + const upstreamEvents = [ + { type: "message_start", message: { id: "msg_1", model: "claude-sonnet-4-6" } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking" } }, + { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "Let me think about 17*23..." }, + }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "text" } }, + { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "391" } }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "end_turn" } }, + { type: "message_stop" }, + ]; + + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: claudeSseStream(upstreamEvents), + })); + + const executor = new ClaudeWebExecutor(); + const result = await executor.execute({ + model: "claude-sonnet-4-6", + body: { messages: [{ role: "user", content: "Solve 17*23" }] }, + stream: true, + credentials: { + // cf_clearance present up front so normalizeClaudeSessionCookieWithAutoRefresh + // takes the fast path and never attempts a real Turnstile solve in-test. + apiKey: "sessionKey=fake-session; cf_clearance=fake-clearance", + orgId: "org-test", + conversationId: "conv-test", + }, + signal: null, + }); + + assert.ok(result.response instanceof Response); + const text = await result.response.text(); + + assert.ok( + text.includes("reasoning_content"), + `expected translated SSE stream to carry a reasoning_content field, got:\n${text}` + ); + assert.ok( + text.includes("Let me think about 17*23"), + `expected the thinking text to be forwarded, got:\n${text}` + ); + }); +}); diff --git a/tests/unit/issue-6686-quota-preflight-coverage.test.ts b/tests/unit/issue-6686-quota-preflight-coverage.test.ts new file mode 100644 index 0000000000..488ee55f58 --- /dev/null +++ b/tests/unit/issue-6686-quota-preflight-coverage.test.ts @@ -0,0 +1,140 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import os from "node:os"; + +// Regression guard for issue #6686: +// "Account selection can pick accounts already out of quota (no live quota +// preflight outside chat/codex)." +// +// Root cause: 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 before this fix it was wired into exactly 2 call sites +// (src/sse/handlers/chat.ts, src/app/api/internal/codex-responses-ws). +// Every other credentialed route called the plain selector, so an account +// whose local cache entry was never populated (e.g. its first request landed +// on a non-chat/codex route) could be selected even at 0% quota remaining. +// +// The fix routes those remaining call sites through +// getProviderCredentialsWithQuotaPreflight() instead. This test has two +// parts: +// 1) A static, file-content check that every affected route no longer +// calls the plain, cache-only selector. +// 2) A behavioral check that the preflight-aware selector — now used by +// every credentialed route — genuinely blocks an account reported +// 100% used by a registered upstream quota fetcher. + +const repoRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url))); + +const ROUTES_REQUIRING_QUOTA_PREFLIGHT = [ + "src/app/api/v1/rerank/route.ts", + "src/app/api/v1/images/generations/route.ts", + "src/app/api/v1/images/edits/route.ts", + "src/app/api/v1/audio/transcriptions/route.ts", + "src/app/api/v1/audio/speech/route.ts", + "src/app/api/v1/audio/translations/route.ts", + "src/app/api/v1/videos/generations/route.ts", + "src/app/api/v1/music/generations/route.ts", + "src/app/api/v1/ocr/route.ts", + "src/app/api/v1/providers/[provider]/embeddings/route.ts", + "src/app/api/v1/providers/[provider]/images/generations/route.ts", + "src/app/api/v1/web/fetch/route.ts", + "src/app/api/v1/moderations/route.ts", + "src/app/api/v1/search/route.ts", +]; + +test("#6686: previously-plain-selector routes must call the quota-preflight-aware selector, not the plain cache-only one", () => { + for (const relPath of ROUTES_REQUIRING_QUOTA_PREFLIGHT) { + const filePath = path.join(repoRoot, relPath); + const source = fs.readFileSync(filePath, "utf8"); + + // A bare `getProviderCredentials(` call (not immediately followed by + // `WithQuotaPreflight`) means live upstream quota is never checked before + // the account is used for this route — the exact #6686 gap. + const bareCalls = source.match(/getProviderCredentials(?!WithQuotaPreflight)\(/g) || []; + assert.equal( + bareCalls.length, + 0, + `${relPath} must not call the plain getProviderCredentials() — use ` + + `getProviderCredentialsWithQuotaPreflight() instead (issue #6686)` + ); + + assert.match( + source, + /getProviderCredentialsWithQuotaPreflight/, + `${relPath} is expected to select credentials via ` + + `getProviderCredentialsWithQuotaPreflight (issue #6686)` + ); + } +}); + +test("#6686: getProviderCredentialsWithQuotaPreflight (now used by every credentialed route) blocks an account already 100% out of quota upstream", async () => { + const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-issue-6686-")); + process.env.DATA_DIR = TEST_DATA_DIR; + process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "issue-6686-secret"; + + const core = await import("../../src/lib/db/core.ts"); + const providersDb = await import("../../src/lib/db/providers.ts"); + const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); + const auth = await import("../../src/sse/services/auth.ts"); + const quotaPreflight = await import("../../open-sse/services/quotaPreflight.ts"); + + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + + try { + const provider = "issue6686"; + + const account = await providersDb.createProviderConnection({ + provider, + authType: "apikey", + name: "issue-6686-exhausted", + apiKey: "sk-issue-6686-exhausted", + isActive: true, + testStatus: "active", + // Same shape used by every affected route's selection call — no + // cooldown, no rate-limit, nothing that would trip the reactive + // filters. Only a live upstream check can catch this account. + providerSpecificData: { + quotaPreflightEnabled: true, + }, + }); + + // A registered upstream quota fetcher — what + // getProviderCredentialsWithQuotaPreflight() calls to discover the + // account is exhausted before the request is sent. + quotaPreflight.registerQuotaFetcher(provider, async () => ({ + used: 100, + total: 100, + percentUsed: 1.0, + resetAt: new Date(Date.now() + 60_000).toISOString(), + })); + + const preflightSelection = await auth.getProviderCredentialsWithQuotaPreflight( + provider, + null, + null, + null + ); + const preflightResult = preflightSelection as { + allRateLimited?: boolean; + connectionId?: string; + } | null; + + assert.ok( + preflightResult?.allRateLimited === true || preflightResult?.connectionId !== account.id, + "getProviderCredentialsWithQuotaPreflight should correctly block the exhausted account" + ); + } finally { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/kiro-idc-cross-region.test.ts b/tests/unit/kiro-idc-cross-region.test.ts new file mode 100644 index 0000000000..6e34c44bb9 --- /dev/null +++ b/tests/unit/kiro-idc-cross-region.test.ts @@ -0,0 +1,267 @@ +/** + * Regression: Kiro enterprise IAM Identity Center accounts whose IdC instance lives OUTSIDE the + * two Amazon Q Developer profile regions (us-east-1 / eu-central-1) — e.g. eu-north-1 (Stockholm), + * start URL https://d-XXXX.awsapps.com/start. + * + * Root cause fixed here: the backend used the IdC/OIDC token region (eu-north-1) for every + * CodeWhisperer runtime call, hitting q.eu-north-1.amazonaws.com — a host that does not exist as a + * Q Developer runtime endpoint. Result: profileArn discovery failed (Limits showed nothing) and + * generateAssistantResponse failed (every request 502). AWS hosts the Q Developer PROFILE (and its + * runtime) only in us-east-1 / eu-central-1, regardless of the IdC region. + * + * The fix: the RUNTIME region is derived from the profileArn (us-east-1 / eu-central-1), and + * profileArn discovery probes those profile regions with the cross-region SSO token — never the + * IdC region. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + resolveKiroRuntimeRegion, + buildKiroProfileDiscoveryRegions, + discoverKiroProfileArnAcrossRegions, + kiroRuntimeHost, + regionFromKiroProfileArn, + KIRO_PROFILE_REGIONS, +} from "../../open-sse/services/kiroRegion.ts"; +import { resolveKiroRegion as resolveExecutorRegion } from "../../open-sse/executors/kiro.ts"; +import { kiro } from "@/lib/oauth/providers/kiro"; +import { __testing } from "@omniroute/open-sse/services/usage.ts"; + +const { getKiroUsage } = __testing; + +const EU_CENTRAL_ARN = "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ"; + +test("KIRO_PROFILE_REGIONS is exactly us-east-1 and eu-central-1", () => { + assert.deepEqual([...KIRO_PROFILE_REGIONS], ["us-east-1", "eu-central-1"]); +}); + +test("regionFromKiroProfileArn extracts the region from a CodeWhisperer ARN", () => { + assert.equal(regionFromKiroProfileArn(EU_CENTRAL_ARN), "eu-central-1"); + assert.equal( + regionFromKiroProfileArn("arn:aws:codewhisperer:us-east-1:1:profile/X"), + "us-east-1" + ); + assert.equal(regionFromKiroProfileArn(undefined), undefined); + assert.equal(regionFromKiroProfileArn("not-an-arn"), undefined); +}); + +test("resolveKiroRuntimeRegion: profileArn region beats the IdC (eu-north-1) stored region", () => { + // The exact failing scenario: IdC token region eu-north-1, profile in eu-central-1. + assert.equal( + resolveKiroRuntimeRegion({ region: "eu-north-1", profileArn: EU_CENTRAL_ARN }), + "eu-central-1" + ); +}); + +test("resolveKiroRuntimeRegion: an IdC region that is not a Q profile region is ignored for runtime", () => { + // No profileArn yet, IdC region eu-north-1 → must NOT route to q.eu-north-1; fall back to us-east-1. + assert.equal(resolveKiroRuntimeRegion({ region: "eu-north-1" }), "us-east-1"); + assert.equal(resolveKiroRuntimeRegion({ region: "us-west-1" }), "us-east-1"); +}); + +test("resolveKiroRuntimeRegion: a valid stored profile region is honored, defaults to us-east-1", () => { + assert.equal(resolveKiroRuntimeRegion({ region: "eu-central-1" }), "eu-central-1"); + assert.equal(resolveKiroRuntimeRegion({ region: "us-east-1" }), "us-east-1"); + assert.equal(resolveKiroRuntimeRegion({}), "us-east-1"); + assert.equal(resolveKiroRuntimeRegion(null), "us-east-1"); +}); + +test("the executor's resolveKiroRegion routes an eu-north-1 IdC account to the profile region", () => { + assert.equal( + resolveExecutorRegion({ + providerSpecificData: { region: "eu-north-1", profileArn: EU_CENTRAL_ARN }, + }), + "eu-central-1" + ); + // generateAssistantResponse must therefore target the real Q host, not q.eu-north-1. + assert.equal(kiroRuntimeHost("eu-central-1"), "https://q.eu-central-1.amazonaws.com"); +}); + +test("buildKiroProfileDiscoveryRegions: EU IdC probes the profile regions first, then the IdC region", () => { + const regions = buildKiroProfileDiscoveryRegions("eu-north-1"); + assert.deepEqual(regions, ["eu-central-1", "us-east-1", "eu-north-1"]); + // The profile regions (fast path) are tried BEFORE the IdC-region fallback. + assert.ok(regions.indexOf("eu-central-1") < regions.indexOf("eu-north-1")); + assert.ok(regions.indexOf("us-east-1") < regions.indexOf("eu-north-1")); + // Another EMEA IdC region → still EU-first, IdC region appended as fallback. + assert.deepEqual(buildKiroProfileDiscoveryRegions("me-central-1"), [ + "eu-central-1", + "us-east-1", + "me-central-1", + ]); +}); + +test("buildKiroProfileDiscoveryRegions: non-EU IdC probes us-east-1 first, then the IdC region", () => { + assert.deepEqual(buildKiroProfileDiscoveryRegions("us-west-2"), [ + "us-east-1", + "eu-central-1", + "us-west-2", + ]); + assert.deepEqual(buildKiroProfileDiscoveryRegions("ap-southeast-2"), [ + "us-east-1", + "eu-central-1", + "ap-southeast-2", + ]); + // No stored region → just the two profile regions. + assert.deepEqual(buildKiroProfileDiscoveryRegions(undefined), ["us-east-1", "eu-central-1"]); +}); + +test("buildKiroProfileDiscoveryRegions: a stored profile region is probed first", () => { + assert.deepEqual(buildKiroProfileDiscoveryRegions("eu-central-1"), ["eu-central-1", "us-east-1"]); + assert.deepEqual(buildKiroProfileDiscoveryRegions("us-east-1"), ["us-east-1", "eu-central-1"]); +}); + +test("discoverKiroProfileArnAcrossRegions: eu-north-1 IdC finds the eu-central-1 profile, skips q.eu-north-1", async () => { + const requested: string[] = []; + const fetchImpl = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + // Simulate reality: q.eu-north-1 does not exist (network failure); eu-central-1 hosts the profile. + if (url.includes("eu-north-1")) throw new Error("ENOTFOUND q.eu-north-1.amazonaws.com"); + if (url.includes("eu-central-1")) { + return new Response(JSON.stringify({ profiles: [{ arn: EU_CENTRAL_ARN }] }), { status: 200 }); + } + // us-east-1 has no profile for this identity. + return new Response(JSON.stringify({ profiles: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + const arn = await discoverKiroProfileArnAcrossRegions("sso-token", "eu-north-1", fetchImpl); + assert.equal(arn, EU_CENTRAL_ARN); + assert.ok( + requested.every((u) => !u.includes("eu-north-1")), + `must never probe q.eu-north-1, got: ${JSON.stringify(requested)}` + ); + assert.ok( + requested.some((u) => u.startsWith("https://q.eu-central-1.amazonaws.com/")), + "must probe the eu-central-1 Q Developer host" + ); +}); + +test("discoverKiroProfileArnAcrossRegions: a non-EU (ap-southeast-2) IdC resolves a us-east-1 profile", async () => { + // Proves the fix is general, not eu-north-1-specific: an APAC IdC's profile lives in a Q + // profile region (us-east-1 here) and is found via the cross-region SSO token. + const US_EAST_ARN = "arn:aws:codewhisperer:us-east-1:111111111111:profile/APAC"; + const requested: string[] = []; + const fetchImpl = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url.includes("us-east-1")) { + return new Response(JSON.stringify({ profiles: [{ arn: US_EAST_ARN }] }), { status: 200 }); + } + return new Response(JSON.stringify({ profiles: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + const arn = await discoverKiroProfileArnAcrossRegions("sso-token", "ap-southeast-2", fetchImpl); + assert.equal(arn, US_EAST_ARN); + assert.equal( + resolveKiroRuntimeRegion({ region: "ap-southeast-2", profileArn: arn }), + "us-east-1" + ); + // The us-east-1 profile region is probed before the ap-southeast-2 IdC-region fallback. + assert.ok(requested.some((u) => u.startsWith("https://codewhisperer.us-east-1.amazonaws.com/"))); +}); + +test("discoverKiroProfileArnAcrossRegions: no token / no profile yields undefined without throwing", async () => { + assert.equal(await discoverKiroProfileArnAcrossRegions("", "eu-north-1"), undefined); + const emptyFetch = (async () => + new Response(JSON.stringify({ profiles: [] }), { status: 200 })) as unknown as typeof fetch; + assert.equal( + await discoverKiroProfileArnAcrossRegions("tok", "eu-north-1", emptyFetch), + undefined + ); +}); + +test("kiro.postExchange (login) discovers the eu-central-1 profile for an eu-north-1 IdC token", async () => { + const originalFetch = global.fetch; + const requested: string[] = []; + global.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url.includes("eu-north-1")) throw new Error("ENOTFOUND"); + if (url.includes("eu-central-1")) { + return new Response(JSON.stringify({ profiles: [{ arn: EU_CENTRAL_ARN }] }), { status: 200 }); + } + return new Response(JSON.stringify({ profiles: [] }), { status: 200 }); + }) as typeof fetch; + + try { + const extra = await kiro.postExchange({ access_token: "sso-token", _region: "eu-north-1" }); + assert.deepEqual(extra, { profileArn: EU_CENTRAL_ARN }); + assert.ok(requested.every((u) => !u.includes("eu-north-1"))); + } finally { + global.fetch = originalFetch; + } +}); + +test("kiro.mapTokens keeps region=eu-north-1 (for OIDC refresh) AND stores the eu-central-1 profileArn", () => { + const mapped = kiro.mapTokens( + { access_token: "at", refresh_token: "rt", expires_in: 3600, _region: "eu-north-1" }, + { profileArn: EU_CENTRAL_ARN } + ); + // region stays the IdC/OIDC region so token refresh hits oidc.eu-north-1.amazonaws.com … + assert.equal(mapped.providerSpecificData.region, "eu-north-1"); + // … while the profileArn carries the eu-central-1 runtime region for CodeWhisperer calls. + assert.equal(mapped.providerSpecificData.profileArn, EU_CENTRAL_ARN); +}); + +test("getKiroUsage: eu-north-1 IdC account resolves quota via the eu-central-1 profile", async () => { + const originalFetch = globalThis.fetch; + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const target = String( + (init?.headers as Record | undefined)?.["x-amz-target"] || "" + ); + requested.push(`${target} ${url}`); + // q.eu-north-1 must never be contacted. + if (url.includes("eu-north-1")) throw new Error("ENOTFOUND q.eu-north-1"); + if (target.endsWith("ListAvailableProfiles")) { + if (url.includes("eu-central-1")) { + return new Response(JSON.stringify({ profiles: [{ arn: EU_CENTRAL_ARN }] }), { + status: 200, + }); + } + return new Response(JSON.stringify({ profiles: [] }), { status: 200 }); + } + // GetUsageLimits at the eu-central-1 host → real IAM CREDIT breakdown. + return new Response( + JSON.stringify({ + subscriptionInfo: { subscriptionTitle: "KIRO POWER" }, + usageBreakdownList: [ + { + resourceType: "CREDIT", + currentUsageWithPrecision: 12, + usageLimitWithPrecision: 1000, + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + try { + // No persisted profileArn (the broken state), IdC region eu-north-1. + const result = (await getKiroUsage("sso-token", { + authMethod: "idc", + region: "eu-north-1", + })) as { + plan?: string; + quotas?: Record; + message?: string; + }; + assert.ok(result.quotas, `expected quotas, got: ${JSON.stringify(result)}`); + assert.equal(result.plan, "KIRO POWER"); + assert.equal(result.quotas!.credit.used, 12); + assert.equal(result.quotas!.credit.total, 1000); + // GetUsageLimits must have gone to the eu-central-1 runtime host, never q.eu-north-1. + assert.ok( + requested.some((r) => r.includes("GetUsageLimits") && r.includes("eu-central-1")), + `GetUsageLimits should hit eu-central-1, got: ${JSON.stringify(requested)}` + ); + assert.ok(requested.every((r) => !r.includes("eu-north-1"))); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/live-ws-public-url.test.ts b/tests/unit/live-ws-public-url.test.ts index 3595e43370..deff8cb926 100644 --- a/tests/unit/live-ws-public-url.test.ts +++ b/tests/unit/live-ws-public-url.test.ts @@ -68,7 +68,7 @@ test("handshake response includes publicUrl when NEXT_PUBLIC_LIVE_WS_PUBLIC_URL ); assert.equal(response.status, 200); - const body = (await response.json()) as any; + const body = await response.json(); assert.equal(body.live.publicUrl, "wss://ws.my-ai.com/live-ws"); }); @@ -82,7 +82,7 @@ test("handshake response includes null publicUrl when NEXT_PUBLIC_LIVE_WS_PUBLIC ); assert.equal(response.status, 200); - const body = (await response.json()) as any; + const body = await response.json(); assert.equal(body.live.publicUrl, null); }); @@ -92,7 +92,7 @@ test("protocol.live.publicUrl reflects env set after module import (lazy read)", const response = await wsRoute.GET(new Request("http://localhost/api/v1/ws")); assert.equal(response.status, 426); - const body = (await response.json()) as any; + const body = await response.json(); assert.equal(body.protocol.live.publicUrl, "wss://custom.example.com/ws"); }); @@ -106,13 +106,13 @@ test("publicUrl with non-WebSocket scheme is rejected (null)", async () => { ); assert.equal(response.status, 200); - const body = (await response.json()) as any; + const body = await response.json(); assert.equal(body.live.publicUrl, null); assert.equal(body.protocol.live.publicUrl, null); }); test("publicUrl with ws:// scheme is accepted", async () => { - process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "ws://lan-host:20129/live-ws"; + process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "ws://lan-host:20132/live-ws"; const response = await wsRoute.GET( new Request("http://localhost/api/v1/ws?handshake=1", { @@ -121,6 +121,34 @@ test("publicUrl with ws:// scheme is accepted", async () => { ); assert.equal(response.status, 200); - const body = (await response.json()) as any; - assert.equal(body.live.publicUrl, "ws://lan-host:20129/live-ws"); + const body = await response.json(); + assert.equal(body.live.publicUrl, "ws://lan-host:20132/live-ws"); +}); + +test("handshake path is derived from NEXT_PUBLIC_LIVE_WS_PUBLIC_URL pathname", async () => { + process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "wss://ws.my-ai.com/my-custom-ws"; + + const response = await wsRoute.GET( + new Request("http://localhost/api/v1/ws?handshake=1", { + headers: { origin: "http://localhost" }, + }) + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.live.path, "/my-custom-ws"); +}); + +test("handshake path defaults to /live-ws when NEXT_PUBLIC_LIVE_WS_PUBLIC_URL is unset", async () => { + delete process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL; + + const response = await wsRoute.GET( + new Request("http://localhost/api/v1/ws?handshake=1", { + headers: { origin: "http://localhost" }, + }) + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.live.path, "/live-ws"); }); diff --git a/tests/unit/livews-forward-backoff-4604.test.ts b/tests/unit/livews-forward-backoff-4604.test.ts index d7fe6a2026..9b2e100585 100644 --- a/tests/unit/livews-forward-backoff-4604.test.ts +++ b/tests/unit/livews-forward-backoff-4604.test.ts @@ -6,7 +6,7 @@ import { __resetLiveWsForwardingState, } from "../../open-sse/handlers/chatCore/telemetryHelpers.ts"; -// #4604 — In single-port Docker deployments the live-WS sidecar (port 20129) is +// #4604 — In single-port Docker deployments the live-WS sidecar (port 20132) is // not running, but forwardDashboardEventToLiveWs POSTed to it on every compression // event. Because the global fetch is proxyFetch, each ECONNREFUSED logged a // "[ProxyFetch] Undici dispatcher failed" warning — 272 times in 42 minutes. The @@ -35,7 +35,7 @@ test("backs off after consecutive failures and stops calling fetch", async () => let calls = 0; const fail = async () => { calls++; - throw new Error("connect ECONNREFUSED 127.0.0.1:20129"); + throw new Error("connect ECONNREFUSED 127.0.0.1:20132"); }; const clock = makeClock(); // First N attempts go through (and fail); after the threshold the forwarder diff --git a/tests/unit/lmarena-provider.test.ts b/tests/unit/lmarena-provider.test.ts index 96cae4d18e..3fb8081b3e 100644 --- a/tests/unit/lmarena-provider.test.ts +++ b/tests/unit/lmarena-provider.test.ts @@ -12,15 +12,59 @@ import { requiresWebSessionCredential, hasUsableWebSessionCredential, } from "../../src/shared/providers/webSessionCredentials.ts"; -import { LMArenaExecutor, parseArenaSSE } from "../../open-sse/executors/lmarena.ts"; +import { + LMArenaExecutor, + markLMArenaCatalogModelDead, + normalizeLMArenaModelsForCatalog, + parseArenaSSE, + parseLMArenaInitialModels, + pickLMArenaModelId, +} from "../../open-sse/executors/lmarena.ts"; +import { clearLMArenaDeadCatalogModels } from "../../open-sse/executors/lmarena/models.ts"; +import { __setTlsFetchOverrideForTesting } from "../../open-sse/services/lmarenaTlsClient.ts"; + +const TEST_ARENA_MODEL_ID = "019e080d-c29d-7d9a-aa54-faed41da0763"; +const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** Protected BaseExecutor methods exercised by unit tests without `any`. */ +type LMArenaExecutorTestAccess = { + provider: string; + buildUrl: (model: string, credentials: unknown) => string; + buildHeaders: (model: string, credentials: unknown, body: unknown) => Record; + transformRequest: ( + body: unknown, + model: string, + credentials?: unknown + ) => { + id: string; + mode: string; + modality: string; + modelAId: string; + modelBId?: string; + userMessageId: string; + modelAMessageId: string; + modelBMessageId?: string; + recaptchaV3Token: string | null; + userMessage: { + content: string; + experimental_attachments: unknown[]; + metadata: Record; + }; + }; +}; + +function access(executor: LMArenaExecutor): LMArenaExecutorTestAccess { + return executor as unknown as LMArenaExecutorTestAccess; +} describe("LMArena Provider Definition", () => { it("is registered in WEB_COOKIE_PROVIDERS", () => { assert.ok(WEB_COOKIE_PROVIDERS.lmarena, "lmarena should be in WEB_COOKIE_PROVIDERS"); assert.equal(WEB_COOKIE_PROVIDERS.lmarena.id, "lmarena"); assert.equal(WEB_COOKIE_PROVIDERS.lmarena.alias, "lma"); - assert.equal(WEB_COOKIE_PROVIDERS.lmarena.name, "LMArena (Free)"); - assert.equal(WEB_COOKIE_PROVIDERS.lmarena.website, "https://lmarena.ai"); + assert.equal(WEB_COOKIE_PROVIDERS.lmarena.name, "Arena (Free)"); + assert.equal(WEB_COOKIE_PROVIDERS.lmarena.textIcon, "AR"); + assert.equal(WEB_COOKIE_PROVIDERS.lmarena.website, "https://arena.ai"); assert.equal(WEB_COOKIE_PROVIDERS.lmarena.hasFree, true); assert.equal(WEB_COOKIE_PROVIDERS.lmarena.riskNoticeVariant, "webCookie"); }); @@ -28,6 +72,7 @@ describe("LMArena Provider Definition", () => { it("has correct metadata", () => { const provider = WEB_COOKIE_PROVIDERS.lmarena; assert.ok(provider.freeNote, "Should have freeNote"); + assert.ok(provider.freeNote.includes("formerly LMArena"), "Should note rebrand"); assert.ok(provider.authHint, "Should have authHint"); assert.ok(provider.icon, "Should have icon"); assert.ok(provider.color, "Should have color"); @@ -44,10 +89,12 @@ describe("LMArena Credential Requirements", () => { const req = getWebSessionCredentialRequirement("lmarena"); assert.ok(req, "Should have credential requirement"); assert.equal(req.kind, "cookie"); - // #3810: lmarena.ai's real auth cookie is `arena-auth-prod-v1`, not `session` - assert.equal(req.credentialName, "arena-auth-prod-v1"); + // #3810: arena.ai's real auth cookie is `arena-auth-prod-v1`, not `session`; + // #4271: it is now split into Supabase SSR chunks. + assert.ok(req.credentialName.includes("arena-auth-prod-v1.0")); + assert.ok(req.credentialName.includes("arena-auth-prod-v1.1")); assert.ok(req.placeholder.includes("arena-auth-prod-v1")); - assert.ok(req.placeholder.includes("lmarena.ai")); + assert.ok(req.placeholder.includes("arena.ai")); assert.equal(req.acceptsFullCookieHeader, true); assert.ok(req.storageKeys.includes("cookie")); assert.ok(req.storageKeys.includes("arena-auth-prod-v1")); @@ -71,19 +118,22 @@ describe("LMArena Executor", () => { it("has correct provider ID", () => { const executor = new LMArenaExecutor(); - assert.equal((executor as any).provider, "lmarena"); + assert.equal(access(executor).provider, "lmarena"); }); - it("builds correct URL (arena.ai/nextjs-api/stream)", () => { + it("builds correct URL (arena.ai/nextjs-api/stream/create-evaluation)", () => { const executor = new LMArenaExecutor(); - const url = (executor as any).buildUrl("gpt-4", {}); + const url = access(executor).buildUrl("gpt-4", {}); assert.ok(url.includes("arena.ai"), "URL should include arena.ai"); - assert.ok(url.includes("/nextjs-api/stream"), "URL should include /nextjs-api/stream"); + assert.ok( + url.includes("/nextjs-api/stream/create-evaluation"), + "URL should include /nextjs-api/stream/create-evaluation" + ); }); it("builds headers with cookie", () => { const executor = new LMArenaExecutor(); - const headers = (executor as any).buildHeaders("gpt-4", { cookie: "session=abc123" }, {}); + const headers = access(executor).buildHeaders("gpt-4", { cookie: "session=abc123" }, {}); assert.ok(headers.Cookie, "Should have Cookie header"); assert.equal(headers.Cookie, "session=abc123"); assert.equal(headers["Content-Type"], "application/json"); @@ -92,35 +142,28 @@ describe("LMArena Executor", () => { it("builds headers without cookie when not provided", () => { const executor = new LMArenaExecutor(); - const headers = (executor as any).buildHeaders("gpt-4", {}, {}); + const headers = access(executor).buildHeaders("gpt-4", {}, {}); assert.ok(!headers.Cookie, "Should not have Cookie header when no cookie provided"); }); it("reads cookie from credentials correctly", () => { const executor = new LMArenaExecutor(); + const ex = access(executor); // Direct cookie field - let headers = (executor as any).buildHeaders("gpt-4", { cookie: "session=abc" }, {}); + let headers = ex.buildHeaders("gpt-4", { cookie: "session=abc" }, {}); assert.equal(headers.Cookie, "session=abc"); // apiKey field (dashboard form) - headers = (executor as any).buildHeaders("gpt-4", { apiKey: "session=def" }, {}); + headers = ex.buildHeaders("gpt-4", { apiKey: "session=def" }, {}); assert.equal(headers.Cookie, "session=def"); // providerSpecificData.cookie - headers = (executor as any).buildHeaders( - "gpt-4", - { providerSpecificData: { cookie: "session=ghi" } }, - {} - ); + headers = ex.buildHeaders("gpt-4", { providerSpecificData: { cookie: "session=ghi" } }, {}); assert.equal(headers.Cookie, "session=ghi"); // Priority: direct > apiKey > providerSpecificData - headers = (executor as any).buildHeaders( - "gpt-4", - { cookie: "session=abc", apiKey: "session=def" }, - {} - ); + headers = ex.buildHeaders("gpt-4", { cookie: "session=abc", apiKey: "session=def" }, {}); assert.equal(headers.Cookie, "session=abc"); }); @@ -133,6 +176,15 @@ describe("LMArena Executor", () => { assert.equal(result.content, "Hello, world!"); }); + it("parses bare AI SDK text events (0: prefix)", () => { + const textEvent = '0:"Hello, world!"'; + const result = parseArenaSSE(textEvent); + + assert.ok(result, "Should parse text event"); + assert.equal(result.type, "text"); + assert.equal(result.content, "Hello, world!"); + }); + it("parses LMArena SSE thinking events (ag: prefix)", () => { const thinkingEvent = 'ag:{"thinking":"Let me analyze this..."}'; const result = parseArenaSSE(thinkingEvent); @@ -142,6 +194,15 @@ describe("LMArena Executor", () => { assert.equal(result.content, "Let me analyze this..."); }); + it("parses bare AI SDK reasoning events (g: prefix)", () => { + const thinkingEvent = 'g:"Let me analyze this..."'; + const result = parseArenaSSE(thinkingEvent); + + assert.ok(result, "Should parse reasoning event"); + assert.equal(result.type, "thinking"); + assert.equal(result.content, "Let me analyze this..."); + }); + it("parses LMArena SSE error events (a3: and ae: prefixes)", () => { const errorEvent1 = 'a3:{"error":"Rate limit exceeded"}'; const result1 = parseArenaSSE(errorEvent1); @@ -156,6 +217,15 @@ describe("LMArena Executor", () => { assert.equal(result2.content, "Invalid session"); }); + it("parses bare AI SDK error events (3: prefix)", () => { + const errorEvent = '3:"Rate limit exceeded"'; + const result = parseArenaSSE(errorEvent); + + assert.ok(result, "Should parse error event"); + assert.equal(result.type, "error"); + assert.equal(result.content, "Rate limit exceeded"); + }); + it("parses LMArena SSE done event (ad: prefix)", () => { const doneEvent = "ad:{}"; const result = parseArenaSSE(doneEvent); @@ -164,6 +234,14 @@ describe("LMArena Executor", () => { assert.equal(result.type, "done"); }); + it("parses bare AI SDK finish events (d: prefix)", () => { + const doneEvent = 'd:{"finishReason":"stop"}'; + const result = parseArenaSSE(doneEvent); + + assert.ok(result, "Should parse done event"); + assert.equal(result.type, "done"); + }); + it("handles malformed SSE events gracefully", () => { const malformedEvent = "invalid:data"; const result = parseArenaSSE(malformedEvent); @@ -171,9 +249,9 @@ describe("LMArena Executor", () => { assert.equal(result, null, "Should return null for malformed events"); }); - it("transforms OpenAI messages to LMArena format", () => { + it("transforms OpenAI messages to LMArena create-evaluation format", () => { const executor = new LMArenaExecutor(); - const transformRequest = (executor as any).transformRequest.bind(executor); + const transformRequest = access(executor).transformRequest.bind(access(executor)); const openaiBody = { messages: [ @@ -189,9 +267,253 @@ describe("LMArena Executor", () => { const arenaBody = transformRequest(openaiBody, "gpt-4"); assert.ok(arenaBody, "Should transform request body"); - assert.ok(arenaBody.messages, "Should have messages array"); - assert.equal(arenaBody.model, "gpt-4", "Should preserve model"); - assert.equal(arenaBody.stream, true, "Should preserve stream flag"); + assert.match(arenaBody.id, UUID_V7_RE, "Should have UUIDv7 evaluation session id"); + assert.match(arenaBody.userMessageId, UUID_V7_RE, "Should have UUIDv7 user message id"); + assert.match(arenaBody.modelAMessageId, UUID_V7_RE, "Should have UUIDv7 model message id"); + assert.equal(arenaBody.mode, "direct-battle"); + assert.equal(arenaBody.modality, "chat"); + assert.equal(arenaBody.modelAId, "gpt-4", "Should set modelAId"); + assert.equal(arenaBody.modelBId, undefined, "Should not set modelBId for direct mode"); + assert.equal(arenaBody.modelBMessageId, undefined, "Should not set modelBMessageId"); + assert.equal(arenaBody.recaptchaV3Token, null); + assert.deepEqual(arenaBody.userMessage.experimental_attachments, []); + assert.deepEqual(arenaBody.userMessage.metadata, {}); + assert.ok( + arenaBody.userMessage.content.includes("You are a helpful assistant."), + "Should preserve system context in first prompt" + ); + assert.ok( + arenaBody.userMessage.content.includes("How are you?"), + "Should preserve latest user prompt" + ); + }); + + it("handles null request bodies when transforming requests", () => { + const executor = new LMArenaExecutor(); + const arenaBody = access(executor).transformRequest(null, "gpt-4"); + + assert.equal(arenaBody.modelAId, "gpt-4"); + assert.equal(arenaBody.userMessage.content, ""); + }); + + it("maps display model names to Arena internal model ids", () => { + const models = [ + { + id: "019e080d-c29d-7d9a-aa54-faed41da0763", + publicName: "gemini-3.1-pro-preview", + name: "gemini-3.1-pro-preview", + displayName: "Gemini 3.1 Pro Preview", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true }, + outputCapabilities: { text: true }, + }, + rankByModality: { chat: 18 }, + }, + ]; + + assert.equal( + pickLMArenaModelId("gemini-3.1-pro-preview", models), + "019e080d-c29d-7d9a-aa54-faed41da0763" + ); + assert.equal( + pickLMArenaModelId("lmarena/Gemini 3.1 Pro Preview", models), + "019e080d-c29d-7d9a-aa54-faed41da0763" + ); + }); + + it("prefers chat-capable ranked variants when public names are duplicated", () => { + const models = [ + { + id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + publicName: "gemini-3.1-pro-preview", + displayName: "gemini-3.1-pro-preview", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true }, + outputCapabilities: { web: true }, + }, + rankByModality: { webdev: 29 }, + }, + { + id: "019e080d-c29d-7d9a-aa54-faed41da0763", + publicName: "gemini-3.1-pro-preview", + displayName: "gemini-3.1-pro-preview", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true, image: true }, + outputCapabilities: { text: true, web: true }, + }, + rankByModality: { chat: 18, webdev: 29 }, + }, + ]; + + assert.equal( + pickLMArenaModelId("gemini-3.1-pro-preview", models), + "019e080d-c29d-7d9a-aa54-faed41da0763" + ); + }); + + it("drops unranked sentinel rows (huge chat rank) that usually 404 on probe", () => { + const models = [ + { + id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + publicName: "mimo-v2.5-pro", + displayName: "mimo-v2.5-pro", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true }, + outputCapabilities: { text: true, web: true }, + }, + rankByModality: { chat: Number.MAX_SAFE_INTEGER }, + }, + { + id: "11111111-2222-3333-4444-555555555555", + name: "mimo-v2.5-pro", + publicName: "mimo-v2.5-pro", + displayName: "mimo-v2.5-pro", + organization: "xiaomi", + provider: "xiaomiV1", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true }, + outputCapabilities: { text: true, web: true }, + }, + rankByModality: { chat: 42 }, + }, + ]; + + assert.equal( + pickLMArenaModelId("mimo-v2.5-pro", models), + "11111111-2222-3333-4444-555555555555" + ); + assert.deepEqual(normalizeLMArenaModelsForCatalog(models), [ + { + id: "mimo-v2.5-pro", + name: "mimo-v2.5-pro", + owned_by: "xiaomi", + apiFormat: "chat-completions", + supportedEndpoints: ["chat"], + }, + ]); + }); + + it("normalizes live initialModels into unique chat catalog ids", () => { + const models = [ + { + id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + publicName: "gemini-3.1-pro-preview", + displayName: "gemini-3.1-pro-preview", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true }, + outputCapabilities: { web: true }, + }, + rankByModality: { webdev: 29 }, + }, + { + id: "019e080d-c29d-7d9a-aa54-faed41da0763", + publicName: "gemini-3.1-pro-preview", + displayName: "gemini-3.1-pro-preview", + organization: "google", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true, image: true }, + outputCapabilities: { text: true, web: true }, + }, + rankByModality: { chat: 18, webdev: 29 }, + }, + { + id: "99999999-9999-9999-9999-999999999999", + publicName: "hidden-model", + displayName: "Hidden Model", + userSelectable: false, + capabilities: { + inputCapabilities: { text: true }, + outputCapabilities: { text: true }, + }, + rankByModality: { chat: 1 }, + }, + ]; + + assert.deepEqual(normalizeLMArenaModelsForCatalog(models), [ + { + id: "gemini-3.1-pro-preview", + name: "gemini-3.1-pro-preview", + owned_by: "google", + supportsVision: true, + apiFormat: "chat-completions", + supportedEndpoints: ["chat"], + }, + ]); + }); + + it("soft-excludes models marked dead after 404/502 probes", () => { + clearLMArenaDeadCatalogModels(); + const models = [ + { + id: "019e080d-c29d-7d9a-aa54-faed41da0763", + publicName: "gemini-3.1-pro-preview", + displayName: "gemini-3.1-pro-preview", + organization: "google", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true, image: true }, + outputCapabilities: { text: true }, + }, + rankByModality: { chat: 18 }, + }, + ]; + assert.equal(normalizeLMArenaModelsForCatalog(models).length, 1); + markLMArenaCatalogModelDead("gemini-3.1-pro-preview"); + assert.equal(normalizeLMArenaModelsForCatalog(models).length, 0); + clearLMArenaDeadCatalogModels(); + }); + + it("keeps raw Arena ids unchanged when no model mapping is needed", () => { + assert.equal(pickLMArenaModelId(TEST_ARENA_MODEL_ID, []), TEST_ARENA_MODEL_ID); + }); + + it("resolves catalog public names via static Direct-chat allowlist (no arena.ai fetch)", async () => { + const executor = new LMArenaExecutor(); + let arenaHomeFetches = 0; + __setTlsFetchOverrideForTesting(async (url) => { + if (url === "https://arena.ai/" || /arena\.ai\/?$/.test(url)) { + arenaHomeFetches++; + return { status: 200, headers: new Headers(), text: "", body: null }; + } + return { + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: '0:"ok"\nd:{"finishReason":"stop"}\n', + body: null, + }; + }); + + try { + const result = await executor.execute({ + model: "gemini-3.1-pro-preview", + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: console, + }); + assert.equal(result.response.status, 200); + // Model resolution must not scrape arena.ai home for initialModels. + assert.equal(arenaHomeFetches, 0); + // create-evaluation should receive the scraped Arena UUID, not the public name. + const body = result.transformedBody as { modelAId?: string }; + assert.match(String(body.modelAId || ""), /^[0-9a-f-]{36}$/i); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + + it("returns an empty model list when initialModels end marker is before the array", () => { + assert.deepEqual( + parseLMArenaInitialModels('"initialModelAId"],"initialModels":[{"id":"bad"}]'), + [] + ); }); it("returns 401 when cookie is missing", async () => { @@ -213,23 +535,27 @@ describe("LMArena Executor", () => { it("handles streaming response correctly", async () => { const executor = new LMArenaExecutor(); - const mockSSE = [ 'data: a0:{"text":"Hello"}\n\n', 'data: a0:{"text":", world!"}\n\n', "data: ad:{}\n\n", ].join(""); - const originalFetch = global.fetch; - global.fetch = async () => - new Response(mockSSE, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(mockSSE)); + controller.close(); + }, + }), + })); try { const result = await executor.execute({ - model: "gpt-4", + model: TEST_ARENA_MODEL_ID, body: { messages: [{ role: "user", content: "Hello" }], stream: true }, credentials: { cookie: "session=test" }, signal: new AbortController().signal, @@ -239,28 +565,22 @@ describe("LMArena Executor", () => { assert.equal(result.response.status, 200, "Should return 200 for successful streaming"); assert.ok(result.response.body, "Should have response body for streaming"); } finally { - global.fetch = originalFetch; + __setTlsFetchOverrideForTesting(null); } }); it("handles error response from LMArena API", async () => { const executor = new LMArenaExecutor(); - - const originalFetch = global.fetch; - global.fetch = async () => - new Response( - JSON.stringify({ - error: { message: "Rate limit exceeded" }, - }), - { - status: 429, - headers: { "Content-Type": "application/json" }, - } - ); + __setTlsFetchOverrideForTesting(async () => ({ + status: 429, + headers: new Headers({ "Content-Type": "application/json" }), + text: JSON.stringify({ error: { message: "Rate limit exceeded" } }), + body: null, + })); try { const result = await executor.execute({ - model: "gpt-4", + model: TEST_ARENA_MODEL_ID, body: { messages: [{ role: "user", content: "Hello" }] }, credentials: { cookie: "session=test" }, signal: new AbortController().signal, @@ -271,7 +591,43 @@ describe("LMArena Executor", () => { const errorBody = await result.response.json(); assert.ok(errorBody.error, "Should have error object"); } finally { - global.fetch = originalFetch; + __setTlsFetchOverrideForTesting(null); + } + }); + + it("forwards optional browser reCAPTCHA token from credentials", () => { + const executor = new LMArenaExecutor(); + const body = access(executor).transformRequest( + { messages: [{ role: "user", content: "Hi" }] }, + "gpt-4", + { cookie: "x=1", providerSpecificData: { recaptchaV3Token: "tok_abc" } } + ); + assert.equal(body.recaptchaV3Token, "tok_abc"); + }); + + it("surfaces Cloudflare challenge as bot-management error", async () => { + const executor = new LMArenaExecutor(); + __setTlsFetchOverrideForTesting(async () => ({ + status: 403, + headers: new Headers({ "Content-Type": "text/html" }), + text: "Just a moment... challenges.cloudflare.com", + body: null, + })); + + try { + const result = await executor.execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: console, + }); + assert.equal(result.response.status, 403); + const err = await result.response.json(); + assert.match(err.error.message, /Cloudflare|bot|recaptcha/i); + assert.equal(err.error.code, "cloudflare_or_bot"); + } finally { + __setTlsFetchOverrideForTesting(null); } }); }); diff --git a/tests/unit/lmarena-split-cookie-4271.test.ts b/tests/unit/lmarena-split-cookie-4271.test.ts index 80b3049282..85c4350f50 100644 --- a/tests/unit/lmarena-split-cookie-4271.test.ts +++ b/tests/unit/lmarena-split-cookie-4271.test.ts @@ -17,10 +17,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { - LMArenaExecutor, - reconstructLMArenaCookie, -} from "../../open-sse/executors/lmarena.ts"; +import { LMArenaExecutor, reconstructLMArenaCookie } from "../../open-sse/executors/lmarena.ts"; import { getWebSessionCredentialRequirement } from "../../src/shared/providers/webSessionCredentials.ts"; function cookieHeaderFor(credentials: unknown): string | undefined { @@ -85,6 +82,28 @@ describe("LMArena split Supabase SSR cookie (#4271)", () => { assert.ok(reconstructed.includes("sidebar=open"), "should keep sidebar"); }); + it("reconstructs from separately stored providerSpecificData chunk keys", () => { + const header = cookieHeaderFor({ + providerSpecificData: { + "arena-auth-prod-v1.0": "base64-eyJABC", + "arena-auth-prod-v1.1": "DEF.ghi", + }, + }); + + assert.ok(header, "should set a Cookie header"); + assert.equal(header, "arena-auth-prod-v1=base64-eyJABCDEF.ghi"); + }); + + it("reconstructs from separately stored top-level chunk keys", () => { + const header = cookieHeaderFor({ + "arena-auth-prod-v1.0": "base64-eyJABC", + "arena-auth-prod-v1.1": "DEF.ghi", + }); + + assert.ok(header, "should set a Cookie header"); + assert.equal(header, "arena-auth-prod-v1=base64-eyJABCDEF.ghi"); + }); + it("treats an empty base with no chunks as no usable session (returned as-is)", () => { const raw = "arena-auth-prod-v1="; const reconstructed = reconstructLMArenaCookie(raw); diff --git a/tests/unit/merge-train-plan.test.ts b/tests/unit/merge-train-plan.test.ts new file mode 100644 index 0000000000..770434d22d --- /dev/null +++ b/tests/unit/merge-train-plan.test.ts @@ -0,0 +1,59 @@ +// Guards scripts/release/merge-train.sh (merge-gates.md §7 — batch validation of N +// queued PRs as one merged result, replacing O(N²) per-PR CI re-runs). Only the +// side-effect-free surface is testable in unit scope: --plan mode (no worktree, no +// network) and argument validation. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pExecFile = promisify(execFile); +const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "../../scripts/release/merge-train.sh"); + +async function run(args: string[]) { + try { + const { stdout, stderr } = await pExecFile("bash", [SCRIPT, ...args]); + return { code: 0, stdout, stderr }; + } catch (err) { + const e = err as { code?: number; stdout?: string; stderr?: string }; + return { code: e.code ?? -1, stdout: e.stdout ?? "", stderr: e.stderr ?? "" }; + } +} + +test("--plan prints the full step plan without touching anything and exits 0", async () => { + const { code, stdout } = await run(["--plan", "release/v9.9.9", "111", "222"]); + assert.equal(code, 0); + assert.match(stdout, /PLAN — base=origin\/release\/v9\.9\.9 prs=111 222/); + assert.match(stdout, /worktree add \.claude\/worktrees\/merge-train-/); + assert.match(stdout, /pull\/111\/head/); + assert.match(stdout, /pull\/222\/head/); + // the parity suite is fully enumerated in the plan + for (const gate of [ + "typecheck:core", + "check-file-size.mjs", + "check-complexity.mjs", + "check-cognitive-complexity.mjs", + "check-changelog-integrity.mjs", + "TEST_SHARD=1/2", + "TEST_SHARD=2/2", + "test:vitest", + ]) { + assert.ok(stdout.includes(gate), `plan must include ${gate}`); + } + assert.match(stdout, /--admin evidence/); + assert.match(stdout, /teardown: git worktree remove/); +}); + +test("usage error without enough args", async () => { + const { code, stderr } = await run(["--plan", "release/v9.9.9"]); + assert.equal(code, 1); + assert.match(stderr, /usage:/); +}); + +test("rejects a non-numeric PR ref", async () => { + const { code, stderr } = await run(["--plan", "release/v9.9.9", "12a"]); + assert.equal(code, 1); + assert.match(stderr, /not numeric/); +}); diff --git a/tests/unit/model-capability-overrides.test.ts b/tests/unit/model-capability-overrides.test.ts new file mode 100644 index 0000000000..2857327e8f --- /dev/null +++ b/tests/unit/model-capability-overrides.test.ts @@ -0,0 +1,83 @@ +import { describe, it, beforeEach, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const moduleDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-model-capability-overrides-")); +process.env.DATA_DIR = moduleDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const caps = await import("../../src/lib/modelCapabilities.ts"); +const overrides = await import("../../src/lib/db/modelCapabilityOverrides.ts"); + +beforeEach(() => { + coreDb.resetDbInstance(); + fs.rmSync(moduleDataDir, { recursive: true, force: true }); + fs.mkdirSync(moduleDataDir, { recursive: true }); + coreDb.getDbInstance(); +}); + +after(() => { + coreDb.resetDbInstance(); + fs.rmSync(moduleDataDir, { recursive: true, force: true }); +}); + +describe("model capability overrides", () => { + it("stores, lists, removes, and applies a provider/model max_token override", () => { + const withoutOverride = caps.getResolvedModelCapabilities({ + provider: "openai", + model: "gpt-4o", + }).maxOutputTokens; + const distinct = (withoutOverride ?? 0) + 12345; + + assert.equal( + overrides.setModelCapabilityOverride("openai/gpt-4o", "max_token", distinct), + true + ); + assert.deepEqual( + overrides.listModelCapabilityOverrides().map((entry) => ({ + target: entry.target, + key: entry.key, + value: entry.value, + })), + [{ target: "openai/gpt-4o", key: "max_token", value: distinct }] + ); + + assert.equal( + caps.getResolvedModelCapabilities({ provider: "openai", model: "gpt-4o" }).maxOutputTokens, + distinct + ); + assert.notEqual( + caps.getResolvedModelCapabilities({ provider: "anthropic", model: "gpt-4o" }).maxOutputTokens, + distinct, + "override must be scoped by provider/model, not bare model id" + ); + + assert.equal(overrides.removeModelCapabilityOverride("openai/gpt-4o", "max_token"), true); + assert.equal( + caps.getResolvedModelCapabilities({ provider: "openai", model: "gpt-4o" }).maxOutputTokens, + withoutOverride + ); + }); + + it("applies overrides stored under provider-scoped model aliases", () => { + assert.equal( + overrides.setModelCapabilityOverride("github/claude-opus-4.5", "max_token", 77777), + true + ); + + assert.equal( + caps.getResolvedModelCapabilities({ provider: "github", model: "claude-opus-4.5" }) + .maxOutputTokens, + 77777 + ); + }); + + it("rejects invalid targets and non-positive values", () => { + assert.equal(overrides.setModelCapabilityOverride("gpt-4o", "max_token", 1000), false); + assert.equal(overrides.setModelCapabilityOverride("openai/gpt-4o", "max_token", 0), false); + assert.equal(overrides.setModelCapabilityOverride("openai/gpt-4o", "max_token", 1.5), false); + assert.deepEqual(overrides.listModelCapabilityOverrides(), []); + }); +}); diff --git a/tests/unit/model-output-cap-synced-fallthrough-6714.test.ts b/tests/unit/model-output-cap-synced-fallthrough-6714.test.ts new file mode 100644 index 0000000000..378a9914c5 --- /dev/null +++ b/tests/unit/model-output-cap-synced-fallthrough-6714.test.ts @@ -0,0 +1,130 @@ +/** + * #6714 follow-up — `getExplicitModelOutputCap` must fall through to the + * registry/spec output cap when a `synced` capability row exists but its + * `limit_output` is not a number. + * + * Root cause: the function used to short-circuit to `null` on ANY truthy + * `synced` row: + * + * if (synced) return typeof synced.limit_output === "number" ? synced.limit_output : null; + * + * models.dev rows commonly omit `limit_output` (it stays `null`) even when + * the model itself has a well-known output cap registered in + * `providerRegistry.ts`. In that case the old code returned `null` instead + * of falling through — silently disabling the reasoning-token-buffer + * clamp added by #6714 (`clampReasoningTokensToOutputCap` in + * open-sse/services/combo.ts) for any model that happens to have a synced + * row without an output limit. + * + * The fix mirrors the `??`-chain precedence already used by + * `getResolvedModelCapabilities().maxOutputTokens`: + * + * synced?.limit_output ?? registryModel?.maxOutputTokens ?? spec?.maxOutputTokens ?? null + * + * i.e. only return the synced value when it actually IS a number; otherwise + * fall through to the registry cap, then the static spec cap. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-output-cap-synced-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); +const modelCapabilities = await import("../../src/lib/modelCapabilities.ts"); +const { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts"); + +// Pick a real registry model that has a known, positive maxOutputTokens so the +// test proves the fallthrough resolves an ACTUAL registry cap, not a fixture. +function findRegistryModelWithOutputCap() { + for (const [provider, models] of Object.entries(PROVIDER_MODELS)) { + for (const model of models as Array<{ id: string; maxOutputTokens?: number | null }>) { + if (typeof model.maxOutputTokens === "number" && model.maxOutputTokens > 0) { + return { provider, modelId: model.id, maxOutputTokens: model.maxOutputTokens }; + } + } + } + throw new Error("no registry model with maxOutputTokens found — fixture assumption broke"); +} + +const { provider, modelId, maxOutputTokens } = findRegistryModelWithOutputCap(); + +function buildCapability(overrides: Record = {}) { + return { + tool_call: null, + reasoning: null, + attachment: null, + structured_output: null, + temperature: null, + modalities_input: "[]", + modalities_output: "[]", + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: null, + limit_context: null, + limit_input: null, + limit_output: null, + interleaved_field: null, + ...overrides, + }; +} + +function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + // The synced-capabilities module keeps an in-memory cache across DB resets + // (`cachedCapabilitiesLoadedAll`) — clear it too so each test starts from a + // truly empty synced-capability set instead of leaking the previous test's row. + modelsDevSync.clearModelsDevCapabilities(); +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#6714 synced row present but limit_output missing falls through to the registry output cap", () => { + // Seed a synced capability row for this exact provider/model with + // limit_output left null (mirrors real models.dev rows that omit it). + modelsDevSync.saveModelsDevCapabilities({ + [provider]: { + [modelId]: buildCapability({ limit_output: null, status: "stable" }), + }, + }); + + const cap = modelCapabilities.getExplicitModelOutputCap(`${provider}/${modelId}`); + assert.equal( + cap, + maxOutputTokens, + "must fall through to the registry maxOutputTokens, not short-circuit to null" + ); +}); + +test("#6714 synced row with a real numeric limit_output still wins over the registry cap", () => { + const syncedOutputCap = maxOutputTokens + 1234; + modelsDevSync.saveModelsDevCapabilities({ + [provider]: { + [modelId]: buildCapability({ limit_output: syncedOutputCap, status: "stable" }), + }, + }); + + const cap = modelCapabilities.getExplicitModelOutputCap(`${provider}/${modelId}`); + assert.equal(cap, syncedOutputCap, "a real numeric synced limit_output must take precedence"); +}); + +test("#6714 no synced row at all still resolves the registry output cap (no regression)", () => { + const cap = modelCapabilities.getExplicitModelOutputCap(`${provider}/${modelId}`); + assert.equal(cap, maxOutputTokens); +}); diff --git a/tests/unit/next-config.test.ts b/tests/unit/next-config.test.ts index e3bd946643..29821fa4ec 100644 --- a/tests/unit/next-config.test.ts +++ b/tests/unit/next-config.test.ts @@ -271,6 +271,23 @@ test("next-intl webpack hook preserves caller config and filters known extractor ); }); +test("turbopack.ignoreIssue suppresses the agentSkills over-bundling warning (#6582)", async () => { + // src/lib/agentSkills/generator.ts joins process.cwd() with a runtime + // `outputDir` parameter — not a compile-time literal — so Turbopack's + // file-tracing analyzer can't narrow it and emits an "Overly broad + // patterns..." warning per entry point importing the module. The fs access + // is legitimate and bounded, so it's suppressed via turbopack.ignoreIssue + // rather than fought. This guards the config shape so the suppression rule + // isn't silently dropped in a future edit. + const { default: nextConfig } = await loadNextConfig("ignore-issue"); + const rules = nextConfig.turbopack?.ignoreIssue; + + assert.ok(Array.isArray(rules), "expected turbopack.ignoreIssue to be an array"); + const agentSkillsRule = rules.find((rule) => String(rule.path).includes("agentSkills")); + assert.ok(agentSkillsRule, "expected an ignoreIssue rule targeting src/lib/agentSkills/**"); + assert.match(String(agentSkillsRule.description), /Overly broad patterns/); +}); + test("optimizePackageImports excludes the internal @omniroute/open-sse workspace (build-OOM guard)", async () => { // Regression guard: adding the internal `@omniroute/open-sse` workspace to // optimizePackageImports makes Next.js resolve its entire barrel at build diff --git a/tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts b/tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts new file mode 100644 index 0000000000..cd95d85ea1 --- /dev/null +++ b/tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts @@ -0,0 +1,103 @@ +/** + * Issue #3709 — Ollama Cloud free-tier accounts have a hard WEEKLY request + * cap. On cap the upstream returns 429 with a body like: + * "you () have reached your weekly usage limit" + * + * ollama-cloud is an apikey-category provider (not oauth), so the existing + * oauth-only `shouldUseQuotaSignal` gate in checkFallbackError skips the + * generic subscription-quota-text branch (Issue #2321) for its 429s. Without + * a dedicated, ungated weekly check the account fell through to the generic + * 429 backoff (starts ~1s, caps at 2min) and got retried every few minutes + * for the rest of the week — one account took 285x429 in 48h. + * + * This test proves: (1) the weekly-usage-limit text is classified as + * QUOTA_EXHAUSTED with a cooldown far longer than the generic backoff cap, + * for BOTH oauth and apikey provider categories, and (2) a sibling + * under-quota connection is unaffected (multi-account: only the exhausted + * connection's checkFallbackError call is affected — selection filtering + * lives in auth.ts and is exercised by other suites). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts"); +const { isWeeklyUsageLimitText, buildWeeklyQuotaFallback } = await import( + "../../open-sse/services/quotaTextCooldowns.ts" +); +const { RateLimitReason, BACKOFF_CONFIG } = await import("../../open-sse/config/constants.ts"); +const { BACKOFF_CONFIG: ERROR_BACKOFF_CONFIG } = await import("../../open-sse/config/errorConfig.ts"); + +const WEEKLY_BODY = "you (acme-corp) have reached your weekly usage limit"; + +test("#3709 isWeeklyUsageLimitText matches the ollama-cloud 429 body", () => { + assert.equal(isWeeklyUsageLimitText(WEEKLY_BODY.toLowerCase()), true); + assert.equal(isWeeklyUsageLimitText("weekly limit reached, try later"), true); + assert.equal(isWeeklyUsageLimitText("rate_limit_exceeded: too many requests"), false); +}); + +test("#3709 buildWeeklyQuotaFallback returns a 24h QUOTA_EXHAUSTED cooldown, far above the generic backoff cap", () => { + const result = buildWeeklyQuotaFallback(WEEKLY_BODY); + assert.ok(result, "expected a non-null fallback for weekly-usage-limit text"); + assert.equal(result!.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(result!.cooldownMs, 24 * 60 * 60 * 1000); + // The generic 429 backoff caps at 2 minutes — the weekly cooldown must be + // far longer, otherwise the account keeps getting retried every few + // minutes for the rest of the week (the exact bug reported in #3709). + assert.ok(result!.cooldownMs > (ERROR_BACKOFF_CONFIG.max ?? BACKOFF_CONFIG.max)); +}); + +test("#3709 buildWeeklyQuotaFallback returns null for unrelated error text", () => { + assert.equal(buildWeeklyQuotaFallback("rate_limit_exceeded: too many requests"), null); + assert.equal(buildWeeklyQuotaFallback("Usage Limit Reached"), null); +}); + +test("#3709 checkFallbackError: apikey-category provider (ollama-cloud) 429 weekly-limit body → QUOTA_EXHAUSTED, 24h cooldown", () => { + // Regression guard for the actual bug: without the fix, ollama-cloud (an + // apikey-category provider) 429s skip quota-text classification entirely + // (shouldUseQuotaSignal is oauth-only) and fall through to the generic + // ~1s->2min exponential backoff. + const out = checkFallbackError( + 429, + WEEKLY_BODY, + 0, // backoffLevel + null, // model + "ollama-cloud", // provider (apikey category) + null, // headers + null, // profileOverride + null // structuredError + ); + + assert.equal(out.shouldFallback, true); + assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(out.cooldownMs, 24 * 60 * 60 * 1000); + assert.ok( + out.cooldownMs > 5 * 60 * 1000, + `expected cooldown far longer than the old 5-minute retry storm window, got ${out.cooldownMs}ms` + ); +}); + +test("#3709 checkFallbackError: oauth-category provider with weekly-limit text also gets the long cooldown", () => { + // The weekly check is generic (not ollama-specific) and runs unconditionally, + // so an oauth provider using the same wording is covered too. + const out = checkFallbackError(429, WEEKLY_BODY, 0, null, "claude", null, null, null); + assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(out.cooldownMs, 24 * 60 * 60 * 1000); +}); + +test("#3709 checkFallbackError: ollama-cloud generic rate-limit body is unaffected (no false positive)", () => { + const out = checkFallbackError( + 429, + "rate_limit_exceeded: too many requests", + 0, + null, + "ollama-cloud", + null, + null, + null + ); + assert.equal(out.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); + assert.ok( + out.cooldownMs <= 2 * 60 * 1000, + "generic rate limit text must keep the normal short backoff, not the 24h weekly cooldown" + ); +}); diff --git a/tests/unit/openai-responses-subagent-strip-2446.test.ts b/tests/unit/openai-responses-subagent-strip-2446.test.ts new file mode 100644 index 0000000000..1eb04f98d1 --- /dev/null +++ b/tests/unit/openai-responses-subagent-strip-2446.test.ts @@ -0,0 +1,57 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +// Regression guard for the Cursor `Subagent` tool call carrying the cloud-only +// optional field `cloud_base_branch` as an empty string. Cursor rejects the call +// ("cloud_base_branch may only be specified when environment equals cloud") when a +// local subagent tool call includes the field at all. The Responses->Chat translator +// strips empty-string/empty-array optional fields, but that cleanup was scoped to +// Claude Code's `Read` tool only; it must also cover Cursor's `Subagent` tool. +// Ported from decolua/9router#2446. + +const LEAF = "../../open-sse/translator/response/openai-responses/pureHelpers.ts"; + +test("Subagent tool: strips empty-string cloud_base_branch, keeps populated fields", async () => { + const { stripEmptyOptionalToolArgs } = await import(LEAF); + const raw = JSON.stringify({ + description: "subagent connectivity test", + prompt: "hello", + readonly: true, + subagent_type: "generalPurpose", + file_attachments: [], + environment: "local", + cloud_base_branch: "", + interrupt: false, + run_in_background: false, + }); + + const cleaned = JSON.parse(stripEmptyOptionalToolArgs(raw, "Subagent")); + + // The offending empty optional field must be gone. + assert.equal("cloud_base_branch" in cleaned, false); + // Empty array optional also dropped (same rule as Read). + assert.equal("file_attachments" in cleaned, false); + // Populated / meaningful fields preserved — including falsy booleans. + assert.equal(cleaned.description, "subagent connectivity test"); + assert.equal(cleaned.prompt, "hello"); + assert.equal(cleaned.readonly, true); + assert.equal(cleaned.subagent_type, "generalPurpose"); + assert.equal(cleaned.environment, "local"); + assert.equal(cleaned.interrupt, false); + assert.equal(cleaned.run_in_background, false); +}); + +test("Read tool cleanup remains intact (no regression)", async () => { + const { stripEmptyOptionalToolArgs } = await import(LEAF); + const raw = JSON.stringify({ file_path: "/a.ts", pages: "" }); + const cleaned = JSON.parse(stripEmptyOptionalToolArgs(raw, "Read")); + assert.equal("pages" in cleaned, false); + assert.equal(cleaned.file_path, "/a.ts"); +}); + +test("arbitrary tools keep empty strings/arrays (unchanged pass-through)", async () => { + const { stripEmptyOptionalToolArgs } = await import(LEAF); + const raw = JSON.stringify({ query: "", tags: [] }); + // Not on the allowlist -> returned verbatim. + assert.equal(stripEmptyOptionalToolArgs(raw, "SomeOtherTool"), raw); +}); diff --git a/tests/unit/openai-to-claude-bare-tool.test.ts b/tests/unit/openai-to-claude-bare-tool.test.ts new file mode 100644 index 0000000000..848d0c75e5 --- /dev/null +++ b/tests/unit/openai-to-claude-bare-tool.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToClaudeRequest } = await import( + "../../open-sse/translator/request/openai-to-claude.ts" +); + +// Regression: some OpenAI-shape clients send a tool as a BARE +// `{ function: { name, description, parameters } }` object, omitting the +// spec-required `type: "function"` parent wrapper. Before this fix, the +// tools-mapping in openai-to-claude.ts only unwrapped `tool.function` when +// `tool.type === "function"` was ALSO true, so a bare-function tool fell +// through with `toolData === tool` (the wrapper itself, which has no +// `.name`) — `originalName` came out empty and the tool was silently +// dropped from the translated request (worse than a 400: the caller has no +// idea the tool never made it upstream). + +test("openaiToClaudeRequest: bare {function:{...}} tool (no parent type) is NOT dropped", () => { + const request = { + messages: [{ role: "user", content: "hi" }], + tools: [ + { + function: { + name: "get_weather", + description: "Get the current weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ], + }; + + const translated = openaiToClaudeRequest("claude-sonnet-4", request, false); + + assert.ok(Array.isArray(translated.tools), "expected translated.tools to be an array"); + assert.equal(translated.tools.length, 1, "expected the bare-function tool to survive translation"); + + const tool = translated.tools[0]; + assert.match(tool.name, /get_weather$/, "expected the original tool name to be preserved (prefixed)"); + assert.equal(tool.description, "Get the current weather"); + assert.deepEqual(tool.input_schema, { + type: "object", + properties: { city: { type: "string" } }, + }); +}); + +test("openaiToClaudeRequest: spec-shape {type:'function', function:{...}} tool still converts (no regression)", () => { + const request = { + messages: [{ role: "user", content: "hi" }], + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the current weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ], + }; + + const translated = openaiToClaudeRequest("claude-sonnet-4", request, false); + + assert.equal(translated.tools.length, 1); + assert.match(translated.tools[0].name, /get_weather$/); +}); diff --git a/tests/unit/openai-to-claude-file-attachments.test.ts b/tests/unit/openai-to-claude-file-attachments.test.ts new file mode 100644 index 0000000000..727044251c --- /dev/null +++ b/tests/unit/openai-to-claude-file-attachments.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToClaudeRequest } = + await import("../../open-sse/translator/request/openai-to-claude.ts"); + +function userBlocks(model: string, content: unknown) { + const translated = openaiToClaudeRequest(model, { messages: [{ role: "user", content }] }, false); + const userMsg = translated.messages.find((m) => m.role === "user"); + assert.ok(userMsg && Array.isArray(userMsg.content), "expected a translated user message"); + return userMsg.content; +} + +test("openaiToClaudeRequest maps an OpenAI file (PDF) block to a Claude document block", () => { + const blocks = userBlocks("claude-sonnet-4", [ + { type: "text", text: "summarize" }, + { + type: "file", + file: { filename: "edital.pdf", file_data: "data:application/pdf;base64,JVBERiAtMQ==" }, + }, + ]); + const doc = blocks.find((b) => b.type === "document"); + assert.ok(doc, "PDF file block must become a Claude document block, not be dropped"); + assert.equal(doc.source.type, "base64"); + assert.equal(doc.source.media_type, "application/pdf"); + assert.equal(doc.source.data, "JVBERiAtMQ=="); + assert.equal(doc.title, "edital.pdf"); +}); + +test("openaiToClaudeRequest maps an OpenAI file (image mime) block to a Claude image block", () => { + const blocks = userBlocks("claude-sonnet-4", [ + { + type: "file", + file: { filename: "shot.png", file_data: "data:image/png;base64,iVBORw0KGgo=" }, + }, + ]); + const img = blocks.find((b) => b.type === "image"); + assert.ok(img, "image-mime file block must become a Claude image block"); + assert.equal(img.source.type, "base64"); + assert.equal(img.source.media_type, "image/png"); + assert.equal(img.source.data, "iVBORw0KGgo="); +}); + +test("openaiToClaudeRequest maps a remote file (PDF url) block to a Claude document url block", () => { + const blocks = userBlocks("claude-sonnet-4", [ + { type: "file", file: { filename: "remote.pdf", file_data: "https://example.com/a.pdf" } }, + ]); + const doc = blocks.find((b) => b.type === "document"); + assert.ok(doc, "remote PDF file block must become a Claude document url block"); + assert.equal(doc.source.type, "url"); + assert.equal(doc.source.url, "https://example.com/a.pdf"); +}); + +test("openaiToClaudeRequest skips a video file block (Claude has no native video input)", () => { + const blocks = userBlocks("claude-sonnet-4", [ + { type: "text", text: "watch this" }, + { type: "file", file: { filename: "clip.mp4", file_data: "data:video/mp4;base64,AAAAIGZ0" } }, + ]); + const doc = blocks.find((b) => b.type === "document"); + const img = blocks.find((b) => b.type === "image"); + assert.ok( + !doc && !img, + "a video file must not be mislabeled as a Claude document or image block" + ); + // the text part is still forwarded + assert.ok( + blocks.some((b) => b.type === "text"), + "the accompanying text part must still be forwarded" + ); +}); diff --git a/tests/unit/openai-to-claude-glm-split-tool-name-2077.test.ts b/tests/unit/openai-to-claude-glm-split-tool-name-2077.test.ts new file mode 100644 index 0000000000..3037a0c0e3 --- /dev/null +++ b/tests/unit/openai-to-claude-glm-split-tool-name-2077.test.ts @@ -0,0 +1,120 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { openaiToClaudeResponse } from "../../open-sse/translator/response/openai-to-claude.ts"; + +// Regression guard for GLM 5.2 (and similar OpenAI-compatible upstreams) that stream a +// tool call's `id` and `function.name` across SEPARATE SSE delta chunks. The Claude SSE +// protocol cannot patch a `content_block_start` after it is emitted, so the translator +// must DEFER `content_block_start` until the tool name has arrived. Previously the block +// was emitted immediately on the id-only chunk with an empty name, and the later +// name-only chunk was silently dropped — Claude Code then rejected the tool_use with +// "No such tool available:" / empty tool name. Ported from decolua/9router#2077. + +function createState() { + return { toolCalls: new Map() }; +} + +function flatten(items) { + return items.flatMap((item) => item || []); +} + +test("#2077: GLM streams tool id then name in separate chunks — content_block_start carries the real name", () => { + const state = createState(); + + // Chunk 1: id only, no function.name yet (GLM 5.2 behavior). + const c1 = openaiToClaudeResponse( + { + id: "chatcmpl-glm", + model: "glm/glm-5.2", + choices: [ + { index: 0, delta: { tool_calls: [{ index: 0, id: "call_glm_1", type: "function" }] }, finish_reason: null }, + ], + }, + state + ); + // Chunk 2: function.name only, no id, no arguments. + const c2 = openaiToClaudeResponse( + { + id: "chatcmpl-glm", + model: "glm/glm-5.2", + choices: [ + { index: 0, delta: { tool_calls: [{ index: 0, function: { name: "get_weather" } }] }, finish_reason: null }, + ], + }, + state + ); + // Chunk 3: arguments. + const c3 = openaiToClaudeResponse( + { + id: "chatcmpl-glm", + model: "glm/glm-5.2", + choices: [ + { + index: 0, + delta: { tool_calls: [{ index: 0, function: { arguments: '{"city":"SP"}' } }] }, + finish_reason: null, + }, + ], + }, + state + ); + const cEnd = openaiToClaudeResponse( + { + id: "chatcmpl-glm", + model: "glm/glm-5.2", + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + }, + state + ); + + const events = flatten([c1, c2, c3, cEnd]); + const starts = events.filter((e) => e?.type === "content_block_start" && e.content_block?.type === "tool_use"); + + assert.equal(starts.length, 1, "exactly one tool_use content_block_start"); + assert.equal(starts[0].content_block.name, "get_weather", "tool name must be captured (not empty)"); + assert.equal(starts[0].content_block.id, "call_glm_1", "tool id preserved"); + + // Arguments must still be delivered (the name-only chunk must not swallow them). + const argDeltas = events + .filter((e) => e?.type === "content_block_delta" && e.delta?.type === "input_json_delta") + .map((e) => e.delta.partial_json) + .join(""); + assert.equal(argDeltas, '{"city":"SP"}'); +}); + +test("#2077 no-regression: id+name+arguments in one chunk still emits a single named start", () => { + const state = createState(); + const c1 = openaiToClaudeResponse( + { + id: "chatcmpl-x", + model: "openai/gpt-4", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { index: 0, id: "call_1", type: "function", function: { name: "search", arguments: '{"q":"hi"}' } }, + ], + }, + finish_reason: null, + }, + ], + }, + state + ); + const cEnd = openaiToClaudeResponse( + { id: "chatcmpl-x", model: "openai/gpt-4", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }, + state + ); + const events = flatten([c1, cEnd]); + const starts = events.filter((e) => e?.type === "content_block_start" && e.content_block?.type === "tool_use"); + assert.equal(starts.length, 1); + assert.equal(starts[0].content_block.name, "search"); + assert.equal(starts[0].content_block.id, "call_1"); + const args = events + .filter((e) => e?.type === "content_block_delta" && e.delta?.type === "input_json_delta") + .map((e) => e.delta.partial_json) + .join(""); + assert.equal(args, '{"q":"hi"}'); +}); diff --git a/tests/unit/openai-to-gemini-gemma-thinking.test.ts b/tests/unit/openai-to-gemini-gemma-thinking.test.ts new file mode 100644 index 0000000000..3bed5678ac --- /dev/null +++ b/tests/unit/openai-to-gemini-gemma-thinking.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression guard for the OpenAI→Gemini translation of Gemma models. +// claude-to-gemini.ts already guards against sending `thinkingConfig` for +// gemma-4-* models (Gemma does not support it — Vertex returns 400: +// "Thinking budget is not supported for this model"). openai-to-gemini.ts +// lacked the same guard, so OpenAI-shape clients hitting a vertex +// `gemma-4-*` model still triggered the 400. +// Port of the thinkingConfig-guard half of decolua/9router#2480 (the +// signature-replay half of that PR is out of scope and NOT ported here). +const { openaiToGeminiRequest } = await import( + "../../open-sse/translator/request/openai-to-gemini.ts" +); + +type GeminiRequestResult = { + generationConfig?: { thinkingConfig?: unknown }; +}; + +test("gemma-4 model: reasoning_effort does NOT produce a thinkingConfig", () => { + const result = openaiToGeminiRequest( + "gemma-4-31b-it", + { + model: "gemma-4-31b-it", + messages: [{ role: "user", content: "hi" }], + reasoning_effort: "high", + stream: false, + }, + false + ) as GeminiRequestResult; + + assert.equal( + result.generationConfig?.thinkingConfig, + undefined, + "gemma-4 models must never receive thinkingConfig (Vertex returns 400)" + ); +}); + +test("gemma-4 model: Claude-style thinking.budget_tokens does NOT produce a thinkingConfig", () => { + const result = openaiToGeminiRequest( + "gemma-4-31b-it", + { + model: "gemma-4-31b-it", + messages: [{ role: "user", content: "hi" }], + thinking: { type: "enabled", budget_tokens: 4096 }, + stream: false, + }, + false + ) as GeminiRequestResult; + + assert.equal( + result.generationConfig?.thinkingConfig, + undefined, + "gemma-4 models must never receive thinkingConfig even via the Claude-shape thinking field" + ); +}); + +test("non-gemma gemini model: reasoning_effort STILL produces a thinkingConfig (no regression)", () => { + const result = openaiToGeminiRequest( + "gemini-2.5-flash", + { + model: "gemini-2.5-flash", + messages: [{ role: "user", content: "hi" }], + reasoning_effort: "high", + stream: false, + }, + false + ) as GeminiRequestResult; + + assert.ok( + result.generationConfig?.thinkingConfig, + "non-gemma Gemini models must keep receiving thinkingConfig" + ); +}); diff --git a/tests/unit/playground-model-selection-3731.test.ts b/tests/unit/playground-model-selection-3731.test.ts index b59254c617..63be0684ea 100644 --- a/tests/unit/playground-model-selection-3731.test.ts +++ b/tests/unit/playground-model-selection-3731.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { pickDefaultModel, resolveModelFilterKey, + filterModelsByQuery, } from "../../src/app/(dashboard)/dashboard/playground/components/modelSelection.ts"; // Regression guards for #3731 (dup #3009): the Playground model selector was unusable @@ -48,3 +49,26 @@ test("pickDefaultModel: a current model not in the list is replaced by the first test("pickDefaultModel: a valid current model is kept (no redundant update)", () => { assert.equal(pickDefaultModel("b", ["a", "b"]), null); }); + +// Regression guards for #4086: search/filter on the raw Playground model had no search/filter, forcing users to scroll +// a flat list (e.g. 50+ OpenRouter models). Regression guard for the search box added to +// StudioConfigPane's model picker. + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/lib/playground/codeExport", () => ({ + endpointToPath: (ep: string) => `/v1/${ep}`, +})); + +const AVAILABLE_MODELS = ["openai/gpt-4o", "anthropic/claude-3", "openrouter/mistral-large"]; + +vi.mock("@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels", () => ({ + useAvailableModels: () => ({ + availableModels: AVAILABLE_MODELS, + modelCapabilities: {}, + loading: false, + }), +})); + +vi.mock("@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions", () => ({ + useProviderOptions: () => ({ + provider: "", + setProvider: vi.fn(), + providerOptions: [], + loading: false, + }), +})); + +const { default: StudioConfigPane } = await import( + "../../../src/app/(dashboard)/dashboard/playground/components/StudioConfigPane" +); +const { DEFAULT_PARAMS } = await import( + "../../../src/app/(dashboard)/dashboard/playground/components/ParamSliders" +); + +const containers: Array<{ root: ReturnType; el: HTMLDivElement }> = []; + +function makeConfig() { + return { + endpoint: "chat.completions" as const, + baseUrl: "http://localhost:20128", + model: "openai/gpt-4o", + systemPrompt: "You are a helpful assistant.", + params: { ...DEFAULT_PARAMS }, + }; +} + +function renderPane( + configState: ReturnType, + setConfigState: (s: ReturnType) => void +): HTMLDivElement { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + act(() => { + root.render( + void} + /> + ); + }); + containers.push({ root, el }); + return el; +} + +function setInputValue(input: HTMLInputElement, value: string) { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + nativeInputValueSetter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("StudioConfigPane model search (#4086)", () => { + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + for (const { root, el } of containers.splice(0)) { + act(() => root.unmount()); + el.remove(); + } + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("renders a search input above the model select", () => { + const config = makeConfig(); + const el = renderPane(config, vi.fn()); + const searchInput = el.querySelector( + "input[type='text'][placeholder='search']" + ) as HTMLInputElement | null; + expect(searchInput).toBeTruthy(); + }); + + it("shows all models in the select when the search box is empty", () => { + const config = makeConfig(); + const el = renderPane(config, vi.fn()); + const modelSelect = Array.from(el.querySelectorAll("select")).find((s) => + Array.from(s.options).some((o) => o.value === "openai/gpt-4o") + ); + expect(modelSelect).toBeTruthy(); + expect(modelSelect?.options.length).toBe(AVAILABLE_MODELS.length); + }); + + it("filters the model select options as the user types", () => { + const config = makeConfig(); + const el = renderPane(config, vi.fn()); + + const searchInput = el.querySelector( + "input[type='text'][placeholder='search']" + ) as HTMLInputElement; + expect(searchInput).toBeTruthy(); + + act(() => { + setInputValue(searchInput, "claude"); + }); + + const modelSelect = Array.from(el.querySelectorAll("select")).find((s) => + Array.from(s.options).some((o) => o.value === "anthropic/claude-3") + ); + expect(modelSelect).toBeTruthy(); + // The non-matching "openrouter/mistral-large" model is filtered out. The currently + // selected "openai/gpt-4o" stays pinned (see the dedicated test below) even though it + // doesn't match "claude" — so the matched model plus the pinned selection remain. + const values = Array.from(modelSelect?.options ?? []).map((o) => o.value); + expect(values).toContain("anthropic/claude-3"); + expect(values).not.toContain("openrouter/mistral-large"); + }); + + it("keeps a currently selected model visible even when it doesn't match the query", () => { + const config = makeConfig(); // model = "openai/gpt-4o" + const el = renderPane(config, vi.fn()); + + const searchInput = el.querySelector( + "input[type='text'][placeholder='search']" + ) as HTMLInputElement; + + act(() => { + setInputValue(searchInput, "claude"); + }); + + const modelSelect = Array.from(el.querySelectorAll("select")).find((s) => + Array.from(s.options).some((o) => o.value === "anthropic/claude-3") + ); + const values = Array.from(modelSelect?.options ?? []).map((o) => o.value); + expect(values).toContain("openai/gpt-4o"); + }); +}); diff --git a/tests/unit/ui/request-logger-autorefresh-visibility-3972.test.tsx b/tests/unit/ui/request-logger-autorefresh-visibility-3972.test.tsx index 44f9284fbb..c0027b1a00 100644 --- a/tests/unit/ui/request-logger-autorefresh-visibility-3972.test.tsx +++ b/tests/unit/ui/request-logger-autorefresh-visibility-3972.test.tsx @@ -36,10 +36,10 @@ vi.mock("@/store/emailPrivacyStore", () => ({ default: () => ({ emailsVisible: true }), })); -const RequestLoggerV2 = (await import("../../../src/shared/components/RequestLoggerV2.tsx")).default; -const { DEFAULT_REFRESH_INTERVAL_SEC } = await import( - "../../../src/shared/components/requestLoggerPreferences.ts" -); +const RequestLoggerV2 = (await import("../../../src/shared/components/RequestLoggerV2.tsx")) + .default; +const { DEFAULT_REFRESH_INTERVAL_SEC } = + await import("../../../src/shared/components/requestLoggerPreferences.ts"); function setVisibility(state: "visible" | "hidden") { Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state }); @@ -58,8 +58,25 @@ let callLogsRequests = 0; let container: HTMLElement; let root: Root; +function deferredResponse() { + let resolve!: (response: Response) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + beforeEach(() => { callLogsRequests = 0; + if (!globalThis.localStorage) { + const store = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, String(value)), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + }); + } localStorage.clear(); vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); vi.stubGlobal( @@ -86,15 +103,96 @@ beforeEach(() => { }); afterEach(async () => { - await act(async () => { - root.unmount(); - }); - container.remove(); + if (root) { + await act(async () => { + root.unmount(); + }); + } + container?.remove(); vi.useRealTimers(); vi.unstubAllGlobals(); setVisibility("visible"); }); +describe("RequestLoggerV2 detail modal lifecycle", () => { + it("does not reopen a manually closed detail modal when a stale detail fetch resolves", async () => { + setVisibility("visible"); + const detail = deferredResponse(); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/usage/call-logs")) { + return Response.json([ + { + id: "log-1", + status: 200, + method: "POST", + path: "/v1/chat/completions", + model: "gpt-test", + provider: "openai", + timestamp: new Date().toISOString(), + duration: 42, + tokens: { in: 1, out: 2 }, + }, + ]); + } + if (url.startsWith("/api/logs/log-1")) { + return detail.promise; + } + if (url.startsWith("/api/provider-nodes")) { + return Response.json({ nodes: [] }); + } + if (url.startsWith("/api/logs/detail")) { + return Response.json({ enabled: false }); + } + return Response.json({}); + }); + vi.stubGlobal("fetch", fetchMock); + + await act(async () => { + root.render(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + const row = Array.from(container.querySelectorAll("tr")).find((tr) => + tr.textContent?.includes("gpt-test") + ); + expect(row).toBeTruthy(); + + await act(async () => { + row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(container.querySelector('[aria-label="Request log detail"]')).not.toBeNull(); + + await act(async () => { + container + .querySelector('[aria-label="Close detail modal"]') + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(container.querySelector('[aria-label="Request log detail"]')).toBeNull(); + + await act(async () => { + detail.resolve( + Response.json({ + id: "log-1", + status: 200, + method: "POST", + path: "/v1/chat/completions", + model: "gpt-test", + provider: "openai", + timestamp: new Date().toISOString(), + duration: 43, + tokens: { in: 1, out: 3 }, + }) + ); + await detail.promise; + }); + + expect(container.querySelector('[aria-label="Request log detail"]')).toBeNull(); + }); +}); + describe("RequestLoggerV2 auto-refresh (#3972 + #4054)", () => { it("keeps polling on the interval when the tab becomes visible without a visibilitychange event", async () => { // Mounts while the document reports "hidden", no visibilitychange ever fires. diff --git a/tests/unit/validation-badge-unsupported-5442.test.ts b/tests/unit/validation-badge-unsupported-5442.test.ts index d10f024b86..74385b32cc 100644 --- a/tests/unit/validation-badge-unsupported-5442.test.ts +++ b/tests/unit/validation-badge-unsupported-5442.test.ts @@ -6,20 +6,32 @@ import assert from "node:assert/strict"; // The Add-API-Key modal only had success/failed states, so it rendered a red // "Invalid" badge for those providers even though the key was saved fine. The // "unsupported" result now maps to a neutral info "N/A" badge, not "Invalid". -const { validationBadgeProps } = await import( - "../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts" -); +const { validationBadgeProps } = + await import("../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts"); test("#5442 unsupported validation → neutral N/A badge, not red Invalid", () => { assert.deepEqual(validationBadgeProps("unsupported"), { variant: "info", labelKey: "notApplicable", + fallback: "N/A", }); }); test("#5442 success and failed badges are unchanged", () => { - assert.deepEqual(validationBadgeProps("success"), { variant: "success", labelKey: "valid" }); - assert.deepEqual(validationBadgeProps("failed"), { variant: "error", labelKey: "invalid" }); + assert.deepEqual(validationBadgeProps("success"), { + variant: "success", + labelKey: "valid", + fallback: "Valid", + }); + assert.deepEqual(validationBadgeProps("failed"), { + variant: "error", + labelKey: "invalid", + fallback: "Invalid", + }); // Any other/unknown result defaults to the error badge (fail-safe). - assert.deepEqual(validationBadgeProps("whatever"), { variant: "error", labelKey: "invalid" }); + assert.deepEqual(validationBadgeProps("whatever"), { + variant: "error", + labelKey: "invalid", + fallback: "Invalid", + }); }); diff --git a/tests/unit/web-cookie-validation-fallback.test.ts b/tests/unit/web-cookie-validation-fallback.test.ts index 6e1a1d75f7..c2d4373664 100644 --- a/tests/unit/web-cookie-validation-fallback.test.ts +++ b/tests/unit/web-cookie-validation-fallback.test.ts @@ -45,16 +45,19 @@ test.afterEach(() => { globalThis.fetch = originalFetch; }); -// ── lmarena (no registry entry, falls back to WEB_COOKIE_PROVIDERS) ── +// ── lmarena (#6280: gained a REAL providerRegistry entry — left the fallback class) ── +// Before #6280 lmarena had no registry entry, so this suite asserted the unsupported +// fallback. The Arena modernization added a real registry entry (registry/lmarena/), +// so validateWebCookieProvider now takes the probe path (directHttpsRequest against the +// registry baseUrl — which deliberately bypasses this suite's fetch mock, so the probe +// itself cannot be unit-asserted here). What this suite still guards for lmarena: its +// classification out of the no-registry fallback. -test("lmarena validation is unsupported (no verified auth probe) and makes no network call", async () => { - const result = await validateProviderApiKey({ - provider: "lmarena", - apiKey: "test-lmarena-cookie", - }); - assert.strictEqual(result.valid, false); - assert.equal(result.unsupported, true); - assert.equal(fetchCalls.length, 0, "must not probe the marketing website"); +test("lmarena has a registry entry — no longer the no-registry unsupported fallback", async () => { + const { getRegistryEntry } = await import("@omniroute/open-sse/config/providerRegistry.ts"); + const entry = getRegistryEntry("lmarena"); + assert.ok(entry, "lmarena must have a providerRegistry entry (#6280 Arena modernization)"); + assert.ok(entry.baseUrl, "the probe path requires a real baseUrl on the registry entry"); }); test("lmarena validation rejects empty cookie before checking support", async () => { diff --git a/tests/unit/web-session-credentials.test.ts b/tests/unit/web-session-credentials.test.ts index 366b502960..250ac5ea39 100644 --- a/tests/unit/web-session-credentials.test.ts +++ b/tests/unit/web-session-credentials.test.ts @@ -37,22 +37,27 @@ test("web session credential metadata identifies cookie, token, and no-auth prov acceptsFullCookieHeader: false, storageKeys: ["token", "userToken"], }); - // lmarena.ai's real auth cookie is `arena-auth-prod-v1`, not `session` (#3810). - // The session is now split across `arena-auth-prod-v1.0`, `.1`, … (#4271). - assert.deepEqual(webSessionCredentials.getWebSessionCredentialRequirement("lmarena"), { - kind: "cookie", - credentialName: "arena-auth-prod-v1", - placeholder: - "Paste the full Cookie header from lmarena.ai (the session is now split across arena-auth-prod-v1.0, .1, …)", - acceptsFullCookieHeader: true, - storageKeys: [ - "cookie", - "arena-auth-prod-v1", - "arena-auth-prod-v1.0", - "arena-auth-prod-v1.1", - "session", - ], - }); + // Arena (lmarena): assert contract/intent only — do not freeze UX copy. + // #3810 chunk name, #4271 split SSR cookies, full Cookie header paste. + { + const req = webSessionCredentials.getWebSessionCredentialRequirement("lmarena"); + assert.ok(req && req.kind === "cookie"); + assert.equal(req.acceptsFullCookieHeader, true); + assert.equal(req.hintKey, "lmarenaWebCookieHint"); + assert.ok(req.storageKeys.includes("cookie")); + assert.ok(req.storageKeys.includes("arena-auth-prod-v1.0")); + assert.ok(req.storageKeys.includes("arena-auth-prod-v1.1")); + // legacy key retained for already-saved credentials + assert.ok(req.storageKeys.includes("session")); + assert.ok(/full cookie header/i.test(req.credentialName)); + assert.ok(/arena-auth-prod-v1/i.test(req.placeholder)); + // hintFallback is operator copy — may change with CF/reCAPTCHA notes; must still + // steer users to a full header and away from the empty single base cookie. + assert.ok(typeof req.hintFallback === "string" && req.hintFallback.length > 0); + assert.ok(/full cookie header/i.test(req.hintFallback)); + assert.ok(/arena-auth-prod-v1/i.test(req.hintFallback)); + assert.ok(/empty/i.test(req.hintFallback)); + } assert.deepEqual(webSessionCredentials.getWebSessionCredentialRequirement("huggingchat"), { kind: "cookie", credentialName: "full Cookie header (hf-chat + token)",