diff --git a/.env.example b/.env.example index aac4c32344..8e201f221a 100644 --- a/.env.example +++ b/.env.example @@ -1027,7 +1027,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup. # Update these when providers release new CLI versions to avoid blocks. -CLAUDE_USER_AGENT="claude-cli/2.1.207 (external, cli)" +CLAUDE_USER_AGENT="claude-cli/2.1.219 (external, cli)" # Disable the deterministic tool-name cloak applied on both Anthropic-bound paths # (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) — diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea3f8cdebc..69817adcb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -477,6 +477,7 @@ jobs: cache: npm - uses: ./.github/actions/npm-ci-retry - run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN + - run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-TW # D4 (plano mestre testes+CI): a matrix de ~40 jobs de <1min por idioma saturava sozinha # a concorrência de jobs da conta (Free = 20 slots, compartilhados entre TODOS os repos) @@ -496,7 +497,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7 with: python-version: "3.12" - name: Validate all languages @@ -907,7 +908,7 @@ jobs: # (if-no-files-found: warn) — Sonar consumes the same file. - name: Upload coverage to Codecov (informational) if: always() - uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: coverage/lcov.info token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 34dd99666a..0761674a01 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,10 +22,10 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: languages: javascript-typescript queries: security-extended - - uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: category: "/language:javascript-typescript" diff --git a/.github/workflows/dast-smoke.yml b/.github/workflows/dast-smoke.yml index 717bf669d3..f8de11432e 100644 --- a/.github/workflows/dast-smoke.yml +++ b/.github/workflows/dast-smoke.yml @@ -53,7 +53,7 @@ jobs: if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi sleep 2 done - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - run: pip install schemathesis diff --git a/.github/workflows/nightly-llm-security.yml b/.github/workflows/nightly-llm-security.yml index a879258919..140a92d2b9 100644 --- a/.github/workflows/nightly-llm-security.yml +++ b/.github/workflows/nightly-llm-security.yml @@ -90,7 +90,7 @@ jobs: if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi sleep 2 done - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7 if: steps.gate.outputs.run == 'true' with: { python-version: "3.12" } - run: pip install garak diff --git a/.github/workflows/nightly-schemathesis.yml b/.github/workflows/nightly-schemathesis.yml index 2ef0cea8d0..2bdf91a1c0 100644 --- a/.github/workflows/nightly-schemathesis.yml +++ b/.github/workflows/nightly-schemathesis.yml @@ -35,7 +35,7 @@ jobs: if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi sleep 2 done - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7 with: { python-version: "3.12" } - name: Install schemathesis run: pip install schemathesis diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index bb3a482722..a0b371bd9b 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: persist-credentials: false - name: Run analysis - uses: ossf/scorecard-action@v2.4.3 + uses: ossf/scorecard-action@v2.4.4 with: results_file: results.sarif results_format: sarif diff --git a/bin/cli/commands/backup.mjs b/bin/cli/commands/backup.mjs index 2801a41e18..6cbe79ef6c 100644 --- a/bin/cli/commands/backup.mjs +++ b/bin/cli/commands/backup.mjs @@ -78,18 +78,15 @@ export function registerBackup(program) { if (exitCode !== 0) process.exit(exitCode); }); - // Legacy: `omniroute backup` without subcommand still creates a backup + // Legacy: `omniroute backup` without a subcommand still creates a backup + // (documented as the canonical usage in USER_GUIDE.md / CLI-TOOLS.md / + // AGENT-SKILLS.md). No flags are declared here — declaring the same + // option names as `create`/`auto enable` here previously shadowed them + // (#8512), and no doc shows `omniroute backup` invoked with flags. backup.action(async (opts) => { const exitCode = await runBackupCommand(opts); if (exitCode !== 0) process.exit(exitCode); }); - backup - .option("--name ", t("backup.nameOpt")) - .option("--cloud", t("backup.cloudOpt")) - .option("--encrypt", t("backup.encryptOpt")) - .option("--key-file ", t("backup.keyFileOpt")) - .option("--exclude ", t("backup.excludeOpt"), (v, prev = []) => [...prev, v], []) - .option("--retention ", t("backup.retentionOpt"), parseInt); } export function registerRestore(program) { diff --git a/bin/cli/commands/setup.mjs b/bin/cli/commands/setup.mjs index cda32573b2..d80777c0c6 100644 --- a/bin/cli/commands/setup.mjs +++ b/bin/cli/commands/setup.mjs @@ -26,6 +26,7 @@ function wantsProviderSetup(opts) { async function resolvePassword(opts, prompt, nonInteractive) { if (opts.password) return opts.password; + if (process.env.INITIAL_PASSWORD) return process.env.INITIAL_PASSWORD; if (nonInteractive) return ""; const answer = await prompt.ask("Set an admin password now? [y/N]", "N"); diff --git a/bin/cli/sqlite.mjs b/bin/cli/sqlite.mjs index 6b32c1a936..2bdb7bd544 100644 --- a/bin/cli/sqlite.mjs +++ b/bin/cli/sqlite.mjs @@ -5,15 +5,34 @@ import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./ async function loadSqlite() { if (process.versions.bun) { - return (await import("bun:sqlite")).Database; + return { Database: (await import("bun:sqlite")).Database }; } try { - return (await import("better-sqlite3")).default; - } catch { - throw new Error("better-sqlite3 is not installed. Run npm install before using setup."); + return { Database: (await import("better-sqlite3")).default }; + } catch (error) { + return { error }; } } +// #7586: unlike the real server (src/lib/db/adapters/driverFactory.ts::tryOpenSync), +// this CLI helper historically had NO fallback beyond better-sqlite3 — so on any +// machine where better-sqlite3's native binary is unavailable (Windows without a +// prebuilt addon, etc.), every `omniroute doctor` DB check reported a false FAIL +// even when the actual server was healthy via its own (correct) driver cascade. +// Reuse that same cascade here instead of re-deriving it. +async function openWithSyncDriverFallback(dbPath, options, importError) { + try { + const { tryOpenSync } = await import("../../src/lib/db/adapters/driverFactory.ts"); + const adapter = tryOpenSync(dbPath, options); + if (adapter) { + return adapter; + } + } catch { + // fall through to the original better-sqlite3 error below + } + throw createSqliteNativeError(importError); +} + function openBunSqlite(Database, dbPath, options) { const raw = new Database(dbPath, options); const prepare = (sql) => { @@ -91,19 +110,25 @@ export function createSqliteNativeError(error) { } async function openSqliteDatabase(dbPath, options = {}) { - const Database = await loadSqlite(); + const loaded = await loadSqlite(); if (process.versions.bun) { if (options.fileMustExist && !fs.existsSync(dbPath)) { throw new Error(`SQLite file does not exist: ${dbPath}`); } - options = options.readonly + const bunOptions = options.readonly ? { readonly: true } : { readwrite: true, create: options.fileMustExist !== true }; + try { + return openBunSqlite(loaded.Database, dbPath, bunOptions); + } catch (error) { + throw createSqliteNativeError(error); + } + } + if (loaded.error) { + return openWithSyncDriverFallback(dbPath, options, loaded.error); } try { - return process.versions.bun - ? openBunSqlite(Database, dbPath, options) - : new Database(dbPath, options); + return new loaded.Database(dbPath, options); } catch (error) { throw createSqliteNativeError(error); } diff --git a/changelog.d/features/8249-call-logs-session-tag.md b/changelog.d/features/8249-call-logs-session-tag.md new file mode 100644 index 0000000000..d747081fe8 --- /dev/null +++ b/changelog.d/features/8249-call-logs-session-tag.md @@ -0,0 +1 @@ +- feat(db): persist caller session tag into call_logs for per-session cost attribution (#8249) diff --git a/changelog.d/features/8297-web-fetch-quota-aware-fallback.md b/changelog.d/features/8297-web-fetch-quota-aware-fallback.md new file mode 100644 index 0000000000..c916cf1767 --- /dev/null +++ b/changelog.d/features/8297-web-fetch-quota-aware-fallback.md @@ -0,0 +1 @@ +- feat(api): quota-aware fallback routing for web-fetch providers (#8297) diff --git a/changelog.d/features/8347-cliproxy-reasoning-levels.md b/changelog.d/features/8347-cliproxy-reasoning-levels.md new file mode 100644 index 0000000000..b75cb72687 --- /dev/null +++ b/changelog.d/features/8347-cliproxy-reasoning-levels.md @@ -0,0 +1 @@ +- feat(providers): map upstream reasoning-level metadata in openai-compatible discovery (#8347) diff --git a/changelog.d/features/adobe-firefly-reference-images.md b/changelog.d/features/adobe-firefly-reference-images.md new file mode 100644 index 0000000000..5c889d9a76 --- /dev/null +++ b/changelog.d/features/adobe-firefly-reference-images.md @@ -0,0 +1 @@ +- **feat(adobe-firefly):** reference-image attach for generate + OpenAI `/v1/images/edits` support (follow-up to #8006). Uploads sources to Firefly storage (`POST /v2/storage/image`), then submits `referenceBlobs` on 3P generate-async (nano multi-ref `usage:general`; gpt-image `usage:subject`). Wire matches live `firefly.adobe.com` captures. Also routes built-in edits to the same path (up to 4 refs). diff --git a/changelog.d/fixes/7447-kimi-cn-key-format.md b/changelog.d/fixes/7447-kimi-cn-key-format.md new file mode 100644 index 0000000000..9bd45b2eb2 --- /dev/null +++ b/changelog.d/fixes/7447-kimi-cn-key-format.md @@ -0,0 +1 @@ +- fix(providers): expose a base-URL override for Kimi/Moonshot so CN-region API keys (issued on platform.kimi.com / moonshot.cn) can be pointed at api.moonshot.cn instead of being rejected by the international host (#7447) diff --git a/changelog.d/fixes/7503-empty-choices-stream-readiness.md b/changelog.d/fixes/7503-empty-choices-stream-readiness.md new file mode 100644 index 0000000000..95861c9e07 --- /dev/null +++ b/changelog.d/fixes/7503-empty-choices-stream-readiness.md @@ -0,0 +1 @@ +- fix(sse): stop stream readiness from treating a choices-less mid-stream error frame as a successful stream, so combo can fail over instead of returning zero `choices` (#7503) diff --git a/changelog.d/fixes/7586-windows-sqlite-launch.md b/changelog.d/fixes/7586-windows-sqlite-launch.md new file mode 100644 index 0000000000..735f7b7556 --- /dev/null +++ b/changelog.d/fixes/7586-windows-sqlite-launch.md @@ -0,0 +1 @@ +- fix(cli): fall back to the node:sqlite driver cascade in `bin/cli/sqlite.mjs` so `omniroute doctor` no longer reports a false "FAIL Database"/"FAIL Storage/encryption" on machines without a working better-sqlite3 native binary (#7586) diff --git a/changelog.d/fixes/7587-vscode-models-missing-openai.md b/changelog.d/fixes/7587-vscode-models-missing-openai.md new file mode 100644 index 0000000000..c5fd715438 --- /dev/null +++ b/changelog.d/fixes/7587-vscode-models-missing-openai.md @@ -0,0 +1 @@ +- fix(api): expose Responses-API-format (OpenAI/Codex) chat models on every VS Code Ollama-compatible listing route, not just `/models` (#7587) diff --git a/changelog.d/fixes/7847-bound-client-raw-request.md b/changelog.d/fixes/7847-bound-client-raw-request.md new file mode 100644 index 0000000000..80ef4870d8 --- /dev/null +++ b/changelog.d/fixes/7847-bound-client-raw-request.md @@ -0,0 +1 @@ +- fix(backend): stop `buildClientRawRequest` deep-cloning the whole request body on every chat request (#7847) — every consumer of `clientRawRequest.body` is observability and keeps at most a bounded copy, so the unbounded clone retained ~41x more than anything used it (3.19 MiB vs 0.08 MiB on a 3.05 MiB / 729-message agent request). Also makes `cloneBoundedForLog` idempotent: arrays, objects and strings all exceeded their own bounds once the truncation marker was added, so re-bounding an already bounded payload silently dropped a further item and misreported the original length diff --git a/changelog.d/fixes/7847-combo-attempt-body-cow.md b/changelog.d/fixes/7847-combo-attempt-body-cow.md new file mode 100644 index 0000000000..afa3539cd0 --- /dev/null +++ b/changelog.d/fixes/7847-combo-attempt-body-cow.md @@ -0,0 +1 @@ +- fix(sse): copy the combo attempt body shallowly instead of deep-cloning it per target (#7847) — the deep clone cost 9.53 MiB at 3 targets and scaled linearly with the target count (31.78 MiB at 10) on a 3.05 MiB agent request, while the isolation it provided only ever needed to contain top-level scalar writes. Also fixes a real cross-target leak in round-robin, which copied the body only when the reasoning buffer changed `max_tokens` and otherwise shared the caller's object, so a Background Task Redirection on one target rewrote `body.model` for the next diff --git a/changelog.d/fixes/7847-structural-json-size.md b/changelog.d/fixes/7847-structural-json-size.md new file mode 100644 index 0000000000..31bfe11f2e --- /dev/null +++ b/changelog.d/fixes/7847-structural-json-size.md @@ -0,0 +1 @@ +- fix(sse): estimate the combo fallback-compression trigger from the request object instead of `JSON.stringify(...)` (#7847) — the string path charged an inline base64 image as if every character were prose (~50k tokens instead of ~1.2k on a 200 KB image), falsely tripping compression on requests nowhere near the context window; the same over-count #8368/#8401 fixed elsewhere. Adds `jsonLength()`, an exact serialized-length walker (property-tested against `JSON.stringify`), and uses it for the readiness-timeout and token estimates so a multi-megabyte body is no longer materialized as a string just to be measured diff --git a/changelog.d/fixes/8014-zai-web-auth.md b/changelog.d/fixes/8014-zai-web-auth.md new file mode 100644 index 0000000000..e836c3dacd --- /dev/null +++ b/changelog.d/fixes/8014-zai-web-auth.md @@ -0,0 +1 @@ +- fix(providers): repoint the zai-web executor at chat.z.ai's current v2 chat-completions endpoint, fixing model-independent 404s (#8014) diff --git a/changelog.d/fixes/8032-path-shaped-vision.md b/changelog.d/fixes/8032-path-shaped-vision.md new file mode 100644 index 0000000000..ce7cefa7d5 --- /dev/null +++ b/changelog.d/fixes/8032-path-shaped-vision.md @@ -0,0 +1 @@ +- **fix(providers):** path-shaped multimodal model ids (e.g. `cp/cline-pass/kimi-k3`) resolve native vision via leaf/registry metadata instead of triggering Vision Bridge ([#8032](https://github.com/diegosouzapw/OmniRoute/issues/8032)) — thanks @Prudhvivuda diff --git a/changelog.d/fixes/8083-responses-input-items-status.md b/changelog.d/fixes/8083-responses-input-items-status.md new file mode 100644 index 0000000000..d184d8dbee --- /dev/null +++ b/changelog.d/fixes/8083-responses-input-items-status.md @@ -0,0 +1 @@ +- fix(translator): set `status: "completed"` on translated OpenAI Responses `input` items so strict Responses-compatible upstreams stop rejecting them with 400 MissingParameter input.status (#8083) diff --git a/changelog.d/fixes/8355-zhcn-zhtw-i18n-propernouns.md b/changelog.d/fixes/8355-zhcn-zhtw-i18n-propernouns.md new file mode 100644 index 0000000000..6651a8f65a --- /dev/null +++ b/changelog.d/fixes/8355-zhcn-zhtw-i18n-propernouns.md @@ -0,0 +1 @@ +- **fix(i18n):** Restore brand/model proper nouns (Claude, OpenAI, Anthropic, Gemini, MiniMax, etc.) in zh-CN and zh-TW — replace Chinese phonetic/translation forms (克劳德/打开Ai/人择/双子座) with original English, unify "provider" translation to "供应商/供應商", and apply zh-TW localized terminology (網路/設定/檔案/新增/啟用/搜尋/儲存) instead of mainland defaults (#8355 — thanks @ikelvingo). diff --git a/changelog.d/fixes/8368-image-token-context.md b/changelog.d/fixes/8368-image-token-context.md new file mode 100644 index 0000000000..b4641899da --- /dev/null +++ b/changelog.d/fixes/8368-image-token-context.md @@ -0,0 +1 @@ +- fix(api): estimate inline base64 image tokens instead of counting the data URL as text so it does not falsely exceed the context window (#8368) diff --git a/changelog.d/fixes/8370-priority-affinity-reorder.md b/changelog.d/fixes/8370-priority-affinity-reorder.md new file mode 100644 index 0000000000..851e000a82 --- /dev/null +++ b/changelog.d/fixes/8370-priority-affinity-reorder.md @@ -0,0 +1 @@ +- fix(backend): stop prompt-cache affinity from silently reordering an explicit priority combo across models (#8370) diff --git a/changelog.d/fixes/8374-plugins-status-optional.md b/changelog.d/fixes/8374-plugins-status-optional.md new file mode 100644 index 0000000000..2af60df3a0 --- /dev/null +++ b/changelog.d/fixes/8374-plugins-status-optional.md @@ -0,0 +1 @@ +- fix(api): accept a missing status query-param on GET /api/plugins instead of rejecting null with Invalid status value (#8374) diff --git a/changelog.d/fixes/8376-econnrefused-breaker.md b/changelog.d/fixes/8376-econnrefused-breaker.md new file mode 100644 index 0000000000..4e9756c203 --- /dev/null +++ b/changelog.d/fixes/8376-econnrefused-breaker.md @@ -0,0 +1 @@ +- fix(resilience): treat an unreachable-proxy ECONNREFUSED as a circuit-breaker event so combo fails over instead of hitting the 503 max-retry limit (#8376) diff --git a/changelog.d/fixes/8385-perkey-proxy-global-toggle.md b/changelog.d/fixes/8385-perkey-proxy-global-toggle.md new file mode 100644 index 0000000000..f30d5a44ed --- /dev/null +++ b/changelog.d/fixes/8385-perkey-proxy-global-toggle.md @@ -0,0 +1 @@ +- fix(backend): make disabling the global per-key proxy toggle override existing per-key proxy assignments (#8385) diff --git a/changelog.d/fixes/8388-compression-detail-persist.md b/changelog.d/fixes/8388-compression-detail-persist.md new file mode 100644 index 0000000000..da963af987 --- /dev/null +++ b/changelog.d/fixes/8388-compression-detail-persist.md @@ -0,0 +1 @@ +- fix(dashboard): persist compression engine detail settings (Headroom / session dedup / CCR) instead of dropping them on save (#8388) diff --git a/changelog.d/fixes/8395-plugin-hooks-fire.md b/changelog.d/fixes/8395-plugin-hooks-fire.md new file mode 100644 index 0000000000..4465f33254 --- /dev/null +++ b/changelog.d/fixes/8395-plugin-hooks-fire.md @@ -0,0 +1 @@ +- fix(plugins): fire registered+active plugin hooks (onRequest/onResponse/onError) during proxying instead of never invoking them (#8395) diff --git a/changelog.d/fixes/8396-cooldown-429-cap.md b/changelog.d/fixes/8396-cooldown-429-cap.md new file mode 100644 index 0000000000..684e2e1f6f --- /dev/null +++ b/changelog.d/fixes/8396-cooldown-429-cap.md @@ -0,0 +1 @@ +- fix(resilience): cap the connection cooldown after a 429 burst so combo fallback is not blacked out past the real rate-limit window (#8396) diff --git a/changelog.d/fixes/8429-capability-alias-canonicalization.md b/changelog.d/fixes/8429-capability-alias-canonicalization.md new file mode 100644 index 0000000000..6d8d49d8e1 --- /dev/null +++ b/changelog.d/fixes/8429-capability-alias-canonicalization.md @@ -0,0 +1 @@ +- fix(api): reach synced model_capabilities rows for canonical provider ids that only appear as an alias in MODELS_DEV_PROVIDER_MAP, e.g. codex/claude (#8429) diff --git a/changelog.d/fixes/8431-multiwindow-quota-eviction.md b/changelog.d/fixes/8431-multiwindow-quota-eviction.md new file mode 100644 index 0000000000..9db9be88f5 --- /dev/null +++ b/changelog.d/fixes/8431-multiwindow-quota-eviction.md @@ -0,0 +1 @@ +- fix(providers): stop marking a multi-quota-window provider exhausted when only some windows are depleted (LIMIT-200 snapshot eviction drops idle healthy windows) (#8431) diff --git a/changelog.d/fixes/8466-agentbridge-dns-per-agent.md b/changelog.d/fixes/8466-agentbridge-dns-per-agent.md new file mode 100644 index 0000000000..96e0f8e751 --- /dev/null +++ b/changelog.d/fixes/8466-agentbridge-dns-per-agent.md @@ -0,0 +1 @@ +- fix(backend): compute AgentBridge diagnose `dnsConfigured` per-agent instead of hard-coded to Antigravity hosts (#8466) diff --git a/changelog.d/fixes/8467-opencode-extra-keys.md b/changelog.d/fixes/8467-opencode-extra-keys.md new file mode 100644 index 0000000000..91dec8f5fc --- /dev/null +++ b/changelog.d/fixes/8467-opencode-extra-keys.md @@ -0,0 +1 @@ +- **fix(providers):** OpencodeExecutor honors Extra API Keys rotation via `resolveEffectiveKey` (empty primary + extras no longer omit Authorization) ([#8467](https://github.com/diegosouzapw/OmniRoute/issues/8467)) — thanks @Prudhvivuda diff --git a/changelog.d/fixes/8486-combo-unavailable-field-mismatch.md b/changelog.d/fixes/8486-combo-unavailable-field-mismatch.md new file mode 100644 index 0000000000..e0d6826fed --- /dev/null +++ b/changelog.d/fixes/8486-combo-unavailable-field-mismatch.md @@ -0,0 +1 @@ +- fix(sse): stop combo's "all targets failed" response from attaching one target's retry-after window to an unrelated target's error message (#8486) diff --git a/changelog.d/fixes/8491-antigravity-projectid-persist.md b/changelog.d/fixes/8491-antigravity-projectid-persist.md new file mode 100644 index 0000000000..71ef22b43f --- /dev/null +++ b/changelog.d/fixes/8491-antigravity-projectid-persist.md @@ -0,0 +1 @@ +- fix(providers): persist a runtime-discovered Antigravity projectId back onto the connection so it survives token refreshes and restarts instead of being rediscovered or lost (#8491) diff --git a/changelog.d/fixes/8512-backup-option-shadowing.md b/changelog.d/fixes/8512-backup-option-shadowing.md new file mode 100644 index 0000000000..4e7c19f1e5 --- /dev/null +++ b/changelog.d/fixes/8512-backup-option-shadowing.md @@ -0,0 +1 @@ +- fix(cli): `backup create` / `backup auto enable` — option shadowing by the parent `backup` command removed; all flags (`--cloud`, `--encrypt`, `--retention`, `--name`, `--exclude`, `--key-file`) now reach the subcommand handler with their actual values instead of being silently discarded diff --git a/changelog.d/fixes/8530-combo-model-name-collision.md b/changelog.d/fixes/8530-combo-model-name-collision.md new file mode 100644 index 0000000000..65e70c5524 --- /dev/null +++ b/changelog.d/fixes/8530-combo-model-name-collision.md @@ -0,0 +1 @@ +- fix(api): surface a non-blocking warning + startup scan when a combo name shadows a real model id, instead of silently routing with zero signal (#8530) diff --git a/changelog.d/fixes/8565-kiro-auth-quota-model-discovery.md b/changelog.d/fixes/8565-kiro-auth-quota-model-discovery.md new file mode 100644 index 0000000000..2f01eed597 --- /dev/null +++ b/changelog.d/fixes/8565-kiro-auth-quota-model-discovery.md @@ -0,0 +1 @@ +- **fix(kiro):** support profileless Builder ID quota, preserve CLI auth identity, stabilize social OAuth polling, and use the live model catalog ([#8565](https://github.com/diegosouzapw/OmniRoute/pull/8565)) — thanks @nguyenha935 diff --git a/changelog.d/maintenance/8566-basered-rebaseline-mergequeue.md b/changelog.d/maintenance/8566-basered-rebaseline-mergequeue.md new file mode 100644 index 0000000000..a7afbb10f9 --- /dev/null +++ b/changelog.d/maintenance/8566-basered-rebaseline-mergequeue.md @@ -0,0 +1 @@ +- **chore(quality):** rebaseline complexity (2130→2183) and cognitive-complexity (951→968) across the v3.8.49 `/merge-prs` queue-drain. The first step (→2169/→956) cleared inherited base drift measured on the pristine release tip (the PR→release fast-path never ratchets these). The second step (→2183/→968) absorbs the aggregate own-growth of the 41-PR merge-train (each PR under-ceiling individually; the combined batch adds +14/+12). Owner-approved (2026-07-25); structural shrink tracked in [#3501](https://github.com/diegosouzapw/OmniRoute/issues/3501). diff --git a/changelog.d/maintenance/combo-predicates-extract.md b/changelog.d/maintenance/combo-predicates-extract.md new file mode 100644 index 0000000000..a469a43550 --- /dev/null +++ b/changelog.d/maintenance/combo-predicates-extract.md @@ -0,0 +1 @@ +- **chore(combo):** extract 8 pure error predicates and quota status helpers (`clampPercent`, `quotaRemainingPercentFromQuota`, `normalizeConnectionStatus`, `hasFutureRateLimitUntil`, `getConnectionStatusQuotaCutoffReason`, `isContextOverflow400`, `isParamValidation400`, `isModelScoped400`) from `open-sse/services/combo.ts` into `open-sse/services/combo/comboPredicates.ts` — pure move, zero behavior change; `combo.ts` shrinks from 3,651 to 3,554 lines while maintaining backward-compatible re-exports. diff --git a/changelog.d/maintenance/decomp-provider-validation.md b/changelog.d/maintenance/decomp-provider-validation.md new file mode 100644 index 0000000000..0514896b83 --- /dev/null +++ b/changelog.d/maintenance/decomp-provider-validation.md @@ -0,0 +1 @@ +- chore(validation): decompose `src/lib/providers/validation.ts` (→ 442 lines) by extracting the web-cookie, kiro and specialty inline validators into `validation/*` leaves — behavior-preserving move; the specialty validators that captured `isLocal` from the enclosing closure now take it as an explicit parameter, with the host dispatcher passing it at each call site diff --git a/changelog.d/maintenance/decomp-token-refresh.md b/changelog.d/maintenance/decomp-token-refresh.md new file mode 100644 index 0000000000..f9bc03062b --- /dev/null +++ b/changelog.d/maintenance/decomp-token-refresh.md @@ -0,0 +1 @@ +- chore(token-refresh): decompose `open-sse/services/tokenRefresh.ts` (999 → 724 lines) by extracting the rotation-map, CAS guard and circuit-breaker refresh logic into `tokenRefresh/*` leaves — behavior-preserving move; `tokenRefresh.ts` still re-exports the moved symbols so the public surface is unchanged diff --git a/changelog.d/maintenance/decomp-usage-service.md b/changelog.d/maintenance/decomp-usage-service.md new file mode 100644 index 0000000000..55806cc43b --- /dev/null +++ b/changelog.d/maintenance/decomp-usage-service.md @@ -0,0 +1 @@ +- chore(usage): decompose `open-sse/services/usage.ts` (999 → 253 lines) by extracting the crof, nanogpt, qoder, opencode, deepseek, bailian, vertex, xiaomi-mimo, xai and github usage fetchers into `usage/*` leaves — behavior-preserving move, the file is now a thin provider→fetcher dispatcher and the public import surface is unchanged diff --git a/changelog.d/maintenance/heap-benchmark-request-body.md b/changelog.d/maintenance/heap-benchmark-request-body.md new file mode 100644 index 0000000000..46ad07feaa --- /dev/null +++ b/changelog.d/maintenance/heap-benchmark-request-body.md @@ -0,0 +1 @@ +- chore(perf): add `npm run bench:heap-body` — a deterministic benchmark that attributes retained V8 heap to each request-body copy on the chat path (entry log clone, combo per-target clone, token-estimation string), reproducing the #7847 incident shape (3.05 MiB / 729 messages / 86 tools) so the clone-amplification work can be justified and regression-guarded with numbers instead of intuition diff --git a/changelog.d/maintenance/lint-suppression-resync-proxy-registry.md b/changelog.d/maintenance/lint-suppression-resync-proxy-registry.md new file mode 100644 index 0000000000..f6a8269335 --- /dev/null +++ b/changelog.d/maintenance/lint-suppression-resync-proxy-registry.md @@ -0,0 +1 @@ +- chore(ci): resync the stale `no-explicit-any` suppression count for `tests/unit/proxy-registry.test.ts` (frozen at 55, actual 54 since #8447) — ESLint was failing the whole run with "There are suppressions left that do not occur anymore", turning `npm run lint` red on `release/v3.8.49` for every PR branched off it diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index d099eb9f5a..9a1b890188 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,7 +1,8 @@ { "_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.", - "_rebaseline_2026_07_20_owner_night_drain": "Owner-approved (chat, 2026-07-20 ~00:50): 2072->2130. The day's 17 merged PRs consumed the entire slack (tip at 2069/2072); queue PRs #6973(+4)/#7662(+2)/#7719(+1) plus the #7744/#7779 reworks were collectively blocked. Owner chose a wide margin for the remainder of the v3.8.49 cycle instead of per-PR extraction.", - "count": 2130, + "_rebaseline_2026_07_25b_v3849_mergetrain_owngrowth": "Owner-approved (chat, 2026-07-25): 2169->2183 (+14). v3.8.49 /merge-prs 41-PR merge-train aggregate own-growth: measured 2183 on the combined boarded tree (tip ac15014ca7) vs 2169 on the pristine release tip. Each boarded PR sits under the ceiling individually, but the combined batch adds +14 (new branches in #8378 chatCore contextLimit / #8432 cursor native_todo / #8476 combo input-bound / #8526 combo select-all modals / etc — the pre-screen-flagged complexity-growth set). Same merge-burst-inherited-drift class as the notes below; owner chose absorbing the ceiling over per-PR helper-extraction churn. Structural shrink stays debt (#3501); tighten via --update next cycle.", + "_rebaseline_2026_07_25_v3849_mergequeue_drain": "Owner-approved (chat, 2026-07-25): 2130->2169 (+39). v3.8.49 /merge-prs queue-drain: the cycle's merge burst (the 8 base-red slices + owner PRs + parallel-session merges #8500-8508) accrued inherited cyclomatic drift the fast-path PR->release never ratchets (check:complexity does not run on PR->release). Measured 2169 on the pristine release tip 4053e2314a alone (BEFORE any queue PR boards) — so the entire +39 is base drift already on the tip, not any queued PR's own growth. Every merge-ready PR in the queue was tripping Fast Quality Gates on this shared base-red. Owner approved raising the ceiling to the measured tip value so the ~34-PR merge-train lands without per-PR helper-extraction churn. Structural shrink stays debt (#3501); tighten via --update next cycle.", + "count": 2183, "_rebaseline_2026_07_19_v3849_fix_sweep_cluster": "2059->2072 (owner-approved, 2026-07-19). /fix-prs validation-train sweep: a cluster of otherwise-clean contributor PRs (#6973/#7683/#7662/#7672/#7633/#7767, each +1/+2 cyclomatic own-growth from new provider/auth/combo branches) collectively pushed the count from tip 2056 to 2068. Individually all but #6973 sit under the old 2059 baseline; combined they exceed it. The tip had only 3 units of slack (2056 vs 2059), so every new-feature PR was tripping the ratchet (this was the 4th such block of the day after #7695/#7747/#7768). Owner approved raising the ceiling to 2072 = combined-cluster 2068 + 4 units headroom, so the cluster lands without per-PR helper-extraction churn and near-term feature PRs have breathing room. Measured 2068 on the 9-PR combined probe tree. Structural shrink stays debt (#3501); tighten via --update next cycle.", "_rebaseline_2026_07_18_pr7360_quota_visibility_resync": "2058->2059 (+1 vs recorded ceiling; measured 2056 fresh on release tip cab9e5f0c alone, so this ceiling still carries 2 units of un-banked slack from prior shrinkage — real regression from this merge is 2056->2059, +3). PR #7360 (JxnLexn) release-resync: merging origin/release/v3.8.49 to resolve the 3-file conflict (ConnectionRow.tsx/ConnectionsListPanel.tsx/useProviderConnections.ts) unions two already-compliant features in the same already-oversized god-component: release's confirm-delete-account wiring (#7361) and this PR's per-connection quota-visibility wiring. Diffed release-tip-only vs merged violation lists (scripts dumped via getComplexityEslintReport): most entries are the SAME pre-existing violations shifted a few lines (ConnectionRow/getStatusPresentation/inferErrorType — no count change) or marginally bigger (ConnectionRow function complexity 85->86, ConnectionsListPanel function 498->510 lines) from the two ConnectionRow call sites each gaining both PRs' multi-line JSX props. The 2 genuinely NEW crossings are the 'no tag' and 'tagged groups' .map() render callbacks in ConnectionsListPanel.tsx (83 and 85 lines, was <=80 on both parents individually) tipping over 80 lines specifically because both PRs' props land on the same call sites. No new logic was written during the resync itself (only import-statement unions); the growth is inherent to combining the two already-reviewed feature branches. Structural shrink tracked in #3501. Tighten via --update next cycle (true floor is 2056, not 2058).", "_rebaseline_2026_07_17_v3849_ownerprs_providers": "2056->2058 (+2). v3.8.49 owner-PR merge campaign own-growth: the new provider handlers/dispatch branches merged this cycle (freetheai/felo/notion/segmind/deepinfra/novita/msdesigner image+video handlers, each adding a format-dispatch guard) pushed cyclomatic violations 2056->2058. Fast-gates PR->release do not run the complexity ratchet, so this surfaced only on re-sync. Spread across the new leaf handlers (not a single extractable function); measured on the release tip. Structural shrink tracked in #3501.", diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 3fce341799..dc40c33777 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -4,11 +4,6 @@ "count": 1 } }, - "open-sse/executors/claudeIdentity.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, "open-sse/executors/cliproxyapi.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -415,11 +410,6 @@ "count": 1 } }, - "src/shared/components/KiroSocialOAuthModal.tsx": { - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, "src/shared/components/LanguageSelector.tsx": { "@next/next/no-img-element": { "count": 1 @@ -890,11 +880,6 @@ "count": 8 } }, - "tests/unit/claude-to-openai-think-close-5123.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, "tests/unit/cli-a2a-invoke-commands.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 16 @@ -1947,7 +1932,7 @@ }, "tests/unit/proxy-registry.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 55 + "count": 54 } }, "tests/unit/proxy-resolution-status-filter.test.ts": { diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 0ff32a4ea2..5332122f14 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).", "_rebaseline_2026_07_22_8131_windowshide_cloudflared_spawn": "PR #8167 (Dingding-leo, fix/windows-hide-child-process, #8131) own growth: src/lib/cloudflaredTunnel.ts 934->935 (+1, irreducible call-site wiring — the single `windowsHide: true` option added to the existing cloudflared spawn() options object so no transient conhost.exe/cmd console window flashes open on Windows). Covered by the pre-merge-fix regression test tests/unit/windows-hide-child-process-spawns-8131.test.ts (added for the two additional spawn() sites the PR missed: ServiceSupervisor.ts, versionManager/processManager.ts) plus the windowsHide assertion added to tests/unit/services/installers/runNpm-shell-5379.test.ts (installers/utils.ts buildNpmExecOptions).", "_rebaseline_2026_07_22_8006_adobe_firefly_media_provider": "PR #8006 (artickc, feat/adobe-firefly-media) own growth: adds Adobe Firefly as a media-only (image + video) provider — unofficial IMS/cookie-session bridge for firefly.adobe.com covering IMS cookie->access_token exchange, discovery-catalog fallback, credits/balance usage, and submit+poll dispatch for both image (nano-banana/gpt-image families) and video (Sora 2/Veo 3.1/Kling 3.0) generation, with 408-under-load retry handling. New leaf open-sse/services/adobeFireflyClient.ts frozen at 1958 (>>cap 800) — a single self-contained upstream client (mirrors the qoderCli.ts precedent for a new provider client that is legitimately large on day one: IMS auth, cookie/JWT normalization, payload builders for 2 media types x multiple model families, SSE-less submit/poll state machine, error sanitization); not extractable without scattering a single upstream integration across artificial module boundaries mid-PR. open-sse/config/imageRegistry.ts (existing, previously under cap) grows 800->821 (+21, the new adobe-firefly IMAGE_PROVIDERS entry + models list, additive registry data at the existing registry chokepoint). src/lib/usage/providerLimits.ts 1000->1003 (+3, adobe-firefly/firefly added to the existing apikey-usage-fetcher allowlist, irreducible call-site wiring mirroring the sibling #7994 PromptQL/HyperAgent entries in the same PR group). Covered by tests/unit/adobe-firefly.test.ts (35/35). Structural shrink tracked in #3501.", "_rebaseline_2026_07_22_7994_hyperagent_web_provider": "PR #7994 (artickc, feat/hyperagent-web) own growth: adds HyperAgent (hyperagent.com) as a new unofficial web-cookie chat provider, reverse-engineered from live SPA captures (thread/session SSE flow, credits/usage endpoint). New leaf open-sse/executors/hyperagent.ts frozen at 937 (>cap 800) — single self-contained executor covering cookie auth, SSE parsing (text/session_start/session_end/done events), and a sticky thread/session cache for multi-turn continuity; not extractable without splitting the executor mid-request-flow (mirrors the sseParser.ts/muse-spark-web.ts precedent for new provider executors that exceed cap on day one). src/lib/usage/providerLimits.ts 1000->1003 (+3, irreducible call-site wiring adding hyperagent/ha to the existing USAGE_FETCHER_PROVIDERS-style allowlist at the chokepoint other web-cookie providers already extend). Covered by tests/unit/executor-hyperagent.test.ts (16/16). Structural shrink tracked in #3501.", @@ -175,7 +176,7 @@ "open-sse/executors/duckduckgo-web.ts": 925, "open-sse/executors/grok-web.ts": 1873, "open-sse/executors/hyperagent.ts": 937, - "open-sse/executors/muse-spark-web.ts": 1396, + "open-sse/executors/muse-spark-web.ts": 1405, "open-sse/executors/perplexity-web.ts": 1032, "open-sse/handlers/audioSpeech.ts": 1061, "open-sse/handlers/chatCore.ts": 5125, @@ -186,7 +187,8 @@ "open-sse/handlers/videoGeneration.ts": 1275, "_rebaseline_2026_07_22_8010_codex_responses_engine": "PR #8010 (@JxnLexn) own growth: open-sse/mcp-server/schemas/tools.ts 1497->1505 (+8 = threading the new \"codex-responses\" literal into the compressionConfigureInput strategy/autoTriggerMode Zod enums and setCompressionEngineInput engine enum, mirroring the existing rtk/omniglyph enum entries; no new tool). open-sse/services/compression/strategySelector.ts 1043->1054 (+11 = one new `if (mode === \"codex-responses\")` dispatch branch in runCompression that delegates 100% to the new codexResponsesEngine.apply, mirroring the existing rtk single-mode dispatch, plus threading config.codexResponsesConfig.preserveToolNames into the shared adaptBodyForCompression call at the 3 existing call sites). src/lib/db/compression.ts (untracked, new-file cap 800) 794->845 (+51 = normalizeCodexResponsesConfig, mirroring the existing normalizeRtkConfig normalizer, plus registering \"codex-responses\" in the COMPRESSION_MODES/STACKED_PIPELINE_ENGINE_IDS/SINGLE_MODE_ENGINE sets and the getCompressionSettings load/save switch) — added to the baseline at its current size. All three are cohesive dispatch/normalizer wiring at existing chokepoints (mirroring the prior compression-mode rebaselines #6534/#6556), not extractable without hiding the mode-dispatch boundary. Covered by tests/unit/compression/codex-responses.test.ts (6) + omniglyph-registries.test.ts/types.test.ts (22, updated for the new mode).", "_rebaseline_2026_07_22_8034_compression_exclusions_persistence": "#8034 (compression exclusions) own growth: src/lib/db/compression.ts 845->850 (+5 = threading the new compressionExclusions field through the existing getCompressionSettings/saveCompressionSettings load/save switch over the shared key_value compression namespace — no new table, no raw SQL). Mirrors the prior compression-field rebaselines (#8010 codex-responses normalizer at the same chokepoint); the load/save switch is a single dispatch boundary, not extractable without hiding it. Covered by the PR's 8 node:test + 3 vitest cases.", - "src/lib/db/compression.ts": 866, + "_rebaseline_2026_07_24_8388_compression_detail_persist": "#8388 (compression engine DETAIL settings — Headroom/session-dedup/CCR — dropped on save) own growth: src/lib/db/compression.ts 866->872 (+6 = irreducible call-site wiring at the existing getCompressionSettings/updateCompressionSettings chokepoint: one import line, one `...buildDetailConfigDefaults()` spread in the seed config, and one `case \"sessionDedup\": case \"ccr\": applyDetailConfigUpdate(config, key, parsed); break;` load-switch case, mirroring the existing headroom/#8056 case immediately above it). The actual normalizer logic (normalizeSessionDedupConfig/normalizeCcrConfig, matching SESSION_DEDUP_SCHEMA/CCR_SCHEMA bounds) was EXTRACTED into a new leaf src/lib/db/compressionDetailNormalizers.ts (well under cap) so this frozen file only carries the minimal dispatch wiring. Covered by tests/unit/8388-compression-detail-persist.test.ts (schema-accept + full DB save->reload round-trip for both new sub-objects, plus a no-regression assertion on the existing headroom round-trip).", + "src/lib/db/compression.ts": 872, "open-sse/mcp-server/schemas/tools.ts": 1505, "open-sse/mcp-server/server.ts": 1555, "open-sse/mcp-server/tools/advancedTools.ts": 1120, @@ -194,8 +196,9 @@ "_rebaseline_2026_07_22_8213_gemini_tpm_quota_cooldown_wait": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: open-sse/services/accountFallback.ts 1857->1932 on the merged tip (release 1892 incl #8050 +35, plus this PR own growth +40); measured against the PR own merge-base was 1857->1898 (+41 — the release tip separately carries an unrelated +34 from #8050's antigravity 404 model-not-found lockout scoping, which this PR's branch does not include and this entry does not cover). Own growth is the Gemini TPM-ceiling classification + cooldown-wait wiring feeding into the combo cooldown-wait state machine (rate-limit wedge recovery) introduced by this PR's commit series. Irreducible additions at the existing account-fallback/model-lockout chokepoint. Covered by the PR's own gemini-rate-limit-tracker and TPM-ceiling benchmark test additions.", "_rebaseline_2026_07_23_8252_combo_400_advance": "#8252 (@RaviTharuma) own growth: accountFallback.ts 1932->1940 (+8) + combo.ts 3604->3630 (+26) — advance combo on model-scoped 400s wrapped as invalid/Bad-Request. Irreducible wiring at existing account-fallback + combo dispatch chokepoints. Covered by combo-model-scoped-400-advance.test.ts.", "_rebaseline_2026_07_23_8247_8248_model_unhealthy": "#8247+#8248 own growth: accountFallback.ts 1940->1941 (+1, irreducible import statement only — the substantive #8248 DEGRADED-pattern classifier was extracted into open-sse/config/errorConfig.ts, which has ample headroom, instead of growing this frozen file; #8247's fix is a single existing-line condition change, net zero lines). Scoping the credits-exhausted 403/429 branch to isCompatibleProvider() (per-model-quota openai/anthropic-compatible-* nicknames) so it stays model-scoped instead of terminalling the whole connection, and classifying NVIDIA NIM 'Function ... DEGRADED' 400 bodies as model-access-denied instead of a raw passthrough 400. Covered by tests/unit/8247-accountfallback-model-unhealthy.test.ts and tests/unit/8248-accountfallback-nvidia-degraded.test.ts.", - "open-sse/services/accountFallback.ts": 1941, - "open-sse/services/adobeFireflyClient.ts": 1958, + "open-sse/services/accountFallback.ts": 1966, + "open-sse/services/adobeFireflyClient.ts": 2317, + "_rebaseline_2026_07_25_adobe_firefly_reference_images": "Follow-up to #8006: storage upload + referenceBlobs for image/video and /v1/images/edits dispatch. adobeFireflyClient.ts 1958->2317 (+upload helpers, extract sources, resolve blob ids). Note: 2317 not 2316 — check-file-size.mjs counts LOC via split(\"\\n\").length (counts the trailing-newline empty element), which is 1 higher than `wc -l` on a file ending in \\n; the PR's original entry (2316) was measured with wc -l and undercounted by 1 against the actual gate.", "open-sse/services/batchProcessor.ts": 915, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, @@ -204,7 +207,8 @@ "_rebaseline_2026_06_24_quota_share_strategy": "Dedicated quota-share strategy (Phase 3 #9): combo.ts 3180->3190 (+10 = one new `else if (strategy === \"quota-share\")` dispatch branch in handleComboChat that delegates 100% to selectQuotaShareTarget + its log line, plus the import). All the new logic lives OUT of the god-file in two new leaves under open-sse/services/combo/: quotaShareInflight.ts (in-flight counter with TTL/lease, ~150 LOC 3225 (+35) = one new `else if (strategy === \"task-aware\")` dispatch branch delegating 100% to selectTaskAwareTarget + its imports/log lines. All scoring/classification logic lives OUT of the god-file in the new leaf open-sse/services/taskAwareRouting.ts (553 LOC 3604 (+56, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Fixes combo cooldown-wait state recording so a bogus 503 is no longer crystallized when the cooldown-wait vars reset every setTry, adds an OpenAI-format SSE error frame path for combo-exhausted rejections (capturing request body + attempted models), and gives an abandoned per-target dispatch its own timeout instead of leaking a permanent 'pending' dashboard entry. Irreducible additions at the existing handleComboChat dispatch/retry chokepoint (mirrors the prior quota-share/headroom/task-aware strategy-branch precedents already frozen in this file). Covered by the PR's own combo-config + Gemini TPM-ceiling benchmark test additions.", - "open-sse/services/combo.ts": 3630, + "_rebaseline_2026_07_25_8476_combo_input_bound_homogeneous_scope": "PR #8476 (herjarsa, fix/8375-8459-combo-image-fixes, #8375) own growth: open-sse/services/combo.ts 3642->3679 (+37 net: +29 the PR's own isInputBoundFailure short-circuit for deterministic context_length_exceeded/context_window_exceeded failures, +8 a /green-prs pre-merge fix scoping that short-circuit to homogeneous remainders only — the shipped code fired unconditionally on ANY target, regressing the intentional heterogeneous-combo fallback #6637/isContextOverflow400 protects, exactly as flagged by this PR's own review evidence but never actually implemented in the branch). The fix compares orderedTargets[i+1..] modelStr against the failing target's modelStr at the existing executeTarget dispatch chokepoint (mirrors the sameProviderNext precedent a few lines below) — irreducible call-site wiring, not extractable without hiding the dispatch boundary. Covered by tests/unit/combo-input-bound-failure-8375.test.ts (homogeneous pool still short-circuits) and the new tests/unit/combo-input-bound-heterogeneous-8375.test.ts (heterogeneous combo now correctly falls through to the larger-context target).", + "open-sse/services/combo.ts": 3679, "_rebaseline_2026_06_26_fidelity_gate_extraction": "Milestone-B fidelity-gate wiring residual: bodyToText+gateAdvance extracted to fidelityGateStep.ts (889->854, -35), but the StackOptions.fidelityGate field, the `const fidelityGate` reads at the two stacked-loop dispatch chokepoints, and the import of FidelityGateConfig are irreducible wiring that cannot leave strategySelector without an architectural refactor of the pre-existing stacked pipeline. Net: 889->854 (+6 vs the pre-Milestone-B frozen 848). Covered by tests/unit/compression/*.test.ts (940 pass).", "_rebaseline_2026_06_28_5243_risk_gate_prepass": "PR #5243 (compression risk-gate pre-pass) own growth: open-sse/services/compression/strategySelector.ts 854->899 (+45). The three exported entry points (applyCompression/applyStackedCompression/applyStackedCompressionAsync) become thin wrappers over pure-extracted private bodies (runCompression/runStackedCompression/runStackedCompressionAsync) so the risk-gate mask->run->restore wrapper sits strictly OUTSIDE the per-step loop — a single universal integration point. The wrapper logic itself (resolveRiskGate/withRiskGate) lives in the new riskGate/strategyWrap.ts (960 (+61 = the opt-in result-memoization branches in applyCompression/applyCompressionAsync — principal+determinism gate, makeMemoKey lookup/store with model+supportsVision folded into the key, recompute-with-memo-off). Default off (memoizeCompressionResults), so zero behavior change. The memo helpers live in the leaf resultMemo.ts (1125 (+33) and open-sse/utils/stream.ts 2814->2869 (+55) — threading the namespace-identity seam through the Responses↔Chat translation + stream paths so tool-call namespaces survive the round-trip. Cohesive translation/stream wiring at existing chokepoints, frozen at new size.", "_rebaseline_2026_07_22_8210_openrouter_midstream_error": "PR #8210 (hartmark, fix/openrouter-midstream-error-surfacing) own growth: open-sse/translator/response/openai-responses.ts 1137->1163 (+26) measured on the merged tip (release 1137 + this PR own growth). Adds a single new branch inside openaiToOpenAIResponsesResponse() that detects an OpenRouter-style mid-stream aggregator error (HTTP 200 SSE chunk with empty choices + a top-level error object) and surfaces it as state.upstreamError instead of silently falling through to the no-op/awaitingTrailingUsage path, which previously masked the failure as a false empty-success completion and skipped combo fallback. Irreducible call-site addition at the existing chunk-dispatch chokepoint (mirrors the Gemini-to-OpenAI translator's #4177 precedent for the same class of upstream error surfacing). Note: this baseline entry does NOT cover the separate pre-existing +11 drift already on the release tip from #8081/#8162 (1125->1136, unrelated reasoning-placeholder-stripping fix merged after this PR branched) — that drift belongs to the maintainer's rebaseline, not this PR.", - "open-sse/translator/response/openai-responses.ts": 1163, + "_rebaseline_2026_07_24_responses_toolcalls_log_summary": "hartmark, fix/responses-tool-calls-log-summary own growth: open-sse/translator/response/openai-responses.ts 1163->1174 (+11). closeToolCall() now also writes the completed tool call into the shared state.toolCalls Map (already populated by the openai-to-claude / claude-to-openai / gemini-to-openai response translators) so stream.ts's completion-log summary builder (which reads state.toolCalls, not this translator's own funcCallIds/funcNames/funcArgsBuf bookkeeping) reports finish_reason \"tool_calls\" and message.tool_calls for openai->openai-responses translated streams instead of always logging \"stop\" with no tool_calls — the actual client-facing SSE events were already correct; only the persisted call-log summary was wrong. Irreducible call-site addition at the existing tool-call-close chokepoint. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts.", + "open-sse/translator/response/openai-responses.ts": 1174, "open-sse/utils/cursorAgentProtobuf.ts": 1521, "_rebaseline_2026_07_23_8143_empty_catch_logging": "#8143 (@chirag127) own growth: open-sse/utils/stream.ts 2869->2887 (+18) — replacing empty catch blocks in the SSE stream subsystem with console.debug logging (Rule #6 silent-swallow fix, issues #8138-#8142). Cohesive logging additions at the existing catch chokepoints, not extractable; frozen at new size. Covered by tests/unit/stream-handler-catch-logging-8143.test.ts.", "open-sse/utils/stream.ts": 2887, @@ -251,7 +256,7 @@ "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264, "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1054, "src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 948, - "src/app/(dashboard)/dashboard/providers/page.tsx": 1927, + "src/app/(dashboard)/dashboard/providers/page.tsx": 1990, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201, "src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": 819, "src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx": 903, @@ -309,10 +314,10 @@ "_rebaseline_2026_07_09_6678_routing_strategy_9router": "#6678 (SeaXen) — 9router-parity Routing Strategy settings card + per-provider/combo sticky-round-robin override. Own growth: ProviderDetailPageClient.tsx 784->786 (single ProviderAccountRoutingCard mount + import), auth.ts 2448->2458 (providerStrategies override resolution: fallbackStrategy/stickyRoundRobinLimit per-provider cascade in getProviderCredentials). Both additive, zero unrelated refactor; new UI/logic lives in new files (ProviderAccountRoutingCard.tsx, RoutingStrategyCard.tsx, rrState.ts::resolveComboStickyRoundRobinLimit). chat.ts value below reflects the current release tip (grown by other concurrent PRs, e.g. #6640), not this PR own change.", "_rebaseline_2026_07_22_8213_chat_abandoned_target_abort": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/sse/handlers/chat.ts 1794->1860 (+66, measured against the PR's own merge-base — the release tip separately carries an unrelated -5 net shrink from #8013's antigravity callable-catalog alignment, which this PR's branch does not include and this entry does not cover). Adds resolveDispatchClientRawRequest(): merges a per-target modelAbortSignal into clientRawRequest.signal (via mergeAbortSignals) so a combo target abandoned by comboTargetTimeoutMs actually observes its own abort and reaches its cleanup path, instead of hanging forever inside withRateLimit/acquireAccountSemaphore and leaking a permanent 'pending' dashboard entry (live incident, log id 1784418258231-14961a). Also wires combo-exhausted rejection logging to capture request body + attempted models via the new rejectedRequestUsage helper. Irreducible additions at the existing chat dispatch chokepoint. Covered by the PR's own combo-config + integration test additions.", "_rebaseline_2026_07_23_8127_grok_weekly_quota": "#8127 (@apoapostolov) own growth: src/sse/handlers/chat.ts 1861->1865 (+4) — weekly quota tracking for grok-web wires a quota-fetch hook at the existing dispatch chokepoint. Thin wiring mirroring adjacent provider-quota branches; not extractable. Covered by tests/unit/grok-quota-fetcher.test.ts.", - "src/sse/handlers/chat.ts": 1865, + "src/sse/handlers/chat.ts": 1866, "_rebaseline_2026_07_20_7779_routingcombo_thread": "PR #7779 own growth: chatHelpers.ts 876->877 (+1, thread routingComboId into executeChatWithBreaker for compression-combo assignment). Frozen so it can only shrink.", "src/sse/handlers/chatHelpers.ts": 878, - "src/sse/services/auth.ts": 2475, + "src/sse/services/auth.ts": 2486, "open-sse/executors/default.ts": 890, "open-sse/translator/request/openai-responses.ts": 902, "open-sse/executors/kiro.ts": 944, @@ -322,7 +327,7 @@ "open-sse/executors/huggingchat.ts": 813, "_rebaseline_2026_07_01_v3843_release_5609": "Rebaseline v3.8.43 (PR #5609 release reconciliation). DRIFT dos 109 commits do ciclo: 8 god-files existentes cresceram (ApiManagerPageClient 2983->3017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui.", "src/lib/providers/validation/webProvidersA.ts": 809, - "src/lib/tokenHealthCheck.ts": 832, + "src/lib/tokenHealthCheck.ts": 843, "_rebaseline_2026_07_09_6587_kiro_api_key_auth": "PR #6587 (@strangersp) own growth for Kiro long-lived API-key auth, merged onto v3.8.47 tip: openai-to-kiro.ts 890->912 (+22, auth-header selection for API-key-vs-OAuth-token connections), providerLimits.ts 998->1000 (+2, API-key auth-type branch), translator-openai-to-kiro.test.ts 1234->1257 (+23), providers-page-utils.test.ts 1109->1107 (net -2 after merging with parallel release drift; connectionMatchesProviderCard api_key coverage added), provider-validation-specialty.test.ts 2856->2980 (+124 net after merge with parallel release drift; this PR also removed the file's `@typescript-eslint/no-explicit-any` eslint-suppression entry by fixing all `any` usages, adding typed replacements). Cohesive additive feature growth, well tested; not extractable without splitting the existing chokepoints mid-merge.", "_rebaseline_2026_07_19_7787_ic2_localdb_reexports": "PR #7787 (IC2 raw connections cache + lazy-decrypt) own growth: localDb.ts 805->807 (gate units, +2). localDb.ts is the re-export-only layer (hard rule #2 — no logic); the PR adds 4 new db/readCache re-exports (touchConnectionLastUsed, getCachedRawProviderConnections, getCachedProviderConnectionById, getCachedProviderNodes) required by existing barrel importers. Irreducible for a re-export list; frozen so it can only shrink.", "_rebaseline_2026_07_20_7819_autocandidateoverrides_reexport": "PR for #7819 (Level 1+2: read-only auto/* candidate transparency + per-API-key exclusions) own growth: localDb.ts 807->808 (+1). Adds a single `export * from \"./db/autoCandidateOverrides\"` barrel re-export (hard rule #2 — no logic) for the new DB module backing per-apiKey candidate exclusions. Irreducible for a re-export list; frozen so it can only shrink.", @@ -339,6 +344,8 @@ }, "testCap": 800, "testFrozen": { + "_rebaseline_2026_07_25_8510_adobe_firefly_reference_images_tests": "#8510 (artickc, feat/adobe-firefly-reference-images) own test growth: tests/unit/adobe-firefly.test.ts 711->871 (+159, entirely this PR's diff — new referenceBlobs upload/dispatch coverage for handleAdobeFireflyImageGeneration, resolveAdobeSourceImageIds, and the storage-upload wire contract). Route-level /v1/images/edits coverage (credentials/rate-limit/4-ref-cap branches added to route.ts) lives in the new tests/unit/8510-adobe-firefly-edits-route.test.ts instead of growing this file further.", + "tests/unit/adobe-firefly.test.ts": 871, "_rebaseline_2026_07_09_6126_clinepass_dualauth": "#6126 (ClinePass dual-auth) own test growth: oauth-providers-config.test.ts 842->845 (+3: clinepass key/config/required-fields entries reusing the Cline WorkOS flow config, needed after registering clinepass in the oauth.ts PROVIDERS enum).", "_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).", "_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.", @@ -467,5 +474,7 @@ "_rebaseline_2026_07_22_v3849_ownGrowth_merge_batch": "OAuthModal(#7735 grok chooser), muse-spark-web(#7528 WS), combo.ts+combo-routing-engine.test(#7301 cooldown-retry) — pre-existing on tip; PricingTab(#7972), ComboDefaultsTab(#8008/#7973) — this train batch. Legitimate own-growth, owner-approved rebaseline.", "_rebaseline_2026_07_23_v3849_merge_train_15": "Own-growth do merge-train de 15 PRs (2026-07-23), medido na tip combinada, release pura abaixo do baseline (auth.ts 2448, muse-spark 1393, translator-test 1523). auth.ts 2462->2475 (#8321 cookie-auth 401 cooldown-em-vez-de-terminal + #8324 noauth opencode-zen via proxy — wiring de classificação no chokepoint getProviderCredentials/markAccountUnavailable, não extraível), muse-spark-web.ts 1394->1396 (#8298 sanitizeErrorMessage runtime repairs isolados do #8177), tests/unit/translator-openai-to-gemini.test.ts 1553->1616 (#8312 cobertura do cap de thinking budget no path budget_tokens explícito). Owner-approved. Frozen; shrink estrutural em #3501.", "_rebaseline_2026_07_22_providerLimits_webcookie_chain": "providerLimits.ts 1003->1005: own-growth from web-cookie provider usage-fetcher entries (#7994/#8006/#8027 chain) landing after the prior rebaseline.", - "_rebaseline_2026_07_22_8056_headroom_minrows": "#8056 (@RaviTharuma, persist Headroom minRows) own growth: src/lib/db/compression.ts 850->866 (+16 HeadroomConfig+DEFAULT_HEADROOM_CONFIG+normalize/store in get/updateCompressionSettings) and open-sse/services/compression/strategySelector.ts 1054->1060 (+6 merge settings.headroom into stacked stepConfig). Cohesive settings-persistence + stacked-merge wiring at existing chokepoints, frozen at new size." + "_rebaseline_2026_07_22_8056_headroom_minrows": "#8056 (@RaviTharuma, persist Headroom minRows) own growth: src/lib/db/compression.ts 850->866 (+16 HeadroomConfig+DEFAULT_HEADROOM_CONFIG+normalize/store in get/updateCompressionSettings) and open-sse/services/compression/strategySelector.ts 1054->1060 (+6 merge settings.headroom into stacked stepConfig). Cohesive settings-persistence + stacked-merge wiring at existing chokepoints, frozen at new size.", + "_rebaseline_2026_07_25_v3849_basered_filesize": "Base-red unblock (2026-07-25): check:file-size was failing on release/v3.8.49 at its own HEAD (36f8fd10), so the quality.yml fast-gates job was red for EVERY PR->release regardless of content — growth inherited from already-merged PRs, with no offending PR branch left to fix (same situation as _rebaseline_2026_07_02_5798_release_green). Prod frozen raised to the current base values: src/lib/tokenHealthCheck.ts 832->841, src/sse/handlers/chat.ts 1865->1866, src/sse/services/auth.ts 2475->2486, open-sse/services/accountFallback.ts 1941->1966, open-sse/services/combo.ts 3630->3642. accountFallback.ts was first frozen here at 1960 (the base value at 36f8fd10) and re-measured to 1966 at base tip 1cafd328c a few hours later — the same inherited drift this entry exists for, since check:file-size does not run on the PR->release fast path and so accrues unmeasured between release rebaselines. These files remain frozen and cannot grow further; any in-flight PR that adds lines to them (e.g. #8482 touches accountFallback.ts and combo.ts) bumps its own entry as usual. The release captain rebaseline-at-release supersedes this note.", + "_rebaseline_2026_07_25_v3849_basered_filesize_2": "Base-red unblock (2026-07-25, second pass): after _rebaseline_2026_07_25_v3849_basered_filesize (measured at 36f8fd10) two more already-merged PRs grew frozen files on release/v3.8.49, so check:file-size — and with it the whole Fast Quality Gates job — is red for EVERY PR->release again, with no offending PR branch left to fix. src/lib/tokenHealthCheck.ts 841->843 (#8426 4528fc455, excludes local CLI providers from expiration) and src/app/(dashboard)/dashboard/providers/page.tsx 1927->1990 (#8349 58ab8b1d2, scroll-position restore on provider-card back-navigation). Trust-but-verify: both values measured on the pristine release tip 30709255 with no working-tree changes. Same situation and remedy as _rebaseline_2026_07_02_5798_release_green. Structural reduction of providers/page.tsx stays tracked separately — it is a 1990-line page, not something to extract inside a base-repair PR." } diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index a22e3120f9..1c11f5f161 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -123,7 +123,9 @@ "_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle." }, "cognitiveComplexity": { - "value": 951, + "value": 968, + "_rebaseline_2026_07_25b_v3849_mergetrain_owngrowth": "Owner-approved (chat, 2026-07-25): 956->968 (+12). v3.8.49 /merge-prs 41-PR merge-train aggregate own-growth: measured 968 on the combined boarded tree (tip ac15014ca7) vs 956 on the pristine release tip. The batch's new over-threshold functions come from the pre-screen-flagged complexity-growth set (#8378/#8432/#8476/#8526 etc); each PR is under-ceiling alone, the combined batch adds +12. Same merge-burst class as the notes below; owner chose ceiling-absorb over per-PR extraction. Structural shrink tracked in #3501; tighten via --update next cycle.", + "_rebaseline_2026_07_25_v3849_mergequeue_drain": "Owner-approved (chat, 2026-07-25): 951->956 (+5). v3.8.49 /merge-prs queue-drain: inherited cognitive-complexity drift from the cycle's merge burst (base-red slices + owner PRs + parallel-session merges #8500-8508); check:cognitive-complexity does not run on PR->release fast-gates, so it accrued unmeasured. Measured 956 on the pristine release tip 4053e2314a alone (BEFORE any queue PR boards) — the entire +5 is base drift already on the tip, reddening Fast Quality Gates for every merge-ready PR. Owner approved raising the ceiling to the measured tip value so the ~34-PR merge-train lands without per-PR extraction churn. Structural shrink tracked in #3501; tighten via --update next cycle.", "_rebaseline_2026_07_10_gcf_v3_2": "885->888 (+3). PR feat/headroom-gcf-v3.2-nested-flattening: own growth from re-vendoring the GCF (Headroom) codec to spec v3.2 (nested flattening). The new over-threshold functions are the vendored v3.2 flatten/unflatten walk in open-sse/services/compression/engines/headroom/gcf/{generic,decode_generic}.ts. Imported third-party code kept byte-faithful to upstream gcf-typescript; measured 888 with the update vs 885 on the pristine origin/release/v3.8.47 tip. Guarded by tests/unit/compression/headroom-smartcrusher.test.ts (deep-nested case). Structural shrink belongs upstream in gcf.", "_rebaseline_2026_07_12_v3847_mergetrain_burst": "885->890 (+5). v3.8.47 /merge-prs merge-train batch (23 merge-ready PRs) inherited drift: cognitive-complexity does NOT run on PR->release fast-gates, so incidental growth accrued unmeasured across the batch. Measured 890 on the combined merge-train tip (5d980352d) vs 885 on the pristine release tip 1b7a9150e. #6838 (headroom gcf codec re-vendor) accounts for +3 (its own baseline bump to 888, superseded here by this later 890 rebaseline which already covers it); the remaining +2 is parallel-batch drift across the other 22 PRs. Owner-approved rebaseline (merge-burst reconciliation, same class as the v3.8.46/v3.8.44 notes below). Tighten via --update next cycle.", "_rebaseline_2026_07_09_6587_kiro_api_key_auth": "884->885 (+1). PR #6587 (@strangersp) own growth: open-sse/services/usage/kiro.ts gains ONE new over-threshold function — getKiroUsage grew from a single fetch to a 3-endpoint fallback chain (codewhisperer-get / codewhisperer-post / q-get) with per-attempt auth-header selection (tokentype: API_KEY vs Bearer-only), needed so usage/quota lookups work for the new long-lived-API-key auth path in addition to the existing OAuth path (measured: 0 violations on release tip -> 1 violation, complexity 33, at open-sse/services/usage/kiro.ts). Covered by tests/unit/kiro-iam-profilearn-usage.test.ts (tokentype header selection, friendly auth-expired/rejected-token messages). Cohesive multi-endpoint-fallback logic at an existing usage chokepoint; not extractable without splitting the fallback loop mid-merge. Structural shrink tracked in #3501.", diff --git a/docs/architecture/RESILIENCE_GUIDE.md b/docs/architecture/RESILIENCE_GUIDE.md index d1553c50d4..5c04f29723 100644 --- a/docs/architecture/RESILIENCE_GUIDE.md +++ b/docs/architecture/RESILIENCE_GUIDE.md @@ -209,14 +209,13 @@ on). Without a `max_concurrent` cap the behavior is unchanged. ### Combo cooldown-aware retry -For quota-share and `auto` combos, a request that would crystallize a 429 for a -SHORT transient cooldown waits it out and re-dispatches instead of returning -the 429 — this covers Gemini-class TPM/RPM windows (~60s retry-after) on a -multi-model `auto` combo, e.g. both targets of a 2-model combo hitting a -per-model rate limit. Bounded by `comboCooldownWait` (`enabled`, `maxWaitMs` -65s, `maxAttempts` 2, `budgetMs` 130s, hard ceiling 90s) in **Settings → -Resilience**. It never waits on `quota_exhausted` (locked until midnight) or -auth/not-found reasons. +For every combo strategy (when enabled), a request that would crystallize a 429 +for a SHORT transient cooldown waits it out and re-dispatches instead of +returning the 429 — this covers Gemini-class TPM/RPM windows (~60s retry-after) +on multi-model combos, e.g. both targets of a 2-model combo hitting a per-model +rate limit. Bounded by `comboCooldownWait` (`enabled`, `maxWaitMs`, `maxAttempts`, +`budgetMs`) in **Settings → Resilience**. It never waits on `quota_exhausted` +(locked until midnight) or auth/not-found reasons. --- diff --git a/docs/guides/CLAUDE-CODE-CONFIGURATION.md b/docs/guides/CLAUDE-CODE-CONFIGURATION.md index a34ee464fd..e533d17ed5 100644 --- a/docs/guides/CLAUDE-CODE-CONFIGURATION.md +++ b/docs/guides/CLAUDE-CODE-CONFIGURATION.md @@ -1,7 +1,7 @@ --- title: "Claude Code CLI — Configuration with OmniRoute" version: 3.8.40 -lastUpdated: 2026-06-28 +lastUpdated: 2026-07-24 --- # Claude Code CLI — Configuration with OmniRoute @@ -140,10 +140,20 @@ extra flags needed. Override per-invocation with `--remote` / `--api-key`. handles this for you. **`/model` picker is empty / missing gateway models** — needs Claude Code -v2.1.129+ and `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`. Only `claude*` / +v2.1.219+ and `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`. Only `claude*` / `anthropic*` model IDs appear in the picker; force any other model with `ANTHROPIC_MODEL=` (this is what profiles do). +**`400 Ambiguous model 'claude-…'`** — Claude Code always sends **unprefixed** +model IDs (e.g. `claude-opus-4-8`), so when both the Claude Code (`cc/…`) and +Claude (`claude/…`) providers are connected the bare id matches two routes and +OmniRoute refuses to guess. Fix it either way: pin a prefixed id with +`ANTHROPIC_MODEL=cc/claude-opus-4-8`, or enable **Prefer Claude Code for +unprefixed Claude models** — the toggle on the Claude provider page, or +`OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS=true` (default off; +see [Environment](../reference/ENVIRONMENT.md)) — which routes bare `claude-*` +IDs to Claude Code instead. Explicit provider prefixes always win. + **Auth errors** — the profile holds no token. Use `omniroute launch --profile` (injects it) or export `ANTHROPIC_AUTH_TOKEN`. diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 431a31c7ac..ff82c1a6b6 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -572,7 +572,7 @@ For the full environment variable reference, see the [README](../README.md). **GitHub Copilot (`gh/`)** — OAuth: `gh/gpt-5.5`, `gh/gpt-5.4`, `gh/gpt-5.4-mini`, `gh/gpt-5-mini`, `gh/gpt-5.3-codex`, `gh/claude-opus-4.7`, `gh/claude-opus-4.6`, `gh/claude-opus-4-5-20251101`, `gh/claude-sonnet-4.6`, `gh/claude-sonnet-4.5`, `gh/claude-haiku-4.5`, `gh/gemini-3.1-pro-preview`, `gh/gemini-3-flash-preview`, `gh/oswe-vscode-prime` -**Kiro (`kr/`)** — FREE OAuth: `kr/auto-kiro`, `kr/claude-opus-4.7`, `kr/claude-opus-4.6`, `kr/claude-sonnet-4.6`, `kr/claude-sonnet-4.5`, `kr/claude-haiku-4.5`, `kr/deepseek-3.2`, `kr/minimax-m2.5`, `kr/minimax-m2.1`, `kr/glm-5`, `kr/qwen3-coder-next` +**Kiro (`kr/`)** — FREE OAuth: use the live catalog shown under **Dashboard → Providers → Kiro → Available Models**. Availability depends on the account and plan. **Qoder (`if/`)** — FREE OAuth: `if/qwen3.8-max-preview`, `if/qwen3.7-max`, `if/qwen3.7-plus`, `if/kimi-k3`, `if/kimi-k2.7-code`, `if/glm-5.2`, `if/deepseek-v4-pro`, `if/deepseek-v4-flash`, `if/minimax-m3` diff --git a/docs/i18n/ar/docs/reference/ENVIRONMENT.md b/docs/i18n/ar/docs/reference/ENVIRONMENT.md index 617d102679..fbda74516d 100644 --- a/docs/i18n/ar/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ar/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/az/docs/reference/ENVIRONMENT.md b/docs/i18n/az/docs/reference/ENVIRONMENT.md index 40ff0ddc54..e3adbd5aa7 100644 --- a/docs/i18n/az/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/az/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/bg/docs/reference/ENVIRONMENT.md b/docs/i18n/bg/docs/reference/ENVIRONMENT.md index bbaf0c7a70..23d09f406f 100644 --- a/docs/i18n/bg/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/bg/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/bn/docs/reference/ENVIRONMENT.md b/docs/i18n/bn/docs/reference/ENVIRONMENT.md index 01947bb475..930f0c6db1 100644 --- a/docs/i18n/bn/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/bn/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/cs/docs/reference/ENVIRONMENT.md b/docs/i18n/cs/docs/reference/ENVIRONMENT.md index df0b88c3d1..f9e203345e 100644 --- a/docs/i18n/cs/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/cs/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/da/docs/reference/ENVIRONMENT.md b/docs/i18n/da/docs/reference/ENVIRONMENT.md index baba753dbd..6c48888ad6 100644 --- a/docs/i18n/da/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/da/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/de/docs/reference/ENVIRONMENT.md b/docs/i18n/de/docs/reference/ENVIRONMENT.md index 2efa0f6f61..61d54d53db 100644 --- a/docs/i18n/de/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/de/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/es/docs/reference/ENVIRONMENT.md b/docs/i18n/es/docs/reference/ENVIRONMENT.md index 62535ca0c7..50bbb41e3e 100644 --- a/docs/i18n/es/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/es/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/fa/docs/reference/ENVIRONMENT.md b/docs/i18n/fa/docs/reference/ENVIRONMENT.md index f2d97d3e0d..2906e5e48e 100644 --- a/docs/i18n/fa/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/fa/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/fi/docs/reference/ENVIRONMENT.md b/docs/i18n/fi/docs/reference/ENVIRONMENT.md index ee75d7d0db..a5c85d6efc 100644 --- a/docs/i18n/fi/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/fi/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/fr/docs/reference/ENVIRONMENT.md b/docs/i18n/fr/docs/reference/ENVIRONMENT.md index 868b8e3ce7..74b3eacb4b 100644 --- a/docs/i18n/fr/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/fr/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/gu/docs/reference/ENVIRONMENT.md b/docs/i18n/gu/docs/reference/ENVIRONMENT.md index 77e5145688..7596561b90 100644 --- a/docs/i18n/gu/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/gu/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/he/docs/reference/ENVIRONMENT.md b/docs/i18n/he/docs/reference/ENVIRONMENT.md index 2fa4e30d62..8529aa9fff 100644 --- a/docs/i18n/he/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/he/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/hi/docs/reference/ENVIRONMENT.md b/docs/i18n/hi/docs/reference/ENVIRONMENT.md index b2c5e08725..57c46cf699 100644 --- a/docs/i18n/hi/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/hi/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/hu/docs/reference/ENVIRONMENT.md b/docs/i18n/hu/docs/reference/ENVIRONMENT.md index c45ff83c21..b8df6d2784 100644 --- a/docs/i18n/hu/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/hu/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/id/docs/reference/ENVIRONMENT.md b/docs/i18n/id/docs/reference/ENVIRONMENT.md index 52daf2c475..682d53ad80 100644 --- a/docs/i18n/id/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/id/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Nilai Default | Kapan Diperbarui | | ------------------------ | --------------------------------------------- | ----------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | Saat Anthropic merilis versi CLI baru | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | Saat Anthropic merilis versi CLI baru | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | Saat OpenAI memperbarui CLI Codex | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override versi klien Codex secara independen dari string UA penuh | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | Saat GitHub Copilot Chat diperbarui | diff --git a/docs/i18n/in/docs/reference/ENVIRONMENT.md b/docs/i18n/in/docs/reference/ENVIRONMENT.md index 11dad0d63e..2a54e2ca21 100644 --- a/docs/i18n/in/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/in/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/it/docs/reference/ENVIRONMENT.md b/docs/i18n/it/docs/reference/ENVIRONMENT.md index d58ea6cfcc..d40abc01ce 100644 --- a/docs/i18n/it/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/it/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/ja/docs/reference/ENVIRONMENT.md b/docs/i18n/ja/docs/reference/ENVIRONMENT.md index 1a904abf8d..669352ec37 100644 --- a/docs/i18n/ja/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ja/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/ko/docs/reference/ENVIRONMENT.md b/docs/i18n/ko/docs/reference/ENVIRONMENT.md index b4aab96773..d0f3ca78dd 100644 --- a/docs/i18n/ko/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ko/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/mr/docs/reference/ENVIRONMENT.md b/docs/i18n/mr/docs/reference/ENVIRONMENT.md index 36109f0399..73b6495c56 100644 --- a/docs/i18n/mr/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/mr/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/ms/docs/reference/ENVIRONMENT.md b/docs/i18n/ms/docs/reference/ENVIRONMENT.md index 4d1543edc9..862eeb13f5 100644 --- a/docs/i18n/ms/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ms/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/nl/docs/reference/ENVIRONMENT.md b/docs/i18n/nl/docs/reference/ENVIRONMENT.md index 0140d6b283..2e8c3d8e1c 100644 --- a/docs/i18n/nl/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/nl/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/no/docs/reference/ENVIRONMENT.md b/docs/i18n/no/docs/reference/ENVIRONMENT.md index 7603a3372b..435dace726 100644 --- a/docs/i18n/no/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/no/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/phi/docs/reference/ENVIRONMENT.md b/docs/i18n/phi/docs/reference/ENVIRONMENT.md index 674f82048e..cc4160541c 100644 --- a/docs/i18n/phi/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/phi/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/pl/docs/reference/ENVIRONMENT.md b/docs/i18n/pl/docs/reference/ENVIRONMENT.md index 3b9b5b900b..a9bccccf27 100644 --- a/docs/i18n/pl/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/pl/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/pt-BR/docs/reference/ENVIRONMENT.md b/docs/i18n/pt-BR/docs/reference/ENVIRONMENT.md index 4ab50782d2..c6745bf31f 100644 --- a/docs/i18n/pt-BR/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/pt-BR/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/pt/docs/reference/ENVIRONMENT.md b/docs/i18n/pt/docs/reference/ENVIRONMENT.md index 7fd851bab4..f946d7c8c4 100644 --- a/docs/i18n/pt/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/pt/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/ro/docs/reference/ENVIRONMENT.md b/docs/i18n/ro/docs/reference/ENVIRONMENT.md index e1dde71b89..419fa46aaa 100644 --- a/docs/i18n/ro/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ro/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/ru/docs/reference/ENVIRONMENT.md b/docs/i18n/ru/docs/reference/ENVIRONMENT.md index b6a01ae258..95f508460a 100644 --- a/docs/i18n/ru/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ru/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/sk/docs/reference/ENVIRONMENT.md b/docs/i18n/sk/docs/reference/ENVIRONMENT.md index a9e2a987cf..c492af2265 100644 --- a/docs/i18n/sk/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/sk/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/sv/docs/reference/ENVIRONMENT.md b/docs/i18n/sv/docs/reference/ENVIRONMENT.md index 87ccfa98a5..c7dd3ff1ce 100644 --- a/docs/i18n/sv/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/sv/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/sw/docs/reference/ENVIRONMENT.md b/docs/i18n/sw/docs/reference/ENVIRONMENT.md index 5668d53c13..c4fd0efc37 100644 --- a/docs/i18n/sw/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/sw/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/ta/docs/reference/ENVIRONMENT.md b/docs/i18n/ta/docs/reference/ENVIRONMENT.md index 62403546e7..a89d4008c6 100644 --- a/docs/i18n/ta/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ta/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/te/docs/reference/ENVIRONMENT.md b/docs/i18n/te/docs/reference/ENVIRONMENT.md index f3e0c2f802..ef88553063 100644 --- a/docs/i18n/te/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/te/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/th/docs/reference/ENVIRONMENT.md b/docs/i18n/th/docs/reference/ENVIRONMENT.md index 2ab580bc1f..eb17e8a41a 100644 --- a/docs/i18n/th/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/th/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/tr/docs/reference/ENVIRONMENT.md b/docs/i18n/tr/docs/reference/ENVIRONMENT.md index 5970a7c0be..1c3336c5b5 100644 --- a/docs/i18n/tr/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/tr/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/uk-UA/docs/reference/ENVIRONMENT.md b/docs/i18n/uk-UA/docs/reference/ENVIRONMENT.md index 86f872d730..ba283ad7e8 100644 --- a/docs/i18n/uk-UA/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/uk-UA/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/ur/docs/reference/ENVIRONMENT.md b/docs/i18n/ur/docs/reference/ENVIRONMENT.md index c92b3f0af9..24ef0b51ca 100644 --- a/docs/i18n/ur/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/ur/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/vi/docs/reference/ENVIRONMENT.md b/docs/i18n/vi/docs/reference/ENVIRONMENT.md index bbc5d3da7d..9f9386753a 100644 --- a/docs/i18n/vi/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/vi/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/zh-CN/docs/guides/USER_GUIDE.md b/docs/i18n/zh-CN/docs/guides/USER_GUIDE.md index b5f1f9b09e..98a4acf372 100644 --- a/docs/i18n/zh-CN/docs/guides/USER_GUIDE.md +++ b/docs/i18n/zh-CN/docs/guides/USER_GUIDE.md @@ -565,7 +565,7 @@ post_install() { **GitHub Copilot (`gh/`)** — OAuth: `gh/gpt-5.5`, `gh/gpt-5.4`, `gh/gpt-5.4-mini`, `gh/gpt-5-mini`, `gh/gpt-5.3-codex`, `gh/claude-opus-4.7`, `gh/claude-opus-4.6`, `gh/claude-opus-4-5-20251101`, `gh/claude-sonnet-4.6`, `gh/claude-sonnet-4.5`, `gh/claude-haiku-4.5`, `gh/gemini-3.1-pro-preview`, `gh/gemini-3-flash-preview`, `gh/oswe-vscode-prime` -**Kiro (`kr/`)** — FREE OAuth: `kr/auto-kiro`, `kr/claude-opus-4.7`, `kr/claude-opus-4.6`, `kr/claude-sonnet-4.6`, `kr/claude-sonnet-4.5`, `kr/claude-haiku-4.5`, `kr/deepseek-3.2`, `kr/minimax-m2.5`, `kr/minimax-m2.1`, `kr/glm-5`, `kr/qwen3-coder-next` +**Kiro (`kr/`)** — 免费 OAuth:请使用 **控制面板 → 提供商 → Kiro → 可用模型** 中显示的实时目录。可用模型取决于账户和套餐。 **Qoder (`if/`)** — FREE OAuth: `if/kimi-k2-0905`, `if/kimi-k2`, `if/qwen3-coder-plus`, `if/qwen3-max`, `if/qwen3-max-preview`, `if/qwen3-vl-plus`, `if/qwen3-32b`, `if/qwen3-235b-a22b-thinking-2507`, `if/qwen3-235b-a22b-instruct`, `if/qwen3-235b`, `if/deepseek-v3.2`, `if/deepseek-v3`, `if/deepseek-r1`, `if/qoder-rome-30ba3b` diff --git a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md index 782d23d747..75923197cd 100644 --- a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md @@ -1,6 +1,7 @@ # 环境变量参考 (中文 (简体)) --- + title: "Environment Variables Reference" version: 3.8.40 lastUpdated: 2026-06-28 @@ -55,13 +56,13 @@ lastUpdated: 2026-06-28 这些变量 **必须** 在首次运行前设置。不设置则应用将拒绝启动,或使用不安全的默认值运行。 -| 变量 | 必需 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | --- | -| `JWT_SECRET` | **是** | _(无)_ | `src/lib/auth` | 签名/校验所有 Dashboard 会话 Cookie(JWT)。使用 `openssl rand -base64 48` 生成。 | -| `API_KEY_SECRET` | **是** | _(无)_ | `src/lib/db/apiKeys.ts` | SQLite 中 API key 静态加密的 AES 密钥。使用 `openssl rand -hex 32` 生成。 | -| `INITIAL_PASSWORD` | **是** | `CHANGEME` | 引导脚本 | 设置初始管理员 Dashboard 密码(与 `.env.example` 默认值一致 — 故意保持不安全以强制更换)。**首次使用前请修改。** 登录后,请通过 Dashboard → Settings → Security 修改。 | -| `OMNIROUTE_WS_BRIDGE_SECRET` | **是**(生产环境) | _(未设置)_ | `src/app/api/internal/codex-responses-ws/route.ts` | 内部 Codex Responses WebSocket 桥接的共享密钥。用于认证 Electron/浏览器 WS 中继与 OmniRoute 之间的桥接请求。⚠️ **生产环境必须设置 — 未设置时所有 WS 桥接请求都会被拒绝。** 使用 `openssl rand -base64 32` 生成。 | -| `OMNIROUTE_PEER_STAMP_TOKEN` | 否(自动) | _(每次启动自动生成)_ | `src/server/authz/policies/management.ts` | 每个进程的密钥,用于证明可信的对等 IP 戳记来自 OmniRoute 自身的 HTTP 服务器(`scripts/dev/peer-stamp.mjs`)。authz 中间件仅在戳记携带此 Token 时才信任请求的本地性(对 LOCAL_ONLY 路由进行 loopback/LAN 门控)。每次启动自动生成 — 保持未设置;仅在必须共享戳记的多进程场景下固定此值。 | +| 变量 | 必需 | 默认值 | 源文件 | 说明 | +| ---------------------------- | ------------------ | -------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `JWT_SECRET` | **是** | _(无)_ | `src/lib/auth` | 签名/校验所有 Dashboard 会话 Cookie(JWT)。使用 `openssl rand -base64 48` 生成。 | +| `API_KEY_SECRET` | **是** | _(无)_ | `src/lib/db/apiKeys.ts` | SQLite 中 API key 静态加密的 AES 密钥。使用 `openssl rand -hex 32` 生成。 | +| `INITIAL_PASSWORD` | **是** | `CHANGEME` | 引导脚本 | 设置初始管理员 Dashboard 密码(与 `.env.example` 默认值一致 — 故意保持不安全以强制更换)。**首次使用前请修改。** 登录后,请通过 Dashboard → Settings → Security 修改。 | +| `OMNIROUTE_WS_BRIDGE_SECRET` | **是**(生产环境) | _(未设置)_ | `src/app/api/internal/codex-responses-ws/route.ts` | 内部 Codex Responses WebSocket 桥接的共享密钥。用于认证 Electron/浏览器 WS 中继与 OmniRoute 之间的桥接请求。⚠️ **生产环境必须设置 — 未设置时所有 WS 桥接请求都会被拒绝。** 使用 `openssl rand -base64 32` 生成。 | +| `OMNIROUTE_PEER_STAMP_TOKEN` | 否(自动) | _(每次启动自动生成)_ | `src/server/authz/policies/management.ts` | 每个进程的密钥,用于证明可信的对等 IP 戳记来自 OmniRoute 自身的 HTTP 服务器(`scripts/dev/peer-stamp.mjs`)。authz 中间件仅在戳记携带此 Token 时才信任请求的本地性(对 LOCAL_ONLY 路由进行 loopback/LAN 门控)。每次启动自动生成 — 保持未设置;仅在必须共享戳记的多进程场景下固定此值。 | ### 生成命令 @@ -82,64 +83,64 @@ echo "OMNIROUTE_WS_BRIDGE_SECRET=$(openssl rand -base64 32)" OmniRoute 使用 **SQLite**(通过 `better-sqlite3`)进行所有持久化存储。以下变量控制数据位置、加密和生命周期。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | SQLite 数据库、备份和数据文件的根目录。在 Docker 卷或自定义路径中可覆盖。 | -| `STORAGE_ENCRYPTION_KEY` | _(空 = 禁用)_ | `src/lib/db/encryption.ts` | 用于 SQLite 数据库静态全量加密的 AES 密钥。使用 `openssl rand -hex 32` 生成。 | -| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | 加密密钥的版本标签。进行密钥轮换时递增,以支持解密旧备份。 | -| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | 设为 `true` 时,跳过每次启动前迁移时运行的自动数据库备份。 | -| `OMNIROUTE_CRYPT_KEY` | _(未设置)_ | `src/lib/db/encryption.ts` | `STORAGE_ENCRYPTION_KEY` 的**旧版别名**。主变量缺失时作为回退被接受。 | -| `OMNIROUTE_API_KEY_BASE64` | _(未设置)_ | `src/lib/db/encryption.ts` | **旧版别名**(Base64 编码形式),作为回退被接受。使用前自动解码。 | -| `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(未设置)_ | `src/lib/db/core.ts` | 覆盖定期 SQLite 健康检查的间隔(毫秒)。未设置时根据 `NODE_ENV` 推导默认值。 | -| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts`, `src/lib/db/healthCheck.ts` | 设为 `1` 可在启动时完全跳过数据库健康检查。适用于短生命周期任务和集成测试。 | -| `OMNIROUTE_FORCE_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts` | 设为 `1` 可强制开启数据库健康检查循环,即使正常会被跳过(如短生命周期任务)。 | -| `OMNIROUTE_SKIP_POSTINSTALL` | `0` | `scripts/postinstall.mjs` | 设为 `1` 可在 `npm install` 期间跳过原生运行时预热。适用于 CI/无头安装,此时 sqlite 已构建好。 | -| `OMNIROUTE_MIGRATIONS_DIR` | _(自动检测)_ | `src/lib/db/migrationRunner.ts` | 覆盖迁移运行器扫描的目录。在自定义构建中打包迁移文件时很有用。 | -| `OMNIROUTE_MAX_PENDING_MIGRATIONS` | `50` | `src/lib/db/migrationRunner.ts` | 大量待处理迁移的安全阈值(#3416)。如果现有数据库上有超过此数量的待处理迁移,启动将中止(防止跟踪表被清空)。恢复旧备份时提高此值;设为 `0` 可禁用检查。 | -| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 批量消费/成本写入器的刷新间隔(毫秒)。值越小写合并越少;值越大数据库争用越少。 | -| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 强制刷新前的最大缓存消费条目数。在高 QPS 部署中提高;在内存受限场景下降低。 | -| `OMNIROUTE_PROXY_FETCH_DEBUG` | _(未设置)_ | `open-sse/utils/proxyFetch.ts` | 设为 `"true"` 可在 Vercel 中继路径上发出 `[ProxyFetch]` 调试日志。默认关闭以避免泄露路由提示。 | -| `BATCH_RETRY_DURATION_MS` | `86400000`(24小时) | `open-sse/services/batchProcessor.ts` | 单个批次项的最大重试窗口(毫秒)。超过此时间的项被标记为失败。 | -| `BATCH_BACKOFF_BASE_MS` | `5000` | `open-sse/services/batchProcessor.ts` | 批次项重试时指数退避的基础延迟(毫秒)。 | -| `BATCH_BACKOFF_MAX_MS` | `3600000`(1小时) | `open-sse/services/batchProcessor.ts` | 批次项重试时指数退避的上限(毫秒)。 | -| `BATCH_MAX_CONCURRENT` | `1` | `open-sse/services/batchProcessor.ts` | 并发处理的批次最大数量。提高以增加吞吐量;保持低值以避免速率限制风暴。 | +| 变量 | 默认值 | 源文件 | 说明 | +| -------------------------------------- | -------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DATA_DIR` | `~/.omniroute/` | `src/lib/db/core.ts` | SQLite 数据库、备份和数据文件的根目录。在 Docker 卷或自定义路径中可覆盖。 | +| `STORAGE_ENCRYPTION_KEY` | _(空 = 禁用)_ | `src/lib/db/encryption.ts` | 用于 SQLite 数据库静态全量加密的 AES 密钥。使用 `openssl rand -hex 32` 生成。 | +| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | 加密密钥的版本标签。进行密钥轮换时递增,以支持解密旧备份。 | +| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | 设为 `true` 时,跳过每次启动前迁移时运行的自动数据库备份。 | +| `OMNIROUTE_CRYPT_KEY` | _(未设置)_ | `src/lib/db/encryption.ts` | `STORAGE_ENCRYPTION_KEY` 的**旧版别名**。主变量缺失时作为回退被接受。 | +| `OMNIROUTE_API_KEY_BASE64` | _(未设置)_ | `src/lib/db/encryption.ts` | **旧版别名**(Base64 编码形式),作为回退被接受。使用前自动解码。 | +| `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(未设置)_ | `src/lib/db/core.ts` | 覆盖定期 SQLite 健康检查的间隔(毫秒)。未设置时根据 `NODE_ENV` 推导默认值。 | +| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts`, `src/lib/db/healthCheck.ts` | 设为 `1` 可在启动时完全跳过数据库健康检查。适用于短生命周期任务和集成测试。 | +| `OMNIROUTE_FORCE_DB_HEALTHCHECK` | `0` | `src/lib/db/core.ts` | 设为 `1` 可强制开启数据库健康检查循环,即使正常会被跳过(如短生命周期任务)。 | +| `OMNIROUTE_SKIP_POSTINSTALL` | `0` | `scripts/postinstall.mjs` | 设为 `1` 可在 `npm install` 期间跳过原生运行时预热。适用于 CI/无头安装,此时 sqlite 已构建好。 | +| `OMNIROUTE_MIGRATIONS_DIR` | _(自动检测)_ | `src/lib/db/migrationRunner.ts` | 覆盖迁移运行器扫描的目录。在自定义构建中打包迁移文件时很有用。 | +| `OMNIROUTE_MAX_PENDING_MIGRATIONS` | `50` | `src/lib/db/migrationRunner.ts` | 大量待处理迁移的安全阈值(#3416)。如果现有数据库上有超过此数量的待处理迁移,启动将中止(防止跟踪表被清空)。恢复旧备份时提高此值;设为 `0` 可禁用检查。 | +| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 批量消费/成本写入器的刷新间隔(毫秒)。值越小写合并越少;值越大数据库争用越少。 | +| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(代码内默认值)_ | `src/lib/spend/batchWriter.ts` | 强制刷新前的最大缓存消费条目数。在高 QPS 部署中提高;在内存受限场景下降低。 | +| `OMNIROUTE_PROXY_FETCH_DEBUG` | _(未设置)_ | `open-sse/utils/proxyFetch.ts` | 设为 `"true"` 可在 Vercel 中继路径上发出 `[ProxyFetch]` 调试日志。默认关闭以避免泄露路由提示。 | +| `BATCH_RETRY_DURATION_MS` | `86400000`(24小时) | `open-sse/services/batchProcessor.ts` | 单个批次项的最大重试窗口(毫秒)。超过此时间的项被标记为失败。 | +| `BATCH_BACKOFF_BASE_MS` | `5000` | `open-sse/services/batchProcessor.ts` | 批次项重试时指数退避的基础延迟(毫秒)。 | +| `BATCH_BACKOFF_MAX_MS` | `3600000`(1小时) | `open-sse/services/batchProcessor.ts` | 批次项重试时指数退避的上限(毫秒)。 | +| `BATCH_MAX_CONCURRENT` | `1` | `open-sse/services/batchProcessor.ts` | 并发处理的批次最大数量。提高以增加吞吐量;保持低值以避免速率限制风暴。 | ### 场景 -| 场景 | 配置 | -| --- | --- | -| **本地开发** | 保留所有默认值。数据库位于 `~/.omniroute/omniroute.db`。 | -| **Docker** | `DATA_DIR=/data` + 挂载卷到 `/data`。 | +| 场景 | 配置 | +| ------------ | --------------------------------------------------------------- | +| **本地开发** | 保留所有默认值。数据库位于 `~/.omniroute/omniroute.db`。 | +| **Docker** | `DATA_DIR=/data` + 挂载卷到 `/data`。 | | **静态加密** | 设置 `STORAGE_ENCRYPTION_KEY` + 备份密钥!丢失密钥 = 丢失数据。 | -| **CI/测试** | `DATA_DIR=/tmp/omniroute-test` — 临时目录,无需加密。 | +| **CI/测试** | `DATA_DIR=/tmp/omniroute-test` — 临时目录,无需加密。 | --- ## 3. 网络与端口 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Dashboard UI 和 API 端点共用的主端口(单端口模式)。 | -| `API_PORT` | _(未设置)_ | `src/lib/runtime/ports.ts` | 设置时,在另外的端口上提供 `/v1/*` 代理 API。 | -| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | API 端口的绑定地址。 | -| `DASHBOARD_PORT` | _(未设置)_ | `src/lib/runtime/ports.ts` | 设置时,在另外的端口上提供 Dashboard UI。 | -| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Docker 生产模式下 Dashboard 的主机侧发布端口。 | -| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Docker 生产模式下 API 的主机侧发布端口。 | -| `OMNIROUTE_PORT` | _(未设置)_ | `src/lib/runtime/ports.ts` | 在 Electron 或其他包装器中运行时优先于 `PORT`。 | -| `LIVE_WS_PORT` | `20129` | `src/server/ws/liveServer.ts` | 实时 WebSocket 监控服务器的端口。 | -| `LIVE_WS_HOST` | `127.0.0.1` | `src/server/ws/liveServer.ts` | 实时 WebSocket 服务器的绑定地址。设为 `0.0.0.0` 可暴露到 LAN(还需配置 `LIVE_WS_ALLOWED_ORIGINS`)。 | -| `LIVE_WS_ALLOWED_ORIGINS` | _(未设置)_ | `src/server/ws/liveServer.ts` | 逗号分隔的额外允许打开实时 WebSocket 的源。loopback Dashboard 源已默认允许。 | -| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` | 设为 `0` 或 `false` 可禁用实时 WebSocket 服务器(默认启用,绑定 loopback)。 | -| `OMNIROUTE_DISABLE_LIVE_WS` | `false` | `scripts/start-ws-server.mjs` | CI/测试工具开关,禁用独立的实时 WebSocket 辅助脚本。 | -| `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | 每个 (Token, IP) 的中继速率限制,请求数/分钟。基于内存,每个实例独立。`0` 或负数可禁用 IP 维度门控(每个 Token 的数据库限制仍然生效)。 | -| `NODE_ENV` | `production` | Next.js 核心 | 控制日志详细程度、缓存、错误详情暴露和 Next.js 优化。 | -| `OMNIROUTE_USE_TURBOPACK` | `1`(`.env.example` 中默认值) | `package.json` / Next.js 16 | 在 `npm run dev` 和 `npm run build` 中切换 Next.js 16 Turbopack 打包器。在 Windows 或遇到原生绑定不兼容时设为 `0`。 | -| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(未设置)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | 设为 `1` 可跳过启动时的 SQLite 完整性健康检查。适用于大型数据库需要更快启动时。 | -| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | 后台凭证健康检查调度器的间隔(毫秒)。最低:10000(10 秒)。 | -| `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | 凭证健康状态缓存的 TTL(毫秒)。 | -| `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | 设为 `1` 或 `true` 可禁用后台定期服务商连接测试。 | -| `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Next.js 开发/启动服务器的绑定地址。设置时覆盖默认的 `0.0.0.0`。 | -| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Playwright 启动 Next.js 时使用的绑定地址。默认为 `127.0.0.1` 以确保测试隔离。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | `20128` | `src/lib/runtime/ports.ts` | Dashboard UI 和 API 端点共用的主端口(单端口模式)。 | +| `API_PORT` | _(未设置)_ | `src/lib/runtime/ports.ts` | 设置时,在另外的端口上提供 `/v1/*` 代理 API。 | +| `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | API 端口的绑定地址。 | +| `DASHBOARD_PORT` | _(未设置)_ | `src/lib/runtime/ports.ts` | 设置时,在另外的端口上提供 Dashboard UI。 | +| `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Docker 生产模式下 Dashboard 的主机侧发布端口。 | +| `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Docker 生产模式下 API 的主机侧发布端口。 | +| `OMNIROUTE_PORT` | _(未设置)_ | `src/lib/runtime/ports.ts` | 在 Electron 或其他包装器中运行时优先于 `PORT`。 | +| `LIVE_WS_PORT` | `20129` | `src/server/ws/liveServer.ts` | 实时 WebSocket 监控服务器的端口。 | +| `LIVE_WS_HOST` | `127.0.0.1` | `src/server/ws/liveServer.ts` | 实时 WebSocket 服务器的绑定地址。设为 `0.0.0.0` 可暴露到 LAN(还需配置 `LIVE_WS_ALLOWED_ORIGINS`)。 | +| `LIVE_WS_ALLOWED_ORIGINS` | _(未设置)_ | `src/server/ws/liveServer.ts` | 逗号分隔的额外允许打开实时 WebSocket 的源。loopback Dashboard 源已默认允许。 | +| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` | 设为 `0` 或 `false` 可禁用实时 WebSocket 服务器(默认启用,绑定 loopback)。 | +| `OMNIROUTE_DISABLE_LIVE_WS` | `false` | `scripts/start-ws-server.mjs` | CI/测试工具开关,禁用独立的实时 WebSocket 辅助脚本。 | +| `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | 每个 (Token, IP) 的中继速率限制,请求数/分钟。基于内存,每个实例独立。`0` 或负数可禁用 IP 维度门控(每个 Token 的数据库限制仍然生效)。 | +| `NODE_ENV` | `production` | Next.js 核心 | 控制日志详细程度、缓存、错误详情暴露和 Next.js 优化。 | +| `OMNIROUTE_USE_TURBOPACK` | `1`(`.env.example` 中默认值) | `package.json` / Next.js 16 | 在 `npm run dev` 和 `npm run build` 中切换 Next.js 16 Turbopack 打包器。在 Windows 或遇到原生绑定不兼容时设为 `0`。 | +| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(未设置)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | 设为 `1` 可跳过启动时的 SQLite 完整性健康检查。适用于大型数据库需要更快启动时。 | +| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | 后台凭证健康检查调度器的间隔(毫秒)。最低:10000(10 秒)。 | +| `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | 凭证健康状态缓存的 TTL(毫秒)。 | +| `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | 设为 `1` 或 `true` 可禁用后台定期服务商连接测试。 | +| `HOST` | `0.0.0.0` | `scripts/dev/run-next.mjs` | Next.js 开发/启动服务器的绑定地址。设置时覆盖默认的 `0.0.0.0`。 | +| `HOSTNAME` | `127.0.0.1` | `scripts/dev/run-next-playwright.mjs` | Playwright 启动 Next.js 时使用的绑定地址。默认为 `127.0.0.1` 以确保测试隔离。 | ### 端口模式 @@ -169,22 +170,22 @@ OmniRoute 使用 **SQLite**(通过 `better-sqlite3`)进行所有持久化存 ## 4. 安全与认证 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | 与硬件标识符组合用于机器指纹的盐值。按部署修改以实现隔离。 | -| `OMNIROUTE_CLI_SALT` | `omniroute-cli-auth-v1` | `src/lib/machineToken.ts` | 用于派生本地 CLI 认证 Token 的 HMAC 盐值。修改此值将轮换机器上的所有 CLI Token。参阅 `docs/security/CLI_TOKEN.md`。 | -| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | 设置会话 Cookie 的 `Secure` 标志。运行在 HTTPS 背后时 **必须设为 `true`**。 | -| `REQUIRE_API_KEY` | `false` | API 中间件 | 设为 `true` 后,所有 `/v1/*` 代理请求必须包含有效的 API key。 | -| `ALLOW_API_KEY_REVEAL` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | 允许在 Dashboard UI 中显示完整 API key 值。可从 Dashboard Feature Flags 配置;共享实例上有安全风险。 | -| `NO_LOG_API_KEY_IDS` | _(空)_ | `src/lib/compliance/index.ts` | 逗号分隔的 API key ID 列表,其请求将绕过日志记录(GDPR 合规)。 | -| `DEFAULT_RATE_LIMIT_PER_DAY` | `1000` | `src/shared/utils/apiKeyPolicy.ts` | 应用于 `rate_limits` 列为 null 的 API key 的回退每日请求预算。默认(未设置/空/格式错误)保持传统的 1000/天、5000/周、20000/月 窗口。显式设为 `0` 可选择退出(无限制)。任意正整数 N 则启用 N/天、5N/周、20N/月。Zod 校验;无效值记录警告并使用传统默认值。 | -| `MAX_BODY_SIZE_BYTES` | `10485760`(10 MB) | `src/shared/middleware/bodySizeGuard.ts` | 允许的最大请求体大小。拒绝超限载荷。 | -| `CORS_ORIGIN` | _(未设置)_ | `src/server/cors/origins.ts` | 旧版单一源 CORS 允许列表。新部署推荐使用 `CORS_ALLOWED_ORIGINS`。CORS 仅用于跨源浏览器 API 客户端;反向代理后的同源 Dashboard 请求使用 `NEXT_PUBLIC_BASE_URL` / 公共源校验。 | -| `CORS_ALLOWED_ORIGINS` | _(未设置)_ | `src/server/cors/origins.ts` | 逗号分隔的 CORS 允许列表。除非显式配置 `CORS_ALLOW_ALL=true`,否则不发送通配符。 | -| `CORS_ALLOW_ALL` | `false` | `src/server/cors/origins.ts` | 仅供开发使用的逃生口,可回显任意浏览器 `Origin`。不要在共享或生产部署中启用。 | -| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | 阻止目标为私有/loopback/链路本地 IP 范围的服务商调用。仅在隔离的测试环境中禁用。 | -| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | 允许指向私有/本地网络(localhost、192.168.x.x、10.x.x.x 等)的服务商 URL。**自托管服务商必需**(LM Studio、Ollama、vLLM、Llamafile、Triton、SearXNG)。设为 `false` 时,Dashboard 拒绝校验本地 URL。 | -| `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | 允许在本地/私有地址上添加/校验服务商(127.0.0.1、localhost、LAN、私有范围)— 仅影响服务商校验路径。**默认 `true`**(本地优先);设为 `false` 可强制严格的仅公网阻断。云元数据端点(169.254.169.254, metadata.google.internal)始终被阻断。(#5066) | +| 变量 | 默认值 | 源文件 | 说明 | +| --------------------------------------- | ----------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | 与硬件标识符组合用于机器指纹的盐值。按部署修改以实现隔离。 | +| `OMNIROUTE_CLI_SALT` | `omniroute-cli-auth-v1` | `src/lib/machineToken.ts` | 用于派生本地 CLI 认证 Token 的 HMAC 盐值。修改此值将轮换机器上的所有 CLI Token。参阅 `docs/security/CLI_TOKEN.md`。 | +| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | 设置会话 Cookie 的 `Secure` 标志。运行在 HTTPS 背后时 **必须设为 `true`**。 | +| `REQUIRE_API_KEY` | `false` | API 中间件 | 设为 `true` 后,所有 `/v1/*` 代理请求必须包含有效的 API key。 | +| `ALLOW_API_KEY_REVEAL` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | 允许在 Dashboard UI 中显示完整 API key 值。可从 Dashboard Feature Flags 配置;共享实例上有安全风险。 | +| `NO_LOG_API_KEY_IDS` | _(空)_ | `src/lib/compliance/index.ts` | 逗号分隔的 API key ID 列表,其请求将绕过日志记录(GDPR 合规)。 | +| `DEFAULT_RATE_LIMIT_PER_DAY` | `1000` | `src/shared/utils/apiKeyPolicy.ts` | 应用于 `rate_limits` 列为 null 的 API key 的回退每日请求预算。默认(未设置/空/格式错误)保持传统的 1000/天、5000/周、20000/月 窗口。显式设为 `0` 可选择退出(无限制)。任意正整数 N 则启用 N/天、5N/周、20N/月。Zod 校验;无效值记录警告并使用传统默认值。 | +| `MAX_BODY_SIZE_BYTES` | `10485760`(10 MB) | `src/shared/middleware/bodySizeGuard.ts` | 允许的最大请求体大小。拒绝超限载荷。 | +| `CORS_ORIGIN` | _(未设置)_ | `src/server/cors/origins.ts` | 旧版单一源 CORS 允许列表。新部署推荐使用 `CORS_ALLOWED_ORIGINS`。CORS 仅用于跨源浏览器 API 客户端;反向代理后的同源 Dashboard 请求使用 `NEXT_PUBLIC_BASE_URL` / 公共源校验。 | +| `CORS_ALLOWED_ORIGINS` | _(未设置)_ | `src/server/cors/origins.ts` | 逗号分隔的 CORS 允许列表。除非显式配置 `CORS_ALLOW_ALL=true`,否则不发送通配符。 | +| `CORS_ALLOW_ALL` | `false` | `src/server/cors/origins.ts` | 仅供开发使用的逃生口,可回显任意浏览器 `Origin`。不要在共享或生产部署中启用。 | +| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | 阻止目标为私有/loopback/链路本地 IP 范围的服务商调用。仅在隔离的测试环境中禁用。 | +| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | 允许指向私有/本地网络(localhost、192.168.x.x、10.x.x.x 等)的服务商 URL。**自托管服务商必需**(LM Studio、Ollama、vLLM、Llamafile、Triton、SearXNG)。设为 `false` 时,Dashboard 拒绝校验本地 URL。 | +| `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | 允许在本地/私有地址上添加/校验服务商(127.0.0.1、localhost、LAN、私有范围)— 仅影响服务商校验路径。**默认 `true`**(本地优先);设为 `false` 可强制严格的仅公网阻断。云元数据端点(169.254.169.254, metadata.google.internal)始终被阻断。(#5066) | ### 加固清单 @@ -205,84 +206,84 @@ OmniRoute 提供两层防护:请求侧的注入扫描和响应侧的 PII 脱 ### 请求侧:提示注入安全护栏 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `INPUT_SANITIZER_ENABLED` | `true` | `src/middleware/promptInjectionGuard.ts` | 启用扫描传入消息中的提示注入模式。 | -| `INPUT_SANITIZER_MODE` | `warn` | `src/middleware/promptInjectionGuard.ts` | `warn` = 仅记录日志,`block` = 以 400 拒绝请求,`redact` = 脱敏可疑模式。 | -| `INJECTION_GUARD_MODE` | _(未设置)_ | `src/middleware/promptInjectionGuard.ts` | `INPUT_SANITIZER_MODE` 的旧版别名 — 行为相同。 | -| `PII_REDACTION_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | 检测传入请求中的 PII(邮箱、电话、社会安全号码等)。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------------- | ---------- | ---------------------------------------- | ------------------------------------------------------------------------- | +| `INPUT_SANITIZER_ENABLED` | `true` | `src/middleware/promptInjectionGuard.ts` | 启用扫描传入消息中的提示注入模式。 | +| `INPUT_SANITIZER_MODE` | `warn` | `src/middleware/promptInjectionGuard.ts` | `warn` = 仅记录日志,`block` = 以 400 拒绝请求,`redact` = 脱敏可疑模式。 | +| `INJECTION_GUARD_MODE` | _(未设置)_ | `src/middleware/promptInjectionGuard.ts` | `INPUT_SANITIZER_MODE` 的旧版别名 — 行为相同。 | +| `PII_REDACTION_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | 检测传入请求中的 PII(邮箱、电话、社会安全号码等)。 | ### 响应侧:PII 脱敏器 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `PII_RESPONSE_SANITIZATION` | `false` | `src/lib/piiSanitizer.ts` | 在返回给客户端之前扫描大语言模型响应中的泄露 PII。 | +| 变量 | 默认值 | 源文件 | 说明 | +| -------------------------------- | -------- | ------------------------- | ------------------------------------------------------------------ | +| `PII_RESPONSE_SANITIZATION` | `false` | `src/lib/piiSanitizer.ts` | 在返回给客户端之前扫描大语言模型响应中的泄露 PII。 | | `PII_RESPONSE_SANITIZATION_MODE` | `redact` | `src/lib/piiSanitizer.ts` | `redact` = 脱敏 PII,`warn` = 仅记录日志,`block` = 丢弃整个响应。 | ### VS Code Token 化路由上下文净化器 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_VSCODE_SANITIZE_CONTEXT` | `1` | `src/app/api/v1/vscode/contextSanitizer.ts` | 从 `/v1/vscode/[token]/*` 请求中剥离隐式的活跃编辑器上下文(`editorContext`, `activeEditor`, `currentFile`, `selection`, `openTabs`...),并脱敏显式附加的敏感文件内容。安全默认启用;设为 `0` 可禁用。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------------------- | ------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_VSCODE_SANITIZE_CONTEXT` | `1` | `src/app/api/v1/vscode/contextSanitizer.ts` | 从 `/v1/vscode/[token]/*` 请求中剥离隐式的活跃编辑器上下文(`editorContext`, `activeEditor`, `currentFile`, `selection`, `openTabs`...),并脱敏显式附加的敏感文件内容。安全默认启用;设为 `0` 可禁用。 | ### 场景 -| 场景 | 配置 | -| --- | --- | +| 场景 | 配置 | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------- | | **企业合规** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=block`, `PII_REDACTION_ENABLED=true`, `PII_RESPONSE_SANITIZATION=true` | -| **仅监控** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=warn` — 记录日志但永不阻断 | -| **个人使用** | 全部禁用 — 零开销 | +| **仅监控** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=warn` — 记录日志但永不阻断 | +| **个人使用** | 全部禁用 — 零开销 | --- ## 6. 工具与路由策略 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `TOOL_POLICY_MODE` | `disabled` | `src/lib/toolPolicy.ts` | 控制大语言模型的工具/函数调用访问。`allowlist` = 仅允许列表中的工具,`denylist` = 除列表外全部允许,`disabled` = 无限制。 | -| `OMNIROUTE_PAYLOAD_RULES_PATH` | `./config/payloadRules.json` | `open-sse/services/payloadRules.ts` | 载荷操作规则 JSON 文件路径(按模型/协议进行上游调整)。 | -| `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | 热加载载荷规则文件的重载间隔(毫秒)。最低 `1000`。 | -| `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` | `false` | `open-sse/services/model.ts` | 启用后:将来自 Claude Code 客户端的无前缀 `claude-*` 模型 ID 通过 Claude Code OAuth 账户路由,而非要求服务商前缀。显式服务商前缀优先级更高。也可通过 Dashboard 中 Claude 服务商页面的开关配置。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------------------------------------------- | ---------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TOOL_POLICY_MODE` | `disabled` | `src/lib/toolPolicy.ts` | 控制大语言模型的工具/函数调用访问。`allowlist` = 仅允许列表中的工具,`denylist` = 除列表外全部允许,`disabled` = 无限制。 | +| `OMNIROUTE_PAYLOAD_RULES_PATH` | `./config/payloadRules.json` | `open-sse/services/payloadRules.ts` | 载荷操作规则 JSON 文件路径(按模型/协议进行上游调整)。 | +| `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | 热加载载荷规则文件的重载间隔(毫秒)。最低 `1000`。 | +| `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` | `false` | `open-sse/services/model.ts` | 启用后:将来自 Claude Code 客户端的无前缀 `claude-*` 模型 ID 通过 Claude Code OAuth 账户路由,而非要求服务商前缀。显式服务商前缀优先级更高。也可通过 Dashboard 中 Claude 服务商页面的开关配置。 | --- ## 7. URL 与云同步 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `BASE_URL` | `http://localhost:20128` | `src/lib/cloudSync.ts` | 内部同步任务调用 `/api/sync/cloud` 的服务器端 URL。即使应用被公共代理,也保持为 loopback/容器 URL。 | -| `CLOUD_URL` | _(空)_ | `src/lib/cloudSync.ts` | 云中继端点 URL(高级功能)。 | -| `CLOUD_SYNC_TIMEOUT_MS` | `12000` | `src/lib/cloudSync.ts` | 云同步请求的 HTTP 超时。 | -| `OMNIROUTE_BUILD_PROFILE` | `full` | Webpack 构建配置 | 构建时配置文件(设为 `minimal` 可物理排除特权模块不打包)。 | -| `OMNIROUTE_CLOUD_SYNC_SECRET` | _(空)_ | `src/lib/cloudSync.ts` | 用于校验云同步响应 HMAC-SHA256 签名的共享密钥。 | -| `OMNIROUTE_CLOUD_SYNC_SECRETS` | `false` | `src/lib/cloudSync.ts` | 设为 `true` 允许云同步端点覆盖本地凭证。默认 `false`。 | -| `OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP` | `false` | `src/app/api/providers/zed/import/route.ts` | 设为 `true` 可回退到 v3.8.5 的一步式"导入全部"行为,无需用户确认。 | -| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth、Dashboard、同步 | 面向公共的 URL,用于 OAuth redirect_uri、Dashboard 链接、生成的公共 URL 以及同源浏览器变更检查。**在反向代理背后时,必须匹配你的公共 URL。** | -| `NEXT_PUBLIC_CLOUD_URL` | _(空)_ | 客户端侧 | `CLOUD_URL` 的客户端镜像。 | -| `NEXT_PUBLIC_APP_URL` | _(未设置)_ | `src/shared/services/cloudSyncScheduler.ts` | `NEXT_PUBLIC_BASE_URL` 的旧版回退。 | -| `OMNIROUTE_PUBLIC_BASE_URL` | _(未设置)_ | 公共源解析器、图片 URL | 最高优先级的浏览器侧 OmniRoute 源,用于公共 URL 生成和源校验(例如 `/v1/chatgpt-web/image/`)。当 OpenWebUI 或其他中继通过内部 URL 访问 OmniRoute,但用户浏览器必须从 LAN、隧道或公共源获取图片时设置。**不要**包含 `/v1`。 | -| `OMNIROUTE_TRUST_PROXY` | _(未设置)_ | `src/server/origin/publicOrigin.ts` | 可选的转发公共源头信任模式。未设置 = 出于安全考虑不信任 `Forwarded` / `X-Forwarded-*`。`true` / `loopback` 仅信任来自经过 Token 戳记的 loopback 代理的转发 host/proto。`private` / `lan` 还信任私有 LAN 代理对端。生产环境中推荐显式设置 `NEXT_PUBLIC_BASE_URL`。 | -| `OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS` | `180000`(3 分钟) | `open-sse/executors/chatgpt-web.ts` | 等待异步 chatgpt-web 图片通过 Celsius WebSocket 到达的最大时间。在上游排队窗口较长时提高此值。 | -| `OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB` | `256` | `open-sse/services/chatgptImageCache.ts` | 为 `/v1/chatgpt-web/image/` 提供服务的 chatgpt-web 图片缓存的内存预算总额(MB)。在内存受限的主机上降低;图片生成量大且客户端争抢 30 分钟 TTL 时提高。 | -| `OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS` | `1200000`(20 分钟) | `open-sse/executors/chatgpt-web.ts` | chatgpt-web GPT-5.5 Pro 后台轮询交接的总体等待预算。Pro 推理在带外完成,OmniRoute 轮询直到结果到达或预算耗尽。如果 Pro 请求完成前超时,请提高此值。 | -| `OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS` | `4000`(4 秒) | `open-sse/executors/chatgpt-web.ts` | chatgpt-web GPT-5.5 Pro 后台轮询尝试的间隔。降低可更快完成但增加上游轮询;提高可减少请求量。 | -| `THEOLDLLM_NAV_TIMEOUT_MS` | `30000`(30 秒) | `open-sse/executors/theoldllm.ts` | 浏览器端 Token 捕获(The Old LLM (theoldllm) 免费服务商使用)的 Playwright 导航超时(毫秒)。如果中继页面加载慢,可在慢速网络上提高。 | -| `KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 异步 kie.ai 任务的公共回调 URL。优先级高于 `OMNIROUTE_KIE_CALLBACK_URL` 和 `OMNIROUTE_PUBLIC_URL`。 | -| `OMNIROUTE_KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | `KIE_CALLBACK_URL` 的替代写法。主变量未设置时的回退。 | -| `OMNIROUTE_PUBLIC_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 用于组合异步回调 URL 的公共源。kie.ai 回调的最低优先级回退;也用作其他中继的通用公共 URL。 | -| `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | Usage 页面使用的 CrofAI 配额查询端点。可覆盖为中继/测试固定件。 | -| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | Usage 页面使用的 OpenCode (zen/go) 配额查询端点。可覆盖为中继/测试固定件。 | -| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | _(未设置)_ | `open-sse/services/opencodeOllamaUsage.ts` | Usage 页面使用的 OpenCode Go 配额查询端点。OpenCode Go 没有公开的配额 API,因此没有默认值;除非运维人员显式设置该变量选择接入自建/镜像端点,否则不会发起网络请求。 | -| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | 配置了 workspace ID 和 auth Cookie 时用于配额抓取的 OpenCode Go Dashboard 基础 URL。可覆盖为中继/测试固定件。 | -| `OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go workspace ID。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID 环境变量的备选名,在较短的别名之前使用。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OPENCODE_GO_AUTH_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go `auth` Cookie。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go `auth` Cookie 环境变量的备选名,在较短的别名之前使用。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | 用于配额抓取的 Ollama Cloud settings URL。可覆盖为中继/测试固定件。 | -| `OLLAMA_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | 用于设置页面配额抓取的 Ollama Cloud `__Secure-session` Cookie。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OLLAMA_CLOUD_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` Cookie 环境变量的备选名。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_OLLAMA_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` Cookie 环境变量的备选名,在较短的别名之前使用。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | -| `OMNIROUTE_CODEWHISPERER_BASE_URL` | `https://codewhisperer.us-east-1.amazonaws.com` | `open-sse/services/usage.ts` | CodeWhisperer (AWS Kiro) 用量限制端点。可覆盖为中继/测试固定件。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------------------------- | ----------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `BASE_URL` | `http://localhost:20128` | `src/lib/cloudSync.ts` | 内部同步任务调用 `/api/sync/cloud` 的服务器端 URL。即使应用被公共代理,也保持为 loopback/容器 URL。 | +| `CLOUD_URL` | _(空)_ | `src/lib/cloudSync.ts` | 云中继端点 URL(高级功能)。 | +| `CLOUD_SYNC_TIMEOUT_MS` | `12000` | `src/lib/cloudSync.ts` | 云同步请求的 HTTP 超时。 | +| `OMNIROUTE_BUILD_PROFILE` | `full` | Webpack 构建配置 | 构建时配置文件(设为 `minimal` 可物理排除特权模块不打包)。 | +| `OMNIROUTE_CLOUD_SYNC_SECRET` | _(空)_ | `src/lib/cloudSync.ts` | 用于校验云同步响应 HMAC-SHA256 签名的共享密钥。 | +| `OMNIROUTE_CLOUD_SYNC_SECRETS` | `false` | `src/lib/cloudSync.ts` | 设为 `true` 允许云同步端点覆盖本地凭证。默认 `false`。 | +| `OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP` | `false` | `src/app/api/providers/zed/import/route.ts` | 设为 `true` 可回退到 v3.8.5 的一步式"导入全部"行为,无需用户确认。 | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth、Dashboard、同步 | 面向公共的 URL,用于 OAuth redirect_uri、Dashboard 链接、生成的公共 URL 以及同源浏览器变更检查。**在反向代理背后时,必须匹配你的公共 URL。** | +| `NEXT_PUBLIC_CLOUD_URL` | _(空)_ | 客户端侧 | `CLOUD_URL` 的客户端镜像。 | +| `NEXT_PUBLIC_APP_URL` | _(未设置)_ | `src/shared/services/cloudSyncScheduler.ts` | `NEXT_PUBLIC_BASE_URL` 的旧版回退。 | +| `OMNIROUTE_PUBLIC_BASE_URL` | _(未设置)_ | 公共源解析器、图片 URL | 最高优先级的浏览器侧 OmniRoute 源,用于公共 URL 生成和源校验(例如 `/v1/chatgpt-web/image/`)。当 OpenWebUI 或其他中继通过内部 URL 访问 OmniRoute,但用户浏览器必须从 LAN、隧道或公共源获取图片时设置。**不要**包含 `/v1`。 | +| `OMNIROUTE_TRUST_PROXY` | _(未设置)_ | `src/server/origin/publicOrigin.ts` | 可选的转发公共源头信任模式。未设置 = 出于安全考虑不信任 `Forwarded` / `X-Forwarded-*`。`true` / `loopback` 仅信任来自经过 Token 戳记的 loopback 代理的转发 host/proto。`private` / `lan` 还信任私有 LAN 代理对端。生产环境中推荐显式设置 `NEXT_PUBLIC_BASE_URL`。 | +| `OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS` | `180000`(3 分钟) | `open-sse/executors/chatgpt-web.ts` | 等待异步 chatgpt-web 图片通过 Celsius WebSocket 到达的最大时间。在上游排队窗口较长时提高此值。 | +| `OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB` | `256` | `open-sse/services/chatgptImageCache.ts` | 为 `/v1/chatgpt-web/image/` 提供服务的 chatgpt-web 图片缓存的内存预算总额(MB)。在内存受限的主机上降低;图片生成量大且客户端争抢 30 分钟 TTL 时提高。 | +| `OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS` | `1200000`(20 分钟) | `open-sse/executors/chatgpt-web.ts` | chatgpt-web GPT-5.5 Pro 后台轮询交接的总体等待预算。Pro 推理在带外完成,OmniRoute 轮询直到结果到达或预算耗尽。如果 Pro 请求完成前超时,请提高此值。 | +| `OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS` | `4000`(4 秒) | `open-sse/executors/chatgpt-web.ts` | chatgpt-web GPT-5.5 Pro 后台轮询尝试的间隔。降低可更快完成但增加上游轮询;提高可减少请求量。 | +| `THEOLDLLM_NAV_TIMEOUT_MS` | `30000`(30 秒) | `open-sse/executors/theoldllm.ts` | 浏览器端 Token 捕获(The Old LLM (theoldllm) 免费服务商使用)的 Playwright 导航超时(毫秒)。如果中继页面加载慢,可在慢速网络上提高。 | +| `KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 异步 kie.ai 任务的公共回调 URL。优先级高于 `OMNIROUTE_KIE_CALLBACK_URL` 和 `OMNIROUTE_PUBLIC_URL`。 | +| `OMNIROUTE_KIE_CALLBACK_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | `KIE_CALLBACK_URL` 的替代写法。主变量未设置时的回退。 | +| `OMNIROUTE_PUBLIC_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 用于组合异步回调 URL 的公共源。kie.ai 回调的最低优先级回退;也用作其他中继的通用公共 URL。 | +| `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | Usage 页面使用的 CrofAI 配额查询端点。可覆盖为中继/测试固定件。 | +| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | Usage 页面使用的 OpenCode (zen/go) 配额查询端点。可覆盖为中继/测试固定件。 | +| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | _(未设置)_ | `open-sse/services/opencodeOllamaUsage.ts` | Usage 页面使用的 OpenCode Go 配额查询端点。OpenCode Go 没有公开的配额 API,因此没有默认值;除非运维人员显式设置该变量选择接入自建/镜像端点,否则不会发起网络请求。 | +| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | 配置了 workspace ID 和 auth Cookie 时用于配额抓取的 OpenCode Go Dashboard 基础 URL。可覆盖为中继/测试固定件。 | +| `OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go workspace ID。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID 环境变量的备选名,在较短的别名之前使用。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OPENCODE_GO_AUTH_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go `auth` Cookie。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go `auth` Cookie 环境变量的备选名,在较短的别名之前使用。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | 用于配额抓取的 Ollama Cloud settings URL。可覆盖为中继/测试固定件。 | +| `OLLAMA_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | 用于设置页面配额抓取的 Ollama Cloud `__Secure-session` Cookie。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OLLAMA_CLOUD_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` Cookie 环境变量的备选名。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OMNIROUTE_OLLAMA_USAGE_COOKIE` | _(未设置)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` Cookie 环境变量的备选名,在较短的别名之前使用。敏感信息;配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | +| `OMNIROUTE_CODEWHISPERER_BASE_URL` | `https://codewhisperer.us-east-1.amazonaws.com` | `open-sse/services/usage.ts` | CodeWhisperer (AWS Kiro) 用量限制端点。可覆盖为中继/测试固定件。 | > [!IMPORTANT] > 当部署在反向代理(nginx、Caddy)之后时,**必须**将 `NEXT_PUBLIC_BASE_URL` 设置为你的公共 URL(例如 `https://omniroute.example.com`)。否则 OAuth 回调可能因 redirect_uri 不匹配而失败,生成的公共链接可能指向内部容器源,同源 Dashboard 变更可能被浏览器源检查拒绝。 @@ -297,29 +298,29 @@ OmniRoute 提供两层防护:请求侧的注入扫描和响应侧的 PII 脱 将上游大语言模型服务商调用通过 HTTP 或 SOCKS5 代理路由,以实现出口控制、异地路由或 IP 掩码。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | 启用上游调用的 SOCKS5 代理代理。可用 `false` 退出。 | -| `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | 客户端侧 | 客户端对 SOCKS5 可用性的感知。 | -| `HTTP_PROXY` | _(未设置)_ | Node.js 标准 | 上游调用的 HTTP 代理。 | -| `HTTPS_PROXY` | _(未设置)_ | Node.js 标准 | 上游调用的 HTTPS 代理。 | -| `ALL_PROXY` | _(未设置)_ | Node.js 标准 | 通用代理(支持 `socks5://`)。 | -| `NO_PROXY` | _(未设置)_ | Node.js 标准 | 逗号分隔的绕过代理的主机名/IP。 | -| `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS` | `32` | `open-sse/utils/proxyDispatcher.ts` | 每个缓存的 HTTP/SOCKS 代理调度器的最大并发套接字数。长连接 SSE 流(如 Codex `/v1/responses`)在多个请求共享同一账户级代理时需要不止一个连接。超过 `256` 的值会被截断。 | -| `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 握手(连接)超时,毫秒。当单个住宅网关主机高并发(如 100 个并发请求)时提高 — 在池饱和情况下实际握手可能超过 10 秒,即使代理可达,否则会显示为虚假的 `[Proxy Fast-Fail] Proxy unreachable`。上限为 `120000`。 | -| `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | 设为 `false`(默认)时,代理解析失败的请求会被**拒绝(fail-closed)**,而不会回退到直连 — 防止真实 IP 泄露。设为 `true` 可恢复旧版的 DIRECT 回退。 | -| `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | 使用 wreq-js 伪装 TLS 指纹(模拟 Chrome 124)。对抗 JA3/JA4 阻断。 | -| `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | 允许 Claude Turnstile 的 Playwright 浏览器上下文忽略 HTTPS 证书错误。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ---------------------------------------- | ---------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | 启用上游调用的 SOCKS5 代理代理。可用 `false` 退出。 | +| `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | 客户端侧 | 客户端对 SOCKS5 可用性的感知。 | +| `HTTP_PROXY` | _(未设置)_ | Node.js 标准 | 上游调用的 HTTP 代理。 | +| `HTTPS_PROXY` | _(未设置)_ | Node.js 标准 | 上游调用的 HTTPS 代理。 | +| `ALL_PROXY` | _(未设置)_ | Node.js 标准 | 通用代理(支持 `socks5://`)。 | +| `NO_PROXY` | _(未设置)_ | Node.js 标准 | 逗号分隔的绕过代理的主机名/IP。 | +| `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS` | `32` | `open-sse/utils/proxyDispatcher.ts` | 每个缓存的 HTTP/SOCKS 代理调度器的最大并发套接字数。长连接 SSE 流(如 Codex `/v1/responses`)在多个请求共享同一账户级代理时需要不止一个连接。超过 `256` 的值会被截断。 | +| `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 握手(连接)超时,毫秒。当单个住宅网关主机高并发(如 100 个并发请求)时提高 — 在池饱和情况下实际握手可能超过 10 秒,即使代理可达,否则会显示为虚假的 `[Proxy Fast-Fail] Proxy unreachable`。上限为 `120000`。 | +| `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | 设为 `false`(默认)时,代理解析失败的请求会被**拒绝(fail-closed)**,而不会回退到直连 — 防止真实 IP 泄露。设为 `true` 可恢复旧版的 DIRECT 回退。 | +| `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | 使用 wreq-js 伪装 TLS 指纹(模拟 Chrome 124)。对抗 JA3/JA4 阻断。 | +| `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | 允许 Claude Turnstile 的 Playwright 浏览器上下文忽略 HTTPS 证书错误。 | ### 场景 -| 场景 | 配置 | -| --- | --- | -| **通过 SSH 隧道走 SOCKS5** | `ALL_PROXY=socks5://127.0.0.1:7890`, `ENABLE_SOCKS5_PROXY=true` | -| **企业 HTTP 代理** | `HTTP_PROXY=http://proxy.corp.com:3128`, `HTTPS_PROXY=http://proxy.corp.com:3128`, `NO_PROXY=localhost,internal.corp.com` | -| **反指纹** | `ENABLE_TLS_FINGERPRINT=true` — 需要 `wreq-js`(已包含) | -| **出口受控 / 无直连访问** | 保持 `PROXY_FAIL_OPEN=false`(默认)。代理不可用时请求直接失败,不会通过直连泄露。 | -| **旧版/开发 — 允许直连回退** | `PROXY_FAIL_OPEN=true`。恢复加固前行为:代理解析失败时使用直连连接。 | +| 场景 | 配置 | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| **通过 SSH 隧道走 SOCKS5** | `ALL_PROXY=socks5://127.0.0.1:7890`, `ENABLE_SOCKS5_PROXY=true` | +| **企业 HTTP 代理** | `HTTP_PROXY=http://proxy.corp.com:3128`, `HTTPS_PROXY=http://proxy.corp.com:3128`, `NO_PROXY=localhost,internal.corp.com` | +| **反指纹** | `ENABLE_TLS_FINGERPRINT=true` — 需要 `wreq-js`(已包含) | +| **出口受控 / 无直连访问** | 保持 `PROXY_FAIL_OPEN=false`(默认)。代理不可用时请求直接失败,不会通过直连泄露。 | +| **旧版/开发 — 允许直连回退** | `PROXY_FAIL_OPEN=true`。恢复加固前行为:代理解析失败时使用直连连接。 | > **注意(NVIDIA 校验绕过 — #3226):** NVIDIA 的 API Key 校验端点 > 在通过全局代理/TLS 修补 fetch(undici dispatcher → 504)路由时会卡住。 @@ -334,23 +335,23 @@ OmniRoute 提供两层防护:请求侧的注入扫描和响应侧的 PII 脱 控制 OmniRoute 如何发现和启动 CLI sidecar(Claude Code、Codex 等)。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `CLI_MODE` | `auto` | `src/shared/services/cliRuntime.ts` | `auto` = 搜索系统 PATH;`manual` = 仅使用显式路径。 | -| `CLI_EXTRA_PATHS` | _(未设置)_ | `src/shared/services/cliRuntime.ts` | 用于 CLI 二进制文件发现的额外 PATH 条目(冒号分隔)。 | -| `CLI_CONFIG_HOME` | _(未设置)_ | `src/shared/services/cliRuntime.ts` | 覆盖读取 CLI 配置(`~/.claude`、`~/.codex`)的主目录。 | -| `CLI_ALLOW_CONFIG_WRITES` | `false` | `src/shared/services/cliRuntime.ts` | 允许 OmniRoute 写入 CLI 配置文件(Token 刷新、会话数据)。 | -| `CLI_CLAUDE_BIN` | `claude` | `src/shared/services/cliRuntime.ts` | Claude CLI 二进制文件的自定义路径。 | -| `CLI_CODEX_BIN` | `codex` | `src/shared/services/cliRuntime.ts` | Codex CLI 二进制文件的自定义路径。 | -| `CLI_DROID_BIN` | `droid` | `src/shared/services/cliRuntime.ts` | Droid CLI 二进制文件的自定义路径。 | -| `CLI_OPENCLAW_BIN` | `openclaw` | `src/shared/services/cliRuntime.ts` | OpenClaw CLI 二进制文件的自定义路径。 | -| `CLI_CURSOR_BIN` | `agent` | `src/shared/services/cliRuntime.ts` | Cursor agent 二进制文件的自定义路径。 | -| `CLI_CLINE_BIN` | `cline` | `src/shared/services/cliRuntime.ts` | Cline CLI 二进制文件的自定义路径。 | -| `CLI_CONTINUE_BIN` | `cn` | `src/shared/services/cliRuntime.ts` | Continue CLI 二进制文件的自定义路径。 | -| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Qoder CLI 二进制文件的自定义路径。 | -| `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Qwen Code CLI 二进制文件的自定义路径。 | -| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Devin CLI 二进制文件的自定义路径(v3.8.0)。由 Windsurf/Devin executor 使用。 | -| `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent 主目录,OmniRoute 从此处读取/写入 Hermes CLI 配置。与 Hermes PowerShell 安装程序在 Windows 上设置的环境变量(`%LOCALAPPDATA%\hermes`)一致。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------------- | ----------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CLI_MODE` | `auto` | `src/shared/services/cliRuntime.ts` | `auto` = 搜索系统 PATH;`manual` = 仅使用显式路径。 | +| `CLI_EXTRA_PATHS` | _(未设置)_ | `src/shared/services/cliRuntime.ts` | 用于 CLI 二进制文件发现的额外 PATH 条目(冒号分隔)。 | +| `CLI_CONFIG_HOME` | _(未设置)_ | `src/shared/services/cliRuntime.ts` | 覆盖读取 CLI 配置(`~/.claude`、`~/.codex`)的主目录。 | +| `CLI_ALLOW_CONFIG_WRITES` | `false` | `src/shared/services/cliRuntime.ts` | 允许 OmniRoute 写入 CLI 配置文件(Token 刷新、会话数据)。 | +| `CLI_CLAUDE_BIN` | `claude` | `src/shared/services/cliRuntime.ts` | Claude CLI 二进制文件的自定义路径。 | +| `CLI_CODEX_BIN` | `codex` | `src/shared/services/cliRuntime.ts` | Codex CLI 二进制文件的自定义路径。 | +| `CLI_DROID_BIN` | `droid` | `src/shared/services/cliRuntime.ts` | Droid CLI 二进制文件的自定义路径。 | +| `CLI_OPENCLAW_BIN` | `openclaw` | `src/shared/services/cliRuntime.ts` | OpenClaw CLI 二进制文件的自定义路径。 | +| `CLI_CURSOR_BIN` | `agent` | `src/shared/services/cliRuntime.ts` | Cursor agent 二进制文件的自定义路径。 | +| `CLI_CLINE_BIN` | `cline` | `src/shared/services/cliRuntime.ts` | Cline CLI 二进制文件的自定义路径。 | +| `CLI_CONTINUE_BIN` | `cn` | `src/shared/services/cliRuntime.ts` | Continue CLI 二进制文件的自定义路径。 | +| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Qoder CLI 二进制文件的自定义路径。 | +| `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Qwen Code CLI 二进制文件的自定义路径。 | +| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Devin CLI 二进制文件的自定义路径(v3.8.0)。由 Windsurf/Devin executor 使用。 | +| `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent 主目录,OmniRoute 从此处读取/写入 Hermes CLI 配置。与 Hermes PowerShell 安装程序在 Windows 上设置的环境变量(`%LOCALAPPDATA%\hermes`)一致。 | ### Docker 示例 @@ -366,59 +367,59 @@ CLI_CLAUDE_BIN=/host-cli/bin/claude 以下变量调优 `omniroute` CLI 二进制文件自身的行为(而非上面的 sidecar 检测)。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_LANG` | _(系统)_ | `bin/cli/i18n.mjs` | 强制 CLI 输出语言。BCP-47 locale(如 `en`、`pt-BR`)。覆盖系统 locale 环境变量(LC_ALL, LC_MESSAGES)。 | -| `OMNIROUTE_SHOW_LOG` | _(未设置)_ | `bin/cli/runtime/processSupervisor.mjs` | 设为 `1` 可在受监管模式下将服务器 stdout/stderr 转发到终端。等同于 `omniroute serve` 的 `--log` 标志。 | -| `OMNIROUTE_CLI_TOKEN` | _(未设置)_ | `bin/cli/api.mjs` | 作为 `x-omniroute-cli-token` 头注入的机器认证 Token。在任务 8.12 中自动生成。 | -| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | CLI → 服务器请求的单次尝试 HTTP 超时(毫秒)。 | -| `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | 设为 `1` 可在 CLI 命令期间将重试/退避诊断信息打印到 stderr。 | -| `OMNIROUTE_PLUGIN_PATH` | _(未设置)_ | `bin/cli/plugins.mjs` | CLI 插件发现的自定义目录(`omniroute-cmd-*` 包)。未设置时默认为 `~/.omniroute/plugins/`。 | -| `OMNIROUTE_PLUGINS_ALLOW_EXEC` | `0` | `src/lib/plugins/pluginWorker.ts` | 设为 `1` 允许插件请求 `exec` 权限(从 Worker 沙箱中生成子进程)。仅供本地运维人员。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------------------ | ---------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_LANG` | _(系统)_ | `bin/cli/i18n.mjs` | 强制 CLI 输出语言。BCP-47 locale(如 `en`、`pt-BR`)。覆盖系统 locale 环境变量(LC_ALL, LC_MESSAGES)。 | +| `OMNIROUTE_SHOW_LOG` | _(未设置)_ | `bin/cli/runtime/processSupervisor.mjs` | 设为 `1` 可在受监管模式下将服务器 stdout/stderr 转发到终端。等同于 `omniroute serve` 的 `--log` 标志。 | +| `OMNIROUTE_CLI_TOKEN` | _(未设置)_ | `bin/cli/api.mjs` | 作为 `x-omniroute-cli-token` 头注入的机器认证 Token。在任务 8.12 中自动生成。 | +| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | CLI → 服务器请求的单次尝试 HTTP 超时(毫秒)。 | +| `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | 设为 `1` 可在 CLI 命令期间将重试/退避诊断信息打印到 stderr。 | +| `OMNIROUTE_PLUGIN_PATH` | _(未设置)_ | `bin/cli/plugins.mjs` | CLI 插件发现的自定义目录(`omniroute-cmd-*` 包)。未设置时默认为 `~/.omniroute/plugins/`。 | +| `OMNIROUTE_PLUGINS_ALLOW_EXEC` | `0` | `src/lib/plugins/pluginWorker.ts` | 设为 `1` 允许插件请求 `exec` 权限(从 Worker 沙箱中生成子进程)。仅供本地运维人员。 | --- ## 10. 内部 Agent 与 MCP 集成 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_BASE_URL` | 自动检测 | `open-sse/mcp-server/server.ts` | MCP/A2A 工具访问 OmniRoute 的显式 URL。覆盖 localhost 自动检测。 | -| `OMNIROUTE_API_KEY` | _(未设置)_ | MCP/A2A 模块 | 内部 MCP 工具和 A2A 技能调用的 API key。 | -| `OMNIROUTE_API_KEY_ID` | _(未设置)_ | `open-sse/mcp-server/audit.ts` | 用于 MCP 审计日志归属的 Key ID。 | -| `ROUTER_API_KEY` | _(未设置)_ | 旧版 | `OMNIROUTE_API_KEY` 的旧版别名。 | -| `OMNIROUTE_CONTEXT` | _(活跃上下文)_ | `bin/cli/program.mjs`, `bin/cli/api.mjs` | `omniroute` 命令的 CLI 远程模式上下文/配置文件;覆盖本地上下文存储中的活跃上下文。等同于 `--context `。 | -| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `true` | `open-sse/mcp-server/server.ts` | 对 MCP 工具调用强制执行基于权限域的访问控制。 | -| `OMNIROUTE_MCP_SCOPES` | _(全部)_ | `open-sse/mcp-server/server.ts` | 逗号分隔的权限域:`admin`、`combos`、`health`、`models`、`routing`、`budget`、`metrics`、`pricing`、`memory`、`skills`。 | -| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | `false` | `open-sse/mcp-server/descriptionCompressor.ts` | 在序列化清单之前压缩 MCP 工具描述。启用值:`1`、`true`、`on`。 | -| `OMNIROUTE_MCP_DESCRIPTION_COMPRESSION` | `rtk` | `open-sse/mcp-server/descriptionCompressor.ts` | 压缩算法/配置文件。禁用值:`0`、`false`、`off`。 | -| `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | 模型目錄同步间隔,小时。 | -| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | 服务商速率限制和配额轮询间隔。 | -| `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | 批量同步中连续 OAuth 配额获取之间的间隔(毫秒);OAuth 连接逐个获取以避免冲击上游。`0` 表示退出(并发)。 | -| `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | 真实用量事件后刷新服务商限制前的延迟(毫秒),给上游配额 API 时间记录消费。 | -| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | 禁用所有后台服务(同步、价格、模型刷新)。适用于 CI/测试。 | -| `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | _(未设置)_ | `src/lib/config/runtimeSettings.ts` | 在自动测试检测下强制运行后台任务。设为 `1` 可覆盖测试推断。 | -| `OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS` | `600000` | `src/lib/jobs/budgetResetJob.ts` | 预算重置检查频率(毫秒)。最低 `10000`。 | -| `OMNIROUTE_CONNECTION_RECOVERY_INTERVAL_MS` | `60000` | `src/lib/quota/connectionRecovery.ts` | 主动连接冷却恢复频率(毫秒):对瞬态 `rate_limited_until` 已过期的连接进行重新校验,脱离请求热路径。最低 `5000`。 | -| `OMNIROUTE_DISABLE_CONNECTION_RECOVERY` | `false` | `src/lib/quota/connectionRecovery.ts` | 禁用主动连接冷却恢复调度器(`getProviderCredentials` 中的惰性恢复仍然生效)。 | -| `OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS` | `1800000` | `src/lib/jobs/reasoningCacheCleanupJob.ts` | 推理缓存清理频率(毫秒)。最低 `60000`。 | -| `OMNIROUTE_CONFIG_HOT_RELOAD_MS` | `5000` | `src/lib/config/hotReload.ts` | 配置热加载的轮询间隔(毫秒)。低于 `1000` 会被拒绝。 | -| `OMNIROUTE_DISABLE_REDIS_AUTH_CACHE` | _(启用)_ | `src/lib/db/apiKeys.ts` | 设为 `1` 可绕过 Redis 支持的 API Key 认证缓存(强制走数据库读取)。 | -| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | `0` | `open-sse/services/compression/engines/rtk/filterLoader.ts` | 信任用户管理的 RTK 项目过滤器规则,无需严格的签名检查。 | -| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | 引导脚本在初始设置后设为 `true`。控制设置向导的可见性。 | -| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | 逃生口:允许请求体覆盖 Antigravity 项目字段。 | -| `ANTIGRAVITY_CREDITS` | _(未设置)_ | `open-sse/services/antigravityCredits.ts` | 覆盖 Antigravity 的广告剩余积分(测试/强制值)。 | -| `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | 覆盖自动检测本地登录导入的 Antigravity CLI (agy) Token 文件路径。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `OMNIROUTE_BASE_URL` | 自动检测 | `open-sse/mcp-server/server.ts` | MCP/A2A 工具访问 OmniRoute 的显式 URL。覆盖 localhost 自动检测。 | +| `OMNIROUTE_API_KEY` | _(未设置)_ | MCP/A2A 模块 | 内部 MCP 工具和 A2A 技能调用的 API key。 | +| `OMNIROUTE_API_KEY_ID` | _(未设置)_ | `open-sse/mcp-server/audit.ts` | 用于 MCP 审计日志归属的 Key ID。 | +| `ROUTER_API_KEY` | _(未设置)_ | 旧版 | `OMNIROUTE_API_KEY` 的旧版别名。 | +| `OMNIROUTE_CONTEXT` | _(活跃上下文)_ | `bin/cli/program.mjs`, `bin/cli/api.mjs` | `omniroute` 命令的 CLI 远程模式上下文/配置文件;覆盖本地上下文存储中的活跃上下文。等同于 `--context `。 | +| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `true` | `open-sse/mcp-server/server.ts` | 对 MCP 工具调用强制执行基于权限域的访问控制。 | +| `OMNIROUTE_MCP_SCOPES` | _(全部)_ | `open-sse/mcp-server/server.ts` | 逗号分隔的权限域:`admin`、`combos`、`health`、`models`、`routing`、`budget`、`metrics`、`pricing`、`memory`、`skills`。 | +| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | `false` | `open-sse/mcp-server/descriptionCompressor.ts` | 在序列化清单之前压缩 MCP 工具描述。启用值:`1`、`true`、`on`。 | +| `OMNIROUTE_MCP_DESCRIPTION_COMPRESSION` | `rtk` | `open-sse/mcp-server/descriptionCompressor.ts` | 压缩算法/配置文件。禁用值:`0`、`false`、`off`。 | +| `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | 模型目錄同步间隔,小时。 | +| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | 服务商速率限制和配额轮询间隔。 | +| `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | 批量同步中连续 OAuth 配额获取之间的间隔(毫秒);OAuth 连接逐个获取以避免冲击上游。`0` 表示退出(并发)。 | +| `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | 真实用量事件后刷新服务商限制前的延迟(毫秒),给上游配额 API 时间记录消费。 | +| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | 禁用所有后台服务(同步、价格、模型刷新)。适用于 CI/测试。 | +| `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | _(未设置)_ | `src/lib/config/runtimeSettings.ts` | 在自动测试检测下强制运行后台任务。设为 `1` 可覆盖测试推断。 | +| `OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS` | `600000` | `src/lib/jobs/budgetResetJob.ts` | 预算重置检查频率(毫秒)。最低 `10000`。 | +| `OMNIROUTE_CONNECTION_RECOVERY_INTERVAL_MS` | `60000` | `src/lib/quota/connectionRecovery.ts` | 主动连接冷却恢复频率(毫秒):对瞬态 `rate_limited_until` 已过期的连接进行重新校验,脱离请求热路径。最低 `5000`。 | +| `OMNIROUTE_DISABLE_CONNECTION_RECOVERY` | `false` | `src/lib/quota/connectionRecovery.ts` | 禁用主动连接冷却恢复调度器(`getProviderCredentials` 中的惰性恢复仍然生效)。 | +| `OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS` | `1800000` | `src/lib/jobs/reasoningCacheCleanupJob.ts` | 推理缓存清理频率(毫秒)。最低 `60000`。 | +| `OMNIROUTE_CONFIG_HOT_RELOAD_MS` | `5000` | `src/lib/config/hotReload.ts` | 配置热加载的轮询间隔(毫秒)。低于 `1000` 会被拒绝。 | +| `OMNIROUTE_DISABLE_REDIS_AUTH_CACHE` | _(启用)_ | `src/lib/db/apiKeys.ts` | 设为 `1` 可绕过 Redis 支持的 API Key 认证缓存(强制走数据库读取)。 | +| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | `0` | `open-sse/services/compression/engines/rtk/filterLoader.ts` | 信任用户管理的 RTK 项目过滤器规则,无需严格的签名检查。 | +| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | 引导脚本在初始设置后设为 `true`。控制设置向导的可见性。 | +| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | 逃生口:允许请求体覆盖 Antigravity 项目字段。 | +| `ANTIGRAVITY_CREDITS` | _(未设置)_ | `open-sse/services/antigravityCredits.ts` | 覆盖 Antigravity 的广告剩余积分(测试/强制值)。 | +| `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | 覆盖自动检测本地登录导入的 Antigravity CLI (agy) Token 文件路径。 | ### OAuth CLI 桥接(内部) -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_SERVER` | 自动检测 | `src/lib/oauth/config/index.ts` | CLI↔OmniRoute 认证桥接的服务器 URL。 | -| `OMNIROUTE_TOKEN` | _(未设置)_ | `src/lib/oauth/config/index.ts` | CLI 桥接的认证 Token。 | -| `OMNIROUTE_USER_ID` | `cli` | `src/lib/oauth/config/index.ts` | CLI 桥接会话的用户 ID。 | -| `SERVER_URL` | _(未设置)_ | `src/lib/oauth/config/index.ts` | `OMNIROUTE_SERVER` 的旧版别名。 | -| `CLI_TOKEN` | _(未设置)_ | `src/lib/oauth/config/index.ts` | `OMNIROUTE_TOKEN` 的旧版别名。 | -| `CLI_USER_ID` | _(未设置)_ | `src/lib/oauth/config/index.ts` | `OMNIROUTE_USER_ID` 的旧版别名。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------- | ---------- | ------------------------------- | ------------------------------------ | +| `OMNIROUTE_SERVER` | 自动检测 | `src/lib/oauth/config/index.ts` | CLI↔OmniRoute 认证桥接的服务器 URL。 | +| `OMNIROUTE_TOKEN` | _(未设置)_ | `src/lib/oauth/config/index.ts` | CLI 桥接的认证 Token。 | +| `OMNIROUTE_USER_ID` | `cli` | `src/lib/oauth/config/index.ts` | CLI 桥接会话的用户 ID。 | +| `SERVER_URL` | _(未设置)_ | `src/lib/oauth/config/index.ts` | `OMNIROUTE_SERVER` 的旧版别名。 | +| `CLI_TOKEN` | _(未设置)_ | `src/lib/oauth/config/index.ts` | `OMNIROUTE_TOKEN` 的旧版别名。 | +| `CLI_USER_ID` | _(未设置)_ | `src/lib/oauth/config/index.ts` | `OMNIROUTE_USER_ID` 的旧版别名。 | --- @@ -426,38 +427,38 @@ CLI_CLAUDE_BIN=/host-cli/bin/claude 用于 **localhost 开发** 的内置凭证。对于远程部署,请在各个服务商的开发者控制台中注册你自己的凭证。 -| 变量 | 服务商 | 备注 | -| --- | --- | --- | -| `CLAUDE_OAUTH_CLIENT_ID` | Claude Code (Anthropic) | 公共客户端 — 无需 secret。 | -| `CLAUDE_CODE_REDIRECT_URI` | Claude Code | 覆盖重定向 URI。默认值:`https://platform.claude.com/oauth/code/callback` | -| `CODEX_OAUTH_CLIENT_ID` | Codex / OpenAI | 公共客户端。 | -| `GEMINI_OAUTH_CLIENT_ID` | Gemini (Google) | 需要匹配的 `_SECRET`。 | -| `GEMINI_OAUTH_CLIENT_SECRET` | Gemini (Google) | — | -| `QWEN_OAUTH_CLIENT_ID` | Qwen (Alibaba) | 公共客户端。 | -| `KIMI_CODING_OAUTH_CLIENT_ID` | Kimi Coding (Moonshot) | 公共客户端。 | -| `ANTIGRAVITY_OAUTH_CLIENT_ID` | Antigravity (Google) | 需要匹配的 `_SECRET`。 | -| `ANTIGRAVITY_OAUTH_CLIENT_SECRET` | Antigravity (Google) | — | -| `GITHUB_OAUTH_CLIENT_ID` | GitHub Copilot | 公共客户端。 | -| `WINDSURF_FIREBASE_API_KEY` | Windsurf / Devin (v3.8) | Windsurf 安全 Token 服务用于刷新的公共 Firebase Web API key。客户端凭证(非密钥)。长期导入 Token 完全跳过此步骤。来源:从 Devin CLI 二进制文件中提取。 | -| `WINDSURF_API_KEY` | Windsurf / Devin (v3.8) | 无每个连接凭证时 `open-sse/executors/devin-cli.ts` 使用的 API key 回退。可选。 | -| `CLI_DEVIN_BIN` | Devin CLI (v3.8) | Devin CLI 二进制文件(`devin`)的自定义路径。由 `open-sse/executors/devin-cli.ts` 解析。 | -| `GITLAB_DUO_OAUTH_CLIENT_ID` | GitLab Duo (v3.8) | GitLab Duo 的 OAuth client ID。在 `https://gitlab.com/-/profile/applications` 注册应用,redirect URI 为 `/callback`,权限域为 `api, read_user, openid, profile, email`。回退到 `GITLAB_OAUTH_CLIENT_ID`。 | -| `GITLAB_DUO_OAUTH_CLIENT_SECRET` | GitLab Duo (v3.8) | GitLab Duo 的 OAuth client secret。可选 — PKCE 流程不需要 secret。回退到 `GITLAB_OAUTH_CLIENT_SECRET`。 | -| `GITLAB_DUO_BASE_URL` | GitLab Duo (v3.8) | 覆盖 GitLab 基础 URL(自托管 GitLab)。默认为 `https://gitlab.com`。回退到 `GITLAB_BASE_URL`。 | -| `GITLAB_BASE_URL` | GitLab Duo (v3.8) | `GITLAB_DUO_BASE_URL` 的旧版回退。在 `_DUO_` 变体未设置时使用。 | -| `GITLAB_OAUTH_CLIENT_ID` | GitLab Duo (v3.8) | `GITLAB_DUO_OAUTH_CLIENT_ID` 的旧版回退,由 `src/lib/oauth/constants/oauth.ts` 使用。 | -| `GITLAB_OAUTH_CLIENT_SECRET` | GitLab Duo (v3.8) | `GITLAB_DUO_OAUTH_CLIENT_SECRET` 的旧版回退,由 `src/lib/oauth/constants/oauth.ts` 使用。 | -| `QODER_OAUTH_CLIENT_SECRET` | Qoder | — | -| `QODER_OAUTH_AUTHORIZE_URL` | Qoder | 设置以启用 Qoder OAuth。 | -| `QODER_OAUTH_TOKEN_URL` | Qoder | — | -| `QODER_OAUTH_USERINFO_URL` | Qoder | — | -| `QODER_OAUTH_CLIENT_ID` | Qoder | — | -| `QODER_PERSONAL_ACCESS_TOKEN` | Qoder | 直接 API key 回退(绕过 OAuth)。 | -| `QODER_CLI_WORKSPACE` | Qoder | Qoder CLI 的 workspace ID。 | -| `OMNIROUTE_QODER_WORKSPACE` | Qoder | `QODER_CLI_WORKSPACE` 的别名。 | -| `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | 作为 `validated` 发送到 `/api/chat` 的前端 `tk` Token。当 Blackbox 强制 Token 匹配时必需;否则 OmniRoute 回退到随机 UUID。参阅 issue #2252。 | -| `VISION_BRIDGE_BASE_URL` | Vision Bridge 安全护栏 | 非 Anthropic 视觉桥接调用的 OpenAI 兼容基础 URL。默认为旧版 OpenAI URL 或 api.openai.com。指向 OmniRoute 的 `/v1` 自循环或任意 OpenAI 兼容端点(Gemini OpenAI-compat、OpenRouter)。Issue #2232。 | -| `VISION_BRIDGE_API_KEY` | Vision Bridge 安全护栏 | 上面 URL 的 API key。对于非 Anthropic 视觉桥接调用,覆盖每个服务商的 OpenAI / Google 环境变量。Anthropic 模型保留其专用的 Anthropic key 路径。Issue #2232。 | +| 变量 | 服务商 | 备注 | +| --------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CLAUDE_OAUTH_CLIENT_ID` | Claude Code (Anthropic) | 公共客户端 — 无需 secret。 | +| `CLAUDE_CODE_REDIRECT_URI` | Claude Code | 覆盖重定向 URI。默认值:`https://platform.claude.com/oauth/code/callback` | +| `CODEX_OAUTH_CLIENT_ID` | Codex / OpenAI | 公共客户端。 | +| `GEMINI_OAUTH_CLIENT_ID` | Gemini (Google) | 需要匹配的 `_SECRET`。 | +| `GEMINI_OAUTH_CLIENT_SECRET` | Gemini (Google) | — | +| `QWEN_OAUTH_CLIENT_ID` | Qwen (Alibaba) | 公共客户端。 | +| `KIMI_CODING_OAUTH_CLIENT_ID` | Kimi Coding (Moonshot) | 公共客户端。 | +| `ANTIGRAVITY_OAUTH_CLIENT_ID` | Antigravity (Google) | 需要匹配的 `_SECRET`。 | +| `ANTIGRAVITY_OAUTH_CLIENT_SECRET` | Antigravity (Google) | — | +| `GITHUB_OAUTH_CLIENT_ID` | GitHub Copilot | 公共客户端。 | +| `WINDSURF_FIREBASE_API_KEY` | Windsurf / Devin (v3.8) | Windsurf 安全 Token 服务用于刷新的公共 Firebase Web API key。客户端凭证(非密钥)。长期导入 Token 完全跳过此步骤。来源:从 Devin CLI 二进制文件中提取。 | +| `WINDSURF_API_KEY` | Windsurf / Devin (v3.8) | 无每个连接凭证时 `open-sse/executors/devin-cli.ts` 使用的 API key 回退。可选。 | +| `CLI_DEVIN_BIN` | Devin CLI (v3.8) | Devin CLI 二进制文件(`devin`)的自定义路径。由 `open-sse/executors/devin-cli.ts` 解析。 | +| `GITLAB_DUO_OAUTH_CLIENT_ID` | GitLab Duo (v3.8) | GitLab Duo 的 OAuth client ID。在 `https://gitlab.com/-/profile/applications` 注册应用,redirect URI 为 `/callback`,权限域为 `api, read_user, openid, profile, email`。回退到 `GITLAB_OAUTH_CLIENT_ID`。 | +| `GITLAB_DUO_OAUTH_CLIENT_SECRET` | GitLab Duo (v3.8) | GitLab Duo 的 OAuth client secret。可选 — PKCE 流程不需要 secret。回退到 `GITLAB_OAUTH_CLIENT_SECRET`。 | +| `GITLAB_DUO_BASE_URL` | GitLab Duo (v3.8) | 覆盖 GitLab 基础 URL(自托管 GitLab)。默认为 `https://gitlab.com`。回退到 `GITLAB_BASE_URL`。 | +| `GITLAB_BASE_URL` | GitLab Duo (v3.8) | `GITLAB_DUO_BASE_URL` 的旧版回退。在 `_DUO_` 变体未设置时使用。 | +| `GITLAB_OAUTH_CLIENT_ID` | GitLab Duo (v3.8) | `GITLAB_DUO_OAUTH_CLIENT_ID` 的旧版回退,由 `src/lib/oauth/constants/oauth.ts` 使用。 | +| `GITLAB_OAUTH_CLIENT_SECRET` | GitLab Duo (v3.8) | `GITLAB_DUO_OAUTH_CLIENT_SECRET` 的旧版回退,由 `src/lib/oauth/constants/oauth.ts` 使用。 | +| `QODER_OAUTH_CLIENT_SECRET` | Qoder | — | +| `QODER_OAUTH_AUTHORIZE_URL` | Qoder | 设置以启用 Qoder OAuth。 | +| `QODER_OAUTH_TOKEN_URL` | Qoder | — | +| `QODER_OAUTH_USERINFO_URL` | Qoder | — | +| `QODER_OAUTH_CLIENT_ID` | Qoder | — | +| `QODER_PERSONAL_ACCESS_TOKEN` | Qoder | 直接 API key 回退(绕过 OAuth)。 | +| `QODER_CLI_WORKSPACE` | Qoder | Qoder CLI 的 workspace ID。 | +| `OMNIROUTE_QODER_WORKSPACE` | Qoder | `QODER_CLI_WORKSPACE` 的别名。 | +| `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | 作为 `validated` 发送到 `/api/chat` 的前端 `tk` Token。当 Blackbox 强制 Token 匹配时必需;否则 OmniRoute 回退到随机 UUID。参阅 issue #2252。 | +| `VISION_BRIDGE_BASE_URL` | Vision Bridge 安全护栏 | 非 Anthropic 视觉桥接调用的 OpenAI 兼容基础 URL。默认为旧版 OpenAI URL 或 api.openai.com。指向 OmniRoute 的 `/v1` 自循环或任意 OpenAI 兼容端点(Gemini OpenAI-compat、OpenRouter)。Issue #2232。 | +| `VISION_BRIDGE_API_KEY` | Vision Bridge 安全护栏 | 上面 URL 的 API key。对于非 Anthropic 视觉桥接调用,覆盖每个服务商的 OpenAI / Google 环境变量。Anthropic 模型保留其专用的 Anthropic key 路径。Issue #2232。 | > [!WARNING] > @@ -478,20 +479,20 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] > **来源:** `open-sse/executors/base.ts` → `buildHeaders()` -| 变量 | 默认值 | 何时更新 | -| --- | --- | --- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.207 (external, cli)` | Anthropic 发布新的 CLI 版本时 | -| `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | 设为 `1`/`true` 可将第三方测试工具的工具名称原封不动地转发到 Anthropic 的两条绑定路径上(原生 OAuth 和 CLIProxyAPI)。默认情况下 executor 会将非 Claude Code 的工具名称确定性别名化(Claude Code 存在规范映射的用规范映射,否则用 PascalCase),并通过 `_toolNameMap` 在响应中还原,从而确保带 snake_case 工具的测试工具不会被视为指纹化第三方客户端而被拒绝。仅供调试。 | -| `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | OpenAI 更新 Codex CLI 时 | -| `CODEX_CLIENT_VERSION` | `0.131.0` | 独立于完整 UA 字符串覆盖 Codex 客户端版本 | -| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` | GitHub Copilot Chat 更新时 | -| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | Antigravity IDE 更新时 | -| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | Kiro IDE 更新时 | -| `KIRO_OAUTH_CLIENT_ID` | `kiro-cli` | 覆盖 Kiro social device-code `clientId`(公共 ID) | -| `KIRO_VERIFY_FULL_CRC` | `false` | 启用:在 Kiro 事件流上全帧消息 CRC 校验(调试损坏的流) | -| `QODER_USER_AGENT` | `Qoder-Cli` | Qoder CLI 更新时 | -| `QWEN_USER_AGENT` | `QwenCode/0.19.3 (linux; x64)` | Qwen Code 更新时 | -| `CURSOR_USER_AGENT` | `Cursor/3.3` | Cursor 更新时 | +| 变量 | 默认值 | 何时更新 | +| -------------------------------- | --------------------------------------------- | ------------------------------------------------------- | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | Anthropic 发布新的 CLI 版本时 | +| `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | 设为 `1`/`true` 可将第三方测试工具的工具名称原封不动地转发到 Anthropic 的两条绑定路径上(原生 OAuth 和 CLIProxyAPI)。默认情况下 executor 会将非 Claude Code 的工具名称确定性别名化(Claude Code 存在规范映射的用规范映射,否则用 PascalCase),并通过 `_toolNameMap` 在响应中还原,从而确保带 snake_case 工具的测试工具不会被视为指纹化第三方客户端而被拒绝。仅供调试。 | +| `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | OpenAI 更新 Codex CLI 时 | +| `CODEX_CLIENT_VERSION` | `0.131.0` | 独立于完整 UA 字符串覆盖 Codex 客户端版本 | +| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` | GitHub Copilot Chat 更新时 | +| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | Antigravity IDE 更新时 | +| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | Kiro IDE 更新时 | +| `KIRO_OAUTH_CLIENT_ID` | `kiro-cli` | 覆盖 Kiro social device-code `clientId`(公共 ID) | +| `KIRO_VERIFY_FULL_CRC` | `false` | 启用:在 Kiro 事件流上全帧消息 CRC 校验(调试损坏的流) | +| `QODER_USER_AGENT` | `Qoder-Cli` | Qoder CLI 更新时 | +| `QWEN_USER_AGENT` | `QwenCode/0.19.3 (linux; x64)` | Qwen Code 更新时 | +| `CURSOR_USER_AGENT` | `Cursor/3.3` | Cursor 更新时 | > [!TIP] > 你可以通过 `{PROVIDER_ID}_USER_AGENT` 模式为 **任意** 服务商添加 User-Agent 覆盖。Executor 会动态构建环境变量名。 @@ -506,30 +507,30 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] ### 按服务商 -| 变量 | 激活方式 | 效果 | -| --- | --- | --- | -| `CLI_COMPAT_CODEX` | `=1` | 模拟 Codex CLI 请求签名 | -| `CLI_COMPAT_CLAUDE` | `=1` | 模拟 Claude Code 请求签名 | -| `CLI_COMPAT_GITHUB` | `=1` | 模拟 GitHub Copilot 请求签名 | -| `CLI_COMPAT_ANTIGRAVITY` | `=1` | 模拟 Antigravity 请求签名 | -| `CLI_COMPAT_CURSOR` | `=1` | 模拟 Cursor 请求签名 | -| `CLI_COMPAT_KIMI_CODING` | `=1` | 模拟 Kimi Coding 请求签名 | -| `CLI_COMPAT_KILOCODE` | `=1` | 模拟 Kilo Code 请求签名 | -| `CLI_COMPAT_CLINE` | `=1` | 模拟 Cline 请求签名 | -| `CLI_COMPAT_QWEN` | `=1` | 模拟 Qwen Code 请求签名 | +| 变量 | 激活方式 | 效果 | +| ------------------------ | -------- | ---------------------------- | +| `CLI_COMPAT_CODEX` | `=1` | 模拟 Codex CLI 请求签名 | +| `CLI_COMPAT_CLAUDE` | `=1` | 模拟 Claude Code 请求签名 | +| `CLI_COMPAT_GITHUB` | `=1` | 模拟 GitHub Copilot 请求签名 | +| `CLI_COMPAT_ANTIGRAVITY` | `=1` | 模拟 Antigravity 请求签名 | +| `CLI_COMPAT_CURSOR` | `=1` | 模拟 Cursor 请求签名 | +| `CLI_COMPAT_KIMI_CODING` | `=1` | 模拟 Kimi Coding 请求签名 | +| `CLI_COMPAT_KILOCODE` | `=1` | 模拟 Kilo Code 请求签名 | +| `CLI_COMPAT_CLINE` | `=1` | 模拟 Cline 请求签名 | +| `CLI_COMPAT_QWEN` | `=1` | 模拟 Qwen Code 请求签名 | ### 全局 -| 变量 | 激活方式 | 效果 | -| --- | --- | --- | -| `CLI_COMPAT_ALL` | `=1` | 一次性为**所有**服务商启用指纹兼容。 | +| 变量 | 激活方式 | 效果 | +| ---------------- | -------- | ------------------------------------ | +| `CLI_COMPAT_ALL` | `=1` | 一次性为**所有**服务商启用指纹兼容。 | ### Kimi Coding CLI 身份标识覆盖 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `KIMI_CLI_VERSION` | `1.36.0` | `src/lib/oauth/providers/kimi-coding.ts` | 覆盖 OAuth/API 调用时发送的 Kimi CLI 版本。 | -| `KIMI_CODING_DEVICE_ID` | _(已捕获的默认值)_ | `src/lib/oauth/providers/kimi-coding.ts` | 覆盖客户端头中使用的已捕获 Kimi 设备 ID。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------- | ------------------ | ---------------------------------------- | ------------------------------------------- | +| `KIMI_CLI_VERSION` | `1.36.0` | `src/lib/oauth/providers/kimi-coding.ts` | 覆盖 OAuth/API 调用时发送的 Kimi CLI 版本。 | +| `KIMI_CODING_DEVICE_ID` | _(已捕获的默认值)_ | `src/lib/oauth/providers/kimi-coding.ts` | 覆盖客户端头中使用的已捕获 Kimi 设备 ID。 | > [!NOTE] > 此功能与 User-Agent 覆盖(§12)协同工作。指纹系统处理头部排序和 body 字段排序,User-Agent 覆盖处理具体的 UA 字符串。两者可独立启用。 @@ -544,10 +545,10 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] 识别模式:`{PROVIDER_ID}_API_KEY` -| 变量 | 服务商 | -| --- | --- | -| `DEEPSEEK_API_KEY` | DeepSeek | -| `NVIDIA_API_KEY` | NVIDIA NIM | +| 变量 | 服务商 | +| ------------------ | ---------- | +| `DEEPSEEK_API_KEY` | DeepSeek | +| `NVIDIA_API_KEY` | NVIDIA NIM | > [!NOTE] > 在 v3.8.0 中移除了 Groq、xAI、Mistral、Perplexity、Together AI、Fireworks、Cerebras、Cohere、Nebius 和 Qianfan 的静态 `${PROVIDER}_API_KEY` 条目,因为运行时不再读取它们 — 这些服务商仅通过 Dashboard / `data/provider-credentials.json` / 加密数据库获取凭证。参阅本文档末尾的 _审计:已移除/废弃的变量_ 部分了解迁移路径。 @@ -580,36 +581,36 @@ REQUEST_TIMEOUT_MS (全局覆盖) └── API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS (默认值: 0 = 禁用) ``` -| 变量 | 默认值 | 说明 | -| --- | --- | --- | -| `REQUEST_TIMEOUT_MS` | _(未设置)_ | 全局快捷方式 — 覆盖 `FETCH_TIMEOUT_MS` 和 `STREAM_IDLE_TIMEOUT_MS` 两者的默认值。 | -| `FETCH_TIMEOUT_MS` | `600000` | 上游服务商调用的 HTTP 请求总超时。 | -| `STREAM_IDLE_TIMEOUT_MS` | `600000` | SSE 块之间的最长静默时间,超时则中止。扩展推理模型很少暂停超过 90 秒。 | -| `STREAM_READINESS_TIMEOUT_MS` | `80000` | 接收第一个非 ping SSE 事件的超时时间。设置时继承 `REQUEST_TIMEOUT_MS`。 | -| `OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS` | _(关闭)_ | 剥离会导致 OpenAI SDK 的 `responses.stream()` 以 502 报错的非标准 `codex.*` SSE 事件(如 `codex.rate_limits`)。设为 `true`/`1`/`yes` 可启用。 | -| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | 接收响应头的超时时间。 | -| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | 接收完整响应体的超时时间。 | -| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP 连接建立超时。 | -| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive 套接字空闲超时。 | -| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS 指纹代理(wreq-js)超时。 | -| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | `/v1` 桥接请求的代理跳超时。 | -| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `300000` | 桥接的服务器请求总超时。 | -| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | 通过桥接发送响应头的超时时间。 | -| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | 桥接 keep-alive 空闲超时。 | -| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | 原始套接字超时(0 = 禁用)。 | -| `SHUTDOWN_TIMEOUT_MS` | `30000` | SIGTERM/SIGINT 后强制退出前的宽限期。 | -| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | `FETCH_TIMEOUT_MS` 未设置时 `src/shared/utils/fetchTimeout.ts` 使用的回退值。 | -| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`chatgptTlsClient.ts`)。 | -| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | -| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000`(30 秒) | ChatGPT TLS sidecar(`chatgptTlsClient.ts`)在中止死流前等待第一个流式字节的最大时间。如果上游冷启动超过窗口则提高。 | -| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`claudeTlsClient.ts`)。 | -| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | -| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`perplexityTlsClient.ts`)。 | -| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | -| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`grokTlsClient.ts`)。 | -| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | -| `OMNIROUTE_BROWSER_POOL` | `on` | 用于浏览器端 Web Cookie 聊天的共享 Playwright 浏览器池(`browserPool.ts`);设为 `off` 可禁用。 | -| `WEB_COOKIE_USE_BROWSER` | `0` | 将 Web Cookie 聊天请求选择进入浏览器端路径(`browserBackedChat.ts`);`1` 启用。 | +| 变量 | 默认值 | 说明 | +| ------------------------------------------------ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | _(未设置)_ | 全局快捷方式 — 覆盖 `FETCH_TIMEOUT_MS` 和 `STREAM_IDLE_TIMEOUT_MS` 两者的默认值。 | +| `FETCH_TIMEOUT_MS` | `600000` | 上游服务商调用的 HTTP 请求总超时。 | +| `STREAM_IDLE_TIMEOUT_MS` | `600000` | SSE 块之间的最长静默时间,超时则中止。扩展推理模型很少暂停超过 90 秒。 | +| `STREAM_READINESS_TIMEOUT_MS` | `80000` | 接收第一个非 ping SSE 事件的超时时间。设置时继承 `REQUEST_TIMEOUT_MS`。 | +| `OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS` | _(关闭)_ | 剥离会导致 OpenAI SDK 的 `responses.stream()` 以 502 报错的非标准 `codex.*` SSE 事件(如 `codex.rate_limits`)。设为 `true`/`1`/`yes` 可启用。 | +| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | 接收响应头的超时时间。 | +| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | 接收完整响应体的超时时间。 | +| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP 连接建立超时。 | +| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive 套接字空闲超时。 | +| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS 指纹代理(wreq-js)超时。 | +| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | `/v1` 桥接请求的代理跳超时。 | +| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `300000` | 桥接的服务器请求总超时。 | +| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | 通过桥接发送响应头的超时时间。 | +| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | 桥接 keep-alive 空闲超时。 | +| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | 原始套接字超时(0 = 禁用)。 | +| `SHUTDOWN_TIMEOUT_MS` | `30000` | SIGTERM/SIGINT 后强制退出前的宽限期。 | +| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | `FETCH_TIMEOUT_MS` 未设置时 `src/shared/utils/fetchTimeout.ts` 使用的回退值。 | +| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`chatgptTlsClient.ts`)。 | +| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | +| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000`(30 秒) | ChatGPT TLS sidecar(`chatgptTlsClient.ts`)在中止死流前等待第一个流式字节的最大时间。如果上游冷启动超过窗口则提高。 | +| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`claudeTlsClient.ts`)。 | +| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | +| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`perplexityTlsClient.ts`)。 | +| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | +| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | bogdanfinn/tls-client koffi 绑定的线路级超时(`grokTlsClient.ts`)。 | +| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | 原生绑定卡住时在线路超时之上添加的 JS 侧宽恕时间。 | +| `OMNIROUTE_BROWSER_POOL` | `on` | 用于浏览器端 Web Cookie 聊天的共享 Playwright 浏览器池(`browserPool.ts`);设为 `off` 可禁用。 | +| `WEB_COOKIE_USE_BROWSER` | `0` | 将 Web Cookie 聊天请求选择进入浏览器端路径(`browserBackedChat.ts`);`1` 启用。 | Combo 目标尝试继承已解析的上游请求超时(`FETCH_TIMEOUT_MS`,或当它提供 fetch 默认值时的 `REQUEST_TIMEOUT_MS`)。仅在 Combo 中设置 `targetTimeoutMs`、Combo 默认值或服务商覆盖值以加快 Combo 回退;超过当前上游超时的值会被截断到上游超时。 @@ -617,24 +618,24 @@ Combo 目标尝试继承已解析的上游请求超时(`FETCH_TIMEOUT_MS`, 服务商级熔断器调优。默认值反映了 v3.6 以来用于 500+ 连接的缩放值。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_CIRCUIT_BREAKER_OAUTH_THRESHOLD` | `8` | `open-sse/config/constants.ts` | OAuth 服务商的连续失败阈值,超过则熔断器跳开。 | -| `OMNIROUTE_CIRCUIT_BREAKER_OAUTH_RESET_MS` | `60000` | `open-sse/config/constants.ts` | OAuth 服务商熔断器的重置窗口(毫秒)。 | -| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_THRESHOLD` | `12` | `open-sse/config/constants.ts` | API-key 服务商的连续失败阈值。 | -| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS` | `30000` | `open-sse/config/constants.ts` | API-key 服务商熔断器的重置窗口(毫秒)。 | -| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD` | `2` | `open-sse/config/constants.ts` | 本地服务商(Ollama、LM Studio 等)的连续失败阈值。 | -| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS` | `15000` | `open-sse/config/constants.ts` | 本地服务商熔断器的重置窗口(毫秒)。 | -| `PIN_DROP_BACKOFF_LEVEL` | `2` | `open-sse/services/combo.ts` | 退避深度,达到此值后上下文缓存 pin 的服务商被视为持续不健康,pin 被丢弃以进行故障转移。 | -| `PIN_DROP_GRACE_MS` | `20000` | `open-sse/services/combo.ts` | 防抖窗口(毫秒),在丢弃上下文缓存 pin 之前容忍短暂的瞬态冷却。 | +| 变量 | 默认值 | 源文件 | 说明 | +| --------------------------------------------- | ------- | ------------------------------ | --------------------------------------------------------------------------------------- | +| `OMNIROUTE_CIRCUIT_BREAKER_OAUTH_THRESHOLD` | `8` | `open-sse/config/constants.ts` | OAuth 服务商的连续失败阈值,超过则熔断器跳开。 | +| `OMNIROUTE_CIRCUIT_BREAKER_OAUTH_RESET_MS` | `60000` | `open-sse/config/constants.ts` | OAuth 服务商熔断器的重置窗口(毫秒)。 | +| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_THRESHOLD` | `12` | `open-sse/config/constants.ts` | API-key 服务商的连续失败阈值。 | +| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS` | `30000` | `open-sse/config/constants.ts` | API-key 服务商熔断器的重置窗口(毫秒)。 | +| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD` | `2` | `open-sse/config/constants.ts` | 本地服务商(Ollama、LM Studio 等)的连续失败阈值。 | +| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS` | `15000` | `open-sse/config/constants.ts` | 本地服务商熔断器的重置窗口(毫秒)。 | +| `PIN_DROP_BACKOFF_LEVEL` | `2` | `open-sse/services/combo.ts` | 退避深度,达到此值后上下文缓存 pin 的服务商被视为持续不健康,pin 被丢弃以进行故障转移。 | +| `PIN_DROP_GRACE_MS` | `20000` | `open-sse/services/combo.ts` | 防抖窗口(毫秒),在丢弃上下文缓存 pin 之前容忍短暂的瞬态冷却。 | ### 场景 -| 场景 | 配置 | -| --- | --- | -| **长时间代码生成** | `REQUEST_TIMEOUT_MS=900000`(15 分钟) | -| **生产 API 快速失败** | `API_BRIDGE_PROXY_TIMEOUT_MS=10000` | -| **扩展推理模型** | `STREAM_IDLE_TIMEOUT_MS=300000`(块间 5 分钟) | +| 场景 | 配置 | +| --------------------- | ---------------------------------------------- | +| **长时间代码生成** | `REQUEST_TIMEOUT_MS=900000`(15 分钟) | +| **生产 API 快速失败** | `API_BRIDGE_PROXY_TIMEOUT_MS=10000` | +| **扩展推理模型** | `STREAM_IDLE_TIMEOUT_MS=300000`(块间 5 分钟) | --- @@ -642,65 +643,65 @@ Combo 目标尝试继承已解析的上游请求超时(`FETCH_TIMEOUT_MS`, 日志系统同时写入 stdout 和轮转日志文件。所有配置由 `src/lib/logEnv.ts` 读取。 -| 变量 | 默认值 | 说明 | -| --- | --- | --- | -| `APP_LOG_LEVEL` | `info` | 最低日志级别:`debug`、`info`、`warn`、`error`。 | -| `APP_LOG_FORMAT` | `text` | 输出格式:`text`(人类可读)或 `json`(结构化)。 | -| `APP_LOG_TO_FILE` | `true` | 同时写入日志文件和 stdout。 | -| `APP_LOG_FILE_PATH` | `logs/application/app.log` | 日志文件路径(相对于项目根目录或 `DATA_DIR`)。 | -| `APP_LOG_MAX_FILE_SIZE` | `50M` | 轮转前的最大文件大小。可接受:`50M`、`1G`、`512K` 或纯字节数。 | -| `APP_LOG_RETENTION_DAYS` | `7` | 保留轮转后应用日志文件的天数。 | -| `APP_LOG_MAX_FILES` | `20` | 最大轮转日志文件备份数。 | -| `CALL_LOG_RETENTION_DAYS` | `7` | 在数据库中保留请求/调用日志条目的天数。 | -| `CALL_LOG_MAX_ENTRIES` | `10000` | 内存缓冲区中最大调用日志条目数。 | -| `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | `call_logs` SQLite 表在清理前的最大行数。 | -| `MAX_PENDING_REQUEST_AGE_MS` | `3600000`(1 小时) | 孤立活跃请求日志条目在内存清理前的最大生存时间。 | -| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `true` | 当 `call_log_pipeline_enabled=true` 时在 pipeline artifacts 中存储流块。 | -| `CALL_LOG_PIPELINE_MAX_SIZE_KB` | `512` | 当 `call_log_pipeline_enabled=true` 时的最大 pipeline 调用日志工件大小(KB)。 | -| `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | `proxy_logs` SQLite 表在清理前的最大行数。 | -| `APP_LOG_ROTATION_CHECK_INTERVAL_MS` | `60000`(1 分钟) | `src/lib/logRotation.ts` 重新检查活跃日志文件大小的频率。 | -| `CHAT_LOG_TEXT_LIMIT` | `65536` | 聊天日志工件中保留的最大字符串长度(默认 64 KB)。 | -| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `24` | 截断聊天日志载荷时从尾部保留的数组项数量。 | -| `CHAT_LOG_MAX_DEPTH` | `6` | 聊天日志载荷被截断前的最大嵌套深度。 | -| `CHAT_DEBUG_FILE` | `false` | 设为 true 时,`serializeArtifactForStorage` 跳过基于大小的截断。仅供调试。 | +| 变量 | 默认值 | 说明 | +| ----------------------------------------- | -------------------------- | ------------------------------------------------------------------------------ | +| `APP_LOG_LEVEL` | `info` | 最低日志级别:`debug`、`info`、`warn`、`error`。 | +| `APP_LOG_FORMAT` | `text` | 输出格式:`text`(人类可读)或 `json`(结构化)。 | +| `APP_LOG_TO_FILE` | `true` | 同时写入日志文件和 stdout。 | +| `APP_LOG_FILE_PATH` | `logs/application/app.log` | 日志文件路径(相对于项目根目录或 `DATA_DIR`)。 | +| `APP_LOG_MAX_FILE_SIZE` | `50M` | 轮转前的最大文件大小。可接受:`50M`、`1G`、`512K` 或纯字节数。 | +| `APP_LOG_RETENTION_DAYS` | `7` | 保留轮转后应用日志文件的天数。 | +| `APP_LOG_MAX_FILES` | `20` | 最大轮转日志文件备份数。 | +| `CALL_LOG_RETENTION_DAYS` | `7` | 在数据库中保留请求/调用日志条目的天数。 | +| `CALL_LOG_MAX_ENTRIES` | `10000` | 内存缓冲区中最大调用日志条目数。 | +| `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | `call_logs` SQLite 表在清理前的最大行数。 | +| `MAX_PENDING_REQUEST_AGE_MS` | `3600000`(1 小时) | 孤立活跃请求日志条目在内存清理前的最大生存时间。 | +| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `true` | 当 `call_log_pipeline_enabled=true` 时在 pipeline artifacts 中存储流块。 | +| `CALL_LOG_PIPELINE_MAX_SIZE_KB` | `512` | 当 `call_log_pipeline_enabled=true` 时的最大 pipeline 调用日志工件大小(KB)。 | +| `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | `proxy_logs` SQLite 表在清理前的最大行数。 | +| `APP_LOG_ROTATION_CHECK_INTERVAL_MS` | `60000`(1 分钟) | `src/lib/logRotation.ts` 重新检查活跃日志文件大小的频率。 | +| `CHAT_LOG_TEXT_LIMIT` | `65536` | 聊天日志工件中保留的最大字符串长度(默认 64 KB)。 | +| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `24` | 截断聊天日志载荷时从尾部保留的数组项数量。 | +| `CHAT_LOG_MAX_DEPTH` | `6` | 聊天日志载荷被截断前的最大嵌套深度。 | +| `CHAT_DEBUG_FILE` | `false` | 设为 true 时,`serializeArtifactForStorage` 跳过基于大小的截断。仅供调试。 | --- ## 17. 内存优化 -| 变量 | 默认值 | 说明 | -| --- | --- | --- | -| `OMNIROUTE_MEMORY_MB` | _自动_ | 运行时 V8 堆限制(MB)。未设置时动态校准(约 35% 系统内存,限制在 `[512, 4096]`);`512` 仅是总内存不可读取时的下限。显式设置以覆盖。Docker 独立运行和 `omniroute serve` 用它设置 `--max-old-space-size`。 | -| `PROMPT_CACHE_MAX_SIZE` | `50` | 最大缓存系统提示条目数。 | -| `PROMPT_CACHE_MAX_BYTES` | `2097152`(2 MB) | 最大提示缓存总大小。 | -| `PROMPT_CACHE_TTL_MS` | `300000`(5 分钟) | 提示缓存条目 TTL。 | -| `SEMANTIC_CACHE_MAX_SIZE` | `100` | 最大缓存 temperature=0 响应数。 | -| `SEMANTIC_CACHE_MAX_BYTES` | `4194304`(4 MB) | 最大语义缓存总大小。 | -| `SEMANTIC_CACHE_TTL_MS` | `1800000`(30 分钟) | 语义缓存条目 TTL。 | -| `STREAM_HISTORY_MAX` | `50` | Dashboard 实时视图缓冲区中最大近期流事件数。 | -| `CONTEXT_LENGTH_DEFAULT` | `128000` | 没有显式配置的模型的全局回退最大上下文长度。 | -| `USAGE_TOKEN_BUFFER` | `100` | 跟踪用量配额时保留的额外 Token 余量。 | +| 变量 | 默认值 | 说明 | +| -------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_MEMORY_MB` | _自动_ | 运行时 V8 堆限制(MB)。未设置时动态校准(约 35% 系统内存,限制在 `[512, 4096]`);`512` 仅是总内存不可读取时的下限。显式设置以覆盖。Docker 独立运行和 `omniroute serve` 用它设置 `--max-old-space-size`。 | +| `PROMPT_CACHE_MAX_SIZE` | `50` | 最大缓存系统提示条目数。 | +| `PROMPT_CACHE_MAX_BYTES` | `2097152`(2 MB) | 最大提示缓存总大小。 | +| `PROMPT_CACHE_TTL_MS` | `300000`(5 分钟) | 提示缓存条目 TTL。 | +| `SEMANTIC_CACHE_MAX_SIZE` | `100` | 最大缓存 temperature=0 响应数。 | +| `SEMANTIC_CACHE_MAX_BYTES` | `4194304`(4 MB) | 最大语义缓存总大小。 | +| `SEMANTIC_CACHE_TTL_MS` | `1800000`(30 分钟) | 语义缓存条目 TTL。 | +| `STREAM_HISTORY_MAX` | `50` | Dashboard 实时视图缓冲区中最大近期流事件数。 | +| `CONTEXT_LENGTH_DEFAULT` | `128000` | 没有显式配置的模型的全局回退最大上下文长度。 | +| `USAGE_TOKEN_BUFFER` | `100` | 跟踪用量配额时保留的额外 Token 余量。 | ### 压缩 -| 变量 | 默认值 | 说明 | -| --- | --- | --- | +| 变量 | 默认值 | 说明 | +| ------------------------------------- | ------ | --------------------------------------------------------------------------------------- | | `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | 未设置 | 无需 `.rtk/trust.json` 哈希即可信任项目 `.rtk/filters.json`。仅在受控的本地开发中使用。 | ### 记忆引擎(plan 21) 持久记忆子系统(`src/lib/memory/`)的嵌入层、向量存储和重排序开关。 -| 变量 | 默认值 | 说明 | -| --- | --- | --- | -| `MEMORY_EMBEDDING_CACHE_TTL_MS` | `300000`(5 分钟) | 内存嵌入缓存(每个源/模型/维度签名)的 TTL。 | -| `MEMORY_EMBEDDING_CACHE_MAX` | `1000` | 嵌入缓存中保留的最大 LRU 条目数。 | -| `MEMORY_TRANSFORMERS_MODEL` | `Xenova/all-MiniLM-L6-v2` | 用于可选的 `@huggingface/transformers` 本地 MiniLM 管线的 HF 仓库 ID(约 23 MB int8,约 400 MB RAM)。 | -| `MEMORY_STATIC_MODEL` | `minishlab/potion-base-8M` | 用于静态 potion/Model2Vec 查找表嵌入器的 HF 仓库 ID。懒加载下载到缓存目录。 | -| `MEMORY_STATIC_CACHE_DIR` | `/embeddings` | 用于缓存静态 potion 模型文件的目录。未设置时默认为 `DATA_DIR` 下。 | -| `MEMORY_VEC_TOP_K` | `20` | `src/lib/memory/vectorStore.ts` 内部 `sqlite-vec` 暴力向量搜索使用的默认 top-K。 | -| `MEMORY_RRF_K` | `60` | FTS5 + 向量混合检索的 Reciprocal Rank Fusion 常数 `k`(sqlite-vec 方案)。 | -| `HF_HUB_ENDPOINT` | `https://huggingface.co` | 覆盖 `staticPotion.ts` 使用的 Hugging Face Hub 基础 URL(如气隙环境下的镜像端点)。 | +| 变量 | 默认值 | 说明 | +| ------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------ | +| `MEMORY_EMBEDDING_CACHE_TTL_MS` | `300000`(5 分钟) | 内存嵌入缓存(每个源/模型/维度签名)的 TTL。 | +| `MEMORY_EMBEDDING_CACHE_MAX` | `1000` | 嵌入缓存中保留的最大 LRU 条目数。 | +| `MEMORY_TRANSFORMERS_MODEL` | `Xenova/all-MiniLM-L6-v2` | 用于可选的 `@huggingface/transformers` 本地 MiniLM 管线的 HF 仓库 ID(约 23 MB int8,约 400 MB RAM)。 | +| `MEMORY_STATIC_MODEL` | `minishlab/potion-base-8M` | 用于静态 potion/Model2Vec 查找表嵌入器的 HF 仓库 ID。懒加载下载到缓存目录。 | +| `MEMORY_STATIC_CACHE_DIR` | `/embeddings` | 用于缓存静态 potion 模型文件的目录。未设置时默认为 `DATA_DIR` 下。 | +| `MEMORY_VEC_TOP_K` | `20` | `src/lib/memory/vectorStore.ts` 内部 `sqlite-vec` 暴力向量搜索使用的默认 top-K。 | +| `MEMORY_RRF_K` | `60` | FTS5 + 向量混合检索的 Reciprocal Rank Fusion 常数 `k`(sqlite-vec 方案)。 | +| `HF_HUB_ENDPOINT` | `https://huggingface.co` | 覆盖 `staticPotion.ts` 使用的 Hugging Face Hub 基础 URL(如气隙环境下的镜像端点)。 | ### 低内存 Docker 示例 @@ -719,60 +720,60 @@ STREAM_HISTORY_MAX=10 从外部源自动同步模型价格数据。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `PRICING_SYNC_ENABLED` | `false` | `src/lib/pricingSync.ts` | 可选的定期价格同步。 | -| `PRICING_SYNC_INTERVAL` | `86400`(24 小时) | `src/lib/pricingSync.ts` | 同步间隔,秒。 | -| `PRICING_SYNC_SOURCES` | `litellm` | `src/lib/pricingSync.ts` | 逗号分隔的数据源。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------- | ------------------ | ------------------------ | -------------------- | +| `PRICING_SYNC_ENABLED` | `false` | `src/lib/pricingSync.ts` | 可选的定期价格同步。 | +| `PRICING_SYNC_INTERVAL` | `86400`(24 小时) | `src/lib/pricingSync.ts` | 同步间隔,秒。 | +| `PRICING_SYNC_SOURCES` | `litellm` | `src/lib/pricingSync.ts` | 逗号分隔的数据源。 | --- ## Arena ELO 同步 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `ARENA_ELO_SYNC_ENABLED` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | 定期 Arena AI 排行榜 ELO 同步,可从 Dashboard Feature Flags 配置或设为 `false` 退出。 | -| `ARENA_ELO_SYNC_INTERVAL` | `86400`(24 小时) | `src/lib/arenaEloSync.ts` | 同步间隔,秒。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------------- | ------------------ | ------------------------------------------------ | ------------------------------------------------------------------------------------- | +| `ARENA_ELO_SYNC_ENABLED` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | 定期 Arena AI 排行榜 ELO 同步,可从 Dashboard Feature Flags 配置或设为 `false` 退出。 | +| `ARENA_ELO_SYNC_INTERVAL` | `86400`(24 小时) | `src/lib/arenaEloSync.ts` | 同步间隔,秒。 | --- ## 19. 模型同步(开发) -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | +| 变量 | 默认值 | 源文件 | 说明 | +| -------------------------- | ------------------ | -------------------------- | ------------------------------ | | `MODELS_DEV_SYNC_INTERVAL` | `86400`(24 小时) | `src/lib/modelsDevSync.ts` | 开发时的模型目錄同步间隔,秒。 | --- ## 20. 服务商特定设置 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OPENROUTER_CATALOG_TTL_MS` | `86400000`(24 小时) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter 模型目錄缓存 TTL。 | -| `MODEL_CATALOG_INCLUDE_NAMES` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | 在 `/v1/models` 响应中包含显示友好的 `name` 字段。对于只需要 ID 的客户端可禁用。 | -| `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | NanoBanana 图片生成任务的最大等待时间。 | -| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana 任务轮询频率。 | -| `AWS_REGION` | _(未设置)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | 用于构建 AWS Bedrock 端点的区域(Kiro、音频)。 | -| `AWS_DEFAULT_REGION` | _(未设置)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | `AWS_REGION` 未设置时的回退。 | -| `CLOUDFLARE_ACCOUNT_ID` | _(未设置)_ | `open-sse/executors/cloudflare-ai.ts` | Cloudflare Workers AI 的 Account ID。 | -| `CLOUDFLARE_API_BASE` | `https://api.cloudflare.com/client/v4` | `src/app/api/settings/proxy/cloudflare-deploy/route.ts` | 覆盖代理池 Workers 中继部署器使用的 Cloudflare REST API 基础 URL(#4640 / 9router#1360)。 | -| `NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx` | 代理池"Deploy Relay"弹窗中建议的默认 worker 项目名。 | -| `NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | 设为 `false` 可从 Proxy Pool 选项卡中隐藏 Cloudflare Workers 中继选项。 | -| `CLOUDFLARED_BIN` | 自动检测 | `src/lib/cloudflaredTunnel.ts` | `cloudflared` 二进制文件的自定义路径。 | -| `DENO_DEPLOY_API_BASE` | `https://api.deno.com/v2` | `src/app/api/settings/proxy/deno-deploy/route.ts` | 覆盖代理池中继部署器使用的 Deno Deploy REST API 基础 URL(#4643 / 9router#1437)。 | -| `NEXT_PUBLIC_DENO_RELAY_DEFAULT_PROJECT` | `omniroute-deno-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx` | 代理池"Deploy Relay"弹窗中建议的默认 Deno Deploy 应用名。 | -| `NEXT_PUBLIC_DENO_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | 设为 `false` 可从 Proxy Pool 选项卡中隐藏 Deno Deploy 中继选项。 | -| `SEARCH_CACHE_TTL_MS` | `300000`(5 分钟) | `open-sse/services/searchCache.ts` | 搜索 API(Perplexity、Brave 等)响应缓存的 TTL。 | -| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | `src/app/api/providers/route.ts` | 允许每个 OpenAI 兼容服务商同时建立多个连接。 | -| `ENABLE_CC_COMPATIBLE_PROVIDER` | `false` | `src/shared/utils/featureFlags.ts` | 为仅 Claude Code 中继显示实验性 CC 兼容服务商 UI。 | -| `NINEROUTER_HOST` | `127.0.0.1` | `open-sse/executors/ninerouter.ts` | 覆盖嵌入式 9router 实例监听的主机。 | -| `NINEROUTER_PORT` | `20130` | `open-sse/executors/ninerouter.ts` | 覆盖嵌入式 9router 实例监听的端口。 | -| `EMBED_WS_PROXY_HOST` | `127.0.0.1` | `src/lib/services/embedWsProxy.ts` | 嵌入式服务 WebSocket 代理的绑定主机(默认仅 loopback)。 | -| `EMBED_WS_PROXY_PORT` | `20131` | `src/lib/services/embedWsProxy.ts` | 嵌入式服务 WebSocket 代理服务器的端口。 | -| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI 桥接主机(旧版集成)。 | -| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI 桥接端口。 | -| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI 配置目录。 | -| `LOCAL_HOSTNAMES` | _(空)_ | `open-sse/config/providerRegistry.ts` | 逗号分隔的额外被视为"本地"的主机名(Docker 服务名称等)。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ---------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `OPENROUTER_CATALOG_TTL_MS` | `86400000`(24 小时) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter 模型目錄缓存 TTL。 | +| `MODEL_CATALOG_INCLUDE_NAMES` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | 在 `/v1/models` 响应中包含显示友好的 `name` 字段。对于只需要 ID 的客户端可禁用。 | +| `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | NanoBanana 图片生成任务的最大等待时间。 | +| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana 任务轮询频率。 | +| `AWS_REGION` | _(未设置)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | 用于构建 AWS Bedrock 端点的区域(Kiro、音频)。 | +| `AWS_DEFAULT_REGION` | _(未设置)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | `AWS_REGION` 未设置时的回退。 | +| `CLOUDFLARE_ACCOUNT_ID` | _(未设置)_ | `open-sse/executors/cloudflare-ai.ts` | Cloudflare Workers AI 的 Account ID。 | +| `CLOUDFLARE_API_BASE` | `https://api.cloudflare.com/client/v4` | `src/app/api/settings/proxy/cloudflare-deploy/route.ts` | 覆盖代理池 Workers 中继部署器使用的 Cloudflare REST API 基础 URL(#4640 / 9router#1360)。 | +| `NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx` | 代理池"Deploy Relay"弹窗中建议的默认 worker 项目名。 | +| `NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | 设为 `false` 可从 Proxy Pool 选项卡中隐藏 Cloudflare Workers 中继选项。 | +| `CLOUDFLARED_BIN` | 自动检测 | `src/lib/cloudflaredTunnel.ts` | `cloudflared` 二进制文件的自定义路径。 | +| `DENO_DEPLOY_API_BASE` | `https://api.deno.com/v2` | `src/app/api/settings/proxy/deno-deploy/route.ts` | 覆盖代理池中继部署器使用的 Deno Deploy REST API 基础 URL(#4643 / 9router#1437)。 | +| `NEXT_PUBLIC_DENO_RELAY_DEFAULT_PROJECT` | `omniroute-deno-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx` | 代理池"Deploy Relay"弹窗中建议的默认 Deno Deploy 应用名。 | +| `NEXT_PUBLIC_DENO_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | 设为 `false` 可从 Proxy Pool 选项卡中隐藏 Deno Deploy 中继选项。 | +| `SEARCH_CACHE_TTL_MS` | `300000`(5 分钟) | `open-sse/services/searchCache.ts` | 搜索 API(Perplexity、Brave 等)响应缓存的 TTL。 | +| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | `src/app/api/providers/route.ts` | 允许每个 OpenAI 兼容服务商同时建立多个连接。 | +| `ENABLE_CC_COMPATIBLE_PROVIDER` | `false` | `src/shared/utils/featureFlags.ts` | 为仅 Claude Code 中继显示实验性 CC 兼容服务商 UI。 | +| `NINEROUTER_HOST` | `127.0.0.1` | `open-sse/executors/ninerouter.ts` | 覆盖嵌入式 9router 实例监听的主机。 | +| `NINEROUTER_PORT` | `20130` | `open-sse/executors/ninerouter.ts` | 覆盖嵌入式 9router 实例监听的端口。 | +| `EMBED_WS_PROXY_HOST` | `127.0.0.1` | `src/lib/services/embedWsProxy.ts` | 嵌入式服务 WebSocket 代理的绑定主机(默认仅 loopback)。 | +| `EMBED_WS_PROXY_PORT` | `20131` | `src/lib/services/embedWsProxy.ts` | 嵌入式服务 WebSocket 代理服务器的端口。 | +| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI 桥接主机(旧版集成)。 | +| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI 桥接端口。 | +| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI 配置目录。 | +| `LOCAL_HOSTNAMES` | _(空)_ | `open-sse/config/providerRegistry.ts` | 逗号分隔的额外被视为"本地"的主机名(Docker 服务名称等)。 | `ENABLE_CC_COMPATIBLE_PROVIDER` 仅适用于接受 Claude Code 客户端的第三方中继。 OmniRoute 会重写请求以使这些中继接受。如果你只想使用 @@ -783,23 +784,23 @@ Anthropic 兼容服务商。 ## 21. 代理健康 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | 快速失败健康检查超时。 | -| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | 健康检查结果缓存 TTL。 | -| `PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS` | `2000` | `src/lib/proxyHealth.ts` | 代理健康探测失败的缓存 TTL。保持在低于 `PROXY_HEALTH_CACHE_TTL_MS` 的值,以便高并发下的瞬态代理超时能快速重试,同时不会为真正死掉的代理禁用快速失败。 | -| `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | 允许 OAuth 和服务商校验流程在代理可达性预检失败时绕过固定代理直接连接。有效优先级为 Feature Flags DB 覆盖 > 环境变量 > 默认值。 | -| `RATE_LIMIT_MAX_WAIT_MS` | `120000`(2 分钟) | `open-sse/services/rateLimitManager.ts` | 在请求失败前等待 429 的最长时间。 | -| `RATE_LIMIT_AUTO_ENABLE` | _(未设置)_ | `open-sse/services/rateLimitManager.ts` | 强制打开/关闭自动启用速率限制安全网,不管持久化的 Dashboard 设置。接受 `true`/`1`/`on` 强制开启,`false`/`0`/`off` 强制关闭。 | -| `PROVIDER_COOLDOWN_ENABLED` | _(未设置 → 关闭)_ | `open-sse/services/providerCooldownTracker.ts` | 启用全局跨请求服务商/连接冷却跟踪。默认关闭(与 Connection Cooldown / Provider Circuit Breaker 重叠)。接受 `true`/`1`/`on` 启用。 | -| `PROVIDER_COOLDOWN_MIN_MS` | `5000` | `open-sse/services/providerCooldownTracker.ts` | 失败的服务商/连接重试前的最短冷却时间(毫秒)。随连续失败次数指数级增长。仅在 `PROVIDER_COOLDOWN_ENABLED` 时使用。 | -| `PROVIDER_COOLDOWN_MAX_MS` | `300000`(5 分钟) | `open-sse/services/providerCooldownTracker.ts` | 失败的服务商/连接重试前的最大冷却上限(毫秒)。仅在 `PROVIDER_COOLDOWN_ENABLED` 时使用。 | -| `STREAM_RECOVERY_ENABLED` | _(未设置 → 关闭)_ | `src/lib/resilience/settings.ts`(种子) → `open-sse/services/streamRecovery.ts`(逻辑) | **这是什么:** 透明恢复被截断的上游流(free-claude-code 端口)。将打开 SSE 窗口保持最多 `STREAM_RECOVERY.HOLDBACK_MS`(750 毫秒),使得 _提交前_ 截断 — 即任何字节到达客户端之前 — 被透明地重新打开和重试。**何时启用:** 频繁在流开始时 0 字节截断的不稳定上游;如果无法承受每个流最多 750 毫秒的首 Token 延迟增加,请保持关闭。接受 `true`/`1`/`on`。为持久化容灾设置提供种子;Dashboard 设置一旦设置即生效。 | -| `STREAM_RECOVERY_MIDSTREAM_ENABLED` | _(未设置 → 关闭)_ | `src/lib/resilience/settings.ts`(种子) → `open-sse/services/streamRecovery.ts`(逻辑) | **这是什么:** 流中续传(Fase 4.4)— _提交后_ 截断(字节已到达客户端)之后,用部分文本作为 assistant 预填充重新请求,拼接缺失的后缀。仅限纯文本 OpenAI 兼容流;有工具调用在进行时永不触发。**何时启用:** 长生成被中途截断,且你接受恢复的后缀以一次性爆发而非逐 Token 到达。独立于 `STREAM_RECOVERY_ENABLED`(不同的风险特征)。接受 `true`/`1`/`on`。 | -| `HEALTHCHECK_STAGGER_MS` | `3000` | `src/lib/tokenHealthCheck.ts` | 启动时服务商 Token 健康检查之间的错开间隔(毫秒)。 | -| `REQUEST_RETRY` | `2` | `src/sse/services/cooldownAwareRetry.ts` | 模型作用域冷却响应上的自动重试次数,之后将错误返回给客户端。 | -| `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | 冷却重试之间的最大退避间隔(秒)。不管上游 `Retry-After` 如何,均受此值限制。 | -| `HEADROOM_URL` | `http://localhost:8787` | `src/lib/headroom/detect.ts` | Headroom Token 节省器代理 URL。Dashboard 生命周期(`api/headroom/*`)默认在 loopback 上启动本地 `headroom-ai` CLI;覆盖以指向外部 Docker sidecar 代理。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | 快速失败健康检查超时。 | +| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | 健康检查结果缓存 TTL。 | +| `PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS` | `2000` | `src/lib/proxyHealth.ts` | 代理健康探测失败的缓存 TTL。保持在低于 `PROXY_HEALTH_CACHE_TTL_MS` 的值,以便高并发下的瞬态代理超时能快速重试,同时不会为真正死掉的代理禁用快速失败。 | +| `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | 允许 OAuth 和服务商校验流程在代理可达性预检失败时绕过固定代理直接连接。有效优先级为 Feature Flags DB 覆盖 > 环境变量 > 默认值。 | +| `RATE_LIMIT_MAX_WAIT_MS` | `120000`(2 分钟) | `open-sse/services/rateLimitManager.ts` | 在请求失败前等待 429 的最长时间。 | +| `RATE_LIMIT_AUTO_ENABLE` | _(未设置)_ | `open-sse/services/rateLimitManager.ts` | 强制打开/关闭自动启用速率限制安全网,不管持久化的 Dashboard 设置。接受 `true`/`1`/`on` 强制开启,`false`/`0`/`off` 强制关闭。 | +| `PROVIDER_COOLDOWN_ENABLED` | _(未设置 → 关闭)_ | `open-sse/services/providerCooldownTracker.ts` | 启用全局跨请求服务商/连接冷却跟踪。默认关闭(与 Connection Cooldown / Provider Circuit Breaker 重叠)。接受 `true`/`1`/`on` 启用。 | +| `PROVIDER_COOLDOWN_MIN_MS` | `5000` | `open-sse/services/providerCooldownTracker.ts` | 失败的服务商/连接重试前的最短冷却时间(毫秒)。随连续失败次数指数级增长。仅在 `PROVIDER_COOLDOWN_ENABLED` 时使用。 | +| `PROVIDER_COOLDOWN_MAX_MS` | `300000`(5 分钟) | `open-sse/services/providerCooldownTracker.ts` | 失败的服务商/连接重试前的最大冷却上限(毫秒)。仅在 `PROVIDER_COOLDOWN_ENABLED` 时使用。 | +| `STREAM_RECOVERY_ENABLED` | _(未设置 → 关闭)_ | `src/lib/resilience/settings.ts`(种子) → `open-sse/services/streamRecovery.ts`(逻辑) | **这是什么:** 透明恢复被截断的上游流(free-claude-code 端口)。将打开 SSE 窗口保持最多 `STREAM_RECOVERY.HOLDBACK_MS`(750 毫秒),使得 _提交前_ 截断 — 即任何字节到达客户端之前 — 被透明地重新打开和重试。**何时启用:** 频繁在流开始时 0 字节截断的不稳定上游;如果无法承受每个流最多 750 毫秒的首 Token 延迟增加,请保持关闭。接受 `true`/`1`/`on`。为持久化容灾设置提供种子;Dashboard 设置一旦设置即生效。 | +| `STREAM_RECOVERY_MIDSTREAM_ENABLED` | _(未设置 → 关闭)_ | `src/lib/resilience/settings.ts`(种子) → `open-sse/services/streamRecovery.ts`(逻辑) | **这是什么:** 流中续传(Fase 4.4)— _提交后_ 截断(字节已到达客户端)之后,用部分文本作为 assistant 预填充重新请求,拼接缺失的后缀。仅限纯文本 OpenAI 兼容流;有工具调用在进行时永不触发。**何时启用:** 长生成被中途截断,且你接受恢复的后缀以一次性爆发而非逐 Token 到达。独立于 `STREAM_RECOVERY_ENABLED`(不同的风险特征)。接受 `true`/`1`/`on`。 | +| `HEALTHCHECK_STAGGER_MS` | `3000` | `src/lib/tokenHealthCheck.ts` | 启动时服务商 Token 健康检查之间的错开间隔(毫秒)。 | +| `REQUEST_RETRY` | `2` | `src/sse/services/cooldownAwareRetry.ts` | 模型作用域冷却响应上的自动重试次数,之后将错误返回给客户端。 | +| `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | 冷却重试之间的最大退避间隔(秒)。不管上游 `Retry-After` 如何,均受此值限制。 | +| `HEADROOM_URL` | `http://localhost:8787` | `src/lib/headroom/detect.ts` | Headroom Token 节省器代理 URL。Dashboard 生命周期(`api/headroom/*`)默认在 loopback 上启动本地 `headroom-ai` CLI;覆盖以指向外部 Docker sidecar 代理。 | ### 流恢复调优常量(非环境变量) @@ -828,19 +829,19 @@ Anthropic 兼容服务商。 > [!CAUTION] > 这些变量会产生**详细输出**,并可能泄露敏感数据。**切勿在生产环境中启用。** -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `CURSOR_DEBUG` | _(未设置)_ | `open-sse/executors/cursor.ts` | 设为 `1` 可启用详细的 Cursor executor 日志(解码的 SSE 块等)。 | -| `CURSOR_STREAM_DEBUG` | _(未设置)_ | `open-sse/executors/cursor.ts` | `CURSOR_DEBUG` 的向后兼容别名。 | -| `CURSOR_DUMP_FILE` | _(未设置)_ | `open-sse/executors/cursor.ts` | 当 `CURSOR_DEBUG=1` 时,接收原始解码 Cursor 块的可选文件路径。 | -| `CURSOR_STREAM_TIMEOUT_MS` | `300000` | `open-sse/executors/cursor.ts` | Cursor executor 的流空闲超时(毫秒)。 | -| `CURSOR_TOOL_DIRECTIVE` | 启用 (`!== "0"`) | `open-sse/executors/cursor.ts` | 使 composer-2.5 可靠发出工具调用的工具提交指令。设为 `0` 可禁用。 | -| `CURSOR_IMAGE_FETCH_TIMEOUT_MS` | `15000` | `open-sse/utils/cursorImages.ts` | 远程 `image_url` 视觉输入的单张图片获取超时(毫秒)。 | -| `CURSOR_STATE_DB_PATH` | _(探测)_ | `open-sse/utils/cursorVersionDetector.ts` | 覆盖版本检测使用的 Cursor 状态数据库查询。 | -| `CURSOR_TOKEN` | _(未设置)_ | `scripts/ad-hoc/cursor-tap.cjs` | 开发工具使用的直接 Cursor bearer Token。 | -| `OMNIROUTE_LOG_REQUEST_SHAPE` | 启用 (`!== "0"`) | `src/app/api/v1/chat/completions/route.ts` | 记录大型聊天载荷的 content-type/length 标记。设为 `"0"` 可静默。 | -| `DEBUG_RESPONSES_SSE_TO_JSON` | _(未设置)_ | `open-sse/handlers/responseTranslator.ts` | 设为 `true` 可记录 Responses API SSE→JSON 转换详情。 | -| `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(未设置)_ | E2E 测试工具 | 设为 `true` 可启用 E2E 测试模式(放宽认证、测试钩子)。 | +| 变量 | 默认值 | 源文件 | 说明 | +| -------------------------------- | ---------------- | ------------------------------------------ | ----------------------------------------------------------------- | +| `CURSOR_DEBUG` | _(未设置)_ | `open-sse/executors/cursor.ts` | 设为 `1` 可启用详细的 Cursor executor 日志(解码的 SSE 块等)。 | +| `CURSOR_STREAM_DEBUG` | _(未设置)_ | `open-sse/executors/cursor.ts` | `CURSOR_DEBUG` 的向后兼容别名。 | +| `CURSOR_DUMP_FILE` | _(未设置)_ | `open-sse/executors/cursor.ts` | 当 `CURSOR_DEBUG=1` 时,接收原始解码 Cursor 块的可选文件路径。 | +| `CURSOR_STREAM_TIMEOUT_MS` | `300000` | `open-sse/executors/cursor.ts` | Cursor executor 的流空闲超时(毫秒)。 | +| `CURSOR_TOOL_DIRECTIVE` | 启用 (`!== "0"`) | `open-sse/executors/cursor.ts` | 使 composer-2.5 可靠发出工具调用的工具提交指令。设为 `0` 可禁用。 | +| `CURSOR_IMAGE_FETCH_TIMEOUT_MS` | `15000` | `open-sse/utils/cursorImages.ts` | 远程 `image_url` 视觉输入的单张图片获取超时(毫秒)。 | +| `CURSOR_STATE_DB_PATH` | _(探测)_ | `open-sse/utils/cursorVersionDetector.ts` | 覆盖版本检测使用的 Cursor 状态数据库查询。 | +| `CURSOR_TOKEN` | _(未设置)_ | `scripts/ad-hoc/cursor-tap.cjs` | 开发工具使用的直接 Cursor bearer Token。 | +| `OMNIROUTE_LOG_REQUEST_SHAPE` | 启用 (`!== "0"`) | `src/app/api/v1/chat/completions/route.ts` | 记录大型聊天载荷的 content-type/length 标记。设为 `"0"` 可静默。 | +| `DEBUG_RESPONSES_SSE_TO_JSON` | _(未设置)_ | `open-sse/handlers/responseTranslator.ts` | 设为 `true` 可记录 Responses API SSE→JSON 转换详情。 | +| `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(未设置)_ | E2E 测试工具 | 设为 `true` 可启用 E2E 测试模式(放宽认证、测试钩子)。 | --- @@ -848,11 +849,11 @@ Anthropic 兼容服务商。 允许用户直接从 Dashboard 报告问题。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `GITHUB_ISSUES_REPO` | _(未设置)_ | `src/app/api/v1/issues/report/route.ts` | 仓库,`owner/repo` 格式。 | -| `GITHUB_ISSUES_TOKEN` | _(未设置)_ | `src/app/api/v1/issues/report/route.ts` | 具有 `issues:write` 权限域的 GitHub Personal Access Token。 | -| `GITHUB_TOKEN` | _(未设置)_ | issue 分类 / 云代理辅助 | 通用 GitHub 访问 Token,用作 `GITHUB_ISSUES_TOKEN` 的回退,并被 `src/lib/cloudAgent/*` 中的云代理辅助使用。 | +| 变量 | 默认值 | 源文件 | 说明 | +| --------------------- | ---------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `GITHUB_ISSUES_REPO` | _(未设置)_ | `src/app/api/v1/issues/report/route.ts` | 仓库,`owner/repo` 格式。 | +| `GITHUB_ISSUES_TOKEN` | _(未设置)_ | `src/app/api/v1/issues/report/route.ts` | 具有 `issues:write` 权限域的 GitHub Personal Access Token。 | +| `GITHUB_TOKEN` | _(未设置)_ | issue 分类 / 云代理辅助 | 通用 GitHub 访问 Token,用作 `GITHUB_ISSUES_TOKEN` 的回退,并被 `src/lib/cloudAgent/*` 中的云代理辅助使用。 | --- @@ -922,16 +923,16 @@ CLI_COMPAT_ALL=1 技能框架(`src/lib/skills/`)在沙箱环境中执行用户定义自动化时应用的限制和安全开关。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `SKILLS_SANDBOX_TIMEOUT_MS` | `10000`(10 秒) | `src/lib/skills/builtins.ts` | 沙箱技能代码的每次执行挂钟超时时间。硬限制;超时则杀死。 | -| `SKILLS_EXECUTION_TIMEOUT_MS` | _(回退到 `SKILLS_SANDBOX_TIMEOUT_MS`)_ | `src/lib/skills/` | 高级技能编排超时。设为高于 `SKILLS_SANDBOX_TIMEOUT_MS` 以允许多步工作流。 | -| `SKILLS_MAX_FILE_BYTES` | `1048576`(1 MB) | `src/lib/skills/builtins.ts` | 技能可从单个沙箱文件读取的最大字节数。 | -| `SKILLS_MAX_HTTP_RESPONSE_BYTES` | `256000`(250 KB) | `src/lib/skills/builtins.ts` | 技能内从单个 HTTP 响应捕获的最大字节数。 | -| `SKILLS_MAX_SANDBOX_OUTPUT_CHARS` | `100000` | `src/lib/skills/builtins.ts` | 沙箱调用返回的 stdout/stderr 字符硬上限。 | -| `SKILLS_SANDBOX_NETWORK_ENABLED` | `false` | `src/lib/skills/builtins.ts` | 设为 `1`/`true` 允许沙箱内部向外联网。默认**隔离**以确保安全。 | -| `SKILLS_ALLOWED_SANDBOX_IMAGES` | _(空)_ | `src/lib/skills/builtins.ts` | 逗号分隔的允许用于沙箱执行的容器镜像允许列表。空意味着仅使用内置默认值。 | -| `SKILLS_SANDBOX_DOCKER_IMAGE` | _(内置默认值)_ | `src/lib/skills/` | 启动 Docker 沙箱时使用的容器镜像。覆盖以固定自定义加固的基础镜像。 | +| 变量 | 默认值 | 源文件 | 说明 | +| --------------------------------- | -------------------------------------- | ---------------------------- | ------------------------------------------------------------------------- | +| `SKILLS_SANDBOX_TIMEOUT_MS` | `10000`(10 秒) | `src/lib/skills/builtins.ts` | 沙箱技能代码的每次执行挂钟超时时间。硬限制;超时则杀死。 | +| `SKILLS_EXECUTION_TIMEOUT_MS` | _(回退到 `SKILLS_SANDBOX_TIMEOUT_MS`)_ | `src/lib/skills/` | 高级技能编排超时。设为高于 `SKILLS_SANDBOX_TIMEOUT_MS` 以允许多步工作流。 | +| `SKILLS_MAX_FILE_BYTES` | `1048576`(1 MB) | `src/lib/skills/builtins.ts` | 技能可从单个沙箱文件读取的最大字节数。 | +| `SKILLS_MAX_HTTP_RESPONSE_BYTES` | `256000`(250 KB) | `src/lib/skills/builtins.ts` | 技能内从单个 HTTP 响应捕获的最大字节数。 | +| `SKILLS_MAX_SANDBOX_OUTPUT_CHARS` | `100000` | `src/lib/skills/builtins.ts` | 沙箱调用返回的 stdout/stderr 字符硬上限。 | +| `SKILLS_SANDBOX_NETWORK_ENABLED` | `false` | `src/lib/skills/builtins.ts` | 设为 `1`/`true` 允许沙箱内部向外联网。默认**隔离**以确保安全。 | +| `SKILLS_ALLOWED_SANDBOX_IMAGES` | _(空)_ | `src/lib/skills/builtins.ts` | 逗号分隔的允许用于沙箱执行的容器镜像允许列表。空意味着仅使用内置默认值。 | +| `SKILLS_SANDBOX_DOCKER_IMAGE` | _(内置默认值)_ | `src/lib/skills/` | 启动 Docker 沙箱时使用的容器镜像。覆盖以固定自定义加固的基础镜像。 | > [!CAUTION] > 启用 `SKILLS_SANDBOX_NETWORK_ENABLED=true` 会打开任意技能代码的出口路径。在共享部署中请搭配 `OUTBOUND_SSRF_GUARD_ENABLED=true` 和严格的 `CORS_ORIGIN`/代理策略。 @@ -942,88 +943,88 @@ CLI_COMPAT_ALL=1 服务商配额端点、网络隧道(Tailscale、Ngrok、MITM 调试代理)、1Proxy 出口池、数据库备份以及 executor 层或脚本引用的小型按功能覆盖。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `REDIS_URL` | `redis://localhost:6379` | `src/shared/utils/rateLimiter.ts` | 速率限制器后端的 Redis 连接字符串。 | -| `ALIBABA_CODING_PLAN_HOST` | _(生产主机)_ | `open-sse/services/bailianQuotaFetcher.ts` | 覆盖用于获取阿里巴巴 Bailian coding-plan 配额的主机。 | -| `ALIBABA_CODING_PLAN_QUOTA_URL` | 派生自主机 | `open-sse/services/bailianQuotaFetcher.ts` | 阿里巴巴 Bailian 的完整配额 URL 覆盖。 | -| `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | 计算提示预算时为补全输出保留的 Token 数。 | -| `MODEL_ALIAS_COMPAT_ENABLED` | 启用 | `open-sse/services/model.ts` | 切换旧客户端使用的旧版模型别名兼容层。 | -| `OMNIROUTE_EMERGENCY_FALLBACK` | 启用 | `open-sse/services/emergencyFallback.ts` | 设为 `false`(或 `0`)可禁用紧急预算耗尽回退,该回退将失败的请求重新路由到免费 `nvidia`/`openai/gpt-oss-120b` 模型。有效优先级为 Feature Flags DB 覆盖 > 环境变量 > 默认值;如果不可用,服务回退到原始环境变量值。 | -| `COMMAND_CODE_CALLBACK_PORT` | _(未设置)_ | `src/app/api/providers/command-code/auth/shared.ts` | Command Code CLI 辅助使用的 OAuth 风格回调的本地端口。 | -| `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | 作为 `x-command-code-version` 头发送到 Command Code 上游的值。覆盖以升级 CLI 版本。 | -| `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | MITM 调试代理的本地绑定端口。 | -| `MITM_DISABLE_TLS_VERIFY` | `0` | `src/mitm/server.cjs` | 设为 `1` 可禁用上游 TLS 校验(仅限开发)。 | -| `MITM_IDLE_TIMEOUT_MS` | `60000` | `src/mitm/socketTimeouts.ts`, `src/mitm/server.cjs` | 代理连接的空闲套接字超时(毫秒);超过该时间的空闲套接字会被拆除,避免泄露半打开隧道。 | -| `MITM_VERBOSE` | `1` | `src/mitm/server.cjs`, `src/mitm/_internal/bypass.cjs` | 路由决策日志详细程度:`0` 静默,值越大记录越多 bypass/路由决策。 | -| `ONEPROXY_ENABLED` | `true` | `src/lib/oneproxySync.ts` | 启用 1Proxy 出口池同步。 | -| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | `src/lib/oneproxySync.ts` | 1Proxy 服务 API URL 覆盖。 | -| `ONEPROXY_MAX_PROXIES` | `500` | `src/lib/oneproxySync.ts` | 每次同步导入的最大代理数。 | -| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | `src/lib/oneproxySync.ts` | 导入代理的最低质量分。 | -| `FREE_PROXY_1PROXY_ENABLED` | `true` | `src/lib/freeProxyProviders/oneproxy.ts` | 启用 1proxy 免费代理源。设为 `false` 可禁用。 | -| `FREE_PROXY_1PROXY_API_URL` | _(见 oneproxy.ts)_ | `src/lib/freeProxyProviders/oneproxy.ts` | 1proxy API URL 覆盖。 | -| `FREE_PROXY_1PROXY_MAX` | `500` | `src/lib/freeProxyProviders/oneproxy.ts` | 从 1proxy 每次同步获取的最大代理数。 | -| `FREE_PROXY_1PROXY_MIN_QUALITY` | `50` | `src/lib/freeProxyProviders/oneproxy.ts` | 1proxy 导入的最低质量分阈值。 | -| `FREE_PROXY_PROXIFLY_ENABLED` | `true` | `src/lib/freeProxyProviders/proxifly.ts` | 启用 Proxifly 免费代理源。设为 `false` 可禁用。 | -| `FREE_PROXY_PROXIFLY_QUANTITY` | `100` | `src/lib/freeProxyProviders/proxifly.ts` | 每次 Proxifly 同步获取的代理数量。 | -| `FREE_PROXY_PROXIFLY_ANONYMITY` | `elite` | `src/lib/freeProxyProviders/proxifly.ts` | Proxifly 的匿名级别过滤(`elite`、`anonymous`、`transparent`)。 | -| `FREE_PROXY_IPLOCATE_ENABLED` | `false` | `src/lib/freeProxyProviders/iplocate.ts` | 启用 IPLocate 免费代理源。仅手动启用。 | -| `FREE_PROXY_IPLOCATE_BASE_URL` | `https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols` | `src/lib/freeProxyProviders/iplocate.ts` | IPLocate 代理列表基础 URL 覆盖。 | -| `NEXT_PUBLIC_VERCEL_RELAY_ENABLED` | `true` | `src/app/(dashboard)/…/ProxyPoolTab.tsx` | 在 Proxy Pool 选项卡中显示/隐藏 Deploy Vercel Relay 按钮。 | -| `VERCEL_API_BASE` | `https://api.vercel.com` | `src/app/api/settings/proxy/vercel-deploy/route.ts` | Vercel API 基础 URL 覆盖(用于测试)。 | -| `NEXT_PUBLIC_VERCEL_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/…/VercelRelayModal.tsx` | Vercel Relay 部署弹窗中预填的默认项目名。 | -| `TAILSCALE_BIN` | _(自动检测)_ | `src/lib/tailscaleTunnel.ts` | `tailscale` 二进制文件的显式路径。 | -| `TAILSCALED_BIN` | _(自动检测)_ | `src/lib/tailscaleTunnel.ts` | `tailscaled` 守护进程二进制文件的显式路径。 | -| `TAILSCALE_AUTHKEY` | _(未设置)_ | `src/lib/tailscaleTunnel.ts` | 非交互式/无头 `tailscale up` 的预共享 Tailscale 认证密钥(通过 `--auth-key=` 传递)。未设置时,登录回退到交互式浏览器认证 URL。 | -| `NGROK_AUTHTOKEN` | _(未设置)_ | `src/lib/ngrokTunnel.ts` | 认证出口 ngrok 隧道。 | -| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | 磁盘上保留的最大 SQLite 备份文件数。覆盖从 Settings → Database backup retention 保存的值。 | -| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | 保留备份的最大天数。`0` 禁用基于时间的清理。覆盖从 Settings → Database backup retention 保存的值。 | -| `OMNIROUTE_TLS_PROXY_URL` | _(未设置)_ | `open-sse/services/chatgptTlsClient.ts` | 覆盖测试用的 TLS sidecar URL。生产环境应保持未设置。 | -| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | 入口点权限检查的容器运行时提示。在无根 Podman 下设为 `podman`,以便修复指令使用 `podman unshare chown` 而非 `sudo chown`。 | -| `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | 配额共享消费存储后端:`sqlite`(默认)或 `redis`。 | -| `QUOTA_STORE_REDIS_URL` | _(未设置)_ | `src/lib/quota/storeFactory.ts` | `QUOTA_STORE_DRIVER=redis` 时使用的 Redis 连接字符串(如 `redis://localhost:6379`)。 | -| `QUOTA_SATURATION_THRESHOLD` | `0.5` | `src/lib/quota/enforce.ts` | 池饱和比率(0..1);达到或超过该值时池进入严格模式(不允许借用)。 | -| `QUOTA_SOFT_DEPRIORITIZE_FACTOR` | `0.7` | `open-sse/services/combo.ts` | 软配额策略降低目标优先级时应用的分数乘数(0..1)。 | -| `STATUS_SOFT_DEPRIORITIZE_FACTOR` | `0.5` | `open-sse/services/combo/autoStrategy.ts` | 预检配额截止关闭时(#4540),在 auto-combo 评分中对已耗尽的服务商(`credits_exhausted`/`rate_limited`)应用的分数乘数(0..1)。 | -| `QUOTA_CONSUMPTION_RETENTION_DAYS` | `14` | `src/lib/db/quotaConsumption.ts` | `quota_consumption` 桶在 GC(`gcQuotaConsumption`)前的保留窗口(天)。 | -| `QUOTA_PREFLIGHT_CUTOFF_ENABLED` | `false` | `src/lib/resilience/settings.ts` | 启用(默认关闭):在评分前丢弃低配额候选项的自动路由硬配额截止。 | -| `OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL` | `false` | `open-sse/services/autoCombo/virtualFactory.ts` | 启用(默认关闭):当 `auto/:` 过滤器没有匹配到已连接候选项时,恢复旧版回退到完整(未过滤)池的行为,而非返回空池。默认关闭使 `:free` 意为"仅免费层"。 | -| `AGENTBRIDGE_UPSTREAM_CA_CERT` | _(未设置)_ | `src/mitm/manager.ts` | AgentBridge 上游 TLS 连接信任的额外 CA 证书(PEM)。 | -| `INSPECTOR_BUFFER_SIZE` | `1000` | `src/mitm/inspector/buffer.ts` | Traffic Inspector 环形缓冲区中保留的最大捕获请求数。 | -| `INSPECTOR_MAX_BODY_KB` | `1024` | `src/mitm/inspector/buffer.ts` | 截断前捕获的请求/响应体最大大小(KB)。 | -| `INSPECTOR_HTTP_PROXY_PORT` | `8080` | `src/mitm/inspector/httpProxyServer.ts` | Traffic Inspector HTTP 代理的本地端口。 | -| `INSPECTOR_HTTP_PROXY_AUTOSTART` | `false` | `src/mitm/inspector/httpProxyServer.ts` | 启动时自动启动 inspector HTTP 代理。 | -| `INSPECTOR_TLS_INTERCEPT` | `false` | `src/lib/inspector/captureState.ts` | 对捕获的 HTTPS 流量启用 TLS 拦截(MITM)。 | -| `INSPECTOR_LLM_HOSTS_EXTRA` | _(未设置)_ | `src/lib/inspector/captureState.ts` | 额外的主机名(逗号分隔),被视为大语言模型端点以进行捕获。 | -| `INSPECTOR_MASK_SECRETS` | `true` | `src/mitm/inspector/buffer.ts` | 在捕获流量中脱敏(认证头 / API key)。 | -| `INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES` | `30` | `src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts` | 系统代理安全护栏自动还原操作系统代理设置前的分钟数。 | -| `INSPECTOR_INTERNAL_INGEST_TOKEN` | _(自动)_ | `src/app/api/tools/traffic-inspector/internal/ingest/route.ts` | 认证进入 inspector 的内部捕获摄入的 Token。 | -| `PLAYGROUND_COMPARE_MAX_COLUMNS` | `4` | `src/app/(dashboard)/dashboard/playground/` | Playground 比较模式下的最大并排列数。 | -| `PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL` | _(未设置)_ | `src/app/(dashboard)/dashboard/playground/` | Playground 'improve prompt' 操作的默认模型(未设置时回退到活跃模型)。 | -| `BIFROST_ENABLED` | `1` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | bifrost sidecar 代理的主开关。设为 `0` 时,路由返回 503 并带 `X-Bifrost-Killswitch` 头,运维人员被弹回 TS 路径。无需重新部署即可禁用 sidecar(tier-1 路由事件、密钥轮换)。 | -| `BIFROST_BASE_URL` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | 设置时,Bifrost sidecar 代理路由将 `/v1/chat/completions` 流量转发到此 Go 网关而非 TS 中继处理器。未设置 → 503-with-fallback。尾部斜杠会被剥离。 | -| `BIFROST_API_KEY` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Bifrost 网关的 API key(作为 `Authorization: Bearer ...` 发送)。未设置时,路由期望请求携带有效的 OmniRoute API key;此 key 仅用于网关侧认证。 | -| `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | 设为 true 时,Bifrost sidecar 路由通过网关以 SSE 流式返回响应,而非 TS 流式 executor。设为 `0` 可强制通过网关返回非流式 JSON 响应。 | -| `BIFROST_TIMEOUT_MS` | `30000` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | 代理到 Bifrost 网关时的每个请求超时(毫秒)。超时时路由通过 `X-Bifrost-Fallback` 头返回 TS 中继路径。 | -| `OMNIROUTE_BIFROST_KEY` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | `BIFROST_API_KEY` 的别名(供通过 `OMNIROUTE_*` 读取环境变量的脚本使用)。两者同时设置时 `BIFROST_API_KEY` 优先。 | -| `OMNIROUTE_RELAY_BACKEND` | `ts` / `auto` | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | `/api/v1/relay/chat/completions` 的中继后端:`ts | bifrost | auto`。`ts` = TypeScript 中继(未配置 Bifrost 时的默认值);`auto` 在 `BIFROST_BASE_URL` 已设置且 `BIFROST_ENABLED` ≠ `0` 时选择 Bifrost,若 sidecar 不可达则自动回退到 TS;`bifrost` 强制 Bifrost(严格,无回退)。认证/速率限制/注入安全护栏/allowlist 始终在 Next 路由中首先运行。响应携带 `X-Routing-Backend` / `X-Routing-Fallback`。 | -| `RELAY_ROUTING_BACKEND` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | `OMNIROUTE_RELAY_BACKEND` 的已接受别名(相同的 `ts | bifrost | auto` 值)。两者同时设置时 `OMNIROUTE_RELAY_BACKEND` 优先。 | -| `OMNIROUTE_BIFROST_FAILURE_COOLDOWN_MS` | `5000` | `src/app/api/v1/relay/chat/completions/bifrostCooldown.ts` | 在 `auto` 模式下 Bifrost sidecar 跳失败后的冷却时间(毫秒),在此期间中继重新尝试 sidecar 之前直接走 TS 路径;冷却过后再次探测。`0` 禁用。仅在 `OMNIROUTE_RELAY_BACKEND=auto` 时生效。 | -| `OMNIROUTE_TLS_CERT` | _(未设置)_ | `bin/cli/commands/serve.mjs` | PEM TLS 证书路径,用于 `omniroute serve` 通过 HTTPS 提供服务(等同于 `--tls-cert`)。必须与 `OMNIROUTE_TLS_KEY` 配对;独立服务器随后在同一监听器上终止 TLS(`wss://` 工作不变)。未设置 → 纯 HTTP。仅提供证书或密钥中的一个,或路径不可读,会记录警告并保持 HTTP。 | -| `OMNIROUTE_TLS_KEY` | _(未设置)_ | `bin/cli/commands/serve.mjs` | `omniroute serve` HTTPS 的 PEM TLS 私钥路径(等同于 `--tls-key`)。必须与 `OMNIROUTE_TLS_CERT` 配对。参阅 `OMNIROUTE_TLS_CERT`。 | -| `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED` | `0` | `src/lib/security/localEndpoints.ts` | `/api/local/*` 路由的主开关。未设置或设为 `0` 时,所有 `/api/local/*` 路由在生产环境中返回 503。在非 loopback 部署中必须设为 `1` 才能启用 Redis 启动器及类似的一键本地服务启动器。与 `isLocalOnlyPath()` 路由守卫分类(`src/server/authz/routeGuard.ts` 中的 `LOCAL_ONLY_API_PREFIXES`)构成双保险。 | -| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(未设置)_ | `src/lib/security/localEndpoints.ts` | 非 loopback 上的 `/api/local/*` 调用者的 Bearer Token(如桌面应用)。设置时,来自非 loopback IP 的请求必须携带 `Authorization: Bearer `。在非 loopback 部署中且 `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` 时必需。 | -| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | 一键 Redis 启动器(`omniroute redis up`)的容器名。CLI 和 `RedisLauncherPanel` GUI 均使用。 | -| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | 一键 Redis 启动器的主机端口。如果主机已绑定 6379 则提升。容器内部端口保持 6379。 | -| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | 一键 Redis 启动器使用的 Redis 镜像。根据需要覆盖为 `redis:8-alpine` 或私有注册表镜像。 | -| `QDRANT_HOST` | `qdrant` | _(可选集群配置文件)_ | `--profile memory` 活跃时 Qdrant sidecar 的主机名。默认指向网内 qdrant 服务名;覆盖为外部部署。仅在代码中 `qdrantEnabled` 为 `true` 时使用(`src/lib/memory/vectorStore.ts:108`)。 | -| `QDRANT_PORT` | `6333` | _(可选集群配置文件)_ | Qdrant sidecar 的 REST 端口。 | -| `QDRANT_GRPC_PORT` | `6334` | _(可选集群配置文件)_ | Qdrant sidecar 的 gRPC 端口。偏好在流式操作中使用 gRPC 而非 REST 的客户端库使用。 | -| `QDRANT_API_KEY` | _(未设置)_ | _(可选集群配置文件)_ | Qdrant Cloud 或认证的本地实例的可选 API key。空 → 不发送 `api-key` 头。 | -| `QDRANT_COLLECTION` | `omniroute-memory` | _(可选集群配置文件)_ | OmniRoute 的对话记忆嵌入的 collection 名称。首次运行时以 `QDRANT_VECTOR_SIZE` 维度创建。 | -| `QDRANT_EMBEDDING_MODEL` | `text-embedding-3-small` | _(可选集群配置文件)_ | Qdrant collection 元数据中记录的默认嵌入模型名称。实际嵌入由 OmniRoute 设置中 `embeddingModel` 字段指向的任意服务商生成。 | -| `QDRANT_VECTOR_SIZE` | `1536` | _(可选集群配置文件)_ | 嵌入向量维度。必须与用于嵌入的模型匹配(text-embedding-3-small → 1536;ada-002 → 1536;nomic-embed-text → 768)。 | -| `QDRANT_HNSW_EF_CONSTRUCT` | `128` | _(可选集群配置文件)_ | HNSW 索引构建时精度。值越高构建越慢、搜索越快。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `REDIS_URL` | `redis://localhost:6379` | `src/shared/utils/rateLimiter.ts` | 速率限制器后端的 Redis 连接字符串。 | +| `ALIBABA_CODING_PLAN_HOST` | _(生产主机)_ | `open-sse/services/bailianQuotaFetcher.ts` | 覆盖用于获取阿里巴巴 Bailian coding-plan 配额的主机。 | +| `ALIBABA_CODING_PLAN_QUOTA_URL` | 派生自主机 | `open-sse/services/bailianQuotaFetcher.ts` | 阿里巴巴 Bailian 的完整配额 URL 覆盖。 | +| `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | 计算提示预算时为补全输出保留的 Token 数。 | +| `MODEL_ALIAS_COMPAT_ENABLED` | 启用 | `open-sse/services/model.ts` | 切换旧客户端使用的旧版模型别名兼容层。 | +| `OMNIROUTE_EMERGENCY_FALLBACK` | 启用 | `open-sse/services/emergencyFallback.ts` | 设为 `false`(或 `0`)可禁用紧急预算耗尽回退,该回退将失败的请求重新路由到免费 `nvidia`/`openai/gpt-oss-120b` 模型。有效优先级为 Feature Flags DB 覆盖 > 环境变量 > 默认值;如果不可用,服务回退到原始环境变量值。 | +| `COMMAND_CODE_CALLBACK_PORT` | _(未设置)_ | `src/app/api/providers/command-code/auth/shared.ts` | Command Code CLI 辅助使用的 OAuth 风格回调的本地端口。 | +| `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | 作为 `x-command-code-version` 头发送到 Command Code 上游的值。覆盖以升级 CLI 版本。 | +| `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | MITM 调试代理的本地绑定端口。 | +| `MITM_DISABLE_TLS_VERIFY` | `0` | `src/mitm/server.cjs` | 设为 `1` 可禁用上游 TLS 校验(仅限开发)。 | +| `MITM_IDLE_TIMEOUT_MS` | `60000` | `src/mitm/socketTimeouts.ts`, `src/mitm/server.cjs` | 代理连接的空闲套接字超时(毫秒);超过该时间的空闲套接字会被拆除,避免泄露半打开隧道。 | +| `MITM_VERBOSE` | `1` | `src/mitm/server.cjs`, `src/mitm/_internal/bypass.cjs` | 路由决策日志详细程度:`0` 静默,值越大记录越多 bypass/路由决策。 | +| `ONEPROXY_ENABLED` | `true` | `src/lib/oneproxySync.ts` | 启用 1Proxy 出口池同步。 | +| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | `src/lib/oneproxySync.ts` | 1Proxy 服务 API URL 覆盖。 | +| `ONEPROXY_MAX_PROXIES` | `500` | `src/lib/oneproxySync.ts` | 每次同步导入的最大代理数。 | +| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | `src/lib/oneproxySync.ts` | 导入代理的最低质量分。 | +| `FREE_PROXY_1PROXY_ENABLED` | `true` | `src/lib/freeProxyProviders/oneproxy.ts` | 启用 1proxy 免费代理源。设为 `false` 可禁用。 | +| `FREE_PROXY_1PROXY_API_URL` | _(见 oneproxy.ts)_ | `src/lib/freeProxyProviders/oneproxy.ts` | 1proxy API URL 覆盖。 | +| `FREE_PROXY_1PROXY_MAX` | `500` | `src/lib/freeProxyProviders/oneproxy.ts` | 从 1proxy 每次同步获取的最大代理数。 | +| `FREE_PROXY_1PROXY_MIN_QUALITY` | `50` | `src/lib/freeProxyProviders/oneproxy.ts` | 1proxy 导入的最低质量分阈值。 | +| `FREE_PROXY_PROXIFLY_ENABLED` | `true` | `src/lib/freeProxyProviders/proxifly.ts` | 启用 Proxifly 免费代理源。设为 `false` 可禁用。 | +| `FREE_PROXY_PROXIFLY_QUANTITY` | `100` | `src/lib/freeProxyProviders/proxifly.ts` | 每次 Proxifly 同步获取的代理数量。 | +| `FREE_PROXY_PROXIFLY_ANONYMITY` | `elite` | `src/lib/freeProxyProviders/proxifly.ts` | Proxifly 的匿名级别过滤(`elite`、`anonymous`、`transparent`)。 | +| `FREE_PROXY_IPLOCATE_ENABLED` | `false` | `src/lib/freeProxyProviders/iplocate.ts` | 启用 IPLocate 免费代理源。仅手动启用。 | +| `FREE_PROXY_IPLOCATE_BASE_URL` | `https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols` | `src/lib/freeProxyProviders/iplocate.ts` | IPLocate 代理列表基础 URL 覆盖。 | +| `NEXT_PUBLIC_VERCEL_RELAY_ENABLED` | `true` | `src/app/(dashboard)/…/ProxyPoolTab.tsx` | 在 Proxy Pool 选项卡中显示/隐藏 Deploy Vercel Relay 按钮。 | +| `VERCEL_API_BASE` | `https://api.vercel.com` | `src/app/api/settings/proxy/vercel-deploy/route.ts` | Vercel API 基础 URL 覆盖(用于测试)。 | +| `NEXT_PUBLIC_VERCEL_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/…/VercelRelayModal.tsx` | Vercel Relay 部署弹窗中预填的默认项目名。 | +| `TAILSCALE_BIN` | _(自动检测)_ | `src/lib/tailscaleTunnel.ts` | `tailscale` 二进制文件的显式路径。 | +| `TAILSCALED_BIN` | _(自动检测)_ | `src/lib/tailscaleTunnel.ts` | `tailscaled` 守护进程二进制文件的显式路径。 | +| `TAILSCALE_AUTHKEY` | _(未设置)_ | `src/lib/tailscaleTunnel.ts` | 非交互式/无头 `tailscale up` 的预共享 Tailscale 认证密钥(通过 `--auth-key=` 传递)。未设置时,登录回退到交互式浏览器认证 URL。 | +| `NGROK_AUTHTOKEN` | _(未设置)_ | `src/lib/ngrokTunnel.ts` | 认证出口 ngrok 隧道。 | +| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | 磁盘上保留的最大 SQLite 备份文件数。覆盖从 Settings → Database backup retention 保存的值。 | +| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | 保留备份的最大天数。`0` 禁用基于时间的清理。覆盖从 Settings → Database backup retention 保存的值。 | +| `OMNIROUTE_TLS_PROXY_URL` | _(未设置)_ | `open-sse/services/chatgptTlsClient.ts` | 覆盖测试用的 TLS sidecar URL。生产环境应保持未设置。 | +| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | 入口点权限检查的容器运行时提示。在无根 Podman 下设为 `podman`,以便修复指令使用 `podman unshare chown` 而非 `sudo chown`。 | +| `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | 配额共享消费存储后端:`sqlite`(默认)或 `redis`。 | +| `QUOTA_STORE_REDIS_URL` | _(未设置)_ | `src/lib/quota/storeFactory.ts` | `QUOTA_STORE_DRIVER=redis` 时使用的 Redis 连接字符串(如 `redis://localhost:6379`)。 | +| `QUOTA_SATURATION_THRESHOLD` | `0.5` | `src/lib/quota/enforce.ts` | 池饱和比率(0..1);达到或超过该值时池进入严格模式(不允许借用)。 | +| `QUOTA_SOFT_DEPRIORITIZE_FACTOR` | `0.7` | `open-sse/services/combo.ts` | 软配额策略降低目标优先级时应用的分数乘数(0..1)。 | +| `STATUS_SOFT_DEPRIORITIZE_FACTOR` | `0.5` | `open-sse/services/combo/autoStrategy.ts` | 预检配额截止关闭时(#4540),在 auto-combo 评分中对已耗尽的服务商(`credits_exhausted`/`rate_limited`)应用的分数乘数(0..1)。 | +| `QUOTA_CONSUMPTION_RETENTION_DAYS` | `14` | `src/lib/db/quotaConsumption.ts` | `quota_consumption` 桶在 GC(`gcQuotaConsumption`)前的保留窗口(天)。 | +| `QUOTA_PREFLIGHT_CUTOFF_ENABLED` | `false` | `src/lib/resilience/settings.ts` | 启用(默认关闭):在评分前丢弃低配额候选项的自动路由硬配额截止。 | +| `OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL` | `false` | `open-sse/services/autoCombo/virtualFactory.ts` | 启用(默认关闭):当 `auto/:` 过滤器没有匹配到已连接候选项时,恢复旧版回退到完整(未过滤)池的行为,而非返回空池。默认关闭使 `:free` 意为"仅免费层"。 | +| `AGENTBRIDGE_UPSTREAM_CA_CERT` | _(未设置)_ | `src/mitm/manager.ts` | AgentBridge 上游 TLS 连接信任的额外 CA 证书(PEM)。 | +| `INSPECTOR_BUFFER_SIZE` | `1000` | `src/mitm/inspector/buffer.ts` | Traffic Inspector 环形缓冲区中保留的最大捕获请求数。 | +| `INSPECTOR_MAX_BODY_KB` | `1024` | `src/mitm/inspector/buffer.ts` | 截断前捕获的请求/响应体最大大小(KB)。 | +| `INSPECTOR_HTTP_PROXY_PORT` | `8080` | `src/mitm/inspector/httpProxyServer.ts` | Traffic Inspector HTTP 代理的本地端口。 | +| `INSPECTOR_HTTP_PROXY_AUTOSTART` | `false` | `src/mitm/inspector/httpProxyServer.ts` | 启动时自动启动 inspector HTTP 代理。 | +| `INSPECTOR_TLS_INTERCEPT` | `false` | `src/lib/inspector/captureState.ts` | 对捕获的 HTTPS 流量启用 TLS 拦截(MITM)。 | +| `INSPECTOR_LLM_HOSTS_EXTRA` | _(未设置)_ | `src/lib/inspector/captureState.ts` | 额外的主机名(逗号分隔),被视为大语言模型端点以进行捕获。 | +| `INSPECTOR_MASK_SECRETS` | `true` | `src/mitm/inspector/buffer.ts` | 在捕获流量中脱敏(认证头 / API key)。 | +| `INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES` | `30` | `src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts` | 系统代理安全护栏自动还原操作系统代理设置前的分钟数。 | +| `INSPECTOR_INTERNAL_INGEST_TOKEN` | _(自动)_ | `src/app/api/tools/traffic-inspector/internal/ingest/route.ts` | 认证进入 inspector 的内部捕获摄入的 Token。 | +| `PLAYGROUND_COMPARE_MAX_COLUMNS` | `4` | `src/app/(dashboard)/dashboard/playground/` | Playground 比较模式下的最大并排列数。 | +| `PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL` | _(未设置)_ | `src/app/(dashboard)/dashboard/playground/` | Playground 'improve prompt' 操作的默认模型(未设置时回退到活跃模型)。 | +| `BIFROST_ENABLED` | `1` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | bifrost sidecar 代理的主开关。设为 `0` 时,路由返回 503 并带 `X-Bifrost-Killswitch` 头,运维人员被弹回 TS 路径。无需重新部署即可禁用 sidecar(tier-1 路由事件、密钥轮换)。 | +| `BIFROST_BASE_URL` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | 设置时,Bifrost sidecar 代理路由将 `/v1/chat/completions` 流量转发到此 Go 网关而非 TS 中继处理器。未设置 → 503-with-fallback。尾部斜杠会被剥离。 | +| `BIFROST_API_KEY` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Bifrost 网关的 API key(作为 `Authorization: Bearer ...` 发送)。未设置时,路由期望请求携带有效的 OmniRoute API key;此 key 仅用于网关侧认证。 | +| `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | 设为 true 时,Bifrost sidecar 路由通过网关以 SSE 流式返回响应,而非 TS 流式 executor。设为 `0` 可强制通过网关返回非流式 JSON 响应。 | +| `BIFROST_TIMEOUT_MS` | `30000` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | 代理到 Bifrost 网关时的每个请求超时(毫秒)。超时时路由通过 `X-Bifrost-Fallback` 头返回 TS 中继路径。 | +| `OMNIROUTE_BIFROST_KEY` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | `BIFROST_API_KEY` 的别名(供通过 `OMNIROUTE_*` 读取环境变量的脚本使用)。两者同时设置时 `BIFROST_API_KEY` 优先。 | +| `OMNIROUTE_RELAY_BACKEND` | `ts` / `auto` | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | `/api/v1/relay/chat/completions` 的中继后端:`ts | bifrost | auto`。`ts` = TypeScript 中继(未配置 Bifrost 时的默认值);`auto`在`BIFROST_BASE_URL`已设置且`BIFROST_ENABLED`≠`0` 时选择 Bifrost,若 sidecar 不可达则自动回退到 TS;`bifrost`强制 Bifrost(严格,无回退)。认证/速率限制/注入安全护栏/allowlist 始终在 Next 路由中首先运行。响应携带`X-Routing-Backend`/`X-Routing-Fallback`。 | +| `RELAY_ROUTING_BACKEND` | _(未设置)_ | `src/app/api/v1/relay/chat/completions/routingBackend.ts` | `OMNIROUTE_RELAY_BACKEND` 的已接受别名(相同的 `ts | bifrost | auto`值)。两者同时设置时`OMNIROUTE_RELAY_BACKEND` 优先。 | +| `OMNIROUTE_BIFROST_FAILURE_COOLDOWN_MS` | `5000` | `src/app/api/v1/relay/chat/completions/bifrostCooldown.ts` | 在 `auto` 模式下 Bifrost sidecar 跳失败后的冷却时间(毫秒),在此期间中继重新尝试 sidecar 之前直接走 TS 路径;冷却过后再次探测。`0` 禁用。仅在 `OMNIROUTE_RELAY_BACKEND=auto` 时生效。 | +| `OMNIROUTE_TLS_CERT` | _(未设置)_ | `bin/cli/commands/serve.mjs` | PEM TLS 证书路径,用于 `omniroute serve` 通过 HTTPS 提供服务(等同于 `--tls-cert`)。必须与 `OMNIROUTE_TLS_KEY` 配对;独立服务器随后在同一监听器上终止 TLS(`wss://` 工作不变)。未设置 → 纯 HTTP。仅提供证书或密钥中的一个,或路径不可读,会记录警告并保持 HTTP。 | +| `OMNIROUTE_TLS_KEY` | _(未设置)_ | `bin/cli/commands/serve.mjs` | `omniroute serve` HTTPS 的 PEM TLS 私钥路径(等同于 `--tls-key`)。必须与 `OMNIROUTE_TLS_CERT` 配对。参阅 `OMNIROUTE_TLS_CERT`。 | +| `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED` | `0` | `src/lib/security/localEndpoints.ts` | `/api/local/*` 路由的主开关。未设置或设为 `0` 时,所有 `/api/local/*` 路由在生产环境中返回 503。在非 loopback 部署中必须设为 `1` 才能启用 Redis 启动器及类似的一键本地服务启动器。与 `isLocalOnlyPath()` 路由守卫分类(`src/server/authz/routeGuard.ts` 中的 `LOCAL_ONLY_API_PREFIXES`)构成双保险。 | +| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(未设置)_ | `src/lib/security/localEndpoints.ts` | 非 loopback 上的 `/api/local/*` 调用者的 Bearer Token(如桌面应用)。设置时,来自非 loopback IP 的请求必须携带 `Authorization: Bearer `。在非 loopback 部署中且 `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` 时必需。 | +| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | 一键 Redis 启动器(`omniroute redis up`)的容器名。CLI 和 `RedisLauncherPanel` GUI 均使用。 | +| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | 一键 Redis 启动器的主机端口。如果主机已绑定 6379 则提升。容器内部端口保持 6379。 | +| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | 一键 Redis 启动器使用的 Redis 镜像。根据需要覆盖为 `redis:8-alpine` 或私有注册表镜像。 | +| `QDRANT_HOST` | `qdrant` | _(可选集群配置文件)_ | `--profile memory` 活跃时 Qdrant sidecar 的主机名。默认指向网内 qdrant 服务名;覆盖为外部部署。仅在代码中 `qdrantEnabled` 为 `true` 时使用(`src/lib/memory/vectorStore.ts:108`)。 | +| `QDRANT_PORT` | `6333` | _(可选集群配置文件)_ | Qdrant sidecar 的 REST 端口。 | +| `QDRANT_GRPC_PORT` | `6334` | _(可选集群配置文件)_ | Qdrant sidecar 的 gRPC 端口。偏好在流式操作中使用 gRPC 而非 REST 的客户端库使用。 | +| `QDRANT_API_KEY` | _(未设置)_ | _(可选集群配置文件)_ | Qdrant Cloud 或认证的本地实例的可选 API key。空 → 不发送 `api-key` 头。 | +| `QDRANT_COLLECTION` | `omniroute-memory` | _(可选集群配置文件)_ | OmniRoute 的对话记忆嵌入的 collection 名称。首次运行时以 `QDRANT_VECTOR_SIZE` 维度创建。 | +| `QDRANT_EMBEDDING_MODEL` | `text-embedding-3-small` | _(可选集群配置文件)_ | Qdrant collection 元数据中记录的默认嵌入模型名称。实际嵌入由 OmniRoute 设置中 `embeddingModel` 字段指向的任意服务商生成。 | +| `QDRANT_VECTOR_SIZE` | `1536` | _(可选集群配置文件)_ | 嵌入向量维度。必须与用于嵌入的模型匹配(text-embedding-3-small → 1536;ada-002 → 1536;nomic-embed-text → 768)。 | +| `QDRANT_HNSW_EF_CONSTRUCT` | `128` | _(可选集群配置文件)_ | HNSW 索引构建时精度。值越高构建越慢、搜索越快。 | --- @@ -1033,38 +1034,38 @@ CLI_COMPAT_ALL=1 `scripts/dev/run-ecosystem-tests.mjs` 和 `scripts/build/uninstall.mjs` 使用。 在生产部署中保持下面所有值未设置。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_E2E_BOOTSTRAP_MODE` | `auth` | `scripts/dev/run-next-playwright.mjs` | Playwright 运行器的 E2E 引导模式(`auth`、`fresh`、`reuse`)。 | -| `OMNIROUTE_E2E_PASSWORD` | 回退到 `INITIAL_PASSWORD` | `scripts/dev/run-next-playwright.mjs` | 注入到 Playwright 环境的管理员密码。 | -| `OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | Playwright 运行期间禁用本地健康检查轮询。 | -| `OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | 测试期间禁用 OAuth Token 健康检查循环。 | -| `OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS` | _(未设置)_ | `src/lib/tokenHealthCheck.ts` | 逗号分隔的排除在主动 Token 刷新扫除之外的服务商(如 `codex,openai`)。完全禁用健康检查的目标替代方案 — 短 TTL 的服务商保持刷新,级联服务商保持仅响应式。 | -| `OMNIROUTE_HIDE_HEALTHCHECK_LOGS` | `true` | `scripts/dev/run-next-playwright.mjs` | Playwright stdout 中的健康检查日志静默。 | -| `OMNIROUTE_PLAYWRIGHT_SKIP_BUILD` | `0` | `scripts/dev/run-next-playwright.mjs` | Playwright 启动前跳过 Next.js 生产构建(CI 优化)。 | -| `OMNIROUTE_SKIP_UNINSTALL_HOOK` | `0` | `scripts/build/uninstall.mjs` | 跳过 OmniRoute 卸载钩子(CI 用,保持 `node_modules` 完整)。 | -| `ECOSYSTEM_SERVER_WAIT_MS` | `180000` | `scripts/dev/run-ecosystem-tests.mjs` | 服务器在运行生态系统/协议测试前变为健康的等待时间(毫秒)。 | -| `ELECTRON_SMOKE_URL` | `http://127.0.0.1:20128/login` | `scripts/dev/smoke-electron-packaged.mjs` | Electron 烟雾测试工具期望打包应用提供服务的 URL。 | -| `ELECTRON_SMOKE_TIMEOUT_MS` | `45000` | `scripts/dev/smoke-electron-packaged.mjs` | 烟雾测试工具放弃前的总超时时间(毫秒)。 | -| `ELECTRON_SMOKE_SETTLE_MS` | `2000` | `scripts/dev/smoke-electron-packaged.mjs` | 页面加载后的稳定窗口(毫秒)。 | -| `ELECTRON_SMOKE_APP_EXECUTABLE` | _(自动)_ | `scripts/dev/smoke-electron-packaged.mjs` | 打包的 Electron 可执行文件的显式路径。 | -| `ELECTRON_SMOKE_DATA_DIR` | _(临时目录)_ | `scripts/dev/smoke-electron-packaged.mjs` | Electron 烟雾测试运行的数据目录。 | -| `ELECTRON_SMOKE_KEEP_DATA` | `0` | `scripts/dev/smoke-electron-packaged.mjs` | 设为 `1` 可在运行后保留烟雾测试数据目录。 | -| `ELECTRON_SMOKE_STREAM_LOGS` | `0` | `scripts/dev/smoke-electron-packaged.mjs` | 设为 `1` 可在运行期间将 Electron 日志流式输出到 stdout。 | -| `CLI_DEVIN_BIN` | _(PATH 查找)_ | `open-sse/executors/devin-cli.ts` | 覆盖 Devin CLI 二进制文件路径。 | +| 变量 | 默认值 | 源文件 | 说明 | +| -------------------------------------- | ------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_E2E_BOOTSTRAP_MODE` | `auth` | `scripts/dev/run-next-playwright.mjs` | Playwright 运行器的 E2E 引导模式(`auth`、`fresh`、`reuse`)。 | +| `OMNIROUTE_E2E_PASSWORD` | 回退到 `INITIAL_PASSWORD` | `scripts/dev/run-next-playwright.mjs` | 注入到 Playwright 环境的管理员密码。 | +| `OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | Playwright 运行期间禁用本地健康检查轮询。 | +| `OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | 测试期间禁用 OAuth Token 健康检查循环。 | +| `OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS` | _(未设置)_ | `src/lib/tokenHealthCheck.ts` | 逗号分隔的排除在主动 Token 刷新扫除之外的服务商(如 `codex,openai`)。完全禁用健康检查的目标替代方案 — 短 TTL 的服务商保持刷新,级联服务商保持仅响应式。 | +| `OMNIROUTE_HIDE_HEALTHCHECK_LOGS` | `true` | `scripts/dev/run-next-playwright.mjs` | Playwright stdout 中的健康检查日志静默。 | +| `OMNIROUTE_PLAYWRIGHT_SKIP_BUILD` | `0` | `scripts/dev/run-next-playwright.mjs` | Playwright 启动前跳过 Next.js 生产构建(CI 优化)。 | +| `OMNIROUTE_SKIP_UNINSTALL_HOOK` | `0` | `scripts/build/uninstall.mjs` | 跳过 OmniRoute 卸载钩子(CI 用,保持 `node_modules` 完整)。 | +| `ECOSYSTEM_SERVER_WAIT_MS` | `180000` | `scripts/dev/run-ecosystem-tests.mjs` | 服务器在运行生态系统/协议测试前变为健康的等待时间(毫秒)。 | +| `ELECTRON_SMOKE_URL` | `http://127.0.0.1:20128/login` | `scripts/dev/smoke-electron-packaged.mjs` | Electron 烟雾测试工具期望打包应用提供服务的 URL。 | +| `ELECTRON_SMOKE_TIMEOUT_MS` | `45000` | `scripts/dev/smoke-electron-packaged.mjs` | 烟雾测试工具放弃前的总超时时间(毫秒)。 | +| `ELECTRON_SMOKE_SETTLE_MS` | `2000` | `scripts/dev/smoke-electron-packaged.mjs` | 页面加载后的稳定窗口(毫秒)。 | +| `ELECTRON_SMOKE_APP_EXECUTABLE` | _(自动)_ | `scripts/dev/smoke-electron-packaged.mjs` | 打包的 Electron 可执行文件的显式路径。 | +| `ELECTRON_SMOKE_DATA_DIR` | _(临时目录)_ | `scripts/dev/smoke-electron-packaged.mjs` | Electron 烟雾测试运行的数据目录。 | +| `ELECTRON_SMOKE_KEEP_DATA` | `0` | `scripts/dev/smoke-electron-packaged.mjs` | 设为 `1` 可在运行后保留烟雾测试数据目录。 | +| `ELECTRON_SMOKE_STREAM_LOGS` | `0` | `scripts/dev/smoke-electron-packaged.mjs` | 设为 `1` 可在运行期间将 Electron 日志流式输出到 stdout。 | +| `CLI_DEVIN_BIN` | _(PATH 查找)_ | `open-sse/executors/devin-cli.ts` | 覆盖 Devin CLI 二进制文件路径。 | ### 文档翻译管线 由 `scripts/i18n/run-translation.mjs`(`npm run i18n:run` 命令)使用。 所有五个变量默认未设置 — 仅在能够运行文档翻译器的机器上的 `.env` 中设置。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_TRANSLATION_API_URL` | _(未设置)_ | `scripts/i18n/run-translation.mjs` | 翻译后端的 OpenAI 兼容基础 URL。 | -| `OMNIROUTE_TRANSLATION_API_KEY` | _(未设置)_ | `scripts/i18n/run-translation.mjs` | 翻译后端的 Bearer Token(永不被记录)。 | -| `OMNIROUTE_TRANSLATION_MODEL` | _(未设置)_ | `scripts/i18n/run-translation.mjs` | 模型 ID,如 `gpt-4o-mini` 或 `cx/gpt-5.4-mini`。 | -| `OMNIROUTE_TRANSLATION_TIMEOUT_MS` | `60000` | `scripts/i18n/run-translation.mjs` | 每个请求的超时,毫秒。 | -| `OMNIROUTE_TRANSLATION_CONCURRENCY` | `4` | `scripts/i18n/run-translation.mjs` | 跨多个文件/locale 运行时的并行翻译请求数。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ----------------------------------- | ---------- | ---------------------------------- | ------------------------------------------------ | +| `OMNIROUTE_TRANSLATION_API_URL` | _(未设置)_ | `scripts/i18n/run-translation.mjs` | 翻译后端的 OpenAI 兼容基础 URL。 | +| `OMNIROUTE_TRANSLATION_API_KEY` | _(未设置)_ | `scripts/i18n/run-translation.mjs` | 翻译后端的 Bearer Token(永不被记录)。 | +| `OMNIROUTE_TRANSLATION_MODEL` | _(未设置)_ | `scripts/i18n/run-translation.mjs` | 模型 ID,如 `gpt-4o-mini` 或 `cx/gpt-5.4-mini`。 | +| `OMNIROUTE_TRANSLATION_TIMEOUT_MS` | `60000` | `scripts/i18n/run-translation.mjs` | 每个请求的超时,毫秒。 | +| `OMNIROUTE_TRANSLATION_CONCURRENCY` | `4` | `scripts/i18n/run-translation.mjs` | 跨多个文件/locale 运行时的并行翻译请求数。 | --- @@ -1072,26 +1073,26 @@ CLI_COMPAT_ALL=1 以下变量出现在旧版 `.env.example` 中,但在当前代码库中**没有运行时引用**。它们已被移除: -| 变量 | 原因 | -| --- | --- | -| `STORAGE_DRIVER=sqlite` | 任何源文件均未读取。SQLite 是唯一支持的驱动 — 无需选择。 | -| `INSTANCE_NAME=omniroute` | 存在于旧文档/环境模板中但运行时未使用。可能在未来多实例功能中回归。 | -| `SQLITE_MAX_SIZE_MB=2048` | 源代码中未引用。数据库大小未被人为限制。 | -| `SQLITE_CLEAN_LEGACY_FILES=true` | 源代码中未引用。旧版清理可能已被移除。 | -| `CLI_ROO_BIN` | 未在 `src/shared/services/cliRuntime.ts` 中注册。 | -| `CLI_KIMI_CODING_BIN` | 未在 `src/shared/services/cliRuntime.ts` 中注册(Kimi Coding 使用 OAuth,而非 CLI 二进制文件)。 | -| `IFLOW_OAUTH_CLIENT_ID` / `IFLOW_OAUTH_CLIENT_SECRET` | 源代码中任何地方均未引用。 | +| 变量 | 原因 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_DRIVER=sqlite` | 任何源文件均未读取。SQLite 是唯一支持的驱动 — 无需选择。 | +| `INSTANCE_NAME=omniroute` | 存在于旧文档/环境模板中但运行时未使用。可能在未来多实例功能中回归。 | +| `SQLITE_MAX_SIZE_MB=2048` | 源代码中未引用。数据库大小未被人为限制。 | +| `SQLITE_CLEAN_LEGACY_FILES=true` | 源代码中未引用。旧版清理可能已被移除。 | +| `CLI_ROO_BIN` | 未在 `src/shared/services/cliRuntime.ts` 中注册。 | +| `CLI_KIMI_CODING_BIN` | 未在 `src/shared/services/cliRuntime.ts` 中注册(Kimi Coding 使用 OAuth,而非 CLI 二进制文件)。 | +| `IFLOW_OAUTH_CLIENT_ID` / `IFLOW_OAUTH_CLIENT_SECRET` | 源代码中任何地方均未引用。 | | `CEREBRAS_API_KEY` / `COHERE_API_KEY` / `FIREWORKS_API_KEY` / `GROQ_API_KEY` / `MISTRAL_API_KEY` / `NEBIUS_API_KEY` / `PERPLEXITY_API_KEY` / `TOGETHER_API_KEY` / `XAI_API_KEY` | 在 v3.8.0 中移除。运行时不再读取这些环境变量 — 凭证来自 Dashboard / `data/provider-credentials.json` / 加密数据库。 | -| `CURSOR_PROTOBUF_DEBUG` | 在 v3.8.0 中移除。Cursor executor 使用 `CURSOR_DEBUG` / `CURSOR_STREAM_DEBUG`(参阅 §22)。 | -| `CLI_COMPAT_KIRO` | 在 v3.8.0 中移除。Kiro 在 `CLI_COMPAT_OMITTED_PROVIDER_IDS` 中 — 其开关无效。 | -| `QIANFAN_API_KEY` | 在 v3.8.0 中随其他未使用的服务商 API key 桩一起移除。 | +| `CURSOR_PROTOBUF_DEBUG` | 在 v3.8.0 中移除。Cursor executor 使用 `CURSOR_DEBUG` / `CURSOR_STREAM_DEBUG`(参阅 §22)。 | +| `CLI_COMPAT_KIRO` | 在 v3.8.0 中移除。Kiro 在 `CLI_COMPAT_OMITTED_PROVIDER_IDS` 中 — 其开关无效。 | +| `QIANFAN_API_KEY` | 在 v3.8.0 中随其他未使用的服务商 API key 桩一起移除。 | ### 默认值修正 -| 变量 | 旧 `.env.example` 值 | 实际代码默认值 | 修正 | -| --- | --- | --- | --- | -| `APP_LOG_RETENTION_DAYS` | `90` | `7` | ✅ 已移除误导性值;记录 `7` 为默认值 | -| `CALL_LOG_RETENTION_DAYS` | `90` | `7` | ✅ 已移除误导性值;记录 `7` 为默认值 | +| 变量 | 旧 `.env.example` 值 | 实际代码默认值 | 修正 | +| ------------------------- | -------------------- | -------------- | ------------------------------------ | +| `APP_LOG_RETENTION_DAYS` | `90` | `7` | ✅ 已移除误导性值;记录 `7` 为默认值 | +| `CALL_LOG_RETENTION_DAYS` | `90` | `7` | ✅ 已移除误导性值;记录 `7` 为默认值 | ### OpenCode 配置重新生成(临时工具) @@ -1099,17 +1100,17 @@ CLI_COMPAT_ALL=1 其中包含从运行中的 OmniRoute 实例拉取的准确的 `limit.context` 和 `limit.output` 值。 这些变量都不是正常运行所需的 — 该脚本仅供开发者工具使用。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | -| `OMNIROUTE_URL` | `http://localhost:20128` | `scripts/ad-hoc/regen-opencode-config.ts` | 查询 `/v1/models` 的 OmniRoute 实例基础 URL。 | -| `OMNIROUTE_KEY` | _(未设置)_ | `scripts/ad-hoc/regen-opencode-config.ts` | 认证 OmniRoute `/v1/models` 端点的 API key。未设置时回退到 `OPENCODE_API_KEY`。 | -| `OPENCODE_API_KEY` | _(未设置)_ | `scripts/ad-hoc/regen-opencode-config.ts` | 写入重新生成的 `opencode.json` 的 OpenCode 风格 API key (`sk-...`)。未设置时回退到 `OMNIROUTE_KEY`。 | +| 变量 | 默认值 | 源文件 | 说明 | +| ------------------ | ------------------------ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_URL` | `http://localhost:20128` | `scripts/ad-hoc/regen-opencode-config.ts` | 查询 `/v1/models` 的 OmniRoute 实例基础 URL。 | +| `OMNIROUTE_KEY` | _(未设置)_ | `scripts/ad-hoc/regen-opencode-config.ts` | 认证 OmniRoute `/v1/models` 端点的 API key。未设置时回退到 `OPENCODE_API_KEY`。 | +| `OPENCODE_API_KEY` | _(未设置)_ | `scripts/ad-hoc/regen-opencode-config.ts` | 写入重新生成的 `opencode.json` 的 OpenCode 风格 API key (`sk-...`)。未设置时回退到 `OMNIROUTE_KEY`。 | ### 压缩离线评估工具(临时工具) 由离线压缩评估 CLI `scripts/compression-eval/index.ts` 使用。 正常运行不需要 — 仅供开发者工具使用。 -| 变量 | 默认值 | 源文件 | 说明 | -| --- | --- | --- | --- | +| 变量 | 默认值 | 源文件 | 说明 | +| ---------------------------- | ---------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `OMNIROUTE_EVAL_CREDENTIALS` | `{}`(空) | `scripts/compression-eval/index.ts` | 运维人员提供的 JSON 凭证,供离线压缩评估 CLI 使用的服务商使用(通过 `JSON.parse` 解析)。未设置时进行试运行。 | diff --git a/docs/i18n/zh-TW/CLAUDE.md b/docs/i18n/zh-TW/CLAUDE.md index 3023258614..427e04086f 100644 --- a/docs/i18n/zh-TW/CLAUDE.md +++ b/docs/i18n/zh-TW/CLAUDE.md @@ -4,7 +4,7 @@ --- -該文件為在此代碼庫中使用 Claude Code (claude.ai/code) 提供指導。 +該文件為在此程式碼庫中使用 Claude Code (claude.ai/code) 提供指導。 ## 快速開始 @@ -26,7 +26,7 @@ npm run check:cycles # 檢測循環依賴 # 單個測試文件(Node.js 原生測試運行器 — 大多數測試) node --import tsx/esm --test tests/unit/your-file.test.ts -# Vitest(MCP 伺服器,autoCombo,緩存) +# Vitest(MCP 伺服器,autoCombo,快取) npm run test:vitest # 所有測試套件 @@ -37,7 +37,7 @@ npm run test:all --- -## 項目概覽 +## 專案概覽 **OmniRoute** — 統一的 AI 代理/路由器。一個端點,160+ LLM 提供者,自動回退。 @@ -47,14 +47,14 @@ npm run test:all | 處理程序 | `open-sse/handlers/` | 請求處理(聊天、嵌入等) | | 執行器 | `open-sse/executors/` | 特定提供者的 HTTP 調度 | | 轉換器 | `open-sse/translator/` | 格式轉換(OpenAI↔Claude↔Gemini) | -| 轉換器 | `open-sse/transformer/` | 響應 API ↔ 聊天完成 | -| 服務 | `open-sse/services/` | 組合路由、速率限制、緩存等 | -| 資料庫 | `src/lib/db/` | SQLite 域模塊(45+ 文件,55 次遷移) | +| 轉換器 | `open-sse/transformer/` | 回應 API ↔ 聊天完成 | +| 服務 | `open-sse/services/` | 組合路由、速率限制、快取等 | +| 資料庫 | `src/lib/db/` | SQLite 域模組(45+ 文件,55 次遷移) | | 域/策略 | `src/domain/` | 策略引擎、成本規則、回退邏輯 | -| MCP 伺服器 | `open-sse/mcp-server/` | 37 個工具(30 基礎 + 3 內存 + 4 技能),3 個傳輸,大約 13 個範圍 | +| MCP 伺服器 | `open-sse/mcp-server/` | 37 個工具(30 基礎 + 3 記憶 + 4 技能),3 個傳輸,大約 13 個範圍 | | A2A 伺服器 | `src/lib/a2a/` | JSON-RPC 2.0 代理協議 | | 技能 | `src/lib/skills/` | 可擴展的技能框架 | -| 內存 | `src/lib/memory/` | 持久化對話內存 | +| 記憶 | `src/lib/memory/` | 持久化對話記憶 | Monorepo: `src/`(Next.js 16 應用),`open-sse/`(流媒體引擎工作區),`electron/`(桌面應用),`tests/`,`bin/`(CLI 入口點)。 @@ -66,17 +66,17 @@ Monorepo: `src/`(Next.js 16 應用),`open-sse/`(流媒體引擎工作區 客戶端 → /v1/chat/completions (Next.js 路由) → CORS → Zod 驗證 → 認證? → 策略檢查 → 提示注入保護 → handleChatCore() [open-sse/handlers/chatCore.ts] - → 緩存檢查 → 速率限制 → 組合路由? - → resolveComboTargets() → 針對每個目標調用 handleSingleModel() + → 快取檢查 → 速率限制 → 組合路由? + → resolveComboTargets() → 針對每個目標呼叫 handleSingleModel() → translateRequest() → getExecutor() → executor.execute() - → fetch() 上遊 → 重試 w/ 回退 - → 響應翻譯 → SSE 流或 JSON + → fetch() 上游 → 重試 w/ 回退 + → 回應翻譯 → SSE 流或 JSON → 如果是 Responses API: responsesTransformer.ts TransformStream ``` -API 路由遵循一致的模式:`路由 → CORS 預檢 → Zod 請求體驗證 → 可選認證 (extractApiKey/isValidApiKey) → API 密鑰策略執行 → 處理程序委派 (open-sse)`。沒有全局的 Next.js 中間件 — 攔截是路由特定的。 +API 路由遵循一致的模式:`路由 → CORS 預檢 → Zod 請求體驗證 → 可選認證 (extractApiKey/isValidApiKey) → API 密鑰策略執行 → 處理程序委派 (open-sse)`。沒有全域的 Next.js 中間件 — 攔截是路由特定的。 -**組合路由** (`open-sse/services/combo.ts`): 14 種策略(優先級、加權、優先填充、輪詢、P2C、隨機、最少使用、成本優化、重置感知、嚴格隨機、自動、lkgp、上下文優化、上下文中繼)。每個目標調用 `handleSingleModel()`,該函數用每個目標的錯誤處理和電路斷路器檢查包裝 `handleChatCore()`。有關 9 因子自動組合評分的資訊,請參見 `docs/routing/AUTO-COMBO.md`,有關 3 層彈性的資訊,請參見 `docs/architecture/RESILIENCE_GUIDE.md`。 +**組合路由** (`open-sse/services/combo.ts`): 14 種策略(優先級、加權、優先填充、輪詢、P2C、隨機、最少使用、成本優化、重置感知、嚴格隨機、自動、lkgp、上下文優化、上下文中繼)。每個目標呼叫 `handleSingleModel()`,該函數用每個目標的錯誤處理和電路斷路器檢查包裝 `handleChatCore()`。有關 9 因子自動組合評分的資訊,請參見 `docs/routing/AUTO-COMBO.md`,有關 3 層彈性的資訊,請參見 `docs/architecture/RESILIENCE_GUIDE.md`。 --- @@ -91,7 +91,7 @@ OmniRoute 有三種相關但不同的臨時故障機制。在調試路由行為 **範圍**: 整個提供者,例如 `glm`、`openai`、`anthropic`。 -**目的**: 停止向一個在上遊/服務級別反覆失敗的提供者發送流量,以便一個不健康的提供者不會減慢每個請求的速度。 +**目的**: 停止向一個在上游/服務級別反覆失敗的提供者發送流量,以便一個不健康的提供者不會減慢每個請求的速度。 **實現**: @@ -104,10 +104,10 @@ OmniRoute 有三種相關但不同的臨時故障機制。在調試路由行為 **狀態**: - `CLOSED`: 允許正常流量。 -- `OPEN`: 提供者暫時被阻止;調用者會收到提供者電路打開的響應,或者組合路由跳過到另一個目標。 +- `OPEN`: 提供者暫時被阻止;呼叫者會收到提供者電路打開的回應,或者組合路由跳過到另一個目標。 - `HALF_OPEN`: 重置超時已過;允許探測請求。成功關閉斷路器,失敗再次打開。 -**默認值** (`open-sse/config/constants.ts`): +**預設值** (`open-sse/config/constants.ts`): - OAuth 提供者: 閾值 `3`,重置超時 `60s`。 - API 密鑰提供者: 閾值 `5`,重置超時 `30s`。 @@ -121,7 +121,7 @@ OmniRoute 有三種相關但不同的臨時故障機制。在調試路由行為 不要因正常的帳戶/密鑰/模型錯誤(如大多數 `401`、`403` 或 `429` 情況)而觸發整個提供者斷路器。這些通常屬於連接冷卻或模型鎖定。除非被歸類為終端提供者/帳戶錯誤,否則通用 API 密鑰提供者的 `403` 應該是可恢復的。 -斷路器使用懶惰恢復,而不是後臺定時器。當 `OPEN` 過期時,像 `getStatus()`、`canExecute()` 和 `getRetryAfterMs()` 這樣的讀取會將狀態刷新為 `HALF_OPEN`,以便儀錶板和組合候選構建器不會永遠排除一個過期的提供者。 +斷路器使用懶惰恢復,而不是後台定時器。當 `OPEN` 過期時,像 `getStatus()`、`canExecute()` 和 `getRetryAfterMs()` 這樣的讀取會將狀態刷新為 `HALF_OPEN`,以便儀表板和組合候選構建器不會永遠排除一個過期的提供者。 ### 連接冷卻 @@ -155,11 +155,11 @@ new Date(rateLimitedUntil).getTime() > Date.now(); 冷卻也是懶惰的:當 `rateLimitedUntil` 在過去時,連接再次變得合格。在成功使用時,`clearAccountError()` 會清除 `testStatus`、`rateLimitedUntil`、錯誤欄位和 `backoffLevel`。 -默認連接冷卻行為: +預設連接冷卻行為: - OAuth 基礎冷卻: `5s`。 - API 密鑰基礎冷卻: `3s`。 -- API 密鑰 `429` 應優先考慮上遊重試提示(`Retry-After`、重置頭或可解析的重置文本),如果可用。 +- API 密鑰 `429` 應優先考慮上游重試提示(`Retry-After`、重置頭或可解析的重置文本),如果可用。 - 重複的可恢復故障使用指數回退: ```ts @@ -187,26 +187,26 @@ baseCooldownMs * 2 ** failureIndex; ### 調試指導 - 如果一個提供者的所有密鑰都被跳過,請檢查提供者斷路器狀態和每個連接的 `rateLimitedUntil`/`testStatus`。 -- 如果一個提供者在重置窗口後似乎被永久排除,請檢查代碼是否在讀取原始 `state` 而不是使用 `getStatus()`/`canExecute()`。 +- 如果一個提供者在重置窗口後似乎被永久排除,請檢查程式碼是否在讀取原始 `state` 而不是使用 `getStatus()`/`canExecute()`。 - 如果一個提供者密鑰失敗但其他密鑰應該有效,請優先考慮連接冷卻而不是提供者斷路器。 - 如果只有一個模型失敗,請優先考慮模型鎖定而不是連接冷卻。 - 如果一個狀態應該自我恢復,它應該有一個未來的時間戳/重置超時和一個讀取路徑來刷新過期狀態。永久狀態需要手動憑據或設定更改。 ## 關鍵約定 -### 代碼風格 +### 程式碼風格 - **2個空格**,分號,雙引號,100字符寬度,es5尾隨逗號(通過lint-staged和Prettier強制執行) - **導入**:外部 → 內部(`@/`,`@omniroute/open-sse`)→ 相對 - **命名**:文件=camelCase/kebab,組件=PascalCase,常量=UPPER_SNAKE - **ESLint**:`no-eval`,`no-implied-eval`,`no-new-func` = 在任何地方都報錯;`no-explicit-any` = 在`open-sse/`和`tests/`中警告 -- **TypeScript**:`strict: false`,目標ES2022,模塊esnext,解析器為打包器。優先使用顯式類型。 +- **TypeScript**:`strict: false`,目標ES2022,模組esnext,解析器為打包器。優先使用顯式類型。 ### 資料庫 -- **始終**通過`src/lib/db/`域模塊 — **絕不**在路由或處理程序中編寫原始SQL +- **始終**通過`src/lib/db/`域模組 — **絕不**在路由或處理程序中編寫原始SQL - **絕不**在`src/lib/localDb.ts`中添加邏輯(僅為重新導出層) -- **絕不**從`localDb.ts`進行桶導入 — 而是導入特定的`db/`模塊 +- **絕不**從`localDb.ts`進行桶導入 — 而是導入特定的`db/`模組 - DB單例:`getDbInstance()`來自`src/lib/db/core.ts`(WAL日誌記錄) - 遷移:`src/lib/db/migrations/` — 版本化的SQL文件,冪等,在事務中運行 @@ -221,11 +221,11 @@ baseCooldownMs * 2 ** failureIndex; - **絕不**使用`eval()`,`new Function()`或隱式eval - 使用Zod模式驗證所有輸入 - 在靜態存儲中加密憑據(AES-256-GCM) -- 上遊頭部拒絕列表:`src/shared/constants/upstreamHeaders.ts` — 編輯時保持清理、Zod模式和單元測試一致 -- **公共上遊憑據**(Gemini/Antigravity/Windsurf風格的OAuth client_id/secret + 從公共CLI提取的Firebase Web密鑰):**必須**通過`resolvePublicCred()`嵌入,來自`open-sse/utils/publicCreds.ts` — **絕不**作為字符串字面量。請參見`docs/security/PUBLIC_CREDS.md`以獲取強制模式。 -- **錯誤響應**(HTTP / SSE / 執行器 / MCP處理程序):**必須**通過`buildErrorBody()`或`sanitizeErrorMessage()`路由,來自`open-sse/utils/error.ts` — **絕不**將原始`err.stack`或`err.message`放入響應體中。請參見`docs/security/ERROR_SANITIZATION.md`。 -- **從變量構建的Shell命令**:在調用`exec()`/`spawn()`時,如果腳本需要運行時值,通過`env`選項傳遞(自動進行Shell轉義) — **絕不**將不受信任/外部路徑字符串插入腳本體中。參考:`src/mitm/cert/install.ts::updateNssDatabases`。 -- **默認安全庫**([tldrsec/awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults)):在添加新的安全敏感表面時,優先使用Helmet.js、DOMPurify、ssrf-req-filter、safe-regex、Google Tink,而不是自定義實現。 +- 上游頭部拒絕列表:`src/shared/constants/upstreamHeaders.ts` — 編輯時保持清理、Zod模式和單元測試一致 +- **公共上游憑據**(Gemini/Antigravity/Windsurf風格的OAuth client_id/secret + 從公共CLI提取的Firebase Web密鑰):**必須**通過`resolvePublicCred()`嵌入,來自`open-sse/utils/publicCreds.ts` — **絕不**作為字串字面量。請參見`docs/security/PUBLIC_CREDS.md`以獲取強制模式。 +- **錯誤回應**(HTTP / SSE / 執行器 / MCP處理程序):**必須**通過`buildErrorBody()`或`sanitizeErrorMessage()`路由,來自`open-sse/utils/error.ts` — **絕不**將原始`err.stack`或`err.message`放入回應體中。請參見`docs/security/ERROR_SANITIZATION.md`。 +- **從變量構建的Shell命令**:在呼叫`exec()`/`spawn()`時,如果腳本需要運行時值,通過`env`選項傳遞(自動進行Shell轉義) — **絕不**將不受信任/外部路徑字串插入腳本體中。參考:`src/mitm/cert/install.ts::updateNssDatabases`。 +- **預設安全庫**([tldrsec/awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults)):在添加新的安全敏感表面時,優先使用Helmet.js、DOMPurify、ssrf-req-filter、safe-regex、Google Tink,而不是自定義實現。 --- @@ -236,9 +236,9 @@ baseCooldownMs * 2 ** failureIndex; 1. 在`src/shared/constants/providers.ts`中註冊(加載時進行Zod驗證) 2. 如果需要自定義邏輯,則在`open-sse/executors/`中添加執行器(擴展`BaseExecutor`) 3. 如果是非OpenAI格式,則在`open-sse/translator/`中添加翻譯器 -4. 如果基於OAuth,則在`src/lib/oauth/constants/oauth.ts`中添加OAuth設定 — 如果上遊CLI提供公共client_id/secret,則通過`resolvePublicCred()`嵌入(見`docs/security/PUBLIC_CREDS.md`),**絕不**作為字面量 +4. 如果基於OAuth,則在`src/lib/oauth/constants/oauth.ts`中添加OAuth設定 — 如果上游CLI提供公共client_id/secret,則通過`resolvePublicCred()`嵌入(見`docs/security/PUBLIC_CREDS.md`),**絕不**作為字面量 5. 在`open-sse/config/providerRegistry.ts`中註冊模型 -6. 在`tests/unit/`中編寫測試(如果添加了新的嵌入默認,則包括publicCreds形狀斷言) +6. 在`tests/unit/`中編寫測試(如果添加了新的嵌入預設,則包括publicCreds形狀斷言) ### 添加新API路由 @@ -246,10 +246,10 @@ baseCooldownMs * 2 ** failureIndex; 2. 創建`route.ts`,包含`GET`/`POST`處理程序 3. 遵循模式:CORS → Zod主體驗證 → 可選身份驗證 → 處理程序委託 4. 處理程序放在`open-sse/handlers/`中(從那裡導入,而不是內聯) -5. 錯誤響應使用`buildErrorBody()` / `errorResponse()`來自`open-sse/utils/error.ts`(自動清理 — 絕不將`err.stack`或`err.message`原樣放入主體中)。請參見`docs/security/ERROR_SANITIZATION.md`。 -6. 添加測試 — 包括至少一個斷言,確保錯誤響應不洩露堆棧跟蹤(`!body.error.message.includes("at /")`) +5. 錯誤回應使用`buildErrorBody()` / `errorResponse()`來自`open-sse/utils/error.ts`(自動清理 — 絕不將`err.stack`或`err.message`原樣放入主體中)。請參見`docs/security/ERROR_SANITIZATION.md`。 +6. 添加測試 — 包括至少一個斷言,確保錯誤回應不洩露堆棧跟蹤(`!body.error.message.includes("at /")`) -### 添加新DB模塊 +### 添加新DB模組 1. 創建`src/lib/db/yourModule.ts` — 從`./core.ts`導入`getDbInstance` 2. 導出您的域表的CRUD函數 @@ -262,7 +262,7 @@ baseCooldownMs * 2 ** failureIndex; 1. 在`open-sse/mcp-server/tools/`中添加工具定義,包含Zod輸入模式 + 異步處理程序 2. 在工具集中註冊(通過`createMcpServer()`連接) 3. 分配給適當的範圍 -4. 編寫測試(工具調用記錄到`mcp_audit`表中) +4. 編寫測試(工具呼叫記錄到`mcp_audit`表中) ### 添加新A2A技能 @@ -283,16 +283,16 @@ baseCooldownMs * 2 ** failureIndex; ### 添加新護欄 / 評估 / 技能 / Webhook事件 -- 護欄:`src/lib/guardrails/` → 文檔:`docs/security/GUARDRAILS.md` -- 評估套件:`src/lib/evals/` → 文檔:`docs/frameworks/EVALS.md` -- 技能(沙盒):`src/lib/skills/` → 文檔:`docs/frameworks/SKILLS.md` -- Webhook事件:`src/lib/webhookDispatcher.ts` → 文檔:`docs/frameworks/WEBHOOKS.md` +- 護欄:`src/lib/guardrails/` → 文件:`docs/security/GUARDRAILS.md` +- 評估套件:`src/lib/evals/` → 文件:`docs/frameworks/EVALS.md` +- 技能(沙盒):`src/lib/skills/` → 文件:`docs/frameworks/SKILLS.md` +- Webhook事件:`src/lib/webhookDispatcher.ts` → 文件:`docs/frameworks/WEBHOOKS.md` -## 參考文檔 +## 參考文件 對於任何非平凡的更改,請先閱讀相應的深入分析: -| 領域 | 文檔 | +| 領域 | 文件 | | ------------------------------- | ----------------------------------------------------------------- | | 倉庫導航 | `docs/architecture/REPOSITORY_MAP.md` | | 架構 | `docs/architecture/ARCHITECTURE.md` | @@ -301,10 +301,10 @@ baseCooldownMs * 2 ** failureIndex; | 彈性(3種機制) | `docs/architecture/RESILIENCE_GUIDE.md` | | 推理重放 | `docs/routing/REASONING_REPLAY.md` | | 技能框架 | `docs/frameworks/SKILLS.md` | -| 內存系統(FTS5 + Qdrant) | `docs/frameworks/MEMORY.md` | +| 記憶系統(FTS5 + Qdrant) | `docs/frameworks/MEMORY.md` | | 雲代理 | `docs/frameworks/CLOUD_AGENT.md` | | 保護措施(PII / 注入 / 視覺) | `docs/security/GUARDRAILS.md` | -| 公共上遊憑證(Gemini等) | `docs/security/PUBLIC_CREDS.md` | +| 公共上游憑證(Gemini等) | `docs/security/PUBLIC_CREDS.md` | | 錯誤資訊清理 | `docs/security/ERROR_SANITIZATION.md` | | 評估 | `docs/frameworks/EVALS.md` | | 合規 / 審計 | `docs/security/COMPLIANCE.md` | @@ -333,11 +333,11 @@ baseCooldownMs * 2 ** failureIndex; | 覆蓋門限 | `npm run test:coverage` (75/75/75/70 — 語句/行/函數/分支) | | 覆蓋報告 | `npm run coverage:report` | -**PR 規則**:如果您更改了 `src/`、`open-sse/`、`electron/` 或 `bin/` 中的生產代碼,您必須在同一 PR 中包含或更新測試。 +**PR 規則**:如果您更改了 `src/`、`open-sse/`、`electron/` 或 `bin/` 中的正式程式碼,您必須在同一 PR 中包含或更新測試。 -**測試層級偏好**:單元測試優先 → 集成測試(多模塊或資料庫狀態) → E2E(僅限 UI/工作流)。在修復之前或同時將錯誤重現編碼為自動化測試。 +**測試層級偏好**:單元測試優先 → 集成測試(多模組或資料庫狀態) → E2E(僅限 UI/工作流)。在修復之前或同時將錯誤重現編碼為自動化測試。 -**Copilot 覆蓋政策**:當 PR 更改生產代碼且覆蓋率低於 75%(語句/行/函數)或 70%(分支)時,不僅僅報告 — 添加或更新測試,重新運行覆蓋門限,然後請求確認。在 PR 報告中包含運行的命令、已更改的測試文件和最終覆蓋結果。 +**Copilot 覆蓋政策**:當 PR 更改正式程式碼且覆蓋率低於 75%(語句/行/函數)或 70%(分支)時,不僅僅報告 — 添加或更新測試,重新運行覆蓋門限,然後請求確認。在 PR 報告中包含運行的命令、已更改的測試文件和最終覆蓋結果。 --- @@ -364,10 +364,10 @@ git push -u origin feat/your-feature ## 環境 - **運行時**:Node.js ≥20.20.2 <21 || ≥22.22.2 <23 || ≥24 <25, ES Modules -- **TypeScript**:5.9+,目標 ES2022,模塊 esnext,解析器 bundler +- **TypeScript**:5.9+,目標 ES2022,模組 esnext,解析器 bundler - **路徑別名**:`@/*` → `src/`,`@omniroute/open-sse` → `open-sse/`,`@omniroute/open-sse/*` → `open-sse/*` -- **默認埠**:20128(API + 儀錶板在同一埠) -- **數據目錄**:`DATA_DIR` 環境變量,默認為 `~/.omniroute/` +- **預設埠**:20128(API + 儀表板在同一埠) +- **數據目錄**:`DATA_DIR` 環境變量,預設為 `~/.omniroute/` - **關鍵環境變量**:`PORT`、`JWT_SECRET`、`API_KEY_SECRET`、`INITIAL_PASSWORD`、`REQUIRE_API_KEY`、`APP_LOG_LEVEL` - 設置:`cp .env.example .env` 然後生成 `JWT_SECRET` (`openssl rand -base64 48`) 和 `API_KEY_SECRET` (`openssl rand -hex 32`) @@ -379,15 +379,15 @@ git push -u origin feat/your-feature 2. 永遠不要在 `localDb.ts` 中添加邏輯 3. 永遠不要使用 `eval()` / `new Function()` / 隱式 eval 4. 永遠不要直接提交到 `main` -5. 永遠不要在路由中編寫原始 SQL — 使用 `src/lib/db/` 模塊 +5. 永遠不要在路由中編寫原始 SQL — 使用 `src/lib/db/` 模組 6. 永遠不要在 SSE 流中靜默吞噬錯誤 7. 始終使用 Zod 模式驗證輸入 -8. 更改生產代碼時始終包含測試 +8. 更改正式程式碼時始終包含測試 9. 覆蓋率必須保持在 ≥75%(語句、行、函數)/ ≥70%(分支)。當前測量:~82%。 10. 在沒有明確操作員批准的情況下,永遠不要繞過 Husky 鉤子(`--no-verify`,`--no-gpg-sign`)。 -11. 永遠不要將公共上遊 OAuth client_id/secret 或 Firebase Web 密鑰作為字符串文字嵌入 — 始終通過 `resolvePublicCred()` 處理(`open-sse/utils/publicCreds.ts`)。參見 `docs/security/PUBLIC_CREDS.md`。 -12. 永遠不要在 HTTP / SSE / 執行器響應中返回原始 `err.stack` / `err.message` — 始終通過 `buildErrorBody()` 或 `sanitizeErrorMessage()` 路由(`open-sse/utils/error.ts`)。參見 `docs/security/ERROR_SANITIZATION.md`。 -13. 永遠不要將外部路徑或運行時值字符串插值到傳遞給 `exec()`/`spawn()` 的 shell 腳本中 — 應通過 `env` 選項傳遞。參考:`src/mitm/cert/install.ts::updateNssDatabases`。 -14. 永遠不要在沒有 (a) 首先檢查上述模式文檔以查看幫助程序是否適用,以及 (b) 在駁回評論中記錄技術理由的情況下駁回 CodeQL / Secret-Scanning 警報。先例:在已經通過 `sanitizeErrorMessage()` 路由的調用站點上引發的 `js/stack-trace-exposure` 是已知的 CodeQL 限制(自定義清理程序未被識別) — 駁回為 `false positive`,引用 `docs/security/ERROR_SANITIZATION.md`。 +11. 永遠不要將公共上游 OAuth client_id/secret 或 Firebase Web 密鑰作為字串文字嵌入 — 始終通過 `resolvePublicCred()` 處理(`open-sse/utils/publicCreds.ts`)。參見 `docs/security/PUBLIC_CREDS.md`。 +12. 永遠不要在 HTTP / SSE / 執行器回應中返回原始 `err.stack` / `err.message` — 始終通過 `buildErrorBody()` 或 `sanitizeErrorMessage()` 路由(`open-sse/utils/error.ts`)。參見 `docs/security/ERROR_SANITIZATION.md`。 +13. 永遠不要將外部路徑或運行時值字串插值到傳遞給 `exec()`/`spawn()` 的 shell 腳本中 — 應通過 `env` 選項傳遞。參考:`src/mitm/cert/install.ts::updateNssDatabases`。 +14. 永遠不要在沒有 (a) 首先檢查上述模式文件以查看幫助程序是否適用,以及 (b) 在駁回評論中記錄技術理由的情況下駁回 CodeQL / Secret-Scanning 警報。先例:在已經通過 `sanitizeErrorMessage()` 路由的呼叫站點上引發的 `js/stack-trace-exposure` 是已知的 CodeQL 限制(自定義清理程序未被識別) — 駁回為 `false positive`,引用 `docs/security/ERROR_SANITIZATION.md`。 15. 永遠不要暴露生成子進程的路由(`/api/mcp/`、`/api/cli-tools/runtime/`),而不在 `src/server/authz/routeGuard.ts` 中進行 `isLocalOnlyPath()` 分類。迴環強制執行在任何身份驗證檢查之前無條件發生 — 通過隧道洩露的 JWT 不能觸發進程生成。參見 `docs/security/ROUTE_GUARD_TIERS.md`。 16. 切勿在提交消息中包含將 AI 助手、LLM 或自動化帳戶作為作者的 `Co-Authored-By` 尾部(例如包含 "Claude"、"GPT"、"Copilot"、"Bot" 的名稱;`anthropic.com` / `openai.com` / 機器人擁有的 `noreply.github.com` 地址上的電子郵件)。這類尾部會將 commit 歸屬路由到 GitHub 上的機器人帳戶,從而在 PR 歷史中隱藏真正的作者 (`diegosouzapw`)。人類協作者——包括 upstream PR 作者和被移植到 OmniRoute 的 issue 報告者——可以並且應該使用標準的 `Co-authored-by: Name ` 尾部進行署名;upstream-port 工作流(`/port-upstream-features`、`/port-upstream-issues`)依賴於此。 diff --git a/docs/i18n/zh-TW/CONTRIBUTING.md b/docs/i18n/zh-TW/CONTRIBUTING.md index 5046ff3752..213ea3b8c5 100644 --- a/docs/i18n/zh-TW/CONTRIBUTING.md +++ b/docs/i18n/zh-TW/CONTRIBUTING.md @@ -35,22 +35,22 @@ echo "API_KEY_SECRET=$(openssl rand -hex 32)" >> .env 開發用的關鍵變數: -| 變數 | 開發環境預設值 | 說明 | -| ---------------------- | ----------------------- | ------------------ | -| `PORT` | `20128` | 伺服器埠號 | -| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | 前端的基礎 URL | -| `JWT_SECRET` | (上方產生) | JWT 簽章密鑰 | -| `INITIAL_PASSWORD` | `CHANGEME` | 首次登入密碼 | -| `APP_LOG_LEVEL` | `info` | 日誌詳細程度 | +| 變數 | 開發環境預設值 | 說明 | +| ---------------------- | ------------------------ | -------------- | +| `PORT` | `20128` | 伺服器埠號 | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | 前端的基礎 URL | +| `JWT_SECRET` | (上方產生) | JWT 簽章密鑰 | +| `INITIAL_PASSWORD` | `CHANGEME` | 首次登入密碼 | +| `APP_LOG_LEVEL` | `info` | 日誌詳細程度 | ### 儀表板設定 儀表板提供 UI 開關,可設定也能透過環境變數配置的功能: -| 設定位置 | 開關 | 說明 | -| ------------------ | -------------- | ---------------------------- | -| 設定 → 進階 | 除錯模式 | 啟用除錯請求日誌(UI) | -| 設定 → 一般 | 側邊欄可見性 | 顯示/隱藏側邊欄區塊 | +| 設定位置 | 開關 | 說明 | +| ----------- | ------------ | ---------------------- | +| 設定 → 進階 | 除錯模式 | 啟用除錯請求日誌(UI) | +| 設定 → 一般 | 側邊欄可見性 | 顯示/隱藏側邊欄區塊 | 這些設定儲存在資料庫中,重新啟動後仍會保留,設定後會覆蓋環境變數的預設值。 @@ -73,11 +73,11 @@ PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev ### 建置輸出結構 -| 目錄 | 內容 | 版本追蹤 | -| ---------- | -------------------------------------------------- | -------- | -| `src/` | 應用程式原始碼(TypeScript / TSX) | 是 | -| `.build/` | 中間產物 — `next build` 輸出(gitignored,`distDir = .build/next`) | 否 | -| `dist/` | 可發佈套件 — 由 `assembleStandalone` 組裝(gitignored) | 否 | +| 目錄 | 內容 | 版本追蹤 | +| --------- | ------------------------------------------------------------------- | -------- | +| `src/` | 應用程式原始碼(TypeScript / TSX) | 是 | +| `.build/` | 中間產物 — `next build` 輸出(gitignored,`distDir = .build/next`) | 否 | +| `dist/` | 可發佈套件 — 由 `assembleStandalone` 組裝(gitignored) | 否 | 建置管線為單次傳遞: @@ -113,14 +113,14 @@ git push -u origin feat/your-feature-name ### 分支命名 -| 前綴 | 用途 | -| ------------ | ---------------------- | -| `feat/` | 新功能 | -| `fix/` | 錯誤修正 | -| `refactor/` | 程式碼重構 | -| `docs/` | 文件變更 | -| `test/` | 測試新增/修正 | -| `chore/` | 工具、CI、依賴項目 | +| 前綴 | 用途 | +| ----------- | ------------------ | +| `feat/` | 新功能 | +| `fix/` | 錯誤修正 | +| `refactor/` | 程式碼重構 | +| `docs/` | 文件變更 | +| `test/` | 測試新增/修正 | +| `chore/` | 工具、CI、依賴項目 | ### 提交訊息 @@ -167,17 +167,17 @@ npm run coverage:report npm run lint npm run check -# 實際上游 combo 冒煙測試(需要 VPS 存取 + 實際提供商額度) -# 會打到真實提供商 — 會花一點錢。絕對不會在 CI 中執行。沒有閘道時會乾淨地跳過。 +# 實際上游 combo 冒煙測試(需要 VPS 存取 + 實際提供者額度) +# 會打到真實提供者 — 會花一點錢。絕對不會在 CI 中執行。沒有閘道時會乾淨地跳過。 # 需要:ssh root@192.168.0.15 存取(從 VPS 讀取唯讀資料庫快照)。 RUN_COMBO_LIVE=1 npm run test:combo:live # Phase-3 VPS 實戰冒煙測試 — 純 Node ESM 腳本,直接打到 .15 伺服器。 # 需要:ssh root@192.168.0.15 存取(combo 透過 SSH sqlite 建立/刪除)。 -# 會打到真實提供商(少量費用)。只會建立/刪除 __live_test__* combo。絕對不會在 CI 中執行。 +# 會打到真實提供者(少量費用)。只會建立/刪除 __live_test__* combo。絕對不會在 CI 中執行。 # REQUIRE_API_KEY=false on .15 所以不需要 API 金鑰,但如果設定了 COMBO_LIVE_BASE_URL / COMBO_LIVE_API_KEY 則會遵循。 npm run test:combo:live:vps # 7 個 HTTP 情境(priority/round-robin/weighted/cost/fusion/auto + health) -npm run test:combo:live:vps:failover # 增加實際跨提供商容錯情境(共 8 個) +npm run test:combo:live:vps:failover # 增加實際跨提供者容錯情境(共 8 個) ``` 覆蓋率注意事項: @@ -201,7 +201,7 @@ npm run test:combo:live:vps:failover # 增加實際跨提供商容錯情境 目前測試狀態:**122 個單元測試檔案** 涵蓋: -- 提供商轉換器與格式轉換 +- 提供者轉換器與格式轉換 - 速率限制、斷路器與彈性 - 語意快取、冪等性、進度追蹤 - 資料庫操作與結構(21 個 DB 模組) @@ -238,7 +238,7 @@ src/ # TypeScript (.ts / .tsx) │ ├── compliance/ # 合規政策引擎 │ ├── db/ # SQLite 資料庫層(21 個模組 + 16 個遷移) │ ├── memory/ # 持久對話記憶 -│ ├── oauth/ # OAuth 提供商、服務與工具 +│ ├── oauth/ # OAuth 提供者、服務與工具 │ ├── skills/ # 可擴展技能框架 │ ├── usage/ # 用量追蹤與成本計算 │ └── localDb.ts # 僅作為重新匯出層 — 永遠不要在此新增邏輯 @@ -246,13 +246,13 @@ src/ # TypeScript (.ts / .tsx) ├── mitm/ # MITM 代理(憑證、DNS、目標路由) ├── shared/ │ ├── components/ # React 元件 (.tsx) -│ ├── constants/ # 提供商定義(177)、MCP 範圍、14 種路由策略 +│ ├── constants/ # 提供者定義(177)、MCP 範圍、14 種路由策略 │ ├── utils/ # 斷路器、清理工具、認證輔助 │ └── validation/ # Zod v4 結構 └── sse/ # SSE 代理管線 open-sse/ # @omniroute/open-sse 工作區 -├── executors/ # 14 個提供商專用請求執行器 +├── executors/ # 14 個提供者專用請求執行器 ├── handlers/ # 11 個請求處理器(聊天、回應、嵌入、圖片等) ├── mcp-server/ # MCP 伺服器(25 個工具、3 種傳輸、10 個範圍) ├── services/ # 36+ 服務(combo、autoCombo、rateLimitManager 等) @@ -282,7 +282,7 @@ docs/ ├── i18n/ # 國際化 README 翻譯 ├── marketing/ # 行銷素材 ├── ops/ # 部署、代理、覆蓋率、發布 -├── providers/ # 提供商專用文件 +├── providers/ # 提供者專用文件 ├── reference/ # API 參考、環境變數、CLI 工具、免費方案 ├── releases/ # 版本說明 ├── routing/ # Auto-combo 引擎、推理重播 @@ -293,9 +293,9 @@ docs/ --- -## 新增提供商 +## 新增提供者 -### 步驟 1:註冊提供商常數 +### 步驟 1:註冊提供者常數 新增至 `src/shared/constants/providers.ts` — 在模組載入時以 Zod 驗證。 @@ -311,7 +311,7 @@ docs/ 在 `src/lib/oauth/constants/oauth.ts` 中新增 OAuth 憑證,並在 `src/lib/oauth/services/` 中新增服務。 -如果上游提供商在其公開 CLI / 瀏覽器套件中分發了公開的 OAuth client_id/secret 或 Firebase Web API 金鑰,**請勿**將其嵌入為字串字面值。請使用 `open-sse/utils/publicCreds.ts` 中的 `resolvePublicCred()`,並在 `EMBEDDED_DEFAULTS` 中新增一個遮罩位元組條目。完整的強制性工作流程記錄於 [`docs/security/PUBLIC_CREDS.md`](./docs/security/PUBLIC_CREDS.md)。 +如果上游提供者在其公開 CLI / 瀏覽器套件中分發了公開的 OAuth client_id/secret 或 Firebase Web API 金鑰,**請勿**將其嵌入為字串字面值。請使用 `open-sse/utils/publicCreds.ts` 中的 `resolvePublicCred()`,並在 `EMBEDDED_DEFAULTS` 中新增一個遮罩位元組條目。完整的強制性工作流程記錄於 [`docs/security/PUBLIC_CREDS.md`](./docs/security/PUBLIC_CREDS.md)。 在處理器/執行器內部,傳送到客戶端的錯誤訊息必須通過 `open-sse/utils/error.ts` 的 `buildErrorBody()` / `sanitizeErrorMessage()` — 絕對不要將原始 `err.stack` 或 `err.message` 放入回應主體。請參閱 [`docs/security/ERROR_SANITIZATION.md`](./docs/security/ERROR_SANITIZATION.md)。 @@ -323,7 +323,7 @@ docs/ 在 `tests/unit/` 中撰寫單元測試,至少涵蓋: -- 提供商註冊 +- 提供者註冊 - 請求/回應轉換 - 錯誤處理 diff --git a/docs/i18n/zh-TW/README.md b/docs/i18n/zh-TW/README.md index 5cee8b425f..5a85f41edf 100644 --- a/docs/i18n/zh-TW/README.md +++ b/docs/i18n/zh-TW/README.md @@ -12,25 +12,25 @@ # 🚀 OmniRoute — 免費 AI 閘道器 -### 永遠不要停止開發。透過一個端點,將每個 AI 工具連接到 **231 個供應商** — **50+ 免費**。 +### 開發不停歇。只需單一端點,即可將所有 AI 工具串接至 **290 家模型提供者** — **90+ 家免費**。 -**將 Claude Code、Codex、Cursor、Cline、Copilot 和 Antigravity 連接到免費的 Claude / GPT / Gemini。自動備援。** +**將 Claude Code、Codex、Cursor、Cline、Copilot 與 Antigravity 無縫對接至免費的 Claude / GPT / Gemini,支援自動切換備援。**
-**RTK + Caveman 壓縮可節省 15–95% 的 Token。永遠不會達到限制。** +**RTK + Caveman 堆疊壓縮可節省 15–95% 的 Token(平均約 89%),告別額度上限痛點。**
-**~1.6B 有記錄的免費 Token/月** — 首月透過註冊獎勵最高可達 **~2.1B** — 聚合所有免費層的配額,加上永久免費、無上限的供應商,而上述壓縮進一步延長每一分 Token。([統計方法 →](../../reference/FREE_TIERS.md#tldr--how-much-free-inference-does-omniroute-actually-aggregate)) +**~1.53B 有記錄的免費 Token/月** — 首月透過註冊獎勵最高可達 **~2.15B** — 聚合所有免費層配額,加上永久免費、無上限的提供者,再輔以智慧壓縮進一步延長每一分 Token 開銷。([統計方法 →](../../reference/FREE_TIERS.md#tldr--how-much-free-inference-does-omniroute-actually-aggregate))
-[![231 AI Providers](https://img.shields.io/badge/231-AI_Providers-6C5CE7?style=for-the-badge)](#-231-ai-providers--50-free) -[![50+ Free](https://img.shields.io/badge/50%2B-Free_Tiers-00B894?style=for-the-badge)](#-231-ai-providers--50-free) -[![1.6B Free Tokens/mo](https://img.shields.io/badge/1.6B-Free_Tokens%2Fmo-00B894?style=for-the-badge)](../../reference/FREE_TIERS.md) +[![290 AI Providers](https://img.shields.io/badge/290-AI_Providers-6C5CE7?style=for-the-badge)](#-290-ai-providers--90-free) +[![90+ Free](https://img.shields.io/badge/90%2B-Free_Tiers-00B894?style=for-the-badge)](#-290-ai-providers--90-free) +[![1.53B Free Tokens/mo](https://img.shields.io/badge/1.53B-Free_Tokens%2Fmo-00B894?style=for-the-badge)](../../reference/FREE_TIERS.md) [![Token Savings](https://img.shields.io/badge/up_to_95%25-Token_Savings-E17055?style=for-the-badge)](#%EF%B8%8F-save-1595-tokens--automatically) -[![18 Strategies](https://img.shields.io/badge/18-Routing_Strategies-0984E3?style=for-the-badge)](#-combos--the-flagship) +[![19 Strategies](https://img.shields.io/badge/19-Routing_Strategies-0984E3?style=for-the-badge)](#-combos--the-flagship) [![$0 to start](https://img.shields.io/badge/%240-To_Start-FDCB6E?style=for-the-badge&logoColor=black)](#-quick-start)
@@ -42,7 +42,7 @@ [![WhatsApp Global](https://img.shields.io/badge/WhatsApp_Global-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) [![WhatsApp Brasil](https://img.shields.io/badge/WhatsApp_Brasil-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz) -**問題、供應商技巧、路線圖與支援 → [Discord](https://discord.gg/U47eFqAXCn) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 全球](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 巴西](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz)** +**問題、提供者技巧、路線圖與支援 → [Discord](https://discord.gg/U47eFqAXCn) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 全球](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 巴西](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz)**
@@ -66,7 +66,7 @@
-[**🚀 快速開始**](#-quick-start) • [**🎯 Combo**](#-combos--the-flagship) • [**🌐 供應商**](#-231-ai-providers--50-free) • [**🔌 CLI 與 MCP**](#-full-cli--a2a--mcp) • [**🗜️ 壓縮**](#%EF%B8%8F-save-1595-tokens--automatically) • [**🌍 網站**](https://omniroute.online) +[**🚀 快速開始**](#-quick-start) • [**🎯 Combo**](#-combos--the-flagship) • [**🌐 提供者**](#-290-ai-providers--90-free) • [**🔌 CLI 與 MCP**](#-full-cli--a2a--mcp) • [**🗜️ 壓縮**](#%EF%B8%8F-save-1595-tokens--automatically) • [**🌍 網站**](https://omniroute.online) [💥 承諾](#-the-promise) • [🤔 為什麼](#-why-omniroute) • [🏆 優勢](#-what-sets-omniroute-apart) • [🤖 相容 CLI](#-compatible-clis--coding-agents) • [🖥️ 執行平台](#%EF%B8%8F-where-omniroute-runs--anywhere) • [🔒 隱私](#-private--local-first) • [🎬 實際展示](#-omniroute-in-action) • [📚 探索更多](#-explore-more) • [📧 支援](#-support--community) @@ -121,20 +121,20 @@
-# 💰 ~1.6B 免費 Token / 月 +# 💰 ~1.53B 免費 Token / 月
-> 手動堆疊免費層很痛苦 — 數十個 SDK、數十個速率限制,而且你不清楚自己到底有多少配額。OmniRoute 將 **40+ 供應商池 / 500+ 模型**的**有記錄**免費層聚合為一個真實數字,並在儀表板上即時展示 (`/dashboard/free-tiers`)。 +> 手動堆疊免費層很痛苦 — 數十個 SDK、數十個速率限制,而且你不清楚自己到底有多少配額。OmniRoute 將 **43 個提供者池 / 516 個模型**的**有記錄**免費層聚合為一個真實數字,並在儀表板上即時展示 (`/dashboard/free-tiers`)。 -- **~1.6B 免費 Token/月**(穩定) — 首月透過註冊獎勵最高可達 **~2.1B**。 -- **池去重,誠實** — 每個共享免費池只計算**一次**,因此標題不會被速率限制上限所誇大。(如果全天候計算每個速率限制,數字會接近 ~10B;我們不發布那個數字。) -- **加上不可計數的** — 永久免費、無 Token 上限的供應商(SiliconFlow、Z.AI GLM-Flash、Kilo、OpenCode Zen…)以及 **$10 OpenRouter 充值**可解鎖 **+24M/月**,兩者分別列出,絕不誇大標題數字。 -- **按模型細分**,當月**已用/剩餘**即時顯示,以及每個供應商的透明**條款標誌**。 +- **~1.53B 免費 Token/月**(穩定) — 首月透過註冊獎勵最高可達 **~2.15B**。 +- **跨池去重,真實不虛報** — 每個共享免費池僅計算**一次**,絕不以速率限制數據誇大宣傳。(若全天候無上限累計,數字可達 ~10B,但我們堅持僅發布實際可用的真實數據。) +- **加上不可計數的免費資源** — 永久免費、無 Token 上限的提供者(SiliconFlow、Z.AI GLM-Flash、Kilo、OpenCode Zen…)以及 **$10 OpenRouter 充值**可解鎖 **+24M/月**,兩者獨立列出,絕不誇大統計數字。 +- **按模型細分**,當月**已用/剩餘**即時顯示,以及每個提供者的透明**條款標記**。 ![Free-Tier Budget card (preview mockup)](../../screenshots/free-tier-budget-card.svg) -> 預覽模型 — 實際截圖將在 `/dashboard/free-tiers` 頁面驗證後上線。完整方法論(池去重、信用層級、供應商條款):**[docs/reference/FREE_TIERS.md](../../reference/FREE_TIERS.md)**。 +> 預覽模型 — 實際截圖將在 `/dashboard/free-tiers` 頁面驗證後上線。完整方法論(池去重、信用層級、提供者條款):**[docs/reference/FREE_TIERS.md](../../reference/FREE_TIERS.md)**。
@@ -144,18 +144,18 @@ -> 一個端點。**231 個供應商。** 永遠不要停止建構 — 讓 OmniRoute 選擇最便宜且有效的方案。 +> 單一端點。**290 家提供者。** 讓開發流程暢行無阻 — 由 OmniRoute 自動挑選最划算且可行的最佳方案。 - + - + - - - + + +
🚫 永遠不會達到限制
跨 231 個供應商毫秒級自動備援。配額用盡?下一個供應商立即接管 — 零停機。
🚫 告別配額限制
跨 290 家提供者毫秒級自動備援。配額用盡?下一個提供者立即接管,實現零中斷體驗。
💸 節省高達 95% 的 Token
RTK + Caveman 堆疊壓縮可削減 15–95% 的合格 Token(工具密集型會話平均約 89%)。
🆓 零成本開始
50+ 供應商提供免費層,11 個永久免費(Kiro、Qoder、Pollinations、LongCat…)。無需信用卡。
🆓 零成本輕鬆上手
90+ 提供者包含免費層,11 家永久免費(Kiro、Qoder、Pollinations、LongCat…)。無須綁定信用卡。
🔌 每個工具都相容
16+ 編碼代理 — Claude Code、Codex、Cursor、Cline、Copilot、Antigravity — 透過一個設定即可使用。
🧩 一個端點
OpenAI ↔ Claude ↔ Gemini ↔ Responses API 轉換。將任何工具指向 /v1 即可使用。
🛡️ 生產級別
斷路器、TLS 隱身、MCP(87 工具)、A2A、記憶、護欄、評估。14,965 個測試。
🔌 廣泛相容各式工具
16+ AI Coding Agent — Claude Code、Codex、Cursor、Cline、Copilot、Antigravity — 單一設定隨插即用。
🧩 單一統一端點
OpenAI ↔ Claude ↔ Gemini ↔ Responses API 雙向轉換。將任何工具指向 /v1 即可直接運作。
🛡️ 生產級穩定架構
斷路器、TLS 指紋隱身、MCP(104 工具)、A2A、對話記憶、護欄、評估套件。
@@ -168,16 +168,16 @@ -> 告別管理 10 個儀表板、失效的 API 金鑰和意外帳單的煩惱。 +> 告別手動切換數十個控制台、API 金鑰過期與預期外帳單的痛點。 -| ❌ 日常痛點 | ✅ OmniRoute 的解決方案 | -|---|---| -| 📉 訂閱配額每月用不完就浪費 | **最大化訂閱** — 追蹤配額,在重置前用盡每個 Token | -| 🛑 速率限制中斷編碼 | **4 層自動備援** — 訂閱 → API → 廉價 → 免費,毫秒級切換 | -| 🔥 工具輸出消耗大量 Token | **RTK + Caveman 壓縮** — 每次請求節省 15–95% 合格 Token | -| 💸 昂貴的 API(每個供應商 $20–50/月) | **成本優化路由** — 自動路由到最便宜的可行模型 | -| 🧰 每個 AI 工具需要不同的設定 | **一個端點,所有工具,一個儀表板** | -| 🌍 所在國家/地區封鎖 AI | **3 層代理** + TLS 指紋隱身 — 從任何地方使用 AI | +| ❌ 日常痛點 | ✅ OmniRoute 的解決方案 | +| -------------------------------------- | ------------------------------------------------------------------- | +| 📉 訂閱配額每月用不完白白浪費 | **最大化訂閱價值** — 精準追蹤配額,在重置前善用每一分 Token | +| 🛑 Rate Limit 導致開發工作中斷 | **4 層自動切換備援** — 訂閱 → API → 廉價模型 → 免費,毫秒級無感切換 | +| 🔥 工具輸出耗費巨量 Token | **RTK + Caveman 堆疊壓縮** — 每次請求大幅節省 15–95% 合格 Token | +| 💸 昂貴的 API 帳單(各平台 $20–50/月) | **成本優化智慧路由** — 自動路由至成本最低且可行的模型 | +| 🧰 各個 AI 工具需要繁瑣獨立設定 | **單一端點、整合所有工具與統一控制儀表板** | +| 🌍 特定區域網路連線限制 | **3 層代理** + TLS 指紋隱身 — 隨時隨地順暢存取 AI 服務 |
@@ -199,6 +199,7 @@ Codex, Copilot Groq, xAI MiniMax $0.2 Pollinations quota out? ───▶ budget hit? ─▶ budget hit? ─▶ always on ``` +

@@ -209,43 +210,43 @@ -> **Combo** 是 OmniRoute **自動**路由的模型鏈。配額用盡、供應商失敗或成本飆升 — Combo 自動滑動到下一個模型。**這就是 OmniRoute 不可中斷的原因。** 🛡️ +> **Combo** 是 OmniRoute 的**自動路由模型鏈結機制**。無論是配額用盡、提供者斷線或成本飆升,Combo 都會自動無縫切換至下一個候選模型。**讓您的 AI 開發流程永遠不中斷!** 🛡️ -### ⚡ 零設定 — 只需使用 `auto` +### ⚡ 零設定 — 只需將模型設為 `auto` -無需建立 Combo。將模型設定為 `auto`(或變體),OmniRoute 會根據您連接的供應商即時評分建構虛擬 Combo: +無需手動建立 Combo。只要將模型名稱設為 `auto`(或其衍生變體),OmniRoute 就會根據您已連線的提供者進行即時動態評分,為您建立虛擬 Combo: -| 模型 ID | 最佳化目標 | -|---|---| -| `auto` | 🎯 平衡預設(LKGP — 沿用上次好的供應商) | -| `auto/coding` | 🧑‍💻 程式碼生成優先品質權重 | -| `auto/fast` | ⚡ 最低延遲優先 | -| `auto/cheap` | 💰 每 Token 最低價優先 | -| `auto/offline` | 🔋 最多配額/速率限制餘量優先 | -| `auto/smart` | 🔭 品質優先 + 10% 探索以發現更好模型 | +| 模型 ID | 最佳化目標 | +| -------------- | -------------------------------------------------- | +| `auto` | 🎯 智慧平衡預設(LKGP — 優先沿用上次穩定的提供者) | +| `auto/coding` | 🧑‍💻 程式碼生成優先品質權重 | +| `auto/fast` | ⚡ 最低延遲優先 | +| `auto/cheap` | 💰 每 Token 最低價優先 | +| `auto/offline` | 🔋 最多配額/速率限制餘量優先 | +| `auto/smart` | 🔭 品質優先 + 10% 探索以發現更好模型 | -### 🔀 或自行建構 — 17 種路由策略 +### 🔀 或自行建構 — 19 種路由策略 -| 目標 | 策略 / Combo | -|---|---| -| 🥇 先用完訂閱再付費 | `priority` / `fill-first` | -| ⚖️ 跨帳戶分散負載 | `round-robin` · `weighted` · `p2c` · `least-used` | -| 💸 始終選最便宜的可行模型 | `cost-optimized` · `auto/cheap` | -| 🧠 模型間移交長上下文 | `context-relay` · `context-optimized` | -| 🎲 隨機/隱私路由 | `random` · `strict-random` | -| 🧬 分發到專家組 + 裁判合成 | `fusion` | -| 📊 按剩餘配額餘量路由 | `reset-window` · `headroom` | -| 🤖 智慧路由 | `auto`(9 因素評分)· `lkgp` · `reset-aware` | +| 目標 | 策略 / Combo | +| -------------------------- | ------------------------------------------------- | +| 🥇 先用完訂閱再付費 | `priority` / `fill-first` | +| ⚖️ 跨帳戶分散負載 | `round-robin` · `weighted` · `p2c` · `least-used` | +| 💸 始終選最便宜的可行模型 | `cost-optimized` · `auto/cheap` | +| 🧠 模型間移交長上下文 | `context-relay` · `context-optimized` | +| 🎲 隨機/隱私路由 | `random` · `strict-random` | +| 🧬 分發到專家組 + 裁判合成 | `fusion` | +| 📊 按剩餘配額餘量路由 | `reset-window` · `headroom` | +| 🤖 智慧路由 | `auto`(9 因素評分)· `lkgp` · `reset-aware` | Auto-Combo 引擎根據 **9 項因素**(健康度、配額、成本、延遲、成功率、新鮮度…)對每個候選模型評分 — 參見 [`docs/routing/AUTO-COMBO.md`](../../routing/AUTO-COMBO.md)。 ### 🧱 內建彈性(3 個獨立層) -| 層 | 範圍 | 作用 | -|---|---|---| -| 🔌 **斷路器** | 整個供應商 | 停止重複呼叫上游失敗的供應商;自動探測恢復 | -| 💤 **連線冷卻** | 一個帳戶/金鑰 | 跳過速率限制的金鑰,其他金鑰繼續提供服務 | -| 🎯 **模型鎖定** | 供應商 + 模型 | 僅隔離配額受限的模型,不影響整個連線 | +| 層 | 範圍 | 作用 | +| --------------- | ------------- | ------------------------------------------ | +| 🔌 **斷路器** | 整個提供者 | 停止重複呼叫上游失敗的提供者;自動探測恢復 | +| 💤 **連線冷卻** | 一個帳戶/金鑰 | 跳過速率限制的金鑰,其他金鑰繼續提供服務 | +| 🎯 **模型鎖定** | 提供者 + 模型 | 僅隔離配額受限的模型,不影響整個連線 | ``` Combo: "always-on" Strategy: priority @@ -266,20 +267,20 @@ Result: 4 layers of fallback = zero downtime -| 功能 | OmniRoute | 其他路由器 | -|---|---|---| -| 🌐 供應商數量 | **231** | 20–100 | -| 🆓 免費供應商 | **50+(11 個永久免費)** | 1–5 | -| 🔀 路由策略 | **17 種**(優先級、加權、成本優化、上下文繼電、融合…) | 1–3 | -| 🗜️ Token 壓縮 | **RTK + Caveman 堆疊(15–95%)** | 無 / 20–40% | -| 🧰 內建 MCP 伺服器 | **87 工具、3 種傳輸、30 個範圍** | 少有 | -| 🤝 A2A 代理協定 | **6 項技能、JSON-RPC 2.0** | 無 | -| 🧠 記憶(FTS5 + 向量) | **支援** | 少有 | -| 🛡️ 護欄(PII、注入、視覺) | **支援** | 少有 | -| ☁️ 雲端代理 | **Codex、Devin、Jules** | 無 | -| 🥷 TLS 指紋隱身 | **JA3/JA4 透過 wreq-js** | 無 | -| 🖥️ 多平台 | **Web · 桌面 · Termux · PWA** | 僅 Web | -| 🌍 國際化 | **42 種語言環境** | 0–4 | +| 功能 | OmniRoute | 其他路由器 | +| -------------------------- | ------------------------------------------------------ | ----------- | +| 🌐 提供者數量 | **290** | 20–100 | +| 🆓 免費提供者 | **90+(40+ 個永久免費)** | 1–5 | +| 🔀 路由策略 | **19 種**(優先級、加權、成本優化、上下文中繼、融合…) | 1–3 | +| 🗜️ Token 壓縮 | **RTK + Caveman 堆疊(15–95%)** | 無 / 20–40% | +| 🧰 內建 MCP 伺服器 | **104 工具、3 種傳輸、31 個範圍** | 少有 | +| 🤝 A2A 代理協定 | **6 項技能、JSON-RPC 2.0** | 無 | +| 🧠 記憶(FTS5 + 向量) | **支援** | 少有 | +| 🛡️ 護欄(PII、注入、視覺) | **支援** | 少有 | +| ☁️ 雲端代理 | **Codex、Devin、Jules** | 無 | +| 🥷 TLS 指紋隱身 | **JA3/JA4 透過 wreq-js** | 無 | +| 🖥️ 多平台 | **Web · 桌面 · Termux · PWA** | 僅 Web | +| 🌍 國際化 | **42 種語言環境** | 0–4 | 📊 與 LiteLLM、OpenRouter 和 Portkey 的詳細比較 → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](../../comparison/OMNIROUTE_VS_ALTERNATIVES.md) @@ -295,13 +296,13 @@ Result: 4 layers of fallback = zero downtime - **⚖️ Quota-Share 路由** — 一種專用 Combo 策略,根據可用配額跨帳戶分配負載:赤字輪詢排程、每個連線的 `max_concurrent` 搭配冷卻等待佇列、多時窗使用量桶(5 小時 / 7 天 / 按模型)、每(金鑰,模型)上限、為提示快取完整性而設的會話黏著性,以及來自上游 Token 使用量標頭的主動飽和偵測。→ [Resilience Guide](../../architecture/RESILIENCE_GUIDE.md) - **🛰️ 遠端模式** — 透過範圍存取令牌從任何機器驅動遠端 OmniRoute(`omniroute connect` / `omniroute contexts` / `omniroute tokens`)。→ [Remote Mode](../../guides/REMOTE-MODE.md) -- **🧭 更智慧的 Auto-Routing** — OpenRouter 風格的 `auto/:` Combo(例如 `auto/coding:fast`、`auto/reasoning:pro`)、**Fusion** 策略(第 16 種 — 並行分發到多個模型,然後透過裁判合成)、**任務感知路由**(按任務類型選擇最佳連線)、每請求 `X-Route-Model` 覆寫、即時 Arena-ELO + models.dev 模型智慧、每步驟帳戶允許清單、供應商萬用字元 Combo 步驟、巢狀 Combo 引用執行、黏性加權選擇和 `web_search` 感知路由。→ [Auto-Combo](../../routing/AUTO-COMBO.md) +- **🧭 更智慧的 Auto-Routing** — OpenRouter 風格的 `auto/:` Combo(例如 `auto/coding:fast`、`auto/reasoning:pro`)、**Fusion** 策略(第 16 種 — 並行分發到多個模型,然後透過裁判合成)、**任務感知路由**(按任務類型選擇最佳連線)、每請求 `X-Route-Model` 覆寫、即時 Arena-ELO + models.dev 模型智慧、每步驟帳戶允許清單、提供者萬用字元 Combo 步驟、巢狀 Combo 引用執行、黏性加權選擇和 `web_search` 感知路由。→ [Auto-Combo](../../routing/AUTO-COMBO.md) - **🗜️ 可插拔壓縮** — **9 個可組合引擎**的非同步管線,含 Compression Studios、LLMLingua-2 ONNX 引擎和啟發式/SLM 雙層 **Ultra**、RTK、委託 Anthropic Context Editing、**Output Styles**(輸出軸控制:terse-prose / less-code / terse-CJK)、**自適應上下文預算撥盤**(僅升級到足以符合上下文視窗)、每請求 `x-omniroute-compression` 控制、可選的離線評估工具、一鍵從儀表板管理 **Headroom** 代理生命週期、合成**壓縮遊樂場**(Play 賽道 + A/B 比較)、可選的**每步驟保真度閘門**,以及統一面板搭配命名設定檔 + 活動設定檔選擇器。→ [Compression](../../compression/COMPRESSION_ENGINES.md) - **🕵️ 透明 MITM 解密(TPROXY)** — 捕獲並轉換忽略代理環境變數的 CLI 流量,配備每個 SNI 的憑證授權機構和信任儲存安裝程式。→ [MITM/TPROXY](../../security/MITM-TPROXY-DECRYPT.md) - **💸 全方位成本遙測** — 每個端點(包括媒體)上的 `X-OmniRoute-*` 成本/使用量標頭、非 Token 成本引擎、快取命中 `X-OmniRoute-Cost-Saved` 標頭,以及每金鑰 USD 支出配額。→ [API Reference](../../reference/API_REFERENCE.md) - **🧠 可控記憶** — 可選的 int8 向量量化(Qdrant + sqlite-vec),記憶預設關閉,以及每請求 `x-omniroute-no-memory` 標頭。→ [Memory](../../frameworks/MEMORY.md) - **🛡️ 安全** — 所有 LLM 路由的提示注入防護(由紅隊測試套件支援),加上免費的 DuckDuckGo 最後手段網路搜尋。→ [Guardrails](../../security/GUARDRAILS.md) -- **🤝 更多供應商和代理** — Cursor Cloud Agent(第 4 個雲端代理)、CodeBuddy CN(`copilot.tencent.com`)、Google Flow 影片生成供應商、新閘道 **DGrid** 和 **Pioneer AI**(Fastino Labs)、入站 **xAI Grok** 轉換器加上 **Grok Build (xAI)** 附 OAuth 匯入令牌流程、GitHub Copilot 供應商上的 GPT-4 / GPT-4o-mini、多模型 **Factory Droid**、**ZenMux Free**(session-cookie 免費層)、**Alibaba DashScope** 文字轉影片(`wan2.7-t2v`)、更新後的 231 供應商目錄、Vertex AI 媒體生成(語音 / 轉錄 / 音樂 / 影片),以及從 CLIProxyAPI 一鍵匯入帳戶。→ [Providers](../../reference/PROVIDER_REFERENCE.md) +- **🤝 更多提供者和代理** — Cursor Cloud Agent(第 4 個雲端代理)、CodeBuddy CN(`copilot.tencent.com`)、Google Flow 影片生成提供者、新閘道 **DGrid** 和 **Pioneer AI**(Fastino Labs)、入站 **xAI Grok** 轉換器加上 **Grok Build (xAI)** 附 OAuth 匯入令牌流程、GitHub Copilot 提供者上的 GPT-4 / GPT-4o-mini、多模型 **Factory Droid**、**ZenMux Free**(session-cookie 免費層)、**Alibaba DashScope** 文字轉影片(`wan2.7-t2v`)、更新後的 290 提供者目錄、Vertex AI 媒體生成(語音 / 轉錄 / 音樂 / 影片),以及從 CLIProxyAPI 一鍵匯入帳戶。→ [Providers](../../reference/PROVIDER_REFERENCE.md) - **⚡ 本地效能與基礎設施** — 一鍵本地 Redis 啟動器(`omniroute redis up`,加上儀表板 Redis 面板)、一鍵 **Cloudflare Workers** 和 **Deno Deploy** 中繼部署器接入代理池,以及可選的 Bifrost Go sidecar,用於卸載最熱門的中繼路徑(`BIFROST_BASE_URL`,逾時時自動備援到 TypeScript 路徑)。→ [Environment](../../reference/ENVIRONMENT.md)
@@ -344,11 +345,11 @@ Result: 4 layers of fallback = zero downtime
-# 🌐 231 個 AI 供應商 — 50+ 免費 +# 🌐 290 個 AI 提供者 — 90+ 免費
-> 最完整的開源路由器目錄:**231 個供應商**、**50+ 具有免費層**、**11 個永久免費**。 +> 最完整的開源路由器目錄:**290 個提供者**、**90+ 具有免費層**、**40+ 永久免費**。
@@ -381,16 +382,16 @@ Result: 4 layers of fallback = zero downtime > 相同的應用程式,您的機器,您的規則。從全域 npm 安裝到透過 Termux **在手機上**執行。 -| 平台 | 安裝方式 | 亮點 | -|---|---|---| -| 📦 **npm(全域)** | `npm install -g omniroute` | 一條命令,任何作業系統 | -| 🐳 **Docker** | `docker run … diegosouzapw/omniroute` | 多架構 **AMD64 + ARM64** | -| 🖥️ **桌面(Electron)** | `npm run electron:build` | 原生視窗 + 系統匣 — **Windows / macOS / Linux** | -| 💪 **ARM** | 原生 `arm64` | Raspberry Pi、ARM 伺服器、Apple Silicon | -| 📱 **Android(Termux)** | `pkg install nodejs-lts && npx -y omniroute` | **在手機上**執行,24/7,無需 root | -| 📲 **PWA** | "新增到主畫面" | 全螢幕、離線、可從瀏覽器安裝 | -| 🧩 **OpenCode 插件** | `@omniroute/opencode-provider` | 原生 OpenCode 整合 | -| 🛠️ **從原始碼建構** | `npm install && npm run dev` | 參與開發 | +| 平台 | 安裝方式 | 亮點 | +| ------------------------ | -------------------------------------------- | ----------------------------------------------- | +| 📦 **npm(全域)** | `npm install -g omniroute` | 一條命令,任何作業系統 | +| 🐳 **Docker** | `docker run … diegosouzapw/omniroute` | 多架構 **AMD64 + ARM64** | +| 🖥️ **桌面(Electron)** | `npm run electron:build` | 原生視窗 + 系統匣 — **Windows / macOS / Linux** | +| 💪 **ARM** | 原生 `arm64` | Raspberry Pi、ARM 伺服器、Apple Silicon | +| 📱 **Android(Termux)** | `pkg install nodejs-lts && npx -y omniroute` | **在手機上**執行,24/7,無需 root | +| 📲 **PWA** | "新增到主畫面" | 全螢幕、離線、可從瀏覽器安裝 | +| 🧩 **OpenCode 插件** | `@omniroute/opencode-provider` | 原生 OpenCode 整合 | +| 🛠️ **從原始碼建構** | `npm install && npm run dev` | 參與開發 | 📖 [Docker Guide](../../guides/DOCKER_GUIDE.md) · [Desktop](../../electron/README.md) · [Termux](../../guides/TERMUX_GUIDE.md) · [PWA](../../guides/PWA_GUIDE.md) · [OpenCode](../../frameworks/OPENCODE.md) @@ -406,7 +407,7 @@ Result: 4 layers of fallback = zero downtime - 🏠 **100% 在您的硬體上執行** — npm、Docker、桌面或手機。OmniRoute 雲端絕不介入請求路徑。 - 🔐 **憑證靜態加密** — API 金鑰和 OAuth 令牌使用 **AES-256-GCM** 加密。 -- 🚫 **預設零遙測** — 您的提示僅傳送給您選擇的供應商,絕無其他去處。 +- 🚫 **預設零遙測** — 您的提示僅傳送給您選擇的提供者,絕無其他去處。 - 🛡️ **強化閘道** — API 金鑰範圍限制、IP 過濾、速率限制、提示注入防護、僅回送處理程序路由。 - 📜 **MIT 授權且完全開源** — 審計每一行程式碼,永久自托管。 @@ -428,7 +429,7 @@ Result: 4 layers of fallback = zero downtime omniroute # 啟動閘道 + 儀表板(埠口 20128) omniroute chat # 互動式 TUI 聊天客戶端(斜線指令:/model /combo /skill /memory) omniroute setup # 引導式首次執行精靈 -omniroute doctor # 診斷供應商、埠口、原生依賴 +omniroute doctor # 診斷提供者、埠口、原生依賴 ``` ### 🛰️ 遠端模式 — 在此執行 CLI,OmniRoute 在 VPS 上 @@ -455,14 +456,14 @@ omniroute contexts use default # ← 切換回本地伺服器 ### 🤝 連接代理 — 代理自行控制 OmniRoute -透過 **MCP** 或 **A2A** 公開 OmniRoute,任何有能力的代理即可取得整個閘道的金鑰 — 路由、供應商、Combo、快取、壓縮、記憶 — 自主運作。 +透過 **MCP** 或 **A2A** 公開 OmniRoute,任何有能力的代理即可取得整個閘道的金鑰 — 路由、提供者、Combo、快取、壓縮、記憶 — 自主運作。 -| 協定 | 端點 | 用途 | -|---|---|---| -| 🧰 **MCP(stdio)** | `omniroute --mcp` | 接入 Claude Desktop、Cursor 等 MCP 客戶端 | -| 🌊 **MCP(HTTP)** | `http://localhost:20128/api/mcp/stream` | 遠端 MCP — **87 工具**、30 範圍、完整稽核軌跡 | -| 📡 **MCP(SSE)** | `http://localhost:20128/api/mcp/sse` | 串流 MCP 傳輸 | -| 🤝 **A2A** | `http://localhost:20128/.well-known/agent.json` | 代理間通訊,**JSON-RPC 2.0** + SSE,6 技能 | +| 協定 | 端點 | 用途 | +| ------------------- | ----------------------------------------------- | ---------------------------------------------- | +| 🧰 **MCP(stdio)** | `omniroute --mcp` | 接入 Claude Desktop、Cursor 等 MCP 客戶端 | +| 🌊 **MCP(HTTP)** | `http://localhost:20128/api/mcp/stream` | 遠端 MCP — **104 工具**、31 範圍、完整稽核軌跡 | +| 📡 **MCP(SSE)** | `http://localhost:20128/api/mcp/sse` | 串流 MCP 傳輸 | +| 🤝 **A2A** | `http://localhost:20128/.well-known/agent.json` | 代理間通訊,**JSON-RPC 2.0** + SSE,6 技能 | ```bash # 將完整 OmniRoute 工具集透過 MCP 提供給 Claude Code: @@ -485,28 +486,28 @@ claude mcp add-server omniroute --type http --url http://localhost:20128/api/mcp 引擎按管線順序執行;每個可獨立切換並按 Combo 設定: -| # | 引擎 | 作用 | -|---|---|---| -| 1 | **Session-Dedup** | 刪除跨輪次重複的內容(內容定址、跨輪次) | -| 2 | **CCR** | 將大塊內容歸檔到檢索標記後,按需取得 | -| 3 | **RTK** | 智慧工具結果過濾、去重和截斷(命令感知) | -| 4 | **Headroom** | 同構 JSON 陣列的無損表格壓縮(~30%+) | -| 5 | **Caveman** | 基於規則的文章壓縮(輸出約 ~65–75%) | -| 6 | **LLMLingua-2** | 透過 MobileBERT ONNX 進行 ML 語義剪枝 — 程式碼安全、非同步 | -| 7 | **Lite** | 空白字元和圖片 URL 修剪(低延遲基準線) | -| 8 | **Aggressive** | 摘要 + 逐步淘汰舊輪次 | -| 9 | **Ultra** | 啟發式 Token 剪枝 + 可選小模型(SLM)層 | +| # | 引擎 | 作用 | +| --- | ----------------- | ---------------------------------------------------------- | +| 1 | **Session-Dedup** | 刪除跨輪次重複的內容(內容定址、跨輪次) | +| 2 | **CCR** | 將大塊內容歸檔到檢索標記後,按需取得 | +| 3 | **RTK** | 智慧工具結果過濾、去重和截斷(命令感知) | +| 4 | **Headroom** | 同構 JSON 陣列的無損表格壓縮(~30%+) | +| 5 | **Caveman** | 基於規則的文章壓縮(輸出約 ~65–75%) | +| 6 | **LLMLingua-2** | 透過 MobileBERT ONNX 進行 ML 語義剪枝 — 程式碼安全、非同步 | +| 7 | **Lite** | 空白字元和圖片 URL 修剪(低延遲基準線) | +| 8 | **Aggressive** | 摘要 + 逐步淘汰舊輪次 | +| 9 | **Ultra** | 啟發式 Token 剪枝 + 可選小模型(SLM)層 | 程式碼區塊、URL 和結構化資料**始終被完美保留**。**一鍵預設**組合引擎: -| 模式 | 節省比例 | 最佳用途 | -|---|---|---| -| 🪶 **Lite** | ~15% | 始終開啟的安全預設 | -| 🪨 **Standard(Caveman)** | ~30% | 日常編碼 | -| ⚡ **Aggressive** | ~50% | 長時間工具密集型會話 | -| 🔥 **Ultra** | ~75% | 最大節省 | -| 🧰 **RTK** | 60–90% | Shell/測試/建構/Git 輸出 | -| 🔗 **堆疊(RTK → Caveman)** | **78–95%** | 混合提示 + 工具日誌 | +| 模式 | 節省比例 | 最佳用途 | +| ---------------------------- | ---------- | ------------------------ | +| 🪶 **Lite** | ~15% | 始終開啟的安全預設 | +| 🪨 **Standard(Caveman)** | ~30% | 日常編碼 | +| ⚡ **Aggressive** | ~50% | 長時間工具密集型會話 | +| 🔥 **Ultra** | ~75% | 最大節省 | +| 🧰 **RTK** | 60–90% | Shell/測試/建構/Git 輸出 | +| 🔗 **堆疊(RTK → Caveman)** | **78–95%** | 混合提示 + 工具日誌 | **實際範例 — Standard 模式:** @@ -572,7 +573,7 @@ omniroute 儀表板:`http://localhost:20128` · API:`http://localhost:20128/v1` -**2) 連接免費供應商(無需註冊)** +**2) 連接免費提供者(無需註冊)** 儀表板 → **Providers** → 連接 **Kiro AI**(免費 Claude,每帳戶約 50 額度/月)或 **OpenCode Free**(無需驗證)→ 完成。 @@ -581,7 +582,7 @@ omniroute ```txt Base URL: http://localhost:20128/v1 API Key: [從儀表板 → Endpoints 複製] -Model: auto (零設定智慧路由 — 或任何供應商/模型) +Model: auto (零設定智慧路由 — 或任何提供者/模型) ``` **4) 驗證是否正常運作** @@ -705,33 +706,33 @@ podman compose --profile base up -d
-💰 價格一覽與 $0 免費堆疊(11 個供應商) +💰 價格一覽與 $0 免費堆疊(11 個提供者)
-| 層級 | 範例 | 成本 | -|---|---|---| -| 💳 **訂閱** | Claude Code Pro / Codex / Copilot | $10–200/月 | -| 🔑 **API 金鑰(免費層)** | NVIDIA NIM、Cerebras、Groq | **免費** | -| 💰 **廉價** | GLM-5 $0.5/1M · MiniMax M2.5 $0.3/1M | 幾分錢 | -| 🆓 **永久免費** | Kiro、Qoder、Qwen、Pollinations、LongCat | **$0** | +| 層級 | 範例 | 成本 | +| ------------------------- | ---------------------------------------- | ---------- | +| 💳 **訂閱** | Claude Code Pro / Codex / Copilot | $10–200/月 | +| 🔑 **API 金鑰(免費層)** | NVIDIA NIM、Cerebras、Groq | **免費** | +| 💰 **廉價** | GLM-5 $0.5/1M · MiniMax M2.5 $0.3/1M | 幾分錢 | +| 🆓 **永久免費** | Kiro、Qoder、Qwen、Pollinations、LongCat | **$0** | **$0 免費堆疊 — 組合成一個不可中斷的 Combo:** -| 供應商 | 前綴 | 免費模型 | 配額 | -|---|---|---|---| -| **Kiro** | `kr/` | Claude Sonnet 4.5、Haiku 4.5、Opus 4.6 | 50 額度/月 | -| **Qoder** | `if/` | kimi-k2-thinking、qwen3-coder-plus、deepseek-r1 | ♾️ 無限 | -| **Qwen** | `qw/` | qwen3-coder-plus/flash/next | ♾️ 無限 | -| **Pollinations** | `pol/` | GPT-5、Claude、Gemini、DeepSeek、Llama 4 | 無需金鑰 | -| **LongCat** | `lc/` | LongCat-Flash-Lite | 5000 萬 Token/天 🔥 | -| **Cloudflare AI** | `cf/` | 50+ 模型 | 1 萬 neurons/天 | -| **NVIDIA NIM** | `nvidia/` | 129 模型 | ~40 RPM | -| **Cerebras** | `cerebras/` | Qwen3 235B、GPT-OSS 120B | 100 萬 Token/天 | +| 提供者 | 前綴 | 免費模型 | 配額 | +| ----------------- | ----------- | ----------------------------------------------- | ------------------- | +| **Kiro** | `kr/` | Claude Sonnet 4.5、Haiku 4.5、Opus 4.6 | 50 額度/月 | +| **Qoder** | `if/` | kimi-k2-thinking、qwen3-coder-plus、deepseek-r1 | ♾️ 無限 | +| **Qwen** | `qw/` | qwen3-coder-plus/flash/next | ♾️ 無限 | +| **Pollinations** | `pol/` | GPT-5、Claude、Gemini、DeepSeek、Llama 4 | 無需金鑰 | +| **LongCat** | `lc/` | LongCat-Flash-Lite | 5000 萬 Token/天 🔥 | +| **Cloudflare AI** | `cf/` | 50+ 模型 | 1 萬 neurons/天 | +| **NVIDIA NIM** | `nvidia/` | 129 模型 | ~40 RPM | +| **Cerebras** | `cerebras/` | Qwen3 235B、GPT-OSS 120B | 100 萬 Token/天 | > 💡 儀表板上的"成本"是**節省追蹤器**,不是帳單 — OmniRoute 從不向您收費。使用免費模型顯示的"$290 總成本"意味著**節省了 $290**。 -📖 完整免費目錄 → [`docs/reference/FREE_TIERS.md`](../../reference/FREE_TIERS.md) — 25+ 供應商、配額、基本 URL。 +📖 完整免費目錄 → [`docs/reference/FREE_TIERS.md`](../../reference/FREE_TIERS.md) — 25+ 提供者、配額、基本 URL。
@@ -751,7 +752,7 @@ podman compose --profile base up -d ``` **24/7 無中斷:** 串聯 2 個訂閱 → 廉價 → 免費,5 層備援。 -**被封鎖地區:** 免費供應商 + 全域/每供應商代理 → 從任何國家存取 AI。 +**被封鎖地區:** 免費提供者 + 全域/每提供者代理 → 從任何國家存取 AI。 **最大節省:** 訂閱 + 廉價備用 + `ultra` 壓縮 (~75%) → 重度使用者每月節省 ~$150–300。 @@ -761,7 +762,7 @@ podman compose --profile base up -d
-🇷🇺 🇨🇳 🇮🇷 🇨🇺 🇹🇷 在被封鎖的地區?OmniRoute 的 **3 層代理**(全域 / 每供應商 / 每連線)代理 API 請求、OAuth 流程、連線測試、Token 重新整理和模型同步。 +🇷🇺 🇨🇳 🇮🇷 🇨🇺 🇹🇷 在被封鎖的地區?OmniRoute 的 **3 層代理**(全域 / 每提供者 / 每連線)代理 API 請求、OAuth 流程、連線測試、Token 重新整理和模型同步。 - **協定:** HTTP/HTTPS、SOCKS5、認證代理 - **🆓 1proxy 市場** — 數百個免費驗證代理、品質評分、自動輪換 @@ -777,8 +778,8 @@ podman compose --profile base up -d
**路由:** 15 種策略 · 任務感知智慧路由 · 思考預算控制 · 萬用字元路由 · 系統提示注入。 -**相容性:** OpenAI ↔ Claude ↔ Gemini ↔ Responses API · 自動 OAuth 重新整理(PKCE,8 個供應商)· 多帳戶輪詢 · Batch + Files API · 即時 OpenAPI 3.0。 -**協定:** MCP(87 工具、3 種傳輸、30 範圍)· A2A(JSON-RPC 2.0、SSE、6 技能)· ACP · 雲端代理(Codex、Devin、Jules)。 +**相容性:** OpenAI ↔ Claude ↔ Gemini ↔ Responses API · 自動 OAuth 重新整理(PKCE,8 個提供者)· 多帳戶輪詢 · Batch + Files API · 即時 OpenAPI 3.0。 +**協定:** MCP(104 工具、3 種傳輸、31 範圍)· A2A(JSON-RPC 2.0、SSE、6 技能)· ACP · 雲端代理(Codex、Devin、Jules)。 **插件:** 自訂插件市場(系統設定的註冊表 URL,附 SSRF 防護擷取)· 安裝/啟用/停用 · Notion + Obsidian 知識庫整合(WebDAV 檔案伺服器、筆記 CRUD)。 **內嵌服務:** 一鍵安裝和生命週期管理本地 sidecar 服務(CLIProxy、NineRouter)。 **品質與維運:** 內建 **Evals**(黃金集:精確/包含/正則/自訂)· 護欄(PII、注入、視覺)· 健康儀表板 · p50/p95/p99 遙測 · webhooks · 合規稽核。 @@ -793,16 +794,16 @@ podman compose --profile base up -d
-| 環境變數 | 預設值 | 用途 | -|---|---|---| -| `PORT` | `20128` | API + 儀表板埠口 | -| `REQUIRE_API_KEY` | `false` | 要求所有請求使用 API 金鑰 | -| `DATA_DIR` | `~/.omniroute` | 資料庫和設定儲存位置 | +| 環境變數 | 預設值 | 用途 | +| ----------------- | -------------- | ------------------------- | +| `PORT` | `20128` | API + 儀表板埠口 | +| `REQUIRE_API_KEY` | `false` | 要求所有請求使用 API 金鑰 | +| `DATA_DIR` | `~/.omniroute` | 資料庫和設定儲存位置 | -**OmniRoute 會向我收費嗎?** 不會 — 它是免費的開源軟體,在您的機器上執行。您只直接向付費供應商付費。OmniRoute 沒有帳單系統。 -**免費供應商真的無限嗎?** 基本上是的 — Qoder、Pollinations、LongCat 和 Cloudflare 是免費的,沒有每帳戶額度上限。Kiro 也是免費的,但每帳戶每月約 50 額度上限。在 Combo 中堆疊多個免費供應商,自動備援讓您以 $0 持續使用。 +**OmniRoute 會向我收費嗎?** 不會 — 它是免費的開源軟體,在您的機器上執行。您只直接向付費提供者付費。OmniRoute 沒有帳單系統。 +**免費提供者真的無限嗎?** 基本上是的 — Qoder、Pollinations、LongCat 和 Cloudflare 是免費的,沒有每帳戶額度上限。Kiro 也是免費的,但每帳戶每月約 50 額度上限。在 Combo 中堆疊多個免費提供者,自動備援讓您以 $0 持續使用。 **壓縮會損害品質嗎?** 不會 — 它只壓縮**輸入**;程式碼、URL、JSON 始終受保護。 -**在被封鎖 AI 的地區能用嗎?** 可以 — 3 層代理 + 1proxy 市場可達所有 231 個供應商。 +**在被封鎖 AI 的地區能用嗎?** 可以 — 3 層代理 + 1proxy 市場可達所有 290 個提供者。 📖 [User Guide](../../guides/USER_GUIDE.md) · [API Reference](../../reference/API_REFERENCE.md) · [Environment Config](../../reference/ENVIRONMENT.md) @@ -813,14 +814,14 @@ podman compose --profile base up -d
-| 問題 | 快速修復 | -|---|---| -| "Language model did not provide messages" | 供應商配額用盡 → 使用 Combo 備援 | -| 速率限制(429) | 新增備援:`cc/claude → glm/glm-4.7 → if/kimi-k2-thinking` | -| OAuth 令牌過期 | 自動重新整理;如果卡住,在 Providers 中刪除並重新驗證 | -| `unsupported_country_region_territory` | 在 Settings → Proxy 中設定代理 | -| Docker SQLite 鎖定 | 使用 `--stop-timeout 40` 進行乾淨的 WAL 檢查點 | -| Node 執行時期錯誤 | 使用 Node `>=22.0.0 <23` 或 `>=24.0.0 <27` | +| 問題 | 快速修復 | +| ----------------------------------------- | --------------------------------------------------------- | +| "Language model did not provide messages" | 提供者配額用盡 → 使用 Combo 備援 | +| 速率限制(429) | 新增備援:`cc/claude → glm/glm-4.7 → if/kimi-k2-thinking` | +| OAuth 令牌過期 | 自動重新整理;如果卡住,在 Providers 中刪除並重新驗證 | +| `unsupported_country_region_territory` | 在 Settings → Proxy 中設定代理 | +| Docker SQLite 鎖定 | 使用 `--stop-timeout 40` 進行乾淨的 WAL 檢查點 | +| Node 執行時期錯誤 | 使用 Node `>=22.0.0 <23` 或 `>=24.0.0 <27` | 🐛 **回報錯誤?** 執行 `npm run system-info` 並附上 `system-info.txt`。📖 [`docs/guides/TROUBLESHOOTING.md`](../../guides/TROUBLESHOOTING.md) @@ -831,12 +832,12 @@ podman compose --profile base up -d
-| 頁面 | 截圖 | 頁面 | 截圖 | -|---|---|---|---| -| Providers | ![Providers](../../screenshots/01-providers.png) | Combos | ![Combos](../../screenshots/02-combos.png) | -| Analytics | ![Analytics](../../screenshots/03-analytics.png) | Health | ![Health](../../screenshots/04-health.png) | -| Translator | ![Translator](../../screenshots/05-translator.png) | Settings | ![Settings](../../screenshots/06-settings.png) | -| CLI Tools | ![CLI Tools](../../screenshots/07-cli-tools.png) | Usage Logs | ![Usage](../../screenshots/08-usage.png) | +| 頁面 | 截圖 | 頁面 | 截圖 | +| ---------- | -------------------------------------------------- | ---------- | ---------------------------------------------- | +| Providers | ![Providers](../../screenshots/01-providers.png) | Combos | ![Combos](../../screenshots/02-combos.png) | +| Analytics | ![Analytics](../../screenshots/03-analytics.png) | Health | ![Health](../../screenshots/04-health.png) | +| Translator | ![Translator](../../screenshots/05-translator.png) | Settings | ![Settings](../../screenshots/06-settings.png) | +| CLI Tools | ![CLI Tools](../../screenshots/07-cli-tools.png) | Usage Logs | ![Usage](../../screenshots/08-usage.png) | @@ -890,64 +891,64 @@ podman compose --profile base up -d ### 📘 入門指南 -| 文件 | 說明 | -|---|---| -| [User Guide](../../guides/USER_GUIDE.md) | 供應商、Combo、CLI 整合、部署 | -| [Setup Guide](../../guides/SETUP_GUIDE.md) | 完整安裝方法、CLI 工具設定、協定設定、逾時調整 | -| [CLI Tools Guide](../../reference/CLI-TOOLS.md) | Claude Code、Codex、Cursor、Cline、OpenClaw、Kilo、Copilot 的個別工具設定 | -| [Remote Mode](../../guides/REMOTE-MODE.md) | 從筆記型電腦 CLI 透過範圍存取令牌驅動遠端 OmniRoute(VPS) | -| [Claude Code Config](../../guides/CLAUDE-CODE-CONFIGURATION.md) | 將 Claude Code 指向 OmniRoute(本地/遠端),附 `launch` + 每模型設定檔 | -| [Quick Start](../../README.md#-quick-start) | 3 步驟安裝 → 連接 → 設定 | +| 文件 | 說明 | +| --------------------------------------------------------------- | ------------------------------------------------------------------------- | +| [User Guide](../../guides/USER_GUIDE.md) | 提供者、Combo、CLI 整合、部署 | +| [Setup Guide](../../guides/SETUP_GUIDE.md) | 完整安裝方法、CLI 工具設定、協定設定、逾時調整 | +| [CLI Tools Guide](../../reference/CLI-TOOLS.md) | Claude Code、Codex、Cursor、Cline、OpenClaw、Kilo、Copilot 的個別工具設定 | +| [Remote Mode](../../guides/REMOTE-MODE.md) | 從筆記型電腦 CLI 透過範圍存取令牌驅動遠端 OmniRoute(VPS) | +| [Claude Code Config](../../guides/CLAUDE-CODE-CONFIGURATION.md) | 將 Claude Code 指向 OmniRoute(本地/遠端),附 `launch` + 每模型設定檔 | +| [Quick Start](../../README.md#-quick-start) | 3 步驟安裝 → 連接 → 設定 | ### 🔧 維運與部署 -| 文件 | 說明 | -|---|---| -| [Docker Guide](../../guides/DOCKER_GUIDE.md) | Docker 執行、Compose 設定檔、Caddy HTTPS、隧道、映像標籤 | -| [Podman Guide](../../contrib/podman/README.md) | Quadlet systemd 整合、podman-compose、SELinux | -| [VM Deployment](../../ops/VM_DEPLOYMENT_GUIDE.md) | 完整指南:VM + nginx + Cloudflare 設定 | -| [Fly.io Deployment](../../ops/FLY_IO_DEPLOYMENT_GUIDE.md) | 部署到 Fly.io,附持久儲存 | -| [Termux Guide](../../guides/TERMUX_GUIDE.md) | 在 Android 上透過 Termux 執行 OmniRoute | -| [PWA Guide](../../guides/PWA_GUIDE.md) | Progressive Web App 安裝、快取、架構 | -| [Uninstall Guide](../../guides/UNINSTALL.md) | 所有安裝方法的完整移除 | -| [Environment Config](../../reference/ENVIRONMENT.md) | 完整 `.env` 變數和參考 | +| 文件 | 說明 | +| --------------------------------------------------------- | -------------------------------------------------------- | +| [Docker Guide](../../guides/DOCKER_GUIDE.md) | Docker 執行、Compose 設定檔、Caddy HTTPS、隧道、映像標籤 | +| [Podman Guide](../../contrib/podman/README.md) | Quadlet systemd 整合、podman-compose、SELinux | +| [VM Deployment](../../ops/VM_DEPLOYMENT_GUIDE.md) | 完整指南:VM + nginx + Cloudflare 設定 | +| [Fly.io Deployment](../../ops/FLY_IO_DEPLOYMENT_GUIDE.md) | 部署到 Fly.io,附持久儲存 | +| [Termux Guide](../../guides/TERMUX_GUIDE.md) | 在 Android 上透過 Termux 執行 OmniRoute | +| [PWA Guide](../../guides/PWA_GUIDE.md) | Progressive Web App 安裝、快取、架構 | +| [Uninstall Guide](../../guides/UNINSTALL.md) | 所有安裝方法的完整移除 | +| [Environment Config](../../reference/ENVIRONMENT.md) | 完整 `.env` 變數和參考 | ### 🧠 功能與架構 -| 文件 | 說明 | -|---|---| -| [Architecture](../../architecture/ARCHITECTURE.md) | 系統架構、資料流程和內部運作 | -| [Compression Guide](../../compression/COMPRESSION_GUIDE.md) | 7 選項管線:off / lite / standard / aggressive / ultra / RTK / stacked | -| [RTK Compression](../../compression/RTK_COMPRESSION.md) | 命令輸出壓縮、過濾器、信任、驗證、原始輸出恢復 | -| [Compression Engines](../../compression/COMPRESSION_ENGINES.md) | Caveman、RTK、堆疊管線、儀表板/API/MCP 表面 | -| [Resilience Guide](../../architecture/RESILIENCE_GUIDE.md) | 斷路器、冷卻、佇列、反奔湧群、TLS 偽造 | -| [Auto-Combo Engine](../../routing/AUTO-COMBO.md) | 9 因素評分、模式包、自我修復 | -| [Proxy Guide](../../ops/PROXY_GUIDE.md) | 3 層代理系統、1proxy 市場、註冊表 CRUD | -| [Free Tiers](../../reference/FREE_TIERS.md) | 25+ 免費 API 供應商整合目錄 | -| [Features Gallery](../../guides/FEATURES.md) | 附截圖的視覺儀表板導覽 | -| [Codebase Documentation](../../architecture/CODEBASE_DOCUMENTATION.md) | 初學者友善的程式碼庫導覽 | +| 文件 | 說明 | +| ---------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [Architecture](../../architecture/ARCHITECTURE.md) | 系統架構、資料流程和內部運作 | +| [Compression Guide](../../compression/COMPRESSION_GUIDE.md) | 7 選項管線:off / lite / standard / aggressive / ultra / RTK / stacked | +| [RTK Compression](../../compression/RTK_COMPRESSION.md) | 命令輸出壓縮、過濾器、信任、驗證、原始輸出恢復 | +| [Compression Engines](../../compression/COMPRESSION_ENGINES.md) | Caveman、RTK、堆疊管線、儀表板/API/MCP 表面 | +| [Resilience Guide](../../architecture/RESILIENCE_GUIDE.md) | 斷路器、冷卻、佇列、反奔湧群、TLS 偽造 | +| [Auto-Combo Engine](../../routing/AUTO-COMBO.md) | 9 因素評分、模式包、自我修復 | +| [Proxy Guide](../../ops/PROXY_GUIDE.md) | 3 層代理系統、1proxy 市場、註冊表 CRUD | +| [Free Tiers](../../reference/FREE_TIERS.md) | 25+ 免費 API 提供者整合目錄 | +| [Features Gallery](../../guides/FEATURES.md) | 附截圖的視覺儀表板導覽 | +| [Codebase Documentation](../../architecture/CODEBASE_DOCUMENTATION.md) | 初學者友善的程式碼庫導覽 | ### 🤖 協定與 API -| 文件 | 說明 | -|---|---| -| [API Reference](../../reference/API_REFERENCE.md) | 所有端點附範例 | -| [OpenAPI Spec](../../openapi.yaml) | OpenAPI 3.0 規格 | -| [MCP Server](../../open-sse/mcp-server/README.md) | 87 個 MCP 工具、IDE 設定、Python/TS/Go 客戶端 | -| [MCP Server Guide](../../frameworks/MCP-SERVER.md) | MCP 安裝、傳輸和工具參考 | -| [A2A Server](../../src/lib/a2a/README.md) | JSON-RPC 2.0 協定、技能、串流、任務管理 | -| [A2A Server Guide](../../frameworks/A2A-SERVER.md) | A2A 代理卡片、任務、技能和串流 | +| 文件 | 說明 | +| -------------------------------------------------- | ---------------------------------------------- | +| [API Reference](../../reference/API_REFERENCE.md) | 所有端點附範例 | +| [OpenAPI Spec](../../openapi.yaml) | OpenAPI 3.0 規格 | +| [MCP Server](../../open-sse/mcp-server/README.md) | 104 個 MCP 工具、IDE 設定、Python/TS/Go 客戶端 | +| [MCP Server Guide](../../frameworks/MCP-SERVER.md) | MCP 安裝、傳輸和工具參考 | +| [A2A Server](../../src/lib/a2a/README.md) | JSON-RPC 2.0 協定、技能、串流、任務管理 | +| [A2A Server Guide](../../frameworks/A2A-SERVER.md) | A2A 代理卡片、任務、技能和串流 | ### 📋 專案與品質 -| 文件 | 說明 | -|---|---| -| [Contributing](../../CONTRIBUTING.md) | 開發設定和指南 | -| [Changelog](../../CHANGELOG.md) | 完整每個版本的發布歷史 | -| [Security Policy](../../SECURITY.md) | 漏洞回報和安全實踐 | -| [i18n Guide](../../guides/I18N.md) | 40+ 語言支援、翻譯工作流程、RTL | -| [Release Checklist](../../ops/RELEASE_CHECKLIST.md) | 發布前驗證步驟 | -| [Coverage Plan](../../ops/COVERAGE_PLAN.md) | 測試覆蓋率策略和 14,965 測試套件 | +| 文件 | 說明 | +| --------------------------------------------------- | -------------------------------- | +| [Contributing](../../CONTRIBUTING.md) | 開發設定和指南 | +| [Changelog](../../CHANGELOG.md) | 完整每個版本的發布歷史 | +| [Security Policy](../../SECURITY.md) | 漏洞回報和安全實踐 | +| [i18n Guide](../../guides/I18N.md) | 40+ 語言支援、翻譯工作流程、RTL | +| [Release Checklist](../../ops/RELEASE_CHECKLIST.md) | 發布前驗證步驟 | +| [Coverage Plan](../../ops/COVERAGE_PLAN.md) | 測試覆蓋率策略和 14,965 測試套件 |
@@ -1079,64 +1080,64 @@ OmniRoute 站在巨人的肩膀上。它始於 **[9router](https://github.com/de ### 🧬 淵源與閘道 -| 專案 | ⭐ | 啟發 OmniRoute 的方式 | -|---|---|---| -| **[9router](https://github.com/decolua/9router)** · decolua | 17.9k | 此分叉所基於的原始專案 — 此處以多模態 API 和完整的 TypeScript 重寫進行擴展。 | -| **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** · router-for-me | 37.8k | 啟發此 JavaScript/TypeScript 移植的 Go 實作。 | -| **[LiteLLM](https://github.com/BerriAI/litellm)** · BerriAI | 50.8k | AI 閘道,其公開定價資料集為我們的成本追蹤同步提供資料,其供應商正規化模型啟發了我們的路由。 | +| 專案 | ⭐ | 啟發 OmniRoute 的方式 | +| ------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------- | +| **[9router](https://github.com/decolua/9router)** · decolua | 17.9k | 此分叉所基於的原始專案 — 此處以多模態 API 和完整的 TypeScript 重寫進行擴展。 | +| **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** · router-for-me | 37.8k | 啟發此 JavaScript/TypeScript 移植的 Go 實作。 | +| **[LiteLLM](https://github.com/BerriAI/litellm)** · BerriAI | 50.8k | AI 閘道,其公開定價資料集為我們的成本追蹤同步提供資料,其提供者正規化模型啟發了我們的路由。 | ### 🗜️ 上下文與 Token 壓縮 — 引擎 -| 專案 | ⭐ | 啟發 OmniRoute 的方式 | -|---|---|---| -| **[Caveman](https://github.com/JuliusBrussee/caveman)** · JuliusBrussee | 74.5k | 病毒式"為什麼用很多 Token 而不用少量 Token"專案 — 其原始人語哲學為我們的標準壓縮模式和 30+ 填充詞/濃縮規則提供動力。 | -| **[RTK – Rust Token Killer](https://github.com/rtk-ai/rtk)** · rtk-ai | 63.6k | 高效能命令輸出壓縮 — 啟發了我們的 RTK 引擎、JSON 過濾器 DSL、原始輸出恢復和堆疊 RTK → Caveman 管線。 | -| **[headroom](https://github.com/chopratejas/headroom)** · chopratejas | 33.6k | 可逆上下文壓縮(SmartCrusher)— 啟發了我們的 `headroom` 引擎和 `ccr` 檢索標記模式。 | -| **[LLMLingua](https://github.com/microsoft/LLMLingua)** · Microsoft | 6.3k | 提示壓縮研究(LLMLingua / LLMLingua-2)— 啟發了我們的非同步、程式碼安全、fail-open `llmlingua` 引擎。 | -| **[llmlingua-2-js](https://github.com/atjsh/llmlingua-2-js)** · atjsh | 27 | JS/ONNX 移植(MobileBERT / XLM-RoBERTa)用作 LLMLingua 引擎的工作執行緒後端。 | -| **[Troglodita](https://github.com/leninejunior/troglodita)** · Lenine Júnior | 15 | PT-BR Token 壓縮 — 為我們的 pt-BR 語言包提供動力:針對巴西葡萄牙語文法調整的冗詞減少和填充詞移除。 | -| **[ponytail](https://github.com/DietrichGebert/ponytail)** · DietrichGebert | 51.4k | 病毒式"懶惰資深開發者" YAGNI 編碼技能 — 啟發了我們的 **less-code** Output Style:最小可行變更控制,減少生成的程式碼(Caveman 精簡文章的輸出軸夥伴)。 | +| 專案 | ⭐ | 啟發 OmniRoute 的方式 | +| ---------------------------------------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| **[Caveman](https://github.com/JuliusBrussee/caveman)** · JuliusBrussee | 74.5k | 病毒式"為什麼用很多 Token 而不用少量 Token"專案 — 其原始人語哲學為我們的標準壓縮模式和 30+ 填充詞/濃縮規則提供動力。 | +| **[RTK – Rust Token Killer](https://github.com/rtk-ai/rtk)** · rtk-ai | 63.6k | 高效能命令輸出壓縮 — 啟發了我們的 RTK 引擎、JSON 過濾器 DSL、原始輸出恢復和堆疊 RTK → Caveman 管線。 | +| **[headroom](https://github.com/chopratejas/headroom)** · chopratejas | 33.6k | 可逆上下文壓縮(SmartCrusher)— 啟發了我們的 `headroom` 引擎和 `ccr` 檢索標記模式。 | +| **[LLMLingua](https://github.com/microsoft/LLMLingua)** · Microsoft | 6.3k | 提示壓縮研究(LLMLingua / LLMLingua-2)— 啟發了我們的非同步、程式碼安全、fail-open `llmlingua` 引擎。 | +| **[llmlingua-2-js](https://github.com/atjsh/llmlingua-2-js)** · atjsh | 27 | JS/ONNX 移植(MobileBERT / XLM-RoBERTa)用作 LLMLingua 引擎的工作執行緒後端。 | +| **[Troglodita](https://github.com/leninejunior/troglodita)** · Lenine Júnior | 15 | PT-BR Token 壓縮 — 為我們的 pt-BR 語言包提供動力:針對巴西葡萄牙語文法調整的冗詞減少和填充詞移除。 | +| **[ponytail](https://github.com/DietrichGebert/ponytail)** · DietrichGebert | 51.4k | 病毒式"懶惰資深開發者" YAGNI 編碼技能 — 啟發了我們的 **less-code** Output Style:最小可行變更控制,減少生成的程式碼(Caveman 精簡文章的輸出軸夥伴)。 | ### 🧩 緊湊格式、Token 研究和程式碼感知工具 -| 專案 | ⭐ | 啟發 OmniRoute 的方式 | -|---|---|---| -| **[TOON](https://github.com/toon-format/toon)** · toon-format | 24.6k | Token 導向物件表示法 — 其欄式、標頭+行模型塑造了我們的表格壓縮階段。 | -| **[GCF](https://github.com/blackwell-systems/gcf)** · Blackwell Systems | 11 | 架構感知的"LLM 用 JSON"表示法 — 共同啟發了我們使用 `[N rows]` 標記的無損同構陣列壓縮。 | -| **[token-optimizer-mcp](https://github.com/ooples/token-optimizer-mcp)** · ooples | 409 | Brotli/SQLite 快取 + 每會話上下文 delta — 啟發了我們的 `session-dedup` 引擎。 | -| **[token-savior](https://github.com/Mibayy/token-savior)** · Mibayy | 993 | Bash 輸出壓縮 + MCP 設定檔 — 啟發了我們的壓縮 bail-out 紀律和 MCP 工具清單縮減。 | -| **[ts-morph](https://github.com/dsherret/ts-morph)** · David Sherret | 6.1k | TypeScript 編譯器 API 工具包 — 啟發了我們基於解析器的註解移除,可保留字串、範本和正則表達式文字。 | +| 專案 | ⭐ | 啟發 OmniRoute 的方式 | +| --------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------- | +| **[TOON](https://github.com/toon-format/toon)** · toon-format | 24.6k | Token 導向物件表示法 — 其欄式、標頭+行模型塑造了我們的表格壓縮階段。 | +| **[GCF](https://github.com/blackwell-systems/gcf)** · Blackwell Systems | 11 | 架構感知的"LLM 用 JSON"表示法 — 共同啟發了我們使用 `[N rows]` 標記的無損同構陣列壓縮。 | +| **[token-optimizer-mcp](https://github.com/ooples/token-optimizer-mcp)** · ooples | 409 | Brotli/SQLite 快取 + 每會話上下文 delta — 啟發了我們的 `session-dedup` 引擎。 | +| **[token-savior](https://github.com/Mibayy/token-savior)** · Mibayy | 993 | Bash 輸出壓縮 + MCP 設定檔 — 啟發了我們的壓縮 bail-out 紀律和 MCP 工具清單縮減。 | +| **[ts-morph](https://github.com/dsherret/ts-morph)** · David Sherret | 6.1k | TypeScript 編譯器 API 工具包 — 啟發了我們基於解析器的註解移除,可保留字串、範本和正則表達式文字。 | ### 🧠 記憶與 RAG -| 專案 | ⭐ | 啟發 OmniRoute 的方式 | -|---|---|---| -| **[Mem0](https://github.com/mem0ai/mem0)** · mem0ai | 58.9k | 通用記憶層 — 其代理作為寫入/讀取邊界模型塑造了我們的記憶架構。 | +| 專案 | ⭐ | 啟發 OmniRoute 的方式 | +| ------------------------------------------------------------------ | ----- | ----------------------------------------------------------------------------------- | +| **[Mem0](https://github.com/mem0ai/mem0)** · mem0ai | 58.9k | 通用記憶層 — 其代理作為寫入/讀取邊界模型塑造了我們的記憶架構。 | | **[Letta (MemGPT)](https://github.com/letta-ai/letta)** · letta-ai | 23.4k | 具有分層記憶的狀態化代理 — 啟發了我們的 Context Control & Recovery(CCR)分層模型。 | -| **[WFGY](https://github.com/onestardao/WFGY)** · onestardao | 1.8k | 16 種常見 RAG/LLM 失敗模式的 ProblemMap 分類法 — 我們故障排除指南中的共享詞彙。 | +| **[WFGY](https://github.com/onestardao/WFGY)** · onestardao | 1.8k | 16 種常見 RAG/LLM 失敗模式的 ProblemMap 分類法 — 我們故障排除指南中的共享詞彙。 | ### 🛰️ 流量檢查、MITM 和透明代理 -| 專案 | ⭐ | 啟發 OmniRoute 的方式 | -|---|---|---| -| **[llm-interceptor](https://github.com/chouzz/llm-interceptor)** · chouzz | 46 | 編碼助手 ↔ LLM 流量的 MITM 攔截/分析 — 我們的 Traffic Inspector 移植其 SSE 合併、對話正規化、主機傳遞和秘密遮罩。 | -| **[ProxyBridge](https://github.com/InterceptSuite/ProxyBridge)** · InterceptSuite | 5.1k | 透明每程序代理路由 — 啟發了我們的崩潰安全 MITM 拆卸、socket 空閒逾時、`/proc` 程序歸屬和 TPROXY 捕獲。 | +| 專案 | ⭐ | 啟發 OmniRoute 的方式 | +| --------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------- | +| **[llm-interceptor](https://github.com/chouzz/llm-interceptor)** · chouzz | 46 | 編碼助手 ↔ LLM 流量的 MITM 攔截/分析 — 我們的 Traffic Inspector 移植其 SSE 合併、對話正規化、主機傳遞和秘密遮罩。 | +| **[ProxyBridge](https://github.com/InterceptSuite/ProxyBridge)** · InterceptSuite | 5.1k | 透明每程序代理路由 — 啟發了我們的崩潰安全 MITM 拆卸、socket 空閒逾時、`/proc` 程序歸屬和 TPROXY 捕獲。 | ### 📚 模型資料、可觀測性與 UI -| 專案 | ⭐ | 啟發 OmniRoute 的方式 | -|---|---|---| -| **[models.dev](https://github.com/anomalyco/models.dev)** · SST / OpenCode | 5.1k | AI 模型規格、定價和能力的開放資料庫 — 原生同步到我們的模型目錄。 | -| **[React Flow / xyflow](https://github.com/xyflow/xyflow)** · xyflow | 37.1k | 驅動我們即時 Compression Studio 和 Combo/Routing Studio 的基於節點的圖形函式庫。 | -| **[LangGraph](https://github.com/langchain-ai/langgraph)** · LangChain | 35.1k | LangGraph Studio 的即時工作流程圖形視覺化啟發了我們 Studios 的即時級聯視圖。 | -| **[Langfuse](https://github.com/langfuse/langfuse)** · Langfuse | 29.3k | 其 trace → span → generation 可觀測性模型塑造了我們的 Compression Studio 瀑布圖。 | -| **[Kiali](https://github.com/kiali/kiali)** · Kiali | 3.6k | Istio 服務網格可觀測性 — 啟發了我們在 Routing/Combo Studio 中的斷路器徽章和錯誤邊緣視覺效果。 | -| **[lobe-icons](https://github.com/lobehub/lobe-icons)** · LobeHub | 2.1k | 在我們儀表板上呈現供應商圖示的 AI/LLM 品牌標誌。 | +| 專案 | ⭐ | 啟發 OmniRoute 的方式 | +| -------------------------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------- | +| **[models.dev](https://github.com/anomalyco/models.dev)** · SST / OpenCode | 5.1k | AI 模型規格、定價和能力的開放資料庫 — 原生同步到我們的模型目錄。 | +| **[React Flow / xyflow](https://github.com/xyflow/xyflow)** · xyflow | 37.1k | 驅動我們即時 Compression Studio 和 Combo/Routing Studio 的基於節點的圖形函式庫。 | +| **[LangGraph](https://github.com/langchain-ai/langgraph)** · LangChain | 35.1k | LangGraph Studio 的即時工作流程圖形視覺化啟發了我們 Studios 的即時級聯視圖。 | +| **[Langfuse](https://github.com/langfuse/langfuse)** · Langfuse | 29.3k | 其 trace → span → generation 可觀測性模型塑造了我們的 Compression Studio 瀑布圖。 | +| **[Kiali](https://github.com/kiali/kiali)** · Kiali | 3.6k | Istio 服務網格可觀測性 — 啟發了我們在 Routing/Combo Studio 中的斷路器徽章和錯誤邊緣視覺效果。 | +| **[lobe-icons](https://github.com/lobehub/lobe-icons)** · LobeHub | 2.1k | 在我們儀表板上呈現提供者圖示的 AI/LLM 品牌標誌。 | ### 🛡️ 安全 -| 專案 | ⭐ | 啟發 OmniRoute 的方式 | -|---|---|---| +| 專案 | ⭐ | 啟發 OmniRoute 的方式 | +| ------------------------------------------------------------------------------------------- | --- | -------------------------------------------------------------------------------------------------------------------- | | **[awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults)** · tldrsec | 708 | 一個精選的安全預設函式庫列表,引導我們的安全選擇(Helmet.js、DOMPurify、ssrf-req-filter、safe-regex、Google Tink)。 | ## ❤️ 支援 @@ -1144,7 +1145,7 @@ OmniRoute 站在巨人的肩膀上。它始於 **[9router](https://github.com/de OmniRoute 是免費且開源的,在公開環境中建構和維護。如果它為您節省了時間或金錢,請考慮支援其開發: - ⭐ **為倉庫加星** — 這確實有助於提高能見度 -- 💖 **[GitHub Sponsors](https://github.com/sponsors/diegosouzapw)** — 資助持續維護和新供應商 +- 💖 **[GitHub Sponsors](https://github.com/sponsors/diegosouzapw)** — 資助持續維護和新提供者 - 🐛 **在 [Discussions](https://github.com/diegosouzapw/OmniRoute/discussions) 中回報錯誤和分享意見回饋** ## 📄 授權 diff --git a/docs/i18n/zh-TW/docs/architecture/ARCHITECTURE.md b/docs/i18n/zh-TW/docs/architecture/ARCHITECTURE.md index 5372a756f2..8fd293242c 100644 --- a/docs/i18n/zh-TW/docs/architecture/ARCHITECTURE.md +++ b/docs/i18n/zh-TW/docs/architecture/ARCHITECTURE.md @@ -13,39 +13,39 @@ _最後更新:2026-06-28_ ## 執行摘要 OmniRoute 是一個建構於 Next.js 上的本地 AI 路由閘道與儀表板。 -它提供單一的 OpenAI 相容端點(`/v1/*`),並透過轉換、備援、令牌刷新與用量追蹤,將流量路由至多個上游供應商。 +它提供單一的 OpenAI 相容端點(`/v1/*`),並透過轉換、備援、令牌刷新與用量追蹤,將流量路由至多個上游提供者。 核心能力: -- OpenAI 相容的 API 表面,適用於 CLI/工具(268 個供應商、84 個執行器) -- 跨供應商格式的請求/回應轉換 +- OpenAI 相容的 API 表面,適用於 CLI/工具(268 個提供者、84 個執行器) +- 跨提供者格式的請求/回應轉換 - 模型組合備援(多模型序列) -- 結構化組合步驟(`供應商 + 模型 + 連線`),支援執行期依 `compositeTiers` 排序 -- 帳戶層級備援(每個供應商多帳戶) +- 結構化組合步驟(`提供者 + 模型 + 連線`),支援執行期依 `compositeTiers` 排序 +- 帳戶層級備援(每個提供者多帳戶) - 主要聊天路徑中的配額預檢與配額感知 P2C 帳戶選擇 -- OAuth + API 金鑰供應商連線管理(19 個 OAuth 供應商模組) -- 透過 `/v1/embeddings` 生成嵌入向量(6 個供應商、9 個模型) -- 透過 `/v1/images/generations` 生成圖片(10+ 個供應商、20+ 個模型) -- 透過 `/v1/audio/transcriptions` 進行語音轉錄(7 個供應商) -- 透過 `/v1/audio/speech` 進行文字轉語音(10 個供應商) +- OAuth + API 金鑰提供者連線管理(19 個 OAuth 提供者模組) +- 透過 `/v1/embeddings` 生成嵌入向量(6 個提供者、9 個模型) +- 透過 `/v1/images/generations` 生成圖片(10+ 個提供者、20+ 個模型) +- 透過 `/v1/audio/transcriptions` 進行語音轉錄(7 個提供者) +- 透過 `/v1/audio/speech` 進行文字轉語音(10 個提供者) - 透過 `/v1/videos/generations` 生成影片(ComfyUI + SD WebUI) - 透過 `/v1/music/generations` 生成音樂(ComfyUI) -- 透過 `/v1/search` 進行網路搜尋(5 個供應商) +- 透過 `/v1/search` 進行網路搜尋(5 個提供者) - 透過 `/v1/moderations` 進行內容審核 - 透過 `/v1/rerank` 進行重新排序 - Think 標籤解析(`...`)用於推理模型 - 回應淨化處理,確保嚴格的 OpenAI SDK 相容性 -- 角色正規化(developer→system, system→user)以實現跨供應商相容性 +- 角色正規化(developer→system, system→user)以實現跨提供者相容性 - 結構化輸出轉換(json_schema → Gemini responseSchema) -- 供應商、金鑰、別名、組合、設定、定價的本地持久化(26 個 DB 模組) +- 提供者、金鑰、別名、組合、設定、定價的本地持久化(26 個 DB 模組) - 用量/成本追蹤與請求記錄 - 選用雲端同步,支援多裝置/狀態同步 - 用於 API 存取控制的 IP 允許清單/封鎖清單 - 思考預算管理(透傳/自動/自訂/自適應) - 全域系統提示注入 - 工作階段追蹤與指紋辨識 -- 每個帳戶的增強速率限制,附供應商特定設定檔 -- 用於供應商韌性的斷路器模式 +- 每個帳戶的增強速率限制,附提供者特定設定檔 +- 用於提供者韌性的斷路器模式 - 使用互斥鎖防止驚群效應 - 基於簽章的請求去重快取 - 領域層:成本規則、備援政策、鎖定政策 @@ -57,7 +57,7 @@ OmniRoute 是一個建構於 Next.js 上的本地 AI 路由閘道與儀表板。 - 關聯 ID(X-Request-Id)實現端到端追蹤 - 合規稽核記錄,可依 API 金鑰選擇退出 - 用於 LLM 品質保證的評估框架 -- 健康狀態儀表板,即時顯示供應商斷路器狀態 +- 健康狀態儀表板,即時顯示提供者斷路器狀態 - MCP 伺服器(87 個工具)支援 3 種傳輸方式(stdio/SSE/Streamable HTTP) - A2A 伺服器(JSON-RPC 2.0 + SSE)含技能與任務生命週期 - 記憶系統(提取、注入、檢索、摘要) @@ -66,23 +66,23 @@ OmniRoute 是一個建構於 Next.js 上的本地 AI 路由閘道與儀表板。 - 提示注入防護中介軟體 - 提示壓縮管線,含 Caveman、RTK、堆疊管線、壓縮組合、語言套件與分析功能 - ACP(代理通訊協定)註冊表 -- 模組化 OAuth 供應商(19 個獨立模組,位於 `src/lib/oauth/providers/`) +- 模組化 OAuth 提供者(19 個獨立模組,位於 `src/lib/oauth/providers/`) - 解除安裝/完整解除安裝指令碼 - OAuth 環境修復動作 - WebSocket 橋接,供 OpenAI 相容的 WS 客戶端使用(`/v1/ws`) - 同步令牌管理(簽發/撤銷,ETag 版本化設定套件下載) -- GLM Thinking(`glmt`)第一級供應商預設 -- 混合令牌計數(供應商端 `/messages/count_tokens` 搭配估算備援) +- GLM Thinking(`glmt`)第一級提供者預設 +- 混合令牌計數(提供者端 `/messages/count_tokens` 搭配估算備援) - 模型別名自動播種(啟動時 30+ 跨代理方言正規化) - 安全的外送請求,含 SSRF 防護、私人 URL 封鎖與可設定的重試 - 具冷卻感知的聊天重試,含可設定的 `requestRetry` 與 `maxRetryIntervalSec` - 啟動時使用 Zod 進行執行環境驗證 -- 合規稽核 v2,含分頁、供應商 CRUD 事件與 SSRF 封鎖驗證記錄 +- 合規稽核 v2,含分頁、提供者 CRUD 事件與 SSRF 封鎖驗證記錄 主要執行模型: - `src/app/api/*` 下的 Next.js 應用路由同時實作儀表板 API 與相容性 API -- `src/sse/*` + `open-sse/*` 中的共用 SSE/路由核心負責供應商執行、轉換、串流、備援與用量 +- `src/sse/*` + `open-sse/*` 中的共用 SSE/路由核心負責提供者執行、轉換、串流、備援與用量 ## 參考圖表 @@ -105,7 +105,7 @@ v3.8.0 平台的標準版本控制 Mermaid 原始檔位於 - 本地閘道執行環境 - 儀表板管理 API -- 供應商驗證與令牌刷新 +- 提供者驗證與令牌刷新 - 請求轉換與 SSE 串流 - 本地狀態 + 用量持久化 - 選用雲端同步協調 @@ -113,16 +113,16 @@ v3.8.0 平台的標準版本控制 Mermaid 原始檔位於 ### 不涵蓋範圍 - `NEXT_PUBLIC_CLOUD_URL` 背後的雲端服務實作 -- 本地程序外的供應商 SLA/控制平面 +- 本地程序外的提供者 SLA/控制平面 - 外部 CLI 二進位檔案本身(Claude CLI、Codex CLI 等) ## 儀表板表面(目前版本) `src/app/(dashboard)/dashboard/` 下的主要頁面: -- `/dashboard` — 快速入門 + 供應商概覽 +- `/dashboard` — 快速入門 + 提供者概覽 - `/dashboard/endpoint` — 端點代理 + MCP + A2A + API 端點分頁 -- `/dashboard/providers` — 供應商連線與憑證 +- `/dashboard/providers` — 提供者連線與憑證 - `/dashboard/combos` — 組合策略、範本、逐步建置器、模型路由規則、手動持久化排序 - `/dashboard/auto-combo` — 自動組合引擎:評分權重、模式套件、虛擬工廠預設、遙測 - `/dashboard/costs` — 成本彙總與定價檢視 @@ -141,7 +141,7 @@ v3.8.0 平台的標準版本控制 Mermaid 原始檔位於 - `/dashboard/system` — 執行時期診斷、版本資訊、環境驗證表面 - `/dashboard/onboarding` — 新安裝的首次執行設定精靈 - `/dashboard/media` — 圖片/影片/音樂測試區 -- `/dashboard/search-tools` — 搜尋供應商測試與歷史記錄 +- `/dashboard/search-tools` — 搜尋提供者測試與歷史記錄 - `/dashboard/health` — 運行時間、斷路器、速率限制、配額監控工作階段 - `/dashboard/logs` — 請求/代理/稽核/主控台記錄 - `/dashboard/settings` — 系統設定分頁(一般、路由、組合預設等) @@ -174,9 +174,9 @@ flowchart LR UDB[(用量表格 + 記錄工件)] end - subgraph Upstreams[上游供應商] - P1[OAuth 供應商\nClaude/Codex/Gemini/Qoder/GitHub/Kiro/Cursor/Antigravity] - P2[API 金鑰供應商\nOpenAI/Anthropic/OpenRouter/GLM/Kimi/MiniMax\nDeepSeek/Groq/xAI/Mistral/Perplexity\nTogether/Fireworks/Cerebras/Cohere/NVIDIA] + subgraph Upstreams[上游提供者] + P1[OAuth 提供者\nClaude/Codex/Gemini/Qoder/GitHub/Kiro/Cursor/Antigravity] + P2[API 金鑰提供者\nOpenAI/Anthropic/OpenRouter/GLM/Kimi/MiniMax\nDeepSeek/Groq/xAI/Mistral/Perplexity\nTogether/Fireworks/Cerebras/Cohere/NVIDIA] P3[相容節點\nOpenAI 相容 / Anthropic 相容] end @@ -218,20 +218,20 @@ flowchart LR - `src/app/api/v1/messages/route.ts` - `src/app/api/v1/responses/route.ts` - `src/app/api/v1/models/route.ts` — 包含 `custom: true` 的自訂模型 -- `src/app/api/v1/embeddings/route.ts` — 嵌入向量生成(6 個供應商) -- `src/app/api/v1/images/generations/route.ts` — 圖片生成(4+ 個供應商,含 Antigravity/Nebius) +- `src/app/api/v1/embeddings/route.ts` — 嵌入向量生成(6 個提供者) +- `src/app/api/v1/images/generations/route.ts` — 圖片生成(4+ 個提供者,含 Antigravity/Nebius) - `src/app/api/v1/messages/count_tokens/route.ts` -- `src/app/api/v1/providers/[provider]/chat/completions/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/providers/[provider]/chat/completions/route.ts` — 專屬的每個提供者聊天 +- `src/app/api/v1/providers/[provider]/embeddings/route.ts` — 專屬的每個提供者嵌入 +- `src/app/api/v1/providers/[provider]/images/generations/route.ts` — 專屬的每個提供者圖片 - `src/app/api/v1beta/models/route.ts` - `src/app/api/v1beta/models/[...path]/route.ts` 管理領域: - 驗證/設定:`src/app/api/auth/*`、`src/app/api/settings/*` -- 供應商/連線:`src/app/api/providers*` -- 供應商節點:`src/app/api/provider-nodes*` +- 提供者/連線:`src/app/api/providers*` +- 提供者節點:`src/app/api/provider-nodes*` - 自訂模型:`src/app/api/provider-models`(GET/POST/DELETE) - 模型目錄:`src/app/api/models/route.ts`(GET) - 代理設定:`src/app/api/settings/proxy`(GET/PUT/DELETE)+ `src/app/api/settings/proxy/test`(POST) @@ -246,8 +246,8 @@ flowchart LR - 壓縮:`src/app/api/settings/compression`、`src/app/api/compression/*` 與 `src/app/api/context/*` - 工作階段:`src/app/api/sessions`(GET) - 速率限制:`src/app/api/rate-limits`(GET) -- 韌性:`src/app/api/resilience`(GET/PATCH)— 請求佇列、連線冷卻、供應商斷路器、等待冷卻設定 -- 韌性重置:`src/app/api/resilience/reset`(POST)— 重置供應商斷路器 +- 韌性:`src/app/api/resilience`(GET/PATCH)— 請求佇列、連線冷卻、提供者斷路器、等待冷卻設定 +- 韌性重置:`src/app/api/resilience/reset`(POST)— 重置提供者斷路器 - 快取統計:`src/app/api/cache/stats`(GET/DELETE) - 遙測:`src/app/api/telemetry/summary`(GET) - 預算:`src/app/api/usage/budget`(GET/POST) @@ -256,7 +256,7 @@ flowchart LR - 評估:`src/app/api/evals`(GET/POST)、`src/app/api/evals/[suiteId]`(GET) - 政策:`src/app/api/policies`(GET/POST) - 同步令牌:`src/app/api/sync/tokens`(GET/POST)、`src/app/api/sync/tokens/[id]`(GET/DELETE) -- 設定套件:`src/app/api/sync/bundle`(GET,設定/供應商/組合/金鑰的 ETag 版本化快照) +- 設定套件:`src/app/api/sync/bundle`(GET,設定/提供者/組合/金鑰的 ETag 版本化快照) - WebSocket:`src/app/api/v1/ws/route.ts` — OpenAI 相容 WS 客戶端的升級處理器 ## 2) SSE + 轉換核心 @@ -265,8 +265,8 @@ flowchart LR - 入口:`src/sse/handlers/chat.ts` - 核心協調:`open-sse/handlers/chatCore.ts` -- 供應商執行轉接器:`open-sse/executors/*` -- 格式偵測/供應商設定:`open-sse/services/provider.ts` +- 提供者執行轉接器:`open-sse/executors/*` +- 格式偵測/提供者設定:`open-sse/services/provider.ts` - 模型解析/解析:`src/sse/services/model.ts`、`open-sse/services/model.ts` - 帳戶備援邏輯:`open-sse/services/accountFallback.ts` - 轉換註冊表:`open-sse/translator/index.ts` @@ -274,9 +274,9 @@ flowchart LR - 用量提取/正規化:`open-sse/utils/usageTracking.ts` - Think 標籤解析器:`open-sse/utils/thinkTagParser.ts` - 嵌入處理器:`open-sse/handlers/embeddings.ts` -- 嵌入供應商註冊表:`open-sse/config/embeddingRegistry.ts` +- 嵌入提供者註冊表:`open-sse/config/embeddingRegistry.ts` - 圖片生成處理器:`open-sse/handlers/imageGeneration.ts` -- 圖片供應商註冊表:`open-sse/config/imageRegistry.ts` +- 圖片提供者註冊表:`open-sse/config/imageRegistry.ts` - 回應淨化:`open-sse/handlers/responseSanitizer.ts` - 角色正規化:`open-sse/services/roleNormalizer.ts` @@ -293,13 +293,13 @@ flowchart LR - 速率限制管理:`open-sse/services/rateLimitManager.ts` - 斷路器:`src/shared/utils/circuitBreaker.ts` - 上下文交接:`open-sse/services/contextHandoff.ts` — 用於上下文轉接策略的交接摘要產生與注入 -- 壓縮:`open-sse/services/compression/*` — 供應商轉換前的主動壓縮;包含 Caveman 規則、RTK 過濾器、堆疊管線、壓縮組合、統計資料與驗證 +- 壓縮:`open-sse/services/compression/*` — 提供者轉換前的主動壓縮;包含 Caveman 規則、RTK 過濾器、堆疊管線、壓縮組合、統計資料與驗證 - Codex 配額擷取器:`open-sse/services/codexQuotaFetcher.ts` — 擷取 Codex 配額用於上下文轉接交接決策 - 具冷卻感知的重試:`src/sse/services/cooldownAwareRetry.ts` — 每個模型的冷卻重試,具可設定的 `requestRetry` / `maxRetryIntervalSec` -- 安全外送請求:`src/shared/network/safeOutboundFetch.ts` — 受防護的供應商/模型請求,含 SSRF 防護、私人 URL 封鎖、重試與逾時 -- 外送 URL 防護:`src/shared/network/outboundUrlGuard.ts` — 驗證供應商 URL 是否位於私人/本地 CIDR 範圍 -- 供應商請求預設值:`open-sse/services/providerRequestDefaults.ts` — 供應商層級的 `maxTokens`、`temperature`、`thinkingBudgetTokens` 預設值 -- GLM 供應商常數:`open-sse/config/glmProvider.ts` — 共用的 GLM 模型、配額 URL、GLMT 逾時/預設值 +- 安全外送請求:`src/shared/network/safeOutboundFetch.ts` — 受防護的提供者/模型請求,含 SSRF 防護、私人 URL 封鎖、重試與逾時 +- 外送 URL 防護:`src/shared/network/outboundUrlGuard.ts` — 驗證提供者 URL 是否位於私人/本地 CIDR 範圍 +- 提供者請求預設值:`open-sse/services/providerRequestDefaults.ts` — 提供者層級的 `maxTokens`、`temperature`、`thinkingBudgetTokens` 預設值 +- GLM 提供者常數:`open-sse/config/glmProvider.ts` — 共用的 GLM 模型、配額 URL、GLMT 逾時/預設值 - Antigravity 上游:`open-sse/config/antigravityUpstream.ts` — 基礎 URL 與探索路徑常數 - Codex 客戶端常數:`open-sse/config/codexClient.ts` — 版本化的使用者代理與客戶端版本值 - 模型別名播種:`src/lib/modelAliasSeed.ts` — 啟動時播種 30+ 跨代理方言別名 @@ -319,10 +319,10 @@ flowchart LR - 評估執行器:`src/lib/evals/evalRunner.ts` - 領域狀態持久化:`src/lib/db/domainState.ts` — 備援鏈、預算、成本歷史、鎖定狀態、斷路器的 SQLite CRUD -OAuth 供應商模組(`src/lib/oauth/providers/` 下的 16 個個別檔案): +OAuth 提供者模組(`src/lib/oauth/providers/` 下的 16 個個別檔案): - 註冊表索引:`src/lib/oauth/providers/index.ts` -- 個別供應商:`claude.ts`、`codex.ts`、`gemini.ts`、`antigravity.ts`、`agy.ts`、`qoder.ts`、`qwen.ts`、`kimi-coding.ts`、`github.ts`、`kiro.ts`、`cursor.ts`、`kilocode.ts`、`cline.ts`、`windsurf.ts`、`gitlab-duo.ts`、`trae.ts` +- 個別提供者:`claude.ts`、`codex.ts`、`gemini.ts`、`antigravity.ts`、`agy.ts`、`qoder.ts`、`qwen.ts`、`kimi-coding.ts`、`github.ts`、`kiro.ts`、`cursor.ts`、`kilocode.ts`、`cline.ts`、`windsurf.ts`、`gitlab-duo.ts`、`trae.ts` - 薄包裝層:`src/lib/oauth/providers.ts` — 從個別模組重新匯出 ## 5) 嵌入式服務(v3.8.4) @@ -341,7 +341,7 @@ OmniRoute 可以安裝、監督並路由至本地運行的 AI 工具程序, `child_process.spawn`,持有 5 MB 環形緩衝區用於 SSE 記錄串流、健康狀態 探測迴圈、原子操作鎖與 SIGTERM→SIGKILL 優雅關機。 `bootstrap.ts` 在程序啟動時連接所有已設定的服務。 -- **供應商/執行器**(`open-sse/executors/ninerouter.ts`)— 9Router 以真實供應商型態 +- **提供者/執行器**(`open-sse/executors/ninerouter.ts`)— 9Router 以真實提供者型態 暴露。模型前綴為 `9router/{sub}/{model}`,每 5 分鐘從 9Router 的 `/v1/models` 端點同步一次。 深入探討:`docs/frameworks/EMBEDDED-SERVICES.md` @@ -367,7 +367,7 @@ OmniRoute 可以安裝、監督並路由至本地運行的 AI 工具程序, - **9 因子評分**:成本、p95 延遲、成功率、配額餘裕、鎖定 接近度、斷路器狀態、近期失敗、模型可用性與標籤親和性。 - **虛擬工廠**在沒有相符的命名組合時實例化暫時組合, - 從健康活躍的供應商連線中篩選候選者。 + 從健康活躍的提供者連線中篩選候選者。 - **自動前綴**:`auto/coding`、`auto/cheap`、`auto/fast`、`auto/offline`、 `auto/smart`、`auto/lkgp` — 每個都有調整過的權重設定檔。 - **4 種模式套件**:coding、fast、cheap、smart — 以預設權重 @@ -422,7 +422,7 @@ Jules)包裝在統一的 DB 支援任務生命週期後方。所有任務建 - 組合解析器:`src/domain/comboResolver.ts` — 將組合名稱、`auto/*` 前綴與萬用字元模型目標解析為具體執行計畫 - 連線/模型規則連接器:`src/domain/connectionModelRules.ts` - 模型可用性快照:`src/domain/modelAvailability.ts` -- 供應商到期追蹤:`src/domain/providerExpiration.ts` +- 提供者到期追蹤:`src/domain/providerExpiration.ts` - 配額快取:`src/domain/quotaCache.ts` - 降級狀態:`src/domain/degradation.ts` - 設定稽核:`src/domain/configAudit.ts` @@ -441,7 +441,7 @@ Jules)包裝在統一的 DB 支援任務生命週期後方。所有任務建 - 斷言輔助:`src/server/authz/assertAuth.ts` - 請求上下文:`src/server/authz/context.ts` -公開路由與管理路由之間有嚴格邊界:代理/冷卻 API 與供應商變更需要管理驗證(缺少時回傳 HTTP 401)。 +公開路由與管理路由之間有嚴格邊界:代理/冷卻 API 與提供者變更需要管理驗證(缺少時回傳 HTTP 401)。 完整的路由分類規則,請參閱 [`docs/architecture/AUTHZ_GUIDE.md`](./AUTHZ_GUIDE.md)。 @@ -457,9 +457,9 @@ Jules)包裝在統一的 DB 支援任務生命週期後方。所有任務建 FSM 轉換結果饋入自動組合的評分,對背景/自動化任務偏向較便宜的模型,對互動式規劃/審查輪次偏向較強的模型。 -### G. 供應商專屬韌性 +### G. 提供者專屬韌性 -數個供應商配備了專用的韌性與隱匿模組,建構在全域斷路器 / 連線冷卻 / 模型鎖定層之上: +數個提供者配備了專用的韌性與隱匿模組,建構在全域斷路器 / 連線冷卻 / 模型鎖定層之上: - Antigravity 429 引擎:`open-sse/services/antigravity429Engine.ts`(輪換身分、清除回應標頭、透過 `antigravityCredits.ts`、`antigravityHeaderScrub.ts`、`antigravityHeaders.ts`、`antigravityIdentity.ts`、`antigravityObfuscation.ts`、`antigravityVersion.ts` 驅動點數/版本追蹤) - ModelScope 配額政策:`open-sse/services/modelscopePolicy.ts` @@ -474,12 +474,12 @@ FSM 轉換結果饋入自動組合的評分,對背景/自動化任務偏向較 ### H. Webhook、推理快取、讀取快取 -- **Webhook** — 用於供應商/帳戶/任務事件的外送調度。 +- **Webhook** — 用於提供者/帳戶/任務事件的外送調度。 - 調度器:`src/lib/webhookDispatcher.ts` - 儲存:`webhooks` SQLite 表格(透過 `src/lib/db/webhooks.ts`) - 儀表板:`/dashboard/webhooks`(訂閱、秘密、重試歷史) - 事件分類與重試語意,請參閱 [`docs/frameworks/WEBHOOKS.md`](../frameworks/WEBHOOKS.md)。 -- **推理快取** — 為會發出思考令牌的供應商(Claude、GLMT 等)提供可重播的推理區塊,讓連續輪次可以跳過重新思考。 +- **推理快取** — 為會發出思考令牌的提供者(Claude、GLMT 等)提供可重播的推理區塊,讓連續輪次可以跳過重新思考。 - DB 層:`src/lib/db/reasoningCache.ts` - 服務層:`open-sse/services/reasoningCache.ts` - 重播語意,請參閱 [`docs/routing/REASONING_REPLAY.md`](../routing/REASONING_REPLAY.md)。 @@ -513,9 +513,9 @@ FSM 轉換結果饋入自動組合的評分,對背景/自動化任務偏向較 - 儀表板 Cookie 驗證:`src/proxy.ts`、`src/app/api/auth/login/route.ts` - API 金鑰生成/驗證:`src/shared/utils/apiKey.ts` -- 供應商秘密儲存在 `providerConnections` 項目中 -- 透過 `open-sse/utils/proxyFetch.ts`(環境變數)與 `open-sse/utils/networkProxy.ts`(可針對每個供應商或全域設定)支援外送代理 -- SSRF / 外送 URL 防護:`src/shared/network/outboundUrlGuard.ts` — 封鎖所有供應商呼叫的私人/迴路/鏈結本地範圍 +- 提供者秘密儲存在 `providerConnections` 項目中 +- 透過 `open-sse/utils/proxyFetch.ts`(環境變數)與 `open-sse/utils/networkProxy.ts`(可針對每個提供者或全域設定)支援外送代理 +- SSRF / 外送 URL 防護:`src/shared/network/outboundUrlGuard.ts` — 封鎖所有提供者呼叫的私人/迴路/鏈結本地範圍 - 執行時期環境驗證:`src/lib/env/runtimeEnv.ts` — 所有環境變數的 Zod 架構,以啟動錯誤/警告形式呈現 - 同步令牌:`src/lib/db/syncTokens.ts` — 用於設定套件下載端點的範圍限定令牌;由 `sync_tokens` SQLite 表格支援(遷移 `024_create_sync_tokens.sql`) - WebSocket 握手驗證:`src/lib/ws/handshake.ts` — 透過 API 金鑰或工作階段 Cookie 驗證 WS 升級請求 @@ -538,8 +538,8 @@ sequenceDiagram participant Core as open-sse/handlers/chatCore participant Model as 模型解析器 participant Auth as 憑證選擇器 - participant Exec as 供應商執行器 - participant Prov as 上游供應商 + participant Exec as 提供者執行器 + participant Prov as 上游提供者 participant Stream as 串流轉換器 participant Usage as usageDb @@ -583,12 +583,12 @@ flowchart TD B -- 否 --> D[單一模型路徑] C --> E[嘗試模型 N] - E --> F[解析供應商/模型] + E --> F[解析提供者/模型] D --> F F --> G[選擇帳戶憑證] G --> H{憑證可用?} - H -- 否 --> I[回傳供應商不可用] + H -- 否 --> I[回傳提供者不可用] H -- 是 --> J[執行請求] J --> K{成功?} @@ -597,14 +597,14 @@ flowchart TD M -- 否 --> N[回傳錯誤] M -- 是 --> O[標記帳戶不可用並冷卻] - O --> P{供應商還有其他帳戶?} + O --> P{提供者還有其他帳戶?} P -- 是 --> G P -- 否 --> Q{在組合中有下一個模型?} Q -- 是 --> E Q -- 否 --> R[回傳全部不可用] ``` -備援決策由 `open-sse/services/accountFallback.ts` 根據狀態碼與錯誤訊息啟發式驅動。組合路由增加了一層額外防護:供應商範圍的 400 錯誤(如上游內容封鎖與角色驗證失敗)被視為模型本地錯誤,以便後續組合目標仍可執行。 +備援決策由 `open-sse/services/accountFallback.ts` 根據狀態碼與錯誤訊息啟發式驅動。組合路由增加了一層額外防護:提供者範圍的 400 錯誤(如上游內容封鎖與角色驗證失敗)被視為模型本地錯誤,以便後續組合目標仍可執行。 ## OAuth 入門與令牌刷新生命週期 @@ -613,10 +613,10 @@ sequenceDiagram autonumber participant UI as 儀表板 UI participant OAuth as /api/oauth/[provider]/[action] - participant ProvAuth as 供應商驗證伺服器 + participant ProvAuth as 提供者驗證伺服器 participant DB as localDb participant Test as /api/providers/[id]/test - participant Exec as 供應商執行器 + participant Exec as 提供者執行器 UI->>OAuth: GET authorize 或 device-code OAuth->>ProvAuth: 建立驗證/裝置流程 @@ -797,7 +797,7 @@ flowchart LR end subgraph External[外部服務] - Providers[AI 供應商] + Providers[AI 提供者] SyncCloud[雲端同步服務] end @@ -816,8 +816,8 @@ flowchart LR ### 路由與 API 模組 - `src/app/api/v1/*`、`src/app/api/v1beta/*`:相容性 API -- `src/app/api/v1/providers/[provider]/*`:專屬的每個供應商路由(聊天、嵌入、圖片) -- `src/app/api/providers*`:供應商 CRUD、驗證、測試 +- `src/app/api/v1/providers/[provider]/*`:專屬的每個提供者路由(聊天、嵌入、圖片) +- `src/app/api/providers*`:提供者 CRUD、驗證、測試 - `src/app/api/provider-nodes*`:自訂相容節點管理 - `src/app/api/provider-models`:自訂模型管理(CRUD) - `src/app/api/models/route.ts`:模型目錄 API(別名 + 自訂模型) @@ -851,7 +851,7 @@ flowchart LR - `src/sse/handlers/chat.ts`:請求解析、組合處理、帳戶選擇迴圈 - `open-sse/handlers/chatCore.ts`:轉換、執行器調度、重試/刷新處理、串流設定 -- `open-sse/executors/*`:供應商特定的網路與格式行為 +- `open-sse/executors/*`:提供者特定的網路與格式行為 ### 轉換註冊表與格式轉換器 @@ -869,109 +869,109 @@ flowchart LR - `src/lib/localDb.ts`:DB 模組的相容性重新匯出 - `src/lib/usageDb.ts`:基於 SQLite 表格的用量歷史/呼叫記錄外觀 -## 供應商執行器覆蓋範圍(策略模式) +## 提供者執行器覆蓋範圍(策略模式) -每個供應商都有一個專門的執行器,繼承自 `BaseExecutor`(位於 `open-sse/executors/base.ts`),提供 URL 建置、標頭建構、指數退避重試、憑證刷新鉤子與 `execute()` 協調方法。 +每個提供者都有一個專門的執行器,繼承自 `BaseExecutor`(位於 `open-sse/executors/base.ts`),提供 URL 建置、標頭建構、指數退避重試、憑證刷新鉤子與 `execute()` 協調方法。 -| 執行器 | 供應商 | 特殊處理 | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `DefaultExecutor` | OpenAI、Claude、Gemini、Qwen、OpenRouter、GLM、Kimi、MiniMax、DeepSeek、Groq、xAI、Mistral、Perplexity、Together、Fireworks、Cerebras、Cohere、NVIDIA 等 | 每個供應商的動態 URL/標頭設定 | -| `AntigravityExecutor` | Google Antigravity | 自訂專案/工作階段 ID、Retry-After 解析、429 混淆 | -| `AzureOpenAIExecutor` | Azure OpenAI | 基於部署的路由、api-version 查詢強制 | -| `BlackboxWebExecutor` | Blackbox AI(網頁模式) | 含 TLS 指紋模擬的網頁工作階段反向 | -| `ChatGPTWebExecutor` | ChatGPT 網頁 | TLS 客戶端 + 工作階段 Cookie 管理(`chatgptTlsClient.ts`) | -| `ClaudeIdentityExecutor` | Claude.ai(CCH 路徑) | 約束 + 工具重新對應管線、指紋塑造 | -| `CliProxyApiExecutor` | CLIProxyAPI 相容供應商 | 自訂驗證與協定處理 | -| `CloudflareAiExecutor` | Cloudflare Workers AI | 帳戶 ID 注入、基於 Neurons 的用量追蹤 | -| `CodexExecutor` | OpenAI Codex | 注入系統指令、強制推理努力 | -| `CommandCodeExecutor` | Command Code | OAuth + 每個工作階段的標頭輪換 | -| `CursorExecutor` | Cursor IDE | ConnectRPC 協定、Protobuf 編碼、透過 checksum 的請求簽署 | -| `DevinCliExecutor` | Devin CLI | 透過雲端代理模組的 Devin 任務生命週期橋接 | -| `GithubExecutor` | GitHub Copilot | Copilot 令牌刷新、模擬 VSCode 標頭 | -| `GitlabExecutor` | GitLab Duo | GitLab OAuth + 專案範圍路由 | -| `GlmExecutor` | Z.AI GLM(含 `glmt` 預設) | 思考預算感知、GLMT 預設常數 | -| `GrokWebExecutor` | xAI Grok 網頁 | 網頁工作階段反向、模式選擇(think/standard) | -| `KieExecutor` | KIE | 自訂令牌簽發,含輪換的工作階段錨點 | -| `KiroExecutor` | AWS CodeWhisperer/Kiro | AWS EventStream 二進位格式 → SSE 轉換 | -| `MuseSparkWebExecutor` | Muse Spark(網頁) | 含圖片訊息橋接的網頁工作階段反向 | -| `NlpCloudExecutor` | NLP Cloud | 供應商特定的請求主體形式 | -| `OpenCodeExecutor` | OpenCode | AI SDK 相容供應商設定 | -| `PerplexityWebExecutor` | Perplexity 網頁 | 用於聊天延續的網頁工作階段反向 | -| `PetalsExecutor` | Petals 分散式推理 | 去中心化群組路由 | -| `PollinationsExecutor` | Pollinations AI | 無需 API 金鑰、速率限制請求 | -| `PuterExecutor` | Puter | 基於瀏覽器的供應商整合 | -| `QoderExecutor` | Qoder AI | PAT 與 OAuth 支援、多模型免費方案 | -| `VertexExecutor` | Google Vertex AI | 服務帳戶驗證、基於區域的端點 | -| `WindsurfExecutor` | Windsurf(Codeium) | Codeium OAuth + 工作階段令牌刷新 | +| 執行器 | 提供者 | 特殊處理 | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| `DefaultExecutor` | OpenAI、Claude、Gemini、Qwen、OpenRouter、GLM、Kimi、MiniMax、DeepSeek、Groq、xAI、Mistral、Perplexity、Together、Fireworks、Cerebras、Cohere、NVIDIA 等 | 每個提供者的動態 URL/標頭設定 | +| `AntigravityExecutor` | Google Antigravity | 自訂專案/工作階段 ID、Retry-After 解析、429 混淆 | +| `AzureOpenAIExecutor` | Azure OpenAI | 基於部署的路由、api-version 查詢強制 | +| `BlackboxWebExecutor` | Blackbox AI(網頁模式) | 含 TLS 指紋模擬的網頁工作階段反向 | +| `ChatGPTWebExecutor` | ChatGPT 網頁 | TLS 客戶端 + 工作階段 Cookie 管理(`chatgptTlsClient.ts`) | +| `ClaudeIdentityExecutor` | Claude.ai(CCH 路徑) | 約束 + 工具重新對應管線、指紋塑造 | +| `CliProxyApiExecutor` | CLIProxyAPI 相容提供者 | 自訂驗證與協定處理 | +| `CloudflareAiExecutor` | Cloudflare Workers AI | 帳戶 ID 注入、基於 Neurons 的用量追蹤 | +| `CodexExecutor` | OpenAI Codex | 注入系統指令、強制推理努力 | +| `CommandCodeExecutor` | Command Code | OAuth + 每個工作階段的標頭輪換 | +| `CursorExecutor` | Cursor IDE | ConnectRPC 協定、Protobuf 編碼、透過 checksum 的請求簽署 | +| `DevinCliExecutor` | Devin CLI | 透過雲端代理模組的 Devin 任務生命週期橋接 | +| `GithubExecutor` | GitHub Copilot | Copilot 令牌刷新、模擬 VSCode 標頭 | +| `GitlabExecutor` | GitLab Duo | GitLab OAuth + 專案範圍路由 | +| `GlmExecutor` | Z.AI GLM(含 `glmt` 預設) | 思考預算感知、GLMT 預設常數 | +| `GrokWebExecutor` | xAI Grok 網頁 | 網頁工作階段反向、模式選擇(think/standard) | +| `KieExecutor` | KIE | 自訂令牌簽發,含輪換的工作階段錨點 | +| `KiroExecutor` | AWS CodeWhisperer/Kiro | AWS EventStream 二進位格式 → SSE 轉換 | +| `MuseSparkWebExecutor` | Muse Spark(網頁) | 含圖片訊息橋接的網頁工作階段反向 | +| `NlpCloudExecutor` | NLP Cloud | 提供者特定的請求主體形式 | +| `OpenCodeExecutor` | OpenCode | AI SDK 相容提供者設定 | +| `PerplexityWebExecutor` | Perplexity 網頁 | 用於聊天延續的網頁工作階段反向 | +| `PetalsExecutor` | Petals 分散式推理 | 去中心化群組路由 | +| `PollinationsExecutor` | Pollinations AI | 無需 API 金鑰、速率限制請求 | +| `PuterExecutor` | Puter | 基於瀏覽器的提供者整合 | +| `QoderExecutor` | Qoder AI | PAT 與 OAuth 支援、多模型免費方案 | +| `VertexExecutor` | Google Vertex AI | 服務帳戶驗證、基於區域的端點 | +| `WindsurfExecutor` | Windsurf(Codeium) | Codeium OAuth + 工作階段令牌刷新 | -所有其他供應商(包括自訂相容節點)使用 `DefaultExecutor`。 +所有其他提供者(包括自訂相容節點)使用 `DefaultExecutor`。 -## 供應商相容性矩陣 +## 提供者相容性矩陣 -> **注意:** 以下矩陣為 OmniRoute v3.8.0 中 237 個已註冊供應商的代表性樣本。 +> **注意:** 以下矩陣為 OmniRoute v3.8.0 中 237 個已註冊提供者的代表性樣本。 > 完整且持續更新的清單,請參閱 > [`docs/reference/PROVIDER_REFERENCE.md`](../reference/PROVIDER_REFERENCE.md)(自動產生)或 > `src/shared/constants/providers.ts`(載入時經 Zod 驗證)中的權威來源。 -| 供應商 | 格式 | 驗證 | 串流 | 非串流 | 令牌刷新 | 用量 API | -| ----------------- | ---------------- | --------------------- | ---------------- | ---------- | ------------- | ------------------ | -| Claude | claude | API 金鑰 / OAuth | ✅ | ✅ | ✅ | ⚠️ 僅管理員 | -| Gemini | gemini | API 金鑰 / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | -| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ 完整配額 API | -| OpenAI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Codex | openai-responses | OAuth | ✅ 強制 | ❌ | ✅ | ✅ 速率限制 | -| GitHub Copilot | openai | OAuth + Copilot 令牌 | ✅ | ✅ | ✅ | ✅ 配額快照 | -| Cursor | cursor | 自訂 checksum | ✅ | ✅ | ❌ | ❌ | -| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ 用量限制 | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每次請求 | -| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| OpenRouter | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| GLM/Kimi/MiniMax | claude | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| DeepSeek | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Groq | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| xAI(Grok) | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Mistral | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Perplexity | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Together AI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Fireworks AI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Cerebras | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Cohere | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| NVIDIA NIM | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Cloudflare AI | openai | API 令牌 + 帳戶 ID | ✅ | ✅ | ❌ | ❌ | -| Pollinations | openai | 無(無需金鑰) | ✅ | ✅ | ❌ | ❌ | -| Scaleway AI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| LongCat | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Ollama Cloud | openai | API 金鑰(選用) | ✅ | ✅ | ❌ | ❌ | -| HuggingFace | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Nebius | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| SiliconFlow | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Hyperbolic | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Vertex AI | gemini | 服務帳戶 | ✅ | ✅ | ✅ | ⚠️ Cloud Console | -| Puter | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ 每次請求 | -| Z.AI / GLM | openai | API 金鑰 / OAuth | ✅ | ✅ | ❌ | ❌ | -| GLMT(預設) | claude | API 金鑰 | ✅ | ✅ | ❌ | ⚠️ 每次請求 | -| Kimi Coding | openai | OAuth / API 金鑰 | ✅ | ✅ | ✅ | ❌ | -| KIE | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Windsurf | openai | OAuth(Codeium) | ✅ | ✅ | ✅ | ⚠️ 每次請求 | -| GitLab Duo | openai | OAuth(GitLab) | ✅ | ✅ | ✅ | ❌ | -| Devin CLI | openai | OAuth | ✅ | ✅ | ✅ | ✅ 任務 API | -| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ 速率限制 | -| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ 任務 API | -| AgentRouter | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| ChatGPT-Web | openai | 工作階段 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | -| Grok-Web | openai | 工作階段 Cookie | ✅ | ✅ | ❌ | ❌ | -| Perplexity-Web | openai | 工作階段 Cookie | ✅ | ✅ | ❌ | ❌ | -| BlackBox-Web | openai | 工作階段 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | -| Muse-Spark-Web | openai | 工作階段 Cookie | ✅ | ✅ | ❌ | ❌ | -| ModelScope | openai | API 金鑰 | ✅ | ✅ | ❌ | ⚠️ 配額政策 | -| BazaarLink | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Petals | openai | 無 | ✅ | ✅ | ❌ | ❌ | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每次請求 | -| OpenCode(Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| CLIProxyAPI | openai | 自訂 | ✅ | ✅ | ❌ | ❌ | +| 提供者 | 格式 | 驗證 | 串流 | 非串流 | 令牌刷新 | 用量 API | +| ------------------ | ---------------- | --------------------- | ---------------- | ------ | -------- | ---------------- | +| Claude | claude | API 金鑰 / OAuth | ✅ | ✅ | ✅ | ⚠️ 僅管理員 | +| Gemini | gemini | API 金鑰 / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | +| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ 完整配額 API | +| OpenAI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Codex | openai-responses | OAuth | ✅ 強制 | ❌ | ✅ | ✅ 速率限制 | +| GitHub Copilot | openai | OAuth + Copilot 令牌 | ✅ | ✅ | ✅ | ✅ 配額快照 | +| Cursor | cursor | 自訂 checksum | ✅ | ✅ | ❌ | ❌ | +| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ 用量限制 | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每次請求 | +| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| OpenRouter | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| GLM/Kimi/MiniMax | claude | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| DeepSeek | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Groq | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| xAI(Grok) | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Mistral | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Perplexity | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Together AI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Fireworks AI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Cerebras | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Cohere | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| NVIDIA NIM | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Cloudflare AI | openai | API 令牌 + 帳戶 ID | ✅ | ✅ | ❌ | ❌ | +| Pollinations | openai | 無(無需金鑰) | ✅ | ✅ | ❌ | ❌ | +| Scaleway AI | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| LongCat | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Ollama Cloud | openai | API 金鑰(選用) | ✅ | ✅ | ❌ | ❌ | +| HuggingFace | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Nebius | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| SiliconFlow | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Hyperbolic | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Vertex AI | gemini | 服務帳戶 | ✅ | ✅ | ✅ | ⚠️ Cloud Console | +| Puter | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ 每次請求 | +| Z.AI / GLM | openai | API 金鑰 / OAuth | ✅ | ✅ | ❌ | ❌ | +| GLMT(預設) | claude | API 金鑰 | ✅ | ✅ | ❌ | ⚠️ 每次請求 | +| Kimi Coding | openai | OAuth / API 金鑰 | ✅ | ✅ | ✅ | ❌ | +| KIE | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Windsurf | openai | OAuth(Codeium) | ✅ | ✅ | ✅ | ⚠️ 每次請求 | +| GitLab Duo | openai | OAuth(GitLab) | ✅ | ✅ | ✅ | ❌ | +| Devin CLI | openai | OAuth | ✅ | ✅ | ✅ | ✅ 任務 API | +| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ 速率限制 | +| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ 任務 API | +| AgentRouter | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| ChatGPT-Web | openai | 工作階段 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | +| Grok-Web | openai | 工作階段 Cookie | ✅ | ✅ | ❌ | ❌ | +| Perplexity-Web | openai | 工作階段 Cookie | ✅ | ✅ | ❌ | ❌ | +| BlackBox-Web | openai | 工作階段 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | +| Muse-Spark-Web | openai | 工作階段 Cookie | ✅ | ✅ | ❌ | ❌ | +| ModelScope | openai | API 金鑰 | ✅ | ✅ | ❌ | ⚠️ 配額政策 | +| BazaarLink | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | +| Petals | openai | 無 | ✅ | ✅ | ❌ | ❌ | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每次請求 | +| OpenCode(Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| CLIProxyAPI | openai | 自訂 | ✅ | ✅ | ❌ | ❌ | ## 格式轉換覆蓋範圍 @@ -996,7 +996,7 @@ flowchart LR 來源格式 → OpenAI(樞紐)→ 目標格式 ``` -轉換根據來源負載形狀與供應商目標格式動態選擇。 +轉換根據來源負載形狀與提供者目標格式動態選擇。 轉換管線中的其他處理層: @@ -1007,29 +1007,29 @@ flowchart LR ## 支援的 API 端點 -| 端點 | 格式 | 處理器 | -| -------------------------------------------------- | ------------------ | ------------------------------------------------------------------- | -| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` | -| `POST /v1/messages` | Claude Messages | 相同處理器(自動偵測) | -| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` | -| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` | -| `GET /v1/embeddings` | 模型列表 | API 路由 | -| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` | -| `GET /v1/images/generations` | 模型列表 | API 路由 | -| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | 專屬每個供應商,含模型驗證 | -| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | 專屬每個供應商,含模型驗證 | -| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | 專屬每個供應商,含模型驗證 | -| `POST /v1/messages/count_tokens` | Claude Token Count | API 路由 | -| `GET /v1/models` | OpenAI Models list | API 路由(聊天 + 嵌入 + 圖片 + 自訂模型) | -| `GET /api/models/catalog` | 目錄 | 所有模型按供應商 + 類型分組 | -| `POST /v1beta/models/*:streamGenerateContent` | Gemini 原生 | API 路由 | -| `GET/PUT/DELETE /api/settings/proxy` | 代理設定 | 網路代理設定 | -| `POST /api/settings/proxy/test` | 代理連線 | 代理健康/連線測試端點 | -| `GET/POST/DELETE /api/provider-models` | Provider Models | 支援自訂與受管可用模型的供應商模型中繼資料 | +| 端點 | 格式 | 處理器 | +| -------------------------------------------------- | ------------------ | ------------------------------------------ | +| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` | +| `POST /v1/messages` | Claude Messages | 相同處理器(自動偵測) | +| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` | +| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` | +| `GET /v1/embeddings` | 模型列表 | API 路由 | +| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` | +| `GET /v1/images/generations` | 模型列表 | API 路由 | +| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | 專屬每個提供者,含模型驗證 | +| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | 專屬每個提供者,含模型驗證 | +| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | 專屬每個提供者,含模型驗證 | +| `POST /v1/messages/count_tokens` | Claude Token Count | API 路由 | +| `GET /v1/models` | OpenAI Models list | API 路由(聊天 + 嵌入 + 圖片 + 自訂模型) | +| `GET /api/models/catalog` | 目錄 | 所有模型按提供者 + 類型分組 | +| `POST /v1beta/models/*:streamGenerateContent` | Gemini 原生 | API 路由 | +| `GET/PUT/DELETE /api/settings/proxy` | 代理設定 | 網路代理設定 | +| `POST /api/settings/proxy/test` | 代理連線 | 代理健康/連線測試端點 | +| `GET/POST/DELETE /api/provider-models` | Provider Models | 支援自訂與受管可用模型的提供者模型中繼資料 | ## 繞過處理器 -繞過處理器(`open-sse/utils/bypassHandler.ts`)攔截來自 Claude CLI 的已知「一次性」請求 — 暖機 ping、標題提取與令牌計數 — 並在不消耗上游供應商令牌的情況下回傳**偽造回應**。僅在 `User-Agent` 包含 `claude-cli` 時觸發。 +繞過處理器(`open-sse/utils/bypassHandler.ts`)攔截來自 Claude CLI 的已知「一次性」請求 — 暖機 ping、標題提取與令牌計數 — 並在不消耗上游提供者令牌的情況下回傳**偽造回應**。僅在 `User-Agent` 包含 `claude-cli` 時觸發。 ## 請求記錄與工件 @@ -1042,22 +1042,22 @@ flowchart LR ## 故障模式與韌性 -## 1) 帳戶/供應商可用性 +## 1) 帳戶/提供者可用性 - 可重試的上游故障時進行連線冷卻 - 在請求失敗前進行帳戶備援 -- 當前模型/供應商路徑耗盡時的組合模型備援 +- 當前模型/提供者路徑耗盡時的組合模型備援 ## 2) 令牌到期 -- 對可刷新的供應商進行預檢查與重試刷新 +- 對可刷新的提供者進行預檢查與重試刷新 - 核心路徑中刷新嘗試後的 401/403 重試 ## 3) 串流安全 - 具斷線感知的串流控制器 - 具串流結束 flush 與 `[DONE]` 處理的轉換串流 -- 供應商用量中繼資料遺失時的用量估算備援 +- 提供者用量中繼資料遺失時的用量估算備援 ## 4) 雲端同步降級 @@ -1071,8 +1071,8 @@ flowchart LR ## 6) SSRF / 外送 URL 防護 -- `src/shared/network/outboundUrlGuard.ts` 在請求到達供應商執行器前封鎖所有私人/迴路/鏈結本地目標 URL -- 供應商模型探索與驗證路由使用 `src/shared/network/safeOutboundFetch.ts`,該函數在每個外送請求前應用防護 +- `src/shared/network/outboundUrlGuard.ts` 在請求到達提供者執行器前封鎖所有私人/迴路/鏈結本地目標 URL +- 提供者模型探索與驗證路由使用 `src/shared/network/safeOutboundFetch.ts`,該函數在每個外送請求前應用防護 - 防護錯誤以 `URL_GUARD_BLOCKED` 呈現,附 HTTP 422,並透過 `providerAudit.ts` 記錄到合規稽核軌跡 ## 可觀測性與操作訊號 @@ -1091,7 +1091,7 @@ flowchart LR - 從客戶端接收到的原始請求 - 實際發送給上游的轉換後請求 -- 重建為 JSON 的供應商回應;串流回應壓縮為最終摘要加上串流中繼資料 +- 重建為 JSON 的提供者回應;串流回應壓縮為最終摘要加上串流中繼資料 - OmniRoute 回傳的最終客戶端回應;串流回應以相同壓縮摘要形式儲存 ## 安全敏感邊界 @@ -1099,7 +1099,7 @@ flowchart LR - JWT 秘密(`JWT_SECRET`)保護儀表板工作階段 Cookie 驗證/簽署 - 初始密碼(`INITIAL_PASSWORD`)應明確設定,用於首次執行佈建 - API 金鑰 HMAC 秘密(`API_KEY_SECRET`)保護產生的本地 API 金鑰格式 -- 供應商秘密(API 金鑰/令牌)持久化在本地 DB 中,應在檔案系統層級保護 +- 提供者秘密(API 金鑰/令牌)持久化在本地 DB 中,應在檔案系統層級保護 - 雲端同步端點依賴 API 金鑰驗證 + 機器 ID 語意 ## 環境與執行時期矩陣 @@ -1124,9 +1124,9 @@ flowchart LR 3. 啟用時請求記錄器寫入完整標頭/主體;請將記錄目錄視為敏感資訊。 4. 雲端行為取決於正確的 `NEXT_PUBLIC_BASE_URL` 與雲端端點可達性。 5. `open-sse/` 目錄以 `@omniroute/open-sse` **npm workspace 套件**形式發布。原始碼透過 `@omniroute/open-sse/...` 匯入(由 Next.js `transpilePackages` 解析)。本文件中的檔案路徑為求一致仍使用目錄名稱 `open-sse/`。 -6. 儀表板中的圖表使用 **Recharts**(基於 SVG)實現可存取、互動式的分析視覺化(模型用量長條圖、含成功率的供應商 breakdown 表格)。 +6. 儀表板中的圖表使用 **Recharts**(基於 SVG)實現可存取、互動式的分析視覺化(模型用量長條圖、含成功率的提供者 breakdown 表格)。 7. E2E 測試使用 **Playwright**(`tests/e2e/`),透過 `npm run test:e2e` 執行。單元測試使用 **Node.js test runner**(`tests/unit/`),透過 `npm run test:unit` 執行。`src/` 下的原始碼為 **TypeScript**(`.ts`/`.tsx`);`open-sse/` workspace 保持 JavaScript(`.js`)。 -8. 設定頁面分為 7 個分頁:一般、外觀、AI、安全性、路由、韌性、進階。韌性頁面僅設定請求佇列、連線冷卻、供應商斷路器與等待冷卻行為;斷路器執行時期狀態顯示在健康狀態頁面上。 +8. 設定頁面分為 7 個分頁:一般、外觀、AI、安全性、路由、韌性、進階。韌性頁面僅設定請求佇列、連線冷卻、提供者斷路器與等待冷卻行為;斷路器執行時期狀態顯示在健康狀態頁面上。 9. **上下文轉接**策略(`context-relay`)分跨兩層:`combo.ts` 決定是否應產生交接,`chat.ts` 在帳戶解析後注入交接。交接資料存在 `context_handoffs` SQLite 表格中。這種拆分是有意為之,因為只有 `chat.ts` 知道實際帳戶是否已變更。 10. **代理強制**現在是全方位的:`tokenHealthCheck.ts` 根據連線解析代理,`/api/providers/validate` 使用 `runWithProxyContext`,而 `proxyFetch.ts` 使用 `undici.fetch()` 以在 Node 22 上維持調度器相容性。 11. **Node.js 執行時期政策偵測**:`/api/settings/require-login` 回傳 `nodeVersion` 與 `nodeCompatible` 欄位。當執行時期超出支援的安全 Node.js 版本範圍時,登入頁面會顯示警告橫幅。 diff --git a/docs/i18n/zh-TW/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/i18n/zh-TW/docs/architecture/CODEBASE_DOCUMENTATION.md index 1c7cb8c8f0..3c2ed68284 100644 --- a/docs/i18n/zh-TW/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/i18n/zh-TW/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -18,18 +18,18 @@ lastUpdated: 2026-06-28 ## 1. 技術棧 -| 面向 | 選擇 | -| ------------ | -------------------------------------------------------------------------------------------------------------------------- | -| Web 框架 | **Next.js 16**(App Router,standalone 輸出,無全域中介軟體) | -| 語言 | **TypeScript 6.0+** — 目標 `ES2022`,`module: esnext`,`moduleResolution: bundler`,`strict: false` | -| 執行時期 | **Node.js** `>=22.22.2 <23` 或 `>=24.0.0 <27`(透過 `engines` + `SUPPORTED_NODE_RANGE` 強制) | -| 資料庫 | **SQLite** 透過 `better-sqlite3`(單例,WAL 日誌模式) | -| 桌面應用 | **Electron 41** + `electron-builder` 26.10(獨立 workspace 位於 `electron/`) | -| 測試 | **Node 原生測試執行器**(單元/整合測試),**Vitest**(MCP、autoCombo、快取),**Playwright**(e2e + protocols-e2e) | -| 建置 | Next.js standalone 透過 `scripts/build/build-next-isolated.mjs` | -| 程式碼風格 | ESLint flat config + Prettier(`lint-staged` 透過 Husky pre-commit) | -| 模組系統 | 全面 ESM(`"type": "module"`) | -| Workspaces | npm workspace — `open-sse` 是唯一的子 workspace | +| 面向 | 選擇 | +| ---------- | ------------------------------------------------------------------------------------------------------------------- | +| Web 框架 | **Next.js 16**(App Router,standalone 輸出,無全域中介軟體) | +| 語言 | **TypeScript 6.0+** — 目標 `ES2022`,`module: esnext`,`moduleResolution: bundler`,`strict: false` | +| 執行時期 | **Node.js** `>=22.22.2 <23` 或 `>=24.0.0 <27`(透過 `engines` + `SUPPORTED_NODE_RANGE` 強制) | +| 資料庫 | **SQLite** 透過 `better-sqlite3`(單例,WAL 日誌模式) | +| 桌面應用 | **Electron 41** + `electron-builder` 26.10(獨立 workspace 位於 `electron/`) | +| 測試 | **Node 原生測試執行器**(單元/整合測試),**Vitest**(MCP、autoCombo、快取),**Playwright**(e2e + protocols-e2e) | +| 建置 | Next.js standalone 透過 `scripts/build/build-next-isolated.mjs` | +| 程式碼風格 | ESLint flat config + Prettier(`lint-staged` 透過 Husky pre-commit) | +| 模組系統 | 全面 ESM(`"type": "module"`) | +| Workspaces | npm workspace — `open-sse` 是唯一的子 workspace | 路徑別名(`tsconfig.json`): @@ -95,20 +95,20 @@ App Router 同時提供儀表板 UI 和公開/管理 HTTP API。 `src/app/` 下的頂層區段: -| 路徑 | 用途 | -| --------------------------------------------------------------------------------- | ------------------------------------------ | -| `api/` | 所有 HTTP API 路由(詳見下方細分) | -| `a2a/` | A2A JSON-RPC 2.0 端點(`POST /a2a`) | -| `.well-known/agent.json/` | A2A Agent Card 探索文件 | -| `(dashboard)/` | 儀表板 UI(路由群組,無 URL 前綴) | -| `auth/`、`login/`、`forgot-password/`、`callback/` | 認證流程 | -| `landing/` | 行銷/登陸頁面 | -| `docs/` | 嵌入式 API 文件檢視器 | -| `status/`、`maintenance/`、`offline/` | 運作狀態頁面 | -| `privacy/`、`terms/` | 法律頁面 | -| `400/`、`401/`、`403/`、`408/`、`429/`、`500/`、`502/`、`503/` | 靜態錯誤頁面 | -| `error.tsx`、`global-error.tsx`、`not-found.tsx`、`forbidden/`、`loading.tsx` | 框架錯誤/載入邊界 | -| `layout.tsx`、`page.tsx`、`globals.css`、`manifest.ts` | 根殼層 | +| 路徑 | 用途 | +| ----------------------------------------------------------------------------- | ------------------------------------ | +| `api/` | 所有 HTTP API 路由(詳見下方細分) | +| `a2a/` | A2A JSON-RPC 2.0 端點(`POST /a2a`) | +| `.well-known/agent.json/` | A2A Agent Card 探索文件 | +| `(dashboard)/` | 儀表板 UI(路由群組,無 URL 前綴) | +| `auth/`、`login/`、`forgot-password/`、`callback/` | 認證流程 | +| `landing/` | 行銷/登陸頁面 | +| `docs/` | 嵌入式 API 文件檢視器 | +| `status/`、`maintenance/`、`offline/` | 運作狀態頁面 | +| `privacy/`、`terms/` | 法律頁面 | +| `400/`、`401/`、`403/`、`408/`、`429/`、`500/`、`502/`、`503/` | 靜態錯誤頁面 | +| `error.tsx`、`global-error.tsx`、`not-found.tsx`、`forbidden/`、`loading.tsx` | 框架錯誤/載入邊界 | +| `layout.tsx`、`page.tsx`、`globals.css`、`manifest.ts` | 根殼層 | #### 3.1.1 `src/app/(dashboard)/dashboard/` — UI 頁面 @@ -241,7 +241,7 @@ v1/ ├── models/ 模型列表(`route.ts`、`catalog.ts`) ├── moderations/ 內容審查 ├── music/ 音樂生成 -├── providers/[provider]/ 各供應商操作 +├── providers/[provider]/ 各提供者操作 ├── quotas/{check} 配額查詢 ├── registered-keys/ 已註冊金鑰管理 ├── rerank/ 重新排序 @@ -267,46 +267,46 @@ v1/ 務必透過這些模組匯入資料、同步、OAuth、技能、記憶體等。以下 表格列出實際目錄及值得注意的頂層檔案。 -| 模組 | 用途 | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `a2a/` | A2A 協定伺服器:`taskManager.ts`、`streaming.ts`、`taskExecution.ts`、`routingLogger.ts`、`skills/`(6 個技能:成本分析、健康報告、供應商探索、配額管理、智慧路由、列出能力) | -| `acp/` | 代理控制協定:`index.ts`、`manager.ts`、`registry.ts` | -| `api/` | 內部 API 輔助程式:`requireManagementAuth.ts`、`requireCliToolsAuth.ts`、`errorResponse.ts` | -| `auth/` | `managementPassword.ts`(密碼重設/雜湊) | -| `batches/` | OpenAI Batches API 服務(`service.ts`) | -| `catalog/` | OpenRouter 目錄同步(`openrouterCatalog.ts`) | -| `cloudAgent/` | 雲端代理註冊表:`api.ts`、`baseAgent.ts`、`db.ts`、`index.ts`、`registry.ts`、`types.ts`、`agents/{codex, devin, jules}.ts` | -| `combos/` | Combo 解析輔助程式 | -| `compliance/` | 稽核 + 供應商稽核:`index.ts`、`providerAudit.ts` | -| `config/` | 執行時期設定黏合層 | -| `db/` | SQLite 領域模組(參見 §3.2.1) | -| `display/` | API 回應使用的 UI/顯示輔助程式 | -| `embeddings/` | 嵌入服務註冊表 | -| `env/` | 環境變數載入 + 內省 | -| `evals/` | 評估執行時期 | -| `guardrails/` | `piiMasker.ts`、`promptInjection.ts`、`visionBridge.ts`、`visionBridgeHelpers.ts`、`registry.ts`、`base.ts` | -| `jobs/` | 背景工作(`autoUpdate.ts`……) | -| `memory/` | 持久化記憶體:`store.ts`、`cache.ts`、`retrieval.ts`、`summarization.ts`、`extraction.ts`、`injection.ts`、`qdrant.ts`、`settings.ts`、`verify.ts`、`schemas.ts`、`types.ts` | -| `monitoring/` | `observability.ts` | -| `oauth/` | OAuth 供應商(13 個):`antigravity`、`claude`、`cline`、`codex`、`cursor`、`gemini`、`github`、`gitlab-duo`、`kilocode`、`kimi-coding`、`kiro`、`qoder`、`windsurf` 加上 `services/`、`utils/{pkce, server, banner, codexAuthFile, ui}`、`constants/oauth.ts` | -| `plugins/` | 外掛載入器(`index.ts`) | -| `promptCache/` | `prefixAnalyzer.ts`、`index.ts` | -| `providerModels/` | 受管模型生命週期:`modelDiscovery.ts`、`managedModelImport.ts`、`managedAvailableModels.ts`、`cursorAgent.ts` | -| `providers/` | 供應商輔助程式:`catalog.ts`、`validation.ts`、`imageValidation.ts`、`claudeExtraUsage.ts`、`codexConnectionDefaults.ts`、`codexFastTier.ts`、`webCookieAuth.ts`、`managedAvailableModels.ts`、`requestDefaults.ts` | -| `resilience/` | `settings.ts` — 斷路器、冷卻、鎖定設定 | -| `runtime/` | 執行時期功能檢測 | -| `search/` | `executeWebSearch.ts` | -| `services/` | 嵌入式服務框架:`ServiceSupervisor.ts`(通用子程序監控器,具備操作鎖、環形緩衝區、健康檢查)、`bootstrap.ts`(程序層級註冊與自動啟動)、`registry.ts`(工具 → 監控器對應)、`apiKey.ts`(AES-256-GCM 金鑰儲存)、`modelSync.ts`(定期模型同步)、`ringBuffer.ts`(5 MB 循環日誌緩衝區)、`healthCheck.ts`(HTTP 健康探測)、`types.ts`、`embedWsProxy.ts`(WebSocket 代理)、`installers/{ninerouter,cliproxy}.ts`。參見 `docs/frameworks/EMBEDDED-SERVICES.md` | +| 模組 | 用途 | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `a2a/` | A2A 協定伺服器:`taskManager.ts`、`streaming.ts`、`taskExecution.ts`、`routingLogger.ts`、`skills/`(6 個技能:成本分析、健康報告、提供者探索、配額管理、智慧路由、列出能力) | +| `acp/` | 代理控制協定:`index.ts`、`manager.ts`、`registry.ts` | +| `api/` | 內部 API 輔助程式:`requireManagementAuth.ts`、`requireCliToolsAuth.ts`、`errorResponse.ts` | +| `auth/` | `managementPassword.ts`(密碼重設/雜湊) | +| `batches/` | OpenAI Batches API 服務(`service.ts`) | +| `catalog/` | OpenRouter 目錄同步(`openrouterCatalog.ts`) | +| `cloudAgent/` | 雲端代理註冊表:`api.ts`、`baseAgent.ts`、`db.ts`、`index.ts`、`registry.ts`、`types.ts`、`agents/{codex, devin, jules}.ts` | +| `combos/` | Combo 解析輔助程式 | +| `compliance/` | 稽核 + 提供者稽核:`index.ts`、`providerAudit.ts` | +| `config/` | 執行時期設定黏合層 | +| `db/` | SQLite 領域模組(參見 §3.2.1) | +| `display/` | API 回應使用的 UI/顯示輔助程式 | +| `embeddings/` | 嵌入服務註冊表 | +| `env/` | 環境變數載入 + 內省 | +| `evals/` | 評估執行時期 | +| `guardrails/` | `piiMasker.ts`、`promptInjection.ts`、`visionBridge.ts`、`visionBridgeHelpers.ts`、`registry.ts`、`base.ts` | +| `jobs/` | 背景工作(`autoUpdate.ts`……) | +| `memory/` | 持久化記憶體:`store.ts`、`cache.ts`、`retrieval.ts`、`summarization.ts`、`extraction.ts`、`injection.ts`、`qdrant.ts`、`settings.ts`、`verify.ts`、`schemas.ts`、`types.ts` | +| `monitoring/` | `observability.ts` | +| `oauth/` | OAuth 提供者(13 個):`antigravity`、`claude`、`cline`、`codex`、`cursor`、`gemini`、`github`、`gitlab-duo`、`kilocode`、`kimi-coding`、`kiro`、`qoder`、`windsurf` 加上 `services/`、`utils/{pkce, server, banner, codexAuthFile, ui}`、`constants/oauth.ts` | +| `plugins/` | 外掛載入器(`index.ts`) | +| `promptCache/` | `prefixAnalyzer.ts`、`index.ts` | +| `providerModels/` | 受管模型生命週期:`modelDiscovery.ts`、`managedModelImport.ts`、`managedAvailableModels.ts`、`cursorAgent.ts` | +| `providers/` | 提供者輔助程式:`catalog.ts`、`validation.ts`、`imageValidation.ts`、`claudeExtraUsage.ts`、`codexConnectionDefaults.ts`、`codexFastTier.ts`、`webCookieAuth.ts`、`managedAvailableModels.ts`、`requestDefaults.ts` | +| `resilience/` | `settings.ts` — 斷路器、冷卻、鎖定設定 | +| `runtime/` | 執行時期功能檢測 | +| `search/` | `executeWebSearch.ts` | +| `services/` | 嵌入式服務框架:`ServiceSupervisor.ts`(通用子程序監控器,具備操作鎖、環形緩衝區、健康檢查)、`bootstrap.ts`(程序層級註冊與自動啟動)、`registry.ts`(工具 → 監控器對應)、`apiKey.ts`(AES-256-GCM 金鑰儲存)、`modelSync.ts`(定期模型同步)、`ringBuffer.ts`(5 MB 循環日誌緩衝區)、`healthCheck.ts`(HTTP 健康探測)、`types.ts`、`embedWsProxy.ts`(WebSocket 代理)、`installers/{ninerouter,cliproxy}.ts`。參見 `docs/frameworks/EMBEDDED-SERVICES.md` | | `agentSkills/` | 代理技能目錄 + 產生器:`catalog.ts`(getCatalog/getSkillById/filterCatalog/computeCoverage)、`generator.ts`(generateAgentSkills → 寫入 `skills/{id}/SKILL.md`)、`openapiParser.ts`(從 OpenAPI 規格提取 REST 端點)、`cliRegistryParser.ts`(從 bin/cli-registry 提取 CLI 子命令)、`schemas.ts`(Zod:AgentSkillSchema、SkillCoverageSchema、ListQuerySchema、GenerateBodySchema)、`types.ts`(AgentSkill、SkillCoverage、SkillMarkdown、GeneratorReport)。由 REST 路由(`/api/agent-skills/*`)、MCP 工具(`omniroute_agent_skills_*`)和 A2A 技能 `list-capabilities` 使用。參見 [AGENT-SKILLS.md](../frameworks/AGENT-SKILLS.md)。 | -| `skills/` | 技能框架:`registry.ts`、`executor.ts`、`interception.ts`、`injection.ts`、`sandbox.ts`、`custom.ts`、`hybrid.ts`、`builtins.ts`、`a2a.ts`、`providerSettings.ts`、`schemas.ts`、`skillssh.ts`、`types.ts`,加上 `builtin/browser.ts` | -| `spend/` | `batchWriter.ts`(寫入緩衝區) | -| `sync/` | `bundle.ts`、`tokens.ts`(雲端同步) | -| `system/` | 系統層級輔助程式 | -| `translator/` | 頂層翻譯器黏合層(委派給 `open-sse/translator/`) | -| `usage/` | 用量會計:`costCalculator.ts`、`tokenAccounting.ts`、`usageHistory.ts`、`aggregateHistory.ts`、`usageStats.ts`、`callLogs.ts`、`callLogArtifacts.ts`、`fetcher.ts`、`providerLimits.ts`、`migrations.ts` | -| `versionManager/` | 自動更新 + 版本清單 | -| `ws/` | WebSocket 橋接 | -| `zed-oauth/` | Zed 編輯器 OAuth 流程 | +| `skills/` | 技能框架:`registry.ts`、`executor.ts`、`interception.ts`、`injection.ts`、`sandbox.ts`、`custom.ts`、`hybrid.ts`、`builtins.ts`、`a2a.ts`、`providerSettings.ts`、`schemas.ts`、`skillssh.ts`、`types.ts`,加上 `builtin/browser.ts` | +| `spend/` | `batchWriter.ts`(寫入緩衝區) | +| `sync/` | `bundle.ts`、`tokens.ts`(雲端同步) | +| `system/` | 系統層級輔助程式 | +| `translator/` | 頂層翻譯器黏合層(委派給 `open-sse/translator/`) | +| `usage/` | 用量會計:`costCalculator.ts`、`tokenAccounting.ts`、`usageHistory.ts`、`aggregateHistory.ts`、`usageStats.ts`、`callLogs.ts`、`callLogArtifacts.ts`、`fetcher.ts`、`providerLimits.ts`、`migrations.ts` | +| `versionManager/` | 自動更新 + 版本清單 | +| `ws/` | WebSocket 橋接 | +| `zed-oauth/` | Zed 編輯器 OAuth 流程 | `src/lib/` 中的頂層檔案: @@ -371,23 +371,23 @@ v1/ 純商業邏輯,無 I/O。由路由和處理器匯入。 -| 檔案 | 用途 | -| ---------------------------------------------- | -------------------------------------------------- | -| `policyEngine.ts` | 頂層政策解析器 | -| `fallbackPolicy.ts` | 備援決策樹 | -| `costRules.ts` | 成本計算規則 | -| `lockoutPolicy.ts` | 模型鎖定決策 | -| `tagRouter.ts` | 基於標籤的路由 | -| `comboResolver.ts` | 從請求解析 combo → 目標清單 | -| `connectionModelRules.ts` | 各連線的模型過濾器 | -| `modelAvailability.ts` | 模型可用性檢查 | -| `degradation.ts` | 降級模式轉換 | -| `providerExpiration.ts` | 過期帳戶/金鑰偵測 | -| `quotaCache.ts` | 快取配額決策 | -| `responses.ts`、`omnirouteResponseMeta.ts` | 回應形狀輔助程式 | -| `configAudit.ts` | 設定變更稽核 | -| `assessment/` | 模型評估(依 RFC,部分實作) | -| `types.ts` | 共用領域型別 | +| 檔案 | 用途 | +| ------------------------------------------ | ---------------------------- | +| `policyEngine.ts` | 頂層政策解析器 | +| `fallbackPolicy.ts` | 備援決策樹 | +| `costRules.ts` | 成本計算規則 | +| `lockoutPolicy.ts` | 模型鎖定決策 | +| `tagRouter.ts` | 基於標籤的路由 | +| `comboResolver.ts` | 從請求解析 combo → 目標清單 | +| `connectionModelRules.ts` | 各連線的模型過濾器 | +| `modelAvailability.ts` | 模型可用性檢查 | +| `degradation.ts` | 降級模式轉換 | +| `providerExpiration.ts` | 過期帳戶/金鑰偵測 | +| `quotaCache.ts` | 快取配額決策 | +| `responses.ts`、`omnirouteResponseMeta.ts` | 回應形狀輔助程式 | +| `configAudit.ts` | 設定變更稽核 | +| `assessment/` | 模型評估(依 RFC,部分實作) | +| `types.ts` | 共用領域型別 | ### 3.4 `src/server/` — 僅伺服器端 @@ -411,7 +411,7 @@ server/ 分為聚焦的子目錄: -- `constants/` — `providers.ts`(Zod 驗證的供應商目錄)、`models.ts`、 +- `constants/` — `providers.ts`(Zod 驗證的提供者目錄)、`models.ts`、 `modelSpecs.ts`、`modelCompat.ts`、`pricing.ts`、`cliTools.ts`、 `cliCompatProviders.ts`、`routingStrategies.ts`、`comboConfigMode.ts`、 `headers.ts`、`upstreamHeaders.ts`(封鎖清單)、`mcpScopes.ts`、 @@ -444,9 +444,9 @@ open-sse/ ├── package.json Workspace 清單 ├── tsconfig.json ├── types.d.ts -├── config/ 供應商註冊表、標頭設定檔、身分識別…… +├── config/ 提供者註冊表、標頭設定檔、身分識別…… ├── handlers/ 請求處理器(聊天、嵌入、音訊、圖片……) -├── executors/ 84 個供應商專屬的 HTTP 執行器 +├── executors/ 84 個提供者專屬的 HTTP 執行器 ├── translator/ 格式轉換(OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro) ├── transformer/ Responses API ↔ Chat Completions 串流轉換器 ├── services/ 80+ 個服務模組(combo、備援、配額、身分識別……) @@ -456,27 +456,27 @@ open-sse/ ### 4.1 `open-sse/handlers/` -| 處理器 | 用途 | -| -------------------------- | ------------------------------------------------------------------------- | -| `chatCore.ts` | 主要聊天管線(快取、速率限制、combo 路由、執行器分派) | -| `responsesHandler.ts` | OpenAI Responses API 進入點 | -| `embeddings.ts` | 嵌入向量 | -| `imageGeneration.ts` | 圖片生成 | -| `audioSpeech.ts` | 文字轉語音 | -| `audioTranscription.ts` | 語音轉文字 | -| `videoGeneration.ts` | 影片生成 | -| `musicGeneration.ts` | 音樂生成 | -| `rerank.ts` | 重新排序 | -| `moderations.ts` | 內容審查 | -| `search.ts` | 網路搜尋 | -| `sseParser.ts` | SSE 事件解析器 | -| `usageExtractor.ts` | 從上游串流提取 Token 計數 | -| `responseSanitizer.ts` | 移除供應商特定的雜訊 | -| `responseTranslator.ts` | 供應商回應與翻譯層之間的黏合層 | +| 處理器 | 用途 | +| ----------------------- | ------------------------------------------------------ | +| `chatCore.ts` | 主要聊天管線(快取、速率限制、combo 路由、執行器分派) | +| `responsesHandler.ts` | OpenAI Responses API 進入點 | +| `embeddings.ts` | 嵌入向量 | +| `imageGeneration.ts` | 圖片生成 | +| `audioSpeech.ts` | 文字轉語音 | +| `audioTranscription.ts` | 語音轉文字 | +| `videoGeneration.ts` | 影片生成 | +| `musicGeneration.ts` | 音樂生成 | +| `rerank.ts` | 重新排序 | +| `moderations.ts` | 內容審查 | +| `search.ts` | 網路搜尋 | +| `sseParser.ts` | SSE 事件解析器 | +| `usageExtractor.ts` | 從上游串流提取 Token 計數 | +| `responseSanitizer.ts` | 移除提供者特定的雜訊 | +| `responseTranslator.ts` | 提供者回應與翻譯層之間的黏合層 | ### 4.2 `open-sse/executors/` -84 個供應商執行器,每個都繼承 `BaseExecutor`(`base.ts`): +84 個提供者執行器,每個都繼承 `BaseExecutor`(`base.ts`): `antigravity`、`azure-openai`、`blackbox-web`、`chatgpt-web`、`cliproxyapi`、 `cloudflare-ai`、`codex`、`commandCode`、`cursor`、`default`、`devin-cli`、 @@ -484,8 +484,8 @@ open-sse/ `pollinations`、`puter`、`qoder`、`vertex`、`windsurf`,加上 `claudeIdentity.ts` (共用身分識別輔助程式)和 `index.ts`(註冊表)。 -> 注意:未列在此處的供應商由 `default.ts` 使用通用的 -> 與 OpenAI 相容的執行器處理。完整供應商目錄(268 個條目)位於 +> 注意:未列在此處的提供者由 `default.ts` 使用通用的 +> 與 OpenAI 相容的執行器處理。完整提供者目錄(268 個條目)位於 > `src/shared/constants/providers.ts`。 ### 4.3 `open-sse/translator/` @@ -516,21 +516,21 @@ open-sse/ 重點項目(完整列表位於 `open-sse/services/` 下): -| 面向 | 檔案 | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Combo 路由 | `combo.ts`(17 種策略)、`comboConfig.ts`、`comboMetrics.ts`、`comboManifestMetrics.ts`、`comboAgentMiddleware.ts` | -| Auto Combo 引擎 | `autoCombo/` — `engine.ts`、`scoring.ts`、`taskFitness.ts`、`virtualFactory.ts`、`modePacks.ts`、`autoPrefix.ts`、`persistence.ts`、`providerDiversity.ts`、`providerRegistryAccessor.ts`、`routerStrategy.ts`、`selfHealing.ts`、`index.ts` | -| 韌性 | `accountFallback.ts`(冷卻 + 鎖定)、`errorClassifier.ts`、`emergencyFallback.ts`、`rateLimitManager.ts`、`rateLimitSemaphore.ts`、`accountSemaphore.ts`、`accountSelector.ts` | -| 配額 | `quotaMonitor.ts`、`quotaPreflight.ts`、`bailianQuotaFetcher.ts`、`codexQuotaFetcher.ts`、`deepseekQuotaFetcher.ts`、`openrouterQuotaFetcher.ts`、`openrouterFreeWindow.ts`、`crofUsageFetcher.ts`、`antigravityCredits.ts` | -| 快取 | `reasoningCache.ts`、`searchCache.ts`、`signatureCache.ts`、`requestDedup.ts` | -| 路由智慧 | `intentClassifier.ts`、`taskAwareRouter.ts`、`backgroundTaskDetector.ts`、`volumeDetector.ts`、`wildcardRouter.ts`、`workflowFSM.ts`、`specificityDetector.ts`、`specificityRules.ts`、`specificityTypes.ts` | -| 模型處理 | `modelCapabilities.ts`、`modelDeprecation.ts`、`modelFamilyFallback.ts`、`modelStrip.ts`、`model.ts`、`provider.ts`、`providerRequestDefaults.ts`、`providerCostData.ts`、`payloadRules.ts` | -| 壓縮 | `compression/` — 完整壓縮引擎接線 | -| Token + 工作階段 | `tokenRefresh.ts`、`sessionManager.ts`、`apiKeyRotator.ts`、`contextManager.ts`、`contextHandoff.ts`、`systemPrompt.ts`、`roleNormalizer.ts`、`responsesInputSanitizer.ts`、`toolSchemaSanitizer.ts`、`toolLimitDetector.ts`、`thinkingBudget.ts` | -| 層級 / 清單 | `tierResolver.ts`、`tierConfig.ts`、`tierDefaults.json`、`tierTypes.ts`、`manifestAdapter.ts` | -| IP / 網路 | `ipFilter.ts`、`webSearchFallback.ts` | -| 批次 | `batchProcessor.ts` | -| 用量 | `usage.ts` | +| 面向 | 檔案 | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Combo 路由 | `combo.ts`(17 種策略)、`comboConfig.ts`、`comboMetrics.ts`、`comboManifestMetrics.ts`、`comboAgentMiddleware.ts` | +| Auto Combo 引擎 | `autoCombo/` — `engine.ts`、`scoring.ts`、`taskFitness.ts`、`virtualFactory.ts`、`modePacks.ts`、`autoPrefix.ts`、`persistence.ts`、`providerDiversity.ts`、`providerRegistryAccessor.ts`、`routerStrategy.ts`、`selfHealing.ts`、`index.ts` | +| 韌性 | `accountFallback.ts`(冷卻 + 鎖定)、`errorClassifier.ts`、`emergencyFallback.ts`、`rateLimitManager.ts`、`rateLimitSemaphore.ts`、`accountSemaphore.ts`、`accountSelector.ts` | +| 配額 | `quotaMonitor.ts`、`quotaPreflight.ts`、`bailianQuotaFetcher.ts`、`codexQuotaFetcher.ts`、`deepseekQuotaFetcher.ts`、`openrouterQuotaFetcher.ts`、`openrouterFreeWindow.ts`、`crofUsageFetcher.ts`、`antigravityCredits.ts` | +| 快取 | `reasoningCache.ts`、`searchCache.ts`、`signatureCache.ts`、`requestDedup.ts` | +| 路由智慧 | `intentClassifier.ts`、`taskAwareRouter.ts`、`backgroundTaskDetector.ts`、`volumeDetector.ts`、`wildcardRouter.ts`、`workflowFSM.ts`、`specificityDetector.ts`、`specificityRules.ts`、`specificityTypes.ts` | +| 模型處理 | `modelCapabilities.ts`、`modelDeprecation.ts`、`modelFamilyFallback.ts`、`modelStrip.ts`、`model.ts`、`provider.ts`、`providerRequestDefaults.ts`、`providerCostData.ts`、`payloadRules.ts` | +| 壓縮 | `compression/` — 完整壓縮引擎接線 | +| Token + 工作階段 | `tokenRefresh.ts`、`sessionManager.ts`、`apiKeyRotator.ts`、`contextManager.ts`、`contextHandoff.ts`、`systemPrompt.ts`、`roleNormalizer.ts`、`responsesInputSanitizer.ts`、`toolSchemaSanitizer.ts`、`toolLimitDetector.ts`、`thinkingBudget.ts` | +| 層級 / 清單 | `tierResolver.ts`、`tierConfig.ts`、`tierDefaults.json`、`tierTypes.ts`、`manifestAdapter.ts` | +| IP / 網路 | `ipFilter.ts`、`webSearchFallback.ts` | +| 批次 | `batchProcessor.ts` | +| 用量 | `usage.ts` | ### 4.6 `open-sse/mcp-server/` @@ -547,7 +547,7 @@ open-sse/ ### 4.7 `open-sse/config/` -供應商註冊表(`providerRegistry.ts`、`providerModels.ts`、 +提供者註冊表(`providerRegistry.ts`、`providerModels.ts`、 `providerHeaderProfiles.ts`)、各格式模型註冊表(`audioRegistry.ts`、 `embeddingRegistry.ts`、`imageRegistry.ts`、`moderationRegistry.ts`、 `musicRegistry.ts`、`rerankRegistry.ts`、`searchRegistry.ts`、`videoRegistry.ts`)、 @@ -561,7 +561,7 @@ open-sse/ ### 4.8 `open-sse/utils/` -串流基礎元件與供應商輔助程式:`stream.ts`、`streamHandler.ts`、 +串流基礎元件與提供者輔助程式:`stream.ts`、`streamHandler.ts`、 `streamHelpers.ts`、`streamPayloadCollector.ts`、`streamReadiness.ts`、 `sseHeartbeat.ts`、`proxyFetch.ts`、`proxyDispatcher.ts`、`tlsClient.ts`、 `networkProxy.ts`、`awsSigV4.ts`、`cacheControlPolicy.ts`、 @@ -627,28 +627,28 @@ bin/ ## 7. `tests/` -| 目錄 | 類型 | -| --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `tests/unit/` | 透過 Node 原生測試執行器的單元測試(1821 個檔案,加上 `api/`、`auth/`、`authz/` 子目錄) | -| `tests/integration/` | 跨模組 + 資料庫狀態測試 | -| `tests/e2e/` | Playwright UI 測試 | -| `tests/protocols-e2e/` | MCP/A2A 協定 e2e 測試 | -| `tests/translator/` | 翻譯器專用測試 | -| `tests/security/` | 安全性回歸測試 | -| `tests/load/` | 負載/壓力測試 | -| `tests/golden-set/` | 翻譯器回歸測試的參考輸出 | -| `tests/helpers/`、`tests/fixtures/`、`tests/manual/`、`tests/scratch_test.mjs` | 支援 | +| 目錄 | 類型 | +| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| `tests/unit/` | 透過 Node 原生測試執行器的單元測試(1821 個檔案,加上 `api/`、`auth/`、`authz/` 子目錄) | +| `tests/integration/` | 跨模組 + 資料庫狀態測試 | +| `tests/e2e/` | Playwright UI 測試 | +| `tests/protocols-e2e/` | MCP/A2A 協定 e2e 測試 | +| `tests/translator/` | 翻譯器專用測試 | +| `tests/security/` | 安全性回歸測試 | +| `tests/load/` | 負載/壓力測試 | +| `tests/golden-set/` | 翻譯器回歸測試的參考輸出 | +| `tests/helpers/`、`tests/fixtures/`、`tests/manual/`、`tests/scratch_test.mjs` | 支援 | 常用命令: -| 命令 | 執行內容 | -| ----------------------------------------------------------- | ----------------------------------------------------------------- | -| `npm run test:unit` | 所有 `tests/unit/*.test.ts` 透過 Node 測試執行器(並發數 10) | -| `npm run test:vitest` | Vitest 套件(MCP、autoCombo、快取) | -| `npm run test:e2e` | Playwright UI 套件 | -| `npm run test:protocols:e2e` | MCP + A2A 協定 e2e | -| `npm run test:coverage` | 覆蓋率門檻(≥60% 行/陳述式/函式/分支) | -| `node --import tsx/esm --test tests/unit/.test.ts` | 單一檔案執行 | +| 命令 | 執行內容 | +| -------------------------------------------------------- | ------------------------------------------------------------- | +| `npm run test:unit` | 所有 `tests/unit/*.test.ts` 透過 Node 測試執行器(並發數 10) | +| `npm run test:vitest` | Vitest 套件(MCP、autoCombo、快取) | +| `npm run test:e2e` | Playwright UI 套件 | +| `npm run test:protocols:e2e` | MCP + A2A 協定 e2e | +| `npm run test:coverage` | 覆蓋率門檻(≥60% 行/陳述式/函式/分支) | +| `node --import tsx/esm --test tests/unit/.test.ts` | 單一檔案執行 | --- @@ -713,11 +713,11 @@ bin/ ### 韌性執行時期狀態(三種機制) -| 機制 | 範圍 | 位置 | -| -------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| 供應商斷路器 | 整個供應商 | `src/shared/utils/circuitBreaker.ts`,持久化於 `domain_circuit_breakers` | -| 連線冷卻 | 單一帳戶/金鑰 | `markAccountUnavailable()` 位於 `src/sse/services/auth.ts`;由 `accountFallback.checkFallbackError()` 使用 | -| 模型鎖定 | 供應商 + 連線 + 模型 | `open-sse/services/accountFallback.ts`,持久化於 `domain_lockout_state` | +| 機制 | 範圍 | 位置 | +| ------------ | -------------------- | ---------------------------------------------------------------------------------------------------------- | +| 提供者斷路器 | 整個提供者 | `src/shared/utils/circuitBreaker.ts`,持久化於 `domain_circuit_breakers` | +| 連線冷卻 | 單一帳戶/金鑰 | `markAccountUnavailable()` 位於 `src/sse/services/auth.ts`;由 `accountFallback.checkFallbackError()` 使用 | +| 模型鎖定 | 提供者 + 連線 + 模型 | `open-sse/services/accountFallback.ts`,持久化於 `domain_lockout_state` | 參見 [RESILIENCE_GUIDE.md](./RESILIENCE_GUIDE.md) 及 [CLAUDE.md](../../CLAUDE.md) 中的專屬章節。 @@ -726,11 +726,11 @@ bin/ ## 10. 如何貢獻 -### 新增供應商 +### 新增提供者 1. 在 `src/shared/constants/providers.ts` 中註冊(載入時以 Zod 驗證)。 2. 若需要自訂邏輯,在 `open-sse/executors/` 中新增執行器(繼承 `BaseExecutor`)。 -3. 若該供應商不支援 OpenAI 格式,在 `open-sse/translator/` 中新增翻譯器。 +3. 若該提供者不支援 OpenAI 格式,在 `open-sse/translator/` 中新增翻譯器。 4. 若為 OAuth 基礎,在 `src/lib/oauth/providers/` 和 `src/lib/oauth/services/` 下新增設定。 5. 在 `open-sse/config/providerRegistry.ts`(或 `open-sse/config/` 下格式專屬的註冊表)中註冊模型。 6. 在 `tests/unit/` 下撰寫測試。 @@ -775,7 +775,7 @@ bin/ - **ESLint**:`no-eval`、`no-implied-eval`、`no-new-func` = `error` 適用於所有地方;`no-explicit-any` = `warn` 在 `open-sse/` 和 `tests/` 中,其他位置為 error。 - **TypeScript**:`strict: false`(舊有設定)。在跨模組邊界處優先使用明確型別而非推斷。 - **資料庫**:切勿在路由或處理器中撰寫原始 SQL — 務必透過 `src/lib/db/` 模組操作。切勿在 `src/lib/localDb.ts` 中新增邏輯。 -- **資料庫實體型別(#3512)**:一個寫入或讀取資料表列形狀的函式,應接收/回傳一個與該資料表欄位 1:1 對應的命名 TS 介面,而非 `any` 或呼叫處的內聯匿名型別。將該介面置於函式旁邊(例如將 `export interface UsageEntry` 放在 `src/lib/usage/usageHistory.ts` 中 `saveRequestUsage` 之上),在不同寫入者逐步填充該列時,將個別欄位保持為可選/可為 null,並對在不同呼叫者間形狀各異的欄位優先使用 `unknown` 而非 `any`(在欄位上註明,例如 `UsageEntry.tokens` 接受原始供應商形狀的用量和正規化後的形狀)。一旦某個檔案的 `any` 計數以此方式歸零,將其加入 `check:any-budget:t11` 白名單(`scripts/check/check-t11-any-budget.mjs`、`maxAny: 0`),使其不會回歸。這是首批適用的慣例 — 更廣泛的「無匿名 `any`」清理將在其餘程式碼庫中迭代進行。 +- **資料庫實體型別(#3512)**:一個寫入或讀取資料表列形狀的函式,應接收/回傳一個與該資料表欄位 1:1 對應的命名 TS 介面,而非 `any` 或呼叫處的內聯匿名型別。將該介面置於函式旁邊(例如將 `export interface UsageEntry` 放在 `src/lib/usage/usageHistory.ts` 中 `saveRequestUsage` 之上),在不同寫入者逐步填充該列時,將個別欄位保持為可選/可為 null,並對在不同呼叫者間形狀各異的欄位優先使用 `unknown` 而非 `any`(在欄位上註明,例如 `UsageEntry.tokens` 接受原始提供者形狀的用量和正規化後的形狀)。一旦某個檔案的 `any` 計數以此方式歸零,將其加入 `check:any-budget:t11` 白名單(`scripts/check/check-t11-any-budget.mjs`、`maxAny: 0`),使其不會回歸。這是首批適用的慣例 — 更廣泛的「無匿名 `any`」清理將在其餘程式碼庫中迭代進行。 - **錯誤處理**:使用特定錯誤型別的 try/catch,以 pino 上下文記錄日誌。切勿在 SSE 串流中默默吞嚥錯誤;使用中止信號進行清理。 - **安全性**:切勿使用 `eval()` / `new Function()` / 隱含 eval。使用 Zod 驗證所有輸入。加密靜態憑證(AES-256-GCM)。保持 `src/shared/constants/upstreamHeaders.ts` 封鎖清單與清理/驗證層一致。 - **提交訊息**:約定式提交 — `feat(scope): subject`。允許的範圍:`db`、`sse`、`oauth`、`dashboard`、`api`、`cli`、`docker`、`ci`、`mcp`、`a2a`、`memory`、`skills`。 diff --git a/docs/i18n/zh-TW/docs/features/context-relay.md b/docs/i18n/zh-TW/docs/features/context-relay.md index 027656ef24..ef8809d28b 100644 --- a/docs/i18n/zh-TW/docs/features/context-relay.md +++ b/docs/i18n/zh-TW/docs/features/context-relay.md @@ -16,9 +16,9 @@ 當以下所有條件成立時,請使用 `context-relay`: -- combo 預計會在同一個提供商的多個帳戶之間輪換 +- combo 預計會在同一個提供者的多個帳戶之間輪換 - 失去短期對話連續性會影響任務品質 -- 提供商暴露了足夠的配額資訊,可以預測即將到來的帳戶限制 +- 提供者暴露了足夠的配額資訊,可以預測即將到來的帳戶限制 這對於可能超過單一帳戶視窗的長時間編碼或研究會話最為有用。 @@ -32,7 +32,7 @@ ### 已使用 85% 至 94% 的配額 -如果活躍提供商在 `handoffProviders` 中啟用,OmniRoute 會在帳戶完全耗盡之前在背景產生結構化的交接摘要。 +如果活躍提供者在 `handoffProviders` 中啟用,OmniRoute 會在帳戶完全耗盡之前在背景產生結構化的交接摘要。 重要細節: @@ -73,7 +73,7 @@ "summary": "關於哪些內容對連續性重要的精簡摘要", "keyDecisions": ["決策 1", "決策 2"], "taskProgress": "已完成的事項、待辦事項以及下一步", - "activeEntities": ["fileA.ts", "功能 X", "提供商 Y"] + "activeEntities": ["fileA.ts", "功能 X", "提供者 Y"] } ``` @@ -85,7 +85,7 @@ - `handoffThreshold`:摘要產生的警告閾值,預設 `0.85` - `handoffModel`:可選的模型覆寫,僅用於摘要產生 -- `handoffProviders`:允許觸發交接產生的提供商允許清單 +- `handoffProviders`:允許觸發交接產生的提供者允許清單 全域預設值可在設定中配置,combo 專用值可在 Combos 頁面中覆寫。 @@ -103,14 +103,14 @@ ## 限制 - 目前的執行時期支援主要集中在 `codex` 配額輪換上 -- `handoffProviders` 已建模為配置表面,但實際的交接產生仍依賴於提供商特定的配額管線 +- `handoffProviders` 已建模為配置表面,但實際的交接產生仍依賴於提供者特定的配額管線 - 摘要刻意保持精簡並基於近期歷史;它不是完整的對話記錄重播機制 - 交接以 `sessionId + comboName` 為範圍,並會自動過期 - 如果工作階段未切換帳戶,則不會注入儲存的交接 ## 建議使用模式 -- 使用同一個提供商的多個帳戶 +- 使用同一個提供者的多個帳戶 - 在整個工作階段中保持穩定的 `sessionId` 值 - 儘早設定 `handoffThreshold`,為背景摘要請求預留空間 - 將此功能視為連續性輔助,而非持久化記憶體的替代方案 diff --git a/docs/i18n/zh-TW/docs/guides/FEATURES.md b/docs/i18n/zh-TW/docs/guides/FEATURES.md index abd311d969..f78269ae30 100644 --- a/docs/i18n/zh-TW/docs/guides/FEATURES.md +++ b/docs/i18n/zh-TW/docs/guides/FEATURES.md @@ -16,57 +16,57 @@ OmniRoute 儀表板各區塊的視覺化導覽。 ## ✨ v3.8.0 重點功能 -v3.7.x → v3.8.0 版本週期新增了零設定自動路由、新供應商、OAuth 流程、更深的抗災能力以及更豐富的 CLI 體驗。以下為重點功能——完整細節請參閱稍後章節及連結的規格文件。 +v3.7.x → v3.8.0 版本週期新增了零設定自動路由、新提供者、OAuth 流程、更深的抗災能力以及更豐富的 CLI 體驗。以下為重點功能——完整細節請參閱稍後章節及連結的規格文件。 - 🤖 **Auto Combo / 零設定自動路由** — 使用前綴 `auto/coding`、`auto/fast`、`auto/cheap`、`auto/offline`、`auto/smart`、`auto/lkgp`。由 9 因子評分引擎和 4 個精選**模式包**(快速出貨、節省成本、品質優先、離線友善)驅動 -- 🆕 **Command Code 供應商**(#2199)— 一級支援,含模型目錄及配額追蹤 -- 🆕 **Z.AI 供應商** — 新增免費方案供應商,附配額標籤 +- 🆕 **Command Code 提供者**(#2199)— 一級支援,含模型目錄及配額追蹤 +- 🆕 **Z.AI 提供者** — 新增免費方案提供者,附配額標籤 - 🎬 **KIE 媒體擴展** — 擴充目錄,納入影片生成模型 - 🔐 **Windsurf + Devin CLI OAuth 流程**(#2168)— 端到端瀏覽器登入 -- 🆓 **8 個新的免費供應商** — LLM7、Lepton、UncloseAI、BazaarLink、Completions、Enally、FreeTheAi、Command Code -- 🎯 **清單感知分層路由 W1–W4** — 供應商清單驅動加權層級選擇 +- 🆓 **8 個新的免費提供者** — LLM7、Lepton、UncloseAI、BazaarLink、Completions、Enally、FreeTheAi、Command Code +- 🎯 **清單感知分層路由 W1–W4** — 提供者清單驅動加權層級選擇 - 🎨 **Cursor 完整 OpenAI 相容性** — 工具呼叫、串流、階段管理端到端 -- 📊 **Cursor Pro 方案用量** — 在供應商限制儀表板中顯示配額與週期數據 +- 📊 **Cursor Pro 方案用量** — 在提供者限制儀表板中顯示配額與週期數據 - ⚡ **服務層級 breakdown / Codex 快速層分析** — 各層級用量可視化 - 📌 **每階段黏性路由** — Codex 階段在輪次間固定使用相同帳戶 - 🔊 **Inworld TTS 增強** — 語音目錄、串流及延遲改善 - 🔑 **Kiro 無頭驗證** — 透過本機 `kiro-cli` SQLite 儲存庫登入,無需瀏覽器 - 📉 **DeepSeek 配額與限制監控** — 在儀表板顯示每日/每月用量 - 🔄 **重設感知路由策略** — Combo 現在優先選用配額視窗最早重置的帳戶 -- ⏱️ **`fallbackDelayMs`** 與**動態工具限制偵測** — 更精細的備援時機 + 各供應商工具數量限制 +- ⏱️ **`fallbackDelayMs`** 與**動態工具限制偵測** — 更精細的備援時機 + 各提供者工具數量限制 - 🔧 **背景模式降級(Responses API)** — 當上游缺乏背景輪詢能力時,降級為同步模式並附上結構化警告 -- 🚦 **各供應商 429 分類** + `useUpstream429BreakerHints` 開關 — 利用上游速率限制提示來微調斷路器行為 +- 🚦 **各提供者 429 分類** + `useUpstream429BreakerHints` 開關 — 利用上游速率限制提示來微調斷路器行為 - 🩺 **模型冷卻儀表板** — 觀察各模型的鎖定狀態,並可從 UI 手動重新啟用 - 🔒 **MITM 動態 Linux 憑證偵測** — 適用於 Debian/Ubuntu、Fedora/RHEL、Arch 及其他發行版 - 💻 **CLI 增強套件** — 20 多個指令,包含 `omniroute providers`、`omniroute combos`、`omniroute doctor`、`omniroute setup` - 🔍 **Qdrant 嵌入模型探索** — 自動向量儲存模型探測 - 🔑 **API 金鑰 / Bearer 金鑰搭配 `manage` 範圍** — 透過 API 以程式方式執行管理操作 -- 🏥 **Combo 目標健康度分析** + **結構化 Combo 建構器** — 各目標健康度及 UI 建構器,用於組合 `(供應商, 模型, 連線)` 步驟 -- 🤝 **GitLab Duo OAuth 供應商** — 使用 GitLab 憑證登入 +- 🏥 **Combo 目標健康度分析** + **結構化 Combo 建構器** — 各目標健康度及 UI 建構器,用於組合 `(提供者, 模型, 連線)` 步驟 +- 🤝 **GitLab Duo OAuth 提供者** — 使用 GitLab 憑證登入 - 🧠 **推理重播快取** — 混合記憶體 + SQLite 持久化推理軌跡 📚 **相關文件:** [技能框架](../frameworks/SKILLS.md) · [記憶系統](../frameworks/MEMORY.md) · [雲端代理](../frameworks/CLOUD_AGENT.md) · [Webhook](../frameworks/WEBHOOKS.md) · [推理重播快取](../routing/REASONING_REPLAY.md) --- -## 🔌 供應商 +## 🔌 提供者 -管理 AI 供應商連線:OAuth 供應商(Claude Code、Codex)、API 金鑰供應商(Groq、DeepSeek、OpenRouter)以及免費供應商(Qoder、Kiro)。Kiro 帳戶包含額度餘額追蹤——剩餘額度、總配額及續約日期,均可在「儀表板 → 用量」中檢視。 +管理 AI 提供者連線:OAuth 提供者(Claude Code、Codex)、API 金鑰提供者(Groq、DeepSeek、OpenRouter)以及免費提供者(Qoder、Kiro)。Kiro 帳戶包含額度餘額追蹤——剩餘額度、總配額及續約日期,均可在「儀表板 → 用量」中檢視。 OpenRouter 連線可在「進階設定」中儲存各連線的 `preset`。設定後,OmniRoute 會將其作為 OpenRouter 頂層請求欄位發送,例如 `"preset": "email-copywriter"`,除非客戶端請求已提供自己的 `preset`。 -![供應商儀表板](../screenshots/01-providers.png) +![提供者儀表板](../screenshots/01-providers.png) --- ## 🎨 Combo -使用 17 種策略建立模型路由組合:優先、加權、先填滿、輪詢、p2c(二選一)、隨機、最少使用、成本最佳化、重設感知、重設視窗、餘裕空間、嚴格隨機、自動、lkgp(最後已知良好供應商)、情境最佳化、情境轉接,以及**融合**(並行分發給多個模型,再由評判模型合成一個答案)。每個組合可串聯多個模型並自動備援,內含快速範本與就緒檢查。 +使用 17 種策略建立模型路由組合:優先、加權、先填滿、輪詢、p2c(二選一)、隨機、最少使用、成本最佳化、重設感知、重設視窗、餘裕空間、嚴格隨機、自動、lkgp(最後已知良好提供者)、情境最佳化、情境轉接,以及**融合**(並行分發給多個模型,再由評判模型合成一個答案)。每個組合可串聯多個模型並自動備援,內含快速範本與就緒檢查。 近期 Combo 改善: -- **結構化 Combo 建構器** — 透過選擇供應商、模型及精確帳戶/連線來建立每個步驟 -- **重複供應商支援** — 只要 `(供應商, 模型, 連線)` 組合唯一,即可在同一組合中多次重複使用相同供應商 +- **結構化 Combo 建構器** — 透過選擇提供者、模型及精確帳戶/連線來建立每個步驟 +- **重複提供者支援** — 只要 `(提供者, 模型, 連線)` 組合唯一,即可在同一組合中多次重複使用相同提供者 - **Combo 目標健康度** — 分析與健康度面板現在可區分個別 Combo 目標/步驟,而非全部收攏為模型字串 - **複合層級排序** — `defaultTier -> fallbackTier` 現在會影響頂層 Combo 步驟的執行/備援順序 @@ -76,7 +76,7 @@ OpenRouter 連線可在「進階設定」中儲存各連線的 `preset`。設定 ## 📊 分析 -全面的用量分析,包含 Token 消耗、成本估算、活動熱圖、每週分佈圖表及各供應商 breakdown。 +全面的用量分析,包含 Token 消耗、成本估算、活動熱圖、每週分佈圖表及各提供者 breakdown。 ![分析儀表板](../screenshots/03-analytics.png) @@ -84,7 +84,7 @@ OpenRouter 連線可在「進階設定」中儲存各連線的 `preset`。設定 ## 🏥 系統健康度 -即時監控:運作時間、記憶體、版本、延遲百分位數(p50/p95/p99)、快取統計、供應商斷路器狀態、活躍配額監控階段及 Combo 目標健康度。 +即時監控:運作時間、記憶體、版本、延遲百分位數(p50/p95/p99)、快取統計、提供者斷路器狀態、活躍配額監控階段及 Combo 目標健康度。 ![健康度儀表板](../screenshots/04-health.png) @@ -100,7 +100,7 @@ OpenRouter 連線可在「進階設定」中儲存各連線的 `preset`。設定 ## 🎮 模型測試區 _(v2.0.9+)_ -直接從儀表板測試任何模型。選擇供應商、模型及端點,使用 Monaco Editor 編寫提示詞,即時串流接收回應,可中途中止並檢視時間指標。 +直接從儀表板測試任何模型。選擇提供者、模型及端點,使用 Monaco Editor 編寫提示詞,即時串流接收回應,可中途中止並檢視時間指標。 --- @@ -117,9 +117,9 @@ OpenRouter 連線可在「進階設定」中儲存各連線的 `preset`。設定 - **一般** — 系統儲存、備份管理(匯出/匯入資料庫) - **外觀** — 主題選擇器(深色/淺色/系統)、色彩主題預設與自訂顏色、健康度記錄可見度、側邊欄項目與群組分隔線可見度控制、端點通道可見度控制 - **AI** — AI 助手功能、預設路由預設(Auto Combo `auto/coding`、`auto/fast`、`auto/cheap`、`auto/smart`)、推理重播快取及技能/記憶開關 -- **安全性** — API 端點保護、自訂供應商封鎖、IP 過濾、階段資訊 +- **安全性** — API 端點保護、自訂提供者封鎖、IP 過濾、階段資訊 - **路由** — 模型別名、背景任務降級、清單感知分層路由(W1–W4)、`fallbackDelayMs`、每階段黏性路由 -- **抗災能力** — 速率限制持久化、斷路器調校、自動停用被封帳戶、供應商到期監控、**Context Relay** 交接門檻與摘要模型配置、各供應商 429 分類及 `useUpstream429BreakerHints` 開關、模型冷卻 +- **抗災能力** — 速率限制持久化、斷路器調校、自動停用被封帳戶、提供者到期監控、**Context Relay** 交接門檻與摘要模型配置、各提供者 429 分類及 `useUpstream429BreakerHints` 開關、模型冷卻 - **進階** — 配置覆寫、配置審計軌跡、備援降級模式、Responses API 背景模式降級 ![設定儀表板](../screenshots/06-settings.png) @@ -141,7 +141,7 @@ OpenRouter 連線可在「進階設定」中儲存各連線的 `preset`。設定 - **安裝狀態** — 已安裝 / 未找到,含版本偵測 - **協定徽章** — stdio、HTTP 等 - **自訂代理** — 透過表單註冊任何 CLI 工具(名稱、二進位檔、版本指令、啟動參數) -- **CLI 指紋比對** — 各供應商開關,用於比對原生 CLI 請求特徵,降低被封風險同時保留代理 IP +- **CLI 指紋比對** — 各提供者開關,用於比對原生 CLI 請求特徵,降低被封風險同時保留代理 IP - **OAuth 支援代理** — Windsurf 與 Devin CLI 現使用瀏覽器 OAuth 流程進行驗證(v3.8.0+) --- @@ -178,7 +178,7 @@ OpenRouter 連線可在「進階設定」中儲存各連線的 `preset`。設定 全面代理設定強制執行,涵蓋整個請求管線: - **Token 健康檢查** — 背景 OAuth 重新整理現在會依連線解析代理設定,防止在需要代理的環境中發生失敗 -- **API 金鑰驗證** — 供應商金鑰驗證(`POST /api/providers/validate`)會經由 `runWithProxyContext` 路由,遵循供應商層級與全域代理設定 +- **API 金鑰驗證** — 提供者金鑰驗證(`POST /api/providers/validate`)會經由 `runWithProxyContext` 路由,遵循提供者層級與全域代理設定 - **undici Dispatcher 修正** — 代理 dispatcher 使用 undici 自身的 fetch 實作而非 Node 內建 fetch,解決 Node.js 22 上的 `invalid onRequestStart method` 錯誤 - **Node.js 版本偵測** — 登入頁面主動偵測不相容的 Node.js 版本(24+),並顯示警告橫幅,提示使用 Node 22 LTS @@ -186,13 +186,13 @@ OpenRouter 連線可在「進階設定」中儲存各連線的 `preset`。設定 ## 📧 電子郵件隱私遮罩 _(v3.5.6+)_ -OAuth 帳戶電子郵件預設會遮罩(例如 `di*****@g****.com`),防止在分享螢幕截圖或錄製示範時意外暴露。使用「設定 → 外觀 → 帳戶電子郵件可見度」可在供應商、Combo、記錄、配額及測試區等畫面中,全域顯示或隱藏完整帳戶郵件。 +OAuth 帳戶電子郵件預設會遮罩(例如 `di*****@g****.com`),防止在分享螢幕截圖或錄製示範時意外暴露。使用「設定 → 外觀 → 帳戶電子郵件可見度」可在提供者、Combo、記錄、配額及測試區等畫面中,全域顯示或隱藏完整帳戶郵件。 --- ## 👁️ 模型可見度開關 _(v3.5.6+)_ -供應商頁面的模型列表現在包含: +提供者頁面的模型列表現在包含: - **即時搜尋/篩選列** — 快速尋找特定模型 - **各模型可見度開關**(👁 圖示)— 隱藏的模型會變灰,並從 `/v1/models` 目錄中排除 @@ -202,7 +202,7 @@ OAuth 帳戶電子郵件預設會遮罩(例如 `di*****@g****.com`),防止 ## 🔧 OAuth 環境修復 _(v3.6.1+)_ -OAuth 供應商的一鍵「修復環境」功能,可恢復遺失的環境變數並修復受損的驗證狀態。可從「儀表板 → 供應商 → [OAuth 供應商] → 修復環境」進入。自動偵測並修復: +OAuth 提供者的一鍵「修復環境」功能,可恢復遺失的環境變數並修復受損的驗證狀態。可從「儀表板 → 提供者 → [OAuth 提供者] → 修復環境」進入。自動偵測並修復: - 遺失的 OAuth 客戶端憑證 - 損毀的 env 檔案條目 @@ -214,10 +214,10 @@ OAuth 供應商的一鍵「修復環境」功能,可恢復遺失的環境變 所有安裝方式的乾淨移除腳本: -| 指令 | 動作 | -| ------------------------ | -------------------------------------------------------------------------------- | -| `npm run uninstall` | 移除系統應用程式,但**保留您的資料庫與配置**於 `~/.omniroute` 中。 | -| `npm run uninstall:full` | 移除應用程式,並**永久清除所有配置、金鑰與資料庫**。 | +| 指令 | 動作 | +| ------------------------ | ------------------------------------------------------------------ | +| `npm run uninstall` | 移除系統應用程式,但**保留您的資料庫與配置**於 `~/.omniroute` 中。 | +| `npm run uninstall:full` | 移除應用程式,並**永久清除所有配置、金鑰與資料庫**。 | --- @@ -229,7 +229,7 @@ OAuth 供應商的一鍵「修復環境」功能,可恢復遺失的環境變 ## 📝 請求記錄 -即時請求記錄,可依供應商、模型、帳戶及 API 金鑰篩選。顯示狀態碼、Token 用量、延遲及回應詳細資料。 +即時請求記錄,可依提供者、模型、帳戶及 API 金鑰篩選。顯示狀態碼、Token 用量、延遲及回應詳細資料。 ![用量記錄](../screenshots/08-usage.png) @@ -245,7 +245,7 @@ OAuth 供應商的一鍵「修復環境」功能,可恢復遺失的環境變 ## 🔑 API 金鑰管理 -建立、設定範圍及撤銷 API 金鑰。每個金鑰可限制為特定模型/供應商,並可設定完整存取或唯讀權限。視覺化金鑰管理,附用量追蹤。 +建立、設定範圍及撤銷 API 金鑰。每個金鑰可限制為特定模型/提供者,並可設定完整存取或唯讀權限。視覺化金鑰管理,附用量追蹤。 --- @@ -300,15 +300,15 @@ OmniRoute 現在透過 `/v1/ws` 升級端點支援 **OpenAI 相容的 WebSocket ## 🧠 GLM Thinking 預設 _(v3.6.6+)_ -**GLM Thinking(`glmt`)** 現已註冊為一級供應商:65,536 最大輸出 token、24,576 思考預算、900 秒預設逾時、Claude 相容 API 格式,及與 GLM 系列的共用用量同步。 +**GLM Thinking(`glmt`)** 現已註冊為一級提供者:65,536 最大輸出 token、24,576 思考預算、900 秒預設逾時、Claude 相容 API 格式,及與 GLM 系列的共用用量同步。 -**混合 Token 計數** 也在 v3.6.6 中登場:當 Claude 相容供應商暴露 `/messages/count_tokens` 端點時,OmniRoute 會在大請求前呼叫它,並附帶優雅的估算備援。 +**混合 Token 計數** 也在 v3.6.6 中登場:當 Claude 相容提供者暴露 `/messages/count_tokens` 端點時,OmniRoute 會在大請求前呼叫它,並附帶優雅的估算備援。 --- ## 🛡️ 安全外出擷取與 SSRF 防護 _(v3.6.6+)_ -所有供應商驗證及模型探索呼叫現在都會通過兩層外出防護: +所有提供者驗證及模型探索呼叫現在都會通過兩層外出防護: 1. **URL 防護**(`src/shared/network/outboundUrlGuard.ts`)— 在 socket 開啟前封鎖私有/迴路/連結本地 IP 範圍 2. **安全擷取包裝**(`src/shared/network/safeOutboundFetch.ts`)— 套用 URL 防護、標準化逾時,並以指數退避重試暫時性錯誤 @@ -319,10 +319,10 @@ OmniRoute 現在透過 `/v1/ws` 升級端點支援 **OpenAI 相容的 WebSocket ## 🔄 冷卻感知重試 _(v3.6.6+)_ -當上游供應商回傳模型層級冷卻時,聊天請求現在會**自動重試**。可透過 `REQUEST_RETRY`(預設:2)及 `MAX_RETRY_INTERVAL_SEC`(預設:30 秒)設定。速率限制標頭學習已改進,涵蓋 `x-ratelimit-reset-requests`、`x-ratelimit-reset-tokens` 及 `Retry-After`——各模型冷卻狀態可在「抗災能力」儀表板中檢視。 +當上游提供者回傳模型層級冷卻時,聊天請求現在會**自動重試**。可透過 `REQUEST_RETRY`(預設:2)及 `MAX_RETRY_INTERVAL_SEC`(預設:30 秒)設定。速率限制標頭學習已改進,涵蓋 `x-ratelimit-reset-requests`、`x-ratelimit-reset-tokens` 及 `Retry-After`——各模型冷卻狀態可在「抗災能力」儀表板中檢視。 --- ## 📋 合規稽核 v2 _(v3.6.6+)_ -稽核記錄已擴充,包含游標分頁、請求上下文豐富化(請求 ID、使用者代理、IP)、結構化驗證事件、含差異上下文的供應商 CRUD 事件,以及 SSRF 封鎖驗證記錄。新事件由 `src/lib/compliance/providerAudit.ts` 發送。 +稽核記錄已擴充,包含游標分頁、請求上下文豐富化(請求 ID、使用者代理、IP)、結構化驗證事件、含差異上下文的提供者 CRUD 事件,以及 SSRF 封鎖驗證記錄。新事件由 `src/lib/compliance/providerAudit.ts` 發送。 diff --git a/docs/i18n/zh-TW/docs/guides/TROUBLESHOOTING.md b/docs/i18n/zh-TW/docs/guides/TROUBLESHOOTING.md index 3b9ccbdcf8..b9a69d8e08 100644 --- a/docs/i18n/zh-TW/docs/guides/TROUBLESHOOTING.md +++ b/docs/i18n/zh-TW/docs/guides/TROUBLESHOOTING.md @@ -18,17 +18,17 @@ OmniRoute 的常見問題與解決方案。 **剛接觸 OmniRoute?** 從這裡開始 — 這些能解決 90% 的問題: -| 我看見這個 | 代表什麼 | 該怎麼做 | -| ----------------------- | -------------------------------- | -------------------------------------------------------------------------------------------- | -| 「無法連線」 | OmniRoute 未在執行 | 執行 `omniroute` 或 `docker restart omniroute` | -| 「API 金鑰無效」 | 金鑰錯誤或已過期 | 從供應商網站重新複製金鑰 | -| 「超出速率限制」 | 請求傳送過於頻繁 | 等待 1 分鐘,或使用 `model: "auto"` 自動切換 | -| 「超出配額」 | 免費/付費配額已用完 | 連接更多供應商,或使用免費供應商(Kiro, Pollinations) | -| 「回應緩慢」 | 供應商忙碌或距離較遠 | 使用 `model: "auto/fast"` 或連接較快的供應商(Groq, Cerebras) | -| 「使用了錯誤的供應商」 | `auto` 選了不同的供應商 | 這是正常的!`auto` 會選最好的。使用 `model: "openai/gpt-4o"` 來強制指定供應商 | -| 「502 Bad Gateway」 | 供應商故障 | 等待後重試,或使用 `model: "auto"` 切換供應商 | -| 「401 Unauthorized」 | 憑證錯誤 | 檢查 API 金鑰或重新透過 OAuth 認證 | -| 「429 Too Many Requests」| 已達速率限制 | 等待 1 分鐘,或連接更多供應商 | +| 我看見這個 | 代表什麼 | 該怎麼做 | +| ------------------------- | ----------------------- | ----------------------------------------------------------------------------- | +| 「無法連線」 | OmniRoute 未在執行 | 執行 `omniroute` 或 `docker restart omniroute` | +| 「API 金鑰無效」 | 金鑰錯誤或已過期 | 從提供者網站重新複製金鑰 | +| 「超出速率限制」 | 請求傳送過於頻繁 | 等待 1 分鐘,或使用 `model: "auto"` 自動切換 | +| 「超出配額」 | 免費/付費配額已用完 | 連接更多提供者,或使用免費提供者(Kiro, Pollinations) | +| 「回應緩慢」 | 提供者忙碌或距離較遠 | 使用 `model: "auto/fast"` 或連接較快的提供者(Groq, Cerebras) | +| 「使用了錯誤的提供者」 | `auto` 選了不同的提供者 | 這是正常的!`auto` 會選最好的。使用 `model: "openai/gpt-4o"` 來強制指定提供者 | +| 「502 Bad Gateway」 | 提供者故障 | 等待後重試,或使用 `model: "auto"` 切換提供者 | +| 「401 Unauthorized」 | 憑證錯誤 | 檢查 API 金鑰或重新透過 OAuth 認證 | +| 「429 Too Many Requests」 | 已達速率限制 | 等待 1 分鐘,或連接更多提供者 | **還是卡住了?** 請參考下方的[詳細疑難排解](#詳細疑難排解),或在 [Discord](https://discord.gg/U47eFqAXCn) 上提問。 @@ -40,18 +40,18 @@ OmniRoute 的常見問題與解決方案。 ## 快速修復 -| 問題 | 解決方案 | -| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 首次登入無法運作 | 在 `.env` 中設定 `INITIAL_PASSWORD`(無硬編碼預設值) | -| 儀表板開啟在錯誤的連接埠 | 設定 `PORT=20128` 和 `NEXT_PUBLIC_BASE_URL=http://localhost:20128` | -| 沒有日誌寫入磁碟 | 設定 `APP_LOG_TO_FILE=true`,並確認呼叫記錄捕捉功能已啟用 | -| EACCES:權限被拒 | 設定 `DATA_DIR=/path/to/writable/dir` 以覆蓋 `~/.omniroute` | -| 路由策略未儲存 | 更新至最新的 v3.x 版本(早期版本已修復 Zod schema 以確保設定持續性) | -| 登入崩潰/空白頁面 | 檢查 Node.js 版本 — 請參閱下方的 [Node.js 相容性](#nodejs-相容性) | -| `dlopen` / `slice is not valid mach-o file`(macOS)| 執行 `cd $(npm root -g)/omniroute/app && npm rebuild better-sqlite3 && omniroute` — 請參閱下方的 [macOS 原生模組重建](#macos-原生模組重建) | -| Proxy「fetch 失敗」 | 確保 Proxy 設定在正確的層級 — 請參閱下方的 [Proxy 問題](#proxy-問題) | -| 防毒軟體隔離 `README.md` | 誤判 — 請參閱下方的[防毒軟體誤判](#防毒軟體誤判) | -| Kaspersky 將桌面應用程式標記為木馬 | 未簽署安裝程式的行為分析誤判 — 請參閱下方的[防毒軟體誤判](#防毒軟體誤判) | +| 問題 | 解決方案 | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| 首次登入無法運作 | 在 `.env` 中設定 `INITIAL_PASSWORD`(無硬編碼預設值) | +| 儀表板開啟在錯誤的連接埠 | 設定 `PORT=20128` 和 `NEXT_PUBLIC_BASE_URL=http://localhost:20128` | +| 沒有日誌寫入磁碟 | 設定 `APP_LOG_TO_FILE=true`,並確認呼叫記錄捕捉功能已啟用 | +| EACCES:權限被拒 | 設定 `DATA_DIR=/path/to/writable/dir` 以覆蓋 `~/.omniroute` | +| 路由策略未儲存 | 更新至最新的 v3.x 版本(早期版本已修復 Zod schema 以確保設定持續性) | +| 登入崩潰/空白頁面 | 檢查 Node.js 版本 — 請參閱下方的 [Node.js 相容性](#nodejs-相容性) | +| `dlopen` / `slice is not valid mach-o file`(macOS) | 執行 `cd $(npm root -g)/omniroute/app && npm rebuild better-sqlite3 && omniroute` — 請參閱下方的 [macOS 原生模組重建](#macos-原生模組重建) | +| Proxy「fetch 失敗」 | 確保 Proxy 設定在正確的層級 — 請參閱下方的 [Proxy 問題](#proxy-問題) | +| 防毒軟體隔離 `README.md` | 誤判 — 請參閱下方的[防毒軟體誤判](#防毒軟體誤判) | +| Kaspersky 將桌面應用程式標記為木馬 | 未簽署安裝程式的行為分析誤判 — 請參閱下方的[防毒軟體誤判](#防毒軟體誤判) | --- @@ -72,7 +72,7 @@ Avast 和 AVG 執行啟發式掃描,會將包含大量類似 HTTP 請求連結 **該怎麼做:** 1. **停止通知** — 在防毒軟體中排除安裝目錄(Avast:設定 → 例外),加入您的全域 `node_modules` 路徑和/或 OmniRoute 資料目錄(`~/.omniroute/`)。 -2. **回報誤判** — ,附上被隔離的 `README.md`。這能幫助所有人,因為這是供應商的啟發式掃描對文字檔案的過度反應。 +2. **回報誤判** — ,附上被隔離的 `README.md`。這能幫助所有人,因為這是提供者的啟發式掃描對文字檔案的過度反應。 **為什麼我們不在這邊「修復」這個問題:** 範例全都是 `http://localhost`,而 localhost 若要使用 `https` 會需要自簽憑證,增加使用摩擦。為了避開某家廠商的啟發式掃描而修改文件,會損害所有讀者的閱讀體驗,只為了一個掃描器的錯誤。 @@ -82,8 +82,8 @@ Avast 和 AVG 執行啟發式掃描,會將包含大量類似 HTTP 請求連結 被標記的檔案是桌面應用程式所捆綁的、已聲明的開源依賴的標準組件,例如: -- `resources/app/.build/next/node_modules/playwright-/lib/…/agentParser.js` 和 `workerProcessEntry.js` — [Playwright](https://playwright.dev),用於應用程式內供應商登入和瀏覽器支援聊天的瀏覽器自動化函式庫。 -- `resources/app/.build/next/node_modules/tls-client-node-/bin/tls-client-windows-64-.dll` — 來自 `tls-client-node` 的原生二進位檔案,用於某些網路供應商的 Cloudflare 相容 HTTP。 +- `resources/app/.build/next/node_modules/playwright-/lib/…/agentParser.js` 和 `workerProcessEntry.js` — [Playwright](https://playwright.dev),用於應用程式內提供者登入和瀏覽器支援聊天的瀏覽器自動化函式庫。 +- `resources/app/.build/next/node_modules/tls-client-node-/bin/tls-client-windows-64-.dll` — 來自 `tls-client-node` 的原生二進位檔案,用於某些網路提供者的 Cloudflare 相容 HTTP。 **為什麼會觸發:** Windows 安裝程式**尚未進行程式碼簽署**,因此未簽署的 NSIS 安裝程式沒有信譽,行為啟發式掃描會以最大強度執行。加上捆綁的原生 DLL 和數百個寫入 `%LOCALAPPDATA%\Programs\OmniRoute` 的 `.js` 檔案(包括 Next.js 獨立建置的雜湊後綴套件目錄),這就足以觸發啟發式掃描。程式碼簽署已規劃中;在完成之前,新版本可能會重複觸發此問題。 @@ -160,11 +160,11 @@ omniroute -### 供應商驗證顯示「fetch 失敗」 +### 提供者驗證顯示「fetch 失敗」 **原因:** API 金鑰驗證端點(`POST /api/providers/validate`)先前會繞過 Proxy 設定,導致在需要 Proxy 路由的環境中失敗。 -**修復方式(v3.5.5+):** 此問題現已修復。供應商驗證會透過 `runWithProxyContext` 路由,自動遵循供應商層級和全域的 Proxy 設定。 +**修復方式(v3.5.5+):** 此問題現已修復。提供者驗證會透過 `runWithProxyContext` 路由,自動遵循提供者層級和全域的 Proxy 設定。 ### Token 健康狀態檢查失敗,顯示「fetch 失敗」 @@ -186,11 +186,11 @@ omniroute --- -## 供應商問題 +## 提供者問題 ###「Language model did not provide messages」 -**原因:** 供應商配額已用完。 +**原因:** 提供者配額已用完。 **修復方式:** @@ -211,8 +211,8 @@ omniroute OmniRoute 會自動刷新 Token。如果問題持續存在: -1. 儀表板 → 供應商 → 重新連線 -2. 刪除並重新加入供應商連線 +1. 儀表板 → 提供者 → 重新連線 +2. 刪除並重新加入提供者連線 ### Kiro 多帳號:第二個帳號使第一個帳號失效 @@ -220,7 +220,7 @@ OmniRoute 會自動刷新 Token。如果問題持續存在: **修復方式(v3.8.0+):** 重新匯入受影響的連線。從 v3.8.0 開始,每個透過**匯入 Token**、**Google/GitHub 社群登入**或**自動匯入**建立的新 Kiro 連線,都會自動註冊其專屬的 OIDC 用戶端。因此該連線完全隔離,刷新一個帳號不會影響任何其他帳號。 -在 v3.8.0 *之前*匯入的連線不帶有每個連線的用戶端註冊。這些連線會繼續使用共用的社群登入刷新端點。若要獲得隔離,請從儀表板 → 供應商刪除舊連線,並透過三種匯入流程之一重新加入。 +在 v3.8.0 *之前*匯入的連線不帶有每個連線的用戶端註冊。這些連線會繼續使用共用的社群登入刷新端點。若要獲得隔離,請從儀表板 → 提供者刪除舊連線,並透過三種匯入流程之一重新加入。 如需完整詳細資訊和逐步新增兩個 Kiro 帳號的說明,請參閱 [`docs/guides/KIRO_SETUP.md`](./KIRO_SETUP.md)。 @@ -288,7 +288,7 @@ curl -s http://localhost:20128/api/cli-tools/openclaw-settings | jq '{installed, 請求工件會在啟用呼叫記錄管線時儲存在 `${DATA_DIR}/call_logs/` 目錄下。 啟用管線捕捉時,設定 `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS=false` 可省略串流區塊負載,或調整 `CALL_LOG_PIPELINE_MAX_SIZE_KB` 來變更工件大小上限(KB)。 -### 檢查供應商健康狀態 +### 檢查提供者健康狀態 ```bash # 健康狀態儀表板 @@ -300,7 +300,7 @@ curl http://localhost:20128/api/monitoring/health ### 執行環境儲存 -- 主要狀態:`${DATA_DIR}/storage.sqlite`(供應商、組合、別名、金鑰、設定) +- 主要狀態:`${DATA_DIR}/storage.sqlite`(提供者、組合、別名、金鑰、設定) - 使用量:`storage.sqlite` 中的 SQLite 表格(`usage_history`、`call_logs`、`proxy_logs`)+ 選用的 `${DATA_DIR}/call_logs/` - 應用程式日誌:`/logs/...`(當 `APP_LOG_TO_FILE=true` 時) - 呼叫記錄工件:啟用呼叫記錄管線時在 `${DATA_DIR}/call_logs/YYYY-MM-DD/...` 下 @@ -311,24 +311,24 @@ curl http://localhost:20128/api/monitoring/health ## 斷路器問題 -### 供應商卡在 OPEN 狀態 +### 提供者卡在 OPEN 狀態 -當供應商的斷路器處於 OPEN 狀態時,請求將被阻擋直到冷卻時間結束。 +當提供者的斷路器處於 OPEN 狀態時,請求將被阻擋直到冷卻時間結束。 **修復方式:** 1. 前往**儀表板 → 設定 → 備援** -2. 檢查受影響供應商的斷路器卡片 +2. 檢查受影響提供者的斷路器卡片 3. 點擊**全部重設**以清除所有斷路器,或等待冷卻時間結束 -4. 在重設前確認供應商確實可用 +4. 在重設前確認提供者確實可用 -### 供應商持續觸發斷路器 +### 提供者持續觸發斷路器 -如果供應商反覆進入 OPEN 狀態: +如果提供者反覆進入 OPEN 狀態: -1. 檢查**儀表板 → 健康狀態 → 供應商健康狀態**以了解失敗模式 -2. 前往**設定 → 備援 → 供應商設定檔**並提高失敗閾值 -3. 檢查供應商是否變更了 API 限制或需要重新認證 +1. 檢查**儀表板 → 健康狀態 → 提供者健康狀態**以了解失敗模式 +2. 前往**設定 → 備援 → 提供者設定檔**並提高失敗閾值 +3. 檢查提供者是否變更了 API 限制或需要重新認證 4. 檢閱延遲遙測資料 — 高延遲可能導致基於超時的失敗 --- @@ -338,13 +338,13 @@ curl http://localhost:20128/api/monitoring/health ###「Unsupported model」錯誤 - 確保使用正確的前綴:`deepgram/nova-3` 或 `assemblyai/best` -- 確認該供應商已在**儀表板 → 供應商**中連線 +- 確認該提供者已在**儀表板 → 提供者**中連線 ### 轉錄回傳空值或失敗 - 檢查支援的音訊格式:`mp3`、`wav`、`m4a`、`flac`、`ogg`、`webm` -- 確認檔案大小在供應商限制內(通常 < 25MB) -- 在供應商卡片中檢查供應商 API 金鑰的有效性 +- 確認檔案大小在提供者限制內(通常 < 25MB) +- 在提供者卡片中檢查提供者 API 金鑰的有效性 --- @@ -352,21 +352,21 @@ curl http://localhost:20128/api/monitoring/health 使用**儀表板 → 翻譯器**來除錯格式翻譯問題: -| 模式 | 使用時機 | -| ---------------- | ----------------------------------------------------------------------------------------------- | -| **遊樂場** | 並排比較輸入/輸出格式 — 貼上失敗的請求以查看翻譯結果 | -| **聊天測試器** | 發送即時訊息並檢查完整的請求/回應負載,包括標頭 | -| **測試平台** | 跨格式組合執行批次測試,找出哪些翻譯有問題 | -| **即時監控器** | 監控即時請求流程,捕捉間歇性的翻譯問題 | +| 模式 | 使用時機 | +| -------------- | ---------------------------------------------------- | +| **遊樂場** | 並排比較輸入/輸出格式 — 貼上失敗的請求以查看翻譯結果 | +| **聊天測試器** | 發送即時訊息並檢查完整的請求/回應負載,包括標頭 | +| **測試平台** | 跨格式組合執行批次測試,找出哪些翻譯有問題 | +| **即時監控器** | 監控即時請求流程,捕捉間歇性的翻譯問題 | ### 常見格式問題 -- **思考標籤未顯示** — 檢查目標供應商是否支援思考功能以及思考預算設定 +- **思考標籤未顯示** — 檢查目標提供者是否支援思考功能以及思考預算設定 - **工具呼叫被遺漏** — 某些格式翻譯可能會移除不支援的欄位;請在遊樂場模式中驗證 - **系統提示詞遺失** — Claude 和 Gemini 處理系統提示詞的方式不同;請檢查翻譯輸出 - **SDK 回傳原始字串而非物件** — 已在 v1.x 中解決;回應清理器會移除導致 OpenAI SDK Pydantic 驗證失敗的非標準欄位(`x_groq`、`usage_breakdown` 等)。如果您在 v3.x+ 仍看到此問題,請提交 issue。 - **GLM/ERNIE 拒絕 `system` 角色** — 已在 v1.x 中解決;角色正規化器會自動將系統訊息合併到使用者訊息中,以相容不相容的模型。如果您在 v3.x+ 仍看到此問題,請提交 issue。 -- **`developer` 角色不被辨識** — 已在 v1.x 中解決;對非 OpenAI 供應商會自動轉換為 `system`。如果您在 v3.x+ 仍看到此問題,請提交 issue。 +- **`developer` 角色不被辨識** — 已在 v1.x 中解決;對非 OpenAI 提供者會自動轉換為 `system`。如果您在 v3.x+ 仍看到此問題,請提交 issue。 - **`json_schema` 在 Gemini 上無法使用** — 已在 v1.x 中解決;`response_format` 現在會轉換為 Gemini 的 `responseMimeType` + `responseSchema`。如果您在 v3.x+ 仍看到此問題,請提交 issue。 --- @@ -375,13 +375,13 @@ curl http://localhost:20128/api/monitoring/health ### 自動速率限制未觸發 -- 自動速率限制僅適用於 API 金鑰供應商(不適用於 OAuth/訂閱) -- 確認**設定 → 備援 → 供應商設定檔**已啟用自動速率限制 -- 檢查供應商是否回傳 `429` 狀態碼或 `Retry-After` 標頭 +- 自動速率限制僅適用於 API 金鑰提供者(不適用於 OAuth/訂閱) +- 確認**設定 → 備援 → 提供者設定檔**已啟用自動速率限制 +- 檢查提供者是否回傳 `429` 狀態碼或 `Retry-After` 標頭 ### 調整指數退避 -供應商設定檔支援以下設定: +提供者設定檔支援以下設定: - **基本延遲** — 首次失敗後的初始等待時間(預設:1 秒) - **最大延遲** — 等待時間上限(預設:30 秒) @@ -389,13 +389,13 @@ curl http://localhost:20128/api/monitoring/health ### 防止驚群效應 -當大量並發請求湧入一個已達速率限制的供應商時,OmniRoute 會使用互斥鎖 + 自動速率限制來序列化請求,防止連鎖失敗。這對 API 金鑰供應商是自動生效的。 +當大量並發請求湧入一個已達速率限制的提供者時,OmniRoute 會使用互斥鎖 + 自動速率限制來序列化請求,防止連鎖失敗。這對 API 金鑰提供者是自動生效的。 --- ## 選用:RAG / LLM 失敗分類(16 種問題) -部分 OmniRoute 使用者將閘道器部署在 RAG 或 Agent 堆疊之前。在這些設定中,常會看到一種奇怪的現象:OmniRoute 看起來正常(供應商正常、路由設定檔無誤、無速率限制警示),但最終答案仍然錯誤。 +部分 OmniRoute 使用者將閘道器部署在 RAG 或 Agent 堆疊之前。在這些設定中,常會看到一種奇怪的現象:OmniRoute 看起來正常(提供者正常、路由設定檔無誤、無速率限制警示),但最終答案仍然錯誤。 實際上,這些問題通常來自下游的 RAG 管線,而非閘道器本身。 @@ -414,7 +414,7 @@ curl http://localhost:20128/api/monitoring/health 1. 當您調查一個錯誤回應時,記錄: - 使用者的任務與請求 - - OmniRoute 中的路由或供應商組合 + - OmniRoute 中的路由或提供者組合 - 下游使用的任何 RAG 上下文(檢索的文件、工具呼叫等) 2. 將事件對應到一或兩個 WFGY ProblemMap 編號(`No.1` … `No.16`)。 3. 將編號儲存在您自己的儀表板、Runbook 或事件追蹤器中,放在 OmniRoute 日誌旁邊。 @@ -437,7 +437,7 @@ v3.8.0 版本特有的問題及其目前的解決方法。如果後續修補版 **症狀:** - 從儀表板完成 Windsurf OAuth 流程時出現「401 unauthorized」 -- 回呼後 Windsurf 供應商卡片仍停留在「需要重新連線」狀態 +- 回呼後 Windsurf 提供者卡片仍停留在「需要重新連線」狀態 **原因:** @@ -449,7 +449,7 @@ v3.8.0 版本特有的問題及其目前的解決方法。如果後續修補版 1. 確認 `.env` 中已設定 `WINDSURF_FIREBASE_API_KEY` 和 `WINDSURF_API_KEY` 2. 重新啟動 OmniRoute 以載入新的環境變數值 -3. 從**儀表板 → 供應商 → Windsurf → 重新連線**重新執行 OAuth 流程 +3. 從**儀表板 → 提供者 → Windsurf → 重新連線**重新執行 OAuth 流程 ### Devin CLI 認證失敗 @@ -481,19 +481,19 @@ v3.8.0 版本特有的問題及其目前的解決方法。如果後續修補版 - **儀表板:** **設定 → 模型冷卻** → 點擊受影響卡片上的**重新啟用** - **API:** 使用管理認證標頭呼叫 `DELETE /api/resilience/model-cooldowns` -### Command Code 供應商連線失敗,顯示 403 +### Command Code 提供者連線失敗,顯示 403 **症狀:** -- 測試 Command Code 供應商連線時出現 403 -- 剛新增後供應商卡片顯示「unauthorized」 +- 測試 Command Code 提供者連線時出現 403 +- 剛新增後提供者卡片顯示「unauthorized」 **原因:** OAuth 流程未完成(回呼未收到或 Token 未持久化)。 **修復方式:** - 從 CLI 執行 `omniroute providers` 以重新觸發 OAuth 流程,或 -- 從**儀表板 → 供應商 → Command Code → 重新連線**重新執行 OAuth +- 從**儀表板 → 提供者 → Command Code → 重新連線**重新執行 OAuth ### ModelScope 回傳積極的 429 冷卻 @@ -502,7 +502,7 @@ v3.8.0 版本特有的問題及其目前的解決方法。如果後續修補版 - 在 ModelScope 上,少量請求突發後出現非常短或立即的冷卻 - 組合路由比預期更早跳過 ModelScope -**原因:** ModelScope 會發出供應商特定的 `Retry-After` 標頭。v3.8.0 提供了專門處理這些標頭的功能,因此較舊的版本會將其誤讀為一般的速率限制提示。 +**原因:** ModelScope 會發出提供者特定的 `Retry-After` 標頭。v3.8.0 提供了專門處理這些標頭的功能,因此較舊的版本會將其誤讀為一般的速率限制提示。 **修復方式:** diff --git a/docs/i18n/zh-TW/docs/guides/UNINSTALL.md b/docs/i18n/zh-TW/docs/guides/UNINSTALL.md index 4667d438fc..a1f93f9571 100644 --- a/docs/i18n/zh-TW/docs/guides/UNINSTALL.md +++ b/docs/i18n/zh-TW/docs/guides/UNINSTALL.md @@ -22,7 +22,7 @@ OmniRoute 提供兩個內建指令碼來進行乾淨的移除: npm run uninstall ``` -此指令會移除 OmniRoute 應用程式,但**保留**您的資料庫、設定檔、API 金鑰及供應商設定於 `~/.omniroute/`。若您日後打算重新安裝並保留既有設定,請使用此方式。 +此指令會移除 OmniRoute 應用程式,但**保留**您的資料庫、設定檔、API 金鑰及提供者設定於 `~/.omniroute/`。若您日後打算重新安裝並保留既有設定,請使用此方式。 ### 完整移除 @@ -33,12 +33,12 @@ npm run uninstall:full 此指令會移除應用程式,**並永久刪除**所有資料: - 資料庫(`storage.sqlite`) -- 供應商設定與 API 金鑰 +- 提供者設定與 API 金鑰 - 備份檔案 - 日誌檔案 - `~/.omniroute/` 目錄中的所有檔案 -> ⚠️ **警告:** `npm run uninstall:full` 為不可逆操作。所有供應商連線、組合設定、API 金鑰及使用記錄都將永久刪除。 +> ⚠️ **警告:** `npm run uninstall:full` 為不可逆操作。所有提供者連線、組合設定、API 金鑰及使用記錄都將永久刪除。 --- @@ -118,24 +118,24 @@ rm -rf ~/.omniroute OmniRoute 預設將資料存放於以下位置: -| 平台 | 預設路徑 | 覆蓋方式 | -| -------------- | ------------------------------ | ----------------------- | -| Linux | `~/.omniroute/` | `DATA_DIR` 環境變數 | -| macOS | `~/.omniroute/` | `DATA_DIR` 環境變數 | -| Windows | `%APPDATA%/omniroute/` | `DATA_DIR` 環境變數 | -| Docker | `/app/data/`(掛載資料卷) | `DATA_DIR` 環境變數 | -| XDG 相容模式 | `$XDG_CONFIG_HOME/omniroute/` | `XDG_CONFIG_HOME` 環境變數 | +| 平台 | 預設路徑 | 覆蓋方式 | +| ------------ | ----------------------------- | -------------------------- | +| Linux | `~/.omniroute/` | `DATA_DIR` 環境變數 | +| macOS | `~/.omniroute/` | `DATA_DIR` 環境變數 | +| Windows | `%APPDATA%/omniroute/` | `DATA_DIR` 環境變數 | +| Docker | `/app/data/`(掛載資料卷) | `DATA_DIR` 環境變數 | +| XDG 相容模式 | `$XDG_CONFIG_HOME/omniroute/` | `XDG_CONFIG_HOME` 環境變數 | ### 資料目錄中的檔案 -| 檔案/目錄 | 說明 | -| --------------------- | --------------------------------------- | -| `storage.sqlite` | 主要資料庫(供應商、組合、設定、金鑰) | -| `storage.sqlite-wal` | SQLite 預寫式日誌(暫存) | -| `storage.sqlite-shm` | SQLite 共享記憶體(暫存) | -| `call_logs/` | 請求承載記錄封存 | -| `backups/` | 自動資料庫備份 | -| `log.txt` | 舊版請求日誌(選用) | +| 檔案/目錄 | 說明 | +| -------------------- | -------------------------------------- | +| `storage.sqlite` | 主要資料庫(提供者、組合、設定、金鑰) | +| `storage.sqlite-wal` | SQLite 預寫式日誌(暫存) | +| `storage.sqlite-shm` | SQLite 共享記憶體(暫存) | +| `call_logs/` | 請求承載記錄封存 | +| `backups/` | 自動資料庫備份 | +| `log.txt` | 舊版請求日誌(選用) | --- diff --git a/docs/i18n/zh-TW/docs/guides/USER_GUIDE.md b/docs/i18n/zh-TW/docs/guides/USER_GUIDE.md index f245c5194d..e6c7b84055 100644 --- a/docs/i18n/zh-TW/docs/guides/USER_GUIDE.md +++ b/docs/i18n/zh-TW/docs/guides/USER_GUIDE.md @@ -572,7 +572,7 @@ post_install() { **GitHub Copilot(`gh/`)** — OAuth:`gh/gpt-5.5`, `gh/gpt-5.4`, `gh/gpt-5.4-mini`, `gh/gpt-5-mini`, `gh/gpt-5.3-codex`, `gh/claude-opus-4.7`, `gh/claude-opus-4.6`, `gh/claude-opus-4-5-20251101`, `gh/claude-sonnet-4.6`, `gh/claude-sonnet-4.5`, `gh/claude-haiku-4.5`, `gh/gemini-3.1-pro-preview`, `gh/gemini-3-flash-preview`, `gh/oswe-vscode-prime` -**Kiro(`kr/`)** — 免費 OAuth:`kr/auto-kiro`, `kr/claude-opus-4.7`, `kr/claude-opus-4.6`, `kr/claude-sonnet-4.6`, `kr/claude-sonnet-4.5`, `kr/claude-haiku-4.5`, `kr/deepseek-3.2`, `kr/minimax-m2.5`, `kr/minimax-m2.1`, `kr/glm-5`, `kr/qwen3-coder-next` +**Kiro(`kr/`)** — 免費 OAuth:請使用 **控制台 → 供應商 → Kiro → 可用模型** 中顯示的即時目錄。可用模型取決於帳戶與方案。 **Qoder(`if/`)** — 免費 OAuth:`if/qwen3.8-max-preview`, `if/qwen3.7-max`, `if/qwen3.7-plus`, `if/kimi-k3`, `if/kimi-k2.7-code`, `if/glm-5.2`, `if/deepseek-v4-pro`, `if/deepseek-v4-flash`, `if/minimax-m3` diff --git a/docs/i18n/zh-TW/docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md b/docs/i18n/zh-TW/docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md index 1f6afe0526..9ffa135615 100644 --- a/docs/i18n/zh-TW/docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md +++ b/docs/i18n/zh-TW/docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md @@ -171,15 +171,15 @@ flyctl deploy 以下變數建議用於 Fly Secrets: -| 變數 | 建議 | 說明 | -| ----------------------------- | -------------- | ----------------------------------- | -| `API_KEY_SECRET` | 必填 | 用於 API 金鑰產生與驗證 | -| `JWT_SECRET` | 必填 | 用於登入工作階段和 JWT 簽章 | -| `OMNIROUTE_WS_BRIDGE_SECRET` | 生產環境必填 | WebSocket 橋接認證密鑰 | -| `STORAGE_ENCRYPTION_KEY` | 強烈建議 | 靜態加密敏感連線資訊 | -| `MACHINE_ID_SALT` | 建議 | 產生穩定的機器識別碼 | -| `INITIAL_PASSWORD` | 可選 | 首次部署時設定初始後端密碼 | -| OAuth/API 私有憑證 | 視需要而定 | 外部平台認證配置 | +| 變數 | 建議 | 說明 | +| ---------------------------- | ------------ | --------------------------- | +| `API_KEY_SECRET` | 必填 | 用於 API 金鑰產生與驗證 | +| `JWT_SECRET` | 必填 | 用於登入工作階段和 JWT 簽章 | +| `OMNIROUTE_WS_BRIDGE_SECRET` | 生產環境必填 | WebSocket 橋接認證密鑰 | +| `STORAGE_ENCRYPTION_KEY` | 強烈建議 | 靜態加密敏感連線資訊 | +| `MACHINE_ID_SALT` | 建議 | 產生穩定的機器識別碼 | +| `INITIAL_PASSWORD` | 可選 | 首次部署時設定初始後端密碼 | +| OAuth/API 私有憑證 | 視需要而定 | 外部平台認證配置 | ### 6.2 目前專案的建議值 @@ -195,7 +195,7 @@ flyctl deploy ### 6.3 OAuth 回呼 URL 配置 -如果您需要在 Fly.io 部署上啟用基於 OAuth 的提供商(例如 Antigravity、Gemini、Cursor),請確保以下兩點: +如果您需要在 Fly.io 部署上啟用基於 OAuth 的提供者(例如 Antigravity、Gemini、Cursor),請確保以下兩點: 1. **將 `NEXT_PUBLIC_BASE_URL` 設定為您的公開 HTTPS 網域** @@ -205,9 +205,9 @@ flyctl deploy 如果您使用自訂網域,請替換為對應的網域(例如 `https://omniroute.yourdomain.com`)。 -2. **在提供商控制台中配置回呼 URL** +2. **在提供者控制台中配置回呼 URL** - 所有 OAuth 提供商共用單一回呼路徑 `/callback` — 沒有每個提供商的獨立回呼路由: + 所有 OAuth 提供者共用單一回呼路徑 `/callback` — 沒有每個提供者的獨立回呼路由: ```text /callback @@ -216,7 +216,7 @@ flyctl deploy 例如,不論是 Gemini、Antigravity、Cursor 或 GitLab Duo: - `https://omniroute.fly.dev/callback` - 如果 `NEXT_PUBLIC_BASE_URL` 與註冊在提供商的回呼 URL 不符,OAuth 流程將在瀏覽器重新導向步驟失敗。 + 如果 `NEXT_PUBLIC_BASE_URL` 與註冊在提供者的回呼 URL 不符,OAuth 流程將在瀏覽器重新導向步驟失敗。 --- diff --git a/docs/i18n/zh-TW/docs/ops/VM_DEPLOYMENT_GUIDE.md b/docs/i18n/zh-TW/docs/ops/VM_DEPLOYMENT_GUIDE.md index f039d3267f..3ddd132bab 100644 --- a/docs/i18n/zh-TW/docs/ops/VM_DEPLOYMENT_GUIDE.md +++ b/docs/i18n/zh-TW/docs/ops/VM_DEPLOYMENT_GUIDE.md @@ -14,16 +14,16 @@ lastUpdated: 2026-06-28 ## 前置需求 -| 項目 | 最低規格 | 建議規格 | -| ----------- | -------------------------- | ------------------ | -| **CPU** | 1 vCPU | 2 vCPU | -| **RAM** | 1 GB | 2 GB | -| **硬碟** | 10 GB SSD | 25 GB SSD | -| **作業系統**| Ubuntu 22.04 LTS | Ubuntu 24.04 LTS | -| **網域** | 在 Cloudflare 註冊 | — | -| **Docker** | Docker Engine 24+ | Docker 27+ | +| 項目 | 最低規格 | 建議規格 | +| ------------ | ------------------ | ---------------- | +| **CPU** | 1 vCPU | 2 vCPU | +| **RAM** | 1 GB | 2 GB | +| **硬碟** | 10 GB SSD | 25 GB SSD | +| **作業系統** | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS | +| **網域** | 在 Cloudflare 註冊 | — | +| **Docker** | Docker Engine 24+ | Docker 27+ | -**經測試的提供商**: Akamai (Linode)、DigitalOcean、Vultr、Hetzner、AWS Lightsail。 +**經測試的提供者**: Akamai (Linode)、DigitalOcean、Vultr、Hetzner、AWS Lightsail。 --- @@ -31,7 +31,7 @@ lastUpdated: 2026-06-28 ### 1.1 建立執行個體 -在你偏好的 VPS 提供商: +在你偏好的 VPS 提供者: - 選擇 Ubuntu 24.04 LTS - 選擇最低方案(1 vCPU / 1 GB RAM) @@ -270,9 +270,9 @@ nginx -t && systemctl reload nginx 在 Cloudflare 儀表板 → DNS: -| 類型 | 名稱 | 內容 | Proxy | -| ---- | ------ | ----------------------- | ----------- | -| A | `llms` | `203.0.113.10`(VM IP) | ✅ 已代理 | +| 類型 | 名稱 | 內容 | Proxy | +| ---- | ------ | ----------------------- | --------- | +| A | `llms` | `203.0.113.10`(VM IP) | ✅ 已代理 | ### 4.2 設定 SSL @@ -414,9 +414,9 @@ npx wrangler deploy ## 連接埠摘要 -| 連接埠 | 服務 | 存取方式 | -| ------ | ------------- | ------------------------------ | -| 22 | SSH | 公開(搭配 fail2ban) | -| 80 | nginx HTTP | 重新導向 → HTTPS | -| 443 | nginx HTTPS | 經由 Cloudflare Proxy | -| 20128 | OmniRoute | 僅限本機(經由 nginx) | +| 連接埠 | 服務 | 存取方式 | +| ------ | ----------- | ---------------------- | +| 22 | SSH | 公開(搭配 fail2ban) | +| 80 | nginx HTTP | 重新導向 → HTTPS | +| 443 | nginx HTTPS | 經由 Cloudflare Proxy | +| 20128 | OmniRoute | 僅限本機(經由 nginx) | diff --git a/docs/i18n/zh-TW/docs/reference/CLI-TOOLS.md b/docs/i18n/zh-TW/docs/reference/CLI-TOOLS.md index 78fa7e6f07..07d4e0ed99 100644 --- a/docs/i18n/zh-TW/docs/reference/CLI-TOOLS.md +++ b/docs/i18n/zh-TW/docs/reference/CLI-TOOLS.md @@ -10,11 +10,11 @@ lastUpdated: 2026-06-28 OmniRoute 整合了三類 CLI 工具,分別對應三個專屬儀表板頁面: -| 頁面 | 路由 | 概念 | 數量 | -| ---------------- | -------------------------- | ------------------------------------------------------------ | ---------------- | -| **CLI 程式碼工具** | `/dashboard/cli-code` | 指向 OmniRoute 的程式碼工具(客戶端 → CLI → OmniRoute → 提供商) | 21 | -| **CLI 代理工具** | `/dashboard/cli-agents` | 指向 OmniRoute 的自動代理工具(相同流程,範圍更廣) | 6 | -| **ACP 代理** | `/dashboard/acp-agents` | OmniRoute 透過 stdio/ACP 以反向流程衍生的 CLI | 參見註冊表 | +| 頁面 | 路由 | 概念 | 數量 | +| ------------------ | ----------------------- | ---------------------------------------------------------------- | ---------- | +| **CLI 程式碼工具** | `/dashboard/cli-code` | 指向 OmniRoute 的程式碼工具(客戶端 → CLI → OmniRoute → 提供者) | 21 | +| **CLI 代理工具** | `/dashboard/cli-agents` | 指向 OmniRoute 的自動代理工具(相同流程,範圍更廣) | 6 | +| **ACP 代理** | `/dashboard/acp-agents` | OmniRoute 透過 stdio/ACP 以反向流程衍生的 CLI | 參見註冊表 | 舊版路由透過 308 重新導向:`/dashboard/cli-tools` → `/dashboard/cli-code`,`/dashboard/agents` → `/dashboard/acp-agents`。 @@ -29,7 +29,7 @@ Claude / Codex / OpenCode / Cline / KiloCode / Continue / Hermes Agent / Goose / ▼ (全部指向 OmniRoute) http://YOUR_SERVER:20128/v1 │ - ▼ (OmniRoute 路由至對應提供商) + ▼ (OmniRoute 路由至對應提供者) Anthropic / OpenAI / Gemini / DeepSeek / Groq / Mistral / ... ACP 代理(反向衍生流程): @@ -68,14 +68,14 @@ omniroute setup-goose omniroute setup-qwen omniroute setup-aider 每個條目包含以下欄位(定義於 `src/shared/schemas/cliCatalog.ts`): -| 欄位 | 型別 | 說明 | -| ------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------- | -| `category` | `"code" \| "agent"` | 工具顯示在哪個頁面 | -| `vendor` | `string` | 工具來源("Anthropic"、"OSS (P. Gauthier)") | -| `acpSpawnable` | `boolean` | 也可用作 ACP 代理(顯示徽章) | -| `baseUrlSupport` | `"full" \| "partial" \| "none"` | 自訂端點支援程度。`"none"` = MITM 待辦事項 | -| `configType` | `"env" \| "custom" \| "guide" \| "custom-builder" \| "mitm"` | 設定機制 | -| `id`、`name`、`color`、`description`、`docsUrl` | 標準 | 核心顯示欄位 | +| 欄位 | 型別 | 說明 | +| ----------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------- | +| `category` | `"code" \| "agent"` | 工具顯示在哪個頁面 | +| `vendor` | `string` | 工具來源("Anthropic"、"OSS (P. Gauthier)") | +| `acpSpawnable` | `boolean` | 也可用作 ACP 代理(顯示徽章) | +| `baseUrlSupport` | `"full" \| "partial" \| "none"` | 自訂端點支援程度。`"none"` = MITM 待辦事項 | +| `configType` | `"env" \| "custom" \| "guide" \| "custom-builder" \| "mitm"` | 設定機制 | +| `id`、`name`、`color`、`description`、`docsUrl` | 標準 | 核心顯示欄位 | `baseUrlSupport: "none"` 的條目**不會**顯示在儀表板頁面上 — 它們會註冊在 MITM 待辦事項中,屬於 plan 11 的範疇(參見 `_tasks/features-v3.8.6/refactorpages/_orchestration/_plan11-mitm-backlog.md`)。 @@ -85,33 +85,33 @@ omniroute setup-goose omniroute setup-qwen omniroute setup-aider 所有出現在 `/dashboard/cli-code` 的工具。`baseUrlSupport: none` 的工具會透過 MITM 或手動指南而非自訂基礎 URL 來連接: -| id | 名稱 | 供應商 | baseUrlSupport | configType | acpSpawnable | -| -------------- | ------------------- | ------------------------ | -------------- | --------------- | ------------ | -| claude | Claude Code | Anthropic | full | env | true | -| codex | OpenAI Codex CLI | OpenAI | full | custom | true | -| cline | Cline | OSS(前 Claude Dev) | full | custom | true | -| kilo | Kilo Code | Kilo-Org | full | custom | false | -| roo | Roo Code | Roo(OSS) | full | guide | false | -| continue | Continue | continue.dev | full | guide | false | -| aider | Aider | OSS(P. Gauthier) | full | guide | true | -| forge | ForgeCode | Antinomy HQ | full | custom | true | -| jcode | jcode | 1jehuang(OSS) | full | custom | false | -| deepseek-tui | DeepSeek TUI | Hunter Bown(OSS) | full | custom | false | -| codewhale | CodeWhale | Hmbown(OSS) | full | custom | false | -| opencode | OpenCode | Anomaly(前 SST) | full | guide | true | -| droid | Factory Droid | Factory AI | partial | guide | false | -| copilot | GitHub Copilot CLI | GitHub/MS | full | custom | false | -| cursor-cli | Cursor CLI | Anysphere | partial | guide | true | -| smelt | Smelt | leonardcser(OSS) | full | custom | false | -| pi | Pi(pi-coding-agent) | M. Zechner(OSS) | full | custom | false | -| grok-build | Grok Build | xAI | full | custom | false | -| crush | Crush | OSS(Charm) | full | custom | false | -| qwen | Qwen Code | Alibaba | full | guide | true | -| cursor | Cursor | Anysphere | none | guide | false | -| antigravity | Antigravity | Google | none | mitm | false | -| hermes | Hermes | Nous Research | none | guide | false | -| kiro | Kiro AI | Amazon | none | mitm | false | -| custom | 自訂 CLI | — | full | custom-builder | false | +| id | 名稱 | 提供者 | baseUrlSupport | configType | acpSpawnable | +| ------------ | --------------------- | -------------------- | -------------- | -------------- | ------------ | +| claude | Claude Code | Anthropic | full | env | true | +| codex | OpenAI Codex CLI | OpenAI | full | custom | true | +| cline | Cline | OSS(前 Claude Dev) | full | custom | true | +| kilo | Kilo Code | Kilo-Org | full | custom | false | +| roo | Roo Code | Roo(OSS) | full | guide | false | +| continue | Continue | continue.dev | full | guide | false | +| aider | Aider | OSS(P. Gauthier) | full | guide | true | +| forge | ForgeCode | Antinomy HQ | full | custom | true | +| jcode | jcode | 1jehuang(OSS) | full | custom | false | +| deepseek-tui | DeepSeek TUI | Hunter Bown(OSS) | full | custom | false | +| codewhale | CodeWhale | Hmbown(OSS) | full | custom | false | +| opencode | OpenCode | Anomaly(前 SST) | full | guide | true | +| droid | Factory Droid | Factory AI | partial | guide | false | +| copilot | GitHub Copilot CLI | GitHub/MS | full | custom | false | +| cursor-cli | Cursor CLI | Anysphere | partial | guide | true | +| smelt | Smelt | leonardcser(OSS) | full | custom | false | +| pi | Pi(pi-coding-agent) | M. Zechner(OSS) | full | custom | false | +| grok-build | Grok Build | xAI | full | custom | false | +| crush | Crush | OSS(Charm) | full | custom | false | +| qwen | Qwen Code | Alibaba | full | guide | true | +| cursor | Cursor | Anysphere | none | guide | false | +| antigravity | Antigravity | Google | none | mitm | false | +| hermes | Hermes | Nous Research | none | guide | false | +| kiro | Kiro AI | Amazon | none | mitm | false | +| custom | 自訂 CLI | — | full | custom-builder | false | `baseUrlSupport: "partial"` 的工具會在儀表板卡片上顯示「⚠ 基礎 URL 部分支援」徽章。 @@ -121,16 +121,16 @@ omniroute setup-goose omniroute setup-qwen omniroute setup-aider 出現在 `/dashboard/cli-agents` 的自動代理工具: -| id | 名稱 | 供應商 | baseUrlSupport | acpSpawnable | -| ------------ | ------------------- | ------------------------- | -------------- | ------------ | -| hermes-agent | Hermes Agent | Nous Research | full | false | -| openclaw | OpenClaw | OSS(P. Steinberger) | full | true | -| goose | Goose | Block / Linux Foundation | full | true | -| interpreter | Open Interpreter | OSS | full | true | -| warp | Warp AI | Warp Inc. | partial | true | -| agent-deck | Agent Deck | asheshgoplani(OSS) | full | false | -| omp | Oh My Pi | OSS | full | true | -| letta | Letta CLI | Letta | full | false | +| id | 名稱 | 提供者 | baseUrlSupport | acpSpawnable | +| ------------ | ---------------- | ------------------------ | -------------- | ------------ | +| hermes-agent | Hermes Agent | Nous Research | full | false | +| openclaw | OpenClaw | OSS(P. Steinberger) | full | true | +| goose | Goose | Block / Linux Foundation | full | true | +| interpreter | Open Interpreter | OSS | full | true | +| warp | Warp AI | Warp Inc. | partial | true | +| agent-deck | Agent Deck | asheshgoplani(OSS) | full | false | +| omp | Oh My Pi | OSS | full | true | +| letta | Letta CLI | Letta | full | false | --- @@ -144,12 +144,12 @@ omniroute setup-goose omniroute setup-qwen omniroute setup-aider 以下 CLI 原生不支援自訂基礎 URL,**不會列出**在 CLI 程式碼工具或 CLI 代理工具頁面中。它們是 plan 11 中 MITM 攔截的候選對象: -| CLI | 原因 | -| --------------------- | ------------------------------------------------- | -| windsurf | BYOK 僅限特定 Claude 模型 + 企業 URL/Token | -| amp | 封閉生態系統(Sourcegraph) | -| amazon-q / kiro-cli | AWS SSO 認證,無自訂 URL | -| cowork | Anthropic Desktop,無可設定的端點 | +| CLI | 原因 | +| ------------------- | ------------------------------------------ | +| windsurf | BYOK 僅限特定 Claude 模型 + 企業 URL/Token | +| amp | 封閉生態系統(Sourcegraph) | +| amazon-q / kiro-cli | AWS SSO 認證,無自訂 URL | +| cowork | Anthropic Desktop,無可設定的端點 | 完整交叉參考請參閱 `_tasks/features-v3.8.6/refactorpages/_orchestration/_plan11-mitm-backlog.md`。 @@ -193,16 +193,16 @@ interface ToolBatchStatus { `configType: "custom"` 的新工具擁有專屬的設定 API 路由: -| 路由 | 工具 | -| ------------------------------------------------- | ----------------------------------- | -| `POST /api/cli-tools/forge-settings` | ForgeCode(.forge.toml) | -| `POST /api/cli-tools/jcode-settings` | jcode(--base-url 旗標) | -| `POST /api/cli-tools/deepseek-tui-settings` | DeepSeek TUI(OPENAI_BASE_URL,舊版) | -| `POST /api/cli-tools/codewhale-settings` | CodeWhale(OPENAI_BASE_URL,主要 + 舊版 `~/.deepseek` 同步) | -| `POST /api/cli-tools/smelt-settings` | Smelt | -| `POST /api/cli-tools/pi-settings` | Pi 程式碼代理 | -| `POST /api/cli-tools/grok-build-settings` | Grok Build(~/.grok/config.toml,`[model.omniroute]`) | -| `POST /api/cli-tools/qwen-settings` | Qwen Code(`~/.qwen/settings.json` + 專用 `.env` 金鑰) | +| 路由 | 工具 | +| ------------------------------------------- | ------------------------------------------------------------ | +| `POST /api/cli-tools/forge-settings` | ForgeCode(.forge.toml) | +| `POST /api/cli-tools/jcode-settings` | jcode(--base-url 旗標) | +| `POST /api/cli-tools/deepseek-tui-settings` | DeepSeek TUI(OPENAI_BASE_URL,舊版) | +| `POST /api/cli-tools/codewhale-settings` | CodeWhale(OPENAI_BASE_URL,主要 + 舊版 `~/.deepseek` 同步) | +| `POST /api/cli-tools/smelt-settings` | Smelt | +| `POST /api/cli-tools/pi-settings` | Pi 程式碼代理 | +| `POST /api/cli-tools/grok-build-settings` | Grok Build(~/.grok/config.toml,`[model.omniroute]`) | +| `POST /api/cli-tools/qwen-settings` | Qwen Code(`~/.qwen/settings.json` + 專用 `.env` 金鑰) | 所有路由都使用 `sanitizeErrorMessage()` 處理錯誤回應(硬性規則 #12)。 @@ -229,20 +229,20 @@ interface ToolBatchStatus { ### 共用 UI 元件(`src/shared/components/cli/`) -| 檔案 | 用途 | -| ------------------------ | ---------------------------------------------- | -| `CliToolCard.tsx` | 智慧型狀態卡片(偵測 + 設定 + 端點) | -| `CliConceptCard.tsx` | 各頁面概念說明卡片 | -| `CliComparisonCard.tsx` | 三欄 CLI 類型比較卡片 | -| `BaseUrlSelect.tsx` | 端點下拉選單(本機/雲端/自訂) | -| `ApiKeySelect.tsx` | API 金鑰選擇器 | -| `ManualConfigModal.tsx` | 可複製的設定片段模態框 | +| 檔案 | 用途 | +| ----------------------- | ------------------------------------ | +| `CliToolCard.tsx` | 智慧型狀態卡片(偵測 + 設定 + 端點) | +| `CliConceptCard.tsx` | 各頁面概念說明卡片 | +| `CliComparisonCard.tsx` | 三欄 CLI 類型比較卡片 | +| `BaseUrlSelect.tsx` | 端點下拉選單(本機/雲端/自訂) | +| `ApiKeySelect.tsx` | API 金鑰選擇器 | +| `ManualConfigModal.tsx` | 可複製的設定片段模態框 | ### 共用 Hook(`src/shared/hooks/cli/`) -| 檔案 | 用途 | -| ---------------------------- | ------------------------------------------- | -| `useToolBatchStatuses.ts` | 擷取 `/api/cli-tools/all-statuses`,管理載入/重新整理狀態 | +| 檔案 | 用途 | +| ------------------------- | --------------------------------------------------------- | +| `useToolBatchStatuses.ts` | 擷取 `/api/cli-tools/all-statuses`,管理載入/重新整理狀態 | --- @@ -250,12 +250,12 @@ interface ToolBatchStatus { plan 14 F9 中新增的命名空間: -| 命名空間 | 用途 | -| ------------- | ------------------------------------------- | -| `cliCommon` | 共用字串(卡片標籤、概念/比較文字、詳細頁面標籤) | -| `cliCode` | CLI 程式碼工具頁面字串 | -| `cliAgents` | CLI 代理工具頁面字串 | -| `acpAgents` | ACP 代理頁面字串 | +| 命名空間 | 用途 | +| ----------- | ------------------------------------------------- | +| `cliCommon` | 共用字串(卡片標籤、概念/比較文字、詳細頁面標籤) | +| `cliCode` | CLI 程式碼工具頁面字串 | +| `cliAgents` | CLI 代理工具頁面字串 | +| `acpAgents` | ACP 代理頁面字串 | 已提供完整的巴西葡萄牙文(PT-BR)和英文(EN)翻譯。其他 39 種語言會透過 `src/i18n/request.ts` 中的命名空間層級合併自動回退為英文。 @@ -524,13 +524,13 @@ kiro-cli status ## 10. 內部 OmniRoute CLI -`omniroute` 二進位檔提供用於伺服器生命週期管理、設定、診斷和提供商管理的指令。進入點:`bin/omniroute.mjs`。 +`omniroute` 二進位檔提供用於伺服器生命週期管理、設定、診斷和提供者管理的指令。進入點:`bin/omniroute.mjs`。 ```bash omniroute # 啟動伺服器(預設通訊埠 20128) omniroute setup # 互動式設定精靈 omniroute doctor # 檢查設定、資料庫、通訊埠、執行環境 -omniroute providers list # 已設定的提供商連線 +omniroute providers list # 已設定的提供者連線 omniroute providers test-all # 測試每個作用中連線 omniroute reset-password # 重設管理員密碼 omniroute logs # 串流要求日誌 @@ -548,15 +548,15 @@ omniroute setup --password '' # 直接設定管理員密碼 omniroute setup --add-provider \ --provider openai \ --api-key '' \ - --test-provider # 一氣呵成新增並測試提供商 + --test-provider # 一氣呵成新增並測試提供者 ``` 非互動式設定可識別的環境變數: -| 變數 | 用途 | -| -------------------- | ----------------------------------------- | -| `OMNIROUTE_API_KEY` | 提供商 API 金鑰(透過 Commander `.env()` 繫結至 `--api-key`) | -| `DATA_DIR` | 覆寫 OmniRoute 資料目錄 | +| 變數 | 用途 | +| ------------------- | ------------------------------------------------------------- | +| `OMNIROUTE_API_KEY` | 提供者 API 金鑰(透過 Commander `.env()` 繫結至 `--api-key`) | +| `DATA_DIR` | 覆寫 OmniRoute 資料目錄 | 所有其他非互動式輸入皆以旗標傳遞(非環境變數): `--password`、`--provider`、`--provider-name`、`--provider-base-url`、`--default-model` @@ -576,15 +576,15 @@ doctor 會執行以下檢查:`Config`、`Database`、`Storage/encryption`、 `Port availability`、`Node runtime`、`Native binary`(better-sqlite3)、 `Memory` 和 `Server liveness`。若有任一檢查結果為 `fail`,則以非零退出碼結束。 -### 提供商管理 +### 提供者管理 ```bash -omniroute providers available # OmniRoute 提供商目錄 +omniroute providers available # OmniRoute 提供者目錄 omniroute providers available --search openai # 依 ID/名稱/別名/類別過濾目錄 omniroute providers available --category api-key # 依類別過濾(api-key、oauth、free 等) omniroute providers available --json # 機器可讀的 JSON -omniroute providers list # 已設定的提供商連線 +omniroute providers list # 已設定的提供者連線 omniroute providers list --json omniroute providers test # 測試一個已設定的連線 @@ -629,8 +629,8 @@ omniroute status # 完整的執行時期狀態 omniroute logs # 串流要求日誌(--json、--search、--follow) omniroute config show # 顯示目前設定 -omniroute provider list # 列出可用提供商(providers list 的別名) -omniroute provider add # 將 OmniRoute 註冊為工具上的提供商 +omniroute provider list # 列出可用提供者(providers list 的別名) +omniroute provider add # 將 OmniRoute 註冊為工具上的提供者 omniroute keys add | list | remove # 管理 API 金鑰 omniroute models [provider] # 列出模型(--json、--search) omniroute combo list | switch | create | delete @@ -639,7 +639,7 @@ omniroute backup # 快照設定 + 資料庫 omniroute restore # 從先前的快照還原 omniroute health # 詳細健康狀態(斷路器、快取、記憶體) -omniroute quota # 提供商配額使用情況 +omniroute quota # 提供者配額使用情況 omniroute cache # 快取狀態 omniroute cache clear # 清除語意 + 簽章快取 @@ -649,36 +649,36 @@ omniroute a2a status | card # A2A 伺服器狀態 / 代理卡片 omniroute tunnel list | create | stop # 管理通道(cloudflare/tailscale/ngrok) omniroute env show | get | set # 檢查 / 設定環境變數(暫時性) -omniroute test # 提供商連線冒煙測試 +omniroute test # 提供者連線冒煙測試 omniroute update # 檢查更新 omniroute completion # 產生 Shell 補全 ``` ### 常用旗標 -| 旗標 | 說明 | -| ------------------- | ----------------------------------------- | -| `--no-open` | 啟動時不自動開啟瀏覽器 | -| `--port ` | 覆寫 API 通訊埠(預設 20128) | -| `--mcp` | 以 MCP 伺服器模式透過 stdio 執行(用於 IDE)| -| `--non-interactive` | CI 模式(無提示;從環境變數/旗標讀取) | -| `--json` | 機器可讀的 JSON 輸出(doctor、providers 等)| -| `--help`、`-h` | 顯示指令專屬說明 | -| `--version`、`-v` | 顯示已安裝版本 | +| 旗標 | 說明 | +| ------------------- | -------------------------------------------- | +| `--no-open` | 啟動時不自動開啟瀏覽器 | +| `--port ` | 覆寫 API 通訊埠(預設 20128) | +| `--mcp` | 以 MCP 伺服器模式透過 stdio 執行(用於 IDE) | +| `--non-interactive` | CI 模式(無提示;從環境變數/旗標讀取) | +| `--json` | 機器可讀的 JSON 輸出(doctor、providers 等) | +| `--help`、`-h` | 顯示指令專屬說明 | +| `--version`、`-v` | 顯示已安裝版本 | --- ## 可用 API 端點 -| 端點 | 說明 | 用途 | -| --------------------------- | ----------------- | ----------------------- | -| `/v1/chat/completions` | 標準聊天(所有提供商)| 所有現代工具 | -| `/v1/responses` | Responses API(OpenAI 格式)| Codex、代理工作流程 | -| `/v1/completions` | 舊版文字補全 | 使用 `prompt:` 的較舊工具 | -| `/v1/embeddings` | 文字嵌入 | RAG、搜尋 | -| `/v1/images/generations` | 圖片生成 | GPT-Image、Flux 等 | -| `/v1/audio/speech` | 文字轉語音 | ElevenLabs、OpenAI TTS | -| `/v1/audio/transcriptions` | 語音轉文字 | Deepgram、AssemblyAI | +| 端點 | 說明 | 用途 | +| -------------------------- | ---------------------------- | ------------------------- | +| `/v1/chat/completions` | 標準聊天(所有提供者) | 所有現代工具 | +| `/v1/responses` | Responses API(OpenAI 格式) | Codex、代理工作流程 | +| `/v1/completions` | 舊版文字補全 | 使用 `prompt:` 的較舊工具 | +| `/v1/embeddings` | 文字嵌入 | RAG、搜尋 | +| `/v1/images/generations` | 圖片生成 | GPT-Image、Flux 等 | +| `/v1/audio/speech` | 文字轉語音 | ElevenLabs、OpenAI TTS | +| `/v1/audio/transcriptions` | 語音轉文字 | Deepgram、AssemblyAI | 可直接貼上的 Token 化 OmniRoute URL 範例: @@ -697,12 +697,12 @@ Ollama 聊天:http://localhost:20128/api/v1/vscode/«redacted:sk-…»/api/cha ## 故障排除 -| 錯誤 | 原因 | 解決方式 | -| ---------------------------------------------- | ------------------------- | ----------------------------------------------- | -| `Connection refused` | OmniRoute 未執行 | `omniroute serve` | -| `401 Unauthorized` | API 金鑰錯誤 | 在 `/dashboard/api-manager` 中檢查 | -| `No combo configured` | 無作用中路由組合 | 在 `/dashboard/combos` 中設定 | -| CLI 顯示「not installed」 | 二進位檔不在 PATH 中 | 檢查 `which ` | -| 儀表板在安裝後顯示「not detected」 | 快取過期 | 點選儀表板中的「⟳ 重新整理偵測」 | -| 舊連結 `/dashboard/cli-tools` | v3.8.6 之前的書籤 | 自動重新導向至 `/dashboard/cli-code`(308) | -| 舊連結 `/dashboard/agents` | v3.8.6 之前的書籤 | 自動重新導向至 `/dashboard/acp-agents`(308) | +| 錯誤 | 原因 | 解決方式 | +| ---------------------------------- | -------------------- | --------------------------------------------- | +| `Connection refused` | OmniRoute 未執行 | `omniroute serve` | +| `401 Unauthorized` | API 金鑰錯誤 | 在 `/dashboard/api-manager` 中檢查 | +| `No combo configured` | 無作用中路由組合 | 在 `/dashboard/combos` 中設定 | +| CLI 顯示「not installed」 | 二進位檔不在 PATH 中 | 檢查 `which ` | +| 儀表板在安裝後顯示「not detected」 | 快取過期 | 點選儀表板中的「⟳ 重新整理偵測」 | +| 舊連結 `/dashboard/cli-tools` | v3.8.6 之前的書籤 | 自動重新導向至 `/dashboard/cli-code`(308) | +| 舊連結 `/dashboard/agents` | v3.8.6 之前的書籤 | 自動重新導向至 `/dashboard/acp-agents`(308) | diff --git a/docs/i18n/zh-TW/docs/reference/ENVIRONMENT.md b/docs/i18n/zh-TW/docs/reference/ENVIRONMENT.md index 1d37957a59..b064777868 100644 --- a/docs/i18n/zh-TW/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/zh-TW/docs/reference/ENVIRONMENT.md @@ -336,7 +336,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | diff --git a/docs/i18n/zh-TW/docs/routing/AUTO-COMBO.md b/docs/i18n/zh-TW/docs/routing/AUTO-COMBO.md index 81d92cdf38..82d7fd2f96 100644 --- a/docs/i18n/zh-TW/docs/routing/AUTO-COMBO.md +++ b/docs/i18n/zh-TW/docs/routing/AUTO-COMBO.md @@ -16,15 +16,15 @@ lastUpdated: 2026-06-28 ### 快速範例 -| 模型 ID | 變體 | 行為 | -| --------------- | --------- | --------------------------------------------------------------- | -| `auto` | 預設 | 所有已連線提供商,LKGP 策略,平衡權重 | -| `auto/coding` | coding | 品質優先權重,適合程式碼生成 | -| `auto/fast` | fast | 低延遲加權選擇 | -| `auto/cheap` | cheap | 成本最佳化路由(最低成本優先) | -| `auto/offline` | offline | 偏好額度可用性最高的提供商 | -| `auto/smart` | smart | 品質優先 + 較高探索率(10%),以發掘更佳模型 | -| `auto/lkgp` | lkgp | 明確 LKGP(與預設 `auto` 相同) | +| 模型 ID | 變體 | 行為 | +| -------------- | ------- | -------------------------------------------- | +| `auto` | 預設 | 所有已連線提供者,LKGP 策略,平衡權重 | +| `auto/coding` | coding | 品質優先權重,適合程式碼生成 | +| `auto/fast` | fast | 低延遲加權選擇 | +| `auto/cheap` | cheap | 成本最佳化路由(最低成本優先) | +| `auto/offline` | offline | 偏好額度可用性最高的提供者 | +| `auto/smart` | smart | 品質優先 + 較高探索率(10%),以發掘更佳模型 | +| `auto/lkgp` | lkgp | 明確 LKGP(與預設 `auto` 相同) | ### 類別 × 層級組合(`auto/<類別>:<層級>`) @@ -33,13 +33,13 @@ OpenRouter 風格的後綴將**路由種類**(類別)與**最佳化方式** - **類別**(依能力過濾候選池):`coding`(程式)· `reasoning`(推理)· `vision`(視覺)· `chat`(對話)· `multimodal`(多模態)。`vision`/`multimodal` 保留具視覺能力的模型;`reasoning` 保留推理/思考模型。 - **層級**(選擇評分權重 / 過濾池):`fast`(快速出貨)· `cheap`(別名 `floor`,節省成本)· `reliable`(斷路器健康度 + 延遲穩定性)· `free` / `pro`(透過 `classifyTier` 依模型層級過濾池 — 免費層 vs. 高級層)。 -| 範例 | 解析結果 | -| --------------------------- | ----------------------------------------------- | -| `auto/coding:fast` | coding 池,低延遲權重 | -| `auto/coding:cheap` | coding 池,成本最佳化(別名 `auto/coding:floor`)| -| `auto/reasoning:pro` | 僅限推理/思考模型,高級層 | -| `auto/vision` | 具視覺能力的模型(無層級 → 平衡權重) | -| `auto/multimodal:free` | 多模態能力模型,僅限免費層 | +| 範例 | 解析結果 | +| ---------------------- | ------------------------------------------------- | +| `auto/coding:fast` | coding 池,低延遲權重 | +| `auto/coding:cheap` | coding 池,成本最佳化(別名 `auto/coding:floor`) | +| `auto/reasoning:pro` | 僅限推理/思考模型,高級層 | +| `auto/vision` | 具視覺能力的模型(無層級 → 平衡權重) | +| `auto/multimodal:free` | 多模態能力模型,僅限免費層 | 任何有效的 `auto/<類別>[:<層級>]` 皆可按需解析;精選子集會在 `/v1/models` 與儀表板中顯示(`AUTO_SUFFIX_VARIANTS` 定義於 `open-sse/services/autoCombo/builtinCatalog.ts`)。過濾採用**容錯開放**機制—若條件未匹配到任何已連線模型,則使用完整候選池,確保路由永不中斷。核心評分器(`combo.ts`)維持不變;類別/層級過濾則在 `buildAutoCandidates` 中執行。 @@ -62,25 +62,25 @@ model: "auto/cheap" # 每 token 最便宜 **運作流程:** 1. OmniRoute 在 `src/sse/handlers/chat.ts` 中偵測到 `auto/` 前綴 -2. 查詢資料庫中所有**活躍的提供商連線** +2. 查詢資料庫中所有**活躍的提供者連線** 3. 過濾出具有有效憑證(API 金鑰或 OAuth token)的連線 -4. 為每個連線決定模型(`connection.defaultModel` 或提供商的第一個模型) +4. 為每個連線決定模型(`connection.defaultModel` 或提供者的第一個模型) 5. 在記憶體中建立**虛擬組合**(不存入資料庫) 6. 使用所選變體的權重設定檔 + LKGP 策略進行路由 **主要特性:** - ✅ **永遠開啟:** 無需開關、無需建立組合、無需設定 -- ✅ **動態:** 自動反映當前連線的提供商 -- ✅ **工作階段黏著性:** LKGP 確保上次成功的提供商獲得優先權 -- ✅ **多帳號感知:** 每個提供商連線成為獨立的候選項目 +- ✅ **動態:** 自動反映當前連線的提供者 +- ✅ **工作階段黏著性:** LKGP 確保上次成功的提供者獲得優先權 +- ✅ **多帳號感知:** 每個提供者連線成為獨立的候選項目 - ✅ **無資料庫寫入:** 虛擬組合僅存在於請求期間,零持久化開銷 ### 依金鑰候選控制(#7819, Level 1+2) `GET /v1/auto-combo/{channel}/candidates`(`{channel}` = `auto/` 後的後綴,或基礎頻道使用 `auto` 字面值)是一個**唯讀**端點,列出某個 `auto/*` 頻道當前的候選池,並裝飾有即時可達性資訊,重複使用現有的彈性讀取機制(絕不直接使用原始的斷路器 `state`): -- 提供商斷路器 — `getCircuitBreaker(provider).getStatus()` / `.canExecute()` +- 提供者斷路器 — `getCircuitBreaker(provider).getStatus()` / `.canExecute()` - 連線冷卻 — `rateLimitedUntil` / `testStatus`(來自已解析的 `provider_connections` 資料列) - 模型鎖定 — `isModelLocked(provider, connectionId, model)` @@ -99,41 +99,41 @@ createVirtualAutoCombo('coding') → 來自活躍連線的候選池 ↓ handleComboChat(與持久化組合使用相同引擎) ↓ -自動評分為每個請求選擇最佳提供商/模型 +自動評分為每個請求選擇最佳提供者/模型 ``` **實作檔案:** -| 檔案 | 用途 | -| ------------------------------------------------------------ | --------------------------------------- | -| `open-sse/services/autoCombo/autoPrefix.ts` | 前綴解析器(`parseAutoPrefix`) | -| `open-sse/services/autoCombo/virtualFactory.ts` | 建立虛擬 `AutoComboConfig` 物件 | -| `open-sse/services/autoCombo/providerRegistryAccessor.ts` | 用於 mock 提供商註冊表的測試鉤子 | -| `src/sse/handlers/chat.ts` | 整合點:自動前綴短路處理 | -| `src/shared/constants/providers.ts` | `SYSTEM_PROVIDERS.auto` 系統條目 | +| 檔案 | 用途 | +| --------------------------------------------------------- | -------------------------------- | +| `open-sse/services/autoCombo/autoPrefix.ts` | 前綴解析器(`parseAutoPrefix`) | +| `open-sse/services/autoCombo/virtualFactory.ts` | 建立虛擬 `AutoComboConfig` 物件 | +| `open-sse/services/autoCombo/providerRegistryAccessor.ts` | 用於 mock 提供者註冊表的測試鉤子 | +| `src/sse/handlers/chat.ts` | 整合點:自動前綴短路處理 | +| `src/shared/constants/providers.ts` | `SYSTEM_PROVIDERS.auto` 系統條目 | ## 運作原理(持久化自動組合) -自動組合引擎使用**12 因子評分函數**(定義於 `open-sse/services/autoCombo/scoring.ts` → `DEFAULT_WEIGHTS`)為每個請求動態選擇最佳的提供商/模型。所有權重合計為 **1.0**。 +自動組合引擎使用**12 因子評分函數**(定義於 `open-sse/services/autoCombo/scoring.ts` → `DEFAULT_WEIGHTS`)為每個請求動態選擇最佳的提供者/模型。所有權重合計為 **1.0**。 ![自動組合 12 因子評分](../diagrams/exported/auto-combo-12factor.svg) > 來源:[diagrams/auto-combo-12factor.mmd](../diagrams/auto-combo-12factor.mmd)(可透過 `npm run docs:render-diagrams` 重新生成)。 -| 因子 | 預設權重 | 說明 | -| :--------------------- | :------- | :---------------------------------------------------------------- | -| `health`(健康度) | 0.20 | 斷路器健康分數(CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) | -| `quota`(額度) | 0.15 | 剩餘額度 / 速率限制餘裕 [0..1] | -| `costInv`(成本倒數) | 0.15 | 倒數**混合**成本(60% 輸入 + 40% 輸出 token 價格,經正規化)— 越便宜分數越高 | -| `latencyInv`(延遲倒數)| 0.12 | 倒數 p95 延遲經池正規化 — 越快分數越高 | -| `taskFit`(任務適應性)| 0.08 | 任務類型適應性(程式、審查、規劃、分析、除錯、文件) | -| `stability`(穩定性) | 0.05 | 基於變異數的穩定性(低延遲 stdDev / 錯誤率) | -| `tierPriority`(層級優先)| 0.05 | 帳戶層級優先級 — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 | -| `tierAffinity`(層級親和性)| 0.05 | 候選層級與清單建議層級之間的親和性 | -| `specificityMatch`(特異性匹配)| 0.05 | 請求特異性(清單提示)與模型層級之間的匹配度 | -| `contextAffinity`(上下文親和性)| 0.05 | 請求的上下文視窗需求與模型上下文視窗之間的親和性 | -| `connectionDensity`(連線密度)| 0.05 | 在同一個提供商的不同連線之間分散負載(反集中化) | -| `resetWindowAffinity`(重置視窗親和性)| 0.00 | 傾向於額度重置視窗有利的連線(預設停用) | +| 因子 | 預設權重 | 說明 | +| :-------------------------------------- | :------- | :--------------------------------------------------------------------------- | +| `health`(健康度) | 0.20 | 斷路器健康分數(CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) | +| `quota`(額度) | 0.15 | 剩餘額度 / 速率限制餘裕 [0..1] | +| `costInv`(成本倒數) | 0.15 | 倒數**混合**成本(60% 輸入 + 40% 輸出 token 價格,經正規化)— 越便宜分數越高 | +| `latencyInv`(延遲倒數) | 0.12 | 倒數 p95 延遲經池正規化 — 越快分數越高 | +| `taskFit`(任務適應性) | 0.08 | 任務類型適應性(程式、審查、規劃、分析、除錯、文件) | +| `stability`(穩定性) | 0.05 | 基於變異數的穩定性(低延遲 stdDev / 錯誤率) | +| `tierPriority`(層級優先) | 0.05 | 帳戶層級優先級 — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 | +| `tierAffinity`(層級親和性) | 0.05 | 候選層級與清單建議層級之間的親和性 | +| `specificityMatch`(特異性匹配) | 0.05 | 請求特異性(清單提示)與模型層級之間的匹配度 | +| `contextAffinity`(上下文親和性) | 0.05 | 請求的上下文視窗需求與模型上下文視窗之間的親和性 | +| `connectionDensity`(連線密度) | 0.05 | 在同一個提供者的不同連線之間分散負載(反集中化) | +| `resetWindowAffinity`(重置視窗親和性) | 0.00 | 傾向於額度重置視窗有利的連線(預設停用) | **合計:** `0.20 + 0.15 + 0.15 + 0.12 + 0.08 + 0.05 + 0.05 + 0.05 + 0.05 + 0.05 + 0.05 + 0.00 = 1.0`(經 `validateWeights()` 驗證)。 @@ -164,11 +164,11 @@ handleComboChat(與持久化組合使用相同引擎) `auto` 組合可透過三個標頭**針對每次請求**進行調整,無需修改組合的儲存設定。這些僅適用於 `auto` 策略,且僅對攜帶這些標頭的請求生效;當標頭不存在時,則使用組合儲存的 `modePack`/`budgetCap`/`budgetFallback`。 -| 標頭 | 接受值 | 效果 | -| :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `X-OmniRoute-Mode` | 預設別名(`fast`、`balanced`、`quality`、`cheap`、`reliable`、`offline`)或原始套件名稱(`ship-fast`、`cost-saver`、`quality-first`、`offline-friendly`、`reliability-first`) | 覆寫本次請求的評分權重。`balanced`/`default` 強制使用預設權重(無套件)。未知值則忽略(保留原有設定)。 | -| `X-OmniRoute-Budget` | 正數(每次請求的最大美元金額) | 硬性成本上限:估計成本超過此值的候選項在選擇前即被過濾。當**每個**候選項都超過上限時,行為由下方的 `X-OmniRoute-Budget-Fallback` 控制。 | -| `X-OmniRoute-Budget-Fallback` | `cheapest`(預設,別名:`cheapest-viable`、`soft`)或 `strict`(別名:`block`、`hard`) | `cheapest`:回退至全域最便宜的候選項(即使仍超過上限,為舊版行為)。`strict`:拒絕選擇—請求快速失敗,回傳 `HTTP 402`,而非默默超支。未知值則忽略。 | +| 標頭 | 接受值 | 效果 | +| :---------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | +| `X-OmniRoute-Mode` | 預設別名(`fast`、`balanced`、`quality`、`cheap`、`reliable`、`offline`)或原始套件名稱(`ship-fast`、`cost-saver`、`quality-first`、`offline-friendly`、`reliability-first`) | 覆寫本次請求的評分權重。`balanced`/`default` 強制使用預設權重(無套件)。未知值則忽略(保留原有設定)。 | +| `X-OmniRoute-Budget` | 正數(每次請求的最大美元金額) | 硬性成本上限:估計成本超過此值的候選項在選擇前即被過濾。當**每個**候選項都超過上限時,行為由下方的 `X-OmniRoute-Budget-Fallback` 控制。 | +| `X-OmniRoute-Budget-Fallback` | `cheapest`(預設,別名:`cheapest-viable`、`soft`)或 `strict`(別名:`block`、`hard`) | `cheapest`:回退至全域最便宜的候選項(即使仍超過上限,為舊版行為)。`strict`:拒絕選擇—請求快速失敗,回傳 `HTTP 402`,而非默默超支。未知值則忽略。 | ```bash # 強制使用最快設定檔,將此請求上限設為 $0.05,超支時直接封鎖而非降級 @@ -186,26 +186,26 @@ curl -sS http://localhost:20128/v1/chat/completions \ OmniRoute 的組合引擎支援 **18 種路由策略**(宣告於 `src/shared/constants/routingStrategies.ts` → `ROUTING_STRATEGY_VALUES`)。自動組合引擎本身以 `auto` 策略對外提供;其他策略可用於持久化組合。 -| 策略 | 說明 | -| :----------------- | :--------------------------------------------------------------------------------- | -| `priority` | 依明確優先級排列的第一目標有序列表 | -| `weighted` | 依各目標權重的加權隨機選擇 | -| `round-robin` | 依序循環切換目標 | -| `context-relay` | 跨目標交接上下文(長對話) | -| `fill-first` | 先填滿每個目標的額度,再移至下一個 | -| `p2c` | Power-of-2-choices 隨機負載平衡 | -| `random` | 均勻隨機選擇 | -| `least-used` | 挑選當前負載最低的目標 | -| `cost-optimized` | 依目錄定價最小化每次請求成本 | -| `reset-aware` ⭐ | 依額度重置時間排序 — 重置視窗短者優先 | -| `reset-window` | 偏好額度視窗最快重置的目標 | -| `headroom` | 挑選剩餘額度空間最大的目標 | -| `strict-random` | 純隨機,不排除重複 | -| `auto` | 使用自動組合評分(9 因子)— **推薦** | -| `lkgp` | 上次已知良好路徑(黏著路由至上次成功的目標) | -| `context-optimized`| 挑選最適合當前上下文大小的目標 | -| `fusion` 🧬 | 平行分發給多個模型面板,再由評判模型合成一個答案(詳見下方) | -| `pipeline` | 依序執行目標,將每個步驟的輸出串接至下一步驟的輸入;僅回傳最終答案(#6396) | +| 策略 | 說明 | +| :------------------ | :-------------------------------------------------------------------------- | +| `priority` | 依明確優先級排列的第一目標有序列表 | +| `weighted` | 依各目標權重的加權隨機選擇 | +| `round-robin` | 依序循環切換目標 | +| `context-relay` | 跨目標交接上下文(長對話) | +| `fill-first` | 先填滿每個目標的額度,再移至下一個 | +| `p2c` | Power-of-2-choices 隨機負載平衡 | +| `random` | 均勻隨機選擇 | +| `least-used` | 挑選當前負載最低的目標 | +| `cost-optimized` | 依目錄定價最小化每次請求成本 | +| `reset-aware` ⭐ | 依額度重置時間排序 — 重置視窗短者優先 | +| `reset-window` | 偏好額度視窗最快重置的目標 | +| `headroom` | 挑選剩餘額度空間最大的目標 | +| `strict-random` | 純隨機,不排除重複 | +| `auto` | 使用自動組合評分(9 因子)— **推薦** | +| `lkgp` | 上次已知良好路徑(黏著路由至上次成功的目標) | +| `context-optimized` | 挑選最適合當前上下文大小的目標 | +| `fusion` 🧬 | 平行分發給多個模型面板,再由評判模型合成一個答案(詳見下方) | +| `pipeline` | 依序執行目標,將每個步驟的輸出串接至下一步驟的輸入;僅回傳最終答案(#6396) | ⭐ = v3.8.0 新增 · 🧬 = v3.8.36 新增 @@ -227,12 +227,12 @@ OmniRoute 的組合引擎支援 **18 種路由策略**(宣告於 `src/shared/c 設定於組合的 `config` blob(無需結構描述遷移—它重複使用現有的 `combos` 資料表): -| 欄位 | 類型 | 預設值 | 用途 | -| :------------------------------------------ | :------- | :---------------- | :---------------------------------------------------------- | -| `config.judgeModel` | `string` | 第一個面板模型 | 負責合成最終答案的模型 | -| `config.fusionTuning.minPanel` | `number` | `2` | 寬限期計時器啟動前所需的成功答案數(限制在 `[2, panelSize]` 之間)| -| `config.fusionTuning.stragglerGraceMs` | `number` | `8000` | 達到法定人數後等待落後者的時間 | -| `config.fusionTuning.panelHardTimeoutMs` | `number` | `90000` | 絕對超時上限,防止單一掛起的模型拖垮整個請求 | +| 欄位 | 類型 | 預設值 | 用途 | +| :--------------------------------------- | :------- | :------------- | :----------------------------------------------------------------- | +| `config.judgeModel` | `string` | 第一個面板模型 | 負責合成最終答案的模型 | +| `config.fusionTuning.minPanel` | `number` | `2` | 寬限期計時器啟動前所需的成功答案數(限制在 `[2, panelSize]` 之間) | +| `config.fusionTuning.stragglerGraceMs` | `number` | `8000` | 達到法定人數後等待落後者的時間 | +| `config.fusionTuning.panelHardTimeoutMs` | `number` | `90000` | 絕對超時上限,防止單一掛起的模型拖垮整個請求 | 預設值位於 `FUSION_DEFAULTS`(`open-sse/services/fusion.ts`)。 @@ -271,7 +271,7 @@ curl -X POST http://localhost:20128/api/combos \ 6. 使用 9 因子 `scorePool()` 和變體的權重套件為每個候選項評分 7. 回傳結果的記憶體中 `AutoComboConfig` 供 `handleComboChat()` 使用 — 永不持久化至資料庫 -這表示**新增一個啟用 `auto/*` 的提供商會自動擴展候選池**—無需手動編輯組合。虛擬組合在每次請求時重新建立,因此新新增或剛恢復健康的連線會立即被納入。 +這表示**新增一個啟用 `auto/*` 的提供者會自動擴展候選池**—無需手動編輯組合。虛擬組合在每次請求時重新建立,因此新新增或剛恢復健康的連線會立即被納入。 ## 自我修復 @@ -282,7 +282,7 @@ curl -X POST http://localhost:20128/api/combos \ ## Bandit 探索 -5% 的請求(可設定)會路由至隨機提供商進行探索。事故模式下停用。 +5% 的請求(可設定)會路由至隨機提供者進行探索。事故模式下停用。 ## API @@ -312,24 +312,23 @@ curl -X POST http://localhost:20128/api/combos \ 持久化的 `strategy: "auto"` 組合可以設定 `config.routerStrategy`(或舊版 `config.auto.routerStrategy`)為以下之一: - `rules` — 預設加權評分 -- `cost` / `eco` — 最便宜的健全提供商 +- `cost` / `eco` — 最便宜的健全提供者 - `latency` / `fast` — 最低 p95 延遲,附可靠性懲罰 - `sla-aware` / `sla` — 偏好滿足 p95 延遲、錯誤率與可選成本 SLA 的候選項 -- `lkgp` — 上次已知良好的提供商優先 +- `lkgp` — 上次已知良好的提供者優先 ### 路由器策略詳細說明 -自動組合引擎提供 5 個可插拔的 **RouterStrategy** 實作,可透過 `config.routerStrategy`(或舊版 `config.auto.routerStrategy`)切換。每種策略根據給定的 `RoutingContext`(任務類型、工具/視覺提示、token 估算、可選 SLA 策略、可選的上次已知良好提供商)從候選池中選擇一個提供商。 +自動組合引擎提供 5 個可插拔的 **RouterStrategy** 實作,可透過 `config.routerStrategy`(或舊版 `config.auto.routerStrategy`)切換。每種策略根據給定的 `RoutingContext`(任務類型、工具/視覺提示、token 估算、可選 SLA 策略、可選的上次已知良好提供者)從候選池中選擇一個提供者。 #### 1. `rules`(預設)— 6 因子加權評分 -包裝現有的評分引擎。過濾掉 `OPEN` 斷路器狀態的候選項,然後使用當前任務類型和 `getTaskFitness()` 執行 `scorePool()`,選取得分最高的提供商。 +包裝現有的評分引擎。過濾掉 `OPEN` 斷路器狀態的候選項,然後使用當前任務類型和 `getTaskFitness()` 執行 `scorePool()`,選取得分最高的提供者。 ```ts class RulesStrategyImpl implements RouterStrategy { readonly name = "rules"; - readonly description = - "6 因子加權評分:額度、健康度、成本、延遲、任務適應性、穩定性"; + readonly description = "6 因子加權評分:額度、健康度、成本、延遲、任務適應性、穩定性"; select(pool, context) { const eligible = pool.filter((c) => c.circuitBreakerState !== "OPEN"); @@ -350,14 +349,14 @@ class RulesStrategyImpl implements RouterStrategy { --- -#### 2. `cost` / `eco` — 最便宜的健全提供商 +#### 2. `cost` / `eco` — 最便宜的健全提供者 將候選池按 `costPer1MTokens`(升序)排序,選取最便宜的。首先過濾掉 `OPEN` 狀態的候選項。 ```ts class CostStrategyImpl implements RouterStrategy { readonly name = "cost"; - readonly description = "始終選擇最便宜的可用提供商"; + readonly description = "始終選擇最便宜的可用提供者"; select(pool, context) { const healthy = pool.filter((c) => c.circuitBreakerState !== "OPEN"); @@ -375,7 +374,7 @@ class CostStrategyImpl implements RouterStrategy { #### 3. `latency` / `fast` — 最低 p95 延遲附可靠性懲罰 -按 `p95LatencyMs + (errorRate * 1000)` 排序。錯誤率懲罰確保不可靠的提供商即使名義延遲較低也會被排在較低位置。 +按 `p95LatencyMs + (errorRate * 1000)` 排序。錯誤率懲罰確保不可靠的提供者即使名義延遲較低也會被排在較低位置。 ```ts class LatencyStrategyImpl implements RouterStrategy { @@ -402,21 +401,20 @@ class LatencyStrategyImpl implements RouterStrategy { 根據每個候選項滿足設定的 SLA 策略的程度進行評分: -| 因子 | 權重 | 公式 | -| ------------ | ---- | --------------------------------- | -| 延遲分數 | 35% | `threshold / max(value, ε)` | -| 錯誤分數 | 35% | `threshold / max(value, ε)` | -| 健康分數 | 15% | `1.0`(CLOSED) / `0.5`(HALF_OPEN) / `0.0`(OPEN) | -| 成本分數 | 10% | `threshold / max(value, ε)` 或反向正規化 | -| 穩定性分數 | 5% | 反向正規化延遲標準差 | +| 因子 | 權重 | 公式 | +| ---------- | ---- | ---------------------------------------------- | +| 延遲分數 | 35% | `threshold / max(value, ε)` | +| 錯誤分數 | 35% | `threshold / max(value, ε)` | +| 健康分數 | 15% | `1.0`(CLOSED) / `0.5`(HALF_OPEN) / `0.0`(OPEN) | +| 成本分數 | 10% | `threshold / max(value, ε)` 或反向正規化 | +| 穩定性分數 | 5% | 反向正規化延遲標準差 | 當 `hardConstraints: true` 時,候選項主要按**違規分數**(超出任何 SLA 的程度)排序,其次再按綜合分數。否則僅使用綜合分數。 ```ts class SLAStrategyImpl implements RouterStrategy { readonly name = "sla-aware"; - readonly description = - "選擇最可能滿足延遲、錯誤率和成本 SLA 的提供商"; + readonly description = "選擇最可能滿足延遲、錯誤率和成本 SLA 的提供者"; select(pool, context) { // ... 根據策略對每個候選項評分:{ targetP95Ms, maxErrorRate, maxCostPer1MTokens, hardConstraints } @@ -445,14 +443,14 @@ class SLAStrategyImpl implements RouterStrategy { --- -#### 5. `lkgp` — 上次已知良好的提供商優先 +#### 5. `lkgp` — 上次已知良好的提供者優先 -先嘗試**上次已知良好的提供商**(若有設定),若失敗則回退至 `rules` 策略。適用於工作階段的黏著性—同一提供商處理對話中的後續請求。 +先嘗試**上次已知良好的提供者**(若有設定),若失敗則回退至 `rules` 策略。適用於工作階段的黏著性—同一提供者處理對話中的後續請求。 ```ts class LKGPStrategyImpl implements RouterStrategy { readonly name = "lkgp"; - readonly description = "先嘗試上次已知良好的提供商,若失敗則回退至 rules"; + readonly description = "先嘗試上次已知良好的提供者,若失敗則回退至 rules"; select(pool, context) { if (context.lkgpEnabled === false) { @@ -474,7 +472,7 @@ class LKGPStrategyImpl implements RouterStrategy { } ``` -**使用時機**:多輪對話中,希望同一提供商處理後續請求(例如為了快取、上下文連續性或定價一致性)。 +**使用時機**:多輪對話中,希望同一提供者處理後續請求(例如為了快取、上下文連續性或定價一致性)。 **別名**:`lkgp`(無別名) @@ -525,13 +523,13 @@ registerStrategy("my-custom", new MyCustomStrategy()); ### 路由器策略選擇指南 -| 使用案例 | 策略 | 原因 | -| -------------------- | ------------ | --------------------------------- | -| 平衡工作負載 | `rules` | 預設 — 考慮所有因素 | -| 最小化成本 | `cost` | 始終選取最便宜的 | -| 最小化延遲 | `latency` | 選取最快的可靠提供商 | -| 嚴格 SLA | `sla-aware` | 按 p95/錯誤率/成本門檻過濾 | -| 多輪對話 | `lkgp` | 工作階段黏著性 | +| 使用案例 | 策略 | 原因 | +| ------------ | ----------- | -------------------------- | +| 平衡工作負載 | `rules` | 預設 — 考慮所有因素 | +| 最小化成本 | `cost` | 始終選取最便宜的 | +| 最小化延遲 | `latency` | 選取最快的可靠提供者 | +| 嚴格 SLA | `sla-aware` | 按 p95/錯誤率/成本門檻過濾 | +| 多輪對話 | `lkgp` | 工作階段黏著性 | SLA-aware 欄位: @@ -564,7 +562,7 @@ SLA-aware 欄位: 12 因子評分函數(`open-sse/services/autoCombo/scoring.ts`)將層級歸屬視為兩個訊號:`tierPriority`(0.05)和 `tierAffinity`(0.05)。請參閱上方標準的[評分因子表](#運作原理持久化自動組合)以取得完整的 `DEFAULT_WEIGHTS` 集合 — 各套件覆寫值(ship-fast/cost-saver/quality-first/offline-friendly)列於「各套件權重設定檔」表中。 -層級本身**不會**強制 Tier 1 優先 — 如果 Tier 1 延遲不佳或成本 vs. 品質次佳,則 Tier 2 勝出。若要強制層級排序,請使用組合策略 `priority` 並按層級排列提供商。 +層級本身**不會**強制 Tier 1 優先 — 如果 Tier 1 延遲不佳或成本 vs. 品質次佳,則 Tier 2 勝出。若要強制層級排序,請使用組合策略 `priority` 並按層級排列提供者。 若要強烈偏好 Tier 1(訂閱制),請增加 `tierPriority` 權重: @@ -575,7 +573,7 @@ SLA-aware 欄位: } ``` -請參閱 `docs/marketing/TIERS.md` 了解層級定義與提供商分類。 +請參閱 `docs/marketing/TIERS.md` 了解層級定義與提供者分類。 ## 測試與覆蓋範圍 @@ -589,29 +587,29 @@ SLA-aware 欄位: 此測試套件在 CI 中執行(`test:integration` 任務),使用 `--test-concurrency=1` 和 `--test-force-exit`,確保確定性且不需要真實憑證。 -### 閘控即時煙霧測試(不在 CI 中—需要真實提供商) +### 閘控即時煙霧測試(不在 CI 中—需要真實提供者) -| 指令 | 功能說明 | -| :---------------------------------------- | :-------------------------------------------------------------------- | -| `npm run test:combo:live` | 處理中真實路由(`RUN_COMBO_LIVE=1`);快照即時 OmniRoute 資料庫 | -| `npm run test:combo:live:vps` | 對即時 OmniRoute 伺服器的 HTTP 呼叫(設定 `COMBO_LIVE_BASE_URL`) | -| `npm run test:combo:live:vps:failover` | 同上,但加入刻意觸發的容錯轉移情境 | +| 指令 | 功能說明 | +| :------------------------------------- | :---------------------------------------------------------------- | +| `npm run test:combo:live` | 處理中真實路由(`RUN_COMBO_LIVE=1`);快照即時 OmniRoute 資料庫 | +| `npm run test:combo:live:vps` | 對即時 OmniRoute 伺服器的 HTTP 呼叫(設定 `COMBO_LIVE_BASE_URL`) | +| `npm run test:combo:live:vps:failover` | 同上,但加入刻意觸發的容錯轉移情境 | -這些煙霧測試實際演練真實線路(組合 → 提供商 → 完成)。刻意排除在 CI 之外,因為它們需要真實憑證和 VPS 存取權限。 +這些煙霧測試實際演練真實線路(組合 → 提供者 → 完成)。刻意排除在 CI 之外,因為它們需要真實憑證和 VPS 存取權限。 --- ## 相關檔案 -| 檔案 | 用途 | -| :---------------------------------------------------------- | :--------------------------------------------- | -| `open-sse/services/autoCombo/scoring.ts` | 9 因子評分函數、`DEFAULT_WEIGHTS`、池正規化 | -| `open-sse/services/autoCombo/taskFitness.ts` | 模型 × 任務適應性查詢表 | -| `open-sse/services/autoCombo/engine.ts` | 選擇邏輯、bandit、預算上限 | -| `open-sse/services/autoCombo/selfHealing.ts` | 排除、探測、事故模式 | -| `open-sse/services/autoCombo/modePacks.ts` | 4 個權重設定檔(ship-fast, cost-saver, quality-first, offline-friendly) | -| `open-sse/services/autoCombo/autoPrefix.ts` | `auto/` 前綴解析器 + 6 個變體 | -| `open-sse/services/autoCombo/virtualFactory.ts` | 從即時連線建立記憶體中 `AutoComboConfig` | -| `open-sse/services/autoCombo/providerRegistryAccessor.ts` | 用於 mock 提供商註冊表的測試鉤子 | -| `src/shared/constants/routingStrategies.ts` | `ROUTING_STRATEGY_VALUES`(18 種策略) | -| `src/sse/handlers/chat.ts` | 整合點:自動前綴短路處理 | +| 檔案 | 用途 | +| :-------------------------------------------------------- | :----------------------------------------------------------------------- | +| `open-sse/services/autoCombo/scoring.ts` | 9 因子評分函數、`DEFAULT_WEIGHTS`、池正規化 | +| `open-sse/services/autoCombo/taskFitness.ts` | 模型 × 任務適應性查詢表 | +| `open-sse/services/autoCombo/engine.ts` | 選擇邏輯、bandit、預算上限 | +| `open-sse/services/autoCombo/selfHealing.ts` | 排除、探測、事故模式 | +| `open-sse/services/autoCombo/modePacks.ts` | 4 個權重設定檔(ship-fast, cost-saver, quality-first, offline-friendly) | +| `open-sse/services/autoCombo/autoPrefix.ts` | `auto/` 前綴解析器 + 6 個變體 | +| `open-sse/services/autoCombo/virtualFactory.ts` | 從即時連線建立記憶體中 `AutoComboConfig` | +| `open-sse/services/autoCombo/providerRegistryAccessor.ts` | 用於 mock 提供者註冊表的測試鉤子 | +| `src/shared/constants/routingStrategies.ts` | `ROUTING_STRATEGY_VALUES`(18 種策略) | +| `src/sse/handlers/chat.ts` | 整合點:自動前綴短路處理 | diff --git a/docs/providers/AGENTROUTER.md b/docs/providers/AGENTROUTER.md index 6f4e39b6b2..aa28834c75 100644 --- a/docs/providers/AGENTROUTER.md +++ b/docs/providers/AGENTROUTER.md @@ -130,7 +130,7 @@ request (see `open-sse/services/claudeCodeCompatible.ts`): | Header | Value | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `Authorization` | `Bearer ` | -| `User-Agent` | `claude-cli/2.1.207 (external, sdk-cli)` | +| `User-Agent` | `claude-cli/2.1.219 (external, sdk-cli)` | | `anthropic-version` | `2023-06-01` | | `anthropic-beta` | `claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24` | | Per-connection redact-thinking beta toggle | Adds `redact-thinking-2026-02-12` for upstreams that specifically require redacted thinking streams | diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 75bcf3d5fd..fbf41ea286 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -68,6 +68,7 @@ Content-Type: application/json | `X-OmniRoute-Progress` | Request | Set to `true` for progress events | | `X-Session-Id` | Request | Sticky session key for external session affinity | | `x_session_id` | Request | Underscore variant also accepted (direct HTTP) | +| `X-OmniRoute-Session-Id` | Request | Caller-supplied session/conversation tag (also feeds memory). When present, persisted verbatim to `call_logs.session_tag` for per-session cost attribution (#8249) — never synthesized when absent | | `Idempotency-Key` | Request | Dedup key (5s window) | | `X-Request-Id` | Request | Alternative dedup key | | `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) | @@ -343,6 +344,32 @@ Web/search provider abstraction (Tavily, Brave, Exa, Serper, etc.). --- +## Web Fetch API + +Extract content from a URL via a configured web-fetch provider (Firecrawl, Jina +Reader, Tavily Extract, TinyFish Fetch). + +| Method | Path | Description | +| ------ | -------------- | ------------------------------------------------------------------------- | +| POST | `/v1/web/fetch` | Fetch/scrape a URL — body validated by `v1WebFetchSchema` | + +**Auth:** Bearer API key (`extractApiKey` + `isValidApiKey`). Policy enforced via `enforceApiKeyPolicy`. + +**Quota-aware fallback (#8297):** when no explicit `provider` is given, the pool +(`firecrawl` → `jina-reader` → `tavily-search` → `tinyfish`) is walked in fixed +priority order (fill-first) — a rate-limited-but-configured provider is skipped +instead of short-circuiting the request, and a retryable/quota upstream failure +(HTTP 429 always; 402/403 for Firecrawl/Tavily/TinyFish quota-style free tiers — +not for Jina Reader, and never for a plain 400 bad request) falls through to the +next untried credentialed provider at request time. When every provider in the +pool is exhausted, the endpoint returns a single `429` (with a `Retry-After` +header) instead of the previous generic `400`. When an explicit `provider` is +requested, there is **no** silent fallback — a rate-limited or failing explicit +provider surfaces its own error (`429` if rate-limited, otherwise the upstream +status). + +--- + ## WebSocket Streaming ```bash diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index d3a5885bad..f2ebe214ea 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -528,7 +528,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | -------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.207 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | | `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | Set to `1`/`true` to forward third-party harness tool names verbatim to Anthropic on both Anthropic-bound paths (native OAuth and CLIProxyAPI). By default the executor deterministically aliases non-Claude-Code tool names (Claude Code canonical mapping where one exists, otherwise PascalCase) and reverses them on the response via `_toolNameMap`, so harnesses with snake_case tools are not refused as fingerprinted third-party clients. Debugging only. | | `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | diff --git a/docs/routing/AUTO-COMBO.md b/docs/routing/AUTO-COMBO.md index 07ecf36f24..424926507a 100644 --- a/docs/routing/AUTO-COMBO.md +++ b/docs/routing/AUTO-COMBO.md @@ -128,6 +128,37 @@ Auto-scoring selects best provider/model per request | `src/sse/handlers/chat.ts` | Integration: auto prefix short-circuit | | `src/shared/constants/providers.ts` | `SYSTEM_PROVIDERS.auto` system entry | +## Combo Names That Match a Real Model Id + +A combo whose `name` is identical to a bare model id (e.g. a combo named +`gpt-5.5`) is an **intentional, supported pattern**, not a bug: it is the +mechanism for per-model-id provider fallback documented in +[#6940](https://github.com/diegosouzapw/OmniRoute/issues/6940). Because combo +resolution is checked before bare-model-id resolution +(`getComboForModel()` in `src/sse/services/model.ts`), a request for the bare +id `gpt-5.5` is routed through the combo's targets (e.g. +`acme-responses/gpt-5.5`, `backup-responses/gpt-5.5`) instead of straight to +a single provider — this reuses the combo-before-rewrite precedence built for +[#3227/#3233](https://github.com/diegosouzapw/OmniRoute/issues/3227) and is +regression-tested by `tests/unit/responses-combo-resolution-3227.test.ts` and +`tests/unit/combo-name-codex-responses-rewrite.test.ts`. + +Creating or renaming a combo to a name that shadows a real model id is +**never rejected** — doing so would break this documented workflow. Instead +(#8530), `POST /api/combos` and `PUT /api/combos/[id]` attach a non-blocking +`warning` field to the response when the (new) name collides with a real +model id: + +```json +{ "warning": { "code": "COMBO_NAME_SHADOWS_MODEL", "modelId": "gpt-5.5", "providerId": "openai" } } +``` + +At boot, `scanComboModelNameCollisionsAtBoot()` +(`src/instrumentation-node.ts`) also logs a one-line `[STARTUP]` warning +enumerating every existing combo that shadows a model id, so operators who +hit this by accident (rather than intentionally, per #6940) have a signal. +The detection helper lives in `src/lib/combos/modelNameCollision.ts`. + ## How It Works (Persisted Auto-Combos) The Auto-Combo Engine dynamically selects the best provider/model for each request using a **12-factor scoring function** (defined in `open-sse/services/autoCombo/scoring.ts` → `DEFAULT_WEIGHTS`). All weights sum to **1.0**. diff --git a/docs/security/STEALTH_GUIDE.md b/docs/security/STEALTH_GUIDE.md index dd737cb764..07f2b7dc6a 100644 --- a/docs/security/STEALTH_GUIDE.md +++ b/docs/security/STEALTH_GUIDE.md @@ -88,9 +88,9 @@ Applied to: `system` blocks, all `messages[].content`, and `tools[].description` For third-party Anthropic relays that only accept "real Claude Code" traffic: -- `CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.207 (external, sdk-cli)"` +- `CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.219 (external, sdk-cli)"` - `CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0"` -- `CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0"` +- `CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v26.3.0"` - `anthropic-beta = "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24"` by default - The per-connection "Enable redact-thinking beta" toggle adds `redact-thinking-2026-02-12` when a CC Compatible upstream specifically requires redacted thinking streams - The per-connection "Enable summarized thinking display" toggle stores `providerSpecificData.requestDefaults.summarizeThinking` and adds `display: "summarized"` to CC Compatible thinking requests that did not already set a display mode @@ -212,7 +212,7 @@ All MITM endpoints require management auth (`requireCliToolsAuth`). The sudo pas | Variable | Default | | ------------------------ | --------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.207 (external, cli)` | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | | `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` | | `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0` | diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index fa8d1e2212..7b04eb6c45 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -1,3 +1,12 @@ +import { + CLAUDE_CODE_CLIENT_BILLING_VERSION, + CLAUDE_CODE_CLIENT_BUILD_REVISION, + CLAUDE_CODE_CLIENT_VERSION, + CLAUDE_CODE_RUNTIME_VERSION, + CLAUDE_CODE_SDK_PACKAGE_VERSION, + getClaudeCodeUserAgent, +} from "@/shared/constants/claudeCodeClient"; + export const ANTHROPIC_VERSION_HEADER = "2023-06-01"; const ANTHROPIC_BETA_BASE = Object.freeze([ @@ -121,7 +130,9 @@ export function normalizeAnthropicHeaderVariants(headers: Record } } -export const CLAUDE_CLI_VERSION = "2.1.207"; -export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`; -export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.94.0"; -export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = "v24.3.0"; +export const CLAUDE_CLI_VERSION = CLAUDE_CODE_CLIENT_VERSION; +export const CLAUDE_CLI_BUILD_REVISION = CLAUDE_CODE_CLIENT_BUILD_REVISION; +export const CLAUDE_CLI_BILLING_VERSION = CLAUDE_CODE_CLIENT_BILLING_VERSION; +export const CLAUDE_CLI_USER_AGENT = getClaudeCodeUserAgent("cli"); +export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = CLAUDE_CODE_SDK_PACKAGE_VERSION; +export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = CLAUDE_CODE_RUNTIME_VERSION; diff --git a/open-sse/config/claudeCodeCompatibleIdentity.ts b/open-sse/config/claudeCodeCompatibleIdentity.ts new file mode 100644 index 0000000000..31b7997f2a --- /dev/null +++ b/open-sse/config/claudeCodeCompatibleIdentity.ts @@ -0,0 +1,23 @@ +import { + CLAUDE_CODE_CLIENT_VERSION, + CLAUDE_CODE_RUNTIME_VERSION, + CLAUDE_CODE_SDK_PACKAGE_VERSION, + getClaudeCodeUserAgent, +} from "@/shared/constants/claudeCodeClient"; + +export const CLAUDE_CODE_COMPATIBLE_VERSION = CLAUDE_CODE_CLIENT_VERSION; +export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = getClaudeCodeUserAgent("sdk-cli"); +export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = CLAUDE_CODE_SDK_PACKAGE_VERSION; +export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = CLAUDE_CODE_RUNTIME_VERSION; +const CONTEXT_1M_NATIVE_MODELS = ["claude-opus-5"]; + +export function modelHasNativeContext1m(model: string | null | undefined): boolean { + const normalizedModel = String(model || "") + .trim() + .toLowerCase() + .replace(/-\d{8}$/, ""); + + return CONTEXT_1M_NATIVE_MODELS.some( + (supported) => normalizedModel === supported || normalizedModel.startsWith(`${supported}-`) + ); +} diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts index 951e9e550d..f8acfd4031 100644 --- a/open-sse/config/glmProvider.ts +++ b/open-sse/config/glmProvider.ts @@ -1,3 +1,5 @@ +import { getClaudeCodeUserAgent } from "@/shared/constants/claudeCodeClient"; + import { ANTHROPIC_VERSION_HEADER } from "./anthropicHeaders.ts"; type JsonRecord = Record; @@ -150,7 +152,7 @@ export const GLMT_REQUEST_DEFAULTS = Object.freeze({ }); export const GLM_COUNT_TOKENS_TIMEOUT_MS = 3_000; -export const GLM_CLAUDE_CODE_USER_AGENT = "claude-cli/2.1.207 (external, sdk-cli)"; +export const GLM_CLAUDE_CODE_USER_AGENT = getClaudeCodeUserAgent("sdk-cli"); export const GLM_ANTHROPIC_BETA = [ "claude-code-20250219", "interleaved-thinking-2025-05-14", diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index adf16477d0..70f6ce831c 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -258,11 +258,12 @@ export function getProviderCategory(provider: string): "oauth" | "apikey" { } /** - * Derive the latest opus/sonnet/haiku model IDs from the `claude` registry entry. + * Derive the latest fable/opus/sonnet/haiku model IDs from the `claude` registry entry. * Picks the first model whose ID matches each family pattern — registry order * determines precedence, so newer models should be listed first. */ export function getClaudeCodeDefaultModels(): { + fable: string; opus: string; sonnet: string; haiku: string; @@ -270,6 +271,7 @@ export function getClaudeCodeDefaultModels(): { const models = REGISTRY.claude?.models ?? []; const find = (pattern: RegExp) => models.find((m) => pattern.test(m.id))?.id ?? ""; return { + fable: find(/fable/i), opus: find(/opus/i), sonnet: find(/sonnet/i), haiku: find(/haiku/i), diff --git a/open-sse/config/providers/registry/anthropic/index.ts b/open-sse/config/providers/registry/anthropic/index.ts index 4ea19e8381..2a3726276d 100644 --- a/open-sse/config/providers/registry/anthropic/index.ts +++ b/open-sse/config/providers/registry/anthropic/index.ts @@ -16,6 +16,20 @@ export const anthropicProvider: RegistryEntry = { "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ + { + id: "claude-fable-5", + name: "Claude Fable 5", + contextLength: 1048576, + unsupportedParams: ["temperature", "top_p", "top_k"], + }, + { + id: "claude-opus-5", + name: "Claude Opus 5", + contextLength: 1000000, + maxOutputTokens: 128000, + supportsXHighEffort: true, + unsupportedParams: ["temperature", "top_p", "top_k"], + }, { id: "claude-opus-4.7", name: "Claude Opus 4.7", @@ -30,12 +44,6 @@ export const anthropicProvider: RegistryEntry = { // Opus 4.7+ (incl. 4.8, Fable 5) reject non-default sampling with a 400. Mirrors claude-opus-4.7. unsupportedParams: ["temperature", "top_p", "top_k"], }, - { - id: "claude-fable-5", - name: "Claude Fable 5", - contextLength: 1048576, - unsupportedParams: ["temperature", "top_p", "top_k"], - }, { id: "claude-opus-4.6", name: "Claude Opus 4.6" }, { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, { diff --git a/open-sse/config/providers/registry/claude/index.ts b/open-sse/config/providers/registry/claude/index.ts index d142872de8..481c831c2f 100644 --- a/open-sse/config/providers/registry/claude/index.ts +++ b/open-sse/config/providers/registry/claude/index.ts @@ -37,6 +37,14 @@ export const claudeProvider: RegistryEntry = { // is fixed; reasoning is steered by output_config.effort). Strip them before dispatch. unsupportedParams: ["temperature", "top_p", "top_k"], }, + { + id: "claude-opus-5", + name: "Claude Opus 5", + contextLength: 1000000, + maxOutputTokens: 128000, + supportsXHighEffort: true, + unsupportedParams: ["temperature", "top_p", "top_k"], + }, { id: "claude-opus-4-8", name: "Claude Opus 4.8", diff --git a/open-sse/config/providers/registry/claude/web/index.ts b/open-sse/config/providers/registry/claude/web/index.ts index f3a62ca676..c701ff9aa3 100644 --- a/open-sse/config/providers/registry/claude/web/index.ts +++ b/open-sse/config/providers/registry/claude/web/index.ts @@ -10,6 +10,15 @@ export const claude_webProvider: RegistryEntry = { authHeader: "cookie", models: [ { id: "claude-fable-5", name: "Claude Fable 5 (web)", toolCalling: false }, + { + id: "claude-opus-5", + name: "Claude Opus 5 (web)", + toolCalling: false, + supportsReasoning: true, + supportsVision: true, + contextLength: 1000000, + maxOutputTokens: 128000, + }, { id: "claude-opus-4-8", name: "Claude Opus 4.8 (web)", toolCalling: false }, { id: "claude-opus-4-7", name: "Claude Opus 4.7 (web)", toolCalling: false }, { id: "claude-opus-4-6", name: "Claude Opus 4.6 (web)", toolCalling: false }, diff --git a/open-sse/config/providers/registry/ghe-copilot/index.ts b/open-sse/config/providers/registry/ghe-copilot/index.ts index fb5c6ca438..5e88116d69 100644 --- a/open-sse/config/providers/registry/ghe-copilot/index.ts +++ b/open-sse/config/providers/registry/ghe-copilot/index.ts @@ -33,6 +33,13 @@ export const gheCopilotProvider: RegistryEntry = { contextLength: 1000000, maxOutputTokens: 64000, }, + { + id: "claude-opus-5", + name: "Claude Opus 5", + contextLength: 1000000, + maxOutputTokens: 64000, + unsupportedParams: ["temperature", "top_p", "top_k"], + }, { id: "claude-opus-4.8-fast", name: "Claude Opus 4.8 (fast mode)", diff --git a/open-sse/config/providers/registry/github/index.ts b/open-sse/config/providers/registry/github/index.ts index 1dc6a22764..9513afdc1e 100644 --- a/open-sse/config/providers/registry/github/index.ts +++ b/open-sse/config/providers/registry/github/index.ts @@ -43,6 +43,14 @@ export const githubProvider: RegistryEntry = { contextLength: 1000000, maxOutputTokens: 64000, }, + { + id: "claude-opus-5", + name: "Claude Opus 5", + targetFormat: "claude", + contextLength: 1000000, + maxOutputTokens: 64000, + unsupportedParams: ["temperature", "top_p", "top_k"], + }, { id: "claude-opus-4.8-fast", name: "Claude Opus 4.8 (fast mode)", diff --git a/open-sse/config/providers/registry/hyperagent/index.ts b/open-sse/config/providers/registry/hyperagent/index.ts index b271205729..b7c1efe62e 100644 --- a/open-sse/config/providers/registry/hyperagent/index.ts +++ b/open-sse/config/providers/registry/hyperagent/index.ts @@ -4,6 +4,9 @@ import { HYPERAGENT_FALLBACK_MODELS } from "../../../../services/hyperagentModel // HyperAgent (hyperagent.com) — unofficial reverse-engineered web session. // Auth: browser Cookie header. Chat: POST /api/threads/{id}/chat (SSE). // Credits: GET /api/settings/billing/usage → creditData.creditBlocks. +/** Claude-family agent models on HyperAgent — 1M context (not the 128k openai default). */ +const HYPERAGENT_CONTEXT_LENGTH = 1_000_000; + export const hyperagentProvider: RegistryEntry = { id: "hyperagent", alias: "ha", @@ -13,8 +16,11 @@ export const hyperagentProvider: RegistryEntry = { authType: "apikey", authHeader: "cookie", passthroughModels: true, + /** Used by getTokenLimit when model-specific context is missing. */ + defaultContextLength: HYPERAGENT_CONTEXT_LENGTH, models: HYPERAGENT_FALLBACK_MODELS.map((m) => ({ id: m.id, name: m.name, + contextLength: HYPERAGENT_CONTEXT_LENGTH, })), }; diff --git a/open-sse/config/providers/registry/opencode/go/index.ts b/open-sse/config/providers/registry/opencode/go/index.ts index e3dd4b4d80..4ead5d8823 100644 --- a/open-sse/config/providers/registry/opencode/go/index.ts +++ b/open-sse/config/providers/registry/opencode/go/index.ts @@ -28,6 +28,9 @@ export const opencode_goProvider: RegistryEntry = { { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, { id: "kimi-k2.6", name: "Kimi K2.6" }, { id: "kimi-k2.5", name: "Kimi K2.5" }, + // #8353: Kimi K3 base + max-effort alias from the OpenCode Go registry. + { id: "kimi-k3", name: "Kimi K3", supportsReasoning: true }, + { id: "kimi-k3-max", name: "Kimi K3 (max effort)", supportsReasoning: true }, // MiMo-V2.5 — base model + effort-tier aliases (#6922). { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", supportsReasoning: true }, { id: "mimo-v2.5", name: "MiMo-V2.5", supportsReasoning: true }, @@ -50,10 +53,69 @@ export const opencode_goProvider: RegistryEntry = { // Issue #2822: These models are text-only — mark supportsVision: false // so combo routing skips them when the request contains image blocks, // preventing image content from reaching a vision-incapable upstream. + // #8353: effort-tier aliases from the OpenCode Go registry. { id: "qwen3.7-max", name: "Qwen3.7 Max", targetFormat: "claude", supportsVision: false }, + { + id: "qwen3.7-max-high", + name: "Qwen3.7 Max (high effort)", + targetFormat: "claude", + supportsVision: false, + supportsReasoning: true, + }, + { + id: "qwen3.7-max-max", + name: "Qwen3.7 Max (max effort)", + targetFormat: "claude", + supportsVision: false, + supportsReasoning: true, + }, + { + id: "qwen3.7-plus", + name: "Qwen3.7 Plus", + targetFormat: "claude", + supportsVision: false, + }, + { + id: "qwen3.7-plus-high", + name: "Qwen3.7 Plus (high effort)", + targetFormat: "claude", + supportsVision: false, + supportsReasoning: true, + }, + { + id: "qwen3.7-plus-max", + name: "Qwen3.7 Plus (max effort)", + targetFormat: "claude", + supportsVision: false, + supportsReasoning: true, + }, { id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false }, + { + id: "qwen3.6-plus-high", + name: "Qwen3.6 Plus (high effort)", + targetFormat: "claude", + supportsVision: false, + supportsReasoning: true, + }, + { + id: "qwen3.6-plus-max", + name: "Qwen3.6 Plus (max effort)", + targetFormat: "claude", + supportsVision: false, + supportsReasoning: true, + }, { id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false }, + // #8353: hy3 is the Go-tier base id (distinct from hy3-preview / hy3-free). + { id: "hy3", name: "Hunyuan3", supportsReasoning: true }, + { id: "hy3-none", name: "Hunyuan3 (none effort)", supportsReasoning: true }, + { id: "hy3-low", name: "Hunyuan3 (low effort)", supportsReasoning: true }, + { id: "hy3-high", name: "Hunyuan3 (high effort)", supportsReasoning: true }, { id: "hy3-preview", name: "Hunyuan3 Preview" }, + // #8353: Grok 4.5 + effort tiers from the OpenCode Go registry. + { id: "grok-4.5", name: "Grok 4.5", supportsReasoning: true }, + { id: "grok-4.5-low", name: "Grok 4.5 (low effort)", supportsReasoning: true }, + { id: "grok-4.5-medium", name: "Grok 4.5 (medium effort)", supportsReasoning: true }, + { id: "grok-4.5-high", name: "Grok 4.5 (high effort)", supportsReasoning: true }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, // OpencodeExecutor rewrites these aliases to the canonical upstream id and injects reasoning_effort. { id: "deepseek-v4-pro-low", name: "DeepSeek V4 Pro (low effort)", supportsReasoning: true }, @@ -65,5 +127,16 @@ export const opencode_goProvider: RegistryEntry = { { id: "deepseek-v4-pro-high", name: "DeepSeek V4 Pro (high effort)", supportsReasoning: true }, { id: "deepseek-v4-pro-max", name: "DeepSeek V4 Pro (max effort)", supportsReasoning: true }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, + // #8353: DeepSeek V4 Flash effort tiers from the OpenCode Go registry. + { + id: "deepseek-v4-flash-high", + name: "DeepSeek V4 Flash (high effort)", + supportsReasoning: true, + }, + { + id: "deepseek-v4-flash-max", + name: "DeepSeek V4 Flash (max effort)", + supportsReasoning: true, + }, ], }; diff --git a/open-sse/config/providers/registry/qwen/web/index.ts b/open-sse/config/providers/registry/qwen/web/index.ts index 3e3ae455a3..8bc7b47ed1 100644 --- a/open-sse/config/providers/registry/qwen/web/index.ts +++ b/open-sse/config/providers/registry/qwen/web/index.ts @@ -19,7 +19,7 @@ export const qwen_webProvider: RegistryEntry = { { id: "qwen3.8-max-preview", name: "Qwen3.8 Max Preview", - toolCalling: true, + toolCalling: false, supportsReasoning: true, supportsVision: true, contextLength: 1_000_000, @@ -28,7 +28,7 @@ export const qwen_webProvider: RegistryEntry = { { id: "qwen3.7-max", name: "Qwen3.7 Max", - toolCalling: true, + toolCalling: false, supportsReasoning: true, supportsVision: false, contextLength: 1_000_000, @@ -37,7 +37,7 @@ export const qwen_webProvider: RegistryEntry = { { id: "qwen3.7-plus", name: "Qwen3.7 Plus", - toolCalling: true, + toolCalling: false, supportsReasoning: true, supportsVision: true, contextLength: 1_000_000, @@ -46,7 +46,7 @@ export const qwen_webProvider: RegistryEntry = { { id: "qwen3.6-plus", name: "Qwen3.6 Plus", - toolCalling: true, + toolCalling: false, supportsReasoning: true, supportsVision: true, contextLength: 1_000_000, diff --git a/open-sse/executors/adobe-firefly.ts b/open-sse/executors/adobe-firefly.ts index 8b0ec194c3..fc8c462bfe 100644 --- a/open-sse/executors/adobe-firefly.ts +++ b/open-sse/executors/adobe-firefly.ts @@ -23,7 +23,7 @@ export class AdobeFireflyExecutor extends BaseExecutor { return makeExecutorErrorResult( 400, "adobe-firefly is a media-generation provider and does not support chat completions. " + - "Use POST /v1/images/generations (e.g. model \"adobe-firefly/nano-banana-pro\") " + + "Use POST /v1/images/generations or /v1/images/edits (e.g. model \"adobe-firefly/nano-banana-pro\") " + "or POST /v1/videos/generations (e.g. model \"adobe-firefly/sora-2\").", _input.body, ADOBE_FIREFLY_BASE_URL diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 26e2c69edf..ada9cd9c29 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -23,6 +23,7 @@ import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/cr import { setConnectionRateLimitUntil } from "@/lib/db/providers"; import { getMitmAlias } from "@/lib/db/models"; import { ensureAntigravityProjectAssigned } from "../services/antigravityProjectBootstrap.ts"; +import { persistDiscoveredAntigravityProjectId } from "../services/antigravityProjectPersist.ts"; import { resolveAntigravityModelId, getAntigravityModelFallbacks, @@ -541,7 +542,16 @@ export class AntigravityExecutor extends BaseExecutor { getAntigravityClientProfile(credentials), signal ); - if (discovered) projectId = discovered; + if (discovered) { + projectId = discovered; + // #8491: persist the recovered id so it survives the next token refresh + // or process restart instead of being silently rediscovered every time. + await persistDiscoveredAntigravityProjectId( + credentials.connectionId, + discovered, + credentials.providerSpecificData + ); + } } if (!projectId) { diff --git a/open-sse/executors/auggie.ts b/open-sse/executors/auggie.ts index f3c98c8d8f..6443f73162 100644 --- a/open-sse/executors/auggie.ts +++ b/open-sse/executors/auggie.ts @@ -149,6 +149,19 @@ export async function initAuggieModels( type AuggieModelResolution = { ok: true; model: string } | { ok: false; error: string }; +/** + * This workspace compiles with `strictNullChecks: false`, where a boolean-literal + * discriminant narrows the positive branch but not the negative one — so `!r.ok` alone + * leaves `r` as the full union and reading `.error` fails. An explicit type predicate + * narrows under those settings without retagging the union (which is public API here: + * `resolveAuggieModel` is exported and its tests deep-equal the `{ ok: true, ... }` shape). + */ +function isAuggieModelFailure( + resolution: AuggieModelResolution +): resolution is Extract { + return !resolution.ok; +} + /** * Validate + resolve the requested model against the registry allowlist. * Rejects flag-smuggling (leading "-") and any id not declared in the registry. @@ -384,7 +397,7 @@ export class AuggieExecutor extends BaseExecutor { await initAuggieModels(signal); // Argument-injection defense: never forward an unvalidated model into the argv. const modelResolution = resolveAuggieModel(model); - if (!modelResolution.ok) { + if (isAuggieModelFailure(modelResolution)) { const response = wantsStream ? buildAuggieSseError(modelResolution.error) : errorResponse(400, modelResolution.error); diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 3aa1e7ce07..7972084afa 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,5 +1,7 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { + CLAUDE_CLI_BILLING_VERSION, + CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, mergeClientAnthropicBeta, normalizeAnthropicHeaderVariants, } from "../config/anthropicHeaders.ts"; @@ -45,6 +47,7 @@ import { appendAnthropicBetaHeader, CONTEXT_1M_BETA_HEADER, enforceThinkingTemperature, + modelHasNativeContext1m, modelSupportsContext1mBeta, } from "../services/claudeCodeCompatible.ts"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; @@ -67,7 +70,6 @@ import { randomUUID } from "node:crypto"; import { CLAUDE_CODE_VERSION, CLAUDE_CODE_STAINLESS_VERSION, - buildHashFor, buildUserIdJson, getSessionId, parseUpstreamMetadataUserId, @@ -77,7 +79,6 @@ import { selectBetaFlags, stainlessArch, stainlessOS, - stainlessRuntimeVersion, stripProxyToolPrefix, } from "./claudeIdentity.ts"; import { withForcedResponsesUpstream } from "./forceResponsesUpstream.ts"; @@ -253,6 +254,25 @@ export function stripVersionedToolModelPrefix(tools: unknown): void { * Implements the Strategy pattern: subclasses override specific methods * (buildUrl, buildHeaders, transformRequest, etc.) for each provider. */ +/** + * What an executor's `execute()` may resolve to. + * + * Both arms are real: the web/scraping executors return a bare `Response` from their + * error and passthrough paths, while the HTTP executors return the richer capture + * object used for upstream request logging. `normalizeExecutorResult()` accepts + * exactly this union and wraps the bare form, so the contract is the union — not the + * object shape that `BaseExecutor.execute` happens to infer from its single return. + */ +export type ExecutorExecuteResult = + | Response + | { + response: Response; + url?: string; + headers?: Record; + transformedBody?: unknown; + transport?: string; + }; + export class BaseExecutor { provider: string; config: ProviderConfig; @@ -352,12 +372,15 @@ export class BaseExecutor { (credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? []; const selectedKeyId = (credentials.providerSpecificData as Record | undefined) ?.selectedKeyId as string | undefined; + const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0); let effectiveKey = credentials.apiKey; - if (extraKeys.length > 0 && credentials.connectionId && credentials.apiKey) { + // Rotate whenever extras exist — including empty primary + populated extras (#8467). + // getValidApiKey already skips a blank primary and round-robins the extras alone. + if (validExtras.length > 0 && credentials.connectionId) { const resolved = resolveKeyForRequest( credentials.connectionId, - credentials.apiKey, - extraKeys, + credentials.apiKey || "", + validExtras, selectedKeyId ?? null ); effectiveKey = resolved?.key ?? credentials.apiKey; @@ -412,7 +435,7 @@ export class BaseExecutor { if (credentials.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; - } else if (credentials.apiKey) { + } else if (effectiveKey) { headers["Authorization"] = `Bearer ${effectiveKey}`; } @@ -581,7 +604,7 @@ export class BaseExecutor { } } - async execute(input: ExecuteInput) { + async execute(input: ExecuteInput): Promise { const { model, body, @@ -728,7 +751,9 @@ export class BaseExecutor { modelSupportsContext1mBeta(model) && !isClaudeCodeCompatible(this.provider); const shouldForwardCcCompatibleContext1m = - isClaudeCodeCompatible(this.provider) && ccRequestDefaults.context1m === true; + isClaudeCodeCompatible(this.provider) && + ccRequestDefaults.context1m === true && + !modelHasNativeContext1m(model); if (shouldForwardExtendedContext || shouldForwardCcCompatibleContext1m) { appendAnthropicBetaHeader(headers, CONTEXT_1M_BETA_HEADER); } @@ -991,9 +1016,7 @@ export class BaseExecutor { // system[0] (billing) and system[1] (sentinel) must not carry // cache_control — that belongs on upstream prompt blocks at [2..]. - const dayStamp = new Date().toISOString().slice(0, 10); - const buildHash = buildHashFor(CLAUDE_CODE_VERSION, dayStamp); - const billingLine = `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_VERSION}.${buildHash}; cc_entrypoint=cli; cch=00000;`; + const billingLine = `x-anthropic-billing-header: cc_version=${CLAUDE_CLI_BILLING_VERSION}; cc_entrypoint=cli; cch=00000;`; const SENTINEL = "You are Claude Code, Anthropic's official CLI for Claude."; const sysBlocks: Array> = Array.isArray(tb.system) @@ -1081,13 +1104,13 @@ export class BaseExecutor { Object.assign(headers, ccHeaders); delete headers["X-Stainless-Helper-Method"]; - // Stainless OS/Arch/Runtime are host-derived (Stainless SDK does the - // same at runtime). Hardcoding them was a unique-per-deployment tell. + // OS/arch follow the host running the signed binary. Runtime version + // is pinned to the captured CLI wire image, not OmniRoute's Node. headers["X-Stainless-Arch"] = stainlessArch(); headers["X-Stainless-Lang"] = "js"; headers["X-Stainless-OS"] = stainlessOS(); headers["X-Stainless-Runtime"] = "node"; - headers["X-Stainless-Runtime-Version"] = stainlessRuntimeVersion(); + headers["X-Stainless-Runtime-Version"] = CLAUDE_CLI_STAINLESS_RUNTIME_VERSION; headers["X-Stainless-Retry-Count"] = "0"; delete headers["X-Stainless-Os"]; diff --git a/open-sse/executors/claude-web/payload.ts b/open-sse/executors/claude-web/payload.ts index f933f977c1..74a88ab1b4 100644 --- a/open-sse/executors/claude-web/payload.ts +++ b/open-sse/executors/claude-web/payload.ts @@ -176,6 +176,7 @@ export function wantsExtendedThinking(body: Record): boolean { } const CLAUDE_WEB_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; +const CLAUDE_WEB_OPUS_5_MODEL = "claude-opus-5"; /** * Resolve the caller's explicit reasoning level into the effort values @@ -251,12 +252,26 @@ function createConversationParams(model: string) { }; } +function resolveClaudeWebThinkingMode( + model: string, + reasoningEffort: ReturnType +): { effort: string; thinkingMode: string } { + if (model.trim().toLowerCase() === CLAUDE_WEB_OPUS_5_MODEL) { + return { effort: reasoningEffort ?? "high", thinkingMode: "auto" }; + } + if (reasoningEffort) { + return { effort: reasoningEffort, thinkingMode: "extended" }; + } + return { effort: "low", thinkingMode: "off" }; +} + function buildClaudeWebPayload( body: Record, model: string, reasoningEffort: ReturnType, turn: ClaudeWebTurnFields ): ClaudeWebRequestPayload { + const thinking = resolveClaudeWebThinkingMode(model, reasoningEffort); return { prompt: turn.prompt, model, @@ -270,11 +285,11 @@ function buildClaudeWebPayload( }, ...(turn.parentMessageUuid ? { parent_message_uuid: turn.parentMessageUuid } : {}), attachments: [], - effort: reasoningEffort ?? "low", + effort: thinking.effort, files: [], sync_sources: [], rendering_mode: "messages", - thinking_mode: reasoningEffort ? "extended" : "off", + thinking_mode: thinking.thinkingMode, ...(turn.toolStates ? { tool_states: turn.toolStates } : {}), ...(turn.isNewConversation ? { create_conversation_params: createConversationParams(model) } diff --git a/open-sse/executors/claude-web/stream.ts b/open-sse/executors/claude-web/stream.ts index e880cace9d..617a0d99fa 100644 --- a/open-sse/executors/claude-web/stream.ts +++ b/open-sse/executors/claude-web/stream.ts @@ -182,6 +182,14 @@ function deltaText(delta: Record, fields: string[]): string { throw new ClaudeWebProtocolError("Content delta text is invalid"); } +function thinkingSummaryText(delta: Record): string { + if (typeof delta.summary === "string") return delta.summary; + if (delta.summary && typeof delta.summary === "object" && !Array.isArray(delta.summary)) { + return deltaText(delta.summary as Record, ["summary", "text", "thinking"]); + } + return deltaText(delta, ["text", "thinking"]); +} + interface ProtocolState { phase: StreamPhase; openBlocks: Map; @@ -265,7 +273,7 @@ function handleContentBlockDelta( return { kind: "reasoning", text: deltaText(delta, ["thinking", "text"]) }; } if (delta.type === "thinking_summary_delta" && block === "thinking") { - return { kind: "reasoning", text: deltaText(delta, ["summary", "text", "thinking"]) }; + return { kind: "reasoning", text: thinkingSummaryText(delta) }; } return protocolFailure(state, "Content delta type does not match its block"); } diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index f8a94a32ce..c9544c6743 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -10,11 +10,16 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { + CLAUDE_CODE_CLIENT_VERSION, + CLAUDE_CODE_SDK_PACKAGE_VERSION, +} from "@/shared/constants/claudeCodeClient"; + // ---------- Versions ------------------------------------------------------ -export const CLAUDE_CODE_VERSION = "2.1.207"; +export const CLAUDE_CODE_VERSION = CLAUDE_CODE_CLIENT_VERSION; /** Bundled @anthropic-ai/sdk version for the pinned CLI release. */ -export const CLAUDE_CODE_STAINLESS_VERSION = "0.94.0"; +export const CLAUDE_CODE_STAINLESS_VERSION = CLAUDE_CODE_SDK_PACKAGE_VERSION; // ---------- Stainless OS / Arch / Runtime -------------------------------- @@ -157,16 +162,19 @@ export async function fetchClaudeBootstrap(accessToken: string): Promise null); - const acct = data?.oauth_account; + const data: unknown = await res.json().catch(() => null); + const acct = + data && typeof data === "object" ? (data as Record).oauth_account : null; if (!acct || typeof acct !== "object") return null; + const account = acct as Record; + const stringOrNull = (value: unknown) => (typeof value === "string" ? value : null); return { - account_uuid: acct.account_uuid || null, - account_email: acct.account_email || null, - organization_uuid: acct.organization_uuid || null, - organization_name: acct.organization_name || null, - organization_type: acct.organization_type || null, - organization_rate_limit_tier: acct.organization_rate_limit_tier || null, + account_uuid: stringOrNull(account.account_uuid), + account_email: stringOrNull(account.account_email), + organization_uuid: stringOrNull(account.organization_uuid), + organization_name: stringOrNull(account.organization_name), + organization_type: stringOrNull(account.organization_type), + organization_rate_limit_tier: stringOrNull(account.organization_rate_limit_tier), }; } catch { return null; @@ -261,14 +269,14 @@ export function parseUpstreamMetadataUserId( const md = body.metadata as Record | undefined; const raw = md?.user_id; if (typeof raw !== "string" || raw.length === 0) return null; - let parsed: any; + let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return null; } if (!parsed || typeof parsed !== "object") return null; - const { device_id, account_uuid, session_id } = parsed; + const { device_id, account_uuid, session_id } = parsed as Record; if ( typeof device_id !== "string" || !HEX64_RE.test(device_id) || @@ -293,8 +301,10 @@ const HEAVY_AGENT_BETA_MODEL_PREFIXES = ["claude-opus", "claude-sonnet"]; /** * Models that support the context-1m beta tier. Only Opus is eligible; * Sonnet trips long-context credit gates under OAuth full-agent traffic. + * Opus 5 is excluded because its 1M context window is native. */ const CONTEXT_1M_BETA_MODEL_PREFIXES = ["claude-opus"]; +const CONTEXT_1M_NATIVE_MODEL_PREFIXES = ["claude-opus-5"]; function matchesModelPrefix(model: unknown, prefixes: string[]): boolean { if (typeof model !== "string") return false; @@ -307,7 +317,10 @@ function isHeavyAgentModel(model: unknown): boolean { } function isContext1mModel(model: unknown): boolean { - return matchesModelPrefix(model, CONTEXT_1M_BETA_MODEL_PREFIXES); + return ( + matchesModelPrefix(model, CONTEXT_1M_BETA_MODEL_PREFIXES) && + !matchesModelPrefix(model, CONTEXT_1M_NATIVE_MODEL_PREFIXES) + ); } /** @@ -360,14 +373,15 @@ export function selectBetaFlags( const isFullAgent = hasTools && hasSystem; const effectiveModel = model ?? (typeof b.model === "string" ? b.model : ""); const isHeavyAgent = isFullAgent && isHeavyAgentModel(effectiveModel); + const isOpusAgent = + isFullAgent && matchesModelPrefix(effectiveModel, CONTEXT_1M_BETA_MODEL_PREFIXES); const isContext1m = isFullAgent && isContext1mModel(effectiveModel); const flags: string[] = []; if (isFullAgent) flags.push("claude-code-20250219"); flags.push("oauth-2025-04-20"); - if (isContext1m) { - flags.push("context-1m-2025-08-07", "mid-conversation-system-2026-04-07"); - } + if (isContext1m) flags.push("context-1m-2025-08-07"); + if (isOpusAgent) flags.push("mid-conversation-system-2026-04-07"); // Thinking betas: gated on the client header (#3415). interleaved-thinking forces // interleaved-thinking semantics that conflict with a tool_choice-forced turn, // producing malformed opus tool_use streams when the client never asked for it. @@ -392,18 +406,6 @@ export function selectBetaFlags( return flags.join(","); } -// ---------- billing-header build hash ------------------------------------ - -/** - * 3-char build hash for the billing header `cc_version=X.Y.Z.HASH`. Stable - * per (day, version) — Anthropic does not appear to validate the value, so - * we keep prompt-cache prefix stable within a day for a given version - * without coupling to any captured value. - */ -export function buildHashFor(version: string, dayStamp: string): string { - return createHash("sha256").update(`${dayStamp}${version}`).digest("hex").slice(0, 3); -} - // ---------- Tool-name normalisation -------------------------------------- const TOOL_PREFIX = "proxy_"; diff --git a/open-sse/executors/devin-cli.ts b/open-sse/executors/devin-cli.ts index 1f5b307ab8..205256feeb 100644 --- a/open-sse/executors/devin-cli.ts +++ b/open-sse/executors/devin-cli.ts @@ -274,9 +274,12 @@ export class DevinCliExecutor extends BaseExecutor { // ── Initialize response ─────────────────────────────────────── if (!initDone && msg.result !== undefined && !msg.method) { initDone = true; - // Create session: send session/new with model and a temp cwd + // Create session: send session/new with model and a temp cwd. + // `mcpServers` is NOT optional — devin CLI 3000.2.x rejects the + // request with -32602 `missing field \`mcpServers\`` if omitted. sendRpc("session/new", { cwd: process.cwd(), + mcpServers: [], model: model || undefined, }); continue; @@ -295,25 +298,68 @@ export class DevinCliExecutor extends BaseExecutor { promptSent = true; sendRpc("session/prompt", { sessionId, - content: [{ type: "text", text: promptText }], + // ACP names this field `prompt`; `content` is rejected with + // -32602 `missing field \`prompt\`` by devin CLI 3000.2.x. + prompt: [{ type: "text", text: promptText }], }); continue; } - // ── session/prompt response (ack) ───────────────────────────── - if (sessionCreated && promptSent && msg.result !== undefined && !msg.method) { - // Acknowledged — streaming notifications will follow - continue; - } + // NOTE: `session/prompt` is a unary call — its response IS the end of + // the turn (it carries `stopReason`), not an ack. Swallowing it here + // used to hang the request until the client timed out, so the final + // result is handled by the branch further down instead. // ── Streaming notifications (session/update) ────────────────── if (msg.method === "session/update" || msg.method === "$/update") { const params = msg.params as Record | undefined; if (!params) continue; - const type = params.type as string | undefined; + // devin CLI 3000.2.x nests the payload: + // params.update = { sessionUpdate: "agent_message_chunk", + // content: { type: "text", text: "…" } } + // Older/other ACP agents use a flat `params.type` + `params.content`. + const update = params.update as Record | undefined; + const kind = (update?.sessionUpdate as string | undefined) ?? undefined; + const type = kind ?? (params.type as string | undefined); - if (type === "message_delta" || type === "text_delta" || type === "content_delta") { + if (kind === "agent_message_chunk") { + const delta = extractChunkText(update?.content); + if (delta) { + if (!roleEmitted) { + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { role: "assistant", content: "" }, + finish_reason: null, + }, + ], + })}\n\n` + ); + roleEmitted = true; + } + totalText += delta; + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: delta }, finish_reason: null }], + })}\n\n` + ); + } + } else if ( + type === "message_delta" || + type === "text_delta" || + type === "content_delta" + ) { const delta = (params.content as string) || (params.delta as string) || @@ -449,6 +495,20 @@ export class DevinCliExecutor extends BaseExecutor { // ─── Helpers ───────────────────────────────────────────────────────────────── /** Try to extract text from a final ACP session/prompt result object. */ +/** + * Pull display text out of an ACP `session/update` content payload, which may be + * a bare string, a single `{type:"text", text}` block, or an array of blocks. + */ +function extractChunkText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) return content.map((c) => extractChunkText(c)).join(""); + if (content && typeof content === "object") { + const text = (content as Record).text; + if (typeof text === "string") return text; + } + return ""; +} + function extractResultText(result: Record): string { // Common result shapes: // { message: { content: "..." } } diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index a7b1a0ec0d..bcf23669a0 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -429,12 +429,13 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { } } - async execute(input: ExecuteInput): Promise<{ - response: Response; - url: string; - headers: Record; - transformedBody: unknown; - }> { + // No explicit return type, matching BaseExecutor and the other ~38 executors: this + // method legitimately returns either a bare `Response` (error paths, processResponse) + // or the richer `{ response, url, headers, transformedBody }` capture object. + // `normalizeExecutorResult()` accepts exactly that union and wraps the bare form, so + // pinning the signature to only the object shape was wrong — it reported 14 valid + // `return` statements as errors. + async execute(input: ExecuteInput) { const { model, body, stream, signal, upstreamExtraHeaders } = input; const upstreamModel = normalizeDuckDuckGoModel(model); const bodyObj = (body || {}) as Record; diff --git a/open-sse/executors/gemini-business.ts b/open-sse/executors/gemini-business.ts index 9dadc37875..ea68969582 100644 --- a/open-sse/executors/gemini-business.ts +++ b/open-sse/executors/gemini-business.ts @@ -150,13 +150,18 @@ export class GeminiBusinessExecutor extends BaseExecutor { headers["Authorization"] = computeSapisidHash(sapisid, baseOrigin); } + // Cap the upstream call, and honor the caller's cancellation when there is one. + // `ExecuteInput.signal` is optional while mergeAbortSignals() needs two real signals, + // so fall back to the timeout alone — same guard the other web executors use. + const timeoutSignal = AbortSignal.timeout(GEMINI_BUSINESS_FETCH_TIMEOUT_MS); + let response: Response; try { response = await fetch(streamUrl, { method: "POST", headers, body: formBody.toString(), - signal: combineAbortSignals(signal, AbortSignal.timeout(GEMINI_BUSINESS_FETCH_TIMEOUT_MS)), + signal: signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal, }); } catch (err) { const message = err instanceof Error ? err.message : "fetch failed"; diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index bbb6bb3641..52b4cb845b 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -8,6 +8,26 @@ import { import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; import { stripUnsupportedParams } from "../translator/paramSupport.ts"; +/** + * What a Copilot credential refresh resolves to. + * + * `refreshCredentials()` returns one of three shapes — the raw GitHub token pair, that pair + * plus the minted Copilot token, or the Copilot token folded onto the existing credentials — + * and `null` when nothing could be refreshed. Left to inference, the union of those literals + * is narrower than the contract subclasses actually honor: `GheCopilotExecutor` carries a + * wider `providerSpecificData` (it also records the enterprise proxy URL) and omits + * `expiresIn`, which made a valid override fail with TS2416. Every field is therefore + * optional here — callers already treat them as such. + */ +export interface RefreshedCopilotCredentials { + accessToken?: string; + refreshToken?: string; + expiresIn?: number; + copilotToken?: string; + copilotTokenExpiresAt?: string | number; + providerSpecificData?: Record; +} + export class GithubExecutor extends BaseExecutor { constructor() { super("github", PROVIDERS.github); @@ -250,7 +270,10 @@ export class GithubExecutor extends BaseExecutor { async execute(input: ExecuteInput) { const result = await super.execute(input); - if (!result || !result.response) return result; + // BaseExecutor.execute() is typed as the union it contracts for; the bare-Response + // arm has nothing to materialize, which is what the existing `!result.response` + // guard already meant. + if (result instanceof Response || !result?.response) return result; if (!input.stream) { // wreq-js clone/text semantics consume the original response body. Materialize @@ -364,7 +387,7 @@ export class GithubExecutor extends BaseExecutor { } } - async refreshCredentials(credentials, log) { + async refreshCredentials(credentials, log): Promise { let copilotResult = await this.refreshCopilotToken(credentials.accessToken, log); if (!copilotResult && credentials.refreshToken) { diff --git a/open-sse/executors/hailuo-web.ts b/open-sse/executors/hailuo-web.ts index b6c85eb9df..1d9ffda358 100644 --- a/open-sse/executors/hailuo-web.ts +++ b/open-sse/executors/hailuo-web.ts @@ -253,7 +253,7 @@ export class HailuoWebExecutor extends BaseExecutor { super("hailuo-web", { id: "hailuo-web", baseUrl: BASE_URL }); } - private buildHeaders(token: string, yy: string): Record { + private buildStreamHeaders(token: string, yy: string): Record { return { Accept: "text/event-stream", "User-Agent": USER_AGENT, @@ -357,7 +357,7 @@ export class HailuoWebExecutor extends BaseExecutor { form.set("chatID", chatID); form.set("searchMode", "0"); - return { url: `${BASE_URL}${pathAndQuery}`, headers: this.buildHeaders(token, yy), form }; + return { url: `${BASE_URL}${pathAndQuery}`, headers: this.buildStreamHeaders(token, yy), form }; } /** POST the signed multipart request and normalize both network + upstream-status errors. */ diff --git a/open-sse/executors/lmarena.ts b/open-sse/executors/lmarena.ts index 3f28acf848..42bcd9cf7e 100644 --- a/open-sse/executors/lmarena.ts +++ b/open-sse/executors/lmarena.ts @@ -73,11 +73,13 @@ export class LMArenaExecutor extends BaseExecutor { super("lmarena", { format: "openai", ...providerConfig }); } - protected buildUrl(_model: string, _credentials: unknown): string { + // Public to match BaseExecutor.buildUrl — a subclass may widen visibility but not + // narrow it. This was masked behind the buildHeaders TS2416 until that one cleared. + buildUrl(_model: string, _credentials: unknown): string { return LMARENA_STREAM_URL; } - protected buildHeaders( + protected buildRequestHeaders( _model: string, credentials: unknown, _body: unknown @@ -91,7 +93,7 @@ export class LMArenaExecutor extends BaseExecutor { return headers; } - protected transformRequest(body: unknown, model: string, credentials?: unknown): unknown { + 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[]) @@ -115,7 +117,7 @@ export class LMArenaExecutor extends BaseExecutor { 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 headers = this.buildRequestHeaders(model, credentials, body); const cookie = readLMArenaCookie(credentials); if (!cookie) { diff --git a/open-sse/executors/mimocode.ts b/open-sse/executors/mimocode.ts index 115e5a4bad..239dfc430d 100644 --- a/open-sse/executors/mimocode.ts +++ b/open-sse/executors/mimocode.ts @@ -100,6 +100,12 @@ interface AccountState { expiresAt: number; cooldownUntil: number; consecutiveFails: number; + /** + * #3837/#5521: the account's resolved proxy, or `null` when none is configured. + * Always present (never `undefined`) so callers can read `acct.proxy` directly — + * syncAccountsFromCredentials() writes it on every account on every sync. + */ + proxy: AccountProxyConfig["proxy"]; } function parseJwtExp(jwt: string): number { diff --git a/open-sse/executors/muse-spark-web.ts b/open-sse/executors/muse-spark-web.ts index 8c7d9617ff..66a2e819ef 100644 --- a/open-sse/executors/muse-spark-web.ts +++ b/open-sse/executors/muse-spark-web.ts @@ -906,6 +906,15 @@ function buildWsUrl(authorization: string, requestId: string): string { type GraphqlResult = { ok: true } | { ok: false; error: string }; +/** + * Narrows the failure arm. Under this workspace's `strictNullChecks: false`, a + * boolean-literal discriminant narrows the positive branch but not the negative one, so + * `!result.ok` leaves the full union and `.error` is unreachable to the checker. + */ +function isGraphqlFailure(result: GraphqlResult): result is Extract { + return !result.ok; +} + async function graphqlPost( docId: string, variables: Record, @@ -1315,7 +1324,7 @@ export class MuseSparkWebExecutor extends BaseExecutor { "Warmup", signal ); - if (!warmupResult.ok) { + if (isGraphqlFailure(warmupResult)) { evictContinuationIfNeeded(cached, continuationCacheKey); log?.error?.("MUSE-SPARK-WEB", `Warmup failed: ${warmupResult.error}`); return errorResult(502, warmupResult.error, "meta_ai_warmup_failed", {}, body); @@ -1329,7 +1338,7 @@ export class MuseSparkWebExecutor extends BaseExecutor { "Mode switch", signal ); - if (!modeResult.ok) { + if (isGraphqlFailure(modeResult)) { evictContinuationIfNeeded(cached, continuationCacheKey); log?.error?.("MUSE-SPARK-WEB", `Mode switch failed: ${modeResult.error}`); return errorResult(502, modeResult.error, "meta_ai_mode_switch_failed", {}, body); diff --git a/open-sse/executors/notion-web.ts b/open-sse/executors/notion-web.ts index 19bd0f3026..707c403621 100644 --- a/open-sse/executors/notion-web.ts +++ b/open-sse/executors/notion-web.ts @@ -77,6 +77,7 @@ export { parseNotionInferenceStream, resolveNotionThreadBinding, notionThreadMarkCreateAttempted, + notionThreadMarkConfirmed, sanitizeNotionAssistantText, }; @@ -583,11 +584,12 @@ export class NotionWebExecutor extends BaseExecutor { const clientFacing = clientFacingModelId(model); const modelId = clientFacing || notionCodename || "notion-ai"; - // Thread continuity (sticky): + // Thread continuity (sticky) — see resolveNotionThreadBinding: // - Prefer X-Notion-Thread-Id / body pin from the client - // - Else sticky root key from first user message (UREW-normalized, durable on disk) - // - Bind threadId *before* the upstream call so error retries never mint a new chat - // - createThread:true only for brand-new roots; never again for that root + // - Else exact conversation-prefix hash (multi-turn OpenAI history) + // - Else sticky root (first user text) for UREW + failed-first-request retries + // - First-turn + confirmed sticky (new Claude Code session with same “hi”) → mint fresh + // - Bind threadId *before* the upstream call so error retries never mint a second chat const inboundHeaders = (input.clientHeaders as Record | null | undefined) ?? ((input as { headers?: Record }).headers as @@ -606,13 +608,26 @@ export class NotionWebExecutor extends BaseExecutor { const reqHeaders = buildNotionExecuteHeaders({ cookie, spaceId, userId, agent }); + type NotionAttempt = + | { ok: true; finalText: string; reqBody: Record } + | { + ok: false; + errorResult: ReturnType; + retryable: boolean; + reqBody: Record; + }; + + // `strictNullChecks: false` narrows a boolean-literal discriminant on the positive + // branch only, so `!attempt.ok` leaves the full union and the failure-only fields are + // unreachable to the checker. An explicit predicate narrows under those settings. + const isFailedAttempt = ( + attempt: NotionAttempt + ): attempt is Extract => !attempt.ok; + const runOnce = async (opts: { createThread: boolean; threadId: string; - }): Promise< - | { ok: true; finalText: string; reqBody: Record } - | { ok: false; errorResult: ReturnType; retryable: boolean; reqBody: Record } - > => { + }): Promise => { const transcript = buildNotionTranscript(messages, { notionModel: notionCodename || undefined, spaceId, @@ -681,13 +696,13 @@ export class NotionWebExecutor extends BaseExecutor { let attempt = await runOnce({ createThread, threadId }); // One automatic retry for transient Notion faults — same threadId, never create again - if (!attempt.ok && attempt.retryable) { + if (isFailedAttempt(attempt) && attempt.retryable) { const delayMs = process.env.NODE_ENV === "test" || process.env.VITEST ? 20 : 700 + Math.floor(Math.random() * 400); await new Promise((r) => setTimeout(r, delayMs)); attempt = await runOnce({ createThread: false, threadId }); } - if (!attempt.ok) { + if (isFailedAttempt(attempt)) { return attempt.errorResult; } diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index babae8a710..8b66d6e421 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -49,11 +49,23 @@ const EFFORT_LEVELS = ["low", "medium", "high", "max"] as const; * low/medium are not supported on the OpenAI transport) * - mimo-v2.5: high/max only (same reasoning; Xiaomi MiMo does not document * low/medium effort tiers) + * - #8353 OpenCode Go registry effort variants (exact suffix sets from + * `opencode models opencode-go --verbose`; MiniMax M3 excluded — different + * thinking-mode mapping): + * deepseek-v4-flash high/max; grok-4.5 low/medium/high; hy3 none/low/high; + * kimi-k3 max; qwen3.6-plus / qwen3.7-max / qwen3.7-plus high/max */ const EFFORT_TIERS: Record = { "deepseek-v4-pro": EFFORT_LEVELS, + "deepseek-v4-flash": ["high", "max"], "glm-5.2": ["high", "max"], "mimo-v2.5": ["high", "max"], + "grok-4.5": ["low", "medium", "high"], + hy3: ["none", "low", "high"], + "kimi-k3": ["max"], + "qwen3.6-plus": ["high", "max"], + "qwen3.7-max": ["high", "max"], + "qwen3.7-plus": ["high", "max"], }; /** @@ -251,7 +263,11 @@ export class OpencodeExecutor extends BaseExecutor { model?: string ) { const headers: Record = { "Content-Type": "application/json" }; - const key = credentials?.apiKey || credentials?.accessToken; + // #8467: honor Extra API Keys rotation via BaseExecutor.resolveEffectiveKey. + // Fall back to accessToken only when no apiKey/extras resolve to a key. + const key = credentials + ? this.resolveEffectiveKey(credentials) || credentials.accessToken + : undefined; if (key) { if (this._requestFormat === "claude") { @@ -326,13 +342,11 @@ export class OpencodeExecutor extends BaseExecutor { ) { delete (modifiedBody as Record).client_metadata; } - if ( - modifiedBody && - typeof modifiedBody === "object" && - Array.isArray(modifiedBody.tools) && - modifiedBody.tools.length > 128 - ) { - modifiedBody.tools = modifiedBody.tools.slice(0, 128); + if (modifiedBody && typeof modifiedBody === "object" && !Array.isArray(modifiedBody)) { + const mb = modifiedBody as Record; + if (Array.isArray(mb.tools) && mb.tools.length > 128) { + mb.tools = mb.tools.slice(0, 128); + } } if (modifiedBody && typeof modifiedBody === "object" && !Array.isArray(modifiedBody)) { const mb = modifiedBody as Record; diff --git a/open-sse/executors/pollinations.ts b/open-sse/executors/pollinations.ts index b14144ef64..9619ee9ebf 100644 --- a/open-sse/executors/pollinations.ts +++ b/open-sse/executors/pollinations.ts @@ -79,7 +79,9 @@ export class PollinationsExecutor extends BaseExecutor { const result = await super.execute(input); if (session && pool) { - const status = result.response.status; + // execute() contracts for `Response | { response, ... }`; both arms carry the + // status this pool bookkeeping needs. + const status = (result instanceof Response ? result : result.response).status; if (status === 429) { pool.reportCooldown(session); } else if (status >= 500) { diff --git a/open-sse/executors/qwen-web.ts b/open-sse/executors/qwen-web.ts index f7b072e174..6c0a710862 100644 --- a/open-sse/executors/qwen-web.ts +++ b/open-sse/executors/qwen-web.ts @@ -94,7 +94,7 @@ export class QwenWebExecutor extends BaseExecutor { super("qwen-web", { id: "qwen-web", baseUrl: BASE_URL }); } - private buildHeaders( + private buildApiHeaders( token: string, cookieHeader: string, chatId?: string @@ -139,7 +139,7 @@ export class QwenWebExecutor extends BaseExecutor { try { const newChatRes = await fetch(CHATS_NEW_URL, { method: "POST", - headers: this.buildHeaders(token, cookieHeader), + headers: this.buildApiHeaders(token, cookieHeader), body: JSON.stringify({ title: "New Chat", models: [modelId], @@ -186,7 +186,7 @@ export class QwenWebExecutor extends BaseExecutor { try { upstream = await fetch(completionUrl, { method: "POST", - headers: this.buildHeaders(token, cookieHeader, chatId), + headers: this.buildApiHeaders(token, cookieHeader, chatId), body: JSON.stringify(msgPayload), signal, }); @@ -251,7 +251,7 @@ export class QwenWebExecutor extends BaseExecutor { }, }), url: completionUrl, - headers: this.buildHeaders(token, cookieHeader, chatId), + headers: this.buildApiHeaders(token, cookieHeader, chatId), transformedBody: msgPayload, }; } diff --git a/open-sse/executors/windsurf.ts b/open-sse/executors/windsurf.ts index 19924afa2a..d87e245149 100644 --- a/open-sse/executors/windsurf.ts +++ b/open-sse/executors/windsurf.ts @@ -248,8 +248,16 @@ function buildGetChatMessageRequest( // ─── gRPC-web framing ──────────────────────────────────────────────────────── -/** Wrap a protobuf message in a 5-byte gRPC-web data frame. */ -function grpcWebFrame(payload: Uint8Array): Uint8Array { +/** + * Wrap a protobuf message in a 5-byte gRPC-web data frame. + * + * Returns `Uint8Array`, not bare `Uint8Array`: the frame is + * allocated with `new Uint8Array(length)`, which is always ArrayBuffer-backed, + * and only that narrower form satisfies `BodyInit` at the `fetch` call below. + * Bare `Uint8Array` widens to `Uint8Array`, which admits + * `SharedArrayBuffer` and is therefore rejected as a request body. + */ +function grpcWebFrame(payload: Uint8Array): Uint8Array { const frame = new Uint8Array(5 + payload.length); frame[0] = 0x00; // compression flag: no compression const view = new DataView(frame.buffer); diff --git a/open-sse/executors/zai-web.ts b/open-sse/executors/zai-web.ts index bd61a83ffd..d6a7e81421 100644 --- a/open-sse/executors/zai-web.ts +++ b/open-sse/executors/zai-web.ts @@ -9,7 +9,9 @@ * modeled on the `chatglm-web` credential entry (#4056) and the `doubao-web` / * `venice-web` cookie executors. * - * Endpoint: POST https://chat.z.ai/api/chat/completions + * Endpoint: POST https://chat.z.ai/api/v2/chat/completions + * (the older unversioned `/api/chat/completions` path is stale and + * 404s model-independently as of 2026-07 — see #8014) * Auth: full Cookie header from chat.z.ai (must contain the `token` JWT). * Sent both as `Cookie` and as `Authorization: Bearer ` — * the SPA's own fetch client sets both, and stripping either one @@ -29,7 +31,7 @@ import { } from "../utils/error.ts"; const BASE_URL = "https://chat.z.ai"; -const CHAT_URL = `${BASE_URL}/api/chat/completions`; +const CHAT_URL = `${BASE_URL}/api/v2/chat/completions`; const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; diff --git a/open-sse/executors/zed-hosted.ts b/open-sse/executors/zed-hosted.ts index 3ad0cc8740..ef358ce217 100644 --- a/open-sse/executors/zed-hosted.ts +++ b/open-sse/executors/zed-hosted.ts @@ -117,8 +117,18 @@ function createErrorChunk(model: string, message: string): Record, "enqueue">; + function enqueueSseObject( - controller: ReadableStreamDefaultController, + controller: SseEnqueueTarget, encoder: TextEncoder, chunk: unknown ): void { @@ -202,7 +212,7 @@ function wrapZedCompletionStream( let buffer = ""; let done = false; - const finish = (controller: ReadableStreamDefaultController) => { + const finish = (controller: SseEnqueueTarget) => { if (done) return; const finalChunk = convertProviderEvent(provider, null, state); enqueueSseObject(controller, encoder, finalChunk); @@ -210,7 +220,7 @@ function wrapZedCompletionStream( done = true; }; - const processLine = (line: string, controller: ReadableStreamDefaultController) => { + const processLine = (line: string, controller: SseEnqueueTarget) => { if (done) return; const payload = unwrapZedLine(line); if (!payload) return; diff --git a/open-sse/handlers/audioTranscription.ts b/open-sse/handlers/audioTranscription.ts index 55762eb4cf..f119860cad 100644 --- a/open-sse/handlers/audioTranscription.ts +++ b/open-sse/handlers/audioTranscription.ts @@ -72,11 +72,16 @@ function getUploadedFileName(file: Blob & { name?: unknown }): string { return typeof file.name === "string" && file.name.length > 0 ? file.name : "audio.wav"; } +/** + * `body` is `Uint8Array`, not bare `Uint8Array`: `new Uint8Array(n)` + * is always ArrayBuffer-backed, and only that narrower form satisfies `BodyInit` + * (the bare type widens to `ArrayBufferLike`, which admits `SharedArrayBuffer`). + */ export async function buildMultipartBody( file: Blob & { name?: unknown }, fields: Record, fileFieldName = "file" -): Promise<{ body: Uint8Array; contentType: string }> { +): Promise<{ body: Uint8Array; contentType: string }> { const boundary = "----OmniRouteAudioBoundary" + Date.now().toString(36); const parts: Uint8Array[] = []; const encoder = new TextEncoder(); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index c1231c79f6..21995f8ebb 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -114,7 +114,10 @@ import { isOpencodeGoProvider, stripBooleanReasoning, } from "../services/opencodeReasoningSanitizer.ts"; -import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThinking.ts"; +import { + normalizeClaudeAdaptiveThinking, + normalizeClaudeDisabledThinkingEffort, +} from "../services/claudeAdaptiveThinking.ts"; import { normalizeClaudeHaikuConstraints } from "../services/claudeHaikuConstraints.ts"; import { applyDefaultReasoningEffort } from "../services/defaultReasoningEffort.ts"; import { echoModelInObject } from "../services/responseModelEcho.ts"; @@ -857,13 +860,17 @@ export async function handleChatCore({ pendingWrite: compressionAnalyticsWritePromise, skillRequestId, }); - const pipelineSessionId = + // #8249: raw header value, kept separate from `pipelineSessionId`'s skillRequestId fallback + // below so call_logs.session_tag is only ever set when the caller explicitly supplied the + // header — never synthesized from the internal per-request skillRequestId. + const explicitSessionIdHeader = (clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function" ? clientRawRequest.headers.get("x-omniroute-session-id") : getHeaderValueCaseInsensitive( clientRawRequest?.headers ?? null, "x-omniroute-session-id" - )) || skillRequestId; + )) || null; + const pipelineSessionId = explicitSessionIdHeader || skillRequestId; // persistAttemptLogs extracted to chatCore/attemptLogging.ts (#3501); bind the per-request context // once so the 16 call sites keep passing only the per-attempt args (byte-identical). const persistAttemptLogs = (args: PersistAttemptLogsArgs) => @@ -891,6 +898,7 @@ export async function handleChatCore({ noLogEnabled, correlationId, modelPinned, + sessionTag: explicitSessionIdHeader, }); // Primary path: merge client model id + alias target so config on either key applies; resolved @@ -2244,6 +2252,14 @@ export async function handleChatCore({ // defaults) to `{type:"adaptive"}` — effort stays on `output_config.effort`. Keyed on // the resolved upstream model, so it covers every routing mode. See claudeAdaptiveThinking.ts. translatedBody = normalizeClaudeAdaptiveThinking(translatedBody, finalModelToUpstream); + // Opus 5 allows disabled thinking only through high effort on Anthropic's direct + // Messages API. The helper scopes this constraint to `anthropic` and `claude`; + // GitHub Copilot and Claude Web use separate upstream contracts. + translatedBody = normalizeClaudeDisabledThinkingEffort( + translatedBody, + finalModelToUpstream, + provider + ); // Claude Haiku rejects `thinking.type:"adaptive"` and `output_config.effort` // (both Sonnet 4.6 / Opus 4.5+ only). Several paths can still emit those // shapes on a Haiku target — native passthrough, reasoning_effort buckets, @@ -2989,11 +3005,7 @@ export async function handleChatCore({ rawResult._executionCredentials?.connectionId && rawResult._executionCredentials?.apiKey ) { - recordKeyHealthStatus( - status, - rawResult._executionCredentials, - rawResult.transport - ); + recordKeyHealthStatus(status, rawResult._executionCredentials, rawResult.transport); } releaseRawResultAccountSemaphore = typeof rawResult._accountSemaphoreRelease === "function" @@ -3214,17 +3226,29 @@ export async function handleChatCore({ // via isLocalStreamLifecycleError so those map to 499 instead of falling // through to the 502 provider-failure default. const isRequestAborted = isLocalStreamLifecycleError(error); + // #8376: an unreachable upstream proxy (ECONNREFUSED/ECONNRESET/...) is tagged by + // proxyFetch.ts (tagProxyUnreachable) with `.errorCode = "proxy_unreachable"` before + // it reaches this catch. Classify it explicitly to 502 instead of falling through + // the generic `error.status` branch (a raw connect-refused error has no `.status` at + // all, so it used to collapse into an ordinary 502/504 the provider-breaker predicate + // can't tell apart from a per-model 5xx). + const isProxyUnreachableFailure = + !isRequestAborted && (error as { errorCode?: unknown })?.errorCode === "proxy_unreachable"; const failureStatus = isRequestAborted ? 499 - : error.name === "TimeoutError" || error.name === "BodyTimeoutError" - ? HTTP_STATUS.GATEWAY_TIMEOUT - : error.status && typeof error.status === "number" - ? error.status - : HTTP_STATUS.BAD_GATEWAY; + : isProxyUnreachableFailure + ? HTTP_STATUS.BAD_GATEWAY + : error.name === "TimeoutError" || error.name === "BodyTimeoutError" + ? HTTP_STATUS.GATEWAY_TIMEOUT + : error.status && typeof error.status === "number" + ? error.status + : HTTP_STATUS.BAD_GATEWAY; const failureMessage = isRequestAborted ? "Request aborted" : formatProviderError(error, provider, model, failureStatus); - const upstreamErrorCode = getUpstreamErrorIdentifier(error); + const upstreamErrorCode = isProxyUnreachableFailure + ? "proxy_unreachable" + : getUpstreamErrorIdentifier(error); // Tag our own deadline timeouts (fetch-start TimeoutError / body BodyTimeoutError, // both surfaced as a 504) as "upstream_timeout" so the cooldown layer can tell a // slow-but-not-failed request apart from a real provider 5xx. (Antigravity already @@ -4477,6 +4501,13 @@ export async function handleChatCore({ if (typeof model === "string" && model) echoModelInObject(translatedResponse, model); // #1311: echo the requested alias/combo name in the non-streaming response model. if (echoModel) echoModelInObject(translatedResponse, echoModel); + + // ── Plugin onResponse hook (fire-and-forget) ── + // #8395: the streaming branch below already calls this; the non-streaming + // (stream:false) branch returned without it, so onResponse never fired for + // non-streaming requests at all. + await runPluginOnResponseHook({ requestId: traceId, body, model, provider, apiKeyInfo }); + return { success: true, response: buildNonStreamingJsonResponse(translatedResponse, responseHeaders), diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts index c6e46c8dc3..245aafdcc3 100644 --- a/open-sse/handlers/chatCore/attemptLogging.ts +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -57,6 +57,10 @@ export type PersistAttemptLogsContext = { noLogEnabled: unknown; correlationId?: string | null; modelPinned?: boolean; + /** #8249: caller-supplied X-OmniRoute-Session-Id header, only set when the header was + * explicitly present (never synthesized from skillRequestId) — persisted as call_logs.session_tag + * for per-session cost attribution. */ + sessionTag?: string | null; }; function toConnectionId(value: unknown): string | null { @@ -171,6 +175,7 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt noLogEnabled, correlationId, modelPinned, + sessionTag, } = ctx; const initialConnectionId = toConnectionId(connectionId); const finalConnectionId = toConnectionId(credentials?.connectionId) || initialConnectionId; @@ -270,6 +275,7 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt pipelinePayloads, correlationId, modelPinned: modelPinned || false, + sessionTag: sessionTag || null, }).catch(() => {}); // Emit the terminal request-lifecycle event to the live dashboard bus. `request.started` diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 67a1fc7797..631f32ac45 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -80,6 +80,9 @@ import { reportPollinationsAnonOutcome, } from "./imageGeneration/pollinationsAnonAuth.ts"; +// Re-export so /v1/images/edits can dispatch Firefly reference-image edits. +export { handleAdobeFireflyImageGeneration }; + interface KieImageOptions { model: string; provider: string; diff --git a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts index 309be55838..4270894188 100644 --- a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts +++ b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts @@ -4,6 +4,11 @@ // Credentials: IMS access_token (JWT, client_id clio-playground-web) or full // Cookie header from firefly.adobe.com. Cookie → IMS check/v6/token with // client_id clio-playground-web (Express projectx_webapp fallback). +// +// Reference images (Media page / OpenAI edit aliases): +// 1) POST raw bytes → firefly-3p /v2/storage/image → { images:[{ id }] } +// 2) generate-async with referenceBlobs:[{ id, usage:"general"|"subject" }] +// See web_providers/adobe_atach_images.txt for live captures. import { sanitizeErrorMessage } from "../../../utils/error.ts"; import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; @@ -11,6 +16,8 @@ import { AdobeFireflyError, adobeFireflyGenerateImage, resolveAdobeAccessToken, + resolveAdobeSourceImageIds, + resolveAdobeImageModel, } from "../../../services/adobeFireflyClient.ts"; function normalizePositiveNumber(value: unknown, fallback: number): number { @@ -40,6 +47,9 @@ export async function handleAdobeFireflyImageGeneration({ timeout_ms?: unknown; image?: unknown; image_url?: unknown; + image_urls?: unknown; + images?: unknown; + [key: string]: unknown; }; credentials: { apiKey?: string; accessToken?: string }; log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; @@ -77,9 +87,27 @@ export async function handleAdobeFireflyImageGeneration({ ? credentials.accessToken : undefined); + // Cap uploads by model family (matches MediaViewModel GetSourceImageLimit). + const { id: resolvedId } = resolveAdobeImageModel(model); + const maxRefs = + resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") + ? 4 + : 2; + + const sourceImageIds = await resolveAdobeSourceImageIds({ + accessToken, + body, + max: maxRefs, + sessionCookie, + prompt, + fetchImpl, + log, + }); + log?.info?.( "IMAGE", - `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + + (sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") ); const result = await adobeFireflyGenerateImage({ @@ -92,6 +120,7 @@ export async function handleAdobeFireflyImageGeneration({ seed: Number.isFinite(seed as number) ? (seed as number) : undefined, negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined, + sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, sessionCookie, timeoutMs, fetchImpl, diff --git a/open-sse/handlers/imageGeneration/providers/designerWeb.ts b/open-sse/handlers/imageGeneration/providers/designerWeb.ts index f32d478d36..f362eecd14 100644 --- a/open-sse/handlers/imageGeneration/providers/designerWeb.ts +++ b/open-sse/handlers/imageGeneration/providers/designerWeb.ts @@ -104,15 +104,26 @@ interface DesignerWebRequestConfig { pollIntervalMs: number; } +/** + * Outcome of request validation. String-discriminated rather than `ok: boolean` + * because `open-sse` compiles with `strictNullChecks: false`, where a + * boolean-literal discriminant narrows the positive branch but leaves the + * negative one as the full union — so `if (!resolved.ok)` would not expose + * `status`/`error`. All three unions in this file shared that root cause. + */ +type DesignerWebRequestResolution = + | { state: "resolved"; config: DesignerWebRequestConfig } + | { state: "invalid"; status: number; error: string }; + /** Validates the request and resolves auth + poll timing. Returns an error status/message on failure. */ function resolveDesignerWebRequest( body: { prompt?: unknown; size?: unknown; timeout_ms?: unknown; poll_interval_ms?: unknown }, credentials: { apiKey?: string; accessToken?: string } -): { ok: true; config: DesignerWebRequestConfig } | { ok: false; status: number; error: string } { +): DesignerWebRequestResolution { const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; if (!prompt) { return { - ok: false, + state: "invalid", status: 400, error: "Prompt is required for Microsoft Designer image generation", }; @@ -120,7 +131,11 @@ function resolveDesignerWebRequest( const accessToken = credentials?.apiKey || credentials?.accessToken; if (!accessToken) { - return { ok: false, status: 401, error: "Microsoft Designer credentials missing access_token" }; + return { + state: "invalid", + status: 401, + error: "Microsoft Designer credentials missing access_token", + }; } const timeoutMs = normalizePositiveNumber( @@ -139,7 +154,7 @@ function resolveDesignerWebRequest( ); return { - ok: true, + state: "resolved", config: { prompt, accessToken, @@ -151,10 +166,20 @@ function resolveDesignerWebRequest( }; } -type DesignerWebStepResult = - | { done: false; waitMs: number } - | { done: true; success: true; imageUrls: string[] } - | { done: true; success: false; status: number; error: string }; +type DesignerWebPending = { state: "pending"; waitMs: number }; +type DesignerWebReady = { state: "ready"; imageUrls: string[] }; +type DesignerWebFailed = { state: "failed"; status: number; error: string }; + +/** One poll cycle: still working, finished with images, or finished with an error. */ +type DesignerWebStepResult = DesignerWebPending | DesignerWebReady | DesignerWebFailed; + +/** + * What the poll loop hands back. Deliberately excludes the pending arm — the + * loop either returns a terminal step or synthesizes a 504, and never surfaces + * `pending` to its caller. The previous signature admitted it, which is why + * `outcome.success` did not exist on every member of that union. + */ +type DesignerWebOutcome = DesignerWebReady | DesignerWebFailed; /** Runs one submit/poll fetch cycle and classifies the outcome. */ async function stepDesignerWebPoll( @@ -167,23 +192,29 @@ async function stepDesignerWebPoll( const resp = await fetchImpl(baseUrl, { method: "POST", headers, body: formBody }); if (!resp.ok) { - return { done: true, success: false, status: resp.status, error: sanitizeErrorMessage(await resp.text()) }; + return { + state: "failed", + status: resp.status, + error: sanitizeErrorMessage(await resp.text()), + }; } const parsed = parseDesignerWebResponse(await resp.json()); if (parsed.status === "ready") { - return { done: true, success: true, imageUrls: parsed.imageUrls }; + return { state: "ready", imageUrls: parsed.imageUrls }; } if (parsed.status === "empty") { return { - done: true, - success: false, + state: "failed", status: 502, error: "Microsoft Designer response did not contain image data or polling metadata", }; } - return { done: false, waitMs: Math.min(parsed.pollIntervalMs ?? pollIntervalMs, pollIntervalMs) }; + return { + state: "pending", + waitMs: Math.min(parsed.pollIntervalMs ?? pollIntervalMs, pollIntervalMs), + }; } /** Drives the submit-then-poll loop to completion, timeout, or a terminal error. */ @@ -192,7 +223,7 @@ async function runDesignerWebPollLoop( config: DesignerWebRequestConfig, fetchImpl: typeof fetch, log?: { info?: (...args: unknown[]) => void } -): Promise { +): Promise { const deadline = Date.now() + config.timeoutMs; let attempt = 0; @@ -205,14 +236,13 @@ async function runDesignerWebPollLoop( config.pollIntervalMs, fetchImpl ); - if (step.done) return step; + if (step.state !== "pending") return step; log?.info?.("IMAGE", `designer-web pending, poll #${attempt} in ${step.waitMs}ms`); await new Promise((resolve) => setTimeout(resolve, step.waitMs)); } return { - done: true, - success: false, + state: "failed", status: 504, error: "Microsoft Designer image generation timed out waiting for a result", }; @@ -237,13 +267,19 @@ export async function handleDesignerWebImageGeneration({ }) { const startTime = Date.now(); const resolved = resolveDesignerWebRequest(body, credentials); - if (!resolved.ok) { - return saveImageErrorResult({ provider, model, status: resolved.status, startTime, error: resolved.error }); + if (resolved.state === "invalid") { + return saveImageErrorResult({ + provider, + model, + status: resolved.status, + startTime, + error: resolved.error, + }); } try { const outcome = await runDesignerWebPollLoop(providerConfig.baseUrl, resolved.config, fetchImpl, log); - if (outcome.success) { + if (outcome.state === "ready") { return saveImageSuccessResult({ provider, model, diff --git a/open-sse/handlers/rerank.ts b/open-sse/handlers/rerank.ts index 719459d555..175116e65d 100644 --- a/open-sse/handlers/rerank.ts +++ b/open-sse/handlers/rerank.ts @@ -16,6 +16,25 @@ import { resolveProxyForConnection } from "@/lib/db/settings"; import { runWithProxyContext } from "../utils/proxyFetch.ts"; import * as log from "@/sse/utils/logger"; +/** A document as the Cohere-compatible rerank API accepts it: a bare string or `{ text }`. */ +type RerankDocument = string | { text?: string }; + +/** + * The caller-side request fields the response adapters need to rebuild Cohere's + * `results[]`. Upstreams either omit the documents entirely (DeepInfra returns + * bare scores) or echo them in their own shape (Voyage returns plain strings), + * so document text is always synthesized from the caller's originals — and + * `top_n` / `return_documents` are honored here rather than upstream. + * + * Every field is optional: the parameter defaults to `{}` and the unit suites + * call it with subsets (e.g. `{ documents: ["a", "b"] }`). + */ +interface RerankResponseOptions { + documents?: RerankDocument[]; + return_documents?: boolean; + top_n?: number; +} + /** * Build authorization header for a rerank provider */ @@ -76,7 +95,11 @@ function buildAuthHeader(providerConfig, token) { /** * Transform response from provider-specific formats back to Cohere format */ -/* @testonly */ export function transformResponseFromProvider(providerConfig, data, options = {}) { +/* @testonly */ export function transformResponseFromProvider( + providerConfig, + data, + options: RerankResponseOptions = {} +) { if (providerConfig.format === "nvidia") { return { id: data.id != null ? String(data.id) : `rerank-${Date.now()}`, diff --git a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts index 244879af90..62250f3f27 100644 --- a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts +++ b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts @@ -10,6 +10,8 @@ import { AdobeFireflyError, adobeFireflyGenerateVideo, resolveAdobeAccessToken, + resolveAdobeSourceImageIds, + resolveAdobeVideoModel, } from "../../services/adobeFireflyClient.ts"; function normalizePositiveNumber(value: unknown, fallback: number): number { @@ -61,9 +63,23 @@ export async function handleAdobeFireflyVideoGeneration({ ? credentials.accessToken : undefined); + // Kling i2v / Veo ref / Sora frame: upload reference images first. + const { id: videoModelId } = resolveAdobeVideoModel(String(model)); + const maxFrames = videoModelId.includes("kling") || videoModelId.includes("sora") ? 2 : 3; + const sourceImageIds = await resolveAdobeSourceImageIds({ + accessToken, + body, + max: maxFrames, + sessionCookie, + prompt, + fetchImpl, + log, + }); + log?.info?.( "VIDEO", - `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + + (sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "") ); const result = await adobeFireflyGenerateVideo({ @@ -83,6 +99,7 @@ export async function handleAdobeFireflyVideoGeneration({ ? body.negativePrompt : undefined, generateAudio: body.generate_audio !== false && body.generateAudio !== false, + sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, sessionCookie, timeoutMs, fetchImpl, diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 28f871719f..c10ade929f 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -56,6 +56,7 @@ import { import { parseDayGranularityResetMs, shouldPreserveQuotaSignals } from "./quotaResetParsing.ts"; import { evictLockoutOverflow } from "./accountFallback/lockoutEviction.ts"; export { MODEL_LOCKOUT_EVICTION_CAP } from "./accountFallback/lockoutEviction.ts"; +import { capScaledCooldownMs } from "./accountFallback/cooldownCap.ts"; export type ProviderProfile = { baseCooldownMs: number; @@ -1451,9 +1452,14 @@ export function checkFallbackError( typeof profile?.baseCooldownMs === "number" && profile.baseCooldownMs >= 0 ? profile.baseCooldownMs : COOLDOWN_MS.transientInitial; + // #8396: cap against profile.maxCooldownMs, mirroring the model-lockout path. return { baseCooldownMs, - cooldownMs: getScaledCooldown(baseCooldownMs, level + 1, maxBackoffSteps), + cooldownMs: capScaledCooldownMs( + getScaledCooldown(baseCooldownMs, level + 1, maxBackoffSteps), + profile?.maxCooldownMs, + BACKOFF_CONFIG.max + ), newBackoffLevel: Math.min(level + 1, maxBackoffSteps), }; } diff --git a/open-sse/services/accountFallback/cooldownCap.ts b/open-sse/services/accountFallback/cooldownCap.ts new file mode 100644 index 0000000000..66a50da03b --- /dev/null +++ b/open-sse/services/accountFallback/cooldownCap.ts @@ -0,0 +1,26 @@ +/** + * accountFallback/cooldownCap.ts — absolute ceiling for exponentially-scaled cooldowns. + * + * Extracted from services/accountFallback.ts (file-size gate): a pure, one-purpose clamp + * so the connection-level 429/retryable-error cooldown path (getScaledBaseCooldown, inside + * checkFallbackError) applies the same absolute ceiling the model-lockout path + * (recordModelLockoutFailure) already enforces. Fixes #8396 — after a sustained 429 burst + * pushed backoffLevel high, `baseCooldownMs * 2^level` had no upper bound on this path and + * could black a connection out for hours, long past any real rate-limit reset window. + */ + +/** + * Clamp an exponentially-scaled cooldown to `maxCooldownMs` (operator-configured, per + * ProviderProfile) falling back to `fallbackMaxMs` (e.g. BACKOFF_CONFIG.max) when the + * profile did not configure one. Never widens the cooldown, only bounds it — the + * exponential backoff itself is untouched. + */ +export function capScaledCooldownMs( + cooldownMs: number, + maxCooldownMs: number | undefined | null, + fallbackMaxMs: number +): number { + const cap = + typeof maxCooldownMs === "number" && maxCooldownMs > 0 ? maxCooldownMs : fallbackMaxMs; + return Math.min(cooldownMs, cap); +} diff --git a/open-sse/services/adobeFireflyClient.ts b/open-sse/services/adobeFireflyClient.ts index 8471da8dfe..888c28b4ed 100644 --- a/open-sse/services/adobeFireflyClient.ts +++ b/open-sse/services/adobeFireflyClient.ts @@ -432,7 +432,7 @@ export function extractAdobeCredentialToken(raw: string): string { } // Authorization: Bearer eyJ... - const authMatch = value.match(/Authorization\s*:\s*Bearer\s+([A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096})/i); + const authMatch = value.match(/Authorization\s*:\s*Bearer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i); if (authMatch?.[1] && looksLikeAdobeJwt(authMatch[1])) return authMatch[1]; // Any eyJ… JWT in the blob (HAR / multi-line). Prefer user AdobeID tokens. @@ -699,7 +699,7 @@ export function buildAdobeImagePayload(opts: { seeds, output: { storeInputs: true }, prompt: opts.prompt, - referenceBlobs: [], + referenceBlobs: [] as Array>, modelSpecificPayload: { size: "auto" }, modelId: opts.modelSpec.upstreamModelId, modelVersion: opts.modelSpec.upstreamModelVersion, @@ -710,14 +710,20 @@ export function buildAdobeImagePayload(opts: { }, }; if (opts.sourceImageIds?.length) { + // gpt-image subject references (mask path uses separate mask blob when present). payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; - payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ id, usage: "subject" })); + payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ + id: String(id), + usage: "subject", + })); payload.modelSpecificPayload = {}; } return payload; } - // nano (Gemini Flash) + generic (Flux / Seedream / Runway image): same 3P image shape + // nano (Gemini Flash) + generic (Flux / Seedream / Runway image): same 3P image shape. + // Live capture (web_providers/adobe_atach_images.txt): referenceBlobs with usage "general" + // keep module "text2image" (not image2image) for nano multi-ref composition. const sizeMap = NANO_SIZE_MAP[opts.outputResolution] || NANO_SIZE_MAP["2K"]; const pixel = sizeMap[ratio] || sizeMap["1:1"]; const payload: Record = { @@ -735,13 +741,19 @@ export function buildAdobeImagePayload(opts: { parameters: { addWatermark: false }, aspectRatio: ratio, }, - referenceBlobs: [], + referenceBlobs: [] as Array>, }; if (Object.keys(genSettings).length) payload.generationSettings = genSettings; if (opts.sourceImageIds?.length) { - payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; - payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ id, usage: "general" })); + payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ + id: String(id), + usage: "general", + })); + // Flux / Seedream / Runway image historically used image2image; nano keeps text2image. + if (opts.modelSpec.family === "generic") { + payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; + } } return payload; } @@ -998,6 +1010,353 @@ export function buildAdobeSubmitHeaders( return headers; } +/** Max reference image size for Firefly storage upload (20 MiB). */ +export const ADOBE_FIREFLY_MAX_UPLOAD_BYTES = 20 * 1024 * 1024; + +/** + * Headers for POST /v2/storage/image (raw image body). + * Live capture (web_providers/adobe_atach_images.txt): Bearer + x-api-key + x-arp + x-nonce + * + content-type image/png|jpeg (not application/json). + */ +export function buildAdobeUploadHeaders( + accessToken: string, + contentType: string, + extras?: { + arpSessionId?: string; + nonce?: string; + cookie?: string; + prompt?: string; + } +): Record { + const base = buildAdobeSubmitHeaders(accessToken, { + arpSessionId: extras?.arpSessionId, + nonce: extras?.nonce, + cookie: extras?.cookie, + prompt: extras?.prompt || "upload", + }); + const ct = String(contentType || "image/png").trim().toLowerCase() || "image/png"; + return { + ...base, + "content-type": ct.startsWith("image/") ? ct : "image/png", + }; +} + +/** + * Collect reference image sources from an OpenAI-style / Media-page image|video body. + * Supports: image_url, image, images[], image_urls[], input_image(s), reference_images, + * provider_options.*, and prompt_image fields used by the WinUI Media page. + */ +export function extractAdobeSourceImageSources(body: unknown, max = 4): string[] { + if (!body || typeof body !== "object") return []; + const b = body as Record; + const po = + b.provider_options && typeof b.provider_options === "object" && !Array.isArray(b.provider_options) + ? (b.provider_options as Record) + : {}; + + const out: string[] = []; + const seen = new Set(); + const push = (v: unknown) => { + if (out.length >= max) return; + if (typeof v === "string") { + const t = v.trim(); + if (!t || seen.has(t)) return; + // Skip empty / clearly non-image + if (t === "null" || t === "undefined") return; + seen.add(t); + out.push(t); + return; + } + if (Array.isArray(v)) { + for (const item of v) { + if (out.length >= max) break; + push(item); + } + return; + } + if (v && typeof v === "object") { + const o = v as Record; + if (typeof o.url === "string") push(o.url); + else if (typeof o.image_url === "string") push(o.image_url); + else if (o.image_url && typeof o.image_url === "object") { + const inner = (o.image_url as Record).url; + if (typeof inner === "string") push(inner); + } else if (typeof o.b64_json === "string") { + push(`data:image/png;base64,${o.b64_json}`); + } else if (typeof o.base64 === "string") { + push(`data:image/png;base64,${o.base64}`); + } + } + }; + + // Order matches MediaViewModel / OpenAI edit aliases (primary single fields first). + const keys = [ + "image_url", + "imageUrl", + "input_image", + "source_image", + "promptImage", + "prompt_image", + "image", + "images", + "image_urls", + "imageUrls", + "input_images", + "reference_images", + "referenceImages", + "reference_image", + ]; + for (const k of keys) { + push(b[k]); + push(po[k]); + } + + // OpenAI chat-style content parts (rare on /v1/images but harmless). + if (Array.isArray(b.messages)) { + for (const msg of b.messages) { + if (!msg || typeof msg !== "object") continue; + const content = (msg as Record).content; + if (!Array.isArray(content)) continue; + for (const part of content) { + if (!part || typeof part !== "object") continue; + const p = part as Record; + if (p.type === "image_url" || p.type === "image") { + push(p.image_url ?? p.image ?? p.url); + } + } + } + } + + return out.slice(0, max); +} + +export function parseAdobeImageSourceBytes(source: string): { + buffer: Buffer; + contentType: string; +} { + const trimmed = String(source || "").trim(); + if (!trimmed) { + throw new AdobeFireflyError("Empty image reference", 400, "bad_image"); + } + + const dataUri = /^data:([^;,]+)?(?:;charset=[^;,]+)?(;base64)?,([\s\S]+)$/i.exec(trimmed); + if (dataUri) { + const mime = (dataUri[1] || "image/png").trim().toLowerCase() || "image/png"; + const isB64 = Boolean(dataUri[2]); + const payload = dataUri[3] || ""; + if (!isB64) { + throw new AdobeFireflyError( + "Image data URL must be base64-encoded (data:image/...;base64,...)", + 400, + "bad_image" + ); + } + const buffer = Buffer.from(payload.replace(/\s/g, ""), "base64"); + if (!buffer.length) { + throw new AdobeFireflyError("Image data URL decoded to empty bytes", 400, "bad_image"); + } + if (buffer.length > ADOBE_FIREFLY_MAX_UPLOAD_BYTES) { + throw new AdobeFireflyError( + `Image reference too large (${buffer.length} bytes; max ${ADOBE_FIREFLY_MAX_UPLOAD_BYTES})`, + 400, + "bad_image" + ); + } + return { buffer, contentType: mime.startsWith("image/") ? mime : "image/png" }; + } + + // Raw base64 without data: prefix + if (!/^https?:\/\//i.test(trimmed) && /^[A-Za-z0-9+/=\s]+$/.test(trimmed) && trimmed.length > 64) { + const buffer = Buffer.from(trimmed.replace(/\s/g, ""), "base64"); + if (buffer.length > 0 && buffer.length <= ADOBE_FIREFLY_MAX_UPLOAD_BYTES) { + return { buffer, contentType: "image/png" }; + } + } + + throw new AdobeFireflyError( + "Unsupported image reference (need data:image/...;base64,... or raw base64). " + + "HTTP(S) URLs are resolved by the caller before upload.", + 400, + "bad_image" + ); +} + +/** + * Parse Firefly storage upload response: {"images":[{"id":"uuid"}]}. + */ +export function parseAdobeStorageUploadResponse(body: unknown): string { + if (!body || typeof body !== "object") return ""; + const images = (body as Record).images; + if (Array.isArray(images) && images.length > 0) { + const first = images[0]; + if (first && typeof first === "object") { + const id = (first as Record).id; + if (typeof id === "string" && id.trim()) return id.trim(); + } + } + const id = (body as Record).id; + if (typeof id === "string" && id.trim()) return id.trim(); + return ""; +} + +/** + * Upload one image to Firefly storage → blob id for referenceBlobs. + * Wire: POST https://firefly-3p.ff.adobe.io/v2/storage/image (raw bytes). + */ +export async function uploadAdobeFireflyImage(opts: { + accessToken: string; + bytes: Buffer | Uint8Array; + contentType?: string; + sessionCookie?: string; + /** Used for deterministic x-nonce (optional). */ + prompt?: string; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; +}): Promise { + const fetchImpl = opts.fetchImpl || fetch; + const buffer = Buffer.isBuffer(opts.bytes) ? opts.bytes : Buffer.from(opts.bytes); + if (!buffer.length) { + throw new AdobeFireflyError("Cannot upload empty image", 400, "bad_image"); + } + if (buffer.length > ADOBE_FIREFLY_MAX_UPLOAD_BYTES) { + throw new AdobeFireflyError( + `Image reference too large (${buffer.length} bytes; max ${ADOBE_FIREFLY_MAX_UPLOAD_BYTES})`, + 400, + "bad_image" + ); + } + + const sessionCookie = String(opts.sessionCookie || "").trim(); + const cookieHeader = extractAdobeCookieHeader(sessionCookie); + const arpSessionId = + extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie); + const contentType = + (opts.contentType && opts.contentType.trim()) || + (buffer[0] === 0xff && buffer[1] === 0xd8 + ? "image/jpeg" + : buffer[0] === 0x89 && buffer[1] === 0x50 + ? "image/png" + : "image/png"); + + const resp = await fetchImpl(ADOBE_FIREFLY_IMAGE_UPLOAD_URL, { + method: "POST", + headers: buildAdobeUploadHeaders(opts.accessToken, contentType, { + arpSessionId: arpSessionId || undefined, + cookie: cookieHeader || undefined, + prompt: opts.prompt || "upload", + }), + body: buffer as unknown as BodyInit, + }); + + const text = await resp.text().catch(() => ""); + if (resp.status === 401 || resp.status === 403) { + throw new AdobeFireflyError( + "Adobe Firefly image upload unauthorized — paste a fresh IMS JWT", + 401, + "auth" + ); + } + if (!resp.ok) { + throw new AdobeFireflyError( + `Adobe Firefly image upload failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`, + resp.status >= 400 && resp.status < 500 ? resp.status : 502, + "upload" + ); + } + + let json: unknown = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + throw new AdobeFireflyError( + "Adobe Firefly image upload returned non-JSON body", + 502, + "upload" + ); + } + const id = parseAdobeStorageUploadResponse(json); + if (!id) { + throw new AdobeFireflyError( + "Adobe Firefly image upload succeeded but no images[].id was returned", + 502, + "upload" + ); + } + opts.log?.info?.("ADOBE-FIREFLY", `uploaded reference image id=${id} (${buffer.length} bytes)`); + return id; +} + +/** + * Resolve Media/OpenAI body image fields → Firefly storage blob ids. + * - data: URLs / raw base64 → upload + * - http(s) URLs → fetch then upload + * - already looks like a UUID blob id → use as-is (advanced) + */ +export async function resolveAdobeSourceImageIds(opts: { + accessToken: string; + body: unknown; + max?: number; + sessionCookie?: string; + prompt?: string; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; +}): Promise { + const max = Math.max(1, Math.min(8, opts.max ?? 4)); + const sources = extractAdobeSourceImageSources(opts.body, max); + if (!sources.length) return []; + + const fetchImpl = opts.fetchImpl || fetch; + const ids: string[] = []; + + for (const src of sources) { + // Already a Firefly storage id (uuid) + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(src)) { + ids.push(src); + continue; + } + + let buffer: Buffer; + let contentType = "image/png"; + + if (/^https?:\/\//i.test(src)) { + const r = await fetchImpl(src, { + method: "GET", + headers: { accept: "image/*,*/*" }, + }); + if (!r.ok) { + throw new AdobeFireflyError( + `Failed to download reference image (${r.status}): ${src.slice(0, 120)}`, + 400, + "bad_image" + ); + } + const ab = await r.arrayBuffer(); + buffer = Buffer.from(ab); + const ct = r.headers.get("content-type") || ""; + if (ct.toLowerCase().startsWith("image/")) { + contentType = ct.split(";")[0]!.trim(); + } + } else { + const parsed = parseAdobeImageSourceBytes(src); + buffer = parsed.buffer; + contentType = parsed.contentType; + } + + const id = await uploadAdobeFireflyImage({ + accessToken: opts.accessToken, + bytes: buffer, + contentType, + sessionCookie: opts.sessionCookie, + prompt: opts.prompt, + fetchImpl, + log: opts.log, + }); + ids.push(id); + } + + return ids; +} + /** Transient Adobe 3P overload / rate / edge errors worth retrying. */ export function isAdobeTransientSubmitError(status: number, bodyText: string): boolean { if (status === 408 || status === 429 || status === 502 || status === 503 || status === 504) { diff --git a/open-sse/services/antigravityProjectPersist.ts b/open-sse/services/antigravityProjectPersist.ts new file mode 100644 index 0000000000..8a0d1070a5 --- /dev/null +++ b/open-sse/services/antigravityProjectPersist.ts @@ -0,0 +1,42 @@ +/** + * Persist a runtime-discovered Antigravity projectId back onto its connection row + * (#8491). + * + * `ensureAntigravityProjectAssigned()` recovers a missing projectId via a + * `loadCodeAssist` round-trip and hands it back to the caller for the in-flight + * request only — nothing wrote it back to the connection record, so every + * subsequent token refresh (or process restart) lost the discovery and forced a + * fresh round-trip. This module is the single best-effort write path both call + * sites (`open-sse/executors/antigravity.ts` and the models-discovery + * normalizer) funnel through, mirroring the shape `mapAntigravityTokens()` + * already persists at OAuth-exchange time (`src/lib/oauth/providers/antigravity.ts`). + */ + +import { updateProviderConnection } from "@/lib/db/providers"; + +/** + * Write `discoveredProjectId` onto both the `projectId` column and + * `providerSpecificData.projectId` for `connectionId`, preserving any other + * `providerSpecificData` fields already on the connection. + * + * Best-effort / non-fatal by design: a persistence failure must never block + * the in-flight request, which already has the discovered id in hand. + */ +export async function persistDiscoveredAntigravityProjectId( + connectionId: string | undefined | null, + discoveredProjectId: string | undefined | null, + existingProviderSpecificData?: Record | null +): Promise { + if (!connectionId || !discoveredProjectId) return; + try { + await updateProviderConnection(connectionId, { + projectId: discoveredProjectId, + providerSpecificData: { + ...(existingProviderSpecificData || {}), + projectId: discoveredProjectId, + }, + }); + } catch { + // Non-fatal: persistence failure must never block the in-flight request. + } +} diff --git a/open-sse/services/ccBridgeTransforms.ts b/open-sse/services/ccBridgeTransforms.ts index 41deb102a4..d13adbeacc 100644 --- a/open-sse/services/ccBridgeTransforms.ts +++ b/open-sse/services/ccBridgeTransforms.ts @@ -21,6 +21,11 @@ */ import { createHash } from "node:crypto"; +import { + CLAUDE_CODE_CLIENT_BUILD_REVISION, + CLAUDE_CODE_CLIENT_VERSION, +} from "@/shared/constants/claudeCodeClient"; + // ──────────────────────────────────────────────────────────────────────────── // DSL types // ──────────────────────────────────────────────────────────────────────────── @@ -96,8 +101,10 @@ export interface InjectBillingHeaderOp { * - static-zero: emit "00000" (relay endpoints don't validate) */ cchAlgo: "sha256-first-user" | "xxhash64-body" | "static-zero"; - /** Override the embedded `cc_version=` value. Defaults to `2.1.207`. */ + /** Override the embedded `cc_version=` value. Defaults to `2.1.219`. */ version?: string; + /** Override its captured build revision. Defaults to a computed compatibility suffix. */ + buildRevision?: string; } export interface CcBridgeTransformsConfig { @@ -114,7 +121,7 @@ export const CCH_SALT = "59cf53e54c78"; /** Character positions sampled from the first user message text. */ export const CCH_POSITIONS = [4, 7, 20] as const; /** Default `cc_version=` value embedded in the billing header. */ -export const DEFAULT_CLAUDE_CODE_VERSION = "2.1.207"; +export const DEFAULT_CLAUDE_CODE_VERSION = CLAUDE_CODE_CLIENT_VERSION; /** Identity sentinel prepended for Claude Agent SDK callers. */ export const CLAUDE_AGENT_SDK_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK."; @@ -176,6 +183,7 @@ export const DEFAULT_CC_BRIDGE_PIPELINE: TransformOp[] = [ entrypoint: "sdk-cli", versionFormat: "ex-machina", cchAlgo: "sha256-first-user", + buildRevision: CLAUDE_CODE_CLIENT_BUILD_REVISION, }, ]; @@ -267,6 +275,7 @@ interface BuildBillingHeaderOptions { versionFormat: "ex-machina" | "omniroute-daystamp"; cchAlgo: "sha256-first-user" | "xxhash64-body" | "static-zero"; version?: string; + buildRevision?: string; now?: Date; } @@ -287,9 +296,10 @@ export function buildBillingHeaderValue( const firstUserText = extractFirstUserMessageText(messages); const suffix = - options.versionFormat === "omniroute-daystamp" + options.buildRevision ?? + (options.versionFormat === "omniroute-daystamp" ? computeDaystampVersionSuffix(version, options.now) - : computeExMachinaVersionSuffix(firstUserText, version); + : computeExMachinaVersionSuffix(firstUserText, version)); let cch: string; switch (options.cchAlgo) { @@ -462,6 +472,7 @@ function applyInjectBillingHeader( versionFormat: op.versionFormat, cchAlgo: op.cchAlgo, version: op.version, + buildRevision: op.buildRevision, }); // Idempotency: replace any existing billing header block (ex-machina + native diff --git a/open-sse/services/claudeAdaptiveThinking.ts b/open-sse/services/claudeAdaptiveThinking.ts index 1239eabc04..d65c79de2b 100644 --- a/open-sse/services/claudeAdaptiveThinking.ts +++ b/open-sse/services/claudeAdaptiveThinking.ts @@ -1,6 +1,10 @@ -import { isAdaptiveThinkingOnly } from "@/shared/constants/modelSpecs.ts"; +import { + getMaxEffortWhenThinkingDisabled, + isAdaptiveThinkingOnly, +} from "@/shared/constants/modelSpecs.ts"; type JsonRecord = Record; +const DIRECT_ANTHROPIC_API_PROVIDERS = new Set(["anthropic", "claude"]); function asRecord(value: unknown): JsonRecord | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; @@ -9,20 +13,22 @@ function asRecord(value: unknown): JsonRecord | null { /** * Collapse manual extended thinking to adaptive for Claude models that no longer accept it. * - * Claude Opus 4.7 and later (Opus 4.7/4.8, Fable 5) removed manual extended thinking: the + * Claude Opus 4.7 and later (Opus 4.7/4.8/5, Fable 5) removed manual extended thinking: the * Messages API returns HTTP 400 for `thinking.type:"enabled"` and for ANY * `thinking.budget_tokens`. Reasoning is steered exclusively by `output_config.effort` - * (Anthropic migration guide, 2026-05-19). OmniRoute can still produce a manual thinking - * block on these models from several paths — a Claude-native passthrough client sending the - * legacy shape, the OpenAI→Claude translator's reasoning_effort buckets, or a per-model - * thinking default — so this is the final, provider-agnostic guard keyed on the target model. + * (Anthropic's current model migration guidance). OmniRoute can still produce a manual + * thinking block on these models from several paths — a Claude-native passthrough client + * sending the legacy shape, the OpenAI→Claude translator's reasoning_effort buckets, or a + * per-model thinking default — so this is the final, provider-agnostic guard keyed on the + * target model. * * Returns a NEW object only when it changes the body: - * - `thinking.type:"enabled"` → `"adaptive"` (the only supported mode); + * - `thinking.type:"enabled"` → `"adaptive"` (the only supported enabled mode); * - `thinking.budget_tokens` / `thinking.max_tokens` → dropped (rejected extras). * `thinking.type:"adaptive"` is left as-is (just stripped of any stray budget), and * `thinking.type:"disabled"` is left untouched — that's handled separately by - * `normalizeThinkingForModel` for the models that reject `disabled` (#3554). + * `normalizeThinkingForModel` for models that reject `disabled` (#3554), and by + * `normalizeClaudeDisabledThinkingEffort` for direct Anthropic API constraints. * * No-op (returns the same reference) when the model is not adaptive-only, when there is no * thinking object, or when the thinking object already carries no manual-budget signal — @@ -50,3 +56,35 @@ export function normalizeClaudeAdaptiveThinking>( + body: T, + model: string | null | undefined, + provider: string | null | undefined +): T { + if (!provider || !DIRECT_ANTHROPIC_API_PROVIDERS.has(provider)) return body; + + const disabledEffortCap = getMaxEffortWhenThinkingDisabled(model); + if (disabledEffortCap !== "high") return body; + + const record = asRecord(body); + const thinking = asRecord(record?.thinking); + const outputConfig = asRecord(record?.output_config); + const effort = typeof outputConfig?.effort === "string" ? outputConfig.effort.toLowerCase() : ""; + if (thinking?.type !== "disabled" || !outputConfig || (effort !== "xhigh" && effort !== "max")) { + return body; + } + + return { + ...record, + output_config: { ...outputConfig, effort: disabledEffortCap }, + } as T; +} diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 5e3f5c7ae8..79d2779881 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -2,6 +2,11 @@ import { createHash, randomUUID } from "node:crypto"; import { getStainlessTimeoutSeconds } from "@/shared/utils/runtimeTimeouts"; import { ANTHROPIC_VERSION_HEADER } from "../config/anthropicHeaders.ts"; +import { + CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION, + CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION, + CLAUDE_CODE_COMPATIBLE_USER_AGENT, +} from "../config/claudeCodeCompatibleIdentity.ts"; import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts"; import { prepareClaudeRequest } from "../translator/helpers/claudeHelper.ts"; import { signRequestBody } from "./claudeCodeCCH.ts"; @@ -43,10 +48,7 @@ export { CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA, resolveClaudeCodeCompatibleAnthropicBeta, } from "./claudeCodeCompatibleBeta.ts"; -export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.207"; -export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.207 (external, sdk-cli)"; -export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0"; -export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0"; +export * from "../config/claudeCodeCompatibleIdentity.ts"; export const CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07"; const CLAUDE_CODE_COMPATIBLE_DEFAULT_SYSTEM_BLOCKS = [ { @@ -65,7 +67,6 @@ const CONTEXT_1M_SUPPORTED_MODELS = [ export const CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS = getStainlessTimeoutSeconds( process.env ); - type HeaderLike = | Headers | Record diff --git a/open-sse/services/claudeTlsClient.ts b/open-sse/services/claudeTlsClient.ts index 3e8d90a199..157c5996aa 100644 --- a/open-sse/services/claudeTlsClient.ts +++ b/open-sse/services/claudeTlsClient.ts @@ -405,7 +405,12 @@ export async function tlsFetchStreaming( // that race; if the request actually fails before producing any bytes, // the timeout falls through to the requestPromise drain below (returning // the real upstream status). - const ready = await waitForContent(path, 5_000, requestPromise); + // Do not impose a second, shorter first-byte timeout here. Opus-class + // models can legitimately take more than five seconds before emitting the + // first SSE event. `requestPromise` is already guarded by the configured + // wire timeout plus the JS hard-timeout grace, so waiting until either the + // file has data or that promise settles remains bounded. + const ready = await waitForContent(path, requestPromise); if (!ready) { const r = await requestPromise.catch( (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike @@ -501,7 +506,6 @@ async function readFirstBytes(path: string, n: number): Promise { */ async function waitForContent( path: string, - timeoutMs: number, requestPromise: Promise ): Promise { let requestSettled = false; @@ -513,8 +517,7 @@ async function waitForContent( requestSettled = true; } ); - const start = Date.now(); - while (Date.now() - start < timeoutMs) { + while (true) { try { const s = await stat(path); if (s.size > 0) return true; @@ -526,7 +529,6 @@ async function waitForContent( if (requestSettled) return false; await sleep(25); } - return false; } function tailFile( diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index ed1a3aece5..60851c2302 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -90,6 +90,7 @@ import { expandPromptCacheAffinityTargets, expandPromptCacheAffinityTargetsFromConnections, resolvePromptCacheAffinityKey, + shouldProtectOriginalFirst, } from "./combo/promptCacheAffinity.ts"; import type { CompressionMode } from "./compression/types.ts"; import { getCachedProviderConnections } from "../../src/lib/db/readCache"; @@ -158,6 +159,7 @@ import { shouldSkipForPredictedTtft, shouldRecordProviderBreakerFailure, isRequestScopedUpstreamFailure, + isInputBoundRequestFailure, shouldSkipConnDisable, resolveDelayMs, comboModelNotFoundResponse, @@ -165,10 +167,25 @@ import { isTokenLimitBreachErrorBody, toRecordedTarget, getExhaustedTargetSkipReason, + clampPercent, + quotaRemainingPercentFromQuota, + normalizeConnectionStatus, + hasFutureRateLimitUntil, + getConnectionStatusQuotaCutoffReason, + isContextOverflow400, + isParamValidation400, + isModelScoped400, } from "./combo/comboPredicates.ts"; +export { + getConnectionStatusQuotaCutoffReason, + isContextOverflow400, + isParamValidation400, + isModelScoped400, +}; import { applyComboTargetExhaustion } from "./combo/targetExhaustion.ts"; import { executeRuntimeUnitCombo } from "./combo/runtimeUnits.ts"; import { extractFusionPanelSpec, buildFusionHandleSingleModel } from "./combo/fusionPanel.ts"; +import { isRetryAfterEligibleStatus } from "./combo/unavailableRetryGate.ts"; import { isRecord } from "./combo/comboData.ts"; import { expandProviderWildcardsInCombo, @@ -299,64 +316,6 @@ function getBootstrapLatencyMs(modelId: string): number { return DEFAULT_MODEL_P95_MS[normalized] ?? 1500; } -function clampPercent(value: number): number { - if (!Number.isFinite(value)) return 100; - return Math.max(0, Math.min(100, value)); -} - -function quotaRemainingPercentFromQuota(quota: unknown): number { - if (!quota || typeof quota !== "object") return 100; - const record = quota as Record; - if (record.limitReached === true) return 0; - - const windows = record.windows; - if (windows && typeof windows === "object" && !Array.isArray(windows)) { - let minRemaining: number | null = null; - for (const windowInfo of Object.values(windows as Record)) { - if (!windowInfo || typeof windowInfo !== "object") continue; - const percentUsed = Number((windowInfo as Record).percentUsed); - if (!Number.isFinite(percentUsed)) continue; - const remaining = clampPercent((1 - percentUsed) * 100); - minRemaining = minRemaining === null ? remaining : Math.min(minRemaining, remaining); - } - if (minRemaining !== null) return minRemaining; - } - - const percentUsed = Number(record.percentUsed); - if (Number.isFinite(percentUsed)) return clampPercent((1 - percentUsed) * 100); - return 100; -} - -const QUOTA_BLOCKING_CONNECTION_STATUSES = new Set([ - "banned", - "credits_exhausted", - "deactivated", - "expired", - "rate_limited", -]); - -function normalizeConnectionStatus(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - -function hasFutureRateLimitUntil(value: unknown): boolean { - if (value == null || value === "") return false; - const time = new Date(String(value)).getTime(); - return Number.isFinite(time) && time > Date.now(); -} - -export function getConnectionStatusQuotaCutoffReason( - connection: Record | undefined -): string | undefined { - if (!connection) return undefined; - const status = normalizeConnectionStatus(connection.testStatus); - if (QUOTA_BLOCKING_CONNECTION_STATUSES.has(status)) return status; - if (status === "unavailable" && hasFutureRateLimitUntil(connection.rateLimitedUntil)) { - return "rate_limited"; - } - return undefined; -} - export async function buildAutoCandidates( targets: ResolvedComboTarget[], comboName: string, @@ -679,48 +638,6 @@ async function isPinnedModelDurablyUnhealthy(pinnedModel: string): Promise p.test(errorText)) - ); -} -/** @param {string} errorText */ -export function isParamValidation400(errorText) { - return ( - /\bmax_tokens\b.*(?:illegal|must|range|invalid)/i.test(errorText) || - /\bparameter is illegal\b/i.test(errorText) || - /\bis illegal.*range\b/i.test(errorText) - ); -} -/** - * #5249 / #2101: model-scoped 400s must NEVER stop the combo. - * Upstream often wraps "model X is not supported" in `invalid_request_error` / - * "Bad Request" envelopes. Those wrapper words match the body-specific stop - * substrings, so without this exemption the combo hard-stops on the first - * unavailable model instead of trying the next target. Keep the models in the - * combo — if one rejects, advance. - * @param {string} errorText - */ -export function isModelScoped400(errorText) { - const text = String(errorText || ""); - if (!text) return false; - if (MODEL_ACCESS_DENIED_PATTERNS.some((p) => p.test(text))) return true; - // Extra model-rejection shapes that providers emit outside the shared list - // (Responses API, Copilot, gateway wrappers). - return ( - /\bmodel\b[\s\S]{0,80}?\b(?:not\s+supported|unsupported|unknown|unavailable)\b/i.test(text) || - /\b(?:not\s+supported|unsupported|unknown)\b[\s\S]{0,80}?\bmodel\b/i.test(text) || - /\bunsupported_api_for_model\b/i.test(text) || - /\bdoes\s+not\s+support\s+(?:the\s+)?responses\s+api\b/i.test(text) - ); -} /** @param {object} options */ export async function handleComboChat({ @@ -1390,10 +1307,7 @@ export async function handleComboChat({ ); if (promptCacheAffinity.applied) { const protectedOriginal = - (_sticky.stuck || - autoUsedExplicitRouter || - strategy === "quota-share" || - strategy === "weighted") && + shouldProtectOriginalFirst(_sticky.stuck, autoUsedExplicitRouter, strategy) && orderedTargets[0]; const protectedFirst = protectedOriginal ? (promptCacheAffinity.targets.find( @@ -1805,12 +1719,9 @@ export async function handleComboChat({ // QA P0 diagnostics: capture the attempt order (provider/model ids only). comboAttemptOrder.push({ provider: provider ?? "unknown", model: modelStr }); - // Deep clone the body to ensure context preservation and prevent mutations - // from affecting other targets in the combo. structuredClone avoids the - // full intermediate JSON string that JSON.parse(JSON.stringify(...)) builds - // (a second multi-hundred-KB allocation per target on large agent payloads), - // halving the per-target transient heap on the hot path (#5152). - let attemptBody = structuredClone(body); + // Copy-on-write, not a deep clone (#7847 — 9.53 MiB at 3 targets). Writes here are + // top-level scalars. Invariant: tests/unit/combo-attempt-body-isolation-7847.test.ts. + let attemptBody = { ...(body as Record) } as typeof body; // Proactive Context Compression for fallbacks (Zero-Latency optimization) if ( @@ -1820,7 +1731,8 @@ export async function handleComboChat({ config.fallbackCompressionMode !== "off" ) { const { estimateTokens } = await import("./contextManager.ts"); - const estimatedTokens = estimateTokens(JSON.stringify(attemptBody)); + // #7847: object, not JSON.stringify — the string branch mis-counts inline images. + const estimatedTokens = estimateTokens(attemptBody); if (estimatedTokens > (config.fallbackCompressionThreshold ?? 1000)) { const { applyCompression } = await import("./compression/strategySelector.ts"); const compressionResult = applyCompression( @@ -1933,7 +1845,7 @@ export async function handleComboChat({ // Fix #1707: Set terminal state so the fallback doesn't emit // misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality. lastError = `Upstream response failed quality validation: ${quality.reason}`; - if (!lastStatus) lastStatus = 502; + lastStatus = 502; if (i > 0) fallbackCount++; if (provider && rawModel) { const mlSettings = resolveModelLockoutSettings(settings); @@ -2272,6 +2184,39 @@ export async function handleComboChat({ } : undefined; const requestScopedFailure = isRequestScopedUpstreamFailure(structuredError); + + // #8375: input-bound request-scoped failures (context_length_exceeded) are + // deterministic for the same input — retrying on other accounts of the same + // model will fail identically. Short-circuit the combo immediately with the + // original error instead of burning MAX_GLOBAL_ATTEMPTS. + // Scoped to homogeneous remainders only: a heterogeneous combo (#6637) may + // have a later target with a different, larger context window that would + // NOT reject the same input — isContextOverflow400 below exists precisely to + // let that case fall through, so only short-circuit when every remaining + // target is the same model (the "retrying will fail identically" premise + // only holds within a homogeneous same-model pool). + const remainingTargets = orderedTargets.slice(i + 1); + const remainderIsHomogeneous = remainingTargets.every( + (nextInPool) => nextInPool.modelStr === modelStr + ); + const isInputBoundFailure = + isInputBoundRequestFailure(structuredError) && remainderIsHomogeneous; + if (isInputBoundFailure) { + log.warn( + "COMBO", + `Input-bound request failure from ${modelStr} — aborting combo (same input will fail identically on every account)` + ); + recordComboRequest(combo.name, modelStr, { + success: false, + latencyMs: Date.now() - startTime, + fallbackCount, + strategy, + target: toRecordedTarget(target), + }); + recordedAttempts++; + if (i > 0) fallbackCount++; + return { ok: false, response: result }; + } const fallbackResult = checkFallbackError( result.status, errorText, @@ -2371,7 +2316,7 @@ export async function handleComboChat({ status: result.status, error: errorText || String(result.status), }); - if (!lastStatus) lastStatus = result.status; + lastStatus = result.status; if (i > 0) fallbackCount++; log.warn("COMBO", `Model ${modelStr} failed with body-specific error, stopping combo`); // #4279: surface the 400 via the {ok,response} contract so the OUTER @@ -2383,12 +2328,11 @@ export async function handleComboChat({ return { ok: false, response: result }; } - // Trigger shared provider circuit breaker for 5xx errors and connection failures. - // If the next target in the combo is on the same provider, don't mark the provider - // as failed — different models on the same provider may still succeed. - // G-02: when fallbackResult.skipProviderBreaker is set (embedded service supervisor - // outage signalled via X-Omni-Fallback-Hint: connection_cooldown) apply connection - // cooldown only — do NOT trip the whole-provider breaker. + // Trigger shared provider circuit breaker for 5xx errors and connection failures. If the + // next target is on the same provider, don't mark it failed (a different model may still + // succeed) — #8376: EXCEPT a proxy-unreachable failure, which poisons every model alike. + // G-02: when fallbackResult.skipProviderBreaker is set (embedded service supervisor outage + // signalled via X-Omni-Fallback-Hint: connection_cooldown) apply cooldown only — never trip. const nextTarget = orderedTargets[i + 1]; const sameProviderNext = typeof nextTarget?.provider === "string" && nextTarget.provider === provider; @@ -2400,6 +2344,7 @@ export async function handleComboChat({ skipProviderBreaker: fallbackResult.skipProviderBreaker, requestScopedFailure, error: errorText, + isProxyUnreachable: structuredError?.code === "proxy_unreachable", }) ) { recordProviderFailure(provider, log, targetWithConnection.connectionId, profile); @@ -2427,7 +2372,7 @@ export async function handleComboChat({ // decision below, even though a real 429 with a short (~1min) retry-after // was just observed. Recording it here mirrors the "done retrying" path. lastError = errorText || String(result.status); - if (!lastStatus) lastStatus = result.status; + lastStatus = result.status; if (i > 0) fallbackCount++; return null; } @@ -2464,7 +2409,7 @@ export async function handleComboChat({ // Same fix as the already-locked branch above — this is the // first-failure lockout path, so lastStatus needs recording here too. lastError = errorText || String(result.status); - if (!lastStatus) lastStatus = result.status; + lastStatus = result.status; if (i > 0) fallbackCount++; return null; } @@ -2486,7 +2431,7 @@ export async function handleComboChat({ status: result.status, error: errorText || String(result.status), }); - if (!lastStatus) lastStatus = result.status; + lastStatus = result.status; if (i > 0) fallbackCount++; // Wire combo failures into the resilience dashboard (model-level lockout) // alongside the provider-level cooldown below — they govern different scopes. @@ -2710,63 +2655,63 @@ export async function handleComboChat({ : ""; const msg = (lastError || "All combo models unavailable") + comboErrorSummary; - if (earliestRetryAfter) { - // Cooldown-aware retry: instead of crystallizing the 429/503, wait out - // a SHORT transient cooldown and re-run the whole set loop. Guarded by - // the helper (quota_exhausted/auth/not-found excluded, ceiling, - // attempts, budget). MAX_GLOBAL_ATTEMPTS still bounds total dispatches. - // Available to ALL combo strategies (not just quota-share). - if (comboCooldownWaitEnabled && status === 429) { - // ONE decision path for EVERY strategy. The reason that drives the - // wait is always the target's REAL model-lockout reason, resolved - // through the helper's allow-list — never a hardcoded literal. - // - // SECURITY (see comboCooldownRetry.ts header): the allow-list is the - // PRIMARY barrier and `maxWaitMs` only the SECOND one. Hardcoding - // reason:"rate_limit" for non-quota-share strategies would drop the - // primary barrier and leave only the ceiling — which does NOT cover a - // quota_exhausted lock carrying a SHORT upstream retry-after (e.g. - // 3s < maxWaitMs): the combo would wait, redispatch against a model - // locked until midnight, and burn the attempt. Model lockouts are - // recorded for all strategies (recordModelLockoutFailure above is not - // gated on quota-share), so the real reason is always available. - const decision: ResolveComboCooldownDecisionResult = resolveComboCooldownWaitDecision({ - targets: orderedTargets, - earliestRetryAfter, - attempt: comboCooldownAttempt, - budgetLeftMs: comboCooldownBudgetLeftMs, - settings: resilienceSettings.comboCooldownWait, - // Key each lookup on the TARGET's own model: quota-share combos are - // single-model/multi-account (so this is identical to the previous - // orderedTargets[0] behavior), but heterogeneous combos carry a - // different model per target. - lookupLock: (provider, connectionId, target) => { - const rawModel = parseModel(target?.modelStr ?? "").model || ""; - if (!rawModel) return null; - return getModelLockoutInfo(provider, connectionId, rawModel); - }, - computeWaitMs: (retryAfter) => computeClosestRetryAfter(retryAfter).waitMs, - }); + // Cooldown-aware retry: instead of crystallizing a transient failure, wait + // out a SHORT cooldown and re-run the whole set loop. Guarded by the helper + // (quota_exhausted/auth/not-found excluded, ceiling, attempts, budget). + // MAX_GLOBAL_ATTEMPTS still bounds total dispatches. Available to ALL combo + // strategies when enabled — entry is driven by earliestRetryAfter + the + // real model-lockout reason, NOT by whichever target last overwrote + // `status` (a later 403 must not skip the allow-list check for an earlier + // 429's retry-after hint). SECURITY (see comboCooldownRetry.ts header): the + // allow-list is the PRIMARY barrier and `maxWaitMs` only the SECOND one. + // Hardcoding reason:"rate_limit" would drop the primary barrier and leave + // only the ceiling — which does NOT cover a quota_exhausted lock carrying a + // SHORT upstream retry-after. Model lockouts are recorded for all strategies, + // so the real reason is always available. + if (comboCooldownWaitEnabled && earliestRetryAfter) { + const decision: ResolveComboCooldownDecisionResult = resolveComboCooldownWaitDecision({ + targets: orderedTargets, + earliestRetryAfter, + attempt: comboCooldownAttempt, + budgetLeftMs: comboCooldownBudgetLeftMs, + settings: resilienceSettings.comboCooldownWait, + // Key each lookup on the TARGET's own model: quota-share combos are + // single-model/multi-account (so this is identical to the previous + // orderedTargets[0] behavior), but heterogeneous combos carry a + // different model per target. + lookupLock: (provider, connectionId, target) => { + const rawModel = parseModel(target?.modelStr ?? "").model || ""; + if (!rawModel) return null; + return getModelLockoutInfo(provider, connectionId, rawModel); + }, + computeWaitMs: (retryAfter) => computeClosestRetryAfter(retryAfter).waitMs, + }); - if (decision.wait) { - log.info( - "COMBO", - `${strategy} cooldown wait: ${msg} — waiting ${Math.ceil( - decision.waitMs / 1000 - )}s (reason=${decision.reason ?? "?"}) then retrying (attempt ${ - comboCooldownAttempt + 1 - }/${resilienceSettings.comboCooldownWait.maxAttempts})` - ); - const completed = await waitForCooldownAwareRetry(decision.waitMs, signal); - if (!completed) { - log.info("COMBO", `${strategy} cooldown wait aborted by client disconnect`); - return errorResponse(499, "Request aborted"); - } - comboCooldownAttempt += 1; - comboCooldownBudgetLeftMs = Math.max(0, comboCooldownBudgetLeftMs - decision.waitMs); - return dispatchWithCooldownRetry(); + if (decision.wait) { + log.info( + "COMBO", + `${strategy} cooldown wait: ${msg} — waiting ${Math.ceil( + decision.waitMs / 1000 + )}s (reason=${decision.reason ?? "?"}) then retrying (attempt ${ + comboCooldownAttempt + 1 + }/${resilienceSettings.comboCooldownWait.maxAttempts})` + ); + const completed = await waitForCooldownAwareRetry(decision.waitMs, signal); + if (!completed) { + log.info("COMBO", `${strategy} cooldown wait aborted by client disconnect`); + return errorResponse(499, "Request aborted"); } + comboCooldownAttempt += 1; + comboCooldownBudgetLeftMs = Math.max(0, comboCooldownBudgetLeftMs - decision.waitMs); + return dispatchWithCooldownRetry(); } + } + + // Retry-after decoration is separate from the wait decision above: only + // rate-limit-class final statuses may carry a `(reset after ...)` suffix + // (see unavailableRetryGate.ts — do not stitch a peer target's window onto + // a config-class status like 403/422). + if (earliestRetryAfter && isRetryAfterEligibleStatus(status)) { const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter)); log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`); return unavailableResponse(status, msg, earliestRetryAfter, retryHuman); @@ -2925,7 +2870,11 @@ async function handleRoundRobinCombo({ // BEFORE availability is known; if every compat-kept target then turns out to be // runtime-unavailable, we must reconsider these before returning 503, instead of // permanently dropping a compat-rejected-but-healthy provider. - const compatRejectedTargets = computeCompatRejectedTargets(evalRankedTargets, filteredTargets, body); + const compatRejectedTargets = computeCompatRejectedTargets( + evalRankedTargets, + filteredTargets, + body + ); let modelCount = filteredTargets.length; if (modelCount === 0) { return comboModelNotFoundResponse("Round-robin combo has no executable targets"); @@ -3183,9 +3132,11 @@ async function handleRoundRobinCombo({ // Issue #3587: Reasoning models can spend the whole output budget on // reasoning. Apply any safe buffer to a per-attempt copy so round-robin // retries never compound across models. - let attemptBody = body; + // #7847: UNCONDITIONAL — copying only when the buffer changed max_tokens left every + // other attempt sharing the caller's object, leaking chatCore's `body.model` forward. + let attemptBody = { ...(body as Record) } as typeof body; { - const bodyRecord = body as Record; + const bodyRecord = attemptBody as Record; const currentMaxTokens = toPositiveInteger(bodyRecord.max_tokens); const bufferedMaxTokens = resolveReasoningBufferedMaxTokens( modelStr, @@ -3197,10 +3148,8 @@ async function handleRoundRobinCombo({ bufferedMaxTokens !== null && bufferedMaxTokens !== currentMaxTokens ) { - attemptBody = { - ...bodyRecord, - max_tokens: bufferedMaxTokens, - } as typeof body; + // Safe to write in place: bodyRecord is the per-attempt copy above, not the caller's. + bodyRecord.max_tokens = bufferedMaxTokens; log.info( "COMBO-RR", `Reasoning model ${modelStr}: adjusted max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}` @@ -3259,7 +3208,7 @@ async function handleRoundRobinCombo({ // Fix #1707: Set terminal state so the fallback doesn't emit // misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality. lastError = `Upstream response failed quality validation: ${quality.reason}`; - if (!lastStatus) lastStatus = 502; + lastStatus = 502; if (offset > 0) fallbackCount++; break; // move to next model } @@ -3517,7 +3466,7 @@ async function handleRoundRobinCombo({ }); recordedAttempts++; lastError = errorText || String(result.status); - if (!lastStatus) lastStatus = result.status; + lastStatus = result.status; if (offset > 0) fallbackCount++; log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status }); @@ -3627,7 +3576,7 @@ async function handleRoundRobinCombo({ const status = lastStatus; const msg = lastError || "All round-robin combo models unavailable"; - if (earliestRetryAfter) { + if (earliestRetryAfter && isRetryAfterEligibleStatus(status)) { const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter)); log.warn("COMBO-RR", `All models failed | ${msg} (${retryHuman})`); return unavailableResponse(status, msg, earliestRetryAfter, retryHuman); diff --git a/open-sse/services/combo/comboPredicates.ts b/open-sse/services/combo/comboPredicates.ts index 315a49a5a2..07478591a8 100644 --- a/open-sse/services/combo/comboPredicates.ts +++ b/open-sse/services/combo/comboPredicates.ts @@ -10,6 +10,7 @@ import { errorResponse } from "../../utils/error.ts"; import { parseModel } from "../model.ts"; import { isSelfInflictedUpstreamTimeout } from "../../handlers/chatCore/cooldownClassification.ts"; import { isLocalStreamLifecycleError } from "@/shared/utils/circuitBreaker"; +import { CONTEXT_OVERFLOW_PATTERNS, MODEL_ACCESS_DENIED_PATTERNS } from "../accountFallback.ts"; import type { ResolvedComboTarget } from "./types.ts"; // Status codes that should mark round-robin target semaphores as cooling down. @@ -140,7 +141,12 @@ const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]); * this intentionally differs from `isProviderFailureCode` (accountFallback.ts), which * INCLUDES 429 for connection-cooldown purposes and must not be changed here. * - When the next combo target is on the SAME provider, don't trip the provider breaker: - * a different model on that provider may still succeed. + * a different model on that provider may still succeed. #8376: EXCEPT when the failure + * itself is a transport-level "proxy unreachable" event (`isProxyUnreachable`) — a dead + * upstream proxy poisons every account on that provider identically, so a different + * model on the same provider will fail the exact same way. Without this override a + * homogeneous same-provider combo pool never trips the breaker and instead burns every + * attempt against the same dead proxy until it hits the 503 max-retry limit. * - G-02 / #2743: when the fallback result carries `skipProviderBreaker` (an embedded * service supervisor outage signalled via `X-Omni-Fallback-Hint: connection_cooldown`) * apply connection cooldown ONLY — never trip the whole-provider breaker. @@ -161,11 +167,14 @@ export function shouldRecordProviderBreakerFailure(args: { skipProviderBreaker?: boolean; requestScopedFailure?: boolean; error?: unknown; + /** #8376: transport-level "proxy unreachable" signal — overrides the `sameProviderNext` + * exemption only; every other AND-term still gates the trip. */ + isProxyUnreachable?: boolean; }): boolean { return ( !args.isStreamReadinessFailure && PROVIDER_BREAKER_FAILURE_STATUSES.has(args.status) && - !args.sameProviderNext && + (!args.sameProviderNext || args.isProxyUnreachable === true) && !args.skipProviderBreaker && !args.requestScopedFailure && !isLocalStreamLifecycleError(args.error) @@ -188,6 +197,23 @@ export function isRequestScopedUpstreamFailure(error?: { return REQUEST_SCOPED_UPSTREAM_ERROR_CODES.has(code) || type === "context_length_exceeded"; } +const INPUT_BOUND_ERROR_CODES = new Set(["context_length_exceeded", "context_window_exceeded"]); + +/** + * #8375: Whether an upstream error is input-bound — i.e. determined solely by the + * request content, not by the provider/account state. A context_length_exceeded + * for a 159K-token input will fail on every account of that same model, so the + * combo loop should propagate the error immediately instead of retrying. + */ +export function isInputBoundRequestFailure(error?: { + code?: string | null; + type?: string | null; +}): boolean { + const code = typeof error?.code === "string" ? error.code.toLowerCase() : ""; + const type = typeof error?.type === "string" ? error.type.toLowerCase() : ""; + return INPUT_BOUND_ERROR_CODES.has(code) || type === "context_length_exceeded"; +} + /** * #7177: whether handleSingleModelChat should skip the connection-level cooldown * (markAccountUnavailable) for a failed attempt — client disconnects, a 401 when the @@ -293,3 +319,99 @@ export function toRecordedTarget(target: ResolvedComboTarget) { label: target.label, }; } + +export function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 100; + return Math.max(0, Math.min(100, value)); +} + +export function quotaRemainingPercentFromQuota(quota: unknown): number { + if (!quota || typeof quota !== "object") return 100; + const record = quota as Record; + if (record.limitReached === true) return 0; + + const windows = record.windows; + if (windows && typeof windows === "object" && !Array.isArray(windows)) { + let minRemaining: number | null = null; + for (const windowInfo of Object.values(windows as Record)) { + if (!windowInfo || typeof windowInfo !== "object") continue; + const percentUsed = Number((windowInfo as Record).percentUsed); + if (!Number.isFinite(percentUsed)) continue; + const remaining = clampPercent((1 - percentUsed) * 100); + minRemaining = minRemaining === null ? remaining : Math.min(minRemaining, remaining); + } + if (minRemaining !== null) return minRemaining; + } + + const percentUsed = Number(record.percentUsed); + if (Number.isFinite(percentUsed)) return clampPercent((1 - percentUsed) * 100); + return 100; +} + +export const QUOTA_BLOCKING_CONNECTION_STATUSES = new Set([ + "banned", + "credits_exhausted", + "deactivated", + "expired", + "rate_limited", +]); + +export function normalizeConnectionStatus(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +export function hasFutureRateLimitUntil(value: unknown): boolean { + if (value == null || value === "") return false; + const time = new Date(String(value)).getTime(); + return Number.isFinite(time) && time > Date.now(); +} + +export function getConnectionStatusQuotaCutoffReason( + connection: Record | undefined +): string | undefined { + if (!connection) return undefined; + const status = normalizeConnectionStatus(connection.testStatus); + if (QUOTA_BLOCKING_CONNECTION_STATUSES.has(status)) return status; + if (status === "unavailable" && hasFutureRateLimitUntil(connection.rateLimitedUntil)) { + return "rate_limited"; + } + return undefined; +} + +/** @param {string} errorText */ +export function isContextOverflow400(errorText: string | null | undefined): boolean { + const text = String(errorText || ""); + if (!text) return false; + return ( + /\bcontext.*(?:length_exceeded|too long|overflow|exceeded|window|limit)\b/i.test(text) || + /exceeds.*context/i.test(text) || + /your input exceeds/i.test(text) || + CONTEXT_OVERFLOW_PATTERNS.some((p) => p.test(text)) + ); +} + +/** @param {string} errorText */ +export function isParamValidation400(errorText: string | null | undefined): boolean { + const text = String(errorText || ""); + if (!text) return false; + return ( + /\bmax_tokens\b.*(?:illegal|must|range|invalid)/i.test(text) || + /\bparameter is illegal\b/i.test(text) || + /\bis illegal.*range\b/i.test(text) + ); +} + +/** + * #5249 / #2101: model-scoped 400s must NEVER stop the combo. + */ +export function isModelScoped400(errorText: string | null | undefined): boolean { + const text = String(errorText || ""); + if (!text) return false; + if (MODEL_ACCESS_DENIED_PATTERNS.some((p) => p.test(text))) return true; + return ( + /\bmodel\b[\s\S]{0,80}?\b(?:not\s+supported|unsupported|unknown|unavailable)\b/i.test(text) || + /\b(?:not\s+supported|unsupported|unknown)\b[\s\S]{0,80}?\bmodel\b/i.test(text) || + /\bunsupported_api_for_model\b/i.test(text) || + /\bdoes\s+not\s+support\s+(?:the\s+)?responses\s+api\b/i.test(text) + ); +} diff --git a/open-sse/services/combo/promptCacheAffinity.ts b/open-sse/services/combo/promptCacheAffinity.ts index fdfe9e1352..070e50f84e 100644 --- a/open-sse/services/combo/promptCacheAffinity.ts +++ b/open-sse/services/combo/promptCacheAffinity.ts @@ -237,6 +237,34 @@ export function expandPromptCacheAffinityTargetsFromConnections( return expandedTargets; } +/** + * #8370: decide whether the strategy-selected first target must be re-pinned + * ahead of a global, cross-model prompt-cache-affinity reorder. + * + * `priority`, `fill-first`, and `lkgp` are deterministic, operator-ordered + * strategies: their first target is a meaningful choice (explicit priority + * order, first-available slot, last-known-good pin) that a rendezvous-hash + * reorder must not silently override, even though affinity is still free to + * pick among the remaining/fallback targets. `quota-share` and `weighted` + * were already protected before this fix; session stickiness and an explicit + * auto-router pin remain independently protected via their own flags. + */ +export function shouldProtectOriginalFirst( + stickyStuck: boolean, + autoUsedExplicitRouter: boolean, + strategy: string +): boolean { + return ( + stickyStuck || + autoUsedExplicitRouter || + strategy === "quota-share" || + strategy === "weighted" || + strategy === "priority" || + strategy === "fill-first" || + strategy === "lkgp" + ); +} + /** * Order eligible targets using rendezvous hashing. The original order is used * as the final tie-breaker, so targets sharing one account identity remain diff --git a/open-sse/services/combo/targetExhaustion.ts b/open-sse/services/combo/targetExhaustion.ts index dc8bfb015f..85cc07dbb7 100644 --- a/open-sse/services/combo/targetExhaustion.ts +++ b/open-sse/services/combo/targetExhaustion.ts @@ -44,7 +44,7 @@ const AUTH_LEVEL_ERROR_STATUSES = [401, 403]; // same-provider leg via #1731v2). It is a model-level transient failure: advance to the next // leg, leaving the rest of that provider's legs eligible. function isEmptyContentFailure(status: number, errorText: string): boolean { - return status === 502 && /empty content/i.test(errorText); + return status === 502 && (/empty content/i.test(errorText) || /empty response/i.test(errorText)); } export type ComboExhaustionSets = { @@ -128,7 +128,10 @@ function markProviderQuotaExhaustion( const { sets, log, tag, exhaustedLogLevel } = opts; sets.exhaustedProviders.add(provider); const emit = exhaustedLogLevel === "debug" ? log.debug : log.info; - emit?.(tag, `Provider ${provider} quota exhausted — marking for skip on remaining targets (#1731)`); + emit?.( + tag, + `Provider ${provider} quota exhausted — marking for skip on remaining targets (#1731)` + ); } /** @@ -140,13 +143,20 @@ function markTransientOrConnectionLevel( target: ResolvedComboTarget, opts: ApplyComboTargetExhaustionOptions ): void { - const { result, errorText, rawModel, isTokenLimitBreach, sets, log, tag, structuredError } = - opts; + const { result, errorText, rawModel, isTokenLimitBreach, sets, log, tag, structuredError } = opts; const provider = target.provider; if (result.status === 429 && !isTokenLimitBreach && provider && provider !== "unknown") { sets.transientRateLimitedProviders.add(provider); } - markConnectionLevelExhaustion(target, { result, errorText, sets, log, tag, rawModel, structuredError }); + markConnectionLevelExhaustion(target, { + result, + errorText, + sets, + log, + tag, + rawModel, + structuredError, + }); } /** diff --git a/open-sse/services/combo/unavailableRetryGate.ts b/open-sse/services/combo/unavailableRetryGate.ts new file mode 100644 index 0000000000..fe9c7670b3 --- /dev/null +++ b/open-sse/services/combo/unavailableRetryGate.ts @@ -0,0 +1,33 @@ +/** + * #8486 Part B: gate for the "all targets failed" unavailable-response + * retry-after decoration in combo.ts (handleComboChat / handleRoundRobinCombo). + * + * Root cause: the aggregation loop tracks `earliestRetryAfter` as the MINIMUM + * retry-after parsed out of ANY target's own response body across the whole + * failure loop, independent of which target ends up supplying the surfaced + * `status`/`msg` pair. When a combo mixes a genuinely rate-limited target + * (real retryAfter) with a config-class failure (e.g. Antigravity's 422 + * `missing_project_id`, which carries no retryAfter of its own), the final + * unavailableResponse() stitches the rate-limited target's reset window onto + * the unrelated target's message text. + * + * `unavailableResponse()` (open-sse/utils/error.ts) has no way to know + * `status`/`msg` and `retryAfter` came from different targets, so the gate + * has to live at the call site: only decorate the response with a + * `(reset after ...)` suffix when the surfaced `status` is itself a + * rate-limit-class code that plausibly owns a retry-after window. A + * config-class status (422, 400, 401, 403, 404, ...) must never receive a + * retry-after suffix that did not originate from its own response body. + */ + +const RETRY_AFTER_ELIGIBLE_STATUSES = new Set([429, 503]); + +/** + * Whether the final aggregated combo-failure `status` is a rate-limit-class + * code allowed to be decorated with a `(reset after ...)` retry-after + * suffix. Config-class statuses (422 missing_project_id, 400, 401, 403, 404, + * ...) are excluded — see module header for the field-mismatch this guards. + */ +export function isRetryAfterEligibleStatus(status: number | null | undefined): boolean { + return typeof status === "number" && RETRY_AFTER_ELIGIBLE_STATUSES.has(status); +} diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts index eada64f694..16cf3a2262 100644 --- a/open-sse/services/comboConfig.ts +++ b/open-sse/services/comboConfig.ts @@ -38,18 +38,22 @@ export const DEFAULT_COMBO_TARGET_TIMEOUT_MS = 120_000; export const COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS = 10_000; /** - * Whether a combo's cooldown-aware wait+retry (#7360) engages for this request: only - * "quota-share" and "auto" strategies wait out a short transient cooldown instead of - * crystallizing a 429 into a combo-level failure, and only when the operator has the - * feature enabled. Shared by combo.ts (to decide whether to wait) and comboSetup.ts (to - * size the per-target timeout floor so it doesn't cut the wait off early — see + * Whether a combo's cooldown-aware wait+retry (#7360 / #7301) engages for this request. + * When the operator has the feature enabled, EVERY combo strategy waits out a short + * transient cooldown instead of crystallizing a 429 into a combo-level failure. + * Shared by combo.ts (to decide whether to wait) and comboSetup.ts (to size the + * per-target timeout floor so it doesn't cut the wait off early — see * resolveComboTargetTimeoutMsForCombo below). + * + * `strategy` is retained in the signature for call-site clarity; eligibility is + * strategy-agnostic (the wait path in combo.ts already uses the real model-lockout + * reason for every strategy). */ export function isComboCooldownWaitEligible( - strategy: string, + _strategy: string, comboCooldownWait: Pick ): boolean { - return (strategy === "quota-share" || strategy === "auto") && comboCooldownWait.enabled; + return comboCooldownWait.enabled; } /** diff --git a/open-sse/services/compression/stepDetailConfig.ts b/open-sse/services/compression/stepDetailConfig.ts new file mode 100644 index 0000000000..d1ba993641 --- /dev/null +++ b/open-sse/services/compression/stepDetailConfig.ts @@ -0,0 +1,26 @@ +// Resolves the persisted per-engine DETAIL sub-object (settings.headroom / .sessionDedup / +// .ccr) for a stacked-pipeline step. Extracted out of strategySelector.ts (frozen at cap by +// file-size-baseline.json — see scripts/check/check-file-size.mjs) rather than growing that +// file inline. +// +// #8056 wired settings.headroom.minRows into buildStepOptions so the dashboard value takes +// effect even when the stacked-pipeline step itself carries no config. #8388 extends the same +// merge to session-dedup and ccr, whose detail settings previously had nowhere to persist to +// (see compressionDetailNormalizers.ts on the DB write side of the same gap). +import type { CompressionConfig, CompressionPipelineStep } from "./types.ts"; + +export function resolveStepDetailConfig( + engine: CompressionPipelineStep["engine"], + config: CompressionConfig | undefined +): Record { + switch (engine) { + case "headroom": + return (config?.headroom as Record | undefined) ?? {}; + case "session-dedup": + return (config?.sessionDedup as Record | undefined) ?? {}; + case "ccr": + return (config?.ccr as Record | undefined) ?? {}; + default: + return {}; + } +} diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 38c2779777..8785ddb550 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -28,6 +28,7 @@ import { decideStep, mergeStackStep, } from "./stackedStepCore.ts"; +import { resolveStepDetailConfig } from "./stepDetailConfig.ts"; import { registerBuiltinCompressionEngines } from "./engines/index.ts"; import { getCompressionEngine, getEngineEntry } from "./engines/registry.ts"; import { codexResponsesEngine } from "./engines/codexResponses/index.ts"; @@ -736,13 +737,12 @@ function buildStepOptions( step: CompressionPipelineStep, options?: StackOptions ): CompressionEngineApplyOptions { - // Headroom detail (minRows) lives on settings.headroom, not only on step.config. - // Merge it so the stacked runner honors the dashboard value (#8056). Explicit - // step.config still wins so combo pipelines can override per step. - const headroomDetail = - step.engine === "headroom" ? (options?.config?.headroom ?? {}) : {}; + // Detail sub-objects (headroom.minRows #8056; sessionDedup/ccr #8388) live on + // settings., not only on step.config. Merge them so the stacked runner + // honors the dashboard value. Explicit step.config still wins so combo pipelines + // can override per step. See resolveStepDetailConfig (stepDetailConfig.ts). const stepConfig: Record = { - ...headroomDetail, + ...resolveStepDetailConfig(step.engine, options?.config), ...(step.config ?? {}), ...(step.intensity ? { intensity: step.intensity } : {}), }; diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index 66e318371d..665af5988f 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -220,6 +220,10 @@ export interface CompressionConfig { ultra?: UltraConfig; /** Headroom SmartCrusher detail settings (minRows gate). */ headroom?: HeadroomConfig; + /** Session Dedup detail settings (minBlockChars / fuzzy, #8388). */ + sessionDedup?: SessionDedupConfig; + /** CCR (context-cache-retrieval) detail settings (minChars / retrievalRampFactor, #8388). */ + ccr?: CcrConfig; /** Provider-delegated context editing (Claude/Anthropic only). */ contextEditing?: ContextEditingConfig; /** Opt-in cache-aligned live-zone compression (default disabled). */ @@ -563,6 +567,39 @@ export const DEFAULT_HEADROOM_CONFIG: HeadroomConfig = { minRows: 8, }; +// ─── Session Dedup detail settings ─────────────────────────────────────────── +// Persisted under compression settings key `sessionDedup` (#8388 — was previously +// rendered on the detail page but had no sub-object to save into). + +/** Configuration for the Session Dedup engine detail page. */ +export interface SessionDedupConfig { + /** Minimum character count for a suffix block to be a dedup candidate. Matches DEFAULT_MIN_BLOCK_CHARS=80. */ + minBlockChars: number; + /** Opt-in fuzzy near-duplicate dedup (replaces ~85%+ similar messages with a CCR marker). */ + fuzzy: boolean; +} + +export const DEFAULT_SESSION_DEDUP_CONFIG: SessionDedupConfig = { + minBlockChars: 80, + fuzzy: false, +}; + +// ─── CCR (context-cache-retrieval) detail settings ─────────────────────────── +// Persisted under compression settings key `ccr` (#8388 — same gap as session-dedup). + +/** Configuration for the CCR engine detail page. */ +export interface CcrConfig { + /** Minimum character count for a block to be a CCR candidate. Matches DEFAULT_MIN_CHARS=600. */ + minChars: number; + /** How steeply frequently-retrieved blocks resist compression; 1 disables the ramp. */ + retrievalRampFactor: number; +} + +export const DEFAULT_CCR_CONFIG: CcrConfig = { + minChars: 600, + retrievalRampFactor: 2, +}; + export type { McpAccessibilityConfig } from "./engines/mcpAccessibility/constants.ts"; export { DEFAULT_MCP_ACCESSIBILITY_CONFIG, diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index bbe844a3ac..ef13454cd4 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -7,6 +7,7 @@ import { REGISTRY } from "../config/providerRegistry.ts"; import { getModelContextLimit } from "../../src/lib/modelCapabilities.ts"; +import { jsonLength } from "../utils/jsonSize.ts"; // Default token limits per provider (fallbacks when not in registry) const DEFAULT_LIMITS: Record = { @@ -14,6 +15,10 @@ const DEFAULT_LIMITS: Record = { openai: 128000, gemini: 1000000, codex: 400000, + // HyperAgent Claude-family agents (fable/opus/sonnet) — 1M default; was falling + // through to 128k and blocking normal agentic tool loops with huge catalogs. + hyperagent: 1_000_000, + ha: 1_000_000, default: 128000, }; @@ -47,13 +52,139 @@ function getReserveTokensOverride(): number | null { // Rough chars-per-token ratio for quick estimation const CHARS_PER_TOKEN = 4; +// Bounded per-image token budget used in place of measuring the raw base64 +// payload as text. In line with the owner's PoC (~1052 total for prompt + +// 1 image) and litellm's calculate_img_tokens() default-count fast-path — +// see #8368 research notes. +const IMAGE_TOKEN_ESTIMATE = 1200; + +// Matches inline base64 data URLs, e.g. "data:image/png;base64,AAAA...". +// Deliberately scoped to `data:image/...;base64,` so remote (http/https) +// URLs and generic long base64 text strings stay on the text-estimation path. +const INLINE_BASE64_IMAGE_RE = /^data:image\/[a-zA-Z0-9.+-]+;base64,/; + +function isInlineBase64ImageUrl(value: unknown): boolean { + return typeof value === "string" && INLINE_BASE64_IMAGE_RE.test(value); +} + +// OpenAI chat.completions: { type: 'image_url', image_url: { url: 'data:...' } | 'data:...' } +function matchesOpenAIImageUrlShape(node: Record): boolean { + const imageUrl = node.image_url; + if (isInlineBase64ImageUrl(imageUrl)) return true; + return ( + !!imageUrl && + typeof imageUrl === "object" && + isInlineBase64ImageUrl((imageUrl as Record).url) + ); +} + +// AI SDK: { type: 'image', image: 'data:...' } (also covers Responses API's +// { type: 'input_image', image_url: 'data:...' } via matchesOpenAIImageUrlShape above). +function matchesAiSdkImageShape(node: Record): boolean { + return node.type === "image" && isInlineBase64ImageUrl(node.image); +} + +// Claude: { type: 'image', source: { type: 'base64', data: '...' } } +function matchesClaudeSourceShape(node: Record): boolean { + if (node.type !== "image") return false; + const source = node.source; + if (!source || typeof source !== "object") return false; + const src = source as Record; + return src.type === "base64" && typeof src.data === "string"; +} + +// Gemini: { inlineData: { data: '...' } } | { inline_data: { data: '...' } } +function matchesGeminiInlineDataShape(node: Record): boolean { + const inlineData = node.inlineData ?? node.inline_data; + if (!inlineData || typeof inlineData !== "object") return false; + return typeof (inlineData as Record).data === "string"; +} + /** - * Estimate token count from text length + * Detect the 5 documented inline-base64 image content-block shapes (see the + * shape-specific matchers above). + */ +function isInlineBase64ImageBlock(node: Record): boolean { + return ( + matchesOpenAIImageUrlShape(node) || + matchesAiSdkImageShape(node) || + matchesClaudeSourceShape(node) || + matchesGeminiInlineDataShape(node) + ); +} + +/** + * Recursively walk a structured node, replacing every recognized inline + * base64 image block with a short placeholder (so its bulk is excluded from + * the char-count pass below) while accumulating a bounded per-image token + * cost. Returns the accumulated image token cost; the caller measures the + * placeholder-substituted structure with the normal char/4 heuristic. + * + * Non-image content (including remote image URLs and generic base64 text) + * is left untouched and continues to flow through the text-estimation path. + */ +function extractImageTokens(node: unknown, seen: Set): { node: unknown; tokens: number } { + if (node === null || typeof node !== "object") { + return { node, tokens: 0 }; + } + // Guard against cycles in structured request bodies. + if (seen.has(node)) return { node, tokens: 0 }; + seen.add(node); + + if (Array.isArray(node)) { + let tokens = 0; + const out = node.map((item) => { + const record = + item && typeof item === "object" && !Array.isArray(item) + ? (item as Record) + : null; + if (record && isInlineBase64ImageBlock(record)) { + tokens += IMAGE_TOKEN_ESTIMATE; + return { __image_token_estimate__: IMAGE_TOKEN_ESTIMATE }; + } + const result = extractImageTokens(item, seen); + tokens += result.tokens; + return result.node; + }); + return { node: out, tokens }; + } + + const record = node as Record; + if (isInlineBase64ImageBlock(record)) { + return { + node: { __image_token_estimate__: IMAGE_TOKEN_ESTIMATE }, + tokens: IMAGE_TOKEN_ESTIMATE, + }; + } + + let tokens = 0; + const out: Record = {}; + for (const [key, value] of Object.entries(record)) { + const result = extractImageTokens(value, seen); + out[key] = result.node; + tokens += result.tokens; + } + return { node: out, tokens }; +} + +/** + * Estimate token count from text length. + * + * Structured input is first walked for inline base64 image blocks (#8368): + * each recognized image block is substituted with a bounded per-image token + * budget instead of measuring its base64 payload as raw text, then the + * remainder of the structure is measured normally via the char/4 heuristic. */ export function estimateTokens(text: string | object | null | undefined): number { if (!text) return 0; - const str = typeof text === "string" ? text : JSON.stringify(text); - return Math.ceil(str.length / CHARS_PER_TOKEN); + if (typeof text === "string") { + return Math.ceil(text.length / CHARS_PER_TOKEN); + } + const { node, tokens: imageTokens } = extractImageTokens(text, new Set()); + // #7847: count the serialized length instead of building the string. Only `.length` was ever + // used, and on a multi-megabyte agent body that string is a pure transient allocation. + // jsonLength is exact (property-tested against JSON.stringify), so the estimate is unchanged. + return Math.ceil(jsonLength(node) / CHARS_PER_TOKEN) + imageTokens; } /** @@ -78,6 +209,8 @@ function resolveTokenLimit( const envOverride = getEnvOverride(provider); if (envOverride) return { limit: envOverride, specific: true }; + const lowerModel = (model || "").toLowerCase(); + // 2. Check models.dev synced DB for per-model context limit if (model) { const dbLimit = getModelContextLimit(provider, model); @@ -92,15 +225,14 @@ function resolveTokenLimit( // 4. Check if model name hints at a known limit if (model) { - const lower = model.toLowerCase(); - if (lower.includes("claude")) return { limit: DEFAULT_LIMITS.claude, specific: true }; - if (lower.includes("gemini")) return { limit: DEFAULT_LIMITS.gemini, specific: true }; + if (lowerModel.includes("claude")) return { limit: DEFAULT_LIMITS.claude, specific: true }; + if (lowerModel.includes("gemini")) return { limit: DEFAULT_LIMITS.gemini, specific: true }; if ( - lower.includes("gpt") || - lower.includes("o1") || - lower.includes("o3") || - lower.includes("o4") || - lower.includes("codex") + lowerModel.includes("gpt") || + lowerModel.includes("o1") || + lowerModel.includes("o3") || + lowerModel.includes("o4") || + lowerModel.includes("codex") ) return { limit: DEFAULT_LIMITS.codex, specific: true }; } diff --git a/open-sse/services/githubCopilotModels.ts b/open-sse/services/githubCopilotModels.ts index 766666ecbe..b1066716f2 100644 --- a/open-sse/services/githubCopilotModels.ts +++ b/open-sse/services/githubCopilotModels.ts @@ -22,6 +22,7 @@ import { getGitHubCopilotChatHeaders } from "../config/providerHeaderProfiles.ts export const GITHUB_COPILOT_MODELS_URL = "https://api.githubcopilot.com/models"; export const GITHUB_COPILOT_MODEL_ALLOWLIST = [ "claude-fable-5", + "claude-opus-5", "claude-opus-4.8-fast", "claude-opus-4.8", "claude-opus-4.7", diff --git a/open-sse/services/hyperagentModels.ts b/open-sse/services/hyperagentModels.ts index b1bbbf9a55..64befa93d3 100644 --- a/open-sse/services/hyperagentModels.ts +++ b/open-sse/services/hyperagentModels.ts @@ -19,40 +19,56 @@ export interface HyperAgentModel { subagent: "fable" | "opus" | "sonnet" | "haiku"; /** Agent runtime for Claude family models. */ runtimeId?: string; + /** Context window for OmniRoute getTokenLimit / compression (Claude-family → 1M). */ + contextLength?: number; } +/** Default context for Fable / Opus / Sonnet on HyperAgent (1M tokens). */ +export const HYPERAGENT_DEFAULT_CONTEXT_LENGTH = 1_000_000; + /** Valid selectable models (live-validated). */ export const HYPERAGENT_FALLBACK_MODELS: HyperAgentModel[] = [ - { id: "fable-latest", name: "Fable 5", subagent: "fable", runtimeId: "claude-agents-sdk" }, + { + id: "fable-latest", + name: "Fable 5", + subagent: "fable", + runtimeId: "claude-agents-sdk", + contextLength: HYPERAGENT_DEFAULT_CONTEXT_LENGTH, + }, { id: "claude-fable-5", name: "Claude Fable 5", subagent: "fable", runtimeId: "claude-agents-sdk", + contextLength: HYPERAGENT_DEFAULT_CONTEXT_LENGTH, }, { id: "opus-latest", name: "Claude Opus Latest", subagent: "opus", runtimeId: "claude-agents-sdk", + contextLength: HYPERAGENT_DEFAULT_CONTEXT_LENGTH, }, { id: "claude-opus-4-8", name: "Claude Opus 4.8", subagent: "opus", runtimeId: "claude-agents-sdk", + contextLength: HYPERAGENT_DEFAULT_CONTEXT_LENGTH, }, { id: "sonnet-latest", name: "Claude Sonnet Latest", subagent: "sonnet", runtimeId: "claude-agents-sdk", + contextLength: HYPERAGENT_DEFAULT_CONTEXT_LENGTH, }, { id: "claude-sonnet-5", name: "Claude Sonnet 5", subagent: "sonnet", runtimeId: "claude-agents-sdk", + contextLength: HYPERAGENT_DEFAULT_CONTEXT_LENGTH, }, ]; diff --git a/open-sse/services/kiroModels.ts b/open-sse/services/kiroModels.ts index 717e8bac66..dade388504 100644 --- a/open-sse/services/kiroModels.ts +++ b/open-sse/services/kiroModels.ts @@ -27,7 +27,13 @@ import { createHash } from "node:crypto"; import { v4 as uuidv4 } from "uuid"; +import { + isExternalIdpAuthMethod, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE, +} from "./kiroExternalIdp.ts"; import { resolveKiroRuntimeRegion } from "./kiroRegion.ts"; +import { supportsKiroAdaptiveThinking } from "../translator/request/openai-to-kiro/adaptiveThinking.ts"; type RawRecord = Record; @@ -98,13 +104,6 @@ export function parseKiroModels(data: unknown): KiroModel[] { return models; } -function stripSyntheticSuffixes(id: string): string { - let out = id; - if (out.endsWith("-agentic")) out = out.slice(0, -"-agentic".length); - if (out.endsWith("-thinking")) out = out.slice(0, -"-thinking".length); - return out; -} - function formatDisplayName(modelName: unknown, modelId: string, rateMultiplier: unknown): string { const base = toNonEmptyString(modelName) || modelId; const rate = Number(rateMultiplier); @@ -115,42 +114,36 @@ function formatDisplayName(modelName: unknown, modelId: string, rateMultiplier: } function buildVariants(upstream: string, displayName: string): KiroModel[] { - const safeUpstream = stripSyntheticSuffixes(upstream); - const display = displayName || `Kiro ${safeUpstream}`; - const isAuto = safeUpstream === "auto" || safeUpstream === "auto-kiro"; + const display = displayName || `Kiro ${upstream}`; const variants: KiroModel[] = [ { - id: safeUpstream, + id: upstream, name: display, owned_by: "kiro", capabilities: { thinking: false, agentic: false }, }, - { - id: `${safeUpstream}-thinking`, + ]; + + if (supportsKiroAdaptiveThinking(upstream)) { + variants.push({ + id: `${upstream}-thinking`, name: `${display} (Thinking)`, owned_by: "kiro", capabilities: { thinking: true, agentic: false }, - }, - ]; - - if (!isAuto) { - variants.push({ - id: `${safeUpstream}-agentic`, - name: `${display} (Agentic)`, - owned_by: "kiro", - capabilities: { thinking: false, agentic: true }, - }); - variants.push({ - id: `${safeUpstream}-thinking-agentic`, - name: `${display} (Thinking + Agentic)`, - owned_by: "kiro", - capabilities: { thinking: true, agentic: true }, }); } return variants; } +export function isObsoleteKiroModelAlias(modelId: unknown): boolean { + if (typeof modelId !== "string") return false; + if (modelId === "auto-kiro" || modelId.endsWith("-agentic")) return true; + if (!modelId.endsWith("-thinking")) return false; + const upstream = modelId.slice(0, -"-thinking".length); + return !supportsKiroAdaptiveThinking(upstream); +} + function expandKiroModels(data: unknown): KiroModel[] { const payload = asRecord(data); const items = Array.isArray(payload.models) @@ -255,7 +248,7 @@ function buildKiroFingerprintHeaders(providerSpecificData: unknown, accessToken: `api/codewhispererruntime#${KIRO_RUNTIME_SDK_VERSION} m/N,E ` + `KiroIDE-${KIRO_IDE_VERSION}-${machineId}`; - return { + const headers: Record = { "User-Agent": userAgent, "x-amz-user-agent": `aws-sdk-js/${KIRO_RUNTIME_SDK_VERSION} KiroIDE-${KIRO_IDE_VERSION}-${machineId}`, "x-amzn-kiro-agent-mode": "vibe", @@ -264,6 +257,15 @@ function buildKiroFingerprintHeaders(providerSpecificData: unknown, accessToken: "amz-sdk-invocation-id": uuidv4(), Accept: "application/json", }; + + if (psd.authMethod === "api_key") { + headers.tokentype = "API_KEY"; + } + if (isExternalIdpAuthMethod(psd.authMethod)) { + headers[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; + } + + return headers; } function cacheKey(accessToken: string, providerSpecificData: unknown): string { @@ -273,7 +275,8 @@ function cacheKey(accessToken: string, providerSpecificData: unknown): string { toNonEmptyString(psd.clientId) || accessToken || "anonymous"; - return createHash("sha256").update(`kiro:${seed}`).digest("hex"); + const authMethod = toNonEmptyString(psd.authMethod) || "unknown"; + return createHash("sha256").update(`kiro:${authMethod}:${seed}`).digest("hex"); } async function tryFetchModels( diff --git a/open-sse/services/modelFamilyFallback.ts b/open-sse/services/modelFamilyFallback.ts index 3ebfb92bfe..9f3ff882f3 100644 --- a/open-sse/services/modelFamilyFallback.ts +++ b/open-sse/services/modelFamilyFallback.ts @@ -78,6 +78,7 @@ const MODEL_FAMILIES: Record = { "claude-fable-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-sonnet-5"], // Claude Opus family + "claude-opus-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-sonnet-5"], "claude-opus-4-8": ["claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-5"], "claude-opus-4-7": ["claude-opus-4-6", "claude-opus-4-5-20251101", "claude-sonnet-5"], "claude-opus-4-6": ["claude-opus-4-6-thinking", "claude-opus-4-5-20251101", "claude-sonnet-5"], diff --git a/open-sse/services/notionThreadSessions.ts b/open-sse/services/notionThreadSessions.ts index 2aaeac14eb..f1e123a17f 100644 --- a/open-sse/services/notionThreadSessions.ts +++ b/open-sse/services/notionThreadSessions.ts @@ -269,9 +269,18 @@ export function notionThreadRootKey(spaceKey: string, messages: NotionMessage[]) /** * Resolve which Notion thread to use and whether to mint a new one. - * - Sticky root binding is written *before* the upstream call so errors/retries - * never open a second Notion chat for the same conversation. - * - Any prior assistant history forces createThread:false when a sticky id exists. + * + * Continuity rules (order matters): + * 1. Client-supplied thread id (body/header pin) → always follow-up. + * 2. Exact conversation-prefix hash → multi-turn OpenAI history (most specific). + * 3. Sticky root (first-user-message key): + * - Multi-turn history present → reuse (UREW-resilient when prefix hash misses). + * - First turn + createAttempted && !confirmed → error-retry stickiness + * (never mint a second Notion chat for the same failed first request). + * - First turn + confirmed → NEW session with the same opener text (e.g. + * Claude Code “new session” + “hi” again). Must mint a fresh threadId — + * reusing the confirmed sticky forks the previous Notion chat. + * 4. Otherwise mint createThread:true and bind optimistically. */ export function resolveNotionThreadBinding( spaceKey: string, @@ -288,27 +297,10 @@ export function resolveNotionThreadBinding( return { threadId: id, createThread: false, rootKey }; } - // Prefer sticky root (survives UREW rewrites + error retries) - if (rootKey) { - const sticky = readThreadSessionEntry(rootKey); - if (sticky?.threadId) { - // Touch TTL - putThreadSession(rootKey, sticky.threadId, { - confirmed: sticky.confirmed, - createAttempted: sticky.createAttempted, - }); - // If we already attempted create for this root, never create again - // (even when the first reply failed — Notion may already have the thread). - const createThread = !sticky.createAttempted && !sticky.confirmed && !hasHistory; - return { - threadId: sticky.threadId, - createThread, - rootKey, - }; - } - } - - // Exact prefix match (full history before last user) + // Exact prefix match first (full history before last user) — most specific + // multi-turn continuity. Prefer this over sticky root so two independent + // sessions that share the same first-user opener do not steal each other's + // sticky binding when both are multi-turn. const prefix = conversationPrefixBeforeLastUser(messages); if (prefix.length > 0) { const exactId = readThreadSession(hashNotionConversation(spaceKey, prefix)); @@ -318,6 +310,61 @@ export function resolveNotionThreadBinding( } } + // Sticky root (first user turn hash) — UREW + error-retry continuity + if (rootKey) { + const sticky = readThreadSessionEntry(rootKey); + if (sticky?.threadId) { + // Multi-turn OpenAI history → continue the sticky Notion chat + // (covers UREW rewrites where prefix hash may not match turn-1 store). + if (hasHistory) { + putThreadSession(rootKey, sticky.threadId, { + confirmed: sticky.confirmed, + createAttempted: sticky.createAttempted, + }); + return { + threadId: sticky.threadId, + createThread: false, + rootKey, + }; + } + + // First-turn error retry: we already issued createThread:true for this + // root but never got a successful reply. Keep the same threadId so Notion + // is not spam-created; do not create again (Notion may already have it). + if (sticky.createAttempted && !sticky.confirmed) { + putThreadSession(rootKey, sticky.threadId, { + confirmed: false, + createAttempted: true, + }); + return { + threadId: sticky.threadId, + createThread: false, + rootKey, + }; + } + + // First-turn + confirmed sticky: a *new* client session that happens to + // start with the same first user text (Claude Code “New session” + “hi”). + // Fall through and mint — never fork the previous Notion thread. + // + // Optimistic pre-bind (createAttempted false, confirmed false) also falls + // through only when no sticky exists; if sticky exists without either flag + // it is mid-flight first bind — reuse with createThread:true once. + if (!sticky.createAttempted && !sticky.confirmed) { + putThreadSession(rootKey, sticky.threadId, { + confirmed: false, + createAttempted: false, + }); + return { + threadId: sticky.threadId, + createThread: true, + rootKey, + }; + } + // sticky.confirmed on first-turn → mint below (rebind root to new id) + } + } + // Mint a new thread id and bind it immediately (optimistic) so concurrent / // failed retries reuse the same id instead of spam-creating Notion chats. const threadId = randomUUID(); diff --git a/open-sse/services/providerCostData.ts b/open-sse/services/providerCostData.ts index b9809dea99..d9a4e0a8ab 100644 --- a/open-sse/services/providerCostData.ts +++ b/open-sse/services/providerCostData.ts @@ -12,6 +12,7 @@ export const KNOWN_MODEL_PRICING: Record = { "gpt-4o": { inputCostPer1M: 2.5, outputCostPer1M: 10.0, isFree: false }, "gpt-4o-mini": { inputCostPer1M: 0.15, outputCostPer1M: 0.6, isFree: false }, "claude-fable-5": { inputCostPer1M: 15.0, outputCostPer1M: 75.0, isFree: false }, + "claude-opus-5": { inputCostPer1M: 5.0, outputCostPer1M: 25.0, isFree: false }, "claude-opus-4-8": { inputCostPer1M: 15.0, outputCostPer1M: 75.0, isFree: false }, "claude-opus-4-7": { inputCostPer1M: 15.0, outputCostPer1M: 75.0, isFree: false }, "claude-sonnet-4-6": { inputCostPer1M: 3.0, outputCostPer1M: 15.0, isFree: false }, diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index c978828592..13c9e23677 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -5,17 +5,39 @@ // file keeps the orchestrator (refreshAccessToken, getAccessToken), the // in-flight/rotation dedup maps, the CAS guard, and refreshWithRetry — the // cross-provider plumbing. The provider-module split was originally proposed -// by KooshaPari in PR #7338 (base was too old to merge as-is); redone here on -// the current tip, credit preserved via co-authorship on the extraction -// commits. All previously-public exports are re-exported below so existing +// by KooshaPari in PR #7338, whose base was too old to merge as-is; this is an +// independent implementation of the same idea against the current tip, not a +// reuse of that diff. All previously-public exports are re-exported below so existing // importers (open-sse/index.ts, executors, src/sse/services/tokenRefresh.ts, // tests) are unaffected. import { AsyncLocalStorage } from "node:async_hooks"; -import { pbkdf2Sync } from "node:crypto"; import { PROVIDERS } from "../config/constants.ts"; import { runWithProxyContext } from "../utils/proxyFetch.ts"; -import { serializeRefresh, wasRefreshTokenRotated } from "./refreshSerializer.ts"; -import { extractOAuthErrorCode, type RefreshLogger } from "./tokenRefresh/shared.ts"; +import { serializeRefresh } from "./refreshSerializer.ts"; +import { + extractOAuthErrorCode, + isUnrecoverableRefreshError, + type RefreshLogger, +} from "./tokenRefresh/shared.ts"; +import { + getRefreshCacheKey, + lookupRotation, + recordRotation, + _getTokenRotationMapStats, + _clearTokenRotationMap, +} from "./tokenRefresh/rotationMap.ts"; +import { + runWithCasGuard, + getActiveCasGuard, + getCasGuardStats, + _resetCasGuardStats, + casGuardShouldSkipPersist, +} from "./tokenRefresh/casGuard.ts"; +import { + isProviderBlocked, + getCircuitBreakerStatus, + refreshWithRetry, +} from "./tokenRefresh/circuitBreaker.ts"; import { refreshWindsurfToken } from "./tokenRefresh/providers/windsurf.ts"; import { refreshCodebuddyCnToken } from "./tokenRefresh/providers/codebuddyCn.ts"; import { refreshClineToken } from "./tokenRefresh/providers/cline.ts"; @@ -43,6 +65,16 @@ export { refreshGitHubToken, refreshCopilotToken, extractOAuthErrorCode, + isUnrecoverableRefreshError, + isProviderBlocked, + getCircuitBreakerStatus, + refreshWithRetry, + runWithCasGuard, + getActiveCasGuard, + getCasGuardStats, + _resetCasGuardStats, + _getTokenRotationMapStats, + _clearTokenRotationMap, }; // Default token expiry buffer (refresh if expires within 5 minutes). @@ -105,8 +137,6 @@ export function getRefreshLeadMs( return REFRESH_LEAD_MS[provider] ?? TOKEN_EXPIRY_BUFFER_MS; } -const CACHE_SECRET = "omniroute-token-cache"; - // In-flight refresh promise cache to prevent race conditions // Key: "provider:sha256(refreshToken)" → Value: Promise const refreshPromiseCache = new Map(); @@ -116,75 +146,9 @@ const refreshPromiseCache = new Map(); // Primary dedup when credentials.connectionId is present; refreshPromiseCache is fallback. const connectionRefreshMutex = new Map(); -// ─── Token Rotation Map (codex-multi-auth pattern) ───────────────────────── -// -// When a rotating-token provider (Codex, Kimi, GitLab Duo, etc.) refreshes, -// the old refresh_token is consumed and a new one is issued. Any subsequent -// caller arriving with the OLD token would, without protection, hit upstream -// and trigger "refresh_token_reused" — which Auth0 treats as a security event -// and invalidates the entire token family. -// -// This in-memory map caches RECENT rotations so a stale caller can be redirected -// to the new tokens WITHOUT touching upstream. The DB staleness check inside -// the per-connection mutex covers the same scenario when connectionId is known, -// but not all callers pass connectionId (e.g., legacy code paths, retries that -// snapshot credentials before the rotation lands in DB). -// -// Ported from ndycode/codex-multi-auth (lib/refresh-queue.ts:218-248), the only -// publicly known tool that reliably sustains multiple Codex OAuth accounts. -// -// Key format: `provider:sha256(oldRefreshToken)` -// Value: { result: tokens, expiresAt: ms_since_epoch } -type RotationEntry = { - result: { accessToken: string; refreshToken: string; expiresIn?: number; expiresAt?: string }; - expiresAt: number; -}; -const tokenRotationMap = new Map(); -const ROTATION_MAP_TTL_MS = 60 * 1000; // 60 seconds — long enough to catch in-flight stale callers - -function cleanupRotationMap(now: number = Date.now()): void { - if (tokenRotationMap.size === 0) return; - for (const [key, entry] of tokenRotationMap.entries()) { - if (entry.expiresAt <= now) tokenRotationMap.delete(key); - } -} - -function lookupRotation(provider: string, refreshToken: string): RotationEntry | undefined { - cleanupRotationMap(); - const key = getRefreshCacheKey(provider, refreshToken); - const entry = tokenRotationMap.get(key); - if (!entry) return undefined; - if (entry.expiresAt <= Date.now()) { - tokenRotationMap.delete(key); - return undefined; - } - return entry; -} - -function recordRotation( - provider: string, - oldRefreshToken: string, - result: { accessToken: string; refreshToken: string; expiresIn?: number; expiresAt?: string } -): void { - if (!oldRefreshToken || !result.refreshToken || oldRefreshToken === result.refreshToken) { - return; - } - const key = getRefreshCacheKey(provider, oldRefreshToken); - tokenRotationMap.set(key, { - result, - expiresAt: Date.now() + ROTATION_MAP_TTL_MS, - }); -} - -// Exported for tests + diagnostics; not part of the public API surface. -export function _getTokenRotationMapStats(): { size: number; entries: number } { - cleanupRotationMap(); - return { size: tokenRotationMap.size, entries: tokenRotationMap.size }; -} - -export function _clearTokenRotationMap(): void { - tokenRotationMap.clear(); -} +// Token Rotation Map (codex-multi-auth pattern) lives in +// ./tokenRefresh/rotationMap.ts — see that leaf for the in-memory rotation +// cache + getRefreshCacheKey. Imported above and re-exported for tests. // AsyncLocalStorage for plumbing `onPersist` through executor.refreshCredentials // without modifying every executor's signature. The chatCore.ts / base.ts call @@ -208,88 +172,13 @@ export function getActiveOnPersist(): RefreshPersistFn | undefined { return onPersistStore.getStore(); } -// ── #4038: compare-and-swap (CAS) guard on the refresh persist ─────────────── -// Fix A makes [network refresh + DB write] atomic *for a single connection's -// mutex*. It does NOT protect against a THIRD writer (a sibling process, a -// concurrent HealthCheck, or a replica) landing a fresher rotation on the same -// `connection_id` between the moment the caller read the row and the moment this -// persist runs. Overwriting that fresher row reverts the sibling's rotation, the -// next caller loads the reverted (now-consumed) refresh_token, and Auth0/Anthropic -// revoke the whole token family (the 1352× claude/aa5dd5cf invalidation storm). -// -// The CAS guard carries the refresh_token the caller PRESENTED (the version token, -// since refresh_tokens rotate on every refresh) plus a `reread` of the row's -// current refresh_token. Right before persisting, `getAccessToken` re-reads and, if -// a concurrent writer already rotated the row past the presented token, SKIPS the -// persist so the DB stays at the fresher state. The caller still receives the new -// accessToken — upstream already authenticated the request; only the DB write is -// skipped. No active guard ⇒ behavior is byte-identical to before (opt-in). -type CasGuard = { - /** The refresh_token the caller presented for this refresh (CAS version token). */ - expectedRefreshToken: string | null; - /** Re-reads the CURRENT persisted refresh_token for this connection (decrypted). */ - reread: () => Promise; -}; -const casGuardStore = new AsyncLocalStorage(); -const casGuardStats = { skipped: 0, persisted: 0 }; +// #4038 compare-and-swap (CAS) guard on the refresh persist lives in +// ./tokenRefresh/casGuard.ts — imported above and re-exported for tests. +// casGuardShouldSkipPersist is imported and used by getAccessToken below. -export function runWithCasGuard( - guard: CasGuard | undefined | null, - fn: () => Promise -): Promise { - if (!guard) return fn(); - return casGuardStore.run(guard, fn); -} - -export function getActiveCasGuard(): CasGuard | undefined { - return casGuardStore.getStore(); -} - -/** Skip/persist counters for observability + tests. */ -export function getCasGuardStats(): { skipped: number; persisted: number } { - return { ...casGuardStats }; -} - -/** Test-only: reset the CAS counters between cases. */ -export function _resetCasGuardStats(): void { - casGuardStats.skipped = 0; - casGuardStats.persisted = 0; -} - -/** - * Returns true when the persist should be SKIPPED because a concurrent writer - * already rotated the row's refresh_token past the one we presented (CAS mismatch). - * Best-effort: any reread failure falls through to persist (never blocks recovery). - */ -async function casGuardShouldSkipPersist(log?: RefreshLogger): Promise { - const guard = getActiveCasGuard(); - if (!guard || !guard.expectedRefreshToken) return false; - let current: string | null | undefined; - try { - current = await guard.reread(); - } catch { - return false; // reread failed — fall through to persist (best-effort) - } - // wasRefreshTokenRotated is true iff both are non-empty AND current !== expected. - if (wasRefreshTokenRotated(guard.expectedRefreshToken, current)) { - casGuardStats.skipped++; - log?.warn?.( - "TOKEN_REFRESH", - "CAS guard: skipping persist — a concurrent writer already rotated the refresh_token (#4038)" - ); - return true; - } - casGuardStats.persisted++; - return false; -} - -function getRefreshCacheKey(provider, refreshToken) { - const tokenHash = pbkdf2Sync(refreshToken, CACHE_SECRET, 1000, 32, "sha256").toString("hex"); - return `${provider}:${tokenHash}`; -} - -// extractOAuthErrorCode lives in ./tokenRefresh/shared.ts (imported above, re-exported below) — -// used both by the generic orchestrator below and by every per-provider refresh module. +// extractOAuthErrorCode + isUnrecoverableRefreshError live in +// ./tokenRefresh/shared.ts (imported above, re-exported below) — used both by +// the generic orchestrator below and by every per-provider refresh module. /** * Refresh OAuth access token using refresh token @@ -472,7 +361,11 @@ export function supportsTokenRefresh(provider) { "cline", "kimi-coding", "windsurf", - "devin-cli", + // #8407: do NOT list "devin-cli" here. It is import-token / local-CLI owned + // (`devin auth login`); connections never carry a refresh token. Leaving it + // in this set made tokenHealthCheck treat it as refresh-capable and force + // testStatus="expired" / errorCode="no_refresh_token". Keep it out of the + // explicit set (same idea as not listing non-refresh local-CLI providers). "gitlab-duo", "codebuddy-cn", ]); @@ -481,21 +374,9 @@ export function supportsTokenRefresh(provider) { return !!(config?.refreshUrl || config?.tokenUrl); } -/** - * Check if a refresh result indicates an unrecoverable error - * (e.g. the refresh token was already consumed and cannot be reused). - * Callers should stop retrying and request re-authentication. - */ -export function isUnrecoverableRefreshError(result) { - return ( - result && - typeof result === "object" && - (result.error === "unrecoverable_refresh_error" || - result.error === "refresh_token_reused" || - result.error === "invalid_request" || - result.error === "invalid_grant") - ); -} +// isUnrecoverableRefreshError lives in ./tokenRefresh/shared.ts (imported above +// and re-exported) — used by refreshWithRetry (./tokenRefresh/circuitBreaker.ts) +// and by callers that need to classify a refresh result. /** * Get access token for a specific provider (with deduplication). @@ -825,51 +706,10 @@ export async function getAllAccessTokens(userInfo, log) { return results; } -/** - * Refresh token with retry and exponential backoff - * Retries on failure with increasing delay: 1s, 2s, 3s... - * - * Includes: - * - Per-provider circuit breaker (5 consecutive failures → 30min pause) - * - 30s timeout per refresh attempt to prevent hanging connections - * - * @param {function} refreshFn - Async function that returns token or null - * @param {number} maxRetries - Max retry attempts (default 3) - * @param {object} log - Logger instance (optional) - * @param {string} provider - Provider ID for circuit breaker tracking (optional) - * @returns {Promise} Token result or null if all retries fail - */ - -// ─── Circuit Breaker State ────────────────────────────────────────────────── -const _circuitBreaker: Record = {}; -const CIRCUIT_BREAKER_THRESHOLD = 5; // consecutive failures before tripping -const CIRCUIT_BREAKER_COOLDOWN = 30 * 60 * 1000; // 30 minutes -const REFRESH_TIMEOUT_MS = 30_000; // 30s max per refresh attempt - -interface CircuitBreakerStatusEntry { - failures: number; - blocked: boolean; - blockedUntil: string | null; - remainingMs: number; -} - -interface RefreshLoggerLike { - error?: (scope: string, message: string) => void; - warn?: (scope: string, message: string) => void; -} - -/** - * Check if a provider is circuit-breaker blocked. - */ -export function isProviderBlocked(provider: string): boolean { - const state = _circuitBreaker[provider]; - if (!state) return false; - if (!state.blockedUntil) return false; - if (state.blockedUntil > Date.now()) return true; - // Cooldown expired — reset - delete _circuitBreaker[provider]; - return false; -} +// Per-provider circuit breaker + refreshWithRetry + withTimeout live in +// ./tokenRefresh/circuitBreaker.ts — imported above and re-exported for tests. +// isProviderBlocked / getCircuitBreakerStatus / refreshWithRetry are +// re-exported from that leaf. /** * Get active per-connection mutex entries (for diagnostics/metrics). @@ -882,114 +722,3 @@ export function getConnectionRefreshMutexStatus(): Record { - const result: Record = {}; - for (const [provider, state] of Object.entries(_circuitBreaker)) { - result[provider] = { - failures: state.failures, - blocked: state.blockedUntil > Date.now(), - blockedUntil: - state.blockedUntil > Date.now() ? new Date(state.blockedUntil).toISOString() : null, - remainingMs: Math.max(0, state.blockedUntil - Date.now()), - }; - } - return result; -} - -/** - * Record a successful refresh — resets circuit breaker for provider. - */ -function recordSuccess(provider: string) { - if (_circuitBreaker[provider]) { - delete _circuitBreaker[provider]; - } -} - -/** - * Record a failed refresh — increments circuit breaker counter. - */ -function recordFailure(provider: string, log: RefreshLoggerLike | null = null) { - if (!_circuitBreaker[provider]) { - _circuitBreaker[provider] = { failures: 0, blockedUntil: 0 }; - } - _circuitBreaker[provider].failures++; - - if (_circuitBreaker[provider].failures >= CIRCUIT_BREAKER_THRESHOLD) { - _circuitBreaker[provider].blockedUntil = Date.now() + CIRCUIT_BREAKER_COOLDOWN; - log?.error?.( - "TOKEN_REFRESH", - `🔴 Circuit breaker tripped for ${provider}: ${CIRCUIT_BREAKER_THRESHOLD} consecutive failures. ` + - `Blocked for ${CIRCUIT_BREAKER_COOLDOWN / 60000}min. Provider needs re-authentication.` - ); - } -} - -/** - * Execute a function with a timeout. - */ -async function withTimeout(fn: () => Promise, timeoutMs: number): Promise { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => resolve(null), timeoutMs); - if (typeof timer === "object" && "unref" in timer) { - (timer as { unref?: () => void }).unref?.(); - } - - fn().then( - (result) => { - clearTimeout(timer); - resolve(result); - }, - (error) => { - clearTimeout(timer); - reject(error); - } - ); - }); -} - -export async function refreshWithRetry( - refreshFn, - maxRetries = 3, - log: RefreshLogger = null, - provider = "unknown" -) { - // Circuit breaker check - if (isProviderBlocked(provider)) { - log?.warn?.("TOKEN_REFRESH", `⚡ Circuit breaker active for ${provider}, skipping refresh`); - return null; - } - - for (let attempt = 0; attempt < maxRetries; attempt++) { - if (attempt > 0) { - const delay = attempt * 1000; - log?.debug?.("TOKEN_REFRESH", `Retry ${attempt}/${maxRetries} after ${delay}ms`); - await new Promise((r) => setTimeout(r, delay)); - } - - try { - const result = await withTimeout(refreshFn, REFRESH_TIMEOUT_MS); - if (isUnrecoverableRefreshError(result)) { - log?.warn?.( - "TOKEN_REFRESH", - `Unrecoverable refresh error for ${provider}: ${result.error} — skipping retries` - ); - return result; - } - if (result) { - recordSuccess(provider); - return result; - } - } catch (error) { - log?.warn?.("TOKEN_REFRESH", `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`); - } - } - - // All retries exhausted — record failure for circuit breaker - recordFailure(provider, log); - log?.error?.("TOKEN_REFRESH", `All ${maxRetries} retry attempts failed for ${provider}`); - return null; -} diff --git a/open-sse/services/tokenRefresh/casGuard.ts b/open-sse/services/tokenRefresh/casGuard.ts new file mode 100644 index 0000000000..3b4a13eb45 --- /dev/null +++ b/open-sse/services/tokenRefresh/casGuard.ts @@ -0,0 +1,84 @@ +// @ts-nocheck +// +// Compare-and-swap (CAS) guard on the refresh persist — extracted from +// open-sse/services/tokenRefresh.ts. See ../shared.ts for provenance notes. +// +// #4038: Fix A makes [network refresh + DB write] atomic *for a single +// connection's mutex*. It does NOT protect against a THIRD writer (a sibling +// process, a concurrent HealthCheck, or a replica) landing a fresher rotation +// on the same `connection_id` between the moment the caller read the row and +// the moment this persist runs. Overwriting that fresher row reverts the +// sibling's rotation, the next caller loads the reverted (now-consumed) +// refresh_token, and Auth0/Anthropic revoke the whole token family (the 1352× +// claude/aa5dd5cf invalidation storm). +// +// The CAS guard carries the refresh_token the caller PRESENTED (the version +// token, since refresh_tokens rotate on every refresh) plus a `reread` of the +// row's current refresh_token. Right before persisting, `getAccessToken` +// re-reads and, if a concurrent writer already rotated the row past the +// presented token, SKIPS the persist so the DB stays at the fresher state. The +// caller still receives the new accessToken — upstream already authenticated +// the request; only the DB write is skipped. No active guard ⇒ behavior is +// byte-identical to before (opt-in). +import { AsyncLocalStorage } from "node:async_hooks"; +import { wasRefreshTokenRotated } from "../refreshSerializer.ts"; +import type { RefreshLogger } from "./shared.ts"; + +type CasGuard = { + /** The refresh_token the caller presented for this refresh (CAS version token). */ + expectedRefreshToken: string | null; + /** Re-reads the CURRENT persisted refresh_token for this connection (decrypted). */ + reread: () => Promise; +}; +const casGuardStore = new AsyncLocalStorage(); +const casGuardStats = { skipped: 0, persisted: 0 }; + +export function runWithCasGuard( + guard: CasGuard | undefined | null, + fn: () => Promise +): Promise { + if (!guard) return fn(); + return casGuardStore.run(guard, fn); +} + +export function getActiveCasGuard(): CasGuard | undefined { + return casGuardStore.getStore(); +} + +/** Skip/persist counters for observability + tests. */ +export function getCasGuardStats(): { skipped: number; persisted: number } { + return { ...casGuardStats }; +} + +/** Test-only: reset the CAS counters between cases. */ +export function _resetCasGuardStats(): void { + casGuardStats.skipped = 0; + casGuardStats.persisted = 0; +} + +/** + * Returns true when the persist should be SKIPPED because a concurrent writer + * already rotated the row's refresh_token past the one we presented (CAS mismatch). + * Best-effort: any reread failure falls through to persist (never blocks recovery). + */ +export async function casGuardShouldSkipPersist(log?: RefreshLogger): Promise { + const guard = getActiveCasGuard(); + if (!guard || !guard.expectedRefreshToken) return false; + let current: string | null | undefined; + try { + current = await guard.reread(); + } catch { + return false; // reread failed — fall through to persist (best-effort) + } + // wasRefreshTokenRotated is true iff both are non-empty AND current !== expected. + if (wasRefreshTokenRotated(guard.expectedRefreshToken, current)) { + casGuardStats.skipped++; + log?.warn?.( + "TOKEN_REFRESH", + "CAS guard: skipping persist — a concurrent writer already rotated the refresh_token (#4038)" + ); + return true; + } + casGuardStats.persisted++; + return false; +} diff --git a/open-sse/services/tokenRefresh/circuitBreaker.ts b/open-sse/services/tokenRefresh/circuitBreaker.ts new file mode 100644 index 0000000000..ae5b503ea6 --- /dev/null +++ b/open-sse/services/tokenRefresh/circuitBreaker.ts @@ -0,0 +1,168 @@ +// @ts-nocheck +// +// Per-provider circuit breaker + refreshWithRetry — extracted from +// open-sse/services/tokenRefresh.ts. See ../shared.ts for provenance notes. +// +// refreshWithRetry wraps a refresh attempt with exponential backoff, a 30s +// per-attempt timeout, and a per-provider circuit breaker (5 consecutive +// failures → 30min pause). Unrecoverable refresh errors (invalid_grant, +// refresh_token_reused, …) short-circuit retries so the HealthCheck can +// deactivate the account instead of looping every 60s. +import type { RefreshLogger } from "./shared.ts"; +import { isUnrecoverableRefreshError } from "./shared.ts"; + +// ─── Circuit Breaker State ────────────────────────────────────────────────── +const _circuitBreaker: Record = {}; +const CIRCUIT_BREAKER_THRESHOLD = 5; // consecutive failures before tripping +const CIRCUIT_BREAKER_COOLDOWN = 30 * 60 * 1000; // 30 minutes +const REFRESH_TIMEOUT_MS = 30_000; // 30s max per refresh attempt + +interface CircuitBreakerStatusEntry { + failures: number; + blocked: boolean; + blockedUntil: string | null; + remainingMs: number; +} + +interface RefreshLoggerLike { + error?: (scope: string, message: string) => void; + warn?: (scope: string, message: string) => void; +} + +/** + * Check if a provider is circuit-breaker blocked. + */ +export function isProviderBlocked(provider: string): boolean { + const state = _circuitBreaker[provider]; + if (!state) return false; + if (!state.blockedUntil) return false; + if (state.blockedUntil > Date.now()) return true; + // Cooldown expired — reset + delete _circuitBreaker[provider]; + return false; +} + +/** + * Get circuit breaker status for all providers (for diagnostics). + */ +export function getCircuitBreakerStatus(): Record { + const result: Record = {}; + for (const [provider, state] of Object.entries(_circuitBreaker)) { + result[provider] = { + failures: state.failures, + blocked: state.blockedUntil > Date.now(), + blockedUntil: + state.blockedUntil > Date.now() ? new Date(state.blockedUntil).toISOString() : null, + remainingMs: Math.max(0, state.blockedUntil - Date.now()), + }; + } + return result; +} + +/** + * Record a successful refresh — resets circuit breaker for provider. + */ +function recordSuccess(provider: string) { + if (_circuitBreaker[provider]) { + delete _circuitBreaker[provider]; + } +} + +/** + * Record a failed refresh — increments circuit breaker counter. + */ +function recordFailure(provider: string, log: RefreshLoggerLike | null = null) { + if (!_circuitBreaker[provider]) { + _circuitBreaker[provider] = { failures: 0, blockedUntil: 0 }; + } + _circuitBreaker[provider].failures++; + + if (_circuitBreaker[provider].failures >= CIRCUIT_BREAKER_THRESHOLD) { + _circuitBreaker[provider].blockedUntil = Date.now() + CIRCUIT_BREAKER_COOLDOWN; + log?.error?.( + "TOKEN_REFRESH", + `🔴 Circuit breaker tripped for ${provider}: ${CIRCUIT_BREAKER_THRESHOLD} consecutive failures. ` + + `Blocked for ${CIRCUIT_BREAKER_COOLDOWN / 60000}min. Provider needs re-authentication.` + ); + } +} + +/** + * Execute a function with a timeout. + */ +async function withTimeout(fn: () => Promise, timeoutMs: number): Promise { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(null), timeoutMs); + if (typeof timer === "object" && "unref" in timer) { + (timer as { unref?: () => void }).unref?.(); + } + + fn().then( + (result) => { + clearTimeout(timer); + resolve(result); + }, + (error) => { + clearTimeout(timer); + reject(error); + } + ); + }); +} + +/** + * Refresh token with retry and exponential backoff + * Retries on failure with increasing delay: 1s, 2s, 3s... + * + * Includes: + * - Per-provider circuit breaker (5 consecutive failures → 30min pause) + * - 30s timeout per refresh attempt to prevent hanging connections + * + * @param {function} refreshFn - Async function that returns token or null + * @param {number} maxRetries - Max retry attempts (default 3) + * @param {object} log - Logger instance (optional) + * @param {string} provider - Provider ID for circuit breaker tracking (optional) + * @returns {Promise} Token result or null if all retries fail + */ +export async function refreshWithRetry( + refreshFn, + maxRetries = 3, + log: RefreshLogger = null, + provider = "unknown" +) { + // Circuit breaker check + if (isProviderBlocked(provider)) { + log?.warn?.("TOKEN_REFRESH", `⚡ Circuit breaker active for ${provider}, skipping refresh`); + return null; + } + + for (let attempt = 0; attempt < maxRetries; attempt++) { + if (attempt > 0) { + const delay = attempt * 1000; + log?.debug?.("TOKEN_REFRESH", `Retry ${attempt}/${maxRetries} after ${delay}ms`); + await new Promise((r) => setTimeout(r, delay)); + } + + try { + const result = await withTimeout(refreshFn, REFRESH_TIMEOUT_MS); + if (isUnrecoverableRefreshError(result)) { + log?.warn?.( + "TOKEN_REFRESH", + `Unrecoverable refresh error for ${provider}: ${result.error} — skipping retries` + ); + return result; + } + if (result) { + recordSuccess(provider); + return result; + } + } catch (error) { + log?.warn?.("TOKEN_REFRESH", `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`); + } + } + + // All retries exhausted — record failure for circuit breaker + recordFailure(provider, log); + log?.error?.("TOKEN_REFRESH", `All ${maxRetries} retry attempts failed for ${provider}`); + return null; +} diff --git a/open-sse/services/tokenRefresh/rotationMap.ts b/open-sse/services/tokenRefresh/rotationMap.ts new file mode 100644 index 0000000000..9d533a1b69 --- /dev/null +++ b/open-sse/services/tokenRefresh/rotationMap.ts @@ -0,0 +1,85 @@ +// @ts-nocheck +// +// Token Rotation Map (codex-multi-auth pattern) — extracted from +// open-sse/services/tokenRefresh.ts. See ../shared.ts for provenance notes. +// +// When a rotating-token provider (Codex, Kimi, GitLab Duo, etc.) refreshes, +// the old refresh_token is consumed and a new one is issued. Any subsequent +// caller arriving with the OLD token would, without protection, hit upstream +// and trigger "refresh_token_reused" — which Auth0 treats as a security event +// and invalidates the entire token family. +// +// This in-memory map caches RECENT rotations so a stale caller can be redirected +// to the new tokens WITHOUT touching upstream. The DB staleness check inside +// the per-connection mutex covers the same scenario when connectionId is known, +// but not all callers pass connectionId (e.g., legacy code paths, retries that +// snapshot credentials before the rotation lands in DB). +// +// Ported from ndycode/codex-multi-auth (lib/refresh-queue.ts:218-248), the only +// publicly known tool that reliably sustains multiple Codex OAuth accounts. +// +// Key format: `provider:sha256(oldRefreshToken)` +// Value: { result: tokens, expiresAt: ms_since_epoch } +import { pbkdf2Sync } from "node:crypto"; + +const CACHE_SECRET = "omniroute-token-cache"; + +/** + * Build the dedup/rotation cache key for a (provider, refreshToken) pair. + * Hashed so a raw refresh_token never sits in a Map key in plaintext. + */ +export function getRefreshCacheKey(provider, refreshToken) { + const tokenHash = pbkdf2Sync(refreshToken, CACHE_SECRET, 1000, 32, "sha256").toString("hex"); + return `${provider}:${tokenHash}`; +} + +type RotationEntry = { + result: { accessToken: string; refreshToken: string; expiresIn?: number; expiresAt?: string }; + expiresAt: number; +}; +const tokenRotationMap = new Map(); +const ROTATION_MAP_TTL_MS = 60 * 1000; // 60 seconds — long enough to catch in-flight stale callers + +function cleanupRotationMap(now: number = Date.now()): void { + if (tokenRotationMap.size === 0) return; + for (const [key, entry] of tokenRotationMap.entries()) { + if (entry.expiresAt <= now) tokenRotationMap.delete(key); + } +} + +export function lookupRotation(provider: string, refreshToken: string): RotationEntry | undefined { + cleanupRotationMap(); + const key = getRefreshCacheKey(provider, refreshToken); + const entry = tokenRotationMap.get(key); + if (!entry) return undefined; + if (entry.expiresAt <= Date.now()) { + tokenRotationMap.delete(key); + return undefined; + } + return entry; +} + +export function recordRotation( + provider: string, + oldRefreshToken: string, + result: { accessToken: string; refreshToken: string; expiresIn?: number; expiresAt?: string } +): void { + if (!oldRefreshToken || !result.refreshToken || oldRefreshToken === result.refreshToken) { + return; + } + const key = getRefreshCacheKey(provider, oldRefreshToken); + tokenRotationMap.set(key, { + result, + expiresAt: Date.now() + ROTATION_MAP_TTL_MS, + }); +} + +// Exported for tests + diagnostics; not part of the public API surface. +export function _getTokenRotationMapStats(): { size: number; entries: number } { + cleanupRotationMap(); + return { size: tokenRotationMap.size, entries: tokenRotationMap.size }; +} + +export function _clearTokenRotationMap(): void { + tokenRotationMap.clear(); +} diff --git a/open-sse/services/tokenRefresh/shared.ts b/open-sse/services/tokenRefresh/shared.ts index bde1a2db10..808a349050 100644 --- a/open-sse/services/tokenRefresh/shared.ts +++ b/open-sse/services/tokenRefresh/shared.ts @@ -110,3 +110,19 @@ export async function readRefreshErrorBody( const code = extractOAuthErrorCode(parsed) ?? extractOAuthErrorCode(rawText); return { rawText, code }; } + +/** + * Check if a refresh result indicates an unrecoverable error + * (e.g. the refresh token was already consumed and cannot be reused). + * Callers should stop retrying and request re-authentication. + */ +export function isUnrecoverableRefreshError(result) { + return ( + result && + typeof result === "object" && + (result.error === "unrecoverable_refresh_error" || + result.error === "refresh_token_reused" || + result.error === "invalid_request" || + result.error === "invalid_grant") + ); +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 14d061e75c..a0fc34c4b5 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -1,35 +1,21 @@ /** * Usage Fetcher - Get usage data from provider APIs + * + * This module is the dispatcher (orchestration) layer: it maps a provider name + * to the per-provider usage fetcher leaf under `./usage/.ts` and + * shapes the connection into the args each leaf expects. The provider-specific + * fetcher/parser logic itself lives in those leaves so this file stays flat. + * External consumers import `getUsageForProvider` / `USAGE_FETCHER_PROVIDERS` + * (and the re-exported helpers) from here — the leaf split is an internal + * implementation detail. */ -import { getGitHubCopilotInternalUserHeaders } from "../config/providerHeaderProfiles.ts"; -import { getDbInstance } from "@/lib/db/core"; -import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts"; -import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts"; -import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "./opencodeQuotaFetcher.ts"; -import { getOpenrouterUsage } from "./usage/openrouter.ts"; -import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts"; -import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.ts"; -import { getPromptQlUsage } from "./usage/promptql.ts"; -import { getHyperAgentUsage } from "./usage/hyperagent.ts"; import { extractCodeAssistOnboardTierId, extractCodeAssistSubscriptionTier, } from "./codeAssistSubscription.ts"; -import { sanitizeErrorMessage } from "../utils/error.ts"; -import { resolveQoderJobToken } from "./qoderCli.ts"; -import { - toRecord, - toNumber, - toPercentage, - toTitleCase, - getFieldValue, - clampPercentage, - roundCurrency, - toDisplayLabel, - pickFirstNonEmptyString, -} from "./usage/scalars.ts"; -import { type UsageQuota, parseResetTime, createQuotaFromUsage } from "./usage/quota.ts"; +import { toDisplayLabel } from "./usage/scalars.ts"; +import { parseResetTime, createQuotaFromUsage } from "./usage/quota.ts"; import { getMiniMaxUsage, getMiniMaxPlanLabel, @@ -62,13 +48,23 @@ import { getKiroUsage, buildKiroUsageResult, discoverKiroProfileArn } from "./us // Re-exported para os testes kiro-* (importam de services/usage). export { buildKiroUsageResult, discoverKiroProfileArn } from "./usage/kiro.ts"; import { getAdobeFireflyUsage } from "./usage/adobeFirefly.ts"; - -// Quota / usage upstream URLs (overridable for testing or relays). -const CROF_USAGE_URL = process.env.OMNIROUTE_CROF_USAGE_URL ?? "https://crof.ai/usage_api/"; - -const NANOGPT_CONFIG = { - usageUrl: "https://nano-gpt.com/api/subscription/v1/usage", -}; +import { getOpenrouterUsage } from "./usage/openrouter.ts"; +import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts"; +import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.ts"; +import { getPromptQlUsage } from "./usage/promptql.ts"; +import { getHyperAgentUsage } from "./usage/hyperagent.ts"; +import { getGitHubUsage, formatGitHubQuotaSnapshot, inferGitHubPlanName } from "./usage/github.ts"; +import { getCrofUsage } from "./usage/crof.ts"; +import { getNanoGptUsage } from "./usage/nanogpt.ts"; +import { getQoderUsage, parseQoderUserStatusUsage } from "./usage/qoder.ts"; +// Re-exported para o teste qoder-usage-quota (importa parseQoderUserStatusUsage de services/usage). +export { parseQoderUserStatusUsage } from "./usage/qoder.ts"; +import { getOpencodeUsage } from "./usage/opencode.ts"; +import { getDeepseekUsage } from "./usage/deepseek.ts"; +import { getBailianCodingPlanUsage } from "./usage/bailian.ts"; +import { getVertexUsage } from "./usage/vertex.ts"; +import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.ts"; +import { getXaiUsage } from "./usage/xai.ts"; type JsonRecord = Record; type UsageProviderConnection = JsonRecord & { @@ -81,429 +77,6 @@ type UsageProviderConnection = JsonRecord & { email?: string; }; -function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota { - if (!quota) return false; - if (quota.unlimited && quota.total <= 0) return false; - return quota.total > 0 || quota.remainingPercentage !== undefined; -} - -// CrofAI surfaces a tiny endpoint with two signals: -// GET https://crof.ai/usage_api/ → { usable_requests: number|null, credits: number } -// `usable_requests` is the daily request bucket on a subscription plan; `null` -// for pay-as-you-go. `credits` is the USD credit balance. We surface both as -// quotas so the Limits & Quotas page can render whichever the account uses. -async function getCrofUsage(apiKey: string) { - if (!apiKey) { - return { message: "CrofAI API key not available. Add a key to view usage." }; - } - - let response: Response; - try { - response = await fetch(CROF_USAGE_URL, { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - }, - }); - } catch (error) { - return { message: `CrofAI connected. Unable to fetch usage: ${(error as Error).message}` }; - } - - const rawText = await response.text(); - - if (response.status === 401 || response.status === 403) { - return { message: "CrofAI connected. The API key was rejected by /usage_api/." }; - } - - if (!response.ok) { - return { message: `CrofAI connected. /usage_api/ returned HTTP ${response.status}.` }; - } - - let payload: JsonRecord = {}; - if (rawText) { - try { - payload = toRecord(JSON.parse(rawText)); - } catch { - return { message: "CrofAI connected. Unable to parse /usage_api/ response." }; - } - } - - const usableRequestsRaw = payload["usable_requests"]; - const usableRequests = - usableRequestsRaw === null || usableRequestsRaw === undefined - ? null - : toNumber(usableRequestsRaw, 0); - const credits = toNumber(payload["credits"], 0); - - const quotas: Record = {}; - - if (usableRequests !== null) { - // CrofAI's /usage_api/ returns only the remaining count; the daily - // allotment is not exposed. CrofAI Pro plan = 1,000 requests/day per - // their pricing page, so use that as the baseline total. If the user - // is on a plan with a higher cap we widen the total to whatever they - // currently report so we never compute a negative `used`. - // Without this, total=0 makes the dashboard's percentage formula read - // 0% (interpreted as "depleted" → red) even on a fresh bucket. - const CROF_DAILY_BASELINE = 1000; - const remaining = Math.max(0, usableRequests); - const total = Math.max(CROF_DAILY_BASELINE, remaining); - const used = Math.max(0, total - remaining); - - // CrofAI also does not return a reset timestamp and the docs only say - // "requests left today". The Crof.ai dashboard shows the daily bucket - // resetting at ~05:00 UTC (verified against the live countdown on - // 2026-04-25), so synthesize the next 05:00 UTC instant to match. - // Swap for a real field if Crof ever exposes one. - const now = new Date(); - const RESET_HOUR_UTC = 5; - const todayResetMs = Date.UTC( - now.getUTCFullYear(), - now.getUTCMonth(), - now.getUTCDate(), - RESET_HOUR_UTC - ); - const nextResetMs = - todayResetMs > now.getTime() ? todayResetMs : todayResetMs + 24 * 60 * 60 * 1000; - const nextResetIso = new Date(nextResetMs).toISOString(); - - quotas["Requests Today"] = { - used, - total, - remaining, - resetAt: nextResetIso, - unlimited: false, - displayName: `Requests Today: ${remaining} left`, - }; - } - - // Credits are an open balance — render as unlimited so the UI shows the - // dollar value rather than a misleading 0/0 bar. - quotas["Credits"] = { - used: 0, - total: 0, - remaining: 0, - resetAt: null, - unlimited: true, - displayName: `Credits: $${credits.toFixed(4)}`, - }; - - return { quotas }; -} - -/** - * Bailian (Alibaba Token Plan) Usage - * Fetches triple-window quota (5h, weekly, monthly) and returns worst-case. - */ -async function getBailianCodingPlanUsage( - connectionId: string, - apiKey: string, - providerSpecificData?: Record -) { - try { - const connection = { apiKey, providerSpecificData }; - const quota = await fetchBailianQuota(connectionId, connection); - - if (!quota) { - return { message: "Alibaba Token Plan connected. Unable to fetch quota." }; - } - - const bailianQuota = quota as BailianTripleWindowQuota; - const used = bailianQuota.used; - const total = bailianQuota.total; - const remaining = Math.max(0, total - used); - const remainingPercentage = Math.round(remaining); - - return { - plan: "Alibaba Token Plan", - used, - total, - remaining, - remainingPercentage, - resetAt: bailianQuota.resetAt, - unlimited: false, - displayName: "Alibaba Token Plan", - }; - } catch (error) { - return { message: `Alibaba Token Plan error: ${(error as Error).message}` }; - } -} - -/** - * DeepSeek Usage - * Fetches balance from the DeepSeek balance API. - * Returns all balances (USD and CNY) as "credits" for credits-style UI display. - */ -async function getDeepseekUsage(connectionId: string, apiKey: string) { - try { - const connection = { apiKey }; - const quota = await fetchDeepseekQuota(connectionId, connection); - - if (!quota) { - return { message: "DeepSeek API key not available. Add a key to view usage." }; - } - - const deepseekQuota = quota as DeepseekQuota; - const { balances, isAvailable, limitReached } = deepseekQuota; - - const quotas: Record = {}; - - // Show all balances as credits-style entries (e.g., credits_usd, credits_cny) - // The UI will display them as "🪙 Balance (USD) $50.00" - for (const balanceInfo of balances) { - const key = `credits_${balanceInfo.currency.toLowerCase()}`; - quotas[key] = { - used: 0, - total: 0, - remaining: balanceInfo.balance, - remainingPercentage: 100, - resetAt: null, - unlimited: true, - currency: balanceInfo.currency, - grantedBalance: balanceInfo.grantedBalance, - toppedUpBalance: balanceInfo.toppedUpBalance, - }; - } - - const plan = isAvailable ? "DeepSeek" : "DeepSeek (Insufficient Balance)"; - - return { - plan, - quotas, - isAvailable, - limitReached, - }; - } catch (error) { - return { message: `DeepSeek error: ${(error as Error).message}` }; - } -} - -// Xiaomi MiMo Token Plan monthly limit (tokens). Keep in sync with the -// "xiaomi-mimo" preset in src/lib/quota/planRegistry.ts. -const XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT = 4_100_000_000; - -/** - * Xiaomi MiMo — SELF-TRACKED monthly quota. - * - * Xiaomi exposes plan usage only behind the console session cookie (the API key - * cannot reach the `tokenPlan/usage` endpoint), so there is no upstream usage - * API to call. Instead we count the tokens OmniRoute itself routed to this - * connection in the current UTC month (from `usage_history`) and compare them - * to the known Token Plan monthly limit. This reflects only traffic that went - * through OmniRoute, not the provider's own dashboard figure. - */ -async function getXiaomiMimoUsage(connectionId: string) { - if (!connectionId) { - return { message: "Xiaomi MiMo: connection id unavailable for self-tracked quota." }; - } - try { - const { getMonthlyProviderTokensForConnection } = await import("@/lib/usage/usageStats"); - const used = getMonthlyProviderTokensForConnection("xiaomi-mimo", connectionId); - const total = XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT; - const now = new Date(); - const resetAt = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1) - ).toISOString(); - return { - plan: "Xiaomi MiMo Token Plan (OmniRoute-tracked)", - quotas: { - monthly: createQuotaFromUsage(used, total, resetAt), - }, - }; - } catch (error) { - return { message: `Xiaomi MiMo self-tracked usage error: ${(error as Error).message}` }; - } -} - -/** - * xAI (Grok) — SELF-TRACKED cumulative usage. - * - * xAI has no public per-account quota API (the billing console at console.x.ai - * requires a session cookie, not an API key), so — exactly like the Xiaomi - * MiMo self-track pattern above — OmniRoute sums the tokens it itself routed - * to this connection (from `usage_history`) instead of calling an upstream - * endpoint. Unlike Xiaomi MiMo, xAI has no fixed monthly cap, so the - * aggregate is reported as `unlimited: true` with `remaining: 100` — this - * renders the dashboard's green "100%" badge instead of a meaningless - * progress bar against a `total: 0`. - */ -async function getXaiUsage(connectionId: string) { - if (!connectionId) { - return { message: "xAI: connection id unavailable for self-tracked usage." }; - } - try { - const { getMonthlyProviderTokensForConnection } = await import("@/lib/usage/usageStats"); - const used = getMonthlyProviderTokensForConnection("xai", connectionId); - return { - plan: "xAI / Grok (OmniRoute-tracked)", - quotas: { - monthly: { - used, - total: 0, - remaining: 100, - remainingPercentage: 100, - resetAt: null, - unlimited: true, - } as UsageQuota, - }, - }; - } catch (error) { - return { message: `xAI self-tracked usage error: ${(error as Error).message}` }; - } -} - -/** - * OpenCode Go / OpenCode / OpenCode Zen Usage - * Delegates to the dedicated opencodeQuotaFetcher and shapes the result into - * the standard `{ plan, quotas }` usage response expected by the limits page. - * - * Three rolling windows are surfaced: $12/5h, $30/wk, $60/mo. - */ -async function getOpencodeUsage(connectionId: string, apiKey: string) { - if (!apiKey) { - return { message: "OpenCode API key not available. Add a key to view usage." }; - } - - try { - const quota = (await fetchOpencodeQuota(connectionId, { - apiKey, - })) as OpencodeTripleWindowQuota | null; - - if (!quota) { - return { message: "OpenCode connected. Unable to fetch quota data." }; - } - - const { window5h, windowWeekly, windowMonthly, limitReached } = quota; - - const quotas: Record = {}; - - // $12 / 5-hour rolling window - quotas["window_5h"] = { - used: window5h.percentUsed * 12, - total: 12, - remaining: (1 - window5h.percentUsed) * 12, - remainingPercentage: (1 - window5h.percentUsed) * 100, - resetAt: window5h.resetAt, - unlimited: false, - displayName: "$12 / 5-hour", - currency: "USD", - }; - - // $30 / weekly window - quotas["window_weekly"] = { - used: windowWeekly.percentUsed * 30, - total: 30, - remaining: (1 - windowWeekly.percentUsed) * 30, - remainingPercentage: (1 - windowWeekly.percentUsed) * 100, - resetAt: windowWeekly.resetAt, - unlimited: false, - displayName: "$30 / week", - currency: "USD", - }; - - // $60 / monthly window - quotas["window_monthly"] = { - used: windowMonthly.percentUsed * 60, - total: 60, - remaining: (1 - windowMonthly.percentUsed) * 60, - remainingPercentage: (1 - windowMonthly.percentUsed) * 100, - resetAt: windowMonthly.resetAt, - unlimited: false, - displayName: "$60 / month", - currency: "USD", - }; - - return { - plan: "OpenCode Go", - quotas, - limitReached, - }; - } catch (error) { - return { message: `OpenCode error: ${sanitizeErrorMessage(error)}` }; - } -} - -/** - * NanoGPT Usage - * Fetches subscription-level quota from the NanoGPT API. - * Returns daily/weekly token limits and daily image limits for PRO accounts. - */ -async function getNanoGptUsage(apiKey: string) { - if (!apiKey) { - return { message: "NanoGPT API key not available. Add a key to view usage." }; - } - - try { - const res = await fetch(NANOGPT_CONFIG.usageUrl, { - headers: { Authorization: `Bearer ${apiKey}` }, - }); - - if (!res.ok) { - if (res.status === 401) return { message: "Invalid NanoGPT API key." }; - return { message: `NanoGPT quota API error (${res.status})` }; - } - - const data = toRecord(await res.json()); - const quotas: Record = {}; - - // active -> PRO, otherwise FREE - const plan = data.active ? "PRO" : "FREE"; - - if (data.active) { - // 1. Tokens limit - // dailyInputTokens if exists, else weeklyInputTokens - let tokenQuota = toRecord(data.dailyInputTokens); - let tokenLabel = "Daily Tokens"; - if (!tokenQuota.resetAt) { - const weeklyQuota = toRecord(data.weeklyInputTokens); - if (weeklyQuota.remaining !== undefined) { - tokenQuota = weeklyQuota; - tokenLabel = "Weekly Tokens"; - } - } - - if (tokenQuota.remaining !== undefined) { - const used = toNumber(tokenQuota.used, 0); - const remaining = toNumber(tokenQuota.remaining, 0); - const total = used + remaining; - quotas[tokenLabel] = { - used, - total, - remaining, - remainingPercentage: clampPercentage(100 - toNumber(tokenQuota.percentUsed, 0) * 100), - resetAt: parseResetTime(tokenQuota.resetAt), - unlimited: false, - }; - } - - // 2. Images limit - const imageQuota = toRecord(data.dailyImages); - if (imageQuota.remaining !== undefined) { - const used = toNumber(imageQuota.used, 0); - const remaining = toNumber(imageQuota.remaining, 0); - const total = used + remaining; - quotas["Daily Images"] = { - used, - total, - remaining, - remainingPercentage: clampPercentage(100 - toNumber(imageQuota.percentUsed, 0) * 100), - resetAt: parseResetTime(imageQuota.resetAt), - unlimited: false, - }; - } - - if (Object.keys(quotas).length === 0) { - return { plan, message: "NanoGPT connected, but no active limits found." }; - } - } - - return { plan, quotas }; - } catch (error) { - return { message: `NanoGPT connected. Unable to fetch usage: ${(error as Error).message}` }; - } -} - /** * Single source of truth for which providers have a `getUsageForProvider` * implementation. Consumers like `genericQuotaFetcher.ts` reference this so @@ -633,11 +206,7 @@ export async function getUsageForProvider( case "promptql": case "pql": // DDN lux JWTs carry projectId only in JWT aud; connection.projectId may be set by sync. - return await getPromptQlUsage( - apiKey || accessToken, - providerSpecificData, - projectId - ); + return await getPromptQlUsage(apiKey || accessToken, providerSpecificData, projectId); case "adobe-firefly": case "firefly": // Cookie or IMS JWT in apiKey/accessToken → GET firefly.adobe.io/v1/credits/balance @@ -650,387 +219,6 @@ export async function getUsageForProvider( } } -/** - * Parse reset date/time to ISO string - * Handles multiple formats: Unix timestamp (ms), ISO date string, etc. - */ -/** - * GitHub Copilot Usage - * Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API - */ -async function getGitHubUsage(accessToken?: string, providerSpecificData?: JsonRecord) { - try { - if (!accessToken) { - throw new Error("No GitHub access token available. Please re-authorize the connection."); - } - - // copilot_internal/user API requires GitHub OAuth token, not copilotToken - const response = await fetch("https://api.github.com/copilot_internal/user", { - headers: getGitHubCopilotInternalUserHeaders(`token ${accessToken}`), - }); - - if (!response.ok) { - const error = await response.text(); - if (response.status === 401 || response.status === 403) { - return { - message: `GitHub token expired or permission denied. Please re-authenticate the connection.`, - }; - } - throw new Error(`GitHub API error: ${error}`); - } - - const data = await response.json(); - const dataRecord = toRecord(data); - - // Handle different response formats (paid vs free) - if (dataRecord.quota_snapshots) { - // Paid plan format - const snapshots = toRecord(dataRecord.quota_snapshots); - const resetAt = parseResetTime( - getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate") - ); - const premiumQuota = formatGitHubQuotaSnapshot(snapshots.premium_interactions, resetAt); - const chatQuota = formatGitHubQuotaSnapshot(snapshots.chat, resetAt); - const completionsQuota = formatGitHubQuotaSnapshot(snapshots.completions, resetAt); - const quotas: Record = {}; - - if (shouldDisplayGitHubQuota(premiumQuota)) { - quotas.premium_interactions = premiumQuota; - } - if (shouldDisplayGitHubQuota(chatQuota)) { - quotas.chat = chatQuota; - } - if (shouldDisplayGitHubQuota(completionsQuota)) { - quotas.completions = completionsQuota; - } - - return { - plan: inferGitHubPlanName(dataRecord, premiumQuota), - resetDate: getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate"), - quotas, - }; - } else if (dataRecord.monthly_quotas || dataRecord.limited_user_quotas) { - // Free/limited plan format. NOTE (#2876): the upstream field - // `limited_user_quotas[name]` is the *remaining* count for the month - // (it counts down toward 0 and resets on `limited_user_reset_date`), - // NOT the used count. The pre-3.8.6 implementation inverted this and - // showed "0% when not used / 100% when fully used" on the dashboard. - // Confirmed against three independent upstream parsers: - // - robinebers/openusage docs/providers/copilot.md (Free Tier table) - // - raycast/extensions agent-usage/src/copilot/fetcher.ts (inline comment) - // - looplj/axonhub frontend/src/components/quota-badges.tsx - const monthlyQuotas = toRecord(dataRecord.monthly_quotas); - const remainingQuotas = toRecord(dataRecord.limited_user_quotas); - const resetDate = getFieldValue( - dataRecord, - "limited_user_reset_date", - "limitedUserResetDate" - ); - const resetAt = parseResetTime(resetDate); - const quotas: Record = {}; - - const addLimitedQuota = (name: string) => { - const total = toNumber(getFieldValue(monthlyQuotas, name, name), 0); - if (total <= 0) return null; - const remainingRaw = Math.max(0, toNumber(getFieldValue(remainingQuotas, name, name), 0)); - const remaining = Math.min(remainingRaw, total); - const used = Math.max(total - remaining, 0); - quotas[name] = { - used, - total, - remaining, - remainingPercentage: clampPercentage((remaining / total) * 100), - unlimited: false, - resetAt, - }; - return quotas[name]; - }; - - const premiumQuota = addLimitedQuota("premium_interactions"); - addLimitedQuota("chat"); - addLimitedQuota("completions"); - - return { - plan: inferGitHubPlanName(dataRecord, premiumQuota), - resetDate, - quotas, - }; - } - - return { message: "GitHub Copilot connected. Unable to parse quota data." }; - } catch (error) { - throw new Error(`Failed to fetch GitHub usage: ${error.message}`); - } -} - -function formatGitHubQuotaSnapshot( - quota: unknown, - resetAt: string | null = null -): UsageQuota | null { - const source = toRecord(quota); - if (Object.keys(source).length === 0) return null; - - const unlimited = source.unlimited === true; - const entitlement = toNumber(source.entitlement, Number.NaN); - const totalValue = toNumber(source.total, Number.NaN); - const remainingValue = toNumber(source.remaining, Number.NaN); - const usedValue = toNumber(source.used, Number.NaN); - const percentRemainingValue = toNumber( - getFieldValue(source, "percent_remaining", "percentRemaining"), - Number.NaN - ); - - let total = Number.isFinite(totalValue) - ? Math.max(0, totalValue) - : Number.isFinite(entitlement) - ? Math.max(0, entitlement) - : 0; - let remaining = Number.isFinite(remainingValue) ? Math.max(0, remainingValue) : undefined; - let used = Number.isFinite(usedValue) ? Math.max(0, usedValue) : undefined; - let remainingPercentage = Number.isFinite(percentRemainingValue) - ? clampPercentage(percentRemainingValue) - : undefined; - - if (used === undefined && total > 0 && remaining !== undefined) { - used = Math.max(total - remaining, 0); - } - - if (remaining === undefined && total > 0 && used !== undefined) { - remaining = Math.max(total - used, 0); - } - - if (remainingPercentage === undefined && total > 0 && remaining !== undefined) { - remainingPercentage = clampPercentage((remaining / total) * 100); - } - - if (total <= 0 && remainingPercentage !== undefined) { - total = 100; - used = 100 - remainingPercentage; - remaining = remainingPercentage; - } - - return { - used: Math.max(0, used ?? 0), - total, - remaining, - remainingPercentage, - resetAt, - unlimited, - }; -} - -function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): string { - const rawPlan = getFieldValue(data, "copilot_plan", "copilotPlan"); - const rawSku = getFieldValue(data, "access_type_sku", "accessTypeSku"); - const planText = typeof rawPlan === "string" ? rawPlan.trim() : ""; - const skuText = typeof rawSku === "string" ? rawSku.trim() : ""; - const combined = `${skuText} ${planText}`.trim().toUpperCase(); - const monthlyQuotas = toRecord(getFieldValue(data, "monthly_quotas", "monthlyQuotas")); - const premiumTotal = - premiumQuota?.total || - toNumber(getFieldValue(monthlyQuotas, "premium_interactions", "premiumInteractions"), 0); - const chatTotal = toNumber(getFieldValue(monthlyQuotas, "chat", "chat"), 0); - - if (combined.includes("PRO+") || combined.includes("PRO_PLUS") || combined.includes("PROPLUS")) { - return "Copilot Pro+"; - } - if (combined.includes("ENTERPRISE")) return "Copilot Enterprise"; - if (combined.includes("BUSINESS")) return "Copilot Business"; - if (combined.includes("STUDENT")) return "Copilot Student"; - if (combined.includes("FREE")) return "Copilot Free"; - if (combined.includes("PRO")) return "Copilot Pro"; - - if (premiumTotal >= 1400) return "Copilot Pro+"; - if (premiumTotal >= 900) return "Copilot Enterprise"; - if (premiumTotal >= 250) { - if (combined.includes("INDIVIDUAL")) return "Copilot Pro"; - return "Copilot Business"; - } - if (premiumTotal > 0 || chatTotal === 50) return "Copilot Free"; - - if (skuText) { - const label = toDisplayLabel(skuText); - return label ? `Copilot ${label}` : "GitHub Copilot"; - } - if (planText) { - const label = toDisplayLabel(planText); - return label ? `Copilot ${label}` : "GitHub Copilot"; - } - return "GitHub Copilot"; -} - -/** - * Vertex AI — SELF-TRACKED spend. - * - * Vertex AI exposes no usage/quota API for an API key or Service Account (billing/credit balance - * lives behind the Cloud Billing API, which the proxy credential can't reach). Instead we report - * the USD that OmniRoute has spent through this connection since the account was added — summed - * from `usage_history` and priced via the backend pricing table. Returns a `message` (with the $ - * figure) plus a `spend` quota entry so the limits cache persists it (a message-only result is - * treated as a transient error and not cached). - */ -async function getVertexUsage(connectionId: string, provider: string) { - if (!connectionId) { - return { message: "Vertex connected. Connection id unavailable for usage tracking." }; - } - try { - const { getConnectionSpendUsdSinceAdded } = await import("@/lib/usage/usageStats"); - const { costUsd, requests } = await getConnectionSpendUsdSinceAdded(provider, connectionId); - - const spend: JsonRecord = { - used: Number(costUsd.toFixed(6)), - displayName: "Spend (USD)", - quotaSource: "localUsageHistory", - resetAt: null, - unlimited: false, - }; - - if (requests === 0) { - return { - plan: "Vertex AI", - message: "Vertex connected. No usage recorded through OmniRoute yet for this account.", - quotas: { spend }, - }; - } - - const costStr = costUsd >= 1 ? costUsd.toFixed(2) : costUsd.toFixed(4); - return { - plan: "Vertex AI", - message: `$${costStr} used since this account was added \u00b7 ${requests} request${ - requests === 1 ? "" : "s" - }`, - quotas: { spend }, - }; - } catch (error) { - return { message: `Vertex usage tracking error: ${(error as Error).message}` }; - } -} - -/** - * Qoder Usage - * - * Qoder exposes account plan + quota at `openapi.qoder.sh/api/v3/user/status`, - * the same endpoint the official qodercli reads for its usage badge. The status - * call needs a short-lived `jt-*` job token, so we exchange the PAT the same way - * the chat/validation paths do (see qoderCli.ts::resolveQoderJobToken). - */ -const QODER_USER_STATUS_URL = "https://openapi.qoder.sh/api/v3/user/status"; - -/** Human-readable plan label from Qoder's `PLAN_TIER_*` enum / `userTag`. */ -function prettifyQoderPlan(planRaw: string, userTag: string): string { - const tag = String(userTag || "").trim(); - if (tag) return tag; - const stripped = String(planRaw || "") - .trim() - .replace(/^PLAN_TIER_/i, ""); - return stripped ? toTitleCase(stripped) : "Qoder"; -} - -/** - * Map a Qoder `/user/status` payload into the shared `{ plan, quotas }` shape. - * Pure (no I/O) so it can be unit-tested against captured payloads. - */ -export function parseQoderUserStatusUsage(status: JsonRecord): { - plan: string; - quotas: Record; -} { - const userType = String(status.userType || "") - .trim() - .toLowerCase(); - const planLabel = prettifyQoderPlan(String(status.plan || ""), String(status.userTag || "")); - const isExceeded = status.isQuotaExceeded === true; - const quotaNum = toNumber(status.quota, 0); - const resetAt = parseResetTime(status.nextResetAt); - // Team/enterprise seats draw from a pooled org quota rather than a per-user - // counter, so `quota: 0` there means "pooled", not "exhausted". - const isPooled = userType === "teams" || userType === "enterprise"; - - const quotas: Record = {}; - if (isExceeded) { - // Genuinely out of quota — remainingPercentage 0 lets routing skip it until reset. - quotas["Quota"] = { - used: quotaNum, - total: quotaNum, - remaining: 0, - remainingPercentage: 0, - resetAt, - unlimited: false, - displayName: "Quota exceeded", - }; - } else if (isPooled || quotaNum <= 0) { - // Pooled/unlimited seat — MUST report 100% remaining. The quota→routing - // conversion (src/domain/quotaCache.ts) ignores `unlimited` and would treat a - // `total: 0` window as 0% (i.e. exhausted), wrongly 429-ing every request. - quotas["Plan"] = { - used: 0, - total: 0, - remaining: 0, - remainingPercentage: 100, - resetAt, - unlimited: true, - displayName: `${planLabel} plan · pooled quota`, - }; - } else { - quotas["Requests"] = { - used: 0, - total: quotaNum, - remaining: quotaNum, - remainingPercentage: 100, - resetAt, - unlimited: false, - displayName: `${quotaNum} requests left`, - }; - } - - return { plan: planLabel, quotas }; -} - -async function getQoderUsage(apiKey?: string, providerSpecificData?: JsonRecord) { - const token = (apiKey || "").trim() || String(providerSpecificData?.qoderPat || "").trim(); - if (!token) { - return { message: "Qoder connected. Add a Personal Access Token to view quota." }; - } - - let jobToken: string; - try { - jobToken = await resolveQoderJobToken(token); - } catch { - return { message: "Qoder connected. Unable to resolve a usage token." }; - } - - let response: Response; - try { - response = await fetch(QODER_USER_STATUS_URL, { - method: "GET", - headers: { Authorization: `Bearer ${jobToken}`, Accept: "application/json" }, - // @ts-ignore — AbortSignal.timeout is available on the Node runtime - signal: AbortSignal.timeout(15000), - }); - } catch (error) { - return { - message: `Qoder connected. Unable to fetch usage: ${sanitizeErrorMessage((error as Error).message)}`, - }; - } - - if (response.status === 401 || response.status === 403) { - return { - message: "Qoder connected. The token was rejected by the usage API — re-test the connection.", - }; - } - if (!response.ok) { - return { message: `Qoder connected. Usage API returned HTTP ${response.status}.` }; - } - - let status: JsonRecord; - try { - status = toRecord(await response.json()); - } catch { - return { message: "Qoder connected. Unable to parse the usage response." }; - } - - return parseQoderUserStatusUsage(status); -} - export const __testing = { parseResetTime, parseQoderUserStatusUsage, diff --git a/open-sse/services/usage/bailian.ts b/open-sse/services/usage/bailian.ts new file mode 100644 index 0000000000..9830472234 --- /dev/null +++ b/open-sse/services/usage/bailian.ts @@ -0,0 +1,50 @@ +/** + * usage/bailian.ts — Bailian (Alibaba Token Plan) usage fetcher. + * + * Extracted from services/usage.ts (god-file decomposition): the Bailian family — + * delegates to the dedicated bailianQuotaFetcher and shapes the triple-window + * (5h, weekly, monthly) worst-case quota into the standard usage response. + * Depends only on the sibling scalar/quota leaves + fetchBailianQuota — no host + * coupling — so it lives as a co-located provider leaf. usage.ts imports + * getBailianCodingPlanUsage (dispatcher). Behavior-preserving move. + */ + +import { fetchBailianQuota, type BailianTripleWindowQuota } from "../bailianQuotaFetcher.ts"; + +/** + * Bailian (Alibaba Token Plan) Usage + * Fetches triple-window quota (5h, weekly, monthly) and returns worst-case. + */ +export async function getBailianCodingPlanUsage( + connectionId: string, + apiKey: string, + providerSpecificData?: Record +) { + try { + const connection = { apiKey, providerSpecificData }; + const quota = await fetchBailianQuota(connectionId, connection); + + if (!quota) { + return { message: "Alibaba Token Plan connected. Unable to fetch quota." }; + } + + const bailianQuota = quota as BailianTripleWindowQuota; + const used = bailianQuota.used; + const total = bailianQuota.total; + const remaining = Math.max(0, total - used); + const remainingPercentage = Math.round(remaining); + + return { + plan: "Alibaba Token Plan", + used, + total, + remaining, + remainingPercentage, + resetAt: bailianQuota.resetAt, + unlimited: false, + displayName: "Alibaba Token Plan", + }; + } catch (error) { + return { message: `Alibaba Token Plan error: ${(error as Error).message}` }; + } +} diff --git a/open-sse/services/usage/crof.ts b/open-sse/services/usage/crof.ts new file mode 100644 index 0000000000..1ab2011dca --- /dev/null +++ b/open-sse/services/usage/crof.ts @@ -0,0 +1,123 @@ +/** + * usage/crof.ts — CrofAI usage fetcher. + * + * Extracted from services/usage.ts (god-file decomposition): the CrofAI family — + * the /usage_api/ endpoint config and the getCrofUsage fetcher that surfaces the + * daily `usable_requests` subscription bucket plus the USD `credits` balance as + * separate quotas. Depends only on the sibling scalar/quota leaves — no host + * coupling — so it lives as a co-located provider leaf. usage.ts imports + * getCrofUsage (dispatcher). Behavior-preserving move. + */ + +import { toRecord, toNumber } from "./scalars.ts"; +import { type UsageQuota } from "./quota.ts"; + +type JsonRecord = Record; + +// Quota / usage upstream URLs (overridable for testing or relays). +const CROF_USAGE_URL = process.env.OMNIROUTE_CROF_USAGE_URL ?? "https://crof.ai/usage_api/"; + +// CrofAI surfaces a tiny endpoint with two signals: +// GET https://crof.ai/usage_api/ → { usable_requests: number|null, credits: number } +// `usable_requests` is the daily request bucket on a subscription plan; `null` +// for pay-as-you-go. `credits` is the USD credit balance. We surface both as +// quotas so the Limits & Quotas page can render whichever the account uses. +export async function getCrofUsage(apiKey: string) { + if (!apiKey) { + return { message: "CrofAI API key not available. Add a key to view usage." }; + } + + let response: Response; + try { + response = await fetch(CROF_USAGE_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }); + } catch (error) { + return { message: `CrofAI connected. Unable to fetch usage: ${(error as Error).message}` }; + } + + const rawText = await response.text(); + + if (response.status === 401 || response.status === 403) { + return { message: "CrofAI connected. The API key was rejected by /usage_api/." }; + } + + if (!response.ok) { + return { message: `CrofAI connected. /usage_api/ returned HTTP ${response.status}.` }; + } + + let payload: JsonRecord = {}; + if (rawText) { + try { + payload = toRecord(JSON.parse(rawText)); + } catch { + return { message: "CrofAI connected. Unable to parse /usage_api/ response." }; + } + } + + const usableRequestsRaw = payload["usable_requests"]; + const usableRequests = + usableRequestsRaw === null || usableRequestsRaw === undefined + ? null + : toNumber(usableRequestsRaw, 0); + const credits = toNumber(payload["credits"], 0); + + const quotas: Record = {}; + + if (usableRequests !== null) { + // CrofAI's /usage_api/ returns only the remaining count; the daily + // allotment is not exposed. CrofAI Pro plan = 1,000 requests/day per + // their pricing page, so use that as the baseline total. If the user + // is on a plan with a higher cap we widen the total to whatever they + // currently report so we never compute a negative `used`. + // Without this, total=0 makes the dashboard's percentage formula read + // 0% (interpreted as "depleted" → red) even on a fresh bucket. + const CROF_DAILY_BASELINE = 1000; + const remaining = Math.max(0, usableRequests); + const total = Math.max(CROF_DAILY_BASELINE, remaining); + const used = Math.max(0, total - remaining); + + // CrofAI also does not return a reset timestamp and the docs only say + // "requests left today". The Crof.ai dashboard shows the daily bucket + // resetting at ~05:00 UTC (verified against the live countdown on + // 2026-04-25), so synthesize the next 05:00 UTC instant to match. + // Swap for a real field if Crof ever exposes one. + const now = new Date(); + const RESET_HOUR_UTC = 5; + const todayResetMs = Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + RESET_HOUR_UTC + ); + const nextResetMs = + todayResetMs > now.getTime() ? todayResetMs : todayResetMs + 24 * 60 * 60 * 1000; + const nextResetIso = new Date(nextResetMs).toISOString(); + + quotas["Requests Today"] = { + used, + total, + remaining, + resetAt: nextResetIso, + unlimited: false, + displayName: `Requests Today: ${remaining} left`, + }; + } + + // Credits are an open balance — render as unlimited so the UI shows the + // dollar value rather than a misleading 0/0 bar. + quotas["Credits"] = { + used: 0, + total: 0, + remaining: 0, + resetAt: null, + unlimited: true, + displayName: `Credits: $${credits.toFixed(4)}`, + }; + + return { quotas }; +} diff --git a/open-sse/services/usage/deepseek.ts b/open-sse/services/usage/deepseek.ts new file mode 100644 index 0000000000..b558b37ca4 --- /dev/null +++ b/open-sse/services/usage/deepseek.ts @@ -0,0 +1,62 @@ +/** + * usage/deepseek.ts — DeepSeek usage fetcher. + * + * Extracted from services/usage.ts (god-file decomposition): the DeepSeek family — + * delegates to the dedicated deepseekQuotaFetcher and shapes the balance result + * into the standard `{ plan, quotas }` usage response (all balances surfaced as + * credits-style entries). Depends only on the sibling scalar/quota leaves + + * fetchDeepseekQuota — no host coupling — so it lives as a co-located provider + * leaf. usage.ts imports getDeepseekUsage (dispatcher). Behavior-preserving move. + */ + +import { fetchDeepseekQuota, type DeepseekQuota } from "../deepseekQuotaFetcher.ts"; +import { type UsageQuota } from "./quota.ts"; + +/** + * DeepSeek Usage + * Fetches balance from the DeepSeek balance API. + * Returns all balances (USD and CNY) as "credits" for credits-style UI display. + */ +export async function getDeepseekUsage(connectionId: string, apiKey: string) { + try { + const connection = { apiKey }; + const quota = await fetchDeepseekQuota(connectionId, connection); + + if (!quota) { + return { message: "DeepSeek API key not available. Add a key to view usage." }; + } + + const deepseekQuota = quota as DeepseekQuota; + const { balances, isAvailable, limitReached } = deepseekQuota; + + const quotas: Record = {}; + + // Show all balances as credits-style entries (e.g., credits_usd, credits_cny) + // The UI will display them as "🪙 Balance (USD) $50.00" + for (const balanceInfo of balances) { + const key = `credits_${balanceInfo.currency.toLowerCase()}`; + quotas[key] = { + used: 0, + total: 0, + remaining: balanceInfo.balance, + remainingPercentage: 100, + resetAt: null, + unlimited: true, + currency: balanceInfo.currency, + grantedBalance: balanceInfo.grantedBalance, + toppedUpBalance: balanceInfo.toppedUpBalance, + }; + } + + const plan = isAvailable ? "DeepSeek" : "DeepSeek (Insufficient Balance)"; + + return { + plan, + quotas, + isAvailable, + limitReached, + }; + } catch (error) { + return { message: `DeepSeek error: ${(error as Error).message}` }; + } +} diff --git a/open-sse/services/usage/github.ts b/open-sse/services/usage/github.ts new file mode 100644 index 0000000000..628f6edb91 --- /dev/null +++ b/open-sse/services/usage/github.ts @@ -0,0 +1,230 @@ +/** + * usage/github.ts — GitHub Copilot usage fetcher + quota/plan helpers. + * + * Extracted from services/usage.ts (god-file decomposition): the GitHub family — + * the copilot_internal/user fetcher (getGitHubUsage), the paid/limited quota + * snapshot formatter (formatGitHubQuotaSnapshot), the plan-name inference + * (inferGitHubPlanName), and the display gate (shouldDisplayGitHubQuota). + * Depends only on the sibling scalar/quota leaves + the GitHub internal-user + * header profile — no host coupling — so it lives as a co-located provider leaf. + * usage.ts imports getGitHubUsage (dispatcher) + re-exports the helpers via + * __testing (existing usage-utils / usage-service-hardening suites read them + * from there). Behavior-preserving move. + */ + +import { getGitHubCopilotInternalUserHeaders } from "../../config/providerHeaderProfiles.ts"; +import { toRecord, toNumber, getFieldValue, clampPercentage, toDisplayLabel } from "./scalars.ts"; +import { type UsageQuota, parseResetTime } from "./quota.ts"; + +type JsonRecord = Record; + +export function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota { + if (!quota) return false; + if (quota.unlimited && quota.total <= 0) return false; + return quota.total > 0 || quota.remainingPercentage !== undefined; +} + +/** + * GitHub Copilot Usage + * Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API + */ +export async function getGitHubUsage(accessToken?: string, providerSpecificData?: JsonRecord) { + try { + if (!accessToken) { + throw new Error("No GitHub access token available. Please re-authorize the connection."); + } + + // copilot_internal/user API requires GitHub OAuth token, not copilotToken + const response = await fetch("https://api.github.com/copilot_internal/user", { + headers: getGitHubCopilotInternalUserHeaders(`token ${accessToken}`), + }); + + if (!response.ok) { + const error = await response.text(); + if (response.status === 401 || response.status === 403) { + return { + message: `GitHub token expired or permission denied. Please re-authenticate the connection.`, + }; + } + throw new Error(`GitHub API error: ${error}`); + } + + const data = await response.json(); + const dataRecord = toRecord(data); + + // Handle different response formats (paid vs free) + if (dataRecord.quota_snapshots) { + // Paid plan format + const snapshots = toRecord(dataRecord.quota_snapshots); + const resetAt = parseResetTime( + getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate") + ); + const premiumQuota = formatGitHubQuotaSnapshot(snapshots.premium_interactions, resetAt); + const chatQuota = formatGitHubQuotaSnapshot(snapshots.chat, resetAt); + const completionsQuota = formatGitHubQuotaSnapshot(snapshots.completions, resetAt); + const quotas: Record = {}; + + if (shouldDisplayGitHubQuota(premiumQuota)) { + quotas.premium_interactions = premiumQuota; + } + if (shouldDisplayGitHubQuota(chatQuota)) { + quotas.chat = chatQuota; + } + if (shouldDisplayGitHubQuota(completionsQuota)) { + quotas.completions = completionsQuota; + } + + return { + plan: inferGitHubPlanName(dataRecord, premiumQuota), + resetDate: getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate"), + quotas, + }; + } else if (dataRecord.monthly_quotas || dataRecord.limited_user_quotas) { + // Free/limited plan format. NOTE (#2876): the upstream field + // `limited_user_quotas[name]` is the *remaining* count for the month + // (it counts down toward 0 and resets on `limited_user_reset_date`), + // NOT the used count. The pre-3.8.6 implementation inverted this and + // showed "0% when not used / 100% when fully used" on the dashboard. + // Confirmed against three independent upstream parsers: + // - robinebers/openusage docs/providers/copilot.md (Free Tier table) + // - raycast/extensions agent-usage/src/copilot/fetcher.ts (inline comment) + // - looplj/axonhub frontend/src/components/quota-badges.tsx + const monthlyQuotas = toRecord(dataRecord.monthly_quotas); + const remainingQuotas = toRecord(dataRecord.limited_user_quotas); + const resetDate = getFieldValue( + dataRecord, + "limited_user_reset_date", + "limitedUserResetDate" + ); + const resetAt = parseResetTime(resetDate); + const quotas: Record = {}; + + const addLimitedQuota = (name: string) => { + const total = toNumber(getFieldValue(monthlyQuotas, name, name), 0); + if (total <= 0) return null; + const remainingRaw = Math.max(0, toNumber(getFieldValue(remainingQuotas, name, name), 0)); + const remaining = Math.min(remainingRaw, total); + const used = Math.max(total - remaining, 0); + quotas[name] = { + used, + total, + remaining, + remainingPercentage: clampPercentage((remaining / total) * 100), + unlimited: false, + resetAt, + }; + return quotas[name]; + }; + + const premiumQuota = addLimitedQuota("premium_interactions"); + addLimitedQuota("chat"); + addLimitedQuota("completions"); + + return { + plan: inferGitHubPlanName(dataRecord, premiumQuota), + resetDate, + quotas, + }; + } + + return { message: "GitHub Copilot connected. Unable to parse quota data." }; + } catch (error) { + throw new Error(`Failed to fetch GitHub usage: ${error.message}`); + } +} + +export function formatGitHubQuotaSnapshot( + quota: unknown, + resetAt: string | null = null +): UsageQuota | null { + const source = toRecord(quota); + if (Object.keys(source).length === 0) return null; + + const unlimited = source.unlimited === true; + const entitlement = toNumber(source.entitlement, Number.NaN); + const totalValue = toNumber(source.total, Number.NaN); + const remainingValue = toNumber(source.remaining, Number.NaN); + const usedValue = toNumber(source.used, Number.NaN); + const percentRemainingValue = toNumber( + getFieldValue(source, "percent_remaining", "percentRemaining"), + Number.NaN + ); + + let total = Number.isFinite(totalValue) + ? Math.max(0, totalValue) + : Number.isFinite(entitlement) + ? Math.max(0, entitlement) + : 0; + let remaining = Number.isFinite(remainingValue) ? Math.max(0, remainingValue) : undefined; + let used = Number.isFinite(usedValue) ? Math.max(0, usedValue) : undefined; + let remainingPercentage = Number.isFinite(percentRemainingValue) + ? clampPercentage(percentRemainingValue) + : undefined; + + if (used === undefined && total > 0 && remaining !== undefined) { + used = Math.max(total - remaining, 0); + } + + if (remaining === undefined && total > 0 && used !== undefined) { + remaining = Math.max(total - used, 0); + } + + if (remainingPercentage === undefined && total > 0 && remaining !== undefined) { + remainingPercentage = clampPercentage((remaining / total) * 100); + } + + if (total <= 0 && remainingPercentage !== undefined) { + total = 100; + used = 100 - remainingPercentage; + remaining = remainingPercentage; + } + + return { + used: Math.max(0, used ?? 0), + total, + remaining, + remainingPercentage, + resetAt, + unlimited, + }; +} + +export function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): string { + const rawPlan = getFieldValue(data, "copilot_plan", "copilotPlan"); + const rawSku = getFieldValue(data, "access_type_sku", "accessTypeSku"); + const planText = typeof rawPlan === "string" ? rawPlan.trim() : ""; + const skuText = typeof rawSku === "string" ? rawSku.trim() : ""; + const combined = `${skuText} ${planText}`.trim().toUpperCase(); + const monthlyQuotas = toRecord(getFieldValue(data, "monthly_quotas", "monthlyQuotas")); + const premiumTotal = + premiumQuota?.total || + toNumber(getFieldValue(monthlyQuotas, "premium_interactions", "premiumInteractions"), 0); + const chatTotal = toNumber(getFieldValue(monthlyQuotas, "chat", "chat"), 0); + + if (combined.includes("PRO+") || combined.includes("PRO_PLUS") || combined.includes("PROPLUS")) { + return "Copilot Pro+"; + } + if (combined.includes("ENTERPRISE")) return "Copilot Enterprise"; + if (combined.includes("BUSINESS")) return "Copilot Business"; + if (combined.includes("STUDENT")) return "Copilot Student"; + if (combined.includes("FREE")) return "Copilot Free"; + if (combined.includes("PRO")) return "Copilot Pro"; + + if (premiumTotal >= 1400) return "Copilot Pro+"; + if (premiumTotal >= 900) return "Copilot Enterprise"; + if (premiumTotal >= 250) { + if (combined.includes("INDIVIDUAL")) return "Copilot Pro"; + return "Copilot Business"; + } + if (premiumTotal > 0 || chatTotal === 50) return "Copilot Free"; + + if (skuText) { + const label = toDisplayLabel(skuText); + return label ? `Copilot ${label}` : "GitHub Copilot"; + } + if (planText) { + const label = toDisplayLabel(planText); + return label ? `Copilot ${label}` : "GitHub Copilot"; + } + return "GitHub Copilot"; +} diff --git a/open-sse/services/usage/kiro.ts b/open-sse/services/usage/kiro.ts index 6ed5f72ed6..91743e926e 100644 --- a/open-sse/services/usage/kiro.ts +++ b/open-sse/services/usage/kiro.ts @@ -287,6 +287,10 @@ async function runKiroUsageAttempts( return { sawAuthError, errors, lastHttpFailure }; } +function supportsProfilelessKiroUsage(authMethod?: string): boolean { + return authMethod === "builder-id"; +} + /** * Kiro (AWS CodeWhisperer) Usage */ @@ -297,6 +301,7 @@ export async function getKiroUsage(accessToken?: string, providerSpecificData?: ? providerSpecificData.authMethod : undefined; const isApiKey = authMethod === "api_key"; + const supportsProfilelessUsage = supportsProfilelessKiroUsage(authMethod); let profileArn = typeof providerSpecificData?.profileArn === "string" ? providerSpecificData.profileArn @@ -313,11 +318,14 @@ export async function getKiroUsage(accessToken?: string, providerSpecificData?: // 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) { + // Builder ID sessions can call GetUsageLimits without a profile ARN. Their + // ListAvailableProfiles request may be denied even while profile-less quota requests work, so + // skip discovery only when the stored identity proves this is Builder ID. + if (!profileArn && accessToken && !supportsProfilelessUsage) { profileArn = await discoverKiroProfileArnAcrossRegions(accessToken, storedRegion); } - if (!profileArn && !isApiKey) { + if (!profileArn && !isApiKey && !supportsProfilelessUsage) { return { message: "Kiro connected. Profile ARN not available for quota tracking." }; } @@ -384,9 +392,7 @@ export async function getKiroUsage(accessToken?: string, providerSpecificData?: // HTTP-status failure (most informative) over a network-level error. throw new Error( outcome.lastHttpFailure || - (errors.length > 0 - ? errors[errors.length - 1] - : "no usage endpoint responded") + (errors.length > 0 ? errors[errors.length - 1] : "no usage endpoint responded") ); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/open-sse/services/usage/nanogpt.ts b/open-sse/services/usage/nanogpt.ts new file mode 100644 index 0000000000..ae6361eb4e --- /dev/null +++ b/open-sse/services/usage/nanogpt.ts @@ -0,0 +1,96 @@ +/** + * usage/nanogpt.ts — NanoGPT usage fetcher. + * + * Extracted from services/usage.ts (god-file decomposition): the NanoGPT family — + * the subscription usage-API config and the getNanoGptUsage fetcher that reads + * daily/weekly token + daily image limits for PRO accounts. Depends only on the + * sibling scalar/quota leaves — no host coupling — so it lives as a co-located + * provider leaf. usage.ts imports getNanoGptUsage (dispatcher). Behavior-preserving move. + */ + +import { toRecord, toNumber, clampPercentage } from "./scalars.ts"; +import { type UsageQuota, parseResetTime } from "./quota.ts"; + +const NANOGPT_CONFIG = { + usageUrl: "https://nano-gpt.com/api/subscription/v1/usage", +}; + +/** + * NanoGPT Usage + * Fetches subscription-level quota from the NanoGPT API. + * Returns daily/weekly token limits and daily image limits for PRO accounts. + */ +export async function getNanoGptUsage(apiKey: string) { + if (!apiKey) { + return { message: "NanoGPT API key not available. Add a key to view usage." }; + } + + try { + const res = await fetch(NANOGPT_CONFIG.usageUrl, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (!res.ok) { + if (res.status === 401) return { message: "Invalid NanoGPT API key." }; + return { message: `NanoGPT quota API error (${res.status})` }; + } + + const data = toRecord(await res.json()); + const quotas: Record = {}; + + // active -> PRO, otherwise FREE + const plan = data.active ? "PRO" : "FREE"; + + if (data.active) { + // 1. Tokens limit + // dailyInputTokens if exists, else weeklyInputTokens + let tokenQuota = toRecord(data.dailyInputTokens); + let tokenLabel = "Daily Tokens"; + if (!tokenQuota.resetAt) { + const weeklyQuota = toRecord(data.weeklyInputTokens); + if (weeklyQuota.remaining !== undefined) { + tokenQuota = weeklyQuota; + tokenLabel = "Weekly Tokens"; + } + } + + if (tokenQuota.remaining !== undefined) { + const used = toNumber(tokenQuota.used, 0); + const remaining = toNumber(tokenQuota.remaining, 0); + const total = used + remaining; + quotas[tokenLabel] = { + used, + total, + remaining, + remainingPercentage: clampPercentage(100 - toNumber(tokenQuota.percentUsed, 0) * 100), + resetAt: parseResetTime(tokenQuota.resetAt), + unlimited: false, + }; + } + + // 2. Images limit + const imageQuota = toRecord(data.dailyImages); + if (imageQuota.remaining !== undefined) { + const used = toNumber(imageQuota.used, 0); + const remaining = toNumber(imageQuota.remaining, 0); + const total = used + remaining; + quotas["Daily Images"] = { + used, + total, + remaining, + remainingPercentage: clampPercentage(100 - toNumber(imageQuota.percentUsed, 0) * 100), + resetAt: parseResetTime(imageQuota.resetAt), + unlimited: false, + }; + } + + if (Object.keys(quotas).length === 0) { + return { plan, message: "NanoGPT connected, but no active limits found." }; + } + } + + return { plan, quotas }; + } catch (error) { + return { message: `NanoGPT connected. Unable to fetch usage: ${(error as Error).message}` }; + } +} diff --git a/open-sse/services/usage/opencode.ts b/open-sse/services/usage/opencode.ts new file mode 100644 index 0000000000..689a7c95ca --- /dev/null +++ b/open-sse/services/usage/opencode.ts @@ -0,0 +1,86 @@ +/** + * usage/opencode.ts — OpenCode / OpenCode Zen usage fetcher. + * + * Extracted from services/usage.ts (god-file decomposition): the OpenCode family — + * delegates to the dedicated opencodeQuotaFetcher and shapes the triple-window + * ($12/5h, $30/wk, $60/mo) result into the standard `{ plan, quotas }` usage + * response expected by the limits page. Depends only on the sibling scalar/quota + * leaves + fetchOpencodeQuota + sanitizeErrorMessage — no host coupling — so it + * lives as a co-located provider leaf. usage.ts imports getOpencodeUsage + * (dispatcher + __testing). Behavior-preserving move. + */ + +import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "../opencodeQuotaFetcher.ts"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { type UsageQuota } from "./quota.ts"; + +/** + * OpenCode Go / OpenCode / OpenCode Zen Usage + * Delegates to the dedicated opencodeQuotaFetcher and shapes the result into + * the standard `{ plan, quotas }` usage response expected by the limits page. + * + * Three rolling windows are surfaced: $12/5h, $30/wk, $60/mo. + */ +export async function getOpencodeUsage(connectionId: string, apiKey: string) { + if (!apiKey) { + return { message: "OpenCode API key not available. Add a key to view usage." }; + } + + try { + const quota = (await fetchOpencodeQuota(connectionId, { + apiKey, + })) as OpencodeTripleWindowQuota | null; + + if (!quota) { + return { message: "OpenCode connected. Unable to fetch quota data." }; + } + + const { window5h, windowWeekly, windowMonthly, limitReached } = quota; + + const quotas: Record = {}; + + // $12 / 5-hour rolling window + quotas["window_5h"] = { + used: window5h.percentUsed * 12, + total: 12, + remaining: (1 - window5h.percentUsed) * 12, + remainingPercentage: (1 - window5h.percentUsed) * 100, + resetAt: window5h.resetAt, + unlimited: false, + displayName: "$12 / 5-hour", + currency: "USD", + }; + + // $30 / weekly window + quotas["window_weekly"] = { + used: windowWeekly.percentUsed * 30, + total: 30, + remaining: (1 - windowWeekly.percentUsed) * 30, + remainingPercentage: (1 - windowWeekly.percentUsed) * 100, + resetAt: windowWeekly.resetAt, + unlimited: false, + displayName: "$30 / week", + currency: "USD", + }; + + // $60 / monthly window + quotas["window_monthly"] = { + used: windowMonthly.percentUsed * 60, + total: 60, + remaining: (1 - windowMonthly.percentUsed) * 60, + remainingPercentage: (1 - windowMonthly.percentUsed) * 100, + resetAt: windowMonthly.resetAt, + unlimited: false, + displayName: "$60 / month", + currency: "USD", + }; + + return { + plan: "OpenCode Go", + quotas, + limitReached, + }; + } catch (error) { + return { message: `OpenCode error: ${sanitizeErrorMessage(error)}` }; + } +} diff --git a/open-sse/services/usage/qoder.ts b/open-sse/services/usage/qoder.ts new file mode 100644 index 0000000000..957ab69dde --- /dev/null +++ b/open-sse/services/usage/qoder.ts @@ -0,0 +1,145 @@ +/** + * usage/qoder.ts — Qoder usage fetcher + status parser. + * + * Extracted from services/usage.ts (god-file decomposition): the Qoder family — + * the /api/v3/user/status endpoint config, the plan-label prettifier, the pure + * status→quotas mapper (parseQoderUserStatusUsage), and the getQoderUsage + * fetcher that exchanges the PAT for a short-lived job token then reads the + * status endpoint. Depends only on the sibling scalar/quota leaves + + * resolveQoderJobToken + sanitizeErrorMessage — no host coupling — so it lives + * as a co-located provider leaf. usage.ts imports getQoderUsage (dispatcher) + + * re-exports parseQoderUserStatusUsage (named export + __testing, used by the + * qoder-usage-quota suite). Behavior-preserving move. + */ + +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { resolveQoderJobToken } from "../qoderCli.ts"; +import { toRecord, toNumber, toTitleCase } from "./scalars.ts"; +import { type UsageQuota, parseResetTime } from "./quota.ts"; + +type JsonRecord = Record; + +const QODER_USER_STATUS_URL = "https://openapi.qoder.sh/api/v3/user/status"; + +/** Human-readable plan label from Qoder's `PLAN_TIER_*` enum / `userTag`. */ +function prettifyQoderPlan(planRaw: string, userTag: string): string { + const tag = String(userTag || "").trim(); + if (tag) return tag; + const stripped = String(planRaw || "") + .trim() + .replace(/^PLAN_TIER_/i, ""); + return stripped ? toTitleCase(stripped) : "Qoder"; +} + +/** + * Map a Qoder `/user/status` payload into the shared `{ plan, quotas }` shape. + * Pure (no I/O) so it can be unit-tested against captured payloads. + */ +export function parseQoderUserStatusUsage(status: JsonRecord): { + plan: string; + quotas: Record; +} { + const userType = String(status.userType || "") + .trim() + .toLowerCase(); + const planLabel = prettifyQoderPlan(String(status.plan || ""), String(status.userTag || "")); + const isExceeded = status.isQuotaExceeded === true; + const quotaNum = toNumber(status.quota, 0); + const resetAt = parseResetTime(status.nextResetAt); + // Team/enterprise seats draw from a pooled org quota rather than a per-user + // counter, so `quota: 0` there means "pooled", not "exhausted". + const isPooled = userType === "teams" || userType === "enterprise"; + + const quotas: Record = {}; + if (isExceeded) { + // Genuinely out of quota — remainingPercentage 0 lets routing skip it until reset. + quotas["Quota"] = { + used: quotaNum, + total: quotaNum, + remaining: 0, + remainingPercentage: 0, + resetAt, + unlimited: false, + displayName: "Quota exceeded", + }; + } else if (isPooled || quotaNum <= 0) { + // Pooled/unlimited seat — MUST report 100% remaining. The quota→routing + // conversion (src/domain/quotaCache.ts) ignores `unlimited` and would treat a + // `total: 0` window as 0% (i.e. exhausted), wrongly 429-ing every request. + quotas["Plan"] = { + used: 0, + total: 0, + remaining: 0, + remainingPercentage: 100, + resetAt, + unlimited: true, + displayName: `${planLabel} plan · pooled quota`, + }; + } else { + quotas["Requests"] = { + used: 0, + total: quotaNum, + remaining: quotaNum, + remainingPercentage: 100, + resetAt, + unlimited: false, + displayName: `${quotaNum} requests left`, + }; + } + + return { plan: planLabel, quotas }; +} + +/** + * Qoder Usage + * + * Qoder exposes account plan + quota at `openapi.qoder.sh/api/v3/user/status`, + * the same endpoint the official qodercli reads for its usage badge. The status + * call needs a short-lived `jt-*` job token, so we exchange the PAT the same way + * the chat/validation paths do (see qoderCli.ts::resolveQoderJobToken). + */ +export async function getQoderUsage(apiKey?: string, providerSpecificData?: JsonRecord) { + const token = (apiKey || "").trim() || String(providerSpecificData?.qoderPat || "").trim(); + if (!token) { + return { message: "Qoder connected. Add a Personal Access Token to view quota." }; + } + + let jobToken: string; + try { + jobToken = await resolveQoderJobToken(token); + } catch { + return { message: "Qoder connected. Unable to resolve a usage token." }; + } + + let response: Response; + try { + response = await fetch(QODER_USER_STATUS_URL, { + method: "GET", + headers: { Authorization: `Bearer ${jobToken}`, Accept: "application/json" }, + // @ts-ignore — AbortSignal.timeout is available on the Node runtime + signal: AbortSignal.timeout(15000), + }); + } catch (error) { + return { + message: `Qoder connected. Unable to fetch usage: ${sanitizeErrorMessage((error as Error).message)}`, + }; + } + + if (response.status === 401 || response.status === 403) { + return { + message: "Qoder connected. The token was rejected by the usage API — re-test the connection.", + }; + } + if (!response.ok) { + return { message: `Qoder connected. Usage API returned HTTP ${response.status}.` }; + } + + let status: JsonRecord; + try { + status = toRecord(await response.json()); + } catch { + return { message: "Qoder connected. Unable to parse the usage response." }; + } + + return parseQoderUserStatusUsage(status); +} diff --git a/open-sse/services/usage/vertex.ts b/open-sse/services/usage/vertex.ts new file mode 100644 index 0000000000..b9d5896d4e --- /dev/null +++ b/open-sse/services/usage/vertex.ts @@ -0,0 +1,61 @@ +/** + * usage/vertex.ts — Vertex AI self-tracked spend usage fetcher. + * + * Extracted from services/usage.ts (god-file decomposition): the Vertex family — + * Vertex AI exposes no usage/quota API for an API key or Service Account, so + * OmniRoute self-tracks the USD it spent through the connection (summed from + * usage_history via getConnectionSpendUsdSinceAdded) and surfaces a `spend` + * quota entry plus a `$X used · N requests` message. Depends only on the + * sibling scalar/quota leaves + the usageStats dynamic import — no host + * coupling — so it lives as a co-located provider leaf. usage.ts imports + * getVertexUsage (dispatcher + __testing). Behavior-preserving move. + */ + +type JsonRecord = Record; + +/** + * Vertex AI — SELF-TRACKED spend. + * + * Vertex AI exposes no usage/quota API for an API key or Service Account (billing/credit balance + * lives behind the Cloud Billing API, which the proxy credential can't reach). Instead we report + * the USD that OmniRoute has spent through this connection since the account was added — summed + * from `usage_history` and priced via the backend pricing table. Returns a `message` (with the $ + * figure) plus a `spend` quota entry so the limits cache persists it (a message-only result is + * treated as a transient error and not cached). + */ +export async function getVertexUsage(connectionId: string, provider: string) { + if (!connectionId) { + return { message: "Vertex connected. Connection id unavailable for usage tracking." }; + } + try { + const { getConnectionSpendUsdSinceAdded } = await import("@/lib/usage/usageStats"); + const { costUsd, requests } = await getConnectionSpendUsdSinceAdded(provider, connectionId); + + const spend: JsonRecord = { + used: Number(costUsd.toFixed(6)), + displayName: "Spend (USD)", + quotaSource: "localUsageHistory", + resetAt: null, + unlimited: false, + }; + + if (requests === 0) { + return { + plan: "Vertex AI", + message: "Vertex connected. No usage recorded through OmniRoute yet for this account.", + quotas: { spend }, + }; + } + + const costStr = costUsd >= 1 ? costUsd.toFixed(2) : costUsd.toFixed(4); + return { + plan: "Vertex AI", + message: `$${costStr} used since this account was added \u00b7 ${requests} request${ + requests === 1 ? "" : "s" + }`, + quotas: { spend }, + }; + } catch (error) { + return { message: `Vertex usage tracking error: ${(error as Error).message}` }; + } +} diff --git a/open-sse/services/usage/xai.ts b/open-sse/services/usage/xai.ts new file mode 100644 index 0000000000..05edfe7630 --- /dev/null +++ b/open-sse/services/usage/xai.ts @@ -0,0 +1,53 @@ +/** + * usage/xai.ts — xAI (Grok) self-tracked cumulative usage fetcher. + * + * Extracted from services/usage.ts (god-file decomposition): the xAI family — + * xAI has no public per-account quota API (the billing console at console.x.ai + * requires a session cookie, not an API key), so — exactly like the Xiaomi MiMo + * self-track pattern — OmniRoute sums the tokens it itself routed to this + * connection (from `usage_history`) instead of calling an upstream endpoint. + * Unlike Xiaomi MiMo, xAI has no fixed monthly cap, so the aggregate is reported + * as `unlimited: true` with `remaining: 100`. Depends only on the sibling + * scalar/quota leaves + the usageStats dynamic import — no host coupling — so it + * lives as a co-located provider leaf. usage.ts imports getXaiUsage (dispatcher + * + __testing). Behavior-preserving move. + */ + +import { type UsageQuota } from "./quota.ts"; + +/** + * xAI (Grok) — SELF-TRACKED cumulative usage. + * + * xAI has no public per-account quota API (the billing console at console.x.ai + * requires a session cookie, not an API key), so — exactly like the Xiaomi + * MiMo self-track pattern above — OmniRoute sums the tokens it itself routed + * to this connection (from `usage_history`) instead of calling an upstream + * endpoint. Unlike Xiaomi MiMo, xAI has no fixed monthly cap, so the + * aggregate is reported as `unlimited: true` with `remaining: 100` — this + * renders the dashboard's green "100%" badge instead of a meaningless + * progress bar against a `total: 0`. + */ +export async function getXaiUsage(connectionId: string) { + if (!connectionId) { + return { message: "xAI: connection id unavailable for self-tracked usage." }; + } + try { + const { getMonthlyProviderTokensForConnection } = await import("@/lib/usage/usageStats"); + const used = getMonthlyProviderTokensForConnection("xai", connectionId); + return { + plan: "xAI / Grok (OmniRoute-tracked)", + quotas: { + monthly: { + used, + total: 0, + remaining: 100, + remainingPercentage: 100, + resetAt: null, + unlimited: true, + } as UsageQuota, + }, + }; + } catch (error) { + return { message: `xAI self-tracked usage error: ${(error as Error).message}` }; + } +} diff --git a/open-sse/services/usage/xiaomi-mimo.ts b/open-sse/services/usage/xiaomi-mimo.ts new file mode 100644 index 0000000000..879471a222 --- /dev/null +++ b/open-sse/services/usage/xiaomi-mimo.ts @@ -0,0 +1,51 @@ +/** + * usage/xiaomi-mimo.ts — Xiaomi MiMo self-tracked monthly quota fetcher. + * + * Extracted from services/usage.ts (god-file decomposition): the Xiaomi MiMo family — + * Xiaomi exposes plan usage only behind the console session cookie (the API key + * cannot reach the `tokenPlan/usage` endpoint), so OmniRoute self-tracks the + * tokens it routed to the connection in the current UTC month (from usage_history) + * and compares them to the known Token Plan monthly limit. Depends only on the + * sibling scalar/quota leaves + the usageStats dynamic import — no host coupling + * — so it lives as a co-located provider leaf. usage.ts imports getXiaomiMimoUsage + * (dispatcher + __testing). Behavior-preserving move. + */ + +import { createQuotaFromUsage } from "./quota.ts"; + +// Xiaomi MiMo Token Plan monthly limit (tokens). Keep in sync with the +// "xiaomi-mimo" preset in src/lib/quota/planRegistry.ts. +const XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT = 4_100_000_000; + +/** + * Xiaomi MiMo — SELF-TRACKED monthly quota. + * + * Xiaomi exposes plan usage only behind the console session cookie (the API key + * cannot reach the `tokenPlan/usage` endpoint), so there is no upstream usage + * API to call. Instead we count the tokens OmniRoute itself routed to this + * connection in the current UTC month (from `usage_history`) and compare them + * to the known Token Plan monthly limit. This reflects only traffic that went + * through OmniRoute, not the provider's own dashboard figure. + */ +export async function getXiaomiMimoUsage(connectionId: string) { + if (!connectionId) { + return { message: "Xiaomi MiMo: connection id unavailable for self-tracked quota." }; + } + try { + const { getMonthlyProviderTokensForConnection } = await import("@/lib/usage/usageStats"); + const used = getMonthlyProviderTokensForConnection("xiaomi-mimo", connectionId); + const total = XIAOMI_MIMO_MONTHLY_TOKEN_LIMIT; + const now = new Date(); + const resetAt = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1) + ).toISOString(); + return { + plan: "Xiaomi MiMo Token Plan (OmniRoute-tracked)", + quotas: { + monthly: createQuotaFromUsage(used, total, resetAt), + }, + }; + } catch (error) { + return { message: `Xiaomi MiMo self-tracked usage error: ${(error as Error).message}` }; + } +} diff --git a/open-sse/translator/helpers/openaiHelper.ts b/open-sse/translator/helpers/openaiHelper.ts index 6dd63570a0..2a4a5feafc 100644 --- a/open-sse/translator/helpers/openaiHelper.ts +++ b/open-sse/translator/helpers/openaiHelper.ts @@ -31,7 +31,16 @@ const CLAUDE_TOOL_CHOICE_REQUIRED = "an" + "y"; // Filter messages to OpenAI standard format // Remove: redacted_thinking, and other non-OpenAI blocks // Convert: thinking blocks → reasoning_content on the message -export function filterToOpenAIFormat(body, opts = {}) { +export interface FilterToOpenAIFormatOptions { + /** Keep `cache_control` on content blocks (providers that honor OpenAI-format breakpoints). */ + preserveCacheControl?: boolean; + /** Keep Moonshot's non-standard `video_url` content block. */ + preserveVideoUrl?: boolean; + /** Keep `reasoning_content` on tool-call assistant turns (reasoning-replay providers). */ + preserveReasoningContent?: boolean; +} + +export function filterToOpenAIFormat(body, opts: FilterToOpenAIFormatOptions = {}) { // #2069 — when the routed provider honors OpenAI-format cache_control // breakpoints (DashScope/alibaba, Xiaomi MiMo, etc.) and preservation was // requested upstream, keep the `cache_control` field on each content block diff --git a/open-sse/translator/helpers/toolCallShim.ts b/open-sse/translator/helpers/toolCallShim.ts index 43ca09481d..0c546bb299 100644 --- a/open-sse/translator/helpers/toolCallShim.ts +++ b/open-sse/translator/helpers/toolCallShim.ts @@ -53,8 +53,13 @@ function sanitizeReadArgs(args: Record): void { } if (typeof args.limit === "number") { - if (args.limit > READ_MAX_LIMIT) args.limit = READ_MAX_LIMIT; - if (args.limit < 1) delete args.limit; + // Read into a local: assigning back to `args.limit` (declared `unknown`) resets the + // `typeof` narrowing, so the second comparison would no longer see a number. The two + // branches are mutually exclusive (READ_MAX_LIMIT is 2000), so testing the original + // value keeps the behavior identical. + const limit = args.limit; + if (limit > READ_MAX_LIMIT) args.limit = READ_MAX_LIMIT; + if (limit < 1) delete args.limit; } if (typeof args.offset === "number" && args.offset < 0) args.offset = 0; diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 980eab7818..ab4182b746 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -34,6 +34,44 @@ import { // importers (tests). Host imports it back for registration below. export { openaiToOpenAIResponsesRequest } from "./openai-responses/toResponses.ts"; +/** + * #8459: Convert a tool output content-part array to a safe string for Chat Completions + * tool content. Responses API tool outputs can contain `input_image` parts which have no + * equivalent in Chat Completions `tool` messages — JSON.stringify would embed the raw + * base64 as inert text. Instead, extract text parts and replace images with a placeholder. + * + * @param output - The tool output value (string, array of content parts, or other JSON) + * @returns A plain string safe for Chat Completions `tool` message content. + */ +function toolOutputContentToString(output: unknown): string { + if (typeof output === "string") return output; + if (!Array.isArray(output)) return JSON.stringify(output); + + const parts: string[] = []; + for (const item of output) { + if (typeof item !== "object" || item === null) { + parts.push(String(item)); + continue; + } + const rec = item as Record; + const type = typeof rec.type === "string" ? rec.type : ""; + if (type === "input_text" || type === "output_text") { + const text = typeof rec.text === "string" ? rec.text : ""; + if (text) parts.push(text); + } else if (type === "input_image") { + parts.push("[Image omitted: not supported on Chat Completions tool results]"); + } else { + // Unknown part type — stringify as fallback + try { + parts.push(JSON.stringify(item)); + } catch { + parts.push(String(item)); + } + } + } + return parts.join("\n"); +} + /** * Convert OpenAI Responses API request to OpenAI Chat Completions format */ @@ -293,7 +331,7 @@ export function openaiResponsesToOpenAIRequest( messages.push({ role: "tool", tool_call_id: toString(item.call_id), - content: typeof item.output === "string" ? item.output : JSON.stringify(item.output), + content: toolOutputContentToString(item.output), }); continue; } @@ -342,7 +380,9 @@ export function openaiResponsesToOpenAIRequest( pendingToolResults = []; } // Unwrap JSON-wrapped output {"output":"...","metadata":{...}} → plain string. - const rawOut = typeof item.output === "string" ? item.output : JSON.stringify(item.output); + // #8459: handle content-part arrays that may contain input_image without + // stringifying raw base64 as text. + const rawOut = toolOutputContentToString(item.output); let toolContent = rawOut; try { const parsed = JSON.parse(rawOut); @@ -633,10 +673,13 @@ export function openaiResponsesToOpenAIRequest( ); } - result.tools = chatTools.filter((toolValue) => + // Keep the filtered array in a local: `result` is a Record, so + // reading `result.tools` back gives `unknown` and `.length` does not type-check. + const allowedTools = chatTools.filter((toolValue) => allowedNames.has(toString(toRecord(toRecord(toolValue).function).name)) ); - if (result.tools.length === 0) { + result.tools = allowedTools; + if (allowedTools.length === 0) { throw unsupportedFeature( "Unsupported Responses API feature: allowed_tools resolved to zero Chat Completions function tools" ); diff --git a/open-sse/translator/request/openai-responses/toResponses.ts b/open-sse/translator/request/openai-responses/toResponses.ts index 859f2d60d7..f91b66f918 100644 --- a/open-sse/translator/request/openai-responses/toResponses.ts +++ b/open-sse/translator/request/openai-responses/toResponses.ts @@ -120,6 +120,7 @@ export function openaiToOpenAIResponsesRequest( type: "message", role: "developer", content: buildResponsesTextParts(msg.content), + status: "completed", }); continue; } @@ -185,6 +186,7 @@ export function openaiToOpenAIResponsesRequest( type: "message", role: "user", content, + status: "completed", }); } @@ -223,6 +225,7 @@ export function openaiToOpenAIResponsesRequest( type: "message", role: "assistant", content: outputContent, + status: "completed", }); } @@ -241,6 +244,7 @@ export function openaiToOpenAIResponsesRequest( call_id: clampCallId(toString(toolCall.id).trim() || generateToolCallId()), name: fnName, arguments: toString(fn.arguments, "{}"), + status: "completed", }); } } @@ -255,6 +259,7 @@ export function openaiToOpenAIResponsesRequest( call_id: clampCallId(`call_${fnName}`), name: fnName, arguments: toString(fc.arguments, "{}"), + status: "completed", }); } } @@ -276,6 +281,7 @@ export function openaiToOpenAIResponsesRequest( return c; }) : String(msg.content ?? ""), + status: "completed", }); } @@ -285,6 +291,7 @@ export function openaiToOpenAIResponsesRequest( type: "function_call_output", call_id: clampCallId(`call_${toString(msg.name)}`), output: typeof msg.content === "string" ? msg.content : String(msg.content ?? ""), + status: "completed", }); } } diff --git a/open-sse/translator/request/openai-to-claude/sanitizeToolResultId.ts b/open-sse/translator/request/openai-to-claude/sanitizeToolResultId.ts index 6fefa7773f..f8f5fd32b4 100644 --- a/open-sse/translator/request/openai-to-claude/sanitizeToolResultId.ts +++ b/open-sse/translator/request/openai-to-claude/sanitizeToolResultId.ts @@ -7,5 +7,7 @@ import { sanitizeToolId } from "../../helpers/schemaCoercion.ts"; // that guard and silently fabricate a tool_result that can never match a tool_use. export function sanitizeToolResultId(rawId: unknown): string | null { if (!rawId) return null; - return sanitizeToolId(rawId); + // sanitizeToolId() takes a string; a non-string id would previously reach `.replace()` + // and throw. Coerce instead so a numeric id (some clients send one) sanitizes normally. + return sanitizeToolId(typeof rawId === "string" ? rawId : String(rawId)); } diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 3da0a59340..f6de86b49f 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -11,7 +11,10 @@ import { normalizeKiroToolSchema, serializeToolResultContent, } from "./openai-to-kiro/messageHelpers.ts"; -import { supportsKiroAdaptiveThinking } from "./openai-to-kiro/adaptiveThinking.ts"; +import { + resolveKiroModelAlias, + supportsKiroAdaptiveThinking, +} from "./openai-to-kiro/adaptiveThinking.ts"; /** * Anthropic's direct-provider `[1m]` context-1m beta suffix. Kiro is AWS @@ -580,26 +583,6 @@ function convertMessages(messages, tools, model) { /** Kiro's accepted reasoning-effort levels (`output_config.effort`). */ const KIRO_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; -function resolveKiroModelAlias(model: string): { upstream: string; thinking: boolean } { - let upstream = String(model || ""); - let thinking = false; - - if (upstream.endsWith("-agentic")) { - upstream = upstream.slice(0, -"-agentic".length); - } - if (upstream.endsWith("-thinking")) { - upstream = upstream.slice(0, -"-thinking".length); - thinking = true; - } - if (upstream === "auto-kiro") { - upstream = "auto"; - } - - upstream = upstream.replace(/^(claude-(?:opus|sonnet|haiku|3-\d+)-\d+)-(\d{1,2})$/, "$1.$2"); - - return { upstream, thinking }; -} - /** * Resolve the Kiro effort level for a request, or "" when no reasoning was asked * for. Effort sources, in priority order: @@ -680,15 +663,14 @@ export function buildKiroPayload(model, body, stream, credentials) { if (hasUnsupportedKiroContextSuffix(model)) { throw new Error(KIRO_UNSUPPORTED_CONTEXT_1M_MESSAGE); } - // Normalize model name: Claude Code sends dashes (claude-sonnet-4-6), // Kiro API expects dots (claude-sonnet-4.6). Convert trailing version segment. // The minor group is bounded to 1-2 digits so date-suffixed ids (e.g. // claude-opus-4-20250514) are never mistaken for a dash-separated minor // version and corrupted into claude-opus-4.20250514 (upstream 9router #2270). - // Synthetic Kiro selector variants (`-thinking`, `-agentic`) are local aliases: - // strip them before the request leaves OmniRoute so Kiro only receives real - // upstream model IDs. We intentionally do not inject an agentic system prompt here. + // The supported `-thinking` selector is a local alias: strip it before the request leaves + // OmniRoute so Kiro only receives a real upstream model ID. Non-functional agentic and + // auto-kiro aliases are rejected above instead of silently degrading to another model. const { upstream: normalizedModel, thinking: modelRequestsThinking } = resolveKiroModelAlias(model); const messages = body.messages || []; diff --git a/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts b/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts index ba8fefdc97..338768f8d6 100644 --- a/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts +++ b/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts @@ -17,3 +17,27 @@ const KIRO_ADAPTIVE_THINKING_MODELS = new Set(["claude-sonnet-5"]); export function supportsKiroAdaptiveThinking(normalizedModel: string): boolean { return KIRO_ADAPTIVE_THINKING_MODELS.has(normalizedModel); } + +const KIRO_UNSUPPORTED_AGENTIC_MESSAGE = + "Kiro agentic aliases are not supported. The '-agentic' suffix did not change the " + + "upstream request; select a real Kiro model instead."; +const KIRO_UNSUPPORTED_THINKING_MESSAGE = + "This Kiro model does not support the '-thinking' alias. Use a model returned by Kiro's " + + "live catalog with Thinking capability."; +const KIRO_REMOVED_AUTO_ALIAS_MESSAGE = + "'auto-kiro' is not a real Kiro upstream model. Select a model returned by the live catalog."; + +export function resolveKiroModelAlias(model: unknown): { upstream: string; thinking: boolean } { + let upstream = String(model || ""); + if (upstream.endsWith("-agentic")) throw new Error(KIRO_UNSUPPORTED_AGENTIC_MESSAGE); + if (upstream === "auto-kiro") throw new Error(KIRO_REMOVED_AUTO_ALIAS_MESSAGE); + + const thinking = upstream.endsWith("-thinking"); + if (thinking) upstream = upstream.slice(0, -"-thinking".length); + upstream = upstream.replace(/^(claude-(?:opus|sonnet|haiku|3-\d+)-\d+)-(\d{1,2})$/, "$1.$2"); + + if (thinking && !supportsKiroAdaptiveThinking(upstream)) { + throw new Error(KIRO_UNSUPPORTED_THINKING_MESSAGE); + } + return { upstream, thinking }; +} diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index f649853552..22fb7d73c1 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -636,6 +636,17 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) { // superseded-call eviction where a new call replaced this one at the same index). if (recordAsCompleted) { recordCompletedItem(state, normalizedIndex, funcItem); + // Mirror into the shared state.toolCalls map (populated by the other response + // translators) so stream.ts's completion-log summary reports finish_reason + // "tool_calls" and message.tool_calls instead of "stop" with no tool calls. + if (state.toolCalls instanceof Map) { + state.toolCalls.set(idx, { + id: callId, + index: normalizedIndex, + type: isCustomTool ? "custom_tool_call" : "function", + function: { name: funcItem.name, arguments: args }, + }); + } } state.funcItemDone[idx] = true; diff --git a/open-sse/tsconfig.json b/open-sse/tsconfig.json index f64585be7e..808d84af25 100644 --- a/open-sse/tsconfig.json +++ b/open-sse/tsconfig.json @@ -13,12 +13,10 @@ "strict": false, "jsx": "react-jsx", "lib": ["dom", "esnext"], - "ignoreDeprecations": "5.0", - "baseUrl": "..", "paths": { - "@/*": ["./src/*"], - "@omniroute/open-sse": ["./open-sse"], - "@omniroute/open-sse/*": ["./open-sse/*"] + "@/*": ["../src/*"], + "@omniroute/open-sse": ["../open-sse"], + "@omniroute/open-sse/*": ["../open-sse/*"] } }, "include": ["**/*.ts", "**/*.js"] diff --git a/open-sse/types.d.ts b/open-sse/types.d.ts index 89cb7fef48..6d95d1e072 100644 --- a/open-sse/types.d.ts +++ b/open-sse/types.d.ts @@ -137,3 +137,24 @@ export interface UsageData { completion_tokens: number; total_tokens: number; } + +// ============ Lib gap: Transformer.cancel ============ + +declare global { + /** + * The WHATWG Streams standard defines `transformer.cancel(reason)`, invoked when + * the readable side is cancelled (for us: an SSE client disconnecting). Node + * implements it — verified on v24 — but `lib.dom.d.ts` still omits it from + * `Transformer`, so every `new TransformStream({ ..., cancel() {} })` in the + * codebase fails with TS2353 ("'cancel' does not exist in type 'Transformer'"). + * + * These `cancel` handlers are load-bearing: they clear heartbeat/progress + * intervals and idle timers on disconnect. Deleting them to satisfy the checker + * would leak a timer per abandoned stream, so the type is patched instead. + * + * Remove once the bundled lib declares it. + */ + interface Transformer { + cancel?: (reason?: unknown) => void | PromiseLike; + } +} diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index b78a497444..7cda7f4d91 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -718,7 +718,7 @@ export function decodeKvServerEvent(payload: Buffer): KvServerEvent | null { if (getBlobArgs) { // GetBlobArgs { blob_id (1): bytes } - let blobId = Buffer.alloc(0); + let blobId: Buffer = Buffer.alloc(0); for (const f of decodeFields(getBlobArgs)) { if (f.fieldNumber === GBA_BLOB_ID && f.wireType === 2) { blobId = f.bytes; @@ -728,8 +728,8 @@ export function decodeKvServerEvent(payload: Buffer): KvServerEvent | null { } if (setBlobArgs) { // SetBlobArgs { blob_id (1): bytes, blob_data (2): bytes } - let blobId = Buffer.alloc(0); - let blobData = Buffer.alloc(0); + let blobId: Buffer = Buffer.alloc(0); + let blobData: Buffer = Buffer.alloc(0); for (const f of decodeFields(setBlobArgs)) { if (f.fieldNumber === SBA_BLOB_ID && f.wireType === 2) { blobId = f.bytes; diff --git a/open-sse/utils/earlyStreamKeepalive.ts b/open-sse/utils/earlyStreamKeepalive.ts index 1ee00354ef..26bf0d001f 100644 --- a/open-sse/utils/earlyStreamKeepalive.ts +++ b/open-sse/utils/earlyStreamKeepalive.ts @@ -187,7 +187,15 @@ export type EarlyStreamKeepaliveOptions = { errorFrame?: Uint8Array; }; -type SettledHandler = { ok: true; response: Response } | { ok: false; error: unknown }; +/** + * Tagged with a string rather than an `ok: true | false` boolean: this workspace compiles + * with `strictNullChecks: false`, where a boolean-literal discriminant narrows the positive + * branch but not the negative one — so reading `.error` off the rejected arm did not + * type-check. A string discriminant narrows both branches under the same settings. + */ +type SettledHandler = + | { status: "fulfilled"; response: Response } + | { status: "rejected"; error: unknown }; export async function withEarlyStreamKeepalive( handlerPromise: Promise, @@ -209,8 +217,8 @@ export async function withEarlyStreamKeepalive( // Settle into a tagged result so neither race branch leaves an unhandled // rejection when the threshold timer wins. const settled: Promise = handlerPromise.then( - (response) => ({ ok: true as const, response }), - (error) => ({ ok: false as const, error }) + (response) => ({ status: "fulfilled" as const, response }), + (error) => ({ status: "rejected" as const, error }) ); let timer: ReturnType | undefined; @@ -224,8 +232,9 @@ export async function withEarlyStreamKeepalive( if (raced.kind === "settled") { // Fast path — return verbatim, or rethrow so the route's normal error handling runs. - if (raced.result.ok) return raced.result.response; - throw raced.result.error; + const result = raced.result; + if (result.status === "fulfilled") return result.response; + throw result.error; } // Slow path — open the SSE stream now and keep it warm until the handler resolves. @@ -287,13 +296,13 @@ export async function withEarlyStreamKeepalive( if (aborted) { // The synthetic keepalive response can be cancelled before the handler resolves. // Cancel the eventual real response so its upstream work and lifecycle hooks finish. - if (result.ok && result.response.body) { + if (result.status === "fulfilled" && result.response.body) { await result.response.body.cancel().catch(() => undefined); } return; } - if (!result.ok) { + if (result.status === "rejected") { // Handler rejected — emit a generic error frame (never the raw error/stack). controller.enqueue(errorFrame); } else { diff --git a/open-sse/utils/jsonSize.ts b/open-sse/utils/jsonSize.ts new file mode 100644 index 0000000000..ea714c7075 --- /dev/null +++ b/open-sse/utils/jsonSize.ts @@ -0,0 +1,127 @@ +/** + * Serialized JSON length without materializing the JSON (#7847). + * + * Several hot-path call sites only need `JSON.stringify(body).length` — a readiness-timeout + * threshold, a payload-size metric, a token estimate. On a 3.05 MiB agent request each of those + * allocates a full 3 MiB string that is read once for its length and thrown away, and #7847 + * reports that class of transient allocation driving V8/cgroup OOM under concurrent long-context + * traffic. + * + * `jsonLength()` walks the value and counts instead. Same O(n) scan, no allocation. + * + * It is EXACT, not an approximation: every consumer feeds a threshold, and an approximation + * would silently shift routing and timeout decisions. `tests/unit/json-size-exactness.test.ts` + * property-tests `jsonLength(x) === JSON.stringify(x).length` over generated structures. + * + * Anything outside the plain-JSON subset (Date, toJSON, class instances, Map, ...) falls back to + * `JSON.stringify` for THAT SUBTREE only, so an exotic leaf never forces the multi-megabyte + * message history back onto the allocating path. + */ + +/** Length of a JSON-encoded string, including the surrounding quotes. */ +function encodedStringLength(value: string): number { + let len = 2; // the quotes + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code === 0x22 || code === 0x5c) { + len += 2; // \" and \\ + } else if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) { + len += 2; // \b \t \n \f \r + } else if (code < 0x20) { + len += 6; // \u00XX + } else if (code >= 0xd800 && code <= 0xdfff) { + // Surrogates: a well-formed pair serializes as its two code units (2 chars); a LONE + // surrogate is escaped as \uXXXX since ES2019 well-formed JSON.stringify. + const isHigh = code <= 0xdbff; + const next = isHigh ? value.charCodeAt(i + 1) : NaN; + const paired = isHigh && next >= 0xdc00 && next <= 0xdfff; + if (paired) { + len += 2; + i++; // consume the low surrogate + } else { + len += 6; + } + } else { + len += 1; + } + } + return len; +} + +/** True for values JSON.stringify drops (object values) or renders as null (array items). */ +function isOmitted(value: unknown): boolean { + return value === undefined || typeof value === "function" || typeof value === "symbol"; +} + +function isPlainContainer(value: object): boolean { + if (Array.isArray(value)) return true; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Exact `JSON.stringify(value).length`, computed without building the string. + * Returns 0 for values JSON.stringify renders as `undefined` (functions, symbols, undefined), + * matching the `try { JSON.stringify(x).length } catch { 0 }` shape of the call sites replaced. + * Throws on circular structures and BigInt, exactly as JSON.stringify does. + */ +export function jsonLength(value: unknown): number { + return lengthOf(value, new Set()); +} + +function lengthOf(value: unknown, seen: Set): number { + if (value === null) return 4; // "null" + const type = typeof value; + + if (type === "string") return encodedStringLength(value as string); + if (type === "boolean") return value ? 4 : 5; + if (type === "number") { + // Non-finite numbers serialize as null. + return Number.isFinite(value as number) ? String(value).length : 4; + } + if (type === "bigint") { + // Match JSON.stringify, which throws rather than guessing an encoding. + throw new TypeError("Do not know how to serialize a BigInt"); + } + if (isOmitted(value)) return 0; + if (type !== "object") return 0; + + const obj = value as object; + + // Delegate anything that is not a plain object/array — Date, class instances with toJSON, + // Map, boxed primitives. Scoped to this subtree so the big arrays stay on the fast path. + if (!isPlainContainer(obj) || typeof (obj as { toJSON?: unknown }).toJSON === "function") { + const encoded = JSON.stringify(obj); + return encoded === undefined ? 0 : encoded.length; + } + + if (seen.has(obj)) { + throw new TypeError("Converting circular structure to JSON"); + } + seen.add(obj); + try { + if (Array.isArray(obj)) { + let len = 2; // [] + for (let i = 0; i < obj.length; i++) { + if (i > 0) len += 1; // comma + const item = obj[i]; + // Omitted values render as null inside arrays rather than disappearing. + len += isOmitted(item) ? 4 : lengthOf(item, seen); + } + return len; + } + + let len = 2; // {} + let first = true; + for (const key of Object.keys(obj)) { + const item = (obj as Record)[key]; + if (isOmitted(item)) continue; // the whole entry disappears + if (!first) len += 1; // comma + first = false; + len += encodedStringLength(key) + 1 + lengthOf(item, seen); // "key":value + } + return len; + } finally { + seen.delete(obj); + } +} diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index bddfa207bf..8eea0d2774 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -22,6 +22,45 @@ function isTlsFingerprintEnabled() { return process.env.ENABLE_TLS_FINGERPRINT === "true"; } +// #8376: transport-level connect-failure codes that mean "the configured upstream +// proxy (or the target itself, for direct egress) is unreachable" — as opposed to an +// ordinary upstream HTTP error. Read `.code` first (stable across undici/node +// versions); native fetch wraps the real socket error in `.cause`, so fall back to +// `.cause.code` when the top-level error is a bare "fetch failed" TypeError. +const PROXY_UNREACHABLE_ERROR_CODES = new Set([ + "ECONNREFUSED", + "ECONNRESET", + "ETIMEDOUT", + "ENETUNREACH", + "EHOSTUNREACH", + "EPIPE", + "UND_ERR_CONNECT_TIMEOUT", +]); + +function isProxyUnreachableError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const code = (err as { code?: unknown }).code; + if (typeof code === "string" && PROXY_UNREACHABLE_ERROR_CODES.has(code)) return true; + const causeCode = (err as { cause?: { code?: unknown } }).cause?.code; + return typeof causeCode === "string" && PROXY_UNREACHABLE_ERROR_CODES.has(causeCode); +} + +/** + * #8376: tag a connect-failure error with a stable `.code`/`.errorCode` BEFORE it is + * rethrown, so chatCore's catch block (and, through the response body, the combo + * provider-breaker predicate) can classify it as "proxy unreachable" instead of + * falling through to a generic 502 that never trips the whole-provider breaker on a + * homogeneous same-provider combo pool. No-op when the error isn't connect-shaped. + */ +function tagProxyUnreachable(err: T): T { + if (isProxyUnreachableError(err)) { + const e = err as Error & { code?: string; errorCode?: string }; + e.code = e.code || "PROXY_UNREACHABLE"; + e.errorCode = "proxy_unreachable"; + } + return err; +} + /** Per-request tracking of whether TLS fingerprint was used */ type TlsFingerprintStore = { used: boolean }; const tlsFingerprintContext = new AsyncLocalStorage(); @@ -530,7 +569,7 @@ async function patchedFetch( if (dispatcherError instanceof Error) { (dispatcherError as Error & { proxyFetchDetail?: string }).proxyFetchDetail = detail; } - throw dispatcherError; + throw tagProxyUnreachable(dispatcherError); } // All attempts exhausted — try proxy fallback before native fetch @@ -573,7 +612,7 @@ async function patchedFetch( if (nativeError instanceof Error) { (nativeError as Error & { proxyFetchDetail?: string }).proxyFetchDetail = detail; } - throw nativeError; + throw tagProxyUnreachable(nativeError); } } throw dispatcherError; diff --git a/open-sse/utils/requestLogger.ts b/open-sse/utils/requestLogger.ts index 4c8dc3f660..f2ef74e84e 100644 --- a/open-sse/utils/requestLogger.ts +++ b/open-sse/utils/requestLogger.ts @@ -102,9 +102,26 @@ function createEmptyStreamChunks() { }; } +const TRUNCATED_ARRAY_MARKER = "_omniroute_truncated_array"; +const TRUNCATED_KEYS_MARKER = "_omniroute_truncated_keys"; + +function isTruncatedArrayMarker(value: unknown): boolean { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + (value as JsonRecord)[TRUNCATED_ARRAY_MARKER] === true + ); +} + function truncateLogString(value: string, maxLength = MAX_LOG_STRING_LENGTH): string { if (value.length <= maxLength) return value; - return `${value.slice(0, Math.floor(maxLength / 2))}\n[...truncated ${value.length - maxLength} chars...]\n${value.slice(-Math.ceil(maxLength / 2))}`; + // The marker has to fit INSIDE the budget (#7847): keeping maxLength characters and then + // adding the marker produced a result longer than maxLength, so re-bounding an already + // bounded string truncated it a second time and the function was not idempotent. + const marker = `\n[...truncated ${value.length - maxLength} chars...]\n`; + const keep = Math.max(0, maxLength - marker.length); + return `${value.slice(0, Math.floor(keep / 2))}${marker}${value.slice(-Math.ceil(keep / 2))}`; } /** @@ -134,6 +151,13 @@ export function cloneBoundedForLog(value: unknown, depth = 0, key: string | null if (depth >= 6) return "[MaxDepth]"; if (Array.isArray(value)) { + // Idempotence (#7847): an already-bounded array is [marker, ...tail] — MAX_LOG_ARRAY_ITEMS + 1 + // entries, which is over the limit. Re-truncating it would drop the marker plus one real + // item and rewrite originalLength with the truncated length (25 instead of the true 800), so + // the log would misreport how much was cut. Keep the original marker, re-bound only the tail. + if (isTruncatedArrayMarker(value[0])) { + return [value[0], ...value.slice(1).map((item) => cloneBoundedForLog(item, depth + 1))]; + } const exempt = key === "tools"; const shouldTruncate = !exempt && value.length > MAX_LOG_ARRAY_ITEMS; const source = shouldTruncate ? value.slice(-MAX_LOG_ARRAY_ITEMS) : value; @@ -141,7 +165,7 @@ export function cloneBoundedForLog(value: unknown, depth = 0, key: string | null if (shouldTruncate) { return [ { - _omniroute_truncated_array: true, + [TRUNCATED_ARRAY_MARKER]: true, originalLength: value.length, retainedTailItems: MAX_LOG_ARRAY_ITEMS, }, @@ -152,12 +176,19 @@ export function cloneBoundedForLog(value: unknown, depth = 0, key: string | null } const result: JsonRecord = {}; - const entries = Object.entries(value as JsonRecord); + // Idempotence (#7847): our own marker key must not be counted as payload, or a re-bounded + // object would push a real key out to make room for it and report `1` dropped instead of 20. + const carriedDropped = (value as JsonRecord)[TRUNCATED_KEYS_MARKER]; + const carried = typeof carriedDropped === "number" ? carriedDropped : 0; + const entries = Object.entries(value as JsonRecord).filter( + ([k]) => !(carried > 0 && k === TRUNCATED_KEYS_MARKER) + ); for (const [k, item] of entries.slice(0, MAX_LOG_OBJECT_KEYS)) { result[k] = cloneBoundedForLog(item, depth + 1, k); } - if (entries.length > MAX_LOG_OBJECT_KEYS) { - result._omniroute_truncated_keys = entries.length - MAX_LOG_OBJECT_KEYS; + const dropped = Math.max(0, entries.length - MAX_LOG_OBJECT_KEYS) + carried; + if (dropped > 0) { + result[TRUNCATED_KEYS_MARKER] = dropped; } return result; } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 60fe7d7fe9..d6f9312c2d 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -485,7 +485,7 @@ function getClaudeEventType(payload: unknown): string | null { return typeof type === "string" ? type : null; } -function isClaudeEventPayload(payload: unknown): payload is JsonRecord { +function isClaudeEventPayload(payload: unknown): boolean { return getClaudeEventType(payload) !== null; } diff --git a/open-sse/utils/streamReadiness.ts b/open-sse/utils/streamReadiness.ts index 0ee108eb6b..7b74f54be3 100644 --- a/open-sse/utils/streamReadiness.ts +++ b/open-sse/utils/streamReadiness.ts @@ -81,11 +81,40 @@ function getPayloadType(payload: unknown, eventType = ""): string { return typeof type === "string" ? type : eventType; } +// Keys that indicate a frame carries (or is starting to carry) actual model +// output — as opposed to a bare `{error:{...}}` frame with no output signal +// at all. A stream that only ever emits error-only frames (e.g. a CLI +// passthrough executor's mid-stream spawn failure, #7503) must NOT be +// classified as "ready" — treating it as ready lets the malformed frame +// reach the client as a fake 200 success and blocks combo fallback to the +// next candidate. +const CONTENT_BEARING_KEYS = [ + "choices", + "candidates", + "content_block", + "delta", + "output", + "response", + "parts", + "tool_calls", + "tool_use", + "function_call", + "function_call_output", +]; + +function isErrorOnlyStructuredPayload(payload: Record): boolean { + if (!("error" in payload)) return false; + return !CONTENT_BEARING_KEYS.some((key) => key in payload); +} + function hasNonPingStructuredPayload(payload: unknown, eventType = ""): boolean { const type = getPayloadType(payload, eventType); if (isPingEventType(eventType) || isPingEventType(type)) return false; if (Array.isArray(payload)) return payload.length > 0; - if (isRecord(payload)) return Object.keys(payload).length > 0; + if (isRecord(payload)) { + if (Object.keys(payload).length === 0) return false; + return !isErrorOnlyStructuredPayload(payload); + } return payload !== null && payload !== undefined; } diff --git a/open-sse/utils/streamReadinessPolicy.ts b/open-sse/utils/streamReadinessPolicy.ts index 9dcf631345..2dcc374809 100644 --- a/open-sse/utils/streamReadinessPolicy.ts +++ b/open-sse/utils/streamReadinessPolicy.ts @@ -1,3 +1,4 @@ +import { jsonLength } from "./jsonSize.ts"; import { getRegistryEntry } from "../config/providerRegistry.ts"; type StreamReadinessBody = Record | null | undefined; @@ -31,7 +32,10 @@ function countArrayField(body: StreamReadinessBody, field: "input" | "messages" function estimateBodyChars(body: StreamReadinessBody): number { if (!body) return 0; try { - return JSON.stringify(body).length; + // #7847: count the serialized length without building the string — this runs on every + // streaming request, and only `.length` was ever used. jsonLength is exact (property-tested + // against JSON.stringify), so the readiness thresholds are unchanged. + return jsonLength(body); } catch { return 0; } diff --git a/package-lock.json b/package-lock.json index a668a06640..9013afaec5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "ink-text-input": "^6.0.0", "ioredis": "^5.10.1", "jose": "^6.2.3", - "js-yaml": "^5.0.0", + "js-yaml": "^5.2.2", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", "lucide-react": "^1.21.0", @@ -103,7 +103,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/better-sqlite3": "^7.6.13", - "@types/bun": "*", + "@types/bun": "latest", "@types/node": "^26.1.0", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", @@ -23683,9 +23683,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", - "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", + "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", "funding": [ { "type": "github", @@ -27778,9 +27778,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -30080,9 +30080,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -30099,7 +30099,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/package.json b/package.json index 80b95385ca..f853597011 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "prebuild:docs": "node scripts/docs/gen-openapi-module.mjs", "gen:provider-reference": "bun scripts/docs/gen-provider-reference.ts", "bench:compression": "bun scripts/compression/benchmark.ts", + "bench:heap-body": "node --expose-gc --import tsx/esm scripts/perf/request-body-heap.ts", "eval:compression": "node --import tsx scripts/compression-eval/index.ts", "eval:router": "node --import tsx scripts/router-eval/index.ts", "eval:router:compare": "node --import tsx scripts/router-eval/compare.ts", @@ -265,7 +266,7 @@ "ink-text-input": "^6.0.0", "ioredis": "^5.10.1", "jose": "^6.2.3", - "js-yaml": "^5.0.0", + "js-yaml": "^5.2.2", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", "lucide-react": "^1.21.0", @@ -394,7 +395,7 @@ "dompurify": "^3.4.12", "fast-xml-parser": "^5.10.1", "sharp": "^0.35.0", - "postcss": "^8.5.14", + "postcss": "^8.5.18", "ip-address": "10.2.0", "qs": "^6.15.2", "uuid": "^14.0.0", @@ -418,6 +419,12 @@ "concurrently": { "shell-quote": "^1.9.0" }, - "adm-zip": "^0.6.0" + "adm-zip": "^0.6.0", + "promptfoo": { + "js-yaml": "^5.2.2", + "@apidevtools/json-schema-ref-parser": { + "js-yaml": "^4.2.0" + } + } } } diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index f90417be78..cc348d69f5 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -48,6 +48,7 @@ export const INTENTIONALLY_INTERNAL = new Set([ "comboForecast", // intentionally-internal: src/lib/usage/comboForecast.ts "commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/* "compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config) + "compressionDetailNormalizers", // db-internal: importado só por db/compression.ts (normalizeSessionDedupConfig/normalizeCcrConfig/buildDetailConfigDefaults/applyDetailConfigUpdate — normalizadores do detail-config split do compression.ts, #8404) "vacuumScheduler", // intentionally-internal: src/instrumentation-node.ts (dynamic import, lifecycle wiring per Rule #2) "detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler) "discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery diff --git a/scripts/check/check-type-coverage.mjs b/scripts/check/check-type-coverage.mjs index 0051063fd3..076fe2d770 100644 --- a/scripts/check/check-type-coverage.mjs +++ b/scripts/check/check-type-coverage.mjs @@ -11,8 +11,12 @@ // - Rationale: the only tsconfig that covers the full open-sse workspace // (src+open-sse together). `tsconfig.json` excludes open-sse; the // `tsconfig.typecheck-core.json` only lists 26 explicit files (partial). -// open-sse/tsconfig.json sets `baseUrl: ".."` and path aliases so it -// resolves both workspaces correctly and yields a representative global %. +// open-sse/tsconfig.json declares path aliases (`@/*`, `@omniroute/open-sse/*`) +// relative to its own directory, so it resolves both workspaces correctly and +// yields a representative global %. It carried a `baseUrl: ".."` until TS 7 +// readiness removed it; that also stopped `electron/*.js` from being pulled +// into the program via root-relative resolution, which moved the measured % +// up (~92.2% -> ~94.0%). // // Direction: up (% can only improve; ratchet blocks drops once wired into INT). // Eps: 0.05 (float noise tolerance — type-coverage may vary by ~0.01% between runs). diff --git a/scripts/i18n/check-glossary-consistency.mjs b/scripts/i18n/check-glossary-consistency.mjs index 05f275d456..8acfc288e0 100644 --- a/scripts/i18n/check-glossary-consistency.mjs +++ b/scripts/i18n/check-glossary-consistency.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * OmniRoute — zh-CN terminology glossary consistency gate. + * OmniRoute — Chinese terminology glossary consistency gate (zh-CN + zh-TW). * * Complements the existing parity (check-ui-keys-coverage.mjs) and ICU * (validate_translation.py) checks with a native-quality layer: @@ -22,6 +22,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import process from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { hasUnblockedOccurrence } from "./glossary-normalize.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(SCRIPT_DIR, "..", ".."); @@ -79,10 +80,11 @@ export function checkGlossaryConsistency(localeMessages, glossary, protectedTerm const terms = glossary && glossary.terms ? glossary.terms : {}; for (const [concept, def] of Object.entries(terms)) { const synonyms = Array.isArray(def.synonyms) ? def.synonyms : []; + const blockedPrefixes = Array.isArray(def.blockedPrefixes) ? def.blockedPrefixes : []; for (const synonym of synonyms) { if (!synonym) continue; for (const leaf of leaves) { - if (leaf.value.includes(synonym)) { + if (hasUnblockedOccurrence(leaf.value, synonym, blockedPrefixes)) { violations.push({ type: "glossary-synonym", concept, @@ -179,7 +181,9 @@ async function main() { logInfo(`FAIL — ${violations.length} violation(s) in ${opts.locale}.`); for (const v of violations.slice(0, 50)) { if (v.type === "glossary-synonym") { - console.log(` - [${v.concept}] ${v.path}: found "${v.found}", canonical is "${v.canonical}"`); + console.log( + ` - [${v.concept}] ${v.path}: found "${v.found}", canonical is "${v.canonical}"` + ); } else { console.log(` - [protected-term] ${v.path}: "${v.term}" rendered as "${v.found}"`); } diff --git a/scripts/i18n/generate-multilang.mjs b/scripts/i18n/generate-multilang.mjs index 39812499e9..9f61aff9ab 100644 --- a/scripts/i18n/generate-multilang.mjs +++ b/scripts/i18n/generate-multilang.mjs @@ -12,6 +12,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; +import { normalizeLocaleText } from "./glossary-normalize.mjs"; console.warn( "[generate-multilang] DEPRECATED: prefer `npm run i18n:run` for docs (this script will be removed in v3.10)." @@ -523,11 +524,22 @@ function protectText(input, options = {}) { return { output, tokens }; } -function restoreText(input, tokens) { +// Post-translation terminology normalization is driven by +// scripts/i18n/glossary/.json through the shared helper, so this +// generator, the active run-translation.mjs pipeline and the drift gate can +// never disagree about what canonical means. +function postProcessLocaleText(text, targetLanguage) { + return normalizeLocaleText(text, targetLanguage); +} + +function restoreText(input, tokens, targetLanguage = null) { let output = input; for (let i = 0; i < tokens.length; i += 1) { output = output.replaceAll(`__OMNI_TOKEN_${i}__`, tokens[i]); } + if (targetLanguage) { + output = postProcessLocaleText(output, targetLanguage); + } return output; } @@ -718,7 +730,9 @@ async function translateStrings(values, targetLanguage, options = {}) { finalMasked[mapping[i]] = translatedUnits[i]; } - return finalMasked.map((value, index) => restoreText(value, protectedValues[index].tokens)); + return finalMasked.map((value, index) => + restoreText(value, protectedValues[index].tokens, targetLanguage) + ); } function collectStringLeaves(node, pathSoFar = [], output = []) { @@ -860,7 +874,7 @@ async function translateMarkdownDocument(content, targetLanguage) { } const joined = parts.join(""); - return restoreText(joined, protectedDoc.tokens); + return restoreText(joined, protectedDoc.tokens, targetLanguage); } async function generateMessageTranslations() { diff --git a/scripts/i18n/glossary-normalize.mjs b/scripts/i18n/glossary-normalize.mjs new file mode 100644 index 0000000000..89a418b08d --- /dev/null +++ b/scripts/i18n/glossary-normalize.mjs @@ -0,0 +1,127 @@ +/** + * OmniRoute — shared glossary terminology normalization. + * + * Single implementation of the "canonical term" rules declared in + * scripts/i18n/glossary/.json, used by three call sites so they can + * never disagree about what canonical means: + * + * - scripts/i18n/run-translation.mjs (active docs pipeline) + * - scripts/i18n/generate-multilang.mjs (deprecated legacy generator) + * - scripts/i18n/check-glossary-consistency.mjs (drift gate) + * + * Why `blockedPrefixes` exists: a synonym is matched as a plain substring, and + * Chinese compounds have no word separators, so a synonym can appear inside an + * unrelated term. 型別 ("type") sits across the character boundary of 模型別名 + * ("model alias") and is also the correct rendering of a programming data type + * in 基本型別. Blocking on the preceding character keeps such a term enforced + * instead of forcing it to be dropped from the glossary entirely. + */ + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const GLOSSARY_DIR = path.join(SCRIPT_DIR, "glossary"); + +const cache = new Map(); + +/** + * @param {string} haystack + * @param {string} needle + * @param {string[]} [blockedPrefixes] + * @returns {boolean} true when at least one occurrence is NOT preceded by a blocked prefix + */ +export function hasUnblockedOccurrence(haystack, needle, blockedPrefixes = []) { + if (!haystack || !needle) return false; + const blocked = Array.isArray(blockedPrefixes) ? blockedPrefixes.filter(Boolean) : []; + let from = 0; + for (;;) { + const at = haystack.indexOf(needle, from); + if (at === -1) return false; + const prev = at === 0 ? "" : haystack.slice(at - 1, at); + if (!prev || !blocked.includes(prev)) return true; + from = at + 1; + } +} + +/** + * Flatten a parsed glossary into applicable replacement rules. Concepts with an + * empty `synonyms` array are documentation-only and produce no rule. + * + * @param {object} glossary - parsed scripts/i18n/glossary/.json + * @returns {Array<{synonym: string, canonical: string, blockedPrefixes: string[]}>} + */ +export function buildReplacements(glossary) { + const terms = glossary && glossary.terms ? glossary.terms : {}; + const replacements = []; + for (const def of Object.values(terms)) { + if (!def || !def.canonical) continue; + const synonyms = Array.isArray(def.synonyms) ? def.synonyms : []; + const blockedPrefixes = Array.isArray(def.blockedPrefixes) ? def.blockedPrefixes : []; + for (const synonym of synonyms) { + if (!synonym) continue; + replacements.push({ synonym, canonical: def.canonical, blockedPrefixes }); + } + } + return replacements; +} + +/** + * @param {string} locale + * @returns {Array<{synonym: string, canonical: string, blockedPrefixes: string[]}>} + */ +export function loadReplacements(locale) { + if (cache.has(locale)) return cache.get(locale); + let replacements = []; + try { + const raw = readFileSync(path.join(GLOSSARY_DIR, `${locale}.json`), "utf8"); + replacements = buildReplacements(JSON.parse(raw)); + } catch { + // No glossary for this locale (or unreadable) — normalization is optional. + replacements = []; + } + cache.set(locale, replacements); + return replacements; +} + +/** + * Apply one replacement rule, skipping blocked occurrences. + * + * @param {string} text + * @param {{synonym: string, canonical: string, blockedPrefixes: string[]}} rule + * @returns {string} + */ +export function applyReplacement(text, rule) { + const { synonym, canonical, blockedPrefixes = [] } = rule; + if (!synonym || !text.includes(synonym)) return text; + let out = ""; + let from = 0; + for (;;) { + const at = text.indexOf(synonym, from); + if (at === -1) return out + text.slice(from); + const prev = at === 0 ? "" : text.slice(at - 1, at); + const blocked = Boolean(prev) && blockedPrefixes.includes(prev); + out += text.slice(from, at) + (blocked ? synonym : canonical); + from = at + synonym.length; + } +} + +/** + * Normalize a translated string to the locale's canonical terminology. + * Returns the input unchanged when the locale has no glossary. + * + * @param {string} text + * @param {string} locale + * @returns {string} + */ +export function normalizeLocaleText(text, locale) { + if (typeof text !== "string" || !text || !locale) return text; + const replacements = loadReplacements(locale); + if (replacements.length === 0) return text; + let result = text; + for (const rule of replacements) { + result = applyReplacement(result, rule); + } + return result; +} diff --git a/scripts/i18n/glossary/zh-TW.json b/scripts/i18n/glossary/zh-TW.json new file mode 100644 index 0000000000..39b9c3ad1f --- /dev/null +++ b/scripts/i18n/glossary/zh-TW.json @@ -0,0 +1,83 @@ +{ + "version": 1, + "locale": "zh-TW", + "description": "Canonical Traditional Chinese (zh-TW) terminology for recurring OmniRoute concepts. Consumed by scripts/i18n/check-glossary-consistency.mjs (drift gate) and scripts/i18n/generate-multilang.mjs (post-translation normalization) — this file is the single source of truth for both. Two failure modes are covered: (1) simplified->traditional conversions that pick the wrong homophone character (儀錶板 for dashboard, 上遊 for upstream, 後臺 for background), and (2) mainland-habit vocabulary that converts to valid traditional characters but is not what TW/HK readers use (默認 vs 預設, 緩存 vs 快取). Concepts whose `synonyms` array is empty are seeded for documentation only — enforcement is deferred because the synonym is also a legitimate rendering of an unrelated concept in today's catalog. `blockedPrefixes` suppresses a synonym match when it is preceded by one of the listed characters, so a term can be enforced even when its string also appears inside an unrelated compound.", + "terms": { + "default": { + "canonical": "預設", + "synonyms": ["默認"] + }, + "memory": { + "canonical": "記憶體", + "synonyms": ["內存"] + }, + "dashboard": { + "canonical": "儀表板", + "synonyms": ["儀錶板"] + }, + "link": { + "canonical": "連結", + "synonyms": ["鏈接"] + }, + "documentation": { + "canonical": "文件", + "synonyms": ["文檔"] + }, + "cache": { + "canonical": "快取", + "synonyms": ["緩存"] + }, + "module": { + "canonical": "模組", + "synonyms": ["模塊"] + }, + "call": { + "canonical": "呼叫", + "synonyms": ["調用"] + }, + "string": { + "canonical": "字串", + "synonyms": ["字符串"] + }, + "global": { + "canonical": "全域", + "synonyms": ["全局"] + }, + "provider": { + "canonical": "提供者", + "synonyms": ["供應商", "提供商"] + }, + "response": { + "canonical": "回應", + "synonyms": ["響應"] + }, + "upstream": { + "canonical": "上游", + "synonyms": ["上遊"] + }, + "background": { + "canonical": "後台", + "synonyms": ["後臺"] + }, + "inactive": { + "canonical": "未啟用", + "synonyms": ["不活躍"] + }, + "type": { + "canonical": "類型", + "synonyms": ["型別"], + "blockedPrefixes": ["模", "本"], + "note": "型別 is the correct rendering for a programming data type (基本型別) and is also the cross-boundary substring of 模型別名 (model alias) — both prefixes are blocked so only UI 'type' labels normalize to 類型." + }, + "code": { + "canonical": "程式碼", + "synonyms": [], + "note": "Enforcement deferred: 代碼 is the correct rendering of 'code' in 控制代碼 (handle), 語系代碼 (locale code), 結束代碼 (exit code) and 錯誤代碼 (error code), all of which the catalog uses legitimately today. Only the unambiguous source-code compound 代碼庫 -> 程式碼庫 is normalized, by hand, never by a blanket rule." + }, + "project": { + "canonical": "專案", + "synonyms": [], + "note": "Enforcement deferred: 項目 is also the correct rendering of 'item' (依賴項目, 必要項目, 共通項目), which dominates real usage. Only 項目概覽 -> 專案概覽 is normalized by hand." + } + } +} diff --git a/scripts/i18n/run-translation.mjs b/scripts/i18n/run-translation.mjs index ae59d83028..5a38c0c60d 100755 --- a/scripts/i18n/run-translation.mjs +++ b/scripts/i18n/run-translation.mjs @@ -35,6 +35,7 @@ import path from "node:path"; import crypto from "node:crypto"; import process from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { normalizeLocaleText } from "./glossary-normalize.mjs"; // ----- .env loader -------------------------------------------------------- // Loads variables from a local `.env` (gitignored) into process.env without @@ -436,7 +437,11 @@ async function translateBody(body, localeEntry, backend) { } } // Re-join with a blank line between chunks (we split on `## ` headings). - return translated.join("\n\n"); + // Then normalize terminology to the locale's canonical glossary — the model + // reliably converts characters but not vocabulary habits, so zh-TW output + // otherwise keeps mainland renderings (默認 for 預設, 緩存 for 快取) and + // wrong-homophone conversions (上遊 for 上游, 儀錶板 for 儀表板). + return normalizeLocaleText(translated.join("\n\n"), localeEntry.code); } // Simple promise-based semaphore (avoid runtime deps). diff --git a/scripts/perf/agentPayloadCorpus.ts b/scripts/perf/agentPayloadCorpus.ts new file mode 100644 index 0000000000..6cf071a6cd --- /dev/null +++ b/scripts/perf/agentPayloadCorpus.ts @@ -0,0 +1,83 @@ +/** + * Deterministic coding-agent request corpus for the #7847 heap benchmark. + * + * Kept separate from `request-body-heap.ts` so it can be imported with zero side effects: + * the benchmark redirects DATA_DIR and transitively boots SQLite at import time, which a unit + * test must not do just to assert corpus stability. + * + * Determinism is the point. A benchmark corpus built with Math.random() would make a + * before/after comparison measure noise instead of the change under test, so the generator uses + * a fixed-seed LCG and is covered by tests/unit/heap-benchmark-corpus.test.ts. + */ + +/** Defaults reproduce the #7847 production incident: 3.05 MiB, 729 messages, 86 tools. */ +export const INCIDENT_SHAPE = { + messages: 729, + tools: 86, + /** Calibrated so the defaults land on the incident's wire size. */ + contentWords: 527, +} as const; + +function lcg(seed: number): () => number { + let s = seed >>> 0; + return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 0x100000000); +} + +const WORDS = [ + "refactor", + "handler", + "provider", + "combo", + "stream", + "token", + "context", + "payload", + "upstream", + "fallback", + "quota", + "retry", + "executor", + "translate", + "compression", + "cache", +]; + +function sentence(rnd: () => number, words: number): string { + const out: string[] = []; + for (let i = 0; i < words; i++) out.push(WORDS[Math.floor(rnd() * WORDS.length)]); + return out.join(" "); +} + +/** A coding-agent shaped request: long alternating history plus a large tool catalog. */ +export function buildAgentPayload( + messages: number = INCIDENT_SHAPE.messages, + tools: number = INCIDENT_SHAPE.tools, + contentWords: number = INCIDENT_SHAPE.contentWords +): Record { + const rnd = lcg(0x5eed); + return { + model: "claude-opus-5", + stream: true, + messages: Array.from({ length: messages }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: sentence(rnd, contentWords), + })), + tools: Array.from({ length: tools }, (_, i) => ({ + type: "function", + function: { + name: `tool_${i}`, + description: sentence(rnd, 25), + parameters: { + type: "object", + properties: Object.fromEntries( + Array.from({ length: 8 }, (_, p) => [ + `param_${p}`, + { type: "string", description: sentence(rnd, 10) }, + ]) + ), + required: ["param_0"], + }, + }, + })), + }; +} diff --git a/scripts/perf/request-body-heap.ts b/scripts/perf/request-body-heap.ts new file mode 100644 index 0000000000..4dae7ec04d --- /dev/null +++ b/scripts/perf/request-body-heap.ts @@ -0,0 +1,200 @@ +/** + * Request-body heap amplification benchmark (#7847). + * + * #7847 reports a 3.05 MiB request with 729 messages and 86 tools reaching ~12,282 MiB of V8 + * heap. The wire size does not explain the peak: the same logical body is retained several times + * over (entry-point log clone, per-combo-target clone, token-estimation string, pending-request + * state). This benchmark attributes retained heap to each of those mechanisms individually, so a + * fix can be justified by numbers instead of intuition — and so a regression can be caught later. + * + * It measures the real production helpers (no reimplementation): `cloneLogPayload` is what + * `buildClientRawRequest` calls per request, and `cloneBoundedForLog` is what the request logger + * actually retains downstream. + * + * Deterministic and API-free (no network, no upstream credentials). The DATA_DIR is redirected to + * a temp dir before importing, because the request-logger module opens the SQLite database on + * import — the benchmark must never touch the operator's real ~/.omniroute store. + * + * Node only — NOT bun. We need `--expose-gc` and V8 heap accounting; measuring "V8 heap" under a + * different engine would report a number that has nothing to do with the production runtime. + * + * Usage: + * npm run bench:heap-body # the #7847 incident shape + * npm run bench:heap-body -- --messages 800 --tools 120 + * npm run bench:heap-body -- --concurrency 16 # simulate overlapping requests + * npm run bench:heap-body -- --json # machine-readable + * npm run bench:heap-body -- --max-retained-mib 64 # non-zero exit if exceeded (regression gate) + */ +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +// Must happen BEFORE the dynamic imports below: open-sse/utils/requestLogger.ts transitively +// opens the SQLite store at import time, and the benchmark must stay hermetic. +const TMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-heapbench-")); +process.env.DATA_DIR = TMP_DATA_DIR; + +// open-sse/utils/requestLogger.ts imports @/lib/usage/usageHistory, so merely importing the +// (pure) log-shaping helper boots SQLite and runs every migration against the temp DATA_DIR. +// That noise would bury the report, so console output is parked for the duration of the import. +// The measurements are unaffected: every baseline is taken after imports have settled. +const { buildAgentPayload, INCIDENT_SHAPE } = await import("./agentPayloadCorpus.ts"); + +const realLog = console.log; +console.log = () => {}; +const { cloneLogPayload } = await import("../../src/lib/logPayloads.ts"); +const { cloneBoundedForLog } = await import("../../open-sse/utils/requestLogger.ts"); +console.log = realLog; + +// ── CLI ────────────────────────────────────────────────────────────────────── +function numArg(flag: string, fallback: number): number { + const i = process.argv.indexOf(flag); + if (i === -1) return fallback; + const v = Number(process.argv[i + 1]); + return Number.isFinite(v) ? v : fallback; +} +const HAS = (flag: string) => process.argv.includes(flag); + +// Defaults mirror the #7847 production incident. +const MESSAGES = numArg("--messages", INCIDENT_SHAPE.messages); +const TOOLS = numArg("--tools", INCIDENT_SHAPE.tools); +// Calibrated so the defaults land on the incident's 3.05 MiB wire size. +const CONTENT_WORDS = numArg("--content-words", INCIDENT_SHAPE.contentWords); +const TARGETS = numArg("--targets", 3); // combo targets -> attemptBody clones +const CONCURRENCY = numArg("--concurrency", 8); +const MAX_RETAINED = numArg("--max-retained-mib", 0); // 0 = report only +const AS_JSON = HAS("--json"); + +const MIB = 1024 * 1024; +const fmt = (bytes: number) => (bytes / MIB).toFixed(2); + +// ── Measurement ────────────────────────────────────────────────────────────── +const gc = globalThis.gc as undefined | (() => void); + +function settle(): void { + // Several passes: one gc() does not reliably collect everything in a young generation. + for (let i = 0; i < 4; i++) gc?.(); +} + +/** + * Retained heap of whatever `produce` returns, while it stays reachable. + * This is the number that maps to the incident: many concurrent requests each holding copies. + */ +function measureRetained(produce: () => T): { bytes: number; value: T } { + settle(); + const before = process.memoryUsage().heapUsed; + const value = produce(); + settle(); + const after = process.memoryUsage().heapUsed; + return { bytes: Math.max(0, after - before), value }; +} + +type Row = { mechanism: string; site: string; bytes: number }; + +async function main(): Promise { + if (typeof gc !== "function") { + console.error( + "[heap-bench] refusing to run without --expose-gc: retained-heap numbers would be\n" + + " dominated by uncollected garbage and are not comparable across runs.\n" + + " Use `npm run bench:heap-body`, which passes it." + ); + process.exitCode = 2; + return; + } + + const body = buildAgentPayload(MESSAGES, TOOLS, CONTENT_WORDS); + const wireBytes = Buffer.byteLength(JSON.stringify(body), "utf8"); + + const rows: Row[] = []; + const hold: unknown[] = []; // keep results reachable so "retained" means retained + + // 1. The entry-point clone every chat request pays (src/sse/handlers/chat.ts buildClientRawRequest). + { + const r = measureRetained(() => cloneLogPayload(body)); + hold.push(r.value); + rows.push({ mechanism: "cloneLogPayload (unbounded)", site: "chat.ts buildClientRawRequest", bytes: r.bytes }); + } + + // 2. What the request logger actually keeps (open-sse/utils/requestLogger.ts). + { + const r = measureRetained(() => cloneBoundedForLog(body)); + hold.push(r.value); + rows.push({ mechanism: "cloneBoundedForLog (bounded)", site: "requestLogger.logClientRawRequest", bytes: r.bytes }); + } + + // 3. Combo per-target attempt clones (open-sse/services/combo.ts attemptBody). + { + const r = measureRetained(() => Array.from({ length: TARGETS }, () => structuredClone(body))); + hold.push(r.value); + rows.push({ mechanism: `structuredClone x${TARGETS} (combo targets)`, site: "combo.ts attemptBody", bytes: r.bytes }); + } + + // 4. Whole-body serialization for token estimation (combo.ts estimateTokens(JSON.stringify(...))). + { + const r = measureRetained(() => JSON.stringify(body)); + hold.push(r.value); + rows.push({ mechanism: "JSON.stringify (token estimate)", site: "combo.ts estimateTokens", bytes: r.bytes }); + } + + // 5. Overlap: what C concurrent in-flight requests retain via the entry clone alone. + const concurrent = measureRetained(() => + Array.from({ length: CONCURRENCY }, () => cloneLogPayload(body)) + ); + hold.push(concurrent.value); + + const perRequest = rows.reduce((a, r) => a + r.bytes, 0); + + if (AS_JSON) { + console.log( + JSON.stringify( + { + shape: { messages: MESSAGES, tools: TOOLS, targets: TARGETS, concurrency: CONCURRENCY }, + wireBytes, + mechanisms: rows, + perRequestBytes: perRequest, + concurrentEntryCloneBytes: concurrent.bytes, + }, + null, + 2 + ) + ); + } else { + console.log(`# Request-body heap amplification (#7847)\n`); + console.log( + `Shape: ${MESSAGES} messages · ${TOOLS} tools · wire size **${fmt(wireBytes)} MiB**` + + ` · ${TARGETS} combo targets\n` + ); + console.log("| mechanism | call site | retained | x wire |"); + console.log("| --- | --- | ---: | ---: |"); + for (const r of rows) { + console.log( + `| ${r.mechanism} | \`${r.site}\` | ${fmt(r.bytes)} MiB | ${(r.bytes / wireBytes).toFixed(2)}x |` + ); + } + console.log( + `| **per request (sum)** | | **${fmt(perRequest)} MiB** | **${(perRequest / wireBytes).toFixed(2)}x** |` + ); + console.log(""); + console.log( + `${CONCURRENCY} concurrent requests retain **${fmt(concurrent.bytes)} MiB**` + + ` via the entry clone alone (${(concurrent.bytes / wireBytes).toFixed(2)}x wire).` + ); + console.log(""); + } + + if (MAX_RETAINED > 0 && perRequest / MIB > MAX_RETAINED) { + console.error( + `[heap-bench] FAIL — per-request retained ${fmt(perRequest)} MiB exceeds --max-retained-mib ${MAX_RETAINED}` + ); + process.exitCode = 1; + } + + // Referenced after all measurements so V8 cannot collect the holds early and flatter the numbers. + if (hold.length === 0) console.log("unreachable"); +} + +try { + await main(); +} finally { + fs.rmSync(TMP_DATA_DIR, { recursive: true, force: true }); +} diff --git a/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx index 0555ad31a1..39f4bff6c2 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx @@ -18,16 +18,16 @@ const HERMES_ROLES: Role[] = [ descriptionKey: "hermesRoleDelegationDesc", }, { id: "vision", labelKey: "hermesRoleVision", descriptionKey: "hermesRoleVisionDesc" }, - { - id: "compression", - labelKey: "hermesRoleCompression", - descriptionKey: "hermesRoleCompressionDesc", - }, { id: "web_extract", labelKey: "hermesRoleWebExtract", descriptionKey: "hermesRoleWebExtractDesc", }, + { + id: "compression", + labelKey: "hermesRoleCompression", + descriptionKey: "hermesRoleCompressionDesc", + }, { id: "skills_hub", labelKey: "hermesRoleSkillsHub", @@ -38,6 +38,45 @@ const HERMES_ROLES: Role[] = [ labelKey: "hermesRoleApproval", descriptionKey: "hermesRoleApprovalDesc", }, + { id: "mcp", labelKey: "hermesRoleMcp", descriptionKey: "hermesRoleMcpDesc" }, + { + id: "title_generation", + labelKey: "hermesRoleTitleGeneration", + descriptionKey: "hermesRoleTitleGenerationDesc", + }, + { + id: "memory_query_rewrite", + labelKey: "hermesRoleMemoryQueryRewrite", + descriptionKey: "hermesRoleMemoryQueryRewriteDesc", + }, + { + id: "tts_audio_tags", + labelKey: "hermesRoleTtsAudioTags", + descriptionKey: "hermesRoleTtsAudioTagsDesc", + }, + { + id: "triage_specifier", + labelKey: "hermesRoleTriageSpecifier", + descriptionKey: "hermesRoleTriageSpecifierDesc", + }, + { + id: "kanban_decomposer", + labelKey: "hermesRoleKanbanDecomposer", + descriptionKey: "hermesRoleKanbanDecomposerDesc", + }, + { + id: "profile_describer", + labelKey: "hermesRoleProfileDescriber", + descriptionKey: "hermesRoleProfileDescriberDesc", + }, + { id: "goal_judge", labelKey: "hermesRoleGoalJudge", descriptionKey: "hermesRoleGoalJudgeDesc" }, + { id: "curator", labelKey: "hermesRoleCurator", descriptionKey: "hermesRoleCuratorDesc" }, + { id: "monitor", labelKey: "hermesRoleMonitor", descriptionKey: "hermesRoleMonitorDesc" }, + { + id: "background_review", + labelKey: "hermesRoleBackgroundReview", + descriptionKey: "hermesRoleBackgroundReviewDesc", + }, ]; const HERMES_AGENT_ZERO_CONFIG_PROVIDERS = ["opencode"]; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index c06fcecda2..77f8e315fb 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -223,6 +223,16 @@ export const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([ "searxng-search", "petals", "comfyui", + // #7447 — Moonshot/Kimi's international host (api.moonshot.ai) rejects + // CN-region keys (issued on platform.kimi.com/moonshot.cn — a separate + // account/keyspace). Neither "kimi" (legacy id) nor "moonshot" (current + // user-facing id) previously exposed a base-URL field at Add-connection + // time, so a CN-region user had no way to point a new connection at + // api.moonshot.cn. resolveBaseUrl()/buildUrl() already honor a + // providerSpecificData.baseUrl override generically — this only exposes + // the existing override affordance for these two ids. + "kimi", + "moonshot", ]); export const DEFAULT_PROVIDER_BASE_URLS: Record = { @@ -234,6 +244,11 @@ export const DEFAULT_PROVIDER_BASE_URLS: Record = { "searxng-search": "http://localhost:8888/search", petals: "https://chat.petals.dev/api/v1/generate", comfyui: "http://localhost:8188", + // #7447 — default stays the international host so existing/new + // international Kimi/Moonshot users see the same prefilled value as + // before; a CN-region user overrides it (see placeholder hint below). + kimi: "https://api.moonshot.ai/v1", + moonshot: "https://api.moonshot.ai/v1", }; export function getLocalProviderMetadata(providerId?: string | null) { @@ -327,6 +342,11 @@ export function getProviderBaseUrlPlaceholder(providerId?: string | null) { return "https://example-account.snowflakecomputing.com"; case "searxng-search": return "http://localhost:8888/search"; + case "kimi": + case "moonshot": + // #7447 — surfaces the CN-region alternative host as the placeholder + // example (mirrors the siliconflow.com/siliconflow.cn pattern above). + return "https://api.moonshot.cn/v1"; default: return ""; } diff --git a/src/app/(dashboard)/dashboard/providers/components/HighlightableProviderCard.tsx b/src/app/(dashboard)/dashboard/providers/components/HighlightableProviderCard.tsx new file mode 100644 index 0000000000..ba5c8ff6fb --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/HighlightableProviderCard.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useCallback, useState } from "react"; +import type { ComponentProps } from "react"; +import ProviderCard from "./ProviderCard"; +import type { ProviderCardHandle } from "./ProviderCard"; +import { recordProviderNavigation, resolveHighlightedCard } from "../providerPageHighlightUtils"; + +type ProviderCardProps = ComponentProps; + +/** + * ProviderCard wrapper that owns the back-navigation highlight concern + * (#8349): records the clicked provider id into history.state before + * navigating, and on return scrolls the matching card into view and + * highlights it. Each card instance keeps its own copy of the highlighted + * id; only the card whose provider id matches reacts, so the instances + * never interfere with each other. + */ +export default function HighlightableProviderCard(props: ProviderCardProps) { + const [highlightedProviderId, setHighlightedProviderId] = useState( + () => window.history.state?.providerId ?? null + ); + + const handleCardClick = useCallback((id: string) => { + recordProviderNavigation(id); + }, []); + + const highlightedCardRef = useCallback( + (handle: ProviderCardHandle | null) => { + resolveHighlightedCard(handle, highlightedProviderId, () => setHighlightedProviderId(null)); + }, + [highlightedProviderId] + ); + + return ; +} diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx index fc8c0423b8..c1382ccf58 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx @@ -1,7 +1,7 @@ "use client"; import type { MouseEvent, ReactNode } from "react"; -import { useState } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { useTranslations } from "next-intl"; @@ -74,6 +74,7 @@ interface ProviderCardProps { stats: ProviderStats; authType?: string; onToggle: (active: boolean) => void; + onCardClick?: (id: string) => void; } const DOT_COLORS: Record = { @@ -152,17 +153,51 @@ function getStatusDisplay( return parts; } -export default function ProviderCard({ - providerId, - provider, - stats, - authType = "apikey", - onToggle, -}: ProviderCardProps) { +export type ProviderCardHandle = { + highlight: () => void; + getProviderId(): string; + scrollIntoView: (options?: ScrollIntoViewOptions) => void; +}; + +const ProviderCard = forwardRef(function ProviderCard( + { providerId, provider, stats, authType = "apikey", onToggle, onCardClick }, + ref +) { const t = useTranslations("providers"); const tc = useTranslations("common"); const tp = useTranslations("miniPlayground"); const [testExpanded, setTestExpanded] = useState(false); + const innerRef = useRef(null); + const linkElementRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + getProviderId() { + return providerId; + }, + highlight() { + const el = innerRef.current; + if (!el) return; + linkElementRef.current?.focus(); + const surface = linkElementRef.current?.firstElementChild; + surface?.animate( + [ + { backgroundColor: "rgba(59,130,246,0.22)" }, + { backgroundColor: "rgba(59,130,246,0.08)" }, + { backgroundColor: "transparent" }, + ], + { duration: 3000, easing: "ease-in-out" } + ); + }, + scrollIntoView() { + const el = innerRef.current; + if (!el) return; + el.scrollIntoView({ behavior: "auto", block: "center" }); + }, + }), + [providerId, innerRef, linkElementRef] + ); // Show the Test button for LLM providers (when serviceKinds includes "llm" // OR when the provider has no explicit serviceKinds but is a regular LLM provider @@ -260,9 +295,18 @@ export default function ProviderCard({ onToggle(allDisabled); }; + const handleCardClick = useCallback(() => { + onCardClick?.(providerId); + }, [onCardClick, providerId]); + return ( -
- +
+ {}} + onChange={undefined} title={allDisabled ? t("enableProvider") : t("disableProvider")} />
@@ -461,4 +505,6 @@ export default function ProviderCard({ )}
); -} +}); + +export default ProviderCard; diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 678356b384..d378122a35 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -49,7 +49,7 @@ import AddCompatibleProviderModal from "./components/AddCompatibleProviderModal" import { CategoryDot } from "./components/CategoryDot"; import { ImportProvidersFromFileModal } from "./components/ImportProvidersFromFileModal"; import NoAuthProvidersSection from "./components/NoAuthProvidersSection"; -import ProviderCard from "./components/ProviderCard"; +import HighlightableProviderCard from "./components/HighlightableProviderCard"; import ProviderCountBadge from "./components/ProviderCountBadge"; import ProviderSummaryCard from "./components/ProviderSummaryCard"; import { @@ -909,7 +909,7 @@ export default function ProvidersPage() { data-testid="provider-compact-grid" > {compactProviderEntries.map((entry) => ( - {compatibleProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - !IDE_PROVIDER_IDS.has(e.providerId)) .map(({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {ideProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {t("webCookieProvidersDesc")}

{webCookieProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - {freeSectionEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {llmProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {t("upstreamProxyProvidersDesc")}

{upstreamProxyEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - {webFetchEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {aggregatorProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {enterpriseProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {cloudAgentProviderEntries.map( ({ providerId, provider, stats, toggleAuthType }) => ( - {t("localProvidersDesc")}

{localProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - {t("searchProvidersDesc")}

{searchProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - {embeddingRerankProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {imageProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {t("audioProvidersDesc")}

{audioProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - {videoProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - void +) { + if (handle?.getProviderId() === highlightedProviderId) { + handle.scrollIntoView({ behavior: "auto", block: "center" }); + handle.highlight(); + } + onAfterHighlight(); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index 3ea8cbc696..f96d479654 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -702,10 +702,10 @@ function ComboCooldownWaitCard({ setDraft(value); }, [value]); - const title = t("resilienceComboCooldownWaitTitle") || "Quota-share combo cooldown wait"; + const title = t("resilienceComboCooldownWaitTitle") || "Combo cooldown wait"; const desc = t("resilienceComboCooldownWaitDesc") || - "For quota-share combos only: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted."; + "For all combo strategies: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted."; return ( @@ -738,7 +738,7 @@ function ComboCooldownWaitCard({ label={t("resilienceEnableServerWait") || "Enabled"} description={ t("resilienceComboCooldownWaitToggleDesc") || - "Quota-share combos only; never waits on quota_exhausted." + "All combo strategies; never waits on quota_exhausted." } checked={draft.enabled} onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))} diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index de394df2f4..04e8a2f9f9 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -171,6 +171,7 @@ const DEFAULT_SYSTEM_TRANSFORMS_CLIENT = { entrypoint: "sdk-cli", versionFormat: "ex-machina", cchAlgo: "sha256-first-user", + buildRevision: "250", }, ], }, diff --git a/src/app/(dashboard)/home/ProviderTopology.tsx b/src/app/(dashboard)/home/ProviderTopology.tsx index 1b19cd230c..2324d93d85 100644 --- a/src/app/(dashboard)/home/ProviderTopology.tsx +++ b/src/app/(dashboard)/home/ProviderTopology.tsx @@ -43,12 +43,18 @@ type ProviderNodeData = { error: boolean; /** Connection-health base state: a healthy connection with no in-flight traffic. */ healthy: boolean; + /** Most recently routed provider. Orthogonal to health — it can be last *and* healthy. */ + last: boolean; }; function ProviderNode({ data }: { data: ProviderNodeData }) { - const { label, color, providerId, active, error, healthy } = data; + const { label, color, providerId, active, error, healthy, last } = data; const GREEN = FLOW_EDGE_COLORS.active; const RED = FLOW_EDGE_COLORS.error; + const AMBER = FLOW_EDGE_COLORS.last; + // "Last routed" is a traffic annotation, not a health state: the border keeps saying + // whether the connection is up, and only the dot turns amber to mark recency. + const dotColor = active ? color : last ? AMBER : GREEN; return (
- {(active || error || healthy) && ( - + {(active || error || healthy || last) && ( + )}
); @@ -229,8 +235,13 @@ function buildLayout( // still reflects "what is connected" at rest instead of going blank after a restart. const trafficError = !active && errorSet.has(pid); const last = !active && !trafficError && lastSet.has(pid); - const healthError = !active && !trafficError && !last && p.status === "error"; - const healthy = !active && !trafficError && !last && !healthError && p.status === "active"; + // Health is orthogonal to recency: having just served a request does not make a + // connection any less connected. `last` used to suppress `healthy`/`healthError`, + // and because the node had no `last` visual it fell all the way through to the idle + // grey — so the provider you had just used rendered *less* connected than an idle + // peer, while its edge was amber. Health drives the border, `last` only the dot. + const healthError = !active && !trafficError && p.status === "error"; + const healthy = !active && !trafficError && !healthError && p.status === "active"; const error = trafficError || healthError; const config = getProviderConfig(p.provider); const nodeId = `provider-${p.provider}`; @@ -251,6 +262,7 @@ function buildLayout( active, error, healthy, + last, } satisfies ProviderNodeData, draggable: false, }); diff --git a/src/app/api/combos/[id]/route.ts b/src/app/api/combos/[id]/route.ts index 5ec69f1385..a1ba03ee1b 100644 --- a/src/app/api/combos/[id]/route.ts +++ b/src/app/api/combos/[id]/route.ts @@ -18,6 +18,7 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { QUOTA_MODEL_PREFIX } from "@/lib/quota/quotaModelNaming"; import { comboErrorResponse } from "@/lib/api/comboErrorResponse"; import { ComboInvariantError } from "@/lib/combos/invariants"; +import { buildComboNameCollisionWarning } from "@/lib/combos/modelNameCollision"; // Minimal shape for the fields we read off a combo row in this route. // `getComboById` returns a structurally `JsonRecord`-typed object, so we @@ -249,7 +250,13 @@ export async function PUT(request, { params }) { // Auto sync to Cloud if enabled await syncToCloudIfEnabled(); - return NextResponse.json(combo); + // #8530: a combo renamed to a real model id is a supported pattern + // (#6940 — bare-model-id provider fallback), so it is never rejected. + // Surface it as a non-blocking warning instead of silently shadowing it. + const warning = comboName + ? buildComboNameCollisionWarning(String(comboName)) + : null; + return NextResponse.json(warning ? { ...combo, warning } : combo); } catch (error) { if (error instanceof ComboInvariantError) { return comboErrorResponse("COMBO_008", 400, { reason: error.message }, request); diff --git a/src/app/api/combos/route.ts b/src/app/api/combos/route.ts index 7bd3baebef..c13466a800 100644 --- a/src/app/api/combos/route.ts +++ b/src/app/api/combos/route.ts @@ -17,6 +17,7 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { comboErrorResponse } from "@/lib/api/comboErrorResponse"; import { computeComboContextLength } from "@/lib/combos/comboContext"; import { ComboInvariantError } from "@/lib/combos/invariants"; +import { buildComboNameCollisionWarning } from "@/lib/combos/modelNameCollision"; // GET /api/combos - Get all combos export async function GET(request: Request) { @@ -120,7 +121,12 @@ export async function POST(request) { // Auto sync to Cloud if enabled await syncToCloudIfEnabled(); - return NextResponse.json(combo, { status: 201 }); + // #8530: a combo named after a real model id is a supported pattern + // (#6940 — bare-model-id provider fallback), so it is never rejected. + // Surface it as a non-blocking warning so the dashboard/API caller can + // confirm it was intentional instead of silently shadowing the model. + const warning = buildComboNameCollisionWarning(name); + return NextResponse.json(warning ? { ...combo, warning } : combo, { status: 201 }); } catch (error) { if (error instanceof ComboInvariantError) { return comboErrorResponse("COMBO_008", 400, { reason: error.message }, request); diff --git a/src/app/api/oauth/kiro/api-key/route.ts b/src/app/api/oauth/kiro/api-key/route.ts index 0564d0937f..96fe095df9 100644 --- a/src/app/api/oauth/kiro/api-key/route.ts +++ b/src/app/api/oauth/kiro/api-key/route.ts @@ -1,6 +1,11 @@ import { NextResponse } from "next/server"; import { KiroService } from "@/lib/oauth/services/kiro"; -import { createProviderConnection, isCloudEnabled } from "@/models"; +import { + createProviderConnection, + getProviderConnections, + updateProviderConnection, + isCloudEnabled, +} from "@/models"; import { syncToCloud } from "@/lib/cloudSync"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { kiroApiKeyImportSchema } from "@/shared/validation/schemas"; @@ -8,6 +13,7 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; import { buildKiroImportError } from "../import/route"; import { buildKiroApiKeyConnectionName, isKiroApiKeyImportClientError } from "./helpers"; +import { findKiroConnectionByIdentity } from "@/lib/oauth/kiroConnectionIdentity"; async function requireKiroApiKeyImportAuth(request: Request) { if (!(await isAuthRequired(request))) return null; @@ -53,11 +59,10 @@ export async function POST(request: Request) { const kiroService = new KiroService(); const credential = await kiroService.validateApiKey(apiKey, region || "us-east-1"); const email = kiroService.extractEmailFromJWT(credential.accessToken); + const name = buildKiroApiKeyConnectionName(targetProvider, credential.region, apiKey); - const connection: any = await createProviderConnection({ - provider: targetProvider, - authType: "apikey", - name: buildKiroApiKeyConnectionName(targetProvider, credential.region, apiKey), + const record = { + name, apiKey: credential.accessToken, accessToken: credential.accessToken, refreshToken: null, @@ -72,7 +77,23 @@ export async function POST(request: Request) { provider: "API Key", }, testStatus: "active", + isActive: true, + }; + const existing = await getProviderConnections({ provider: targetProvider }); + const match = findKiroConnectionByIdentity(existing, { + authType: "apikey", + profileArn: credential.profileArn, + email, + name, }); + const connection: any = + typeof match?.id === "string" + ? await updateProviderConnection(match.id, record) + : await createProviderConnection({ + provider: targetProvider, + authType: "apikey", + ...record, + }); await syncToCloudIfEnabled(); diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index d3db085568..40b927b4c9 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -12,6 +12,7 @@ import { import { syncToCloud } from "@/lib/cloudSync"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { KiroService } from "@/lib/oauth/services/kiro"; +import { findKiroConnectionByIdentity } from "@/lib/oauth/kiroConnectionIdentity"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { emailFromExternalIdpToken, @@ -73,6 +74,7 @@ async function tryKiroCliSqlite(): Promise<{ clientSecret?: string; region?: string; profileArn?: string; + authMethod?: "builder-id" | "idc"; source?: string; }> { // Build list of candidate DB paths to probe in order. @@ -111,8 +113,7 @@ async function tryKiroCliSqlite(): Promise<{ for (const table of ["auth_kv", "ItemTable", "storage"]) { try { const row = db.prepare(`SELECT value FROM ${table} WHERE key = ?`).get(key) as - | { value: string } - | undefined; + { value: string } | undefined; if (row?.value) { try { tokenData = JSON.parse(row.value); @@ -139,8 +140,7 @@ async function tryKiroCliSqlite(): Promise<{ for (const table of ["auth_kv", "ItemTable", "storage"]) { try { const row = db.prepare(`SELECT value FROM ${table} WHERE key = ?`).get(key) as - | { value: string } - | undefined; + { value: string } | undefined; if (row?.value) { try { regData = JSON.parse(row.value); @@ -192,6 +192,7 @@ async function tryKiroCliSqlite(): Promise<{ clientSecret: regData?.client_secret, region, profileArn, + authMethod: resolveKiroCliAuthMethod(profileArn), }; } finally { try { @@ -417,6 +418,12 @@ export function deriveKiroConnectionName(opts: { return `Kiro (${r})`; } +export function resolveKiroCliAuthMethod( + profileArn: string | null | undefined +): "builder-id" | "idc" { + return profileArn ? "idc" : "builder-id"; +} + type ProviderConnectionLike = { id?: unknown; providerSpecificData?: unknown; @@ -434,17 +441,7 @@ export function findKiroConnectionByProfileArn( connections: ProviderConnectionLike[], profileArn: string | undefined ): ProviderConnectionLike | null { - if (!profileArn) return null; - for (const conn of connections) { - const psd = conn.providerSpecificData; - if (psd && typeof psd === "object" && !Array.isArray(psd)) { - const stored = (psd as Record).profileArn; - if (typeof stored === "string" && stored === profileArn) { - return conn; - } - } - } - return null; + return findKiroConnectionByIdentity(connections, { profileArn }); } // ── Save to OmniRoute DB ────────────────────────────────────────────────────── @@ -505,10 +502,12 @@ async function saveAndRespond( if (profileArn) providerSpecificData.profileArn = profileArn; const existingConnections = await getProviderConnections({ provider: targetProvider }); - const existingByArn = findKiroConnectionByProfileArn( - existingConnections, - profileArn || undefined - ); + const existingByArn = findKiroConnectionByIdentity(existingConnections, { + authType: "oauth", + profileArn, + clientId: result.clientId, + email, + }); const record = { accessToken: refreshed.accessToken, refreshToken: refreshed.refreshToken || result.refreshToken!, @@ -547,13 +546,12 @@ async function saveAndRespond( let expiresAt = result.expiresAt; let profileArn = result.profileArn; - // Determine authMethod: prefer the value from the SSO cache token (e.g. "idc") - // so that kiroService.refreshToken() takes the correct OIDC path for IDC tokens - // (#2059). Fall back to "kiro-cli" for the SQLite path and "imported" for plain - // social SSO cache tokens (no clientIdHash → no IDC client creds). + // `kiro-cli` identifies where credentials came from, not the account type. Persist + // the actual auth method so IdC accounts still use their profile ARN and Builder ID + // accounts keep the profile-less flow. const resolvedAuthMethod = result.source === "kiro-cli-sqlite" - ? "kiro-cli" + ? result.authMethod || resolveKiroCliAuthMethod(profileArn) : result.clientId ? result.authMethod || "idc" : "imported"; @@ -618,7 +616,12 @@ async function saveAndRespond( // just refresh its tokens instead of inserting a new row. This prevents the // duplicate-row accumulation reported in #3615 (4 rows after 6 days). const existingConnections = await getProviderConnections({ provider: targetProvider }); - const existingByArn = findKiroConnectionByProfileArn(existingConnections, profileArn); + const existingByArn = findKiroConnectionByIdentity(existingConnections, { + authType: "oauth", + profileArn, + clientId: providerSpecificData.clientId, + email, + }); if (existingByArn && typeof existingByArn.id === "string") { await updateProviderConnection(existingByArn.id, { diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index ffaa4c9842..54b63d2f3e 100755 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -1,6 +1,12 @@ import { NextResponse } from "next/server"; import { KiroService } from "@/lib/oauth/services/kiro"; -import { createProviderConnection, isCloudEnabled, resolveProxyForProvider } from "@/models"; +import { + createProviderConnection, + getProviderConnections, + updateProviderConnection, + isCloudEnabled, + resolveProxyForProvider, +} from "@/models"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; import { kiroImportSchema } from "@/shared/validation/schemas"; @@ -8,6 +14,7 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { findKiroConnectionByIdentity } from "@/lib/oauth/kiroConnectionIdentity"; import { emailFromExternalIdpToken, isExternalIdpAuthMethod, @@ -36,6 +43,23 @@ async function requireOAuthImportAuth(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } +async function upsertImportedKiroConnection( + targetProvider: string, + record: Record, + identity: { profileArn?: unknown; clientId?: unknown; email?: unknown; name?: unknown } +) { + const existing = await getProviderConnections({ provider: targetProvider }); + const match = findKiroConnectionByIdentity(existing, { authType: "oauth", ...identity }); + if (typeof match?.id === "string") { + return updateProviderConnection(match.id, record); + } + return createProviderConnection({ + provider: targetProvider, + authType: "oauth", + ...record, + } as any); +} + /** * POST /api/oauth/kiro/import * Import and validate refresh token from Kiro IDE @@ -95,9 +119,7 @@ export async function POST(request: Request) { const email = emailFromExternalIdpToken(refreshed.accessToken) || kiroService.extractEmailFromJWT(refreshed.accessToken); - const connection: any = await createProviderConnection({ - provider: targetProvider, - authType: "oauth", + const record = { accessToken: refreshed.accessToken, refreshToken: refreshed.refreshToken || refreshToken.trim(), expiresAt: new Date(Date.now() + (refreshed.expiresIn || 3600) * 1000).toISOString(), @@ -112,7 +134,13 @@ export async function POST(request: Request) { region: region || "us-east-1", }, testStatus: "active", - } as any); + isActive: true, + }; + const connection: any = await upsertImportedKiroConnection(targetProvider, record, { + profileArn, + clientId, + email, + }); await syncToCloudIfEnabled(); return NextResponse.json({ success: true, @@ -165,29 +193,34 @@ export async function POST(request: Request) { const resolvedProfileArn = (tokenData as any).profileArn || null; // Save to database - const connection: any = await createProviderConnection({ - provider: targetProvider, - authType: "oauth", + const providerSpecificData = { + profileArn: resolvedProfileArn, + authMethod: resolvedAuthMethod, + provider: isIdc ? "Enterprise" : "Imported", + ...(tokenData.clientId + ? { + clientId: tokenData.clientId, + clientSecret: tokenData.clientSecret, + region: region || "us-east-1", + ...(tokenData.clientSecretExpiresAt + ? { clientSecretExpiresAt: tokenData.clientSecretExpiresAt } + : {}), + } + : {}), + }; + const record = { accessToken: tokenData.accessToken, refreshToken: tokenData.refreshToken || refreshToken.trim(), expiresAt: new Date(Date.now() + (tokenData.expiresIn || 3600) * 1000).toISOString(), email: email || null, - providerSpecificData: { - profileArn: resolvedProfileArn, - authMethod: resolvedAuthMethod, - provider: isIdc ? "Enterprise" : "Imported", - ...(tokenData.clientId - ? { - clientId: tokenData.clientId, - clientSecret: tokenData.clientSecret, - region: region || "us-east-1", - ...(tokenData.clientSecretExpiresAt - ? { clientSecretExpiresAt: tokenData.clientSecretExpiresAt } - : {}), - } - : {}), - }, + providerSpecificData, testStatus: "active", + isActive: true, + }; + const connection: any = await upsertImportedKiroConnection(targetProvider, record, { + profileArn: resolvedProfileArn, + clientId: providerSpecificData.clientId, + email, }); // Auto sync to Cloud if enabled diff --git a/src/app/api/oauth/kiro/social-exchange/route.ts b/src/app/api/oauth/kiro/social-exchange/route.ts index d668ed02eb..d6d6ffe629 100755 --- a/src/app/api/oauth/kiro/social-exchange/route.ts +++ b/src/app/api/oauth/kiro/social-exchange/route.ts @@ -1,17 +1,24 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { KiroService } from "@/lib/oauth/services/kiro"; -import { createProviderConnection, isCloudEnabled } from "@/models"; +import { + createProviderConnection, + getProviderConnections, + updateProviderConnection, + isCloudEnabled, +} from "@/models"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { KIRO_CONFIG } from "@/lib/oauth/constants/oauth"; +import { findKiroConnectionByIdentity } from "@/lib/oauth/kiroConnectionIdentity"; +import { classifyKiroSocialPoll } from "@/lib/oauth/kiroSocialPoll"; const socialExchangeSchema = z.object({ deviceCode: z.string().min(1, "Missing deviceCode or provider"), - provider: z.string().min(1, "Missing deviceCode or provider"), - targetProvider: z.string().optional(), + provider: z.enum(["google", "github"]), + targetProvider: z.enum(["kiro", "amazon-q"]).optional(), }); /** @@ -57,19 +64,25 @@ export async function POST(request: Request) { }); const data = await response.json(); + const poll = classifyKiroSocialPoll(response.ok, response.status, data); - if (!response.ok || data.error === "authorization_pending" || data.error === "slow_down") { + if (poll.kind === "pending") { return NextResponse.json({ + success: false, pending: true, - error: data.error || "authorization_pending", + error: poll.error, }); } - if (!data.accessToken && !data.refreshToken) { - return NextResponse.json({ - pending: true, - error: data.error || "no_tokens", - }); + if (poll.kind === "error") { + return NextResponse.json( + { + success: false, + pending: false, + error: poll.error, + }, + { status: poll.status } + ); } const kiroService = new KiroService(); @@ -84,16 +97,30 @@ export async function POST(request: Request) { providerSpecificData.profileArn = data.profileArn; } - const connection: any = await createProviderConnection({ - provider: targetProvider || "kiro", - authType: "oauth", + const resolvedProvider = targetProvider || "kiro"; + const record = { accessToken: data.accessToken, refreshToken: data.refreshToken, expiresAt: new Date(Date.now() + (data.expiresIn || 3600) * 1000).toISOString(), email: email || null, providerSpecificData, testStatus: "active", + isActive: true, + }; + const existing = await getProviderConnections({ provider: resolvedProvider }); + const match = findKiroConnectionByIdentity(existing, { + authType: "oauth", + profileArn: data.profileArn, + email, }); + const connection: any = + typeof match?.id === "string" + ? await updateProviderConnection(match.id, record) + : await createProviderConnection({ + provider: resolvedProvider, + authType: "oauth", + ...record, + }); await syncToCloudIfEnabled(); diff --git a/src/app/api/plugins/route.ts b/src/app/api/plugins/route.ts index c9b54645f8..22f742bed3 100644 --- a/src/app/api/plugins/route.ts +++ b/src/app/api/plugins/route.ts @@ -19,7 +19,7 @@ export async function GET(request: NextRequest) { const authError = await requireManagementAuth(request); if (authError) return authError; const url = new URL(request.url); - const statusResult = StatusSchema.safeParse(url.searchParams.get("status")); + const statusResult = StatusSchema.safeParse(url.searchParams.get("status") ?? undefined); if (!statusResult.success) { return NextResponse.json( { error: "Invalid status value", details: statusResult.error.issues }, diff --git a/src/app/api/providers/[id]/models/discovery/normalizers.ts b/src/app/api/providers/[id]/models/discovery/normalizers.ts index ab6c06e477..5b553fb3fa 100644 --- a/src/app/api/providers/[id]/models/discovery/normalizers.ts +++ b/src/app/api/providers/[id]/models/discovery/normalizers.ts @@ -17,6 +17,7 @@ import { } from "@omniroute/open-sse/config/agyModels.ts"; import { normalizeAntigravityClientProfile } from "@/shared/constants/antigravityClientProfile"; import { ensureAntigravityProjectAssigned } from "@omniroute/open-sse/services/antigravityProjectBootstrap.ts"; +import { persistDiscoveredAntigravityProjectId } from "@omniroute/open-sse/services/antigravityProjectPersist.ts"; import { asRecord, toNonEmptyString } from "./helpers"; const antigravityDiscoveryInflight = new Map< @@ -115,7 +116,16 @@ export async function fetchAntigravityDiscoveryModelsCached( const promise = (async () => { await resolveAntigravityClientVersion(profile); - await ensureAntigravityProjectAssigned(accessToken, fetch, profile); + const discovered = await ensureAntigravityProjectAssigned(accessToken, fetch, profile); + if (discovered) { + // #8491: persist the recovered id so it survives the next token refresh + // or process restart instead of being silently rediscovered every time. + await persistDiscoveredAntigravityProjectId( + connectionId, + discovered, + asRecord(providerSpecificData) + ); + } for (const discoveryUrl of [ ...getAntigravityFetchAvailableModelsUrls(), diff --git a/src/app/api/providers/[id]/models/discoveryClientVersion.ts b/src/app/api/providers/[id]/models/discoveryClientVersion.ts new file mode 100644 index 0000000000..b359e59999 --- /dev/null +++ b/src/app/api/providers/[id]/models/discoveryClientVersion.ts @@ -0,0 +1,57 @@ +// #8347: CLIProxyAPI-style upstreams gate a richer catalog behind a `client_version` query +// param on the model-list request (mirroring `discovery/codex.ts::buildCodexModelsUrl`). +// This is a per-connection, default-OFF opt-in — appending an unexpected query param to +// every generic OpenAI-compatible upstream's model-list call is the stated regression +// vector, so this MUST default to false and MUST never be applied to inference URLs. + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +export type DiscoveryClientVersionOptions = { + discoveryClientVersionEnabled?: boolean; + discoveryClientVersion?: string; +}; + +/** + * Reads the opt-in flag + optional version string off a connection's + * `providerSpecificData`. Default is OFF: absent, falsy, or malformed data never enables + * the query param. + */ +export function getDiscoveryClientVersionOptions( + providerSpecificData: unknown +): DiscoveryClientVersionOptions { + const record = asRecord(providerSpecificData); + const enabled = record.discoveryClientVersionEnabled === true; + const version = + typeof record.discoveryClientVersion === "string" && record.discoveryClientVersion.trim() + ? record.discoveryClientVersion.trim() + : undefined; + return { discoveryClientVersionEnabled: enabled, discoveryClientVersion: version }; +} + +/** + * Appends `client_version` to a model-LIST URL only, and only when the connection has + * explicitly opted in. Never call this for an inference/chat-completions URL. Returns the + * URL unchanged (byte-identical) when the opt-in is absent or off, so unrelated generic + * OpenAI-compatible upstreams see no behavior change. + */ +export function buildProviderModelsUrl( + modelsUrl: string, + options: DiscoveryClientVersionOptions | undefined +): string { + if (!options?.discoveryClientVersionEnabled || !options.discoveryClientVersion) { + return modelsUrl; + } + try { + const url = new URL(modelsUrl); + url.searchParams.set("client_version", options.discoveryClientVersion); + return url.toString(); + } catch { + // Malformed base URL — fall back to the untouched string rather than throwing; + // the caller's own fetch will surface the real error. + return modelsUrl; + } +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index b24c4b7cf5..c7d74a1f9d 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -83,6 +83,10 @@ import { isAutoFetchModelsEnabled, persistDiscoveredModels, } from "@/lib/providerModels/modelDiscovery"; +import { + buildProviderModelsUrl, + getDiscoveryClientVersionOptions, +} from "./discoveryClientVersion"; import { parseGeminiModelsList, type GeminiDiscoveryModel, @@ -802,8 +806,16 @@ export async function GET( `${baseUrl.replace(/\/$/, "")}/models`, // Original fallback ]; + // #8347: opt-in `client_version` query param on the model-LIST request only (never on + // any inference URL, and never on this path by default) — see discoveryClientVersion.ts. + const discoveryClientVersionOptions = getDiscoveryClientVersionOptions( + connection.providerSpecificData + ); + // Remove duplicates - const uniqueEndpoints = [...new Set(endpoints)]; + const uniqueEndpoints = [...new Set(endpoints)].map((endpoint) => + buildProviderModelsUrl(endpoint, discoveryClientVersionOptions) + ); let models = null; let lastErrorStatus = null; const token = apiKey || accessToken; diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts index 84ffac324d..2ba98472bf 100644 --- a/src/app/api/providers/[id]/test/oauthTestConfig.ts +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -51,6 +51,18 @@ export const OAUTH_TEST_CONFIG = { authPrefix: "Bearer ", refreshable: true, }, + // `agy` is a separate connection id that shares the Antigravity backend and the same + // Google OAuth token lifecycle (tokenRefresh.ts routes it to refreshGoogleToken), but + // it was missing here — so "Test Connection" fell through to "Provider test not + // supported", recorded testStatus="error", and painted the home topology node red on a + // perfectly good account. Probe the same userinfo endpoint as antigravity. + agy: { + url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, xai: { url: "https://api.x.ai/v1/chat/completions", method: "POST", @@ -112,6 +124,16 @@ export const OAUTH_TEST_CONFIG = { checkExpiry: true, refreshable: true, }, + "devin-cli": { + // Same gap as grok-cli #7610: absent from this table, so "Test Connection" + // always fell through to "Provider test not supported" and left a working + // connection showing a red ERR badge. There is no HTTP probe to hit — the + // executor drives the local `devin` binary over ACP stdio and the binary + // owns its own credentials (`devin auth login`), so there is no refresh + // token to rotate either. Validate on token presence/expiry; real + // connectivity is proven by every chat/completions request. + checkExpiry: true, + }, "grok-cli": { // #7610: was entirely absent from OAUTH_TEST_CONFIG, so "Test Connection" // always fell through to the generic "Provider test not supported" branch diff --git a/src/app/api/tools/agent-bridge/diagnose/route.ts b/src/app/api/tools/agent-bridge/diagnose/route.ts index 26dd7f2e40..2de8fd3d23 100644 --- a/src/app/api/tools/agent-bridge/diagnose/route.ts +++ b/src/app/api/tools/agent-bridge/diagnose/route.ts @@ -37,9 +37,10 @@ function probeTcp(port: number, host = "127.0.0.1", timeoutMs = 1500): Promise { +export async function GET(request: Request): Promise { try { - const status = await getMitmStatus(); + const agentId = new URL(request.url).searchParams.get("agentId") ?? undefined; + const status = await getMitmStatus(agentId); const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt"); const certExists = fs.existsSync(certPath); const certTrusted = certExists ? await checkCertInstalled(certPath) : false; diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index b5612f6271..33b9497cd7 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -1,4 +1,5 @@ import { + handleAdobeFireflyImageGeneration, handleCodexImageEdit, handleImageEdit, handleOpenAIImageEdit, @@ -185,6 +186,115 @@ function jsonResponse(data: unknown, status = 200): Response { }); } +/** Reduce reference images (multi + single fallback) to data-URL strings for Firefly. */ +function buildAdobeFireflyEditDataUrls( + images: Array<{ bytes: Buffer; mime: string }>, + imageBytes: Buffer | null, + imageMime: string | null +): string[] { + const dataUrls: string[] = []; + const refList = Array.isArray(images) ? images : []; + for (const ref of refList) { + if (!ref || typeof ref !== "object") continue; + const bytes = (ref as { bytes?: Buffer }).bytes; + const mime = + typeof (ref as { mime?: string }).mime === "string" && + String((ref as { mime?: string }).mime).startsWith("image/") + ? String((ref as { mime?: string }).mime) + : "image/png"; + if (Buffer.isBuffer(bytes) && bytes.length > 0) { + dataUrls.push(`data:${mime};base64,${bytes.toString("base64")}`); + } + } + if (dataUrls.length === 0 && imageBytes && imageBytes.length > 0) { + const mime = typeof imageMime === "string" && imageMime.startsWith("image/") ? imageMime : "image/png"; + dataUrls.push(`data:${mime};base64,${imageBytes.toString("base64")}`); + } + return dataUrls; +} + +/** + * Adobe Firefly edit = storage upload + generate-async referenceBlobs (same as i2i generate). + * Extracted from postHandler to keep cyclomatic/cognitive complexity in check + * (config/quality/complexity-baseline.json ratchet). + */ +async function handleAdobeFireflyEditRequest(params: { + parsed: ReturnType; + providerConfig: NonNullable>; + allowedConnections: string[] | null; + resolvedModel: string; + prompt: string; + size: string | null; + responseFormat: string | null; + images: Array<{ bytes: Buffer; mime: string }>; + imageBytes: Buffer | null; + imageMime: string | null; +}): Promise { + const { + parsed, + providerConfig, + allowedConnections, + resolvedModel, + prompt, + size, + responseFormat, + images, + imageBytes, + imageMime, + } = params; + + const credentials = await getProviderCredentialsWithQuotaPreflight( + parsed.provider, + null, + allowedConnections, + resolvedModel + ); + if (!credentials) { + return errorResponse(HTTP_STATUS.UNAUTHORIZED, `No credentials for provider: ${parsed.provider}`); + } + if (credentials.allRateLimited) { + return unavailableResponse( + HTTP_STATUS.RATE_LIMITED, + `[${parsed.provider}] All accounts rate limited`, + credentials.retryAfter, + credentials.retryAfterHuman + ); + } + + // Prefer multi-image list when present; fall back to the primary imageBytes. + const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); + if (dataUrls.length === 0) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: image"); + } + + const result = await handleAdobeFireflyImageGeneration({ + provider: parsed.provider, + model: parsed.model, + providerConfig, + body: { + prompt, + size: size ?? undefined, + response_format: responseFormat ?? undefined, + n: 1, + image_url: dataUrls[0], + image: dataUrls.length === 1 ? dataUrls[0] : dataUrls, + image_urls: dataUrls, + images: dataUrls, + }, + credentials, + log, + }); + + if ((result as { success?: boolean }).success) { + await clearRecoveredProviderState(credentials); + return jsonResponse((result as { data?: unknown }).data); + } + return jsonResponse( + toJsonErrorPayload((result as { error?: unknown }).error, "Image edit provider error"), + (result as { status?: number }).status ?? HTTP_STATUS.BAD_GATEWAY + ); +} + async function postHandler(request: Request, _context?: unknown) { let input: EditInput | null; try { @@ -246,13 +356,22 @@ async function postHandler(request: Request, _context?: unknown) { const resolvedModel = await resolveImageRouteModel(fullModel); const parsed = parseImageModel(resolvedModel); const providerConfig = parsed.provider ? getImageProvider(parsed.provider) : null; + // Firefly nano/gpt-image accept multiple reference blobs; other non-Codex stay at 1. + const maxRefsForProvider = + providerConfig?.format === "adobe-firefly-image" + ? 4 + : providerConfig?.format === "codex-responses" + ? Number.POSITIVE_INFINITY + : MAX_NON_CODEX_IMAGE_EDIT_REFERENCES; if ( providerConfig?.format !== "codex-responses" && - imageInputCount > MAX_NON_CODEX_IMAGE_EDIT_REFERENCES + imageInputCount > maxRefsForProvider ) { return errorResponse( HTTP_STATUS.BAD_REQUEST, - "This image edit provider currently supports only one reference image" + providerConfig?.format === "adobe-firefly-image" + ? "Adobe Firefly image edit supports at most 4 reference images" + : "This image edit provider currently supports only one reference image" ); } // chatgpt-web keeps its conversation-continuation edit flow unchanged. @@ -395,12 +514,28 @@ async function postHandler(request: Request, _context?: unknown) { ); } - // Other built-in non-chatgpt-web providers do not expose an OpenAI-compatible edit endpoint. + // Adobe Firefly: edit = storage upload + generate-async referenceBlobs (same as i2i generate). + if (providerConfig?.format === "adobe-firefly-image") { + return handleAdobeFireflyEditRequest({ + parsed, + providerConfig, + allowedConnections, + resolvedModel, + prompt, + size, + responseFormat, + images, + imageBytes, + imageMime, + }); + } + + // Other built-in providers do not expose an OpenAI-compatible edit endpoint. if (providerConfig) { return errorResponse( HTTP_STATUS.BAD_REQUEST, `Image edit is not supported for built-in provider "${parsed.provider}". ` + - `Use chatgpt-web or a custom OpenAI-compatible image provider.` + `Use adobe-firefly, chatgpt-web, codex, or a custom OpenAI-compatible image provider.` ); } diff --git a/src/app/api/v1/vscode/[token]/api/show/route.ts b/src/app/api/v1/vscode/[token]/api/show/route.ts index f48a500ea8..37f6a92098 100644 --- a/src/app/api/v1/vscode/[token]/api/show/route.ts +++ b/src/app/api/v1/vscode/[token]/api/show/route.ts @@ -16,6 +16,7 @@ import { } from "@/app/api/v1/vscode/[token]/serviceTierVariants"; import { getFamilyFirstModelCandidates, getFamilyFirstPublishedModelId } from "@/app/api/v1/vscode/[token]/familyFirstModelIds"; import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest"; +import { isUsableChatModel } from "@/app/api/v1/vscode/[token]/usableChatModel"; type OpenAiCatalogModel = { id?: string; @@ -33,35 +34,6 @@ type OpenAiCatalogModel = { supported_endpoints?: string[]; }; -function isUsableChatModel(model: OpenAiCatalogModel) { - if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { - return false; - } - if (typeof model.parent === "string" && model.parent.length > 0) return false; - if (typeof model.type === "string" && model.type !== "chat") return false; - - const apiFormat = typeof model.api_format === "string" ? model.api_format : "chat-completions"; - if (apiFormat !== "chat-completions") return false; - - if ( - Array.isArray(model.supported_endpoints) && - model.supported_endpoints.length > 0 && - !model.supported_endpoints.includes("chat") - ) { - return false; - } - - if ( - Array.isArray(model.output_modalities) && - model.output_modalities.length > 0 && - !model.output_modalities.includes("text") - ) { - return false; - } - - return true; -} - function getCatalogModelId(model: OpenAiCatalogModel) { return model.id || model.name || model.root || "unknown"; } diff --git a/src/app/api/v1/vscode/[token]/api/tags/route.ts b/src/app/api/v1/vscode/[token]/api/tags/route.ts index 81273c909b..c7a061132a 100644 --- a/src/app/api/v1/vscode/[token]/api/tags/route.ts +++ b/src/app/api/v1/vscode/[token]/api/tags/route.ts @@ -19,6 +19,7 @@ import { } from "@/app/api/v1/vscode/[token]/serviceTierVariants"; import { getFamilyFirstPublishedModelId } from "@/app/api/v1/vscode/[token]/familyFirstModelIds"; import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest"; +import { isUsableChatModel } from "@/app/api/v1/vscode/[token]/usableChatModel"; type OpenAiCatalogModel = { id?: string; @@ -64,35 +65,6 @@ async function selectPreferredModels(models: OpenAiCatalogModel[]) { return codexModels.length > 0 ? codexModels : models; } -function isUsableChatModel(model: OpenAiCatalogModel) { - if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { - return false; - } - if (typeof model.parent === "string" && model.parent.length > 0) return false; - if (typeof model.type === "string" && model.type !== "chat") return false; - - const apiFormat = typeof model.api_format === "string" ? model.api_format : "chat-completions"; - if (apiFormat !== "chat-completions") return false; - - if ( - Array.isArray(model.supported_endpoints) && - model.supported_endpoints.length > 0 && - !model.supported_endpoints.includes("chat") - ) { - return false; - } - - if ( - Array.isArray(model.output_modalities) && - model.output_modalities.length > 0 && - !model.output_modalities.includes("text") - ) { - return false; - } - - return true; -} - function getOllamaModelFamily(model: OpenAiCatalogModel, canonicalFamily?: string | null) { const rawModelId = getModelName(model).trim(); const tierParsedModel = parseVscodeServiceTierVariantModelId(rawModelId); diff --git a/src/app/api/v1/vscode/[token]/models/route.ts b/src/app/api/v1/vscode/[token]/models/route.ts index 5d0e39fad5..450373e476 100644 --- a/src/app/api/v1/vscode/[token]/models/route.ts +++ b/src/app/api/v1/vscode/[token]/models/route.ts @@ -25,6 +25,7 @@ import { parseVscodeServiceTierVariantModelId, } from "@/app/api/v1/vscode/[token]/serviceTierVariants"; import { getFamilyFirstPublishedModelId } from "@/app/api/v1/vscode/[token]/familyFirstModelIds"; +import { isUsableChatModel } from "@/app/api/v1/vscode/[token]/usableChatModel"; type CatalogModelEntry = { id?: string; @@ -72,12 +73,6 @@ type EnrichModelForVscodeOptions = { preserveNativeId?: boolean; }; -const TEXT_GENERATION_API_FORMATS = new Set([ - "chat-completions", - "responses", - "openai-responses", -]); - function usesResponsesApi(model: CatalogModelEntry) { return ( model.api_format === "responses" || @@ -86,41 +81,6 @@ function usesResponsesApi(model: CatalogModelEntry) { ); } -function excludesChatAndResponsesEndpoints(model: CatalogModelEntry) { - return ( - Array.isArray(model.supported_endpoints) && - model.supported_endpoints.length > 0 && - !model.supported_endpoints.includes("chat") && - !model.supported_endpoints.includes("responses") - ); -} - -function excludesTextOutputModality(model: CatalogModelEntry) { - return ( - Array.isArray(model.output_modalities) && - model.output_modalities.length > 0 && - !model.output_modalities.includes("text") - ); -} - -function isUsableChatModel(model: CatalogModelEntry) { - if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { - return false; - } - if (typeof model.parent === "string" && model.parent.length > 0) return false; - if (typeof model.type === "string" && model.type !== "chat") return false; - if ( - typeof model.api_format === "string" && - !TEXT_GENERATION_API_FORMATS.has(model.api_format) - ) { - return false; - } - if (excludesChatAndResponsesEndpoints(model)) return false; - if (excludesTextOutputModality(model)) return false; - - return true; -} - function getModelImportReasoningEffortValues(model: VscodeCatalogModel, reasoningEffortValues: string[]) { const providerId = (model.owned_by || "").trim() || diff --git a/src/app/api/v1/vscode/[token]/usableChatModel.ts b/src/app/api/v1/vscode/[token]/usableChatModel.ts new file mode 100644 index 0000000000..744bf7e070 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/usableChatModel.ts @@ -0,0 +1,63 @@ +// Shared "is this catalog model usable as a VS Code chat model" filter. +// +// Historically this predicate was copy-pasted independently into every VS +// Code listing route (models, api/tags, api/show — both the token-prefixed +// and `raw` token variants). PR #7012 widened only the `models/route.ts` +// copy to accept Responses-API-format models (api_format "responses" / +// "openai-responses", or supported_endpoints containing "responses") +// alongside plain "chat-completions" models — the other 4 copies were left +// on the old, stricter filter, silently hiding OpenAI/Codex "responses" +// models from every Ollama-compatible listing endpoint (#7587). +// +// Centralizing the predicate here means a future widening only has to +// happen once. + +export type UsableChatModelCandidate = { + owned_by?: string; + parent?: string | null; + type?: string; + api_format?: string; + supported_endpoints?: string[]; + output_modalities?: string[]; +}; + +export const TEXT_GENERATION_API_FORMATS = new Set([ + "chat-completions", + "responses", + "openai-responses", +]); + +function excludesChatAndResponsesEndpoints(model: UsableChatModelCandidate) { + return ( + Array.isArray(model.supported_endpoints) && + model.supported_endpoints.length > 0 && + !model.supported_endpoints.includes("chat") && + !model.supported_endpoints.includes("responses") + ); +} + +function excludesTextOutputModality(model: UsableChatModelCandidate) { + return ( + Array.isArray(model.output_modalities) && + model.output_modalities.length > 0 && + !model.output_modalities.includes("text") + ); +} + +export function isUsableChatModel(model: UsableChatModelCandidate) { + if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { + return false; + } + if (typeof model.parent === "string" && model.parent.length > 0) return false; + if (typeof model.type === "string" && model.type !== "chat") return false; + if ( + typeof model.api_format === "string" && + !TEXT_GENERATION_API_FORMATS.has(model.api_format) + ) { + return false; + } + if (excludesChatAndResponsesEndpoints(model)) return false; + if (excludesTextOutputModality(model)) return false; + + return true; +} diff --git a/src/app/api/v1/vscode/raw/[token]/api/show/route.ts b/src/app/api/v1/vscode/raw/[token]/api/show/route.ts index 5c43ab18a4..c6fe788f9c 100644 --- a/src/app/api/v1/vscode/raw/[token]/api/show/route.ts +++ b/src/app/api/v1/vscode/raw/[token]/api/show/route.ts @@ -14,6 +14,7 @@ import { import { parseVscodeServiceTierVariantModelId } from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants"; import { getFamilyFirstModelCandidates } from "@/app/api/v1/vscode/raw/[token]/familyFirstModelIds"; import { withPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest"; +import { isUsableChatModel } from "@/app/api/v1/vscode/[token]/usableChatModel"; type OpenAiCatalogModel = { id?: string; @@ -31,35 +32,6 @@ type OpenAiCatalogModel = { supported_endpoints?: string[]; }; -function isUsableChatModel(model: OpenAiCatalogModel) { - if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { - return false; - } - if (typeof model.parent === "string" && model.parent.length > 0) return false; - if (typeof model.type === "string" && model.type !== "chat") return false; - - const apiFormat = typeof model.api_format === "string" ? model.api_format : "chat-completions"; - if (apiFormat !== "chat-completions") return false; - - if ( - Array.isArray(model.supported_endpoints) && - model.supported_endpoints.length > 0 && - !model.supported_endpoints.includes("chat") - ) { - return false; - } - - if ( - Array.isArray(model.output_modalities) && - model.output_modalities.length > 0 && - !model.output_modalities.includes("text") - ) { - return false; - } - - return true; -} - function getCatalogModelId(model: OpenAiCatalogModel) { return model.id || model.name || model.root || "unknown"; } diff --git a/src/app/api/v1/vscode/raw/[token]/api/tags/route.ts b/src/app/api/v1/vscode/raw/[token]/api/tags/route.ts index 830cab45da..08cadb8636 100644 --- a/src/app/api/v1/vscode/raw/[token]/api/tags/route.ts +++ b/src/app/api/v1/vscode/raw/[token]/api/tags/route.ts @@ -14,6 +14,7 @@ import { } from "@/app/api/v1/vscode/raw/[token]/reasoningMetadata"; import { parseVscodeServiceTierVariantModelId } from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants"; import { withPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest"; +import { isUsableChatModel } from "@/app/api/v1/vscode/[token]/usableChatModel"; type OpenAiCatalogModel = { id?: string; @@ -59,35 +60,6 @@ async function selectPreferredModels(models: OpenAiCatalogModel[]) { return codexModels.length > 0 ? codexModels : models; } -function isUsableChatModel(model: OpenAiCatalogModel) { - if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { - return false; - } - if (typeof model.parent === "string" && model.parent.length > 0) return false; - if (typeof model.type === "string" && model.type !== "chat") return false; - - const apiFormat = typeof model.api_format === "string" ? model.api_format : "chat-completions"; - if (apiFormat !== "chat-completions") return false; - - if ( - Array.isArray(model.supported_endpoints) && - model.supported_endpoints.length > 0 && - !model.supported_endpoints.includes("chat") - ) { - return false; - } - - if ( - Array.isArray(model.output_modalities) && - model.output_modalities.length > 0 && - !model.output_modalities.includes("text") - ) { - return false; - } - - return true; -} - function getOllamaModelFamily(model: OpenAiCatalogModel, canonicalFamily?: string | null) { const rawModelId = getModelName(model).trim(); const tierParsedModel = parseVscodeServiceTierVariantModelId(rawModelId); diff --git a/src/app/api/v1/web/fetch/route.ts b/src/app/api/v1/web/fetch/route.ts index 1fbfa0be5a..f7c49f8da1 100644 --- a/src/app/api/v1/web/fetch/route.ts +++ b/src/app/api/v1/web/fetch/route.ts @@ -6,11 +6,21 @@ * * Request: { url, provider?, format?, depth?, wait_for_selector?, include_metadata? } * Response: { provider, url, content, links, metadata, screenshot_url } + * + * Quota-aware fallback (#8297): when no explicit provider is requested, the + * pool is walked in fixed priority order (fill-first) — a rate-limited or + * quota-exhausted provider is skipped instead of short-circuiting the whole + * request. When an explicit provider is requested, no silent fallback is + * performed — a rate-limited/failing explicit provider surfaces its own error. */ -import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; +import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; -import { handleWebFetch } from "@omniroute/open-sse/handlers/webFetch.ts"; +import { + handleWebFetch, + type WebFetchCredentials, + type WebFetchResult, +} from "@omniroute/open-sse/handlers/webFetch.ts"; import * as log from "@/sse/utils/logger"; import { extractApiKey, @@ -21,6 +31,11 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags"; import { v1WebFetchSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { + isAllRateLimitedCredentials, + rateLimitedProviderResponse, + type RateLimitedCredentials, +} from "@/app/api/v1/_shared/rateLimit"; const CORS_HEADERS = { "Access-Control-Allow-Methods": "POST, OPTIONS", @@ -30,25 +45,192 @@ const CORS_HEADERS = { const WEB_FETCH_PROVIDERS = ["firecrawl", "jina-reader", "tavily-search", "tinyfish"] as const; type WebFetchProviderId = (typeof WEB_FETCH_PROVIDERS)[number]; +// Providers whose free/low tiers surface quota exhaustion as 402/403 instead +// of (or in addition to) 429. jina-reader has no such quota-status signal — +// a 402/403 there is a real auth/bad-request failure, not exhaustion. +const QUOTA_STATUS_PROVIDERS = new Set([ + "firecrawl", + "tavily-search", + "tinyfish", +]); + +type CredentialsLookup = WebFetchCredentials | RateLimitedCredentials | null; + export async function OPTIONS() { return new Response(null, { headers: CORS_HEADERS }); } /** - * Resolve credentials for a web-fetch provider. Tries each known provider in - * priority order when no explicit provider is requested. + * Resolve credentials for a web-fetch provider (may be a rate-limited stub, + * real credentials, or null when unconfigured). */ -async function resolveCredentials( - providerId: WebFetchProviderId -): Promise<{ apiKey?: string } | null> { +async function resolveCredentials(providerId: WebFetchProviderId): Promise { try { const creds = await getProviderCredentialsWithQuotaPreflight(providerId); - return creds ?? null; + return (creds as CredentialsLookup) ?? null; } catch { return null; } } +/** A request-time upstream status that means "try the next provider" instead of giving up. */ +function isRetryableWebFetchStatus(providerId: WebFetchProviderId, status?: number): boolean { + if (status === HTTP_STATUS.RATE_LIMITED) return true; + if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.FORBIDDEN) { + return QUOTA_STATUS_PROVIDERS.has(providerId); + } + return false; +} + +/** Find the next untried, non-rate-limited, credentialed provider in pool order. */ +async function findNextFallbackProvider( + tried: Set +): Promise<{ providerId: WebFetchProviderId; credentials: WebFetchCredentials } | null> { + for (const pid of WEB_FETCH_PROVIDERS) { + if (tried.has(pid)) continue; + const creds = await resolveCredentials(pid); + tried.add(pid); + if (creds && !isAllRateLimitedCredentials(creds)) { + return { providerId: pid, credentials: creds }; + } + } + return null; +} + +interface WebFetchExecutionInput { + url: string; + format: "markdown" | "html" | "links" | "screenshot"; + depth: 0 | 1 | 2; + wait_for_selector?: string; + include_metadata?: boolean; +} + +interface WebFetchExecutionResult { + result: WebFetchResult; + provider: WebFetchProviderId; + poolExhausted: boolean; +} + +/** + * Execute the web-fetch request. When `allowFallback` is true (auto-select), + * a retryable/quota upstream failure walks the remaining pool in order + * before giving up. Explicit-provider requests never fall back. + */ +async function executeWithFallback( + reqBody: WebFetchExecutionInput, + startProvider: WebFetchProviderId, + startCredentials: WebFetchCredentials, + allowFallback: boolean, + triedProviders: Set +): Promise { + let provider = startProvider; + let credentials = startCredentials; + let result = await handleWebFetch(reqBody, credentials, provider); + + if (!allowFallback) { + return { result, provider, poolExhausted: false }; + } + + while (!result.success && isRetryableWebFetchStatus(provider, result.status)) { + const next = await findNextFallbackProvider(triedProviders); + if (!next) { + return { result, provider, poolExhausted: true }; + } + provider = next.providerId; + credentials = next.credentials; + result = await handleWebFetch(reqBody, credentials, provider); + } + + return { result, provider, poolExhausted: false }; +} + +type ResolvedWebFetchTarget = + | { + ok: true; + provider: WebFetchProviderId; + credentials: WebFetchCredentials; + tried: Set; + isExplicit: boolean; + } + | { ok: false; response: Response }; + +/** Resolve credentials for an explicitly requested provider (no fallback allowed). */ +async function resolveExplicitTarget( + providerId: WebFetchProviderId +): Promise { + const creds = await resolveCredentials(providerId); + if (isAllRateLimitedCredentials(creds)) { + return { ok: false, response: rateLimitedProviderResponse(providerId, creds) }; + } + if (!creds) { + return { + ok: false, + response: errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials configured for web-fetch provider: ${providerId}. ` + + `Add an API key for "${providerId}" in the dashboard.` + ), + }; + } + return { + ok: true, + provider: providerId, + credentials: creds, + tried: new Set([providerId]), + isExplicit: true, + }; +} + +/** + * Auto-select: walk the pool in fixed priority order (fill-first), skipping + * rate-limited stubs instead of letting them short-circuit the loop (#8297). + */ +async function resolveAutoSelectTarget(): Promise { + let firstRateLimited: { + providerId: WebFetchProviderId; + credentials: RateLimitedCredentials; + } | null = null; + + for (const pid of WEB_FETCH_PROVIDERS) { + const creds = await resolveCredentials(pid); + if (isAllRateLimitedCredentials(creds)) { + firstRateLimited ??= { providerId: pid, credentials: creds }; + continue; + } + if (creds) { + return { ok: true, provider: pid, credentials: creds, tried: new Set([pid]), isExplicit: false }; + } + } + + if (firstRateLimited) { + return { + ok: false, + response: rateLimitedProviderResponse( + firstRateLimited.providerId, + firstRateLimited.credentials + ), + }; + } + return { + ok: false, + response: errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials configured for any web-fetch provider. ` + + `Add an API key for one of: ${WEB_FETCH_PROVIDERS.join(", ")}.` + ), + }; +} + +/** Resolve the provider + credentials to use for this request (explicit or auto-select). */ +async function resolveWebFetchTarget( + requestedProvider: string | undefined +): Promise { + if (requestedProvider) { + return resolveExplicitTarget(requestedProvider as WebFetchProviderId); + } + return resolveAutoSelectTarget(); +} + export async function POST(request: Request) { let rawBody: unknown; try { @@ -79,43 +261,13 @@ export async function POST(request: Request) { const policy = await enforceApiKeyPolicy(request, "web-fetch"); if (policy.rejection) return policy.rejection; - // Resolve provider + credentials - let resolvedProvider: WebFetchProviderId | undefined; - let credentials: { apiKey?: string } = {}; + // Resolve provider + credentials (explicit provider never falls back; #8297) + const target = await resolveWebFetchTarget(body.provider); + if (!target.ok) return target.response; - if (body.provider) { - resolvedProvider = body.provider as WebFetchProviderId; - const creds = await resolveCredentials(resolvedProvider); - if (!creds) { - return errorResponse( - HTTP_STATUS.BAD_REQUEST, - `No credentials configured for web-fetch provider: ${resolvedProvider}. ` + - `Add an API key for "${resolvedProvider}" in the dashboard.` - ); - } - credentials = creds; - } else { - // Auto-select: try providers in priority order - for (const pid of WEB_FETCH_PROVIDERS) { - const creds = await resolveCredentials(pid); - if (creds) { - resolvedProvider = pid; - credentials = creds; - break; - } - } - if (!resolvedProvider) { - return errorResponse( - HTTP_STATUS.BAD_REQUEST, - `No credentials configured for any web-fetch provider. ` + - `Add an API key for one of: ${WEB_FETCH_PROVIDERS.join(", ")}.` - ); - } - } + log.info("WEB_FETCH", `${target.provider} | ${body.url} | format=${body.format}`); - log.info("WEB_FETCH", `${resolvedProvider} | ${body.url} | format=${body.format}`); - - const result = await handleWebFetch( + const { result, provider: finalProvider, poolExhausted } = await executeWithFallback( { url: body.url, format: body.format, @@ -123,10 +275,19 @@ export async function POST(request: Request) { wait_for_selector: body.wait_for_selector, include_metadata: body.include_metadata, }, - credentials, - resolvedProvider + target.provider, + target.credentials, + !target.isExplicit, + target.tried ); + if (poolExhausted) { + return unavailableResponse( + HTTP_STATUS.RATE_LIMITED, + "All configured web-fetch providers are rate limited or quota-exhausted" + ); + } + if (!result.success) { return new Response( JSON.stringify({ @@ -139,6 +300,10 @@ export async function POST(request: Request) { ); } + if (finalProvider !== target.provider) { + log.info("WEB_FETCH", `Fell back from ${target.provider} to ${finalProvider}`); + } + return new Response(JSON.stringify(result.data), { status: 200, headers: { "Content-Type": "application/json", ...CORS_HEADERS }, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index fa9d5df7c5..2995b8f688 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2905,6 +2905,28 @@ "hermesRoleSkillsHubDesc": "Skills and tool-use reasoning", "hermesRoleApproval": "Approval", "hermesRoleApprovalDesc": "Safety and approval decisions", + "hermesRoleMcp": "MCP", + "hermesRoleMcpDesc": "MCP server tool calls", + "hermesRoleTitleGeneration": "Title Generation", + "hermesRoleTitleGenerationDesc": "Session title generation", + "hermesRoleMemoryQueryRewrite": "Memory Query Rewrite", + "hermesRoleMemoryQueryRewriteDesc": "Memory search query rewriting", + "hermesRoleTtsAudioTags": "TTS Audio Tags", + "hermesRoleTtsAudioTagsDesc": "TTS audio tag generation", + "hermesRoleTriageSpecifier": "Triage Specifier", + "hermesRoleTriageSpecifierDesc": "Issue and PR triage specification", + "hermesRoleKanbanDecomposer": "Kanban Decomposer", + "hermesRoleKanbanDecomposerDesc": "Kanban task decomposition", + "hermesRoleProfileDescriber": "Profile Describer", + "hermesRoleProfileDescriberDesc": "User profile description", + "hermesRoleGoalJudge": "Goal Judge", + "hermesRoleGoalJudgeDesc": "Goal completion judging", + "hermesRoleCurator": "Curator", + "hermesRoleCuratorDesc": "Skill and memory curation", + "hermesRoleMonitor": "Monitor", + "hermesRoleMonitorDesc": "Background monitoring", + "hermesRoleBackgroundReview": "Background Review", + "hermesRoleBackgroundReviewDesc": "Background code review", "hermesSelectBeforePreview": "Select models for roles, or ensure the roles are loaded, before previewing.", "hermesPreviewFailed": "Failed to generate preview", "hermesSavedTo": "Saved to {path}", @@ -7458,9 +7480,9 @@ "resilienceEnableServerWaitDesc": "When enabled, OmniRoute waits for the first cooldown to expire and retries automatically.", "resilienceMaxAttempts": "Maximum attempts", "resilienceMaxWaitPerAttempt": "Maximum wait per attempt", - "resilienceComboCooldownWaitTitle": "Quota-share combo cooldown wait", - "resilienceComboCooldownWaitDesc": "For quota-share combos only: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.", - "resilienceComboCooldownWaitToggleDesc": "Quota-share combos only; never waits on quota_exhausted.", + "resilienceComboCooldownWaitTitle": "Combo cooldown wait", + "resilienceComboCooldownWaitDesc": "For all combo strategies: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.", + "resilienceComboCooldownWaitToggleDesc": "All combo strategies; never waits on quota_exhausted.", "resilienceComboCooldownMaxWaitMs": "Maximum wait per attempt", "resilienceComboCooldownBudgetMs": "Total wait budget", "resilienceQuotaShareConcurrencyTitle": "Quota-share per-connection concurrency", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 2a071d258c..c41a7626e0 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -2905,6 +2905,28 @@ "hermesRoleSkillsHubDesc": "Wnioskowanie o umiejętnościach i użyciu narzędzi", "hermesRoleApproval": "Zatwierdzanie", "hermesRoleApprovalDesc": "Decyzje dotyczące bezpieczeństwa i zatwierdzania", + "hermesRoleMcp": "MCP", + "hermesRoleMcpDesc": "Wywołania narzędzi serwera MCP", + "hermesRoleTitleGeneration": "Generowanie tytułów", + "hermesRoleTitleGenerationDesc": "Generowanie tytułów sesji", + "hermesRoleMemoryQueryRewrite": "Przepisywanie zapytań pamięci", + "hermesRoleMemoryQueryRewriteDesc": "Przepisywanie zapytań wyszukiwania pamięci", + "hermesRoleTtsAudioTags": "Tagi audio TTS", + "hermesRoleTtsAudioTagsDesc": "Generowanie tagów audio TTS", + "hermesRoleTriageSpecifier": "Specyfikacja triage", + "hermesRoleTriageSpecifierDesc": "Specyfikacja triage issue i PR", + "hermesRoleKanbanDecomposer": "Dekompozycja kanban", + "hermesRoleKanbanDecomposerDesc": "Dekompozycja zadań kanban", + "hermesRoleProfileDescriber": "Opis profilu", + "hermesRoleProfileDescriberDesc": "Opis profilu użytkownika", + "hermesRoleGoalJudge": "Ocena celów", + "hermesRoleGoalJudgeDesc": "Ocena realizacji celów", + "hermesRoleCurator": "Kurator", + "hermesRoleCuratorDesc": "Kuracja umiejętności i pamięci", + "hermesRoleMonitor": "Monitor", + "hermesRoleMonitorDesc": "Monitorowanie w tle", + "hermesRoleBackgroundReview": "Przegląd w tle", + "hermesRoleBackgroundReviewDesc": "Przegląd kodu w tle", "hermesSelectBeforePreview": "Wybierz modele dla ról lub upewnij się, że role są załadowane przed wyświetleniem podglądu.", "hermesPreviewFailed": "Nie udało się wygenerować podglądu", "hermesSavedTo": "Zapisano w {path}", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 5052681ccb..5bc621514b 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -2905,6 +2905,28 @@ "hermesRoleSkillsHubDesc": "Raciocínio de habilidades e uso de ferramentas", "hermesRoleApproval": "Aprovação", "hermesRoleApprovalDesc": "Decisões de segurança e aprovação", + "hermesRoleMcp": "MCP", + "hermesRoleMcpDesc": "Chamadas de ferramentas de servidores MCP", + "hermesRoleTitleGeneration": "Geração de Título", + "hermesRoleTitleGenerationDesc": "Geração do título da sessão", + "hermesRoleMemoryQueryRewrite": "Reescrita de Consulta de Memória", + "hermesRoleMemoryQueryRewriteDesc": "Reescrita das consultas de busca na memória", + "hermesRoleTtsAudioTags": "Tags de Áudio TTS", + "hermesRoleTtsAudioTagsDesc": "Geração de tags de áudio para TTS", + "hermesRoleTriageSpecifier": "Especificador de Triagem", + "hermesRoleTriageSpecifierDesc": "Especificação de triagem de issues e PRs", + "hermesRoleKanbanDecomposer": "Decompositor de Kanban", + "hermesRoleKanbanDecomposerDesc": "Decomposição de tarefas no Kanban", + "hermesRoleProfileDescriber": "Descritor de Perfil", + "hermesRoleProfileDescriberDesc": "Descrição do perfil do usuário", + "hermesRoleGoalJudge": "Juiz de Objetivos", + "hermesRoleGoalJudgeDesc": "Julgamento da conclusão de objetivos", + "hermesRoleCurator": "Curador", + "hermesRoleCuratorDesc": "Curadoria de habilidades e memória", + "hermesRoleMonitor": "Monitor", + "hermesRoleMonitorDesc": "Monitoramento em segundo plano", + "hermesRoleBackgroundReview": "Revisão em Segundo Plano", + "hermesRoleBackgroundReviewDesc": "Revisão de código em segundo plano", "hermesSelectBeforePreview": "Selecione os modelos para as funções, ou certifique-se de que as funções estejam carregadas, antes de visualizar.", "hermesPreviewFailed": "Falha ao gerar visualização", "hermesSavedTo": "Salvo em {path}", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index fdfc43ceff..774318ddcb 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -2905,6 +2905,28 @@ "hermesRoleSkillsHubDesc": "Suy luận về kỹ năng và cách dùng công cụ", "hermesRoleApproval": "Phê duyệt", "hermesRoleApprovalDesc": "Quyết định về an toàn và phê duyệt", + "hermesRoleMcp": "MCP", + "hermesRoleMcpDesc": "Lệnh gọi công cụ của máy chủ MCP", + "hermesRoleTitleGeneration": "Tạo tiêu đề", + "hermesRoleTitleGenerationDesc": "Tạo tiêu đề cho phiên làm việc", + "hermesRoleMemoryQueryRewrite": "Viết lại truy vấn bộ nhớ", + "hermesRoleMemoryQueryRewriteDesc": "Viết lại truy vấn tìm kiếm trong bộ nhớ", + "hermesRoleTtsAudioTags": "Thẻ âm thanh TTS", + "hermesRoleTtsAudioTagsDesc": "Tạo thẻ âm thanh cho TTS", + "hermesRoleTriageSpecifier": "Bộ đặc tả phân loại", + "hermesRoleTriageSpecifierDesc": "Đặc tả phân loại issue và PR", + "hermesRoleKanbanDecomposer": "Bộ phân rã Kanban", + "hermesRoleKanbanDecomposerDesc": "Phân rã công việc trên Kanban", + "hermesRoleProfileDescriber": "Bộ mô tả hồ sơ", + "hermesRoleProfileDescriberDesc": "Mô tả hồ sơ người dùng", + "hermesRoleGoalJudge": "Bộ đánh giá mục tiêu", + "hermesRoleGoalJudgeDesc": "Đánh giá mức độ hoàn thành mục tiêu", + "hermesRoleCurator": "Bộ tuyển chọn", + "hermesRoleCuratorDesc": "Tuyển chọn kỹ năng và bộ nhớ", + "hermesRoleMonitor": "Giám sát", + "hermesRoleMonitorDesc": "Giám sát chạy nền", + "hermesRoleBackgroundReview": "Đánh giá chạy nền", + "hermesRoleBackgroundReviewDesc": "Đánh giá mã nguồn chạy nền", "hermesSelectBeforePreview": "Hãy chọn mô hình cho các vai trò hoặc bảo đảm vai trò đã được tải trước khi xem trước.", "hermesPreviewFailed": "Không thể tạo bản xem trước", "hermesSavedTo": "Đã lưu vào {path}", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index e5605ff3de..f9bc6b3c0e 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -539,7 +539,7 @@ "mcpQuickStartTitle": "MCP 快速开始", "comboUpdated": "Combo 已更新", "weighted": "加权", - "providers": "Provider", + "providers": "供应商", "ccCompatibleLabel": "CC 兼容", "noFallbackChainsDesc": "创建一条链路,用于定义某个模型的提供者回退顺序。", "yesImport": "确认导入", @@ -583,7 +583,7 @@ "cacheCleared": "缓存已清除", "searchTypeNews": "搜索类型:News", "durationMillisecondsShort": "时长毫秒短标签", - "addOpenAICompatible": "添加打开Ai兼容", + "addOpenAICompatible": "添加OpenAI 兼容", "chatTesterTab": "聊天测试标签页", "queued": "排队中", "domainPlaceholder": "域名占位符", @@ -1444,15 +1444,15 @@ "auth.login.success": "登录成功", "auth.logout.success": "注销成功", "compliance.cleanup": "合规清理", - "provider.credentials.applied": "提供者凭据已应用", - "provider.credentials.batch_revoked": "提供者凭据批次已撤销", - "provider.credentials.bulk_created": "提供者凭据批量创建", - "provider.credentials.bulk_imported": "提供者凭据已批量导入", - "provider.credentials.created": "提供者凭据已创建", - "provider.credentials.imported": "提供者凭据已导入", - "provider.credentials.revoked": "提供者凭据已被撤销", - "provider.credentials.updated": "提供者凭据已更新", - "provider.validation.ssrf_blocked": "提供者 SSRF 被阻止", + "provider.credentials.applied": "供应商凭据已应用", + "provider.credentials.batch_revoked": "供应商凭据批次已撤销", + "provider.credentials.bulk_created": "供应商凭据批量创建", + "provider.credentials.bulk_imported": "供应商凭据已批量导入", + "provider.credentials.created": "供应商凭据已创建", + "provider.credentials.imported": "供应商凭据已导入", + "provider.credentials.revoked": "供应商凭据已被撤销", + "provider.credentials.updated": "供应商凭据已更新", + "provider.validation.ssrf_blocked": "供应商 SSRF 被阻止", "quota.plan.updated": "配额计划已更新", "quota.pool.created": "配额池已创建", "quota.pool.deleted": "配额池已删除", @@ -2219,7 +2219,7 @@ "emptyFilterTitle": "没有密钥匹配当前筛选条件", "emptyFilterClear": "清除筛选", "disableNonPublicModels": "禁用非公开模型", - "disableNonPublicModelsDesc": "拒绝对未发现或未标记为公共的模型在提供者目录中的请求", + "disableNonPublicModelsDesc": "拒绝对未发现或未标记为公共的模型在供应商目录中的请求", "normalKeysSection": "普通键", "quotaKeysSection": "配额密钥", "bypassProviderQuota": "绕过服务商配额限制", @@ -2871,7 +2871,7 @@ "visibleToolsCount": "{count} 个工具可用", "customCliBuilderTitle": "兼容 OpenAI 的 CLI 构建器", "customCliBuilderDescription": "为任何接受 OpenAI 兼容的基本 URL、API 密钥和模型 ID 的 CLI 或 SDK 生成环境变量和 JSON 片段。", - "customCliNoModels": "连接至少一个提供者以填充模型选择器。", + "customCliNoModels": "连接至少一个供应商以填充模型选择器。", "customCliNameLabel": "CLI 名称", "customCliNamePlaceholder": "例如我的团队 CLI", "customCliDefaultModelLabel": "默认型号", @@ -2886,7 +2886,7 @@ "customCliEndpointHintLabel": "如何连接端点", "customCliEndpointHint": "将任何 OpenAI 兼容客户端指向 OmniRoute /v1 基本 URL。原始聊天完成端点是 {endpoint}。当工具需要提供程序对象时使用 JSON 块,或者在读取 OPENAI_* 变量时使用 env 脚本。", "customCliEnvBlockTitle": "环境 / shell 片段", - "customCliJsonBlockTitle": "提供者 JSON 块", + "customCliJsonBlockTitle": "供应商 JSON 块", "networkError": "网络错误", "other": "其他", "preview": "预览", @@ -4086,15 +4086,15 @@ }, "embedding": { "autoLabel": "自动", - "autoDesc": "使用最佳可用选项:远程提供者 > 静态 > 转换器", - "remoteLabel": "远程提供者", - "remoteDesc": "通过提供者 API 使用嵌入(需要 API 密钥)", + "autoDesc": "使用最佳可用选项:远程供应商 > 静态 > 转换器", + "remoteLabel": "远程供应商", + "remoteDesc": "通过供应商 API 使用嵌入(需要 API 密钥)", "staticLabel": "静态本地(药水)", "staticDesc": "不使用WASM或外部依赖的本地嵌入", "transformersLabel": "Transformers.js (MiniLM)", "transformersDesc": "通过 @huggingface/transformers 本地嵌入 (~400MB RAM)", - "providerModelLabel": "提供者 / 模型", - "noRemoteProviders": "没有配置 API 密钥的提供者", + "providerModelLabel": "供应商 / 模型", + "noRemoteProviders": "没有配置 API 密钥的供应商", "selectProviderModel": "选择一个模型", "staticEnabledLabel": "启用静态药水", "staticEnabledDesc": "在本地下载并使用 potion-base-8M 模型", @@ -4208,9 +4208,9 @@ "enableLabel": "启用重新排序", "enableDesc": "在搜索后使用重新排序模型对结果进行重新排序", "warning": "Rerank 会增加 +200-500ms 的延迟和每个请求的额外成本。请谨慎使用。", - "providerModelLabel": "重新排序提供者 / 模型", - "noProviderWithKey": "没有配置 API 密钥的提供者。请配置一个提供者以使用 rerank。", - "selectProviderModel": "选择一个提供者/模型" + "providerModelLabel": "重新排序供应商 / 模型", + "noProviderWithKey": "没有配置 API 密钥的供应商。请配置一个供应商以使用 rerank。", + "selectProviderModel": "选择一个供应商/模型" }, "save": "保存", "saving": "保存中...", @@ -5228,18 +5228,18 @@ "applyClaudeAuthLocal": "申请授权", "exportClaudeAuthFile": "导出授权", "importClaudeAuth": "导入授权", - "claudeApplyModalTitle": "适用于当地克劳德代码", + "claudeApplyModalTitle": "适用于当地Claude Code", "claudeApplyTargetLabel": "目标路径", "claudeApplyBackupLabel": "备份", "claudeApplyMcpHint": "现有的 MCP OAuth 状态将被保留。", "claudeApplyWarning": "这将取代现有的 claudeAiOauth 部分。继续?", "claudeApplyConfirmCheckbox": "我确认我想替换现有的 claudeAiOauth 部分", "claudeApply": "申请", - "claudeAuthAppliedLocal": "克劳德授权在本地应用", + "claudeAuthAppliedLocal": "Claude授权在本地应用", "claudeAuthApplyFailed": "本地申请Claude auth失败", - "claudeAuthExported": "克劳德授权文件导出", + "claudeAuthExported": "Claude授权文件导出", "claudeAuthExportFailed": "无法导出 Claude 身份验证文件", - "claudeImportModalTitle": "导入克劳德·奥特", + "claudeImportModalTitle": "导入Claude·奥特", "claudeImportTabSingle": "单人", "claudeImportTabBulk": "散装", "claudeImportTabUpload": "上传文件", @@ -5250,12 +5250,12 @@ "claudeImportNameLabel": "连接名称(可选)", "claudeImportOverwriteLabel": "如果帐户已存在,则替换现有连接", "claudeImportSubmit": "进口", - "claudeImportSuccess": "克劳德连接导入成功", + "claudeImportSuccess": "Claude连接导入成功", "claudeImportInvalidJson": "无法将文件解析为 JSON", "claudeImportInvalidShape": "该文件不是有效的 .credentials.json", "claudeImportDuplicate": "帐户已存在 - 启用“替换现有”以覆盖", "claudeImportIdentityUnverified": "Bootstrap 无法验证该帐户。启用“替换现有”或提供电子邮件。", - "claudeImportFailed": "导入克劳德授权失败", + "claudeImportFailed": "导入Claude授权失败", "claudeImportBulkModeUpload": "上传文件", "claudeImportBulkModePaste": "粘贴 JSON 数组", "claudeImportBulkModeZip": "上传ZIP", @@ -5618,7 +5618,7 @@ "providerDetailCallbackUrl": "回调 URL", "providerDetailValidClaudeCredentialsFile": "有效的 Claude 凭据文件", "providerDetailPathAutoDetectedAllOs": "路径按操作系统自动检测(Linux/Mac/Windows)。", - "providerDetailMyClaudeAccountPlaceholder": "我的克劳德账户", + "providerDetailMyClaudeAccountPlaceholder": "我的Claude 账户", "providerDetailPathAutoDetected": "根据操作系统 (Linux/Mac) 自动检测路径。", "compatBlockedParamsPlaceholder": "thinking, … (逗号分隔)", "compatAllowedParamsPlaceholder": "reasoning, … (逗号分隔)", @@ -5670,7 +5670,7 @@ "onboardingSearchProviders": "搜索提供者...", "onboardingApiKeyOptional": "API 密钥可选", "onboardingProviderConnected": "提供者已连接", - "onboardingProviderSavedWithWarnings": "提供者保存时带有警告", + "onboardingProviderSavedWithWarnings": "供应商保存时带有警告", "onboardingProviderFinished": "提供者入职完成", "onboardingYourProviderConnection": "您的提供者连接", "onboardingTestPassed": "测试通过", @@ -5682,12 +5682,12 @@ "onboardingValidatingCredentials": "正在验证凭据...", "onboardingSavingConnection": "正在保存提供者连接...", "onboardingProviderFailed": "提供者加入失败", - "onboardingCreatingCompatibleProvider": "创建兼容的提供者...", + "onboardingCreatingCompatibleProvider": "创建兼容的供应商...", "onboardingSavingCompatibleConnection": "正在保存兼容的提供者连接...", "onboardingCustomProviderFallbackName": "定制提供者", "onboardingCustomProviderFailed": "自定义提供者加入失败", "onboardingLoadingOAuthConnection": "正在加载 OAuth 连接...", - "onboardingOAuthNoConnectionFound": "OAuth 已完成,但未找到提供者连接。", + "onboardingOAuthNoConnectionFound": "OAuth 已完成,但未找到供应商连接。", "onboardingOAuthFailed": "OAuth 登录失败", "onboardingAddProvider": "添加 {provider}", "onboardingConnectionName": "连接名称", @@ -5706,7 +5706,7 @@ "onboardingProtocol": "协议", "onboardingOpenAiCompatible": "兼容 OpenAI", "onboardingAnthropicCompatible": "人类兼容", - "onboardingClaudeCodeCompatible": "克劳德代码兼容", + "onboardingClaudeCodeCompatible": "Claude Code兼容", "onboardingProviderPrefix": "提供者前缀", "onboardingProviderPrefixHint": "用于生成托管提供者 ID。", "onboardingChatPath": "聊天路径", @@ -5916,9 +5916,9 @@ "codex": "使用现有的 OAuth 流程连接 OpenAI Codex。", "qwen": "使用现有的 OAuth 流程连接 Qwen Code。" }, - "passthroughModelsDescription": "{provider} 接受提供者本机模型 ID。从 /models 导入或添加用于路由的自定义 ID。", + "passthroughModelsDescription": "{provider} 接受供应商本机模型 ID。从 /models 导入或添加用于路由的自定义 ID。", "bedrockModelsDescription": "Amazon Bedrock 模型的范围按 AWS 区域划分。从 /models 导入或添加在所选区域中启用的基岩模型 ID。", - "bedrockModelPlaceholder": "人类.克劳德十四行诗-4-6", + "bedrockModelPlaceholder": "anthropic.Claudesonnet-4-6", "addProviderSessionCookieTitle": "添加 {provider} 会话 cookie", "openWebProviderSite": "打开 {host}", "addProviderWebTokenTitle": "添加 {provider} 网络令牌", @@ -7283,7 +7283,7 @@ "memorySkillsSkillsmpMarketplace": "SkillsMP 市场", "memorySkillsFailedToSave": "保存失败", "memorySkillsApiKey": "API密钥", - "memorySkillsActiveSkillsProvider": "主动技能提供者", + "memorySkillsActiveSkillsProvider": "主动技能供应商", "cliproxyapiFallback": "CLIProxyAPI 后备", "cliproxyapiEnableFallback": "启用 CLIProxyAPI 回退", "cliproxyapiUrl": "CLIProxyAPI URL", @@ -7357,12 +7357,12 @@ "codexFastTierModelsLabel": "快速层模型", "codexFastTierModelsHint": "启用快速层后,只有勾选的模型会附带 service_tier。", "codexFastTierModelCheckbox": "为 {model} 启用快速层", - "claudeFastModeTitle": "克劳德快速模式", - "claudeFastModeDesc": "选择选定的克劳德请求进入人择快速模式(速度:“快速”)。", + "claudeFastModeTitle": "Claude快速模式", + "claudeFastModeDesc": "选择选定的Claude请求进入Anthropic快速模式(速度:“快速”)。", "claudeFastModeHint": "Anthropic 并未正式支持 SDK 样式客户端的快速模式。启用后,OmniRoute 会转发 X-CPA-Force-Fast-Mode 标头,以便配对的 CLIProxyAPI 构建可以选择欺骗入口点。只有列出的 Opus 模型才会受到 Anthropic 客户端检查的控制。订阅层、最大计划和快速模式信用余额仍然在服务器端强制执行 - 即使打开切换,Anthropic 也可能返回 out_of_credits。", "claudeFastModeModelsLabel": "应用于模型 ({count})", "claudeFastModeModelCheckbox": "为 {model} 启用快速模式", - "claudeFastModeSaveError": "无法更新克劳德快速模式设置", + "claudeFastModeSaveError": "无法更新Claude快速模式设置", "authz": { "cors": { "wildcard": { @@ -7453,7 +7453,7 @@ "resilienceWaitForCooldownScope": "当前客户请求", "resilienceWaitForCooldownTrigger": "当所有候选连接已经冷却时", "resilienceWaitForCooldownEffect": "等待服务器并在第一个冷却时间到期时重试", - "resilienceWaitForCooldownDesc": "这仅影响当前请求。它不存储连接或提供者状态。", + "resilienceWaitForCooldownDesc": "这仅影响当前请求。它不存储连接或供应商状态。", "resilienceEnableServerWait": "启用服务器端等待", "resilienceEnableServerWaitDesc": "启用后,OmniRoute 会等待第一次冷却时间到期并自动重试。", "resilienceMaxAttempts": "最大尝试次数", @@ -8323,14 +8323,14 @@ "techniques": "技巧:", "friendlyTitle": "翻译器", "friendlySubtitle": "使用您现有的应用程序与任何提供者 — 无需重写代码。", - "conceptHeadline": "您的应用程序使用一个 API 的“语言”。翻译器将其转换为使用另一个提供者。", + "conceptHeadline": "您的应用程序使用一个 API 的“语言”。翻译器将其转换为使用另一个供应商。", "conceptDiagramAppLabel": "您的应用程序", "conceptDiagramSourceLabel": "源格式", "conceptDiagramHubLabel": "OpenAI (中心)", - "conceptDiagramTargetLabel": "目标提供者", + "conceptDiagramTargetLabel": "目标供应商", "conceptDiagramExampleApp": "例如 Anthropic SDK", "conceptDiagramExampleSource": "Claude", - "conceptDiagramExampleTarget": "双子座", + "conceptDiagramExampleTarget": "Gemini", "conceptHowItWorksToggle": "它是如何工作的", "conceptHowItWorksBody": "您的应用以其自己的格式发送请求。翻译器检测该格式,通过 OpenAI 作为中介中心进行转换(或在可用的情况下直接进行转换),将其发送到所选提供者,并将响应转换回您应用的格式。", "tabTranslate": "翻译", @@ -8340,7 +8340,7 @@ "simpleAppUsesLabel": "我的应用程序使用", "simpleAppUsesHint": "您的应用程序使用的 API 格式(例如,Anthropic SDK = claude)。", "simpleSendToLabel": "发送到", - "simpleSendToHint": "实际将请求发送到哪里(在 OmniRoute 中连接的提供者)。", + "simpleSendToHint": "实际将请求发送到哪里(在 OmniRoute 中连接的供应商)。", "simpleStartWithLabel": "开始于", "simpleStartWithExamplePlaceholder": "选择一个现成的示例", "simpleStartWithCustomOption": "粘贴您的请求(高级)", @@ -8379,17 +8379,17 @@ "pipelineStepFormatDetectedDesc": "自动检测到的源格式", "pipelineStepOpenAIIntermediate": "OpenAI 中级", "pipelineStepOpenAIIntermediateDesc": "翻译为 OpenAI hub 格式", - "pipelineStepProviderFormat": "提供者格式", - "pipelineStepProviderFormatDesc": "翻译为提供者目标格式", - "pipelineStepProviderResponse": "提供者响应", - "pipelineStepProviderResponseDesc": "来自提供者的流式响应", + "pipelineStepProviderFormat": "供应商格式", + "pipelineStepProviderFormatDesc": "翻译为供应商目标格式", + "pipelineStepProviderResponse": "供应商响应", + "pipelineStepProviderResponseDesc": "来自供应商的流式响应", "conceptDiagramArrow1": "说话", "conceptDiagramArrow2": "翻译", "conceptDiagramArrow3": "转换", "conceptDiagramExampleHub": "OpenAI", "conceptDiagramHubTooltip": "翻译器用于在没有直接映射的格式之间转换的中间枢纽。", "conceptDiagramSourceTooltip": "您的应用程序使用的 API 格式(例如,Anthropic SDK = claude)。", - "conceptDiagramTargetTooltip": "请求将实际发送到的提供者。", + "conceptDiagramTargetTooltip": "请求将实际发送到的供应商。", "compressionEmptyHint": "填写“翻译”标签页上的输入字段(简单控件或原始 JSON)以启用预览。", "compressionModeLabel": "压缩模式", "compressionPreviewButton": "预览压缩", @@ -10416,10 +10416,10 @@ "kpiAvgUtilization": "平均利用率", "kpiBorrowingNow": "现在借款", "conceptTitle": "配额分成是如何工作的", - "conceptIntro": "配额共享通过节约型公平分享将提供者的配额分配给多个 API 密钥:每个密钥获得一个按比例分配的份额,但可以在不超过全球上限的情况下从自由余额中借用。", + "conceptIntro": "配额共享通过节约型公平分享将供应商的配额分配给多个 API 密钥:每个密钥获得一个按比例分配的份额,但可以在不超过全球上限的情况下从自由余额中借用。", "conceptFairShare": "公平共享:每个键接收与其配置权重成比例的配额", "conceptBorrowing": "借用:密钥可以在不违反上限的情况下消耗他人的自由余额", - "conceptGlobalCap": "硬性全球上限:提供者的绝对限制永远不会被超越", + "conceptGlobalCap": "硬性全球上限:供应商的绝对限制永远不会被超越", "conceptWindows": "Windows: 5小时,按小时、按日、按周、按月 — 每个独立跟踪", "conceptKeyHowTitle": "为配额启用密钥", "conceptKeyHowDesc": "在 API 管理器中正常创建密钥 — 它会自动出现在向导的密钥步骤中。在那里勾选独占以使其仅限配额。没有单独的启用步骤。", @@ -10444,7 +10444,7 @@ "wizardStep2Label": "限制", "wizardStep3Label": "密钥", "wizardStep1Title": "选择提供者连接", - "wizardStep1Subtitle": "选择此池将共享配额的提供者帐户,设置名称和默认策略。", + "wizardStep1Subtitle": "选择此池将共享配额的供应商帐户,设置名称和默认策略。", "wizardStep2Title": "配置配额维度", "wizardStep2Subtitle": "为所选连接定义配额计划维度(单位、窗口、限制)。保持不变以保持当前设置。", "wizardStep3Title": "分配 API 密钥", @@ -10458,10 +10458,10 @@ "wizardExclusiveLabel": "独占配额", "wizardExclusiveHint": "启用后,这些 API 密钥将仅允许使用此池的虚拟模型(在保存时应用 allowedQuotas 对账)。", "wizardPreviewLabel": "虚拟模型名称预览", - "wizardConnectionsLabel": "提供者连接", + "wizardConnectionsLabel": "供应商连接", "wizardPrimaryBadge": "主要", "wizardAdditionalConnectionsNote": "附加连接使用其目录默认限制(稍后可编辑)。", - "wizardSingleProviderNote": "一个池使用单一提供者", + "wizardSingleProviderNote": "一个池使用单一供应商", "wizardPreviewMoreModels": "+{count} 更多", "accountQuotaTitle": "账户配额", "accountQuotaNone": "—", @@ -10543,7 +10543,7 @@ "quotaPlans": { "title": "计划与配额", "description": "为每个提供者配置配额计划 — 维度(%、请求、令牌、$)和时间窗口", - "providerLabel": "提供者 / 连接", + "providerLabel": "供应商 / 连接", "detectedPlanLabel": "检测到的计划", "manualPlanLabel": "手动覆盖", "unconfiguredLabel": "未配置 — 需要手动设置", @@ -10575,11 +10575,11 @@ "title": "活动", "description": "最近事件动态", "emptyTitle": "尚无活动", - "emptyDescription": "当您添加提供者、创建组合或旋转密钥时,事件将出现在这里。", + "emptyDescription": "当您添加供应商、创建组合或旋转密钥时,事件将出现在这里。", "todayHeader": "今天", "yesterdayHeader": "昨天", "filterAll": "全部", - "filterProviders": "提供者", + "filterProviders": "供应商", "filterCombos": "组合", "filterApiKeys": "API 密钥", "filterSettings": "设置", @@ -10600,9 +10600,9 @@ "daysAgo": "{n} 天前" }, "eventVerb": { - "providerAdded": "{actor} 添加了提供者 {target}", - "providerRemoved": "{actor} 移除了提供者 {target}", - "providerTested": "{actor} 测试了提供者 {target}", + "providerAdded": "{actor} 添加了供应商 {target}", + "providerRemoved": "{actor} 移除了供应商 {target}", + "providerTested": "{actor} 测试了供应商 {target}", "comboCreated": "{actor} 创建了组合 {target}", "comboUpdated": "{actor} 更新了组合 {target}", "comboDeleted": "{actor} 移除了组合 {target}", @@ -10713,8 +10713,8 @@ "toggling": "切换中…", "viewTraffic": "查看流量", "emptyNoProvidersTitle": "尚未配置提供程序", - "emptyNoProvidersBody": "要使用 AgentBridge,首先连接至少一个提供者。它将是 IDE 请求路由的目标。", - "emptyGoToProviders": "前往提供者", + "emptyNoProvidersBody": "要使用 AgentBridge,首先连接至少一个供应商。它将是 IDE 请求路由的目标。", + "emptyGoToProviders": "前往供应商", "wizardTitle": "设置向导", "wizardSubtitle": "3步设置", "wizardStep1Label": "验证", @@ -10894,7 +10894,7 @@ "sessionsDropdown": "会话", "annotationPlaceholder": "添加备注…", "contextFingerprint": "上下文指纹", - "llmProvider": "检测到的提供者", + "llmProvider": "检测到的供应商", "llmApiKind": "API 类型", "llmModel": "模型", "llmMessages": "消息", @@ -10974,13 +10974,13 @@ "code": { "title": "CLI 代码的", "phrase": "指向 OmniRoute 的代码工具", - "flow": "你 → CLI 代码 → OmniRoute → 提供者", + "flow": "你 → CLI 代码 → OmniRoute → 供应商", "seeOther": "查看 →" }, "agent": { "title": "CLI 代理", "phrase": "通用自主CLI代理,您可以指向OmniRoute", - "flow": "您 → CLI 代理 → OmniRoute → 提供者", + "flow": "您 → CLI 代理 → OmniRoute → 供应商", "seeOther": "查看 →" }, "acp": { @@ -10997,7 +10997,7 @@ "code": { "title": "代码工具", "desc": "指向 Omni", - "flow": "你 → CLI → Omni → 提供者", + "flow": "你 → CLI → Omni → 供应商", "examples": "例如:claude,codex" }, "agent": { @@ -11042,9 +11042,9 @@ "baseUrlLabel": "基础 URL", "apiKeyLabel": "API 密钥", "modelMappingLabel": "模型映射", - "noActiveProviders": "没有活动的提供者。", - "noActiveProvidersDesc": "请前往 Providers 连接至少 1 个提供者,然后再配置 CLI。", - "openProviders": "打开提供者 →" + "noActiveProviders": "没有活动的供应商。", + "noActiveProvidersDesc": "请前往 Providers 连接至少 1 个供应商,然后再配置 CLI。", + "openProviders": "打开供应商 →" } }, "cliCode": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 125a6207cf..c0f79acc90 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -29,7 +29,7 @@ "enabled": "啟用", "disabled": "已停用", "active": "啟用中", - "inactive": "不活躍", + "inactive": "未啟用", "noData": "無可用資料", "nothingHere": "暫無內容", "configure": "設定", @@ -38,7 +38,7 @@ "name": "名稱", "actions": "操作", "status": "狀態", - "type": "型別", + "type": "類型", "model": "模型", "models": "模型", "provider": "提供者", @@ -53,7 +53,7 @@ "reloadPage": "重新載入頁面", "connected": "已連線", "disconnected": "已斷開連線", - "notConfigured": "未配置", + "notConfigured": "未設定", "testConnection": "測試連線", "enable": "啟用", "disable": "停用", @@ -79,7 +79,7 @@ "apiKeyName": "API 金鑰名稱", "apiKeySecret": "API 金鑰密文", "authorization": "授權", - "content-type": "內容型別", + "content-type": "內容類型", "content-length": "內容長度", "cookie": "Cookie", "file": "檔案", @@ -241,8 +241,8 @@ "apiKeyLabel": "API key 標籤", "noModelsForProvider": "此提供者沒有可用模型", "mcpCardDescription": "通過 MCP 工具連線 Agent 和自動化流程。", - "apiTypeLabel": "API 型別標籤", - "configuredProvidersLabel": "已配置 Provider 標籤", + "apiTypeLabel": "API 類型標籤", + "configuredProvidersLabel": "已設定 Provider 標籤", "maxRetriesLabel": "最大重試次數標籤", "title": "標題", "output": "輸出", @@ -277,7 +277,7 @@ "description": "描述", "noProviderFound": "未找到提供者", "auto": "自動", - "protocolsDescription": "配置並測試支援的協議端點。", + "protocolsDescription": "設定並測試支援的協議端點。", "cloudSessionNote": "Cloud Session 提示", "advancedSettings": "高階設定", "defaultStrategy": "預設策略", @@ -297,7 +297,7 @@ "addCcCompatible": "新增 CC 相容", "duplicate": "重複", "createCombo": "建立組合", - "searchTypeWeb": "搜尋型別:Web", + "searchTypeWeb": "搜尋類型:Web", "addChain": "新增鏈", "prefixLabel": "字首標籤", "listModelsDesc": "列出可用 Model。", @@ -329,7 +329,7 @@ "machineId": "機器 ID", "globalProxy": "全域性代理", "hitsMisses": "命中未命中", - "testDesc": "執行連線測試以驗證配置。", + "testDesc": "執行連線測試以驗證設定。", "chatDesc": "聊天說明", "importSuccess": "匯入成功", "chat": "聊天", @@ -344,9 +344,9 @@ "modelsPathPlaceholder": "Model 路徑", "noCombosYet": "暫無 Combo", "connectedVerificationPendingWithError": "已連線,驗證待完成:{error}", - "comboDefaultsGuideHint1": "配置 Combo 預設策略和目標。", + "comboDefaultsGuideHint1": "設定 Combo 預設策略和目標。", "enableCloudTitle": "啟用雲端", - "configuredProvidersHint": "僅顯示已配置的 Provider。", + "configuredProvidersHint": "僅顯示已設定的 Provider。", "paused": "已暫停", "llmProviders": "LLM Provider", "enableCombo": "啟用組合", @@ -354,7 +354,7 @@ "nodeVersion": "Node 版本", "openai": "OpenAI", "exportFailedWithError": "匯出失敗:{error}", - "proxyConfigured": "代理已配置", + "proxyConfigured": "代理已設定", "concurrencyPerModel": "每個模型併發數", "protocolTasksLabel": "協議任務標籤", "oauthProviders": "OAuth 提供者", @@ -387,7 +387,7 @@ "modelName": "模型名稱", "apiKeyForCheck": "用於檢查的 API 金鑰", "cloudRequestTimeout": "雲端請求超時", - "showConfiguredOnly": "顯示已配置僅", + "showConfiguredOnly": "顯示已設定僅", "showFreeOnly": "僅免費", "addFirstProvider": "新增您的第一個提供者", "addFirstProviderDesc": "連線 AI 提供者以開始通過 OmniRoute 路由請求。您可以使用免費提供者、API 金鑰或 OAuth 帳戶。", @@ -399,7 +399,7 @@ "chatPathPlaceholder": "聊天路徑佔位符", "databasePath": "資料庫路徑", "usingLocalServer": "正在使用本地 Server", - "globalComboConfig": "全域性 Combo 配置", + "globalComboConfig": "全域性 Combo 設定", "backupFailed": "備份失敗", "tabProtocols": "協議標籤頁", "continue": "繼續", @@ -452,7 +452,7 @@ "globalProxyDesc": "全域性代理說明", "latencyP99": "延遲P99", "connectingToCloud": "正在連線雲端", - "responses": "響應", + "responses": "回應", "errorDeleting": "錯誤刪除", "openaiPrefixPlaceholder": "OpenAI 字首", "enterPassword": "輸入密碼", @@ -461,7 +461,7 @@ "modeTest": "測試模式", "failedDeleteChain": "刪除鏈失敗", "providerDesc": "提供者描述", - "templateLoadHint": "選擇模板以快速填充配置。", + "templateLoadHint": "選擇模板以快速填充設定。", "lastFailure": "最近失敗", "moveDown": "移動下移", "providerLabel": "提供者標籤", @@ -477,7 +477,7 @@ "defaultStrategyDesc": "預設策略說明", "latencyP95": "延遲P95", "textToSpeech": "文本轉語音", - "searchType": "搜尋型別", + "searchType": "搜尋類型", "messages": "訊息", "aggregatorsGateways": "聚合器與閘道器", "comboStrategyAria": "Combo 策略", @@ -493,7 +493,7 @@ "notAvailable": "不可用", "skipAndContinue": "跳過並繼續", "moderations": "稽核", - "proxyConfig": "代理配置", + "proxyConfig": "代理設定", "upstreamProxyProviders": "上游代理 Provider", "exportAll": "匯出全部", "queuedCount": "排隊中數量", @@ -507,7 +507,7 @@ "timeLeft": "時間剩餘", "expirationBannerExpired": "過期橫幅已過期", "language": "語言", - "invalidFileType": "無效檔案型別", + "invalidFileType": "無效檔案類型", "monitoredProviders": "監控中的 Provider", "errors": "錯誤", "heap": "堆記憶體", @@ -539,7 +539,7 @@ "mcpQuickStartTitle": "MCP 快速開始", "comboUpdated": "Combo 已更新", "weighted": "加權", - "providers": "Provider", + "providers": "提供者", "ccCompatibleLabel": "CC 相容", "noFallbackChainsDesc": "建立一條鏈路,用於定義某個模型的提供者回退順序。", "yesImport": "確認匯入", @@ -552,7 +552,7 @@ "testAllOAuth": "測試所有 OAuth", "mcpQuickStartStep3": "MCP 快速開始步驟 3", "repairEnvFailed": "環境修復失敗", - "zedImportHint": "從 Zed 配置中匯入 Provider。", + "zedImportHint": "從 Zed 設定中匯入 Provider。", "modelsAcrossEndpoints": "跨 Endpoint 的 Model", "autoDisableBannedAccounts": "自動停用被封禁帳戶", "securityDesc": "安全說明", @@ -581,7 +581,7 @@ "createChain": "建立鏈", "audioSpeech": "語音合成", "cacheCleared": "快取已清除", - "searchTypeNews": "搜尋型別:News", + "searchTypeNews": "搜尋類型:News", "durationMillisecondsShort": "時長毫秒短標籤", "addOpenAICompatible": "新增開啟Ai相容", "chatTesterTab": "聊天測試標籤頁", @@ -678,7 +678,7 @@ "loadingHealth": "正在載入健康狀態", "providerOverrides": "提供者覆蓋", "audioTranscriptionDesc": "音訊轉寫說明", - "learnedFromHeaders": "從響應頭學習", + "learnedFromHeaders": "從回應頭學習", "totalRequests": "總計請求", "cloudUnstableNote": "Cloud 連線不穩定,部分功能可能受影響。", "gamificationAdmin": "遊戲化管理員", @@ -741,7 +741,7 @@ "tokensLastSync": "上次同步:{date}", "tokensDisconnect": "中斷連線", "tierCoverageTitle": "層級覆蓋範圍", - "tierCoverageSubtitle": "每個後備層配置的提供程式", + "tierCoverageSubtitle": "每個後備層設定的提供程式", "batchDetailCopyId": "複製身份證件", "batchDetailClose": "關閉", "batchDetailEndpoint": "端點", @@ -957,7 +957,7 @@ "omniSkills": "全方位技能", "agentSkills": "代理技能", "chaosConfig": "混沌模式", - "docs": "文件", + "docs": "檔案", "issues": "問題反饋", "endpoints": "端點", "endpointsSubtitle": "您的 AI 連線 URL", @@ -1132,7 +1132,7 @@ "providerQuotaSubtitle": "跟蹤提供者使用限制", "runtimeSubtitle": "即時彈性與會話", "contextCombosSubtitle": "組合壓縮引擎", - "cliToolsSubtitle": "配置 CLI 執行時", + "cliToolsSubtitle": "設定 CLI 執行時", "agentsSubtitle": "管理本地代理", "cloudAgentsSubtitle": "管理基於雲的代理", "apiEndpointsSubtitle": "暴露自定義端點", @@ -1191,11 +1191,11 @@ "settingsSidebar": "側邊欄", "settingsSidebarSubtitle": "自定義側邊欄佈局", "settingsAuthzSubtitle": "路由清單與繞過策略", - "docsSubtitle": "文件", + "docsSubtitle": "檔案", "issuesSubtitle": "報告錯誤", "changelogSubtitle": "釋出說明", "costsQuotaPlans": "計劃與配額", - "costsQuotaPlansSubtitle": "按提供者配置計劃", + "costsQuotaPlansSubtitle": "按提供者設定計劃", "activity": "活動", "activitySubtitle": "近期事件的友好動態", "logsGroup": "日誌", @@ -1227,8 +1227,8 @@ }, "webhooks": { "title": "Webhook", - "description": "配置系統事件的 HTTP 回撥。", - "configuredWebhooks": "已配置的 Webhook", + "description": "設定系統事件的 HTTP 回撥。", + "configuredWebhooks": "已設定的 Webhook", "configuredWebhooksDesc": "管理投遞端點、訂閱事件、狀態和測試傳送。", "addWebhook": "新增 Webhook", "editWebhook": "編輯 Webhook", @@ -1267,13 +1267,13 @@ "edit": "編輯", "enable": "啟用", "disable": "停用", - "noWebhooks": "尚未配置 Webhook。", + "noWebhooks": "尚未設定 Webhook。", "signatureTitle": "Webhook 簽名", "signatureDescription": "每次投遞都會包含一個 X-Webhook-Signature 請求頭,該簽名使用 Webhook 金鑰通過 HMAC-SHA256 生成。信任載荷前請先驗證簽名。", "wizard": { "cancel": "取消", "step1Title": "選擇整合", - "step2Title": "配置目標", + "step2Title": "設定目標", "step3Title": "事件與測試", "back": "返回", "next": "下一步", @@ -1281,8 +1281,8 @@ "step1Desc": "選擇此 Webhook 要對接的目標整合系統。" }, "howItWorks": { - "step1": "選擇一個整合提供者(如 Slack、Discord、自定義 Webhook)並配置連線詳情。", - "step2": "配置要訂閱的系統事件(如補全錯誤、模型回退或用量限制)。", + "step1": "選擇一個整合提供者(如 Slack、Discord、自定義 Webhook)並設定連線詳情。", + "step2": "設定要訂閱的系統事件(如補全錯誤、模型回退或用量限制)。", "step3": "傳送測試負載以驗證端點是否能正常接收資料。", "step4": "使用 Webhook 金鑰對 X-Webhook-Signature HMAC-SHA256 頭進行驗證,以確保端點安全。", "title": "Webhook 工作原理", @@ -1322,7 +1322,7 @@ "teamsDesc": "將系統通知轉發到 Microsoft Teams 頻道" }, "testPayloadSent": "測試負載已傳送", - "testResponse": "測試響應", + "testResponse": "測試回應", "validateUrl": { "checking": "正在檢查 URL...", "ok": "URL 有效", @@ -1355,7 +1355,7 @@ "chatId": "Telegram 聊天 ID / 頻道", "chatIdPlaceholder": "-100123456789 或 @channelname", "chatIdHint": "聊天/頻道的唯一數字識別符號或公開使用者名稱。", - "tutorial": "如何配置 Telegram Webhook:" + "tutorial": "如何設定 Telegram Webhook:" } }, "compliance": { @@ -1365,8 +1365,8 @@ "mcpTab": "MCP 審計", "title": "合規審計", "description": "合規審計日誌記錄的策略、訪問、提供者和安全事件。", - "eventType": "事件型別", - "eventTypePlaceholder": "按操作或事件型別篩選", + "eventType": "事件類型", + "eventTypePlaceholder": "按操作或事件類型篩選", "severity": "嚴重級別", "allSeverities": "所有嚴重級別", "info": "資訊", @@ -1439,7 +1439,7 @@ "auth.login.error": "登入錯誤", "auth.login.failed": "登入失敗", "auth.login.locked": "登入被鎖定", - "auth.login.misconfigured": "登入配置錯誤", + "auth.login.misconfigured": "登入設定錯誤", "auth.login.setup_required": "需要登入設定", "auth.login.success": "登入成功", "auth.logout.success": "登出成功", @@ -1492,7 +1492,7 @@ "analytics": "分析", "analyticsDescription": "檢視圖表、趨勢和評測洞察", "cliTools": "CLI 工具", - "cliToolsDescription": "配置 CLI 工具", + "cliToolsDescription": "設定 CLI 工具", "home": "首頁", "homeDescription": "歡迎使用 OmniRoute", "endpoint": "端點", @@ -1510,8 +1510,8 @@ "themes": "主題", "themesDescription": "為整個儀表板選擇顏色主題", "costsDescription": "跟蹤支出,分析趨勢,管理所有 AI 提供者的預算", - "cacheDescription": "監控提供者提示快取效率和本地語義響應複用。", - "limitsDescription": "為每個 API 金鑰和提供者配置速率限制和配額", + "cacheDescription": "監控提供者提示快取效率和本地語義回應複用。", + "limitsDescription": "為每個 API 金鑰和提供者設定速率限制和配額", "runtimeDescription": "即時執行時可觀測性 — 斷路器、冷卻、模型鎖定、會話和配額告警", "apiManagerDescription": "管理 OmniRoute 例項的 API 金鑰和訪問控制", "batchDescription": "通過批次 API 呼叫非同步處理大量請求", @@ -1519,35 +1519,35 @@ "contextRtkDescription": "針對工具輸出、終端日誌和構建結果的命令感知壓縮。", "contextCombosDescription": "定義如何為不同路由場景組合引擎。", "changelogDescription": "瞭解最新平臺功能和公告。", - "agentsDescription": "管理和配置 AI 代理工具:Codex、Devin、Jules 和自定義代理", + "agentsDescription": "管理和設定 AI 代理工具:Codex、Devin、Jules 和自定義代理", "cloudAgentsDescription": "編排基於雲的 AI 代理,支援即時任務跟蹤和計劃審批", "memoryDescription": "持久的對話記憶,支援語義搜尋和 FTS5 全文索引", "skillsDescription": "安裝和管理沙箱技能,實現自動提示和執行", "agentSkillsDescription": "代理就緒技能目錄,一鍵複製 URL 以整合 AI 客戶端", "translatorDescription": "跨 API 格式翻譯和測試提示:OpenAI ↔ Claude ↔ Gemini", - "playgroundDescription": "互動式測試提示,即時檢視提供者響應和格式檢查", + "playgroundDescription": "互動式測試提示,即時檢視提供者回應和格式檢查", "searchToolsDescription": "搜尋分析、提供者細分、快取命中率和成本跟蹤", "logsDescription": "即時請求日誌、錯誤追蹤和流式事件檢查器", "auditDescription": "API 金鑰使用、MCP 工具呼叫和策略事件的合規審計追蹤", - "webhooksDescription": "配置 Webhook 端點以接收即時事件通知", + "webhooksDescription": "設定 Webhook 端點以接收即時事件通知", "healthDescription": "系統健康概覽:提供者、斷路器、速率限制和資料庫", - "proxyDescription": "為出站提供者連線配置上游代理設定", - "apiEndpointsDescription": "管理自定義 API 端點配置和路由覆蓋", + "proxyDescription": "為出站提供者連線設定上游代理設定", + "apiEndpointsDescription": "管理自定義 API 端點設定和路由覆蓋", "batchFilesDescription": "瀏覽和管理批處理作業輸出檔案和結果", "analyticsEvalsDescription": "模型評估結果和效能基準", "analyticsSearchDescription": "搜尋查詢分析、快取命中率和成本跟蹤", "analyticsUtilizationDescription": "提供者利用率指標和容量規劃", - "analyticsComboHealthDescription": "組合路由配置的即時健康與效能", + "analyticsComboHealthDescription": "組合路由設定的即時健康與效能", "analyticsCompressionDescription": "上下文壓縮分析和權杖節省", "costsBudgetDescription": "每個 API 金鑰和提供者的預算限制和支出告警", - "costsPricingDescription": "用於權杖成本計算的自定義定價配置", + "costsPricingDescription": "用於權杖成本計算的自定義定價設定", "logsProxyDescription": "上游代理請求日誌和流量檢查", "logsConsoleDescription": "應用控制台輸出和除錯日誌", "logsActivityDescription": "使用者操作和系統事件的審計追蹤", "auditMcpDescription": "MCP 工具呼叫審計追蹤和合規記錄", "auditA2a": "A2A稽核", "auditA2aDescription": "A2A任務執行審計追蹤、狀態轉換、技能呼叫記錄", - "settingsGeneralDescription": "儲存、資料庫和通用例項配置", + "settingsGeneralDescription": "儲存、資料庫和通用例項設定", "settingsAppearanceDescription": "主題、品牌和視覺自定義", "settingsAiDescription": "AI 行為、思維預算、視覺和記憶設定", "settingsCacheDescription": "__MISSING__:TTL for model catalog cache entries", @@ -1576,10 +1576,10 @@ "featureFlagsSaved": "標誌已更新", "featureFlagsError": "更新標誌失敗", "settingsRoutingDescription": "路由規則、模型別名、組合預設值和降級設定", - "settingsResilienceDescription": "斷路器、重試和回退配置", + "settingsResilienceDescription": "斷路器、重試和回退設定", "settingsAdvancedDescription": "高階負載規則、請求限制和代理 API 設定", - "mitmProxyDescription": "配置 MITM 代理設定以進行流量檢查和除錯", - "oneProxyDescription": "配置 1Proxy 設定以實現高階代理鏈", + "mitmProxyDescription": "設定 MITM 代理設定以進行流量檢查和除錯", + "oneProxyDescription": "設定 1Proxy 設定以實現高階代理鏈", "omniSkillsDescription": "安裝和管理用於自動提示和工具執行的沙箱技能" }, "cloudSyncStatus": { @@ -1596,7 +1596,7 @@ "breadcrumbs": { "ariaLabel": "麵包屑", "dashboard": "儀表板", - "providers": "供應商", + "providers": "提供者", "combos": "組合", "settings": "設定", "general": "一般", @@ -1664,14 +1664,14 @@ "pricing": "定價", "quotaShare": "配額分享", "discovery": "探索", - "freeProviderRankings": "免費供應商排名", + "freeProviderRankings": "免費提供者排名", "freeTiers": "免費方案", "gamification": "遊戲化", "leaderboard": "排行榜", "limits": "限制", "profile": "個人檔案", "plugins": "外掛程式", - "providerStats": "供應商統計", + "providerStats": "提供者統計", "new": "新增", "quota": "配額", "relay": "轉發", @@ -1687,28 +1687,28 @@ "home": { "quickStart": "快速入門", "quickStartDesc": "4 個步驟快速上手:連線提供者、路由模型並監控全域性執行情況。", - "fullDocs": "完整文件", + "fullDocs": "完整檔案", "step1Title": "1. 建立 API 金鑰", "step1Desc": "前往 端點 -> 已註冊金鑰。為每個環境生成一個獨立金鑰。", "step2Title": "2. 連線提供者", "step2Desc": "在 提供者 中新增帳戶。支援 OAuth、API Key 和免費套餐。", - "step3Title": "3. 配置客戶端", + "step3Title": "3. 設定客戶端", "step3Desc": "在 IDE 或 API 客戶端中將基本 URL 設定為 {url}。", "step4Title": "4. 監控與最佳化", "step4Desc": "在 請求日誌分析 中跟蹤 Token、成本與錯誤。", "providersOverview": "提供者概覽", - "configuredOf": "{total} 個可用提供者中已配置 {configured} 個", + "configuredOf": "{total} 個可用提供者中已設定 {configured} 個", "noModelsAvailable": "該提供者當前沒有可用模型。", - "noProvidersConfigured": "尚未配置提供者", + "noProvidersConfigured": "尚未設定提供者", "addProvider": "新增提供者", - "configureFirst": "首先在 {providers} 中配置連線", - "configureProvider": "配置提供者", + "configureFirst": "首先在 {providers} 中設定連線", + "configureProvider": "設定提供者", "modelAvailable": "{count} 模型可用", "modelsAvailable": "{count} 個模型可用", "connectionsActive": "{count} 連線處於活動狀態", "connectionsActivePlural": "{count} 連線處於活動狀態", "copyModelName": "複製模型名稱", - "documentation": "文件", + "documentation": "檔案", "healthMonitor": "健康監測", "reportIssue": "報告問題", "activeError": "{active} 有效 · {errors} 錯誤", @@ -1728,12 +1728,12 @@ "analytics": { "title": "分析", "usageAnalyticsTitle": "用量分析", - "diversityScoreTitle": "供應商多元化", + "diversityScoreTitle": "提供者多元化", "diversityScoreDesc": "近期流量視窗內提供者集中度的快照。", "diversityShannonEntropy": "夏農熵", "diversityWindow": "視窗:{count} 次請求 · 最近 {mins} 分鐘", "diversityHealthy": "分佈健康", - "diversityRiskHigh": "供應商鎖定風險高", + "diversityRiskHigh": "提供者鎖定風險高", "diversityRiskModerate": "分佈一般", "diversityScoreLabel": "得分", "diversityHigherExplanation": "數值越高,表示流量分散到的提供者越多。", @@ -1772,7 +1772,7 @@ "chartProvider": "提供者", "chartProviderBreakdown": "按提供者拆分", "chartDate": "日期", - "chartRequestsByProviderDate": "依供應商與日期的請求", + "chartRequestsByProviderDate": "依提供者與日期的請求", "filterAllKeys": "全部金鑰", "filterSearchKeys": "搜尋金鑰…", "filterNoKeysMatch": "沒有匹配的金鑰", @@ -1870,7 +1870,7 @@ "comboHealthProjectedQuota": "預估配額", "comboHealthPricingCoverage": "定價涵蓋範圍", "comboHealthAutopilotTitle": "組合健康自動駕駛儀", - "comboHealthAutopilotDescription": "來自組合健康狀態、預測、配額和供應商健康狀態的優先建議。", + "comboHealthAutopilotDescription": "來自組合健康狀態、預測、配額和提供者健康狀態的優先建議。", "comboHealthIssues": "問題", "comboHealthActionable": "{count} 個可操作", "comboHealthDown": "已中斷", @@ -1904,10 +1904,10 @@ "degraded": "需要注意", "healthy": "健康" }, - "comboHealthModelProviderCount": "{models} 個模型橫跨 {providers} 個供應商", + "comboHealthModelProviderCount": "{models} 個模型橫跨 {providers} 個提供者", "comboHealthGiniCoefficient": "吉尼係數", "comboHealthRequestCount": "{count} 次請求", - "comboHealthQuotaHealthDescription": "各供應商中最低的剩餘配額,附帶短期趨勢訊號。", + "comboHealthQuotaHealthDescription": "各提供者中最低的剩餘配額,附帶短期趨勢訊號。", "comboHealthRemainingQuota": "剩餘配額 {value}", "comboHealthTrend": { "improving": "改善中", @@ -1928,7 +1928,7 @@ "comboHealthForecastHorizon": "{value} 預測", "comboHealthNoData": "無可用的組合健康資料", "comboHealthNoDataDescription": "組合配額快照和路由請求將在流量開始流動後顯示於此。", - "comboHealthStepCreate": "在組合中使用多個供應商建立組合", + "comboHealthStepCreate": "在組合中使用多個提供者建立組合", "comboHealthStepSend": "傳送請求至組合端點以產生流量資料", "comboHealthStepAutomatic": "健康指標將在請求路由時自動顯示", "comboHealthTracking": "正在追蹤 {range} 內的 {count} 個組合", @@ -1956,17 +1956,17 @@ "compressionAnalyticsRealUsageReceipts": "實際用量收據", "compressionAnalyticsSources": "來源", "compressionAnalyticsModeBreakdown": "模式分佈", - "compressionAnalyticsProviderBreakdown": "供應商分佈", + "compressionAnalyticsProviderBreakdown": "提供者分佈", "compressionAnalyticsLast24HoursActivity": "過去 24 小時(活動)", "compressionAnalyticsChartPoint": "{hour}:{count} 個請求,省下 {tokens} 個 Token", "compressionAnalyticsMaxRequests": "每小時最大請求數:{count}", "compressionAnalyticsMaxTokens": "每小時最大 Token 數:{count}", "compressionAnalyticsStartTracking": "使用 POST /v1/chat/completions 並搭配壓縮設定,即可開始追蹤壓縮分析。", - "compressionAnalyticsInfo": "壓縮分析:按模式(關閉、精簡、標準、積極、極致、RTK、堆疊)、引擎、壓縮組合與供應商追蹤 Token 節省量。將滑鼠懸停在圖表上查看詳細資訊。使用時間選擇器檢視不同時間範圍。", + "compressionAnalyticsInfo": "壓縮分析:按模式(關閉、精簡、標準、積極、極致、RTK、堆疊)、引擎、壓縮組合與提供者追蹤 Token 節省量。將滑鼠懸停在圖表上查看詳細資訊。使用時間選擇器檢視不同時間範圍。", "searchAnalyticsTotalSearches": "總搜尋次數", "searchAnalyticsCacheHitRate": "快取命中率", "searchAnalyticsTotalCost": "總成本", - "searchAnalyticsAvgResponse": "平均響應", + "searchAnalyticsAvgResponse": "平均回應", "searchAnalyticsNoSearchesYet": "還沒有搜尋", "providerUtilizationTitle": "提供者利用率", "providerUtilizationFailedToLoad": "無法載入利用率資料", @@ -1985,9 +1985,9 @@ "providerUtilizationLoading": "正在載入使用率資料…", "retrying": "正在重試…", "retry": "重試", - "providerUtilizationNoDataDescription": "收集使用率資料後,供應商配額快照便會顯示在此處。", - "providerUtilizationStepConnect": "透過 OAuth 或 API 金鑰在供應商中連線供應商", - "providerUtilizationStepEnable": "在組合或直接請求中使用該供應商,以啟用配額追蹤", + "providerUtilizationNoDataDescription": "收集使用率資料後,提供者配額快照便會顯示在此處。", + "providerUtilizationStepConnect": "透過 OAuth 或 API 金鑰在提供者中連線提供者", + "providerUtilizationStepEnable": "在組合或直接請求中使用該提供者,以啟用配額追蹤", "providerUtilizationStepAutomatic": "資料將在收集配額快照時自動顯示", "statusExhausted": "已耗盡", "statusLow": "偏低", @@ -2030,7 +2030,7 @@ "routeRecentSuccess": "近期成功", "routeAvgTargetLatency": "平均目標延遲", "routeSelectedTarget": "已選目標", - "provider": "供應商", + "provider": "提供者", "model": "模型", "account": "帳戶", "connection": "連線", @@ -2094,13 +2094,13 @@ "keyName": "金鑰名稱", "keyNamePlaceholder": "例如:生產環境金鑰、開發環境金鑰", "keyNameDesc": "使用清晰的名稱標識該金鑰的用途", - "managementAccessDesc": "允許此 API 金鑰管理 OmniRoute 配置。", + "managementAccessDesc": "允許此 API 金鑰管理 OmniRoute 設定。", "selfServiceVisibility": "自助可見性", "selfServiceVisibilityDesc": "控制此金鑰可檢視自身用量和共享上游配額的範圍。", "ownUsageVisibility": "自身成本和 Token 用量", "ownUsageVisibilityDesc": "允許此金鑰呼叫狀態端點,檢視自身美元用量、預算佔比和 Token 總量。", "sharedAccountQuotaVisibility": "共享帳號配額", - "sharedAccountQuotaVisibilityDesc": "當配置了一個明確連線時,允許此金鑰檢視共享上游帳號配額。", + "sharedAccountQuotaVisibilityDesc": "當設定了一個明確連線時,允許此金鑰檢視共享上游帳號配額。", "localUsageCommand": "允許本機使用量指令", "localUsageCommandDesc": "允許此 API 金鑰使用 @@om-usage 來擷取快取的使用量和配額資訊,而無需呼叫上游提供者。", "localUsageCommandBadge": "使用量命令", @@ -2115,7 +2115,7 @@ "autoResolve": "自動解析", "autoResolveDesc": "為這個 API 金鑰自動將有歧義的模型名解析到原生提供者。", "streamDefaultMode": "流預設相容性", - "streamDefaultModeDesc": "此鍵省略了 `stream` 標誌。JSON 模式返回非流式響應,除非客戶端明確請求 SSE。", + "streamDefaultModeDesc": "此鍵省略了 `stream` 標誌。JSON 模式返回非流式回應,除非客戶端明確請求 SSE。", "streamDefaultLegacy": "遺留", "streamDefaultJson": "JSON 相容", "streamDefaultBadge": "JSON 流預設", @@ -2206,7 +2206,7 @@ "searchPlaceholder": "按名稱或 token 搜尋...", "activeOnly": "僅顯示啟用", "filterStatus": "狀態", - "filterType": "型別", + "filterType": "類型", "filterAll": "全部", "filterStatusActive": "啟用", "filterStatusDisabled": "已停用", @@ -2222,8 +2222,8 @@ "disableNonPublicModelsDesc": "拒絕對未發現或未標記為公共的模型在提供者目錄中的請求", "normalKeysSection": "普通鍵", "quotaKeysSection": "配額金鑰", - "bypassProviderQuota": "繞過供應商配額限制", - "bypassProviderQuotaDescription": "允許此金鑰在路由期間忽略上游供應商/帳戶的限制政策。API 金鑰的美元配額仍然適用。", + "bypassProviderQuota": "繞過提供者配額限制", + "bypassProviderQuotaDescription": "允許此金鑰在路由期間忽略上游提供者/帳戶的限制政策。API 金鑰的美元配額仍然適用。", "quotaPill": "配額", "quotaModeOnly": "僅配額" }, @@ -2239,7 +2239,7 @@ "filterByAction": "按操作過濾...", "filterByActor": "按操作人篩選...", "filterEntriesAria": "過濾稽核日誌條目", - "filterByActionTypeAria": "按操作型別過濾", + "filterByActionTypeAria": "按操作類型過濾", "filterByActorAria": "按操作人篩選", "refreshAuditLogAria": "重新整理稽核日誌", "tableAria": "稽核日誌條目", @@ -2257,7 +2257,7 @@ "generate": "生成", "generating": "生成中...", "loadingModels": "正在載入可用模型...", - "noModels": "暫無可用模型。請先配置支援媒體能力的提供者。", + "noModels": "暫無可用模型。請先設定支援媒體能力的提供者。", "error": "生成失敗", "result": "結果", "imageDescription": "使用 OpenAI、xAI、Together、Hyperbolic、SD WebUI、ComfyUI 等根據文本提示生成影像。", @@ -2275,13 +2275,13 @@ "music": "音樂", "ocr": "OCR" }, - "noProviders": "尚未為此型別配置任何提供者。", + "noProviders": "尚未為此類型設定任何提供者。", "addConnection": "新增連線", "backToProviders": "返回提供者列表", "connections": "{count} 個連線", "noConnections": "暫無連線 —— 請從提供者頁面新增。", "loading": "正在載入...", - "suggestedModels": "供應商建議的模型", + "suggestedModels": "提供者建議的模型", "imageGeneration": "圖片生成", "imageToText": "圖片轉文字", "imageToTextComingSoon": "當 /api/v1/images/understanding 實作後,即可使用內嵌的圖片轉文字遊樂場。", @@ -2345,11 +2345,11 @@ "rerankResults": "結果重排", "searchHistory": "搜尋歷史", "urlOverlap": "URL 重疊度", - "noSearchProviders": "尚未配置搜尋提供者。請前往設定新增。", + "noSearchProviders": "尚未設定搜尋提供者。請前往設定新增。", "noRerankModels": "沒有可用的重排模型", "webSearch": "網頁搜尋", "provider": "提供者", - "searchType": "搜尋型別", + "searchType": "搜尋類型", "maxResults": "最大結果數", "filters": "篩選條件", "country": "國家", @@ -2399,14 +2399,14 @@ "rerankConceptTitle": "重新排序", "rerankConceptDesc": "重新排序 = 通過 LLM 重新排序結果以基於查詢提高相關性", "autoConceptTitle": "自動(最便宜)", - "autoConceptDesc": "自動(最便宜)= 自動選擇具有已配置憑據的最便宜可用提供者", + "autoConceptDesc": "自動(最便宜)= 自動選擇具有已設定憑據的最便宜可用提供者", "providerCatalogTitle": "提供者目錄", "kindSearch": "搜尋", "kindFetch": "獲取/抓取", - "statusConfigured": "已配置", + "statusConfigured": "已設定", "statusMissing": "無憑據", "statusRateLimited": "速率受限", - "configureProvider": "在提供者中配置", + "configureProvider": "在提供者中設定", "loadingProviders": "載入提供者…", "failedToLoadProviders": "載入提供者失敗", "costPerQuery": "成本/查詢", @@ -2433,21 +2433,21 @@ "compareRun": "比較", "compareRunning": "比較中…", "autoProvider": "自動(最便宜)", - "configuredStatus": "已配置", + "configuredStatus": "已設定", "rateLimitedStatus": "速率受限", "noCredential": "無憑據", - "searchTypeLabel": "型別", + "searchTypeLabel": "類型", "rerankModelLabel": "重新排序模型", "noneOption": "無", "size": "大小", "configurationPane": "設定面板", "configuration": "設定", "status": "狀態", - "compareProviderHint": "在比較標籤頁中選擇最多 4 個供應商,以進行並排比較。", + "compareProviderHint": "在比較標籤頁中選擇最多 4 個提供者,以進行並排比較。", "history": "歷史記錄", "historyHint": "歷史記錄可在搜尋標籤頁中使用。", - "noActiveProvider": "無作用中的搜尋供應商", - "configureMoreProviders": "設定更多供應商", + "noActiveProvider": "無作用中的搜尋提供者", + "configureMoreProviders": "設定更多提供者", "links": "連結", "contentTruncated": "內容已截斷至 256 KB(原始大小:{size})", "viewFullRaw": "檢視完整原始內容", @@ -2476,21 +2476,21 @@ "videoSample": "山脈上空雲層的延時攝影", "webFetch": "網頁擷取", "webSearchSample": "什麼是 OmniRoute AI 閘道?", - "noActiveProviderDescription": "無作用中的搜尋供應商。請在供應商中設定一個。", - "configureProviders": "設定供應商", + "noActiveProviderDescription": "無作用中的搜尋提供者。請在提供者中設定一個。", + "configureProviders": "設定提供者", "compareQuery": "要比較的查詢", "compareQueryPlaceholder": "2026 年人工智慧趨勢", - "selectedProviders": "供應商(已選 {count} 個):", + "selectedProviders": "提供者(已選 {count} 個):", "selectAll": "全選", "clear": "清除", - "maxCompareProviders": "一次最多可比較 {count} 個供應商。", + "maxCompareProviders": "一次最多可比較 {count} 個提供者。", "compareResults": "結果 —「{query}」", "resultCount": "{count} 筆結果", "noResults": "無結果", - "sharedResultTitle": "與其他供應商共通", + "sharedResultTitle": "與其他提供者共通", "sharedResult": "共通", "overlapSummary": "{first} vs {second}:{overlap} 個共通項目", - "compareEmptyTitle": "選擇供應商並輸入查詢以進行比較", + "compareEmptyTitle": "選擇提供者並輸入查詢以進行比較", "compareEmptyDescription": "結果將並排顯示,包含延遲、成本與 URL 重疊程度" }, "cliTools": { @@ -2513,11 +2513,11 @@ "claudeProfiles": "Claude Code 設定檔", "claudeProfilesDescription": "在模型探索後重新產生每個 ~/.claude/profiles/…/settings.json。", "noActiveProviders": "當前沒有活躍的提供者", - "noActiveProvidersDesc": "請先新增並連線提供者以配置 CLI 工具。", + "noActiveProvidersDesc": "請先新增並連線提供者以設定 CLI 工具。", "mapModels": "對映模型", "testConnection": "測試連線", "connectionStatus": "連線狀態", - "configureEndpoint": "配置端點", + "configureEndpoint": "設定端點", "instructions": "使用說明", "modelMapping": "模型對映", "reasoningEffort": "{model} 的推理努力", @@ -2534,8 +2534,8 @@ "routeModelPlaceholder": "將 {model} 路由至...", "baseUrl": "基礎 URL", "apiKey": "API金鑰", - "configured": "已配置", - "notConfigured": "未配置", + "configured": "已設定", + "notConfigured": "未設定", "notInstalled": "未安裝", "custom": "自定義", "unknown": "未知", @@ -2550,8 +2550,8 @@ "runtimeCheckFailed": "執行時檢查失敗", "yourApiKeyPlaceholder": "你的 API 金鑰", "modelPlaceholder": "provider/model-id", - "configurationSaved": "配置儲存成功。", - "failedToSave": "儲存配置失敗。", + "configurationSaved": "設定儲存成功。", + "failedToSave": "儲存設定失敗。", "noApiKeysCreateOne": "無 API 金鑰 - 在“金鑰”頁面建立一個", "defaultOmnirouteKey": "sk_omniroute(預設)", "selectModel": "選擇模型", @@ -2566,19 +2566,19 @@ "cliFoundNotRunnable": "CLI 已找到但無法執行{reason}", "cliRuntimeNotDetected": "未檢測到 CLI 執行時", "binary": "二進位制", - "configPath": "配置路徑", + "configPath": "設定路徑", "configPathShort": "設定", "failedCheckRuntimeStatus": "無法檢查執行時狀態。", "copy": "複製", "copied": "已複製", - "copyConfig": "複製配置", - "saveConfig": "儲存配置", + "copyConfig": "複製設定", + "saveConfig": "儲存設定", "selectionSaved": "選擇已儲存", "guide": "指南", "detected": "檢測到", "notReady": "還沒準備好", "active": "活躍", - "inactive": "不活躍", + "inactive": "未啟用", "startMitm": "啟動中間人", "stopMitm": "停止中間人", "mitmStarted": "MITM 啟動成功!", @@ -2640,9 +2640,9 @@ "providerModelPlaceholder": "提供者/模型 ID", "apply": "應用", "reset": "重置", - "manualConfig": "手動配置", + "manualConfig": "手動設定", "backups": "備份", - "configBackups": "配置備份", + "configBackups": "設定備份", "noBackupsYet": "還沒有備份。每次應用或重置之前都會自動建立備份。", "restore": "恢復", "backupRestoredReloading": "備份已恢復!正在重新載入狀態...", @@ -2650,47 +2650,47 @@ "applied": "已應用!", "failed": "失敗", "resetDone": "重置!", - "omnirouteConfiguredOpenAiCompatible": "OmniRoute 已配置為 OpenAI 相容提供者", + "omnirouteConfiguredOpenAiCompatible": "OmniRoute 已設定為 OpenAI 相容提供者", "provider": "提供者", "model": "模型", "providers": "提供者", "auth": "授權", "noApiKeysAvailable": "沒有可用的 API 金鑰", "usingDefaultOmniroute": "使用預設值:sk_omniroute", - "updateConfig": "更新配置", - "applyConfig": "應用配置", + "updateConfig": "更新設定", + "applyConfig": "應用設定", "noBackupsAvailable": "沒有可用的備份。", - "profileSaved": "配置檔案“{name}”已儲存!", - "failedSaveProfile": "儲存配置檔案失敗", - "profileActivated": "配置檔案已啟用!", - "failedActivateProfile": "啟用配置檔案失敗", - "profiles": "配置檔案", - "savedProfiles": "儲存的配置檔案", - "noProfilesYet": "尚未儲存配置檔案。將當前配置儲存為下面的配置檔案。", + "profileSaved": "設定檔案“{name}”已儲存!", + "failedSaveProfile": "儲存設定檔案失敗", + "profileActivated": "設定檔案已啟用!", + "failedActivateProfile": "啟用設定檔案失敗", + "profiles": "設定檔案", + "savedProfiles": "儲存的設定檔案", + "noProfilesYet": "尚未儲存設定檔案。將當前設定儲存為下面的設定檔案。", "activate": "啟用", - "deleteProfile": "刪除配置檔案", - "profileNamePlaceholder": "配置檔案名稱(例如:個人帳戶)", + "deleteProfile": "刪除設定檔案", + "profileNamePlaceholder": "設定檔案名稱(例如:個人帳戶)", "saveCurrent": "儲存當前", "codexAuthNotePrefix": "Codex 使用", "codexAuthNoteMiddle": "與", - "codexAuthNoteSuffix": "單擊“應用”進行自動配置。", - "claudeManualConfiguration": "Claude CLI - 手動配置", - "codexManualConfiguration": "Codex CLI - 手動配置", - "droidManualConfiguration": "Factory Droid - 手動配置", - "openClawManualConfiguration": "OpenClaw - 手動配置", - "clineManualConfiguration": "Cline - 手動配置", - "kiloManualConfiguration": "Kilo - 手動配置", + "codexAuthNoteSuffix": "單擊“應用”進行自動設定。", + "claudeManualConfiguration": "Claude CLI - 手動設定", + "codexManualConfiguration": "Codex CLI - 手動設定", + "droidManualConfiguration": "Factory Droid - 手動設定", + "openClawManualConfiguration": "OpenClaw - 手動設定", + "clineManualConfiguration": "Cline - 手動設定", + "kiloManualConfiguration": "Kilo - 手動設定", "whenToUseLabel": "何時使用", - "openToolDocs": "開啟工具文件", + "openToolDocs": "開啟工具檔案", "toolUseCases": { "claude": "當您需要強大的規劃工作流程和使用 Claude Code 進行長的多檔案重構時使用。", - "codex": "當您的團隊在 OpenAI Codex CLI 流程和基於配置檔案的身份驗證方面實現標準化時使用。", + "codex": "當您的團隊在 OpenAI Codex CLI 流程和基於設定檔案的身份驗證方面實現標準化時使用。", "droid": "當您需要專注於快速編碼和命令執行迴圈的輕量級終端代理時使用。", "openclaw": "當您需要 Open Claw 風格的編碼代理但通過 OmniRoute 策略進行路由時使用。", - "cline": "當您在編輯器內配置編碼代理並希望使用 OmniRoute 模型進行引導設定時使用。", + "cline": "當您在編輯器內設定編碼代理並希望使用 OmniRoute 模型進行引導設定時使用。", "kilo": "當您的工作流程依賴於 Kilo Code 命令和快速迭代編輯時使用。", "cursor": "在 Cursor 中編碼並且需要通過 OmniRoute 自定義 OpenAI 相容模型時使用。", - "continue": "在 IDE 中執行“Continue”並且需要可移植的基於 JSON 的提供程式配置時使用。", + "continue": "在 IDE 中執行“Continue”並且需要可移植的基於 JSON 的提供程式設定時使用。", "opencode": "當您更喜歡通過 OpenCode 進行終端本機代理執行和指令碼自動化時使用。", "kiro": "在整合 Kiro 並從 OmniRoute 集中控制模型路由時使用。", "windsurf": "當您需要 Windsurf AI IDE 並通過 OmniRoute 路由模型時使用。", @@ -2699,7 +2699,7 @@ "amp": "當您想要 Amp 簡寫工作流,但仍需要 OmniRoute 別名和路由規則支援時使用。", "hermes": "當您需要輕量級終端原生 AI 助手來處理快速任務時使用。", "hermes-agent": "需要使用 Hermes Agent (by Nousresearch) 時使用,預設、委派、視覺與輔助模型均通過 OmniRoute 路由。", - "custom": "用於自定義工具實現或通用 OpenAI 相容配置。" + "custom": "用於自定義工具實現或通用 OpenAI 相容設定。" }, "toolDescriptions": { "antigravity": "帶 MITM 的 Google Antigravity IDE", @@ -2720,7 +2720,7 @@ "amp": "Sourcegraph Amp 程式設計助手 CLI", "hermes": "Hermes AI 終端助手", "hermes-agent": "Hermes Agent (by Nousresearch) — 支援多模型(委派、視覺、壓縮等)的高階終端 AI。", - "custom": "通用 OpenAI 相容 CLI 或 SDK 配置生成器", + "custom": "通用 OpenAI 相容 CLI 或 SDK 設定生成器", "aider": "Aider AI 結對程式設計 CLI,支援 OpenAI 相容的基礎 URL", "forge": "ForgeCode 程式代理 CLI,支援自訂提供者", "cursor-cli": "Cursor Agent CLI 的無頭代理模式", @@ -2771,8 +2771,8 @@ "continue": { "steps": { "1": { - "title": "開啟配置", - "desc": "開啟繼續配置檔案" + "title": "開啟設定", + "desc": "開啟繼續設定檔案" }, "2": { "title": "API金鑰" @@ -2781,12 +2781,12 @@ "title": "選擇模型" }, "4": { - "title": "新增模型配置", - "desc": "將以下配置新增到您的模型陣列中:" + "title": "新增模型設定", + "desc": "將以下設定新增到您的模型陣列中:" } }, "notes": { - "0": "Continue 使用 JSON 配置檔案。" + "0": "Continue 使用 JSON 設定檔案。" } }, "opencode": { @@ -2811,7 +2811,7 @@ } }, "notes": { - "0": "OpenCode 需要配置 API 金鑰。", + "0": "OpenCode 需要設定 API 金鑰。", "1": "將基礎 URL 設定為您的 OmniRoute 端點。" } }, @@ -2861,8 +2861,8 @@ } } }, - "autoConfiguredTab": "自動配置", - "toolCategoriesDesc": "配置 AI 程式設計助手通過 OmniRoute 路由", + "autoConfiguredTab": "自動設定", + "toolCategoriesDesc": "設定 AI 程式設計助手通過 OmniRoute 路由", "allToolsTab": "所有工具", "guidedClientsTab": "引導客戶端", "mitmClientsTab": "MITM 客戶端", @@ -2878,7 +2878,7 @@ "customCliDefaultModelHelp": "使用任何 OmniRoute 模型 ID 或組合。大多數與 OpenAI 相容的 CLI 只需要 /v1 基本 URL 加上模型字串。", "customCliKeyHelper": "對於本地安裝 OmniRoute 可以使用 sk_omniroute。在雲模式下,選擇您的管理 API 金鑰之一。", "customCliAliasMappingsLabel": "別名對映", - "customCliAliasMappingsHelp": "需要穩定簡寫名稱的包裝器指令碼或配置檔案的可選幫助程式別名。", + "customCliAliasMappingsHelp": "需要穩定簡寫名稱的包裝器指令碼或設定檔案的可選幫助程式別名。", "customCliAddAlias": "新增別名", "customCliNoMappings": "還沒有別名對映。如果您的包裝器或團隊指令碼使用穩定的短名稱,請新增一個。", "customCliAliasPlaceholder": "例如評論", @@ -2920,7 +2920,7 @@ "hermesRolesWillUpdate": "{count, plural, one {# 個角色將被更新} other {# 個角色將被更新}}", "hermesPreviewPath": "預覽——將寫入至 ~/.hermes/config.yaml", "hermesSaveDescription": "將選取的模型為每個角色儲存至", - "copilotConfigGenerator": "GitHub Copilot 配置生成器", + "copilotConfigGenerator": "GitHub Copilot 設定生成器", "copilotGeneratorDescriptionPrefix": "產生", "copilotGeneratorDescriptionSuffix": "VS Code GitHub Copilot 的區塊,使用 Azure 廠商模式。選取您想要的模型,然後將 JSON 複製到您的設定檔中。", "copilotCompatibilityWarning": "此設定使用 Azure 廠商解決方案來處理自訂模型清單。已在 VS Code ≥ 1.109GitHub Copilot Chat ≥ v0.37 上測試。未來的擴充功能更新可能會改變此行為。", @@ -2938,7 +2938,7 @@ "copilotPasteInto": "貼上到:", "copilotReloadInstruction": "然後重新載入 VS Code 並在輸入提示中設定 API 金鑰。", "wireApiChatCompletions": "聊天完成 (/chat/completions)", - "wireApiResponses": "響應 API (/responses)" + "wireApiResponses": "回應 API (/responses)" }, "combos": { "title": "組合", @@ -2985,13 +2985,13 @@ "more": "另有 +{count} 個", "reqs": "請求", "success": "成功", - "proxyConfigured": "已配置代理", + "proxyConfigured": "已設定代理", "copyComboName": "複製組合名稱", "enableCombo": "啟用組合", "disableCombo": "停用組合", "testCombo": "測試組合", "duplicate": "複製", - "proxyConfig": "代理配置", + "proxyConfig": "代理設定", "nameRequired": "名稱不能為空", "nameInvalid": "僅允許字母、數字、-、_、/ 和 .", "nameHint": "僅允許字母、數字、-、_、/ 和 .", @@ -3031,7 +3031,7 @@ "contextRelaySummaryModel": "摘要模型", "contextRelaySummaryModelHelp": "僅用於生成交接摘要的可選覆蓋模型。留空則複用當前活躍的 combo 模型。", "contextRelayProviderNote": "Context Relay 當前主要為 Codex 帳戶輪換生成交接摘要。與同一提供者的多個帳戶配合使用時,連續性效果最佳。", - "advancedHint": "留空則使用全域性預設值。這些設定會覆蓋每個提供者的配置。", + "advancedHint": "留空則使用全域性預設值。這些設定會覆蓋每個提供者的設定。", "failoverBeforeRetry": "重試之前進行故障轉移", "maxSetRetries": "最大設定重試次數", "setRetryDelayMs": "設定重試延遲(毫秒)", @@ -3102,7 +3102,7 @@ "example": "同一模型掛在多個帳號上,用於分攤吞吐。" }, "random": { - "when": "你只需要簡單分流,且不想做太多配置。", + "when": "你只需要簡單分流,且不想做太多設定。", "avoid": "你需要嚴格的流量保證。", "example": "對等模型之間的快速原型驗證。" }, @@ -3114,7 +3114,7 @@ "cost-optimized": { "when": "降低成本是你的首要目標。", "avoid": "定價資料缺失或已經過期。", - "example": "後臺任務或批處理作業,優先考慮更低成本。" + "example": "後台任務或批處理作業,優先考慮更低成本。" }, "reset-aware": { "when": "您可以使用配額遙測和不同的重置視窗跨多個帳戶進行路由。", @@ -3170,7 +3170,7 @@ "disableSessionStickiness": "在每次請求時輪換到不同的連線,而不是根據首則訊息雜湊將整個對話固定到單一連線。覆寫全域預設值。保留為「繼承」以保留多輪對話的提示快取命中效果。" }, "templatesTitle": "快捷模板", - "templatesDescription": "先套用一個初始模板,再按需調整模型和配置。", + "templatesDescription": "先套用一個初始模板,再按需調整模型和設定。", "templateApply": "應用模板", "templateHighAvailability": "高可用", "templateHighAvailabilityDesc": "優先順序路由,配合健康檢查和安全重試。", @@ -3212,13 +3212,13 @@ "saveBlockModels": "請至少新增一個模型。", "saveBlockWeighted": "請將權重總和設定為 100%(當前:{total}%)。", "saveBlockPricing": "請至少為一個模型補充定價資訊,或改用其他策略。", - "recommendationsLabel": "推薦配置", + "recommendationsLabel": "推薦設定", "applyRecommendations": "應用推薦", - "recommendationsUpdated": "已為 {strategy} 更新推薦配置。", - "recommendationsApplied": "推薦配置已應用到當前組合。", + "recommendationsUpdated": "已為 {strategy} 更新推薦設定。", + "recommendationsApplied": "推薦設定已應用到當前組合。", "intelligentPanelTitle": "智慧路由儀表盤", "intelligentPanelDesc": "此自動路由組合的即時評分和健康狀態。", - "configOnlyStatus": "配置檢視", + "configOnlyStatus": "設定檢視", "configOnlyHint": "此面板僅顯示路由輸入,即時熔斷器狀態請到健康頁面檢視。", "routingInputs": "路由輸入", "routingInputsHint": "模式包與權重保留在此處;熔斷器執行狀態保留在健康頁面。", @@ -3231,29 +3231,29 @@ "builderNeedValidName": "請先填寫有效的組合名稱再繼續。", "statusOverview": "狀態概覽", "normalOperation": "正常執行", - "allProvidersHealthy": "供應商報告路由狀況良好。", + "allProvidersHealthy": "提供者報告路由狀況良好。", "incidentMode": "事件模式", "highCircuitBreakerRate": "檢測到熔斷器頻繁觸發。", "activeModePack": "當前模式包", "modePackUpdated": "模式包已更新為 {pack}。", "modePackHint": "切換預設以調整路由引擎偏向,無需重建組合。", - "providerScores": "供應商評分", - "allProvidersEvaluated": "未配置候選池。執行時評估所有活躍供應商。", - "excludedProviders": "已排除的供應商", - "excludedProvidersHint": "熔斷器處於 OPEN 狀態的供應商將被臨時排除在路由之外。", - "noExcludedProviders": "當前沒有供應商被排除。", + "providerScores": "提供者評分", + "allProvidersEvaluated": "未設定候選池。執行時評估所有活躍提供者。", + "excludedProviders": "已排除的提供者", + "excludedProvidersHint": "熔斷器處於 OPEN 狀態的提供者將被臨時排除在路由之外。", + "noExcludedProviders": "當前沒有提供者被排除。", "cooldownMinutes": "冷卻:{minutes} 分鐘", - "builderIntelligentTitle": "智慧路由配置", - "builderIntelligentDesc": "為此自動路由組合配置多因子評分引擎。", + "builderIntelligentTitle": "智慧路由設定", + "builderIntelligentDesc": "為此自動路由組合設定多因子評分引擎。", "candidatePoolLabel": "候選池", - "candidatePoolHint": "選擇引擎應評估的供應商。留空則使用所有活躍供應商。", - "candidatePoolEmpty": "暫無可用活躍供應商。", - "candidatePoolAllProviders": "所有供應商", + "candidatePoolHint": "選擇引擎應評估的提供者。留空則使用所有活躍提供者。", + "candidatePoolEmpty": "暫無可用活躍提供者。", + "candidatePoolAllProviders": "所有提供者", "modePackLabel": "模式包", "routerStrategyLabel": "路由策略", "strategyRules": "規則(6 因子評分)", "explorationRateLabel": "探索率", - "explorationRateHint": "{percent}% 的請求可以探索非最優供應商。", + "explorationRateHint": "{percent}% 的請求可以探索非最優提供者。", "budgetCapLabel": "預算上限(美元/請求)", "budgetCapPlaceholder": "無限制", "advancedWeightsTitle": "高階:評分權重", @@ -3268,7 +3268,7 @@ "weightStability": "穩定性", "weightTierPriority": "層級", "weightCacheAffinity": "Cache Hit Affinity", - "reviewIntelligentTitle": "智慧路由配置", + "reviewIntelligentTitle": "智慧路由設定", "strategyRecommendations": { "priority": { "title": "穩妥基線", @@ -3292,7 +3292,7 @@ "tip3": "結合佇列超時,在飽和時快速失敗。" }, "random": { - "title": "低配置快速分散", + "title": "低設定快速分散", "description": "適用於不需要嚴格保證、只想簡單分流的場景。", "tip1": "儘量選擇延遲特徵相近的模型。", "tip2": "保留重試機制,以吸收隨機命中的失敗。", @@ -3310,7 +3310,7 @@ "description": "在具備定價後設資料時,優先路由到成本更低的模型。", "tip1": "確保所有已選模型都具備定價資訊。", "tip2": "為高難度提示保留一個質量更高的回退模型。", - "tip3": "適合批處理或後臺任務等成本是主要指標的場景。" + "tip3": "適合批處理或後台任務等成本是主要指標的場景。" }, "reset-aware": { "title": "重置感知帳戶輪換", @@ -3386,7 +3386,7 @@ "wizardStep3Title": "選擇策略", "wizardStep3Desc": "選擇請求在模型之間的分發方式 — 提供 13 種策略", "wizardStep4Title": "審查並儲存", - "wizardStep4Desc": "審查您的配置並啟用組合", + "wizardStep4Desc": "審查您的設定並啟用組合", "emailVisibilityStateOn": "開啟", "emailVisibilityStateOff": "關閉", "reorderHandle": "拖拽排序", @@ -3444,7 +3444,7 @@ "reviewAdvanced": "高階設定", "reviewAgentFlags": "Agent 標誌", "reviewSequence": "模型序列", - "reviewNoSteps": "未配置任何步驟", + "reviewNoSteps": "未設定任何步驟", "builderStagesDescription": "按順序完成各個階段以定義組合、構建步驟、選擇路由策略並審查結果。", "builderStepsDescription": "按順序構建每個組合步驟:提供者、模型,然後是帳戶。這允許在不同帳戶上重複使用相同的提供者和模型。", "selectProvider": "選擇提供者", @@ -3476,8 +3476,8 @@ "agentFeaturesSystemMessagePlaceholder": "覆蓋通過此組合路由的所有請求的系統提示…", "agentFeaturesSystemMessageHint": "替換客戶端傳送的任何系統訊息。留空則透傳客戶端系統訊息。", "agentFeaturesToolFilterRegex": "工具過濾器正則", - "agentFeaturesToolFilterHint": "只有名稱匹配此正則的工具才會轉發給供應商。留空則轉發所有工具。", - "agentFeaturesContextCacheHint": "跨輪次鎖定供應商/模型以保持快取會話。內部標籤在轉發給供應商前會被移除。", + "agentFeaturesToolFilterHint": "只有名稱匹配此正則的工具才會轉發給提供者。留空則轉發所有工具。", + "agentFeaturesContextCacheHint": "跨輪次鎖定提供者/模型以保持快取會話。內部標籤在轉發給提供者前會被移除。", "agentFeaturesContextCacheProtection": "上下文快取保護", "agentFeaturesContextLength": "上下文長度", "agentFeaturesContextLengthPlaceholder": "例如128000", @@ -3593,7 +3593,7 @@ "apiKeyLabel": "API 金鑰", "registeredKeys": "已註冊金鑰", "chatCompletions": "對話補全", - "responses": "響應", + "responses": "回應", "listModels": "列出模型", "usingCloudProxy": "當前使用雲代理", "usingLocalServer": "當前使用本地伺服器", @@ -3608,7 +3608,7 @@ "imageGeneration": "影像生成", "imageDesc": "根據文本提示生成影像", "rerank": "重排", - "rerankDesc": "按與查詢的相關性重新排序文件", + "rerankDesc": "按與查詢的相關性重新排序檔案", "audioTranscription": "音訊轉錄", "audioTranscriptionDesc": "將音訊檔案轉錄為文本(Whisper)", "textToSpeech": "文本轉語音", @@ -3619,7 +3619,7 @@ "moderationsDesc": "內容安全稽核與分類", "responsesDesc": "適用於 Codex 和高階智慧體工作流的 OpenAI Responses API", "listModelsDesc": "列出所有已連線提供者下的可用模型", - "settingsApiDesc": "通過 API 讀取和修改 OmniRoute 配置", + "settingsApiDesc": "通過 API 讀取和修改 OmniRoute 設定", "settingsApi": "設定 API", "categoryCore": "核心 API", "categoryMedia": "媒體與多模態", @@ -3628,12 +3628,12 @@ "webSearch": "網頁搜尋", "webSearchDesc": "統一接入多個提供者的網頁搜尋,支援自動故障轉移與快取", "searchProvider": "搜尋提供者", - "searchProviderDesc": "該提供者會用於 `POST /v1/search` 的網頁搜尋。無需配置模型,只要連線 API 金鑰即可使用。", + "searchProviderDesc": "該提供者會用於 `POST /v1/search` 的網頁搜尋。無需設定模型,只要連線 API 金鑰即可使用。", "enableCloudTitle": "啟用雲代理", "whatYouGet": "啟用後可獲得", "cloudBenefitAccess": "從世界任何地方訪問你的 API", "cloudBenefitShare": "方便與團隊共享端點", - "cloudBenefitPorts": "無需開放埠或配置防火牆", + "cloudBenefitPorts": "無需開放埠或設定防火牆", "cloudBenefitEdge": "全球邊緣網路加速", "cloudSessionNote": "雲端會保留你的認證會話 1 天;若未使用,將自動刪除。", "cloudUnstableNote": "目前雲端在部分 Claude Code OAuth 場景下仍不夠穩定。", @@ -3707,7 +3707,7 @@ "openA2aDashboard": "開啟 A2A 管理", "mcpQuickStartTitle": "MCP 快速開始", "mcpQuickStartStep1": "通過 `omniroute --mcp` 啟動 MCP 服務。", - "mcpQuickStartStep2": "將你的 MCP 客戶端配置為通過 stdio 傳輸連線。", + "mcpQuickStartStep2": "將你的 MCP 客戶端設定為通過 stdio 傳輸連線。", "mcpQuickStartStep3": "呼叫 `omniroute_get_health`、`omniroute_list_combos` 等工具驗證連通性。", "a2aQuickStartTitle": "A2A 快速開始", "a2aQuickStartStep1": "通過 `/.well-known/agent.json` 發現 Agent Card。", @@ -3754,7 +3754,7 @@ "tailscaleLastError": "最近錯誤:{error}", "tailscaleInstallTitle": "安裝 Tailscale", "tailscaleInstallIntro": "在此機器上安裝 Tailscale,並準備讓 OmniRoute 啟用 Funnel。", - "tailscaleInstallPasswordHint": "在 macOS 和 Linux 上,安裝軟體包和啟動守護程序可能需要 sudo。", + "tailscaleInstallPasswordHint": "在 macOS 和 Linux 上,安裝軟體包和啟動守護程式可能需要 sudo。", "tailscaleSudoPlaceholder": "可選 sudo 密碼", "tailscaleInstalling": "正在安裝", "tailscaleSudoLabel": "Sudo 密碼(macOS/Linux 上必需)", @@ -3889,8 +3889,8 @@ "apiEndpointsDescription": "可被其他應用程式和服務使用的後端API端點。", "comingSoon": "即將推出", "plannedFeatures": "計劃的功能", - "featureRestApi": "REST API目錄與互動式文件", - "featureWebhooks": "Webhook配置和事件訂閱", + "featureRestApi": "REST API目錄與互動式檔案", + "featureWebhooks": "Webhook設定和事件訂閱", "featureSwagger": "OpenAPI / Swagger規範自動生成", "featureAuth": "每個端點的API金鑰和OAuth範圍管理" }, @@ -3901,28 +3901,28 @@ "confirmSwitchCombo": "確定要將組合“{combo}”設為{action}嗎?", "switchComboFailed": "切換組合狀態失敗。", "switchComboSuccess": "組合“{combo}”已更新。", - "confirmApplyProfile": "確定要應用彈性配置“{profile}”嗎?", - "applyProfileFailed": "應用彈性配置失敗。", - "applyProfileSuccess": "已應用配置“{profile}”。", + "confirmApplyProfile": "確定要應用彈性設定“{profile}”嗎?", + "applyProfileFailed": "應用彈性設定失敗。", + "applyProfileSuccess": "已應用設定“{profile}”。", "confirmResetBreakers": "確定要重置全部斷路器嗎?", "resetBreakersFailed": "重置斷路器失敗。", "resetBreakersSuccess": "斷路器已重置。", - "processStatus": "程序狀態", + "processStatus": "程式狀態", "online": "線上", "offline": "離線", "disableLabel": "停用 {label}", "enableLabel": "啟用 {label}", "transportMode": "傳輸模式", - "transportStdioDesc": "本地 — IDE 通過 omniroute --mcp 啟動程序", + "transportStdioDesc": "本地 — IDE 通過 omniroute --mcp 啟動程式", "transportSseDesc": "遠端 — 基於 HTTP 的 Server-Sent Events", "transportStreamableHttpDesc": "遠端 — 現代雙向 HTTP", "copy": "複製", "mcpDashboardCopyUrl": "複製網址", "mcpDisabledTitle": "MCP 已停用", - "mcpDisabledDesc": "在上方啟用 MCP 後即可配置傳輸模式並檢視伺服器遙測。", + "mcpDisabledDesc": "在上方啟用 MCP 後即可設定傳輸模式並檢視伺服器遙測。", "mcpIntro": "Model Context Protocol — {tools} 個工具,覆蓋 {scopes} 個作用域,支援 {transports} 種傳輸方式(stdio / SSE / Streamable HTTP)。", "mcpStep1": "通過 {code} 執行", - "mcpStep2": "將 MCP 客戶端配置為通過 stdio 傳輸連線。", + "mcpStep2": "將 MCP 客戶端設定為通過 stdio 傳輸連線。", "mcpStep3": "呼叫 {code1} 和 {code2} 等工具。", "pid": "PID", "sessionUptime": "會話執行時長", @@ -3946,11 +3946,11 @@ "active": "已啟用", "activateCombo": "啟用組合", "deactivateCombo": "停用組合", - "applyResilienceProfile": "應用彈性配置", + "applyResilienceProfile": "應用彈性設定", "profileAggressive": "激進", "profileBalanced": "平衡", "profileConservative": "保守", - "applyProfile": "應用配置", + "applyProfile": "應用設定", "resetCircuitBreakers": "重置斷路器", "resetCircuitBreakersHelp": "清除當前斷路器狀態及提供者失敗計數。", "resetAllBreakers": "重置全部斷路器", @@ -4050,7 +4050,7 @@ "enableLabel": "啟用 {label}", "a2aDisabledTitle": "A2A 已停用", "a2aDisabledDesc": "在上方啟用 A2A 後即可檢視任務遙測、代理詳情與校驗工具。", - "a2aIntro": "Agent2Agent JSON-RPC 2.0 端點 — 傳送任務、流式響應、取消執行中的任務。", + "a2aIntro": "Agent2Agent JSON-RPC 2.0 端點 — 傳送任務、流式回應、取消執行中的任務。", "a2aStep1": "在 {code} 處發現代理卡片。", "a2aStep2": "向 {code1} 傳送 JSON-RPC,使用 {code2} 或 {code3}。", "a2aStep3": "使用 {code1} 和 {code2} 跟蹤並取消任務。" @@ -4059,16 +4059,16 @@ "a": "A", "actions": "操作", "addMemory": "新增記憶", - "allTypes": "全部型別", + "allTypes": "全部類型", "cancel": "取消", "checkHealth": "檢查健康狀況", "checkingHealth": "正在檢查...", "compactOld": "緊湊舊版", "concept": { "title": "對話記憶", - "description": "OmniRoute 從每次對話中學習,記住事實、事件、程式和語義概念,使響應更加準確和上下文感知。", + "description": "OmniRoute 從每次對話中學習,記住事實、事件、程式和語義概念,使回應更加準確和上下文感知。", "howWorksToggle": "它是如何工作的", - "howWorksContent": "1. 自動提取:在每個響應結束時,事實和事件會被自動檢測並儲存。\n2. 檢索:在每個響應之前,通過FTS5(精確)、向量(語義)或混合RRF搜尋最相關的記憶。\n3. 注入:相關記憶被注入到助手上下文中,以提高響應質量。\n4. 管理:使用此頁面檢視、編輯、匯出和壓縮舊記憶。" + "howWorksContent": "1. 自動提取:在每個回應結束時,事實和事件會被自動檢測並儲存。\n2. 檢索:在每個回應之前,通過FTS5(精確)、向量(語義)或混合RRF搜尋最相關的記憶。\n3. 注入:相關記憶被注入到助手上下文中,以提高回應質量。\n4. 管理:使用此頁面檢視、編輯、匯出和壓縮舊記憶。" }, "content": "內容", "contentPlaceholder": "要記住的值或 JSON 內容", @@ -4094,7 +4094,7 @@ "transformersLabel": "Transformers.js (MiniLM)", "transformersDesc": "通過 @huggingface/transformers 本地嵌入 (~400MB RAM)", "providerModelLabel": "提供者 / 模型", - "noRemoteProviders": "沒有配置 API 金鑰的提供者", + "noRemoteProviders": "沒有設定 API 金鑰的提供者", "selectProviderModel": "選擇一個模型", "staticEnabledLabel": "啟用靜態藥水", "staticEnabledDesc": "在本地下載並使用 potion-base-8M 模型", @@ -4209,7 +4209,7 @@ "enableDesc": "在搜尋後使用重新排序模型對結果進行重新排序", "warning": "Rerank 會增加 +200-500ms 的延遲和每個請求的額外成本。請謹慎使用。", "providerModelLabel": "重新排序提供者 / 模型", - "noProviderWithKey": "沒有配置 API 金鑰的提供者。請配置一個提供者以使用 rerank。", + "noProviderWithKey": "沒有設定 API 金鑰的提供者。請設定一個提供者以使用 rerank。", "selectProviderModel": "選擇一個提供者/模型" }, "save": "儲存", @@ -4239,7 +4239,7 @@ "semantic": "概念、偏好和領域知識" }, "totalEntries": "總條目數", - "type": "型別", + "type": "類型", "memoryEnabled": "記憶體已啟用" }, "skills": { @@ -4270,13 +4270,13 @@ "status": "狀態", "duration": "耗時", "time": "時間", - "sandboxConfig": "沙箱配置", + "sandboxConfig": "沙箱設定", "cpuLimit": "CPU 限制", "cpuLimitDesc": "單個技能允許的最長執行時間", "memoryLimit": "記憶體限制", "memoryLimitDesc": "允許分配的最大記憶體", "timeout": "超時", - "timeoutDesc": "等待響應的最長時間", + "timeoutDesc": "等待回應的最長時間", "networkAccess": "網路訪問", "networkAccessDesc": "允許發起出站網路請求", "mode": "模式", @@ -4314,7 +4314,7 @@ "activeProvider": "當前提供者:", "changeInSettings": "可在 設定 → 記憶與技能 中修改。", "installs": "安裝", - "marketplaceSkillsMpHint": "請在設定中配置 SkillsMP API 金鑰以瀏覽市場。", + "marketplaceSkillsMpHint": "請在設定中設定 SkillsMP API 金鑰以瀏覽市場。", "marketplaceSkillsShHint": "搜尋 skills.sh 公開目錄以發現並安裝代理技能。", "installSkillModalDesc": "貼上技能清單 JSON 或上傳 .json 檔案。", "uploadJson": "上傳 JSON", @@ -4369,10 +4369,10 @@ "issuesLabel": "檢測到的問題", "operational": "執行中", "providers": "提供者", - "configuredProvidersLabel": "在儀表板中配置", - "configuredProvidersHint": "指已在 /dashboard/providers 中配置憑證的提供者,無論當前執行時狀態如何。", + "configuredProvidersLabel": "在儀表板中設定", + "configuredProvidersHint": "指已在 /dashboard/providers 中設定憑證的提供者,無論當前執行時狀態如何。", "activeProviders": "{count} 個活躍", - "activeProvidersHint": "指當前已啟用並可參與請求路由的已配置提供者。", + "activeProvidersHint": "指當前已啟用並可參與請求路由的已設定提供者。", "monitoredProviders": "{count} 個監控中", "monitoredProvidersHint": "指當前由斷路器健康監控器持續跟蹤的提供者。", "healthyCount": "{count} 健康", @@ -4395,10 +4395,10 @@ "resetAll": "全部重置", "until": "直到 {time}", "limitExhausted": "已耗盡", - "learnedFromHeaders": "從響應頭學習", + "learnedFromHeaders": "從回應頭學習", "remainingOfLimit": "剩餘 {remaining}/{limit}", "throttleStatus": "限流:{value}", - "lastHeaderUpdate": "響應頭更新:{age}", + "lastHeaderUpdate": "回應頭更新:{age}", "databaseHealth": "資料庫健康狀況", "databaseHealthDescription": "診斷並修復過時的配額/領域行和損毀的組合參考。", "status": "狀態", @@ -4443,7 +4443,7 @@ "modelLockoutSummary": "{reason} · 剩餘 {duration}", "locked": "已鎖定", "inferred": "推斷", - "inactive": "不活躍", + "inactive": "未啟用", "noConnectionId": "無連線 ID", "accountModelSummary": "{connectionId} · {count} 個模型", "cooldown": "冷卻中", @@ -4459,7 +4459,7 @@ }, "telemetry": { "title": "系統遙測", - "description": "來自此 OmniRoute 程序的滾動請求、執行時、會話和記憶體訊號。", + "description": "來自此 OmniRoute 程式的滾動請求、執行時、會話和記憶體訊號。", "uptime": "執行時間", "totalRequests": "請求總數", "avgLatency": "平均延遲", @@ -4478,7 +4478,7 @@ "title": "MITM 代理", "description": "用於攔截和路由客戶端請求的透明代理。", "enable": "啟用 MITM 代理", - "enableDesc": "啟動或停止本地攔截程序和 DNS 覆蓋。", + "enableDesc": "啟動或停止本地攔截程式和 DNS 覆蓋。", "status": "狀態", "running": "執行中", "stopped": "已停止", @@ -4489,7 +4489,7 @@ "apiKey": "路由器 API 金鑰", "apiKeyPlaceholder": "可選;留空則回退到本地金鑰", "sudoPassword": "Sudo 密碼", - "cachedPassword": "已為此程序快取", + "cachedPassword": "已為此程式快取", "saveSettings": "儲存設定", "settingsSaved": "MITM 設定已儲存。", "startedSuccess": "MITM 代理已啟動。", @@ -4510,7 +4510,7 @@ "targetRoutes": "目標路由", "interceptedRequests": "已攔截請求", "activeConnections": "活動連線", - "dnsConfigured": "DNS 已配置", + "dnsConfigured": "DNS 已設定", "pid": "PID", "lastIntercept": "上次攔截", "target": "目標", @@ -4518,10 +4518,10 @@ "localPort": "本地埠", "endpoints": "端點", "enabled": "已啟用", - "configured": "已配置", + "configured": "已設定", "yes": "是", "no": "否", - "noTargets": "未配置目標路由。" + "noTargets": "未設定目標路由。" }, "limits": { "title": "限制和配額", @@ -4543,7 +4543,7 @@ "filterByAction": "按操作過濾...", "filterByActor": "按操作人篩選...", "filterEntriesAria": "過濾稽核日誌條目", - "filterByActionTypeAria": "按操作型別過濾", + "filterByActionTypeAria": "按操作類型過濾", "filterByActorAria": "按操作人篩選", "refreshAuditLogAria": "重新整理稽核日誌", "tableAria": "稽核日誌條目", @@ -4570,7 +4570,7 @@ "offset": "偏移量", "limit": "限制", "status": "狀態", - "resourceType": "資源型別", + "resourceType": "資源類型", "totalEntries": "總條目數", "tab": "標籤頁", "auditModalSubtitle": "審計詳情", @@ -4591,7 +4591,7 @@ "activeStageWaitingRateLimit": "等待速率限制器", "activeStageRateLimitSlotAcquired": "已獲取速率限制槽位", "activeStageSendingToProvider": "正在傳送到上游", - "activeStageProviderResponseStarted": "上游響應已開始", + "activeStageProviderResponseStarted": "上游回應已開始", "count": "數量", "payloads": "Payload", "viewPayloads": "檢視", @@ -4668,9 +4668,9 @@ "testingConnection": "測試連線...", "connectionSuccessful": "連線成功!您的提供者已準備就緒。", "noProviderFound": "未找到提供者。您可以稍後從儀表板新增一個。", - "testFailed": "測試失敗,但您可以稍後配置。", + "testFailed": "測試失敗,但您可以稍後設定。", "couldNotTest": "現在無法測試。您可以從儀表板進行測試。", - "doneDesc": "你都準備好了!您的 OmniRoute 例項已配置並準備好代理 AI 請求。", + "doneDesc": "你都準備好了!您的 OmniRoute 例項已設定並準備好代理 AI 請求。", "yourEndpoint": "您的端點:", "continue": "繼續", "retry": "重試", @@ -4695,7 +4695,7 @@ "label": "後備和專業", "description": "本地託管或專用端點用作後備。" }, - "configure": "配置提供者" + "configure": "設定提供者" }, "tierFlowDiagramAlt": "OmniRoute 3 層回退圖", "apiKeyMgmt": "API金鑰管理" @@ -4757,7 +4757,7 @@ }, "editProvider": "編輯提供者", "deleteProvider": "刪除提供者", - "noProviders": "沒有配置提供者", + "noProviders": "沒有設定提供者", "modelAvailability": "模型可用性", "accounts": "帳戶", "newAccount": "新帳戶", @@ -4848,7 +4848,7 @@ "addAnthropicCompatible": "新增 Anthropic 相容端點", "addNewProvider": "新增新提供者", "backToProviders": "返回提供者", - "configureNewProvider": "配置新的 AI 提供者以與您的應用程式一起使用。", + "configureNewProvider": "設定新的 AI 提供者以與您的應用程式一起使用。", "providerLabel": "提供者", "selectProvider": "選擇提供者", "selectedProvider": "選定的提供者", @@ -4863,7 +4863,7 @@ "oauth2Desc": "使用 OAuth2 身份驗證連線您的帳戶。", "displayName": "顯示名稱", "displayNamePlaceholder": "例如,生產 API、開發環境", - "displayNameHint": "可選。用於標識此配置的友好名稱。", + "displayNameHint": "可選。用於標識此設定的友好名稱。", "active": "活躍", "activeDescription": "啟用此提供者以在您的應用程式中使用", "cancel": "取消", @@ -4871,10 +4871,10 @@ "failedCreate": "建立提供者失敗", "errorOccurred": "發生錯誤。請再試一次。", "modelStatus": "模型狀態", - "showConfiguredOnly": "僅顯示已配置", + "showConfiguredOnly": "僅顯示已設定", "allModelsOperational": "所有模型執行正常", "modelsWithIssues": "{count} 有問題的模型", - "allModelsNormal": "所有模型當前響應正常。", + "allModelsNormal": "所有模型當前回應正常。", "cooldownCleared": "{model} 的冷卻時間已清除", "failedClearCooldown": "無法清除冷卻時間", "loadingAvailability": "正在載入模型可用性...", @@ -4896,12 +4896,12 @@ "nameLabel": "名稱", "prefixLabel": "字首", "baseUrlLabel": "基礎 URL", - "apiTypeLabel": "API 型別", + "apiTypeLabel": "API 類型", "prefixHint": "必填。模型名稱使用的唯一字首。", "nameHint": "必填。該節點的友好標籤。", "baseUrlHint": "必填。 提供者 API 基本 URL。", "iconUrlLabel": "圖示網址", - "iconUrlHint": "選用。顯示為此供應商圖示的圖片網址。", + "iconUrlHint": "選用。顯示為此提供者圖示的圖片網址。", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -4958,7 +4958,7 @@ "noNewModelsToImport": "沒有新模型可匯入 — 所有模型已在登錄檔或自定義模型列表中", "skippingExistingModels": "跳過 {count} 個已有模型", "autoSync": "自動同步", - "autoSyncTooltip": "每 24 小時自動重新整理模型列表(可通過 MODEL_SYNC_INTERVAL_HOURS 配置)", + "autoSyncTooltip": "每 24 小時自動重新整理模型列表(可通過 MODEL_SYNC_INTERVAL_HOURS 設定)", "autoSyncEnabled": "自動同步已啟用 — 模型將定期重新整理", "autoSyncDisabled": "自動同步已停用", "autoSyncToggleFailed": "切換自動同步失敗", @@ -4967,11 +4967,11 @@ "clearAllModelsSuccess": "所有模型已清除", "clearAllModelsFailed": "清除模型失敗", "addConnectionToImport": "新增連線以啟用匯入。", - "noModelsConfigured": "尚未配置模型", + "noModelsConfigured": "尚未設定模型", "connectionCount": "{count} 連線", "fetchingModels": "正在獲取可用模型...", "failedFetchModels": "獲取模型失敗", - "noFreeModelsFound": "此供應商未找到免費模型 — 未匯入任何內容。", + "noFreeModelsFound": "此提供者未找到免費模型 — 未匯入任何內容。", "fetchModelsSuccess": "找到 {count} 個新模型", "fetchModelsFailed": "無法自動獲取模型(可從設定中重試)", "noModelsFound": "未找到模型", @@ -5042,8 +5042,8 @@ "singleConnectionPerCompatible": "每個相容節點僅允許一個連線。如果需要更多連線,請新增另一個節點。", "connections": "連線", "providerProxyTitleConfigured": "提供者代理:{host}", - "configured": "已配置", - "providerProxyConfigureHint": "為該提供者的所有連線配置代理", + "configured": "已設定", + "providerProxyConfigureHint": "為該提供者的所有連線設定代理", "providerProxy": "提供者代理", "repairEnv": "修復 .env", "repairEnvWorking": "修復中...", @@ -5099,7 +5099,7 @@ "disableConnection": "停用連線", "enableConnection": "啟用連線", "reauthenticateConnection": "重新驗證此連線", - "proxyConfig": "代理配置", + "proxyConfig": "代理設定", "aliasExistsAlert": "別名“{alias}”已存在。請使用不同的模型或編輯現有別名。", "aliasInputPlaceholder": "alias name", "clickToSetAlias": "Click to set alias", @@ -5132,7 +5132,7 @@ "contextWindowOverrideHint": "當提供者回報不正確時,手動設定此模型的實際上下文視窗(token)。此設定優先於自動偵測/目錄值,並可防止組合路由丟棄該模型。", "contextWindowOverrideInvalid": "上下文視窗覆寫值必須為正整數 token 數", "visionCapableLabel": "支援視覺", - "visionCapableHint": "當供應商的探索中繼資料未回報圖片輸入模式時(常見於自託管/本地後端),手動將此模型標記為具備視覺能力。", + "visionCapableHint": "當提供者的探索中繼資料未回報圖片輸入模式時(常見於自託管/本地後端),手動將此模型標記為具備視覺能力。", "compatParamFiltersLabel": "參數過濾器", "compatBlockedParamsHint": "封鎖的參數(從請求中移除)", "compatAllowedParamsHint": "允許的參數(拒絕後重新加入)", @@ -5161,7 +5161,7 @@ "interceptionLoadError": "載入攔截設定失敗:{error}", "interceptionSaveError": "儲存攔截設定失敗:{error}", "compatUpstreamHeadersLabel": "上游額外請求頭", - "compatUpstreamHeadersHint": "與修改廠商連線/API 配置同屬高許可權能力,僅可信管理員應使用。這些頭會在 OmniRoute 按廠商 API Key 自動加好鑑權頭之後再合併。若「名稱」與系統已加的頭相同(例如都叫 Authorization),則以你填的值為準,會整段替換自動那條(含 Bearer 權杖),上游請求裡不再使用面板裡儲存的金鑰來生成 Authorization。填錯可能導致 401,請謹慎。每個請求頭單獨一行;部分閘道器需要額外 Authentication 等可在此加。滑鼠移入或聚焦「值」可暫時看明文。點空白處、關閉本面板或切走焦點即儲存。", + "compatUpstreamHeadersHint": "與修改廠商連線/API 設定同屬高許可權能力,僅可信管理員應使用。這些頭會在 OmniRoute 按廠商 API Key 自動加好鑑權頭之後再合併。若「名稱」與系統已加的頭相同(例如都叫 Authorization),則以你填的值為準,會整段替換自動那條(含 Bearer 權杖),上游請求裡不再使用面板裡儲存的金鑰來生成 Authorization。填錯可能導致 401,請謹慎。每個請求頭單獨一行;部分閘道器需要額外 Authentication 等可在此加。滑鼠移入或聚焦「值」可暫時看明文。點空白處、關閉本面板或切走焦點即儲存。", "compatUpstreamHeaderName": "請求頭名稱", "compatUpstreamHeaderValue": "值", "compatUpstreamAddRow": "新增請求頭", @@ -5215,7 +5215,7 @@ "failed": "失敗", "leaveBlankKeepCurrentApiKey": "留空以保留當前的 API 金鑰。", "editCompatibleTitle": "編輯 {type} 相容", - "compatibleBaseUrlHint": "{type} 相容 API 的根 URL。若端點路徑是自定義的,請在高階設定中配置。", + "compatibleBaseUrlHint": "{type} 相容 API 的根 URL。若端點路徑是自定義的,請在高階設定中設定。", "apiKeyForCheck": "API 金鑰(用於檢查)", "testModelIdLabel": "模型 ID(選用)", "testModelIdPlaceholder": "例如:my-model-id", @@ -5403,7 +5403,7 @@ "bulkPasteHint": "每行貼上一個 API 金鑰。空行會被忽略,重複金鑰會被跳過。", "ccCompatibleBaseUrlHint": "Claude Code 專用中轉站的 Base URL,不要包含 /messages。", "ccCompatibleBaseUrlPlaceholder": "https://relay.example.com/v1", - "ccCompatibleChatPathHint": "預設使用 Claude Code 嚴格的 Messages API 路徑。僅在中轉站文件要求時修改。", + "ccCompatibleChatPathHint": "預設使用 Claude Code 嚴格的 Messages API 路徑。僅在中轉站檔案要求時修改。", "ccCompatibleContext1mDescription": "當所選 Claude 模型支援時,新增 context-1m beta header。", "ccCompatibleContext1mLabel": "啟用 1M context beta", "ccCompatibleRedactThinkingDescription": "為要求隱藏 Claude 思考流的 CC Compatible 上游新增 redact-thinking beta header。", @@ -5427,8 +5427,8 @@ "compatUpstreamHeaderNamePlaceholder": "上游 Header 名稱", "compatUpstreamHeaderValuePlaceholder": "上游 Header 值", "compatible": "相容", - "configuredCount": "已配置數量", - "consoleApiKeyOracleHint": "用於從 Console 獲取或驗證 API key 的輔助配置。", + "configuredCount": "已設定數量", + "consoleApiKeyOracleHint": "用於從 Console 獲取或驗證 API key 的輔助設定。", "consoleApiKeyOracleLabel": "Console API key Oracle", "consoleApiKeyOraclePlaceholder": "Console API key Oracle 佔位符", "newApiUserIdLabel": "New-API 使用者 ID", @@ -5461,9 +5461,9 @@ "apiKeyInvalidAlertTitle": "API 金鑰健康提醒", "apiKeyWarningAlert": "{count} 個 API 金鑰在以下連線中處於警告狀態:{connections}。請檢查以防止輪換問題。", "apiKeyWarningAlertTitle": "API 金鑰警告", - "googlePseInfo": "配置 Google Programmable Search Engine 以啟用 Web Search。", + "googlePseInfo": "設定 Google Programmable Search Engine 以啟用 Web Search。", "antigravityProjectIdHint": "反重力雲程式碼請求的可選覆蓋。留空以使用 Google OAuth 期間發現的專案。", - "antigravityClientProfileLabel": "客戶端配置", + "antigravityClientProfileLabel": "客戶端設定", "antigravityClientProfileHint": "選擇 OmniRoute 向 API 呈現的 Antigravity 客戶端身份。", "antigravityClientProfileIde": "IDE", "antigravityClientProfileCli": "CLI", @@ -5499,9 +5499,9 @@ "oauth": "OAuth", "openCliTools": "開啟 CLI Tools", "openSettings": "開啟設定", - "openaiResponsesStoreDescription": "允許相容的 Responses API 請求保留已儲存的響應狀態。", + "openaiResponsesStoreDescription": "允許相容的 Responses API 請求保留已儲存的回應狀態。", "openaiResponsesStoreLabel": "OpenAI Responses 儲存", - "perplexitySearchSharedKeyInfo": "Perplexity Search 可使用共享 key 配置。", + "perplexitySearchSharedKeyInfo": "Perplexity Search 可使用共享 key 設定。", "perplexityWebCookieHint": "從 Perplexity Web 會話複製 Cookie。", "perplexityWebCookiePlaceholder": "Perplexity Web Cookie", "personalAccessTokenLabel": "個人訪問權杖", @@ -5535,7 +5535,7 @@ "searchByModel": "依模型搜尋…", "searchProvidersHeading": "搜尋 Provider", "searxngBaseUrlHint": "SearXNG 例項的 Base URL。", - "searxngInfo": "配置 SearXNG 以啟用自託管 Web Search。", + "searxngInfo": "設定 SearXNG 以啟用自託管 Web Search。", "sessionCookieLabel": "Session Cookie", "showEmail": "顯示郵箱", "snowflakeBaseUrlHint": "Snowflake Cortex 服務的 Base URL。", @@ -5576,7 +5576,7 @@ "zedImportDescription": "探索 Zed IDE 存放在 OS 鑰匙圈中的 AI 提供者憑證(OpenAI、Anthropic、Google、Mistral、xAI),並將其匯入為連線。此機器上必須已安裝 Zed IDE。", "zedImportButton": "從 Zed 匯入", "zedImportFailed": "Zed 匯入失敗", - "zedImportHint": "從 Zed 配置中匯入 Provider。", + "zedImportHint": "從 Zed 設定中匯入 Provider。", "zedImportNetworkError": "Zed 匯入網路錯誤", "zedImportNone": "沒有可從 Zed 匯入的內容", "zedImportSuccess": "Zed 匯入成功", @@ -5633,7 +5633,7 @@ "webFetch": "網頁抓取", "webFetchTooltip": "從網頁 URL 抽取內容的提供者(HTML → Markdown、抓取、截圖)", "webFetchProvidersHeading": "網頁抓取提供者", - "compatibleProvidersDesc": "您託管或配置的 OpenAI 相容和 Anthropic 相容端點。將任意 OpenAI SDK 指向您的 URL 並在此處路由請求。", + "compatibleProvidersDesc": "您託管或設定的 OpenAI 相容和 Anthropic 相容端點。將任意 OpenAI SDK 指向您的 URL 並在此處路由請求。", "oauthProvidersDesc": "通過 OAuth 認證的提供者——登入一次,OmniRoute 自動處理權杖輪換。", "webCookieProvidersDesc": "這些提供者使用瀏覽器網路會話、cookie 或網路權杖而不是 API 金鑰。開啟提供程式以新增所需的會話憑據。", "apiKeyProvidersDesc": "標準 API 金鑰提供者。新增金鑰後,OmniRoute 代為路由、重試和限流。", @@ -5644,7 +5644,7 @@ "enterpriseCloudDesc": "企業級和雲託管模型,提供增強 SLA、合規認證和專用容量。", "cloudAgentProvidersDesc": "自主雲代理,可執行長時間執行的任務,支援計劃審批和即時狀態跟蹤。", "localProvidersDesc": "在您自己的硬體上執行的自託管模型。資料不會離開您的基礎設施。", - "searchProvidersDesc": "網頁和文件搜尋提供者。可附加到 LLM 呼叫以實現檢索增強生成(RAG)。", + "searchProvidersDesc": "網頁和檔案搜尋提供者。可附加到 LLM 呼叫以實現檢索增強生成(RAG)。", "audioProvidersDesc": "文本轉語音和語音轉文本提供者,用於語音輸入輸出和音訊轉錄管道。", "embeddingRerankProvidersDesc": "向量嵌入和重排序提供者,用於語義搜尋、RAG 管道和相似度評分。", "imageProvidersDesc": "影像生成和視覺提供者——從文本建立影像或分析現有影像。", @@ -5652,7 +5652,7 @@ "onboardingWizard": "提供者入職嚮導", "onboardingWizardShort": "入職嚮導", "onboardingWizardDescription": "通過驗證、永續性和即時連線測試連線 API 金鑰、自定義相容和 OAuth 提供者。", - "onboardingStepType": "型別", + "onboardingStepType": "類型", "onboardingStepProvider": "提供者", "onboardingStepCredentials": "憑證", "onboardingStepResult": "結果", @@ -5665,7 +5665,7 @@ "onboardingChooseOAuthProvider": "選擇 OAuth 提供者", "onboardingChooseApiKeyProvider": "選擇 API 金鑰提供者", "onboardingChooseProviderDescription": "選擇一個提供者,然後嚮導將指導您完成憑據和測試。", - "onboardingChangeType": "變更型別", + "onboardingChangeType": "變更類型", "onboardingChangeProvider": "更換提供者", "onboardingSearchProviders": "搜尋提供者...", "onboardingApiKeyOptional": "API 金鑰可選", @@ -6004,7 +6004,7 @@ "routingRegexFlagsHint": "JavaScript 正則修飾符(g = 全部匹配,i = 忽略大小寫,s = 點匹配換行,m = 多行)。預設 'g'。", "routingBlockTextHint": "新系統塊的完整文本。使用字面字串;系統塊僅儲存文本。", "routingIdempotencyKeyHint": "可選。設定後,如果已有以該鍵開頭的塊存在,則跳過此操作,避免重試時重複插入。", - "routingBillingEntrypointHint": "作為 'cc_entrypoint=' 注入的值。Anthropic 接受 'sdk-cli'(Agent SDK)、'cli'(Claude Code CLI)或其他文件化的值。", + "routingBillingEntrypointHint": "作為 'cc_entrypoint=' 注入的值。Anthropic 接受 'sdk-cli'(Agent SDK)、'cli'(Claude Code CLI)或其他檔案化的值。", "routingBillingVersionFormatHint": "cc_version= 後 3 字元構建雜湊的計算方式。'ex-machina' = sha256(CCH_SALT + 第一條使用者訊息字元 + 版本)(每條訊息獨立)。'omniroute-daystamp' = sha256(YYYY-MM-DD + 版本)(按天穩定)。", "routingBillingCchAlgoHint": "5 字元 cch= 權杖的計算方式。'sha256-first-user' = 第一條使用者訊息文本的 sha256;'xxhash64-body' = 由請求體級簽名稍後填充;'static-zero' = 字面佔位符 '00000'。", "routingObfuscateWordsHint": "要混淆的小寫詞語。ZWJ 插入對大小寫不敏感,所以 'opencode' 也會匹配 'OpenCode' 與 'OPENCODE'。", @@ -6140,8 +6140,8 @@ "comboConfigModeGuidedDesc": "使用當前逐步 Combo 建置器。", "comboConfigModeExpert": "專家", "comboConfigModeExpertDesc": "在單一頁面顯示所有 Combo 選項,並啟用直接輸入模型。", - "providerQuotaAutoRefresh": "供應商配額自動重新整理", - "providerQuotaAutoRefreshDesc": "在供應商限制檢視開啟時自動重新整理。", + "providerQuotaAutoRefresh": "提供者配額自動重新整理", + "providerQuotaAutoRefreshDesc": "在提供者限制檢視開啟時自動重新整理。", "providerQuotaAutoRefreshToggle": "自動重新整理", "providerQuotaAutoRefreshToggleDesc": "在頁面可見時每隔幾分鐘重新整理配額檢視。", "providerQuotaAutoRefreshInterval": "重新整理間隔", @@ -6185,7 +6185,7 @@ "customBannedSignalsPlaceholder": "例如:api key revoked", "noCustomBannedSignals": "無自訂關鍵字。僅內建關鍵字生效。", "resilienceStructureTitle": "彈性結構", - "resilienceStructureDesc": "此頁面僅配置行為。即時斷路器狀態顯示在執行狀況頁面上。組合特定的重試和迴圈槽控制保留在組合設定上。", + "resilienceStructureDesc": "此頁面僅設定行為。即時斷路器狀態顯示在執行狀況頁面上。組合特定的重試和迴圈槽控制保留在組合設定上。", "enableThinking": "激發思考", "maxThinkingTokens": "最大思考權杖", "enableProxy": "啟用代理", @@ -6275,15 +6275,15 @@ "evictions": "驅逐", "loadingCacheStats": "正在載入快取統計資訊...", "globalProxy": "全域性代理", - "globalProxyDesc": "為所有 API 呼叫配置全域性出站代理。單獨的提供者、組合和鍵可以覆蓋此設定。", - "noGlobalProxy": "沒有配置全域性代理", + "globalProxyDesc": "為所有 API 呼叫設定全域性出站代理。單獨的提供者、組合和鍵可以覆蓋此設定。", + "noGlobalProxy": "沒有設定全域性代理", "proxyPool": "代理池", "freePool": "免費池", - "proxyDocumentation": "代理文件", - "proxyGlobalConfigTab": "全域性配置", + "proxyDocumentation": "代理檔案", + "proxyGlobalConfigTab": "全域性設定", "proxyPoolTab": "代理池", "freePoolTab": "免費泳池", - "proxyDocumentationTab": "文件", + "proxyDocumentationTab": "檔案", "proxySubscriptionsTab": "訂閱", "proxySubscription": { "error": { @@ -6293,7 +6293,7 @@ } }, "bulkHealthcheck": "批次健康檢查", - "bulkHealthcheckDesc": "針對目標 URL 測試所有已配置代理,找出可用代理。", + "bulkHealthcheckDesc": "針對目標 URL 測試所有已設定代理,找出可用代理。", "healthcheckTesting": "測試中...", "healthcheckAll": "全部健康檢查", "healthcheckFailed": "執行健康檢查失敗", @@ -6312,7 +6312,7 @@ "proxyDocumentationAddDescBefore": "轉到", "proxyDocumentationAddDescMiddle": "選項卡 → 點選", "proxyDocumentationAddCta": "+ 新增代理", - "proxyDocumentationAddDescAfter": "。填寫型別(http/https/socks5)、主機和埠。可選擇將其分配給作用域。", + "proxyDocumentationAddDescAfter": "。填寫類型(http/https/socks5)、主機和埠。可選擇將其分配給作用域。", "proxyDocumentationBulkTitle": "批次匯入格式", "proxyDocumentationBulkDesc": "管道符分隔的欄位:", "proxyDocumentationSocks5DescBefore": "SOCKS5 代理預設停用。設定", @@ -6342,7 +6342,7 @@ "proxyFreePoolAddVisible": "將所有可見項新增到池", "proxyFreePoolSource": "來源", "proxyFreePoolHostPort": "主機:埠", - "proxyFreePoolType": "型別", + "proxyFreePoolType": "類型", "proxyFreePoolCountry": "國家", "proxyFreePoolQuality": "質量", "proxyFreePoolLatency": "延遲", @@ -6378,7 +6378,7 @@ "passthrough": "直通", "passthroughDesc": "沒有變化——客戶控制思維預算", "auto": "自動", - "autoDesc": "丟棄所有思考配置,由提供者自行決定", + "autoDesc": "丟棄所有思考設定,由提供者自行決定", "custom": "定製", "customDesc": "為所有請求設定固定的 Token 預算", "adaptive": "自適應", @@ -6417,7 +6417,7 @@ "corsAllowedOrigins": "CORS 允許的源", "corsAllowedOriginsDesc": "允許呼叫此伺服器的瀏覽器來源列表,以逗號分隔。空列表 = 不允許瀏覽器 CORS 訪問(伺服器到伺服器仍可用)。僅在開發環境使用 CORS_ALLOW_ALL=true 環境變數。", "blockedProviders": "被阻止的提供者", - "blockedProvidersDesc": "在 `/v1/models` 響應中隱藏指定提供者。被隱藏的提供者不會出現在模型列表中。", + "blockedProvidersDesc": "在 `/v1/models` 回應中隱藏指定提供者。被隱藏的提供者不會出現在模型列表中。", "providersBlocked": "{count} 個提供者/模型已遮蔽", "blockProviderTitle": "遮蔽 {provider}", "unblockProviderTitle": "取消遮蔽 {provider}", @@ -6430,16 +6430,16 @@ "systemTransformsDesc": "在轉發之前,每個提供者訂購的轉換管道應用於請求正文。支援任何提供者 ID。", "systemTransformsAddProvider": "新增提供者", "systemTransformsAddProviderPlaceholder": "選擇提供者...", - "systemTransformsAddProviderAllConfigured": "所有提供者均已配置", + "systemTransformsAddProviderAllConfigured": "所有提供者均已設定", "systemTransformsRemoveProvider": "刪除提供者", - "systemTransformsNoProviders": "沒有配置提供者。新增提供者即可開始。", + "systemTransformsNoProviders": "沒有設定提供者。新增提供者即可開始。", "systemTransformsOpMoveUp": "向上移動", "systemTransformsOpMoveDown": "下移", "systemTransformsOpDelete": "刪除操作", "routingStrategy": "路由策略", "routingAdvancedGuideTitle": "高階路由指南", "routingAdvancedGuideHint1": "需要可預測優先順序時使用 Fill First,需要公平分配時使用 Round Robin,需要延遲彈性時使用 P2C。", - "routingAdvancedGuideHint2": "如果各提供者在質量或成本上差異明顯,後臺任務可優先考慮“成本最佳化”,需要均衡消耗時可從“最少使用”開始。", + "routingAdvancedGuideHint2": "如果各提供者在質量或成本上差異明顯,後台任務可優先考慮“成本最佳化”,需要均衡消耗時可從“最少使用”開始。", "fillFirst": "優先填滿", "fillFirstDesc": "按優先順序順序使用帳戶", "roundRobin": "輪詢", @@ -6471,7 +6471,7 @@ "routingStrategyComboSummary": " Combo 在每個目標 {limit} 次呼叫後輪換。", "routingStrategyComboFallbackSummary": " Combo 使用各自設定的策略(預設優先/備用)。", "providerAccountRoutingTitle": "多帳號路由", - "providerAccountRoutingDesc": "覆寫此供應商的全域帳號策略(9router 對等)。", + "providerAccountRoutingDesc": "覆寫此提供者的全域帳號策略(9router 對等)。", "providerRoutingStrategy": "帳號策略", "providerRoutingInheritGlobal": "繼承全域預設", "modelAliases": "模型別名", @@ -6482,10 +6482,10 @@ "newModelId": "新模型 ID", "customAliases": "自定義別名", "builtInAliases": "內建別名", - "backgroundDegradationTitle": "後臺任務降級", - "backgroundDegradationDesc": "自動檢測後臺任務(標題、摘要)並路由到更便宜的模型", - "enableDegradation": "啟用後臺任務降級", - "enableDegradationHint": "啟用後,標題生成和摘要等後臺任務會自動路由到更便宜的模型", + "backgroundDegradationTitle": "後台任務降級", + "backgroundDegradationDesc": "自動檢測後台任務(標題、摘要)並路由到更便宜的模型", + "enableDegradation": "啟用後台任務降級", + "enableDegradationHint": "啟用後,標題生成和摘要等後台任務會自動路由到更便宜的模型", "tasksDetected": "檢測到的任務", "degradationMap": "模型降級對映", "premiumModel": "高階模型", @@ -6555,11 +6555,11 @@ "comboDefaultsGuideTitle": "如何調整組合預設值", "comboDefaultsGuideHint1": "在低延遲流中保持較低的重試次數;僅增加長生成任務的超時。", "comboDefaultsGuideHint2": "當一個提供程式需要與全域性預設值不同的超時/重試行為時,請使用提供程式覆蓋。", - "globalComboConfig": "全域性組合配置", + "globalComboConfig": "全域性組合設定", "moveUp": "向上移動", "moveDown": "向下移動", - "allProvidersAdded": "所有供應商已新增", - "noProvidersFound": "找不到供應商", + "allProvidersAdded": "所有提供者已新增", + "noProvidersFound": "找不到提供者", "defaultStrategy": "預設策略", "defaultStrategyDesc": "應用於沒有明確策略的新組合", "comboStrategyAria": "組合策略", @@ -6580,8 +6580,8 @@ "providerTimeoutAria": "{provider} 超時毫秒", "removeProviderOverrideAria": "刪除 {provider} 覆蓋", "selectProviderPlaceholder": "選擇提供者…", - "searchProviderPlaceholder": "搜尋供應商⋯", - "searchProviderAria": "搜尋供應商", + "searchProviderPlaceholder": "搜尋提供者⋯", + "searchProviderAria": "搜尋提供者", "newProviderNamePlaceholder": "例如 google、openai...", "newProviderNameAria": "新的提供者名稱", "retries": "重試", @@ -6594,8 +6594,8 @@ "contextRelayHandoffThreshold": "交接閾值", "contextRelayMaxMessages": "摘要最大訊息數", "contextRelaySummaryModel": "摘要模型", - "contextRelayProviderNote": "Context Relay 當前會為 Codex 帳戶生成交接摘要,並將這些值作為新的或未配置 combo 的全域性預設值。", - "providerProfiles": "提供者策略配置", + "contextRelayProviderNote": "Context Relay 當前會為 Codex 帳戶生成交接摘要,並將這些值作為新的或未設定 combo 的全域性預設值。", + "providerProfiles": "提供者策略設定", "providerProfilesDesc": "為 OAuth(基於會話)和 API Key(計費型)提供者分別設定彈性策略。由於速率限制更低,OAuth 提供者通常採用更嚴格的閾值。", "oauthProviders": "OAuth 提供者", "apiKeyProviders": "API 金鑰提供者", @@ -6605,7 +6605,7 @@ "cbThreshold": "熔斷閾值", "cbResetTime": "熔斷重置時間", "rateLimiting": "速率限制", - "rateLimitingDesc": "API 金鑰提供者會自動受到安全預設值的速率限制。限制是從響應標頭中學習的,並隨著時間的推移進行調整。", + "rateLimitingDesc": "API 金鑰提供者會自動受到安全預設值的速率限制。限制是從回應標頭中學習的,並隨著時間的推移進行調整。", "defaultSafetyNet": "預設安全網", "rpm": "RPM", "minGap": "最小間隔", @@ -6664,7 +6664,7 @@ "yes": "是的", "no": "否", "restore": "恢復", - "invalidFileType": "檔案型別無效,僅接受 `.sqlite` 檔案。", + "invalidFileType": "檔案類型無效,僅接受 `.sqlite` 檔案。", "exportFailed": "匯出失敗", "exportFailedWithError": "匯出失敗:{error}", "fullExportFailedWithError": "完全匯出失敗:{error}", @@ -6684,7 +6684,7 @@ "errorDuringRestore": "恢復期間發生錯誤", "errorDuringImport": "匯入時發生錯誤", "modelPricing": "模型定價", - "modelPricingDesc": "配置每個模型的成本費率 • 所有費率均以美元/100 萬 Tokens 為單位", + "modelPricingDesc": "設定每個模型的成本費率 • 所有費率均以美元/100 萬 Tokens 為單位", "pricingCoverage": "覆蓋率", "pricingAuth": "驗證", "pricingSort": "排序", @@ -6700,8 +6700,8 @@ "pricingFilteredFrom": "(從 {count} 中篩選)", "pricingShowMoreProviders": "顯示另外 {count} 個(剩餘 {remaining} 個)", "modelOverridesTitle": "模型覆寫", - "modelOverridesDesc": "覆寫路由與請求塑形所使用的供應商/模型能力。目標使用與 Combo 相同的供應商/模型格式。", - "searchModelOverrideTargets": "搜尋供應商/模型...", + "modelOverridesDesc": "覆寫路由與請求塑形所使用的提供者/模型能力。目標使用與 Combo 相同的提供者/模型格式。", + "searchModelOverrideTargets": "搜尋提供者/模型...", "selectedModel": "已選模型", "configured": "已設定", "none": "無", @@ -6726,7 +6726,7 @@ "model": "模型", "models": "模型", "moreProviders": "{count} 更多提供者", - "withPricing": "已配置定價", + "withPricing": "已設定定價", "policiesCircuitBreakers": "策略與斷路器", "activeIssuesDetected": "檢測到活躍問題", "off": "關閉", @@ -6741,12 +6741,12 @@ "totalModels": "模型總數", "active": "活躍", "costCalculation": "成本計算", - "costCalculationDesc": "成本是根據為每個模型配置的權杖使用情況和定價費率計算的。", + "costCalculationDesc": "成本是根據為每個模型設定的權杖使用情況和定價費率計算的。", "pricingFormat": "定價格式", "pricingFormatDesc": "所有費率均以美元/100 萬 Tokens 為單位(每百萬 Tokens 美元)。", - "tokenTypes": "Token 型別", + "tokenTypes": "Token 類型", "inputTokenDesc": "標準提示 Tokens", - "outputTokenDesc": "補全 / 響應 Tokens", + "outputTokenDesc": "補全 / 回應 Tokens", "cachedTokenDesc": "快取輸入 Tokens(通常按輸入費率的 50% 計)", "reasoningTokenDesc": "特殊推理 / 思考 Tokens(回退到輸出費率)", "cacheCreationTokenDesc": "用於建立快取條目的 Tokens(回退到輸入費率)", @@ -6757,7 +6757,7 @@ "adaptiveVolumeRouting": "自適應流量路由", "adaptiveVolumeRoutingDesc": "根據即時負載量和吞吐壓力,動態調整各提供者連線承載的流量。", "lkgpToggleTitle": "最後已知良好提供者(LKGP)", - "lkgpToggleDesc": "啟用後,路由器會記住上一次成功返回響應的提供者,並在後續請求中優先嚐試它。", + "lkgpToggleDesc": "啟用後,路由器會記住上一次成功返回回應的提供者,並在後續請求中優先嚐試它。", "echoRequestedModelTitle": "在回應中反映請求的模型名稱", "echoRequestedModelDesc": "啟用後,回應的 `model` 欄位會反映客戶端請求的別名或組合名稱,而非上游模型名稱。修正嚴格客戶端(例如 Claude Desktop)因回應的 model 與請求不符而拒絕回應的問題。", "webSearchRouteTitle": "網路搜尋路由", @@ -6775,7 +6775,7 @@ "purgeLogsFailed": "清理日誌失敗", "logsDeleted": "{count, plural, =0 {無過期紀錄被清除} one {已清除 # 筆過期紀錄} other {已清除 # 筆過期紀錄}}", "resetUsageData": "重置使用量資料", - "resetUsageDataDesc": "選擇要刪除多遠以前的使用量、請求紀錄和分析資料。供應商設定、連線、API 金鑰、組合和設定將會保留。此動作無法復原。", + "resetUsageDataDesc": "選擇要刪除多遠以前的使用量、請求紀錄和分析資料。提供者設定、連線、API 金鑰、組合和設定將會保留。此動作無法復原。", "resetUsagePeriod_5m": "5 分鐘", "resetUsagePeriod_1h": "1 小時", "resetUsagePeriod_3h": "3 小時", @@ -6802,16 +6802,16 @@ "selectCombo": "選擇組合...", "priorityHint": "數值越高越優先檢查。具體模式建議使用 10+。", "patternHint": "使用 * 匹配任意字元,? 匹配單個字元。不區分大小寫。", - "noRoutingRules": "未配置路由規則。請求預設使用全域性組合。", + "noRoutingRules": "未設定路由規則。請求預設使用全域性組合。", "routingRuleHint": "新增類似 claude-opus* -> frontier-combo 的規則,以自動路由請求。", "deleteRoutingRule": "刪除此模型路由規則?", "exactMatchMode": "精確匹配", "wildcardPatternMode": "萬用字元模式", "exactMatchModeDesc": "對已棄用或已重新命名的模型 ID 使用精確別名。", "wildcardPatternModeDesc": "當一組模型應對映到同一目標時,使用帶 * 和 ? 的萬用字元別名。", - "noExactAliasesConfigured": "未配置精確匹配別名。", + "noExactAliasesConfigured": "未設定精確匹配別名。", "wildcardRulesTitle": "萬用字元規則", - "noWildcardAliasesConfigured": "未配置萬用字元別名。", + "noWildcardAliasesConfigured": "未設定萬用字元別名。", "overview": "概覽", "unknownError": "未知錯誤", "pricingSourceLiteLLM": "LiteLLM 定價來源", @@ -6872,9 +6872,9 @@ "compressionModeAggressiveDesc": "摘要 + 工具結果壓縮 + 漸進老化,以實現最大節省", "compressionModeUltra": "極速", "compressionModeUltraDesc": "使用包括語義去重在內的全部技術進行最大壓縮", - "compressionAggressiveConfig": "Aggressive 引擎配置", + "compressionAggressiveConfig": "Aggressive 引擎設定", "compressionAggressiveConfigDesc": "微調摘要、工具壓縮和老化閾值", - "compressionUltraConfig": "Ultra 引擎配置", + "compressionUltraConfig": "Ultra 引擎設定", "compressionUltraConfigDesc": "微調啟發式剪枝、SLM 後備和每條訊息閾值", "compressionUltraRate": "保留比例", "compressionUltraMinScore": "最低分數閾值", @@ -6891,7 +6891,7 @@ "compressionAgingThresholds": "老化閾值", "compressionAgingThresholdsDesc": "每個老化層級保留的最近訊息數量(越高表示保留越多)", "compressionToolStrategies": "工具結果策略", - "compressionToolStrategiesDesc": "為不同工具結果型別切換壓縮策略", + "compressionToolStrategiesDesc": "為不同工具結果類型切換壓縮策略", "compressionGeneral": "通用設定", "compressionAutoTrigger": "自動觸發閾值", "compressionCacheTTL": "快取 TTL", @@ -6908,7 +6908,7 @@ "compressionExclusionsSaved": "__MISSING__:Saved", "compressionExclusionsCount": "__MISSING__:{count, plural, one {# exclusion} other {# exclusions}} configured", "compressionExclusionsEmpty": "__MISSING__:No exclusions configured — every model/endpoint is eligible for compression (default behavior).", - "compressionCavemanConfig": "Caveman 引擎配置", + "compressionCavemanConfig": "Caveman 引擎設定", "compressionCavemanConfigDesc": "微調基於規則的壓縮引擎", "compressionCavemanPanelHint": "其開關與等級在面板中設定:", "compressionRoles": "壓縮訊息角色", @@ -6938,8 +6938,8 @@ "qdrantEnableDesc": "啟用後,語義/混合策略可以使用 Qdrant 來檢索記憶。", "qdrantTesting": "測試...", "qdrantTestConnection": "測試連線", - "qdrantSaved": "配置已儲存", - "qdrantSaveError": "儲存配置失敗", + "qdrantSaved": "設定已儲存", + "qdrantSaveError": "儲存設定失敗", "qdrantHostHint": "沒有埠。示例:127.0.0.1 或 http://qdrant", "qdrantPort": "港口", "qdrantPortHint": "Qdrant 預設值:6333", @@ -6953,14 +6953,14 @@ "qdrantHelpStep4": "4. 儲存,測試連線,然後測試搜尋。", "qdrantEmbeddingQuickSelect": "從發現的模型中快速選擇...", "qdrantEmbeddingInputPlaceholder": "openai/text-embedding-3-small", - "qdrantEmbeddingHint": "格式:提供者/模型。必須配置提供者憑證。", + "qdrantEmbeddingHint": "格式:提供者/模型。必須設定提供者憑證。", "qdrantApiKeyPlaceholderKeep": "(留空以保留當前金鑰)", "qdrantApiKeyPlaceholderOptional": "(如不用則留空)", "qdrantSaveHint": "提示:編輯主機/埠/集合/模型,然後單擊“儲存”。 API 金鑰是可選的。", "qdrantSearchTestTitle": "搜尋測試", "qdrantSearchTestDesc": "在 Qdrant 中生成嵌入和搜尋。", "qdrantSearchPlaceholder": "示例:使用者偏好、歷史記錄等", - "qdrantNoResults": "無結果(或未配置 Qdrant)。", + "qdrantNoResults": "無結果(或未設定 Qdrant)。", "qdrantCleanupTitle": "保留和清理", "qdrantCleanupDesc": "根據以下內容刪除過期和舊的積分", "searching": "正在尋找...", @@ -7005,7 +7005,7 @@ "routingWordsToObfuscate": "要混淆的單詞(在第一個字元後插入 ZWJ)", "logsSettingsTitle": "日誌設定", "detailedLogsLabel": "啟用詳細日誌", - "detailedLogsDesc": "啟用詳細的請求/響應日誌記錄", + "detailedLogsDesc": "啟用詳細的請求/回應日誌記錄", "callLogPipelineLabel": "呼叫日誌管道", "callLogPipelineDesc": "啟用通話記錄處理管道", "maxDetailSizeLabel": "最大細節大小 (KB)", @@ -7051,13 +7051,13 @@ "routingServerRejectedSave": "⚠ 伺服器拒絕儲存:", "routingAddTransformOp": "新增變換操作", "routingClientCacheControlTitle": "客戶端快取控制", - "routingClientCacheControlDesc": "配置 OmniRoute 是否保留客戶端提交的 cache_control 標記", + "routingClientCacheControlDesc": "設定 OmniRoute 是否保留客戶端提交的 cache_control 標記", "routingClientCacheControlAutoDesc": "對於確定性的 Claude 相容流程,按原樣保留客戶端提交的 cache_control。如果請求未攜帶 cache_control,OmniRoute 不會注入任何由橋接器擁有的標記,以相容 CC 相容的第三方代理。", "routingClientCacheControlAlwaysLabel": "始終保留", "routingClientCacheControlAlwaysDesc": "始終按原樣將客戶端提交的 cache_control 請求頭轉發給上游提供者。", "routingClientCacheControlNeverLabel": "從不保留", "routingClientCacheControlNeverDesc": "始終移除客戶端的 cache_control 請求頭,在原生提供者流程支援時由 OmniRoute 管理快取。", - "routingZeroConfigTitle": "零配置自動路由", + "routingZeroConfigTitle": "零設定自動路由", "routingZeroConfigDesc": "啟用使用 auto/ 字首的自動提供者選擇。啟用後,發往 auto、auto/coding、auto/fast 等的請求將在所有已連線提供者之間動態路由。", "routingDefaultAutoVariant": "預設自動變體", "visionBridge": "願景橋", @@ -7200,7 +7200,7 @@ "compressionDerivedMode": "模式:{mode}", "compressionAdaptiveOff": "自適應上下文預算:關閉(傳統自動觸發)", "compressionAdaptiveTarget": "自適應({mode},策略:{policy})— 目標約 {target, number} tokens(針對 {contextLimit, number} token 的視窗)", - "compressionOutputStylesDescription": "注入回應塑形指令,無需改寫供應商輸出。可自由組合。", + "compressionOutputStylesDescription": "注入回應塑形指令,無需改寫提供者輸出。可自由組合。", "mcpAccessibilityDescription": "限定 MCP 工具輸出範圍(獨立儲存區)。", "compressionStylesTileSummary": "已節省 {tokens, number} tokens · {runs} 次風格執行", "compressionStylesTileEmpty": "尚無已套用風格的執行。", @@ -7293,13 +7293,13 @@ "cliproxyapiImportAuthDesc": "將 CLIProxyAPI 已儲存在 ~/.cli-proxy-api/ 中的 OAuth 帳戶匯入為 OmniRoute 連線,這樣您就不必再次登入每個帳戶。支援的帳戶類型(Gemini、Codex、Claude、Antigravity、Qwen、Kimi)會被匯入;其他則會跳過。", "cliproxyapiImportAuthButton": "匯入帳戶", "payloadRulesTitle": "負載規則", - "payloadRulesDesc": "按模型與協議配置請求 payload 的變更。修改會持久化到設定中,並在儲存後立即熱載入到執行時。", + "payloadRulesDesc": "按模型與協議設定請求 payload 的變更。修改會持久化到設定中,並在儲存後立即熱載入到執行時。", "payloadRuleDefaultTitle": "default", "payloadRuleDefaultDesc": "僅在外發 payload 缺少目標路徑時應用引數。", "payloadRuleOverrideTitle": "override", "payloadRuleOverrideDesc": "強制將值寫入 payload,替換該路徑上已存在的任何內容。", "payloadRuleFilterTitle": "filter", - "payloadRuleFilterDesc": "在向上遊發起請求前,從 payload 中移除被遮蔽的引數。", + "payloadRuleFilterDesc": "在向上游發起請求前,從 payload 中移除被遮蔽的引數。", "payloadRuleDefaultRawTitle": "defaultRaw", "payloadRuleDefaultRawDesc": "與 default 類似,但會先嚐試將字串值解析為 JSON。儲存時也接受舊的輸入別名 default-raw。", "payloadEditorTitle": "編輯器", @@ -7313,15 +7313,15 @@ "payloadValidJsonRequired": "儲存前 Payload 規則必須是合法 JSON。", "savePayloadRules": "儲存 Payload 規則", "requestLimitsTitle": "請求限制", - "requestLimitsDesc": "配置全域性請求限制與併發保護。", + "requestLimitsDesc": "設定全域性請求限制與併發保護。", "maxRequestSizeLabel": "最大請求大小(MB)", "maxRequestSizeDesc": "傳入 API 請求允許的最大大小。", - "maxResponseSizeLabel": "最大響應大小(MB)", - "maxResponseSizeDesc": "傳出 API 響應允許的最大大小。", + "maxResponseSizeLabel": "最大回應大小(MB)", + "maxResponseSizeDesc": "傳出 API 回應允許的最大大小。", "maxRequestTokensLabel": "最大請求 Token 數", "maxRequestTokensDesc": "單次請求允許的總 Token 上限。", - "maxResponseTokensLabel": "最大響應 Token 數", - "maxResponseTokensDesc": "單次響應允許的總 Token 上限。", + "maxResponseTokensLabel": "最大回應 Token 數", + "maxResponseTokensDesc": "單次回應允許的總 Token 上限。", "modelCooldownsTitle": "處於冷卻狀態的模型", "modelCooldownsEmpty": "目前沒有模型處於冷卻狀態。", "modelCooldownsDescription": "模型在故障後暫時隔離。冷卻期滿後會自動恢復。", @@ -7333,17 +7333,17 @@ "modelCooldownsReactivateAll": "全部重新啟用", "modelCooldownsReasonRemaining": "原因:{reason} • 剩餘:{remaining}", "modelCooldownsReactivate": "重新啟用", - "responsesStateTitle": "響應狀態", + "responsesStateTitle": "回應狀態", "responsesStateDesc": "控制 OmniRoute 如何處理 previous_response_id。", "responsesStateModeLabel": "previous_response_id 處理", "responsesStateModeAuto": "自動", "responsesStateModeStrip": "剝離", "responsesStateModePreserve": "保留", - "responsesStateHint": "自動模式會剝離 previous_response_id,除非連線明確啟用 OpenAI 響應儲存。對於無狀態客戶端(如 VS Code 自定義端點),剝離模式最安全;上下文取決於客戶端傳送完整歷史記錄。", - "responsesStateSaveError": "更新響應狀態設定失敗", + "responsesStateHint": "自動模式會剝離 previous_response_id,除非連線明確啟用 OpenAI 回應儲存。對於無狀態客戶端(如 VS Code 自定義端點),剝離模式最安全;上下文取決於客戶端傳送完整歷史記錄。", + "responsesStateSaveError": "更新回應狀態設定失敗", "codexFastTierTitle": "Codex 快速層", "codexFastTierDesc": "為 OpenAI Codex 請求全域性注入 service_tier=priority。", - "codexFastTierHint": "啟用後,OmniRoute 會將 service_tier=priority 新增到尚未指定層的連線的出站 Codex 請求中。優先順序需要 OpenAI Enterprise API 金鑰或 ChatGPT-auth Codex 路徑;其他金鑰型別將從 OpenAI 收到與層相關的錯誤。 Codex 提供程式頁面上的每個連線設定優先。", + "codexFastTierHint": "啟用後,OmniRoute 會將 service_tier=priority 新增到尚未指定層的連線的出站 Codex 請求中。優先順序需要 OpenAI Enterprise API 金鑰或 ChatGPT-auth Codex 路徑;其他金鑰類型將從 OpenAI 收到與層相關的錯誤。 Codex 提供程式頁面上的每個連線設定優先。", "codexFastTierSaveError": "無法更新 Codex Fast Tier 設定", "codexAutoPingTitle": "Codex Quota Auto-Ping", "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", @@ -7392,9 +7392,9 @@ "desc": "管理範圍的 API 金鑰(或儀表盤會話)可從非環回地址訪問的 LOCAL_ONLY 字首。", "add": "新增字首", "placeholder": "/api/mcp/v2/", - "empty": "未配置任何字首,繞過實際處於關閉狀態。" + "empty": "未設定任何字首,繞過實際處於關閉狀態。" }, - "cli_tools_runtime_note": "可建立子程序的字首。編譯期已停用,無法設為可繞過。僅供檢視。" + "cli_tools_runtime_note": "可建立子程式的字首。編譯期已停用,無法設為可繞過。僅供檢視。" }, "password": { "prompt": { @@ -7414,13 +7414,13 @@ "auth_required": "需要鑑權", "public": "公開", "always_protected": "始終受保護", - "spawn_capable": "可建立子程序" + "spawn_capable": "可建立子程式" }, "error": { "PASSWORD_REQUIRED": "需要輸入當前密碼才能應用這些更改。", "PASSWORD_MISMATCH": "當前密碼不正確。", "INSUFFICIENT_SCOPE": "API 金鑰缺少 manage 範圍。", - "BYPASS_PREFIX_NOT_ALLOWED": "其中一個或多個字首指向可建立子程序的路由,無法被繞過。", + "BYPASS_PREFIX_NOT_ALLOWED": "其中一個或多個字首指向可建立子程式的路由,無法被繞過。", "GENERIC": "更新授權設定失敗。" } }, @@ -7443,7 +7443,7 @@ "resilienceUseUpstreamRetryHintsDesc": "使用來自上游提供者的重試/重置值(如果可用)。", "resilienceUseUpstream429BreakerHints": "使用上游 429 提示進行斷路器冷卻", "resilienceUseUpstream429BreakerHintsShort": "使用上游 429 提示", - "resilienceUseUpstream429BreakerHintsDesc": "將 429 響應中的重試/配額耗盡訊號應用於斷路器冷卻持續時間。預設使用每個提供者的策略:直接雲提供者預設開啟;反向代理、自託管和 CLI 支援的提供者預設關閉。獨立於“使用上游重試提示”。", + "resilienceUseUpstream429BreakerHintsDesc": "將 429 回應中的重試/配額耗盡訊號應用於斷路器冷卻持續時間。預設使用每個提供者的策略:直接雲提供者預設開啟;反向代理、自託管和 CLI 支援的提供者預設關閉。獨立於“使用上游重試提示”。", "resilienceProviderBreakerScope": "整個提供者", "resilienceProviderBreakerTrigger": "連接回退耗盡後最終傳輸/伺服器失敗", "resilienceProviderBreakerEffect": "暫時阻止該提供者,直到重置時間到期", @@ -7470,7 +7470,7 @@ "resilienceProviderCooldownScope": "所有組合請求", "resilienceProviderCooldownTrigger": "當提供者/連線失敗時", "resilienceProviderCooldownEffect": "在重試前跳過失敗的提供者一段冷卻時間", - "resilienceProviderCooldownDesc": "防止後續請求重複嘗試同一組失敗的供應商。冷卻時間會隨著連續失敗次數呈指數級增長。", + "resilienceProviderCooldownDesc": "防止後續請求重複嘗試同一組失敗的提供者。冷卻時間會隨著連續失敗次數呈指數級增長。", "resilienceProviderCooldownEnabled": "啟用全域提供者冷卻", "resilienceProviderCooldownEnabledDesc": "啟用後,失敗的提供者會在全域範圍內被追蹤並在冷卻期間內被跳過。", "resilienceProviderCooldownMin": "最小冷卻", @@ -7478,7 +7478,7 @@ "forcedFingerprintTitle": "{provider} 始終啟用 — OAuth 帳戶安全所必需;無法關閉。", "forcedFingerprintBadge": "必需", "sessionAffinityTitle": "Session 親和性", - "sessionAffinityDesc": "將同一對話保持在同一個帳號上達此秒數(適用於任何供應商)。設為 0 以停用。", + "sessionAffinityDesc": "將同一對話保持在同一個帳號上達此秒數(適用於任何提供者)。設為 0 以停用。", "sessionAffinityTtl": "親和性 TTL(秒)", "resetAwareQuotaCacheTitle": "重置感知配額快取", "resetAwareQuotaCacheDesc": "僅快取重置感知排序的配額遙測。配額預檢仍然保護請求。 0/0 保持即時獲取。", @@ -7576,7 +7576,7 @@ "maxChars": "最大字元數", "deduplicateThreshold": "去重閾值", "customFilters": "自定義過濾器", - "detectedType": "檢測型別", + "detectedType": "檢測類型", "confidence": "信心", "beforeAfter": "之前/之後", "trustProjectFilters": "信任專案過濾器", @@ -8048,7 +8048,7 @@ "contextCaveman": { "title": "Caveman 引擎", "description": "基於規則的訊息壓縮,包含語言包、分析和輸出模式控制。", - "advancedConfig": "高階配置", + "advancedConfig": "高階設定", "advancedConfigDesc": "微調壓縮行為", "aggressiveSettings": "激進的設定", "aggressiveSettingsDesc": "最大壓縮與潛在的質量權衡", @@ -8105,7 +8105,7 @@ "testBench": "測試臺", "liveMonitor": "即時監控", "modeDescriptionPlayground": "貼上任意 API 請求體,檢視 OmniRoute 如何在不同提供者格式之間進行轉換(OpenAI ↔ Claude ↔ Gemini ↔ Responses API)。", - "modeDescriptionChatTester": "通過 OmniRoute 傳送真實聊天請求,並檢查完整往返流程:輸入、轉換後的請求、提供者響應以及轉換後的輸出。", + "modeDescriptionChatTester": "通過 OmniRoute 傳送真實聊天請求,並檢查完整往返流程:輸入、轉換後的請求、提供者回應以及轉換後的輸出。", "modeDescriptionTestBench": "執行預定義的場景並比較提供者和模型之間的相容性。", "modeDescriptionLiveMonitor": "即時檢視請求流經 OmniRoute 時產生的翻譯事件。", "modeDescriptionFallback": "除錯、測試並可視化 OmniRoute 如何在提供者之間轉換 API 請求。", @@ -8149,10 +8149,10 @@ "featureRoleNormalizationDesc": "針對非 OpenAI 目標將 developer→system。針對不支援 system 角色的模型將 system→user。", "featureToolCallIds": "工具呼叫 ID 規範化", "featureToolCallIdsDesc": "在缺失時生成唯一的 tool_call ID。針對 Mistral 等提供者規範化為 9 字元格式。", - "featureMissingToolResponse": "工具響應注入", - "featureMissingToolResponseDesc": "當客戶端傳送 tool_calls 但沒有對應響應時,注入空的 tool_result 訊息。", + "featureMissingToolResponse": "工具回應注入", + "featureMissingToolResponseDesc": "當客戶端傳送 tool_calls 但沒有對應回應時,注入空的 tool_result 訊息。", "featureThinkingBudget": "思考預算", - "featureThinkingBudgetDesc": "自動管理 thinking 配置。當最後一條訊息不是使用者訊息時移除 thinking 引數。", + "featureThinkingBudgetDesc": "自動管理 thinking 設定。當最後一條訊息不是使用者訊息時移除 thinking 引數。", "featureDirectPaths": "直接轉換路徑", "featureDirectPathsDesc": "某些格式組合(Claude→Gemini)有繞過 OpenAI 中樞的直接轉換器,可產生更準確的輸出。", "featureImageMapping": "影像尺寸對映", @@ -8259,7 +8259,7 @@ "pipelineVisualization": "管道視覺化", "pipelineVisualizationHint": "傳送訊息以檢視您的請求如何經過檢測 → 翻譯 → 提供者呼叫。", "chatTesterDescription": "以特定客戶端格式傳送訊息並檢查翻譯管道的每個步驟。", - "chatTesterFlow": "客戶端請求 → 格式檢測 → OpenAI 中間格式 → 提供者格式 → 響應", + "chatTesterFlow": "客戶端請求 → 格式檢測 → OpenAI 中間格式 → 提供者格式 → 回應", "clickStepToInspect": "單擊任意步驟即可檢查該階段的資料。", "clientFormat": "客戶端格式", "provider": "提供者", @@ -8280,7 +8280,7 @@ "providerFormat": "提供者格式", "providerFormatDescription": "隨後再把 OpenAI 格式轉換為提供者原生格式", "providerResponse": "提供者回應", - "providerResponseRawDescription": "來自提供者 API 的原始響應", + "providerResponseRawDescription": "來自提供者 API 的原始回應", "providerResponseSseDescription": "來自提供者 API 的原始 SSE 流", "unexpectedError": "發生意外錯誤", "error": "錯誤", @@ -8295,22 +8295,22 @@ "liveMonitorDescriptionPrefix": "這裡會顯示 API 呼叫流經 OmniRoute 時產生的翻譯事件。事件來自記憶體緩衝區(重啟後會重置)。使用", "liveMonitorDescriptionSuffix": ",或外部 API 呼叫來生成事件。", "streamTransformer": "流轉換器", - "modeDescriptionStreamTransformer": "通過響應轉換器執行聊天完成 SSE 流。", - "streamTransformerTitle": "響應流轉換器", - "streamTransformerDescription": "貼上聊天完成 SSE 流,通過 OmniRoute 的響應轉換器執行它,並在連線客戶端之前檢查發出的響應。* 事件。", + "modeDescriptionStreamTransformer": "通過回應轉換器執行聊天完成 SSE 流。", + "streamTransformerTitle": "回應流轉換器", + "streamTransformerDescription": "貼上聊天完成 SSE 流,通過 OmniRoute 的回應轉換器執行它,並在連線客戶端之前檢查發出的回應。* 事件。", "loadTextSample": "載入文本樣本", "loadToolSample": "載入工具呼叫示例", - "transformToResponses": "轉換為響應", + "transformToResponses": "轉換為回應", "rawChatSseInput": "原始聊天完成 SSE", - "transformedResponsesSse": "轉換後的響應 API SSE", + "transformedResponsesSse": "轉換後的回應 API SSE", "noResultsYet": "還沒有結果", "transformedEvents": "轉化事件", - "uniqueEventTypes": "獨特的事件型別", + "uniqueEventTypes": "獨特的事件類型", "inputLines": "輸入線", "outputLines": "輸出線", "transformedEventTimeline": "轉變的事件時間線", "transformerTimelineHint": "執行變壓器以按順序檢查發出的 response.output_* 事件。", - "eventType": "事件型別", + "eventType": "事件類型", "eventPreview": "預覽", "comboRouted": "組合路由", "uniqueEndpoints": "獨特的端點", @@ -8332,7 +8332,7 @@ "conceptDiagramExampleSource": "Claude", "conceptDiagramExampleTarget": "雙子座", "conceptHowItWorksToggle": "它是如何工作的", - "conceptHowItWorksBody": "您的應用以其自己的格式傳送請求。翻譯器檢測該格式,通過 OpenAI 作為中介中心進行轉換(或在可用的情況下直接進行轉換),將其傳送到所選提供者,並將響應轉換回您應用的格式。", + "conceptHowItWorksBody": "您的應用以其自己的格式傳送請求。翻譯器檢測該格式,通過 OpenAI 作為中介中心進行轉換(或在可用的情況下直接進行轉換),將其傳送到所選提供者,並將回應轉換回您應用的格式。", "tabTranslate": "翻譯", "tabMonitor": "監視器", "tabTranslateAriaLabel": "前往翻譯選項卡", @@ -8346,7 +8346,7 @@ "simpleStartWithCustomOption": "貼上您的請求(高階)", "simpleModeLabel": "模式", "simpleModePreview": "僅預覽翻譯", - "simpleModeSend": "傳送並檢視響應", + "simpleModeSend": "傳送並檢視回應", "simpleAdvancedToggle": "高階", "simpleInputPanelTitle": "輸入", "simpleInputPanelHint": "自由文本訊息或現成示例", @@ -8354,7 +8354,7 @@ "narratedDetected": "✓ 檢測到: {format}", "narratedTranslating": "正在翻譯到 {target}...", "narratedSending": "正在傳送到 {target}...", - "narratedSuccess": "→ 翻譯為 {target} · 響應時間 {latency}ms", + "narratedSuccess": "→ 翻譯為 {target} · 回應時間 {latency}ms", "narratedError": "失敗:{reason}", "narratedSeeTranslatedJson": "檢視翻譯後的 JSON", "narratedSeePipeline": "檢視管道", @@ -8364,8 +8364,8 @@ "advancedRawJsonSubtitle": "貼上一個 JSON 請求;格式會自動檢測。", "advancedPipelineTitle": "OpenAI 中間管道", "advancedPipelineSubtitle": "視覺化每個翻譯步驟(中心輻射模型)。", - "advancedStreamTransformTitle": "流轉換器 (聊天 → 響應 SSE)", - "advancedStreamTransformSubtitle": "將聊天完成 SSE 轉換為響應 API。", + "advancedStreamTransformTitle": "流轉換器 (聊天 → 回應 SSE)", + "advancedStreamTransformSubtitle": "將聊天完成 SSE 轉換為回應 API。", "advancedTestBenchTitle": "測試平臺 (8 個場景)", "advancedTestBenchSubtitle": "執行所有場景並報告通過/失敗 + 相容性 %。", "advancedCompressionTitle": "壓縮預覽", @@ -8381,8 +8381,8 @@ "pipelineStepOpenAIIntermediateDesc": "翻譯為 OpenAI hub 格式", "pipelineStepProviderFormat": "提供者格式", "pipelineStepProviderFormatDesc": "翻譯為提供者目標格式", - "pipelineStepProviderResponse": "提供者響應", - "pipelineStepProviderResponseDesc": "來自提供者的流式響應", + "pipelineStepProviderResponse": "提供者回應", + "pipelineStepProviderResponseDesc": "來自提供者的流式回應", "conceptDiagramArrow1": "說話", "conceptDiagramArrow2": "翻譯", "conceptDiagramArrow3": "轉換", @@ -8451,7 +8451,7 @@ "run": "執行", "runStepDescription": "通過 OmniRoute 對你的 LLM 端點執行測試用例。每個案例都會作為真實 API 請求傳送。", "evaluate": "評估", - "evaluateStepDescription": "將響應與預期標準進行比較。檢視每種情況的通過/失敗情況以及延遲指標和詳細反饋。", + "evaluateStepDescription": "將回應與預期標準進行比較。檢視每種情況的通過/失敗情況以及延遲指標和詳細反饋。", "evalsStrategyContainsLabel": "包含", "evalsStrategyExactLabel": "精確匹配", "evalsStrategyRegexLabel": "正則", @@ -8459,7 +8459,7 @@ "evalsStrategyContainsDescription": "檢查 LLM 輸出是否包含期望的子串。", "evalsStrategyExactDescription": "檢查 LLM 輸出是否與期望值完全一致。", "evalsStrategyRegexDescription": "通過正規表示式校驗 LLM 輸出。", - "evalsStrategyCustomDescription": "自定義評估邏輯(通過 JSON 配置)。", + "evalsStrategyCustomDescription": "自定義評估邏輯(通過 JSON 設定)。", "historyColumnSuiteName": "套件名稱", "historyColumnTarget": "目標", "historyColumnPassRate": "通過率", @@ -8517,7 +8517,7 @@ "notifyEvalRunFailed": "評估執行失敗", "notifyEvalTitle": "評估:{name}", "modelEvals": "模型評估", - "evalsHeroDescription": "通過執行預定義評測套件來測試和驗證你的 LLM 端點。每個套件都包含多個測試用例,會經由 OmniRoute 傳送真實提示,並將響應與預期標準進行比較,幫助你發現迴歸、比較模型,並確保跨提供者的響應質量。", + "evalsHeroDescription": "通過執行預定義評測套件來測試和驗證你的 LLM 端點。每個套件都包含多個測試用例,會經由 OmniRoute 傳送真實提示,並將回應與預期標準進行比較,幫助你發現迴歸、比較模型,並確保跨提供者的回應質量。", "qualityValidation": "質量驗證", "modelComparison": "模型對比", "regressionDetection": "迴歸檢測", @@ -8589,7 +8589,7 @@ "statCritical": "嚴重", "statAlert": "警報", "statHealthy": "健康", - "filterPurchaseTypeLabel": "型別", + "filterPurchaseTypeLabel": "類型", "filterTierLabel": "層級", "purchaseAll": "全部", "purchaseOauthSub": "訂閱", @@ -8671,12 +8671,12 @@ "scorecardSuites": "套件", "evalCompareTarget": "對比目標", "suiteBuilderCaseModelPlaceholder": "例如 gpt-4o-mini", - "evalControlsHint": "配置評估目標和 API 金鑰,然後執行套件以驗證模型質量。", + "evalControlsHint": "設定評估目標和 API 金鑰,然後執行套件以驗證模型質量。", "recentRunsTitle": "最近執行", "suiteBuilderCaseSystemPromptLabel": "系統提示", "suiteBuilderCaseExpectedPlaceholder": "例如 def fibonacci", "suiteBuilderCaseExpectedPlaceholderContains": "例如 def fibonacci", - "suiteBuilderCaseExpectedPlaceholderExact": "貼上精確的預期響應", + "suiteBuilderCaseExpectedPlaceholderExact": "貼上精確的預期回應", "suiteBuilderCaseExpectedPlaceholderRegex": "例如 ^\\s*\\{.*\\}\\s*$", "suiteBuilderCaseExpectedHintRegex": "使用不帶包裹斜槓的 JavaScript 正規表示式。", "suiteBuilderNamePlaceholder": "例如 Coding Quality Suite", @@ -8714,11 +8714,11 @@ "suiteBuilderCaseTagsLabel": "標籤", "suiteBuilderNewSuite": "新建 Suite", "notifyEvalLoadFailed": "載入評估資料失敗", - "notConfigured": "未配置", + "notConfigured": "未設定", "suiteBuilderCaseNamePlaceholder": "例如 Python 斐波那契測試", "intervalLabel": "間隔", "suiteBuilderCreateAction": "建立套件", - "suiteBuilderCasesHint": "每個用例都會發送一個提示並驗證響應。", + "suiteBuilderCasesHint": "每個用例都會發送一個提示並驗證回應。", "suiteBuilderUpdatedAt": "更新時間", "errorBadge": "錯誤", "suiteBuilderCasesTitle": "測試用例", @@ -8840,7 +8840,7 @@ "readingFromCache": "從 AWS SSO 快取中讀取", "readingFromCursor": "從 Cursor IDE 資料庫讀取", "initializing": "正在初始化...", - "pricingConfig": "定價配置", + "pricingConfig": "定價設定", "loadingPricing": "正在載入定價資料...", "pricingRatesFormat": "定價格式", "noPricingData": "無可用定價資料", @@ -8851,7 +8851,7 @@ "allModels": "所有模型", "allAccounts": "所有帳戶", "allApiKeys": "所有 API 金鑰", - "allTypes": "所有型別", + "allTypes": "所有類型", "allLevels": "所有級別", "modelAZ": "模型 A-Z", "modelZA": "Z-A型", @@ -8899,12 +8899,12 @@ "loading": "正在載入...", "invalidPassword": "密碼無效", "errorOccurredRetry": "發生錯誤。請再試一次。", - "configureInstance": "開始配置你的 OmniRoute 例項", + "configureInstance": "開始設定你的 OmniRoute 例項", "runOnboardingWizard": "執行入門嚮導來設定您的密碼並連線您的第一個 AI 提供者。", "startOnboarding": "開始引導", "secureYourInstance": "保護您的例項", "setPasswordDescription": "設定密碼以保護您的儀表板並保護您的 API 端點免遭未經授權的訪問。", - "configurePassword": "配置密碼", + "configurePassword": "設定密碼", "continue": "繼續", "windowWillClose": "該視窗將自動關閉...", "closeTabNow": "您現在可以關閉此選項卡。", @@ -8958,7 +8958,7 @@ "navigateHome": "導航至主頁", "toggleMenu": "切換選單", "featuresLink": "特點", - "docsLink": "文件", + "docsLink": "檔案", "github": "GitHub", "versionLive": "v1.0 現已上線", "oneEndpoint": "一個端點", @@ -8979,7 +8979,7 @@ "featureOAuthApiKeysTitle": "OAuth 與 API 金鑰", "featureOAuthApiKeysDesc": "在一個保管庫中安全地管理憑據。", "featureCloudSyncTitle": "雲同步", - "featureCloudSyncDesc": "立即跨裝置同步您的配置。", + "featureCloudSyncDesc": "立即跨裝置同步您的設定。", "featureCliSupportTitle": "CLI 支援", "featureCliSupportDesc": "適用於 Claude Code、Codex、Cline、Cursor 等工具。", "featureDashboardTitle": "儀表板", @@ -8993,11 +8993,11 @@ "howItWorksStep3Title": "3. AI 提供者", "howItWorksStep3Description": "請求會被立即轉發給 OpenAI、Anthropic、Gemini 或其他提供者完成處理。", "getStartedIn30Seconds": "30 秒內開始", - "getStartedDescription": "安裝 OmniRoute,通過 Web 儀表板配置你的提供者,然後開始路由 AI 請求。", + "getStartedDescription": "安裝 OmniRoute,通過 Web 儀表板設定你的提供者,然後開始路由 AI 請求。", "installOmniRoute": "安裝 OmniRoute", "installStepDescription": "執行 npx 命令立即啟動伺服器", "openDashboard": "開啟儀表板", - "openDashboardStepDescription": "通過 Web 介面配置提供者和 API 金鑰", + "openDashboardStepDescription": "通過 Web 介面設定提供者和 API 金鑰", "routeRequests": "路由請求", "routeRequestsStepDescription": "將您的 CLI 工具指向 {endpoint}", "terminal": "終端", @@ -9007,7 +9007,7 @@ "serverRunningOnLabel": "伺服器運行於", "dashboardLabel": "儀表板", "readyToRoute": "已準備好開始路由! ✓", - "configureProvidersNote": "📝 在儀表板中配置提供者或使用環境變數", + "configureProvidersNote": "📝 在儀表板中設定提供者或使用環境變數", "dataLocation": "資料位置:", "dataLocationMacLinux": "macOS/Linux:", "dataLocationWindows": "Windows:", @@ -9015,7 +9015,7 @@ "dashboardLink": "儀表板", "changelog": "變更日誌", "resources": "資源", - "documentation": "文件", + "documentation": "檔案", "npm": "npm", "legal": "法律", "mitLicense": "MIT 許可證", @@ -9033,10 +9033,10 @@ "ctaTitle": "準備好簡化你的 AI 基礎設施了嗎?", "ctaDescription": "加入更多開發者,一起用 OmniRoute 簡化 AI 整合流程。開源且可免費開始使用。", "startFree": "免費開始", - "readDocumentation": "閱讀文件" + "readDocumentation": "閱讀檔案" }, "docs": { - "title": "文件", + "title": "檔案", "quickStart": "快速入門", "deploymentGuides": "部署指南", "features": "特點", @@ -9054,17 +9054,17 @@ "modelPrefixes": "模型字首", "prefix": "字首", "troubleshooting": "故障排除", - "supportsChat": "支援聊天和響應端點。", + "supportsChat": "支援聊天和回應端點。", "oauthAutoRefresh": "支援自動重新整理 Token 的 OAuth 連線。", "fullStreaming": "所有模型都支援完整流式輸出。", - "docsLabel": "文件", + "docsLabel": "檔案", "docsHeroDescription": "面向多提供者 LLM 的 AI 閘道器。一個端點即可統一接入 OpenAI、Anthropic、Gemini、GitHub Copilot、Claude Code、Cursor 等 20+ 提供者。", "openDashboard": "開啟儀表板", "endpointPage": "端點頁面", "github": "GitHub", "reportIssue": "報告問題", "onThisPage": "在此頁面上", - "documentationVersion": "文件 - v{version}", + "documentationVersion": "檔案 - v{version}", "quickStartStep1Title": "1.安裝並執行", "quickStartStep1Prefix": "執行", "quickStartStep1Middle": "或者從 GitHub 克隆並執行", @@ -9076,7 +9076,7 @@ "quickStartStep4Prefix": "將您的 IDE 或 API 客戶端指向", "quickStartStep4Suffix": "例如使用提供者字首", "deploySetupTitle": "設定指南", - "deploySetupText": "OmniRoute 的分步安裝、環境配置和首次執行演練。", + "deploySetupText": "OmniRoute 的分步安裝、環境設定和首次執行演練。", "deployElectronTitle": "電子桌面", "deployElectronText": "在 Windows、macOS 和 Linux 上將 OmniRoute 作為本機桌面應用程式執行。", "deployDockerTitle": "碼頭工人", @@ -9100,12 +9100,12 @@ "featureHealthTitle": "健康監測", "featureHealthText": "即時健康檢查、提供者狀態、斷路器狀態以及具有指數退避功能的自動速率限制檢測。", "featureCliTitle": "CLI工具", - "featureCliText": "可在儀表板中管理 IDE 配置、匯出/匯入備份、發現 Codex 配置檔案並修改設定。", + "featureCliText": "可在儀表板中管理 IDE 設定、匯出/匯入備份、發現 Codex 設定檔案並修改設定。", "featureSecurityTitle": "安全與策略", "featureSecurityText": "API 金鑰身份驗證、IP 過濾、提示注入防護、域策略、會話管理和稽核日誌記錄。", "featureCloudSyncTitle": "雲同步", - "featureCloudSyncText": "將配置同步到 Cloudflare Workers,以便通過加密憑據和自動故障轉移實現遠端訪問。", - "providersAcrossConnectionTypes": "跨三種連線型別的 {count} 提供者。", + "featureCloudSyncText": "將設定同步到 Cloudflare Workers,以便通過加密憑據和自動故障轉移實現遠端訪問。", + "providersAcrossConnectionTypes": "跨三種連線類型的 {count} 提供者。", "manageProviders": "管理提供者", "providersCount": "{count} 提供者", "providerTypeFree": "免費套餐", @@ -9114,7 +9114,7 @@ "useCaseSingleEndpointTitle": "許多提供者的單一端點", "useCaseSingleEndpointText": "將客戶端統一指向一個 Base URL,再通過模型字首進行路由(例如:gh/、cc/、kr/、openai/)。", "useCaseFallbackTitle": "使用組合進行回退和模型切換", - "useCaseFallbackText": "在儀表板中建立組合模型,並在提供者內部輪換時保持客戶端配置穩定。", + "useCaseFallbackText": "在儀表板中建立組合模型,並在提供者內部輪換時保持客戶端設定穩定。", "useCaseUsageVisibilityTitle": "使用情況、成本和除錯可見性", "useCaseUsageVisibilityText": "在“使用情況”和“分析”選項卡中按提供者、帳戶和 API 金鑰跟蹤權杖和成本。", "clientCherryStudioTitle": "櫻桃工作室", @@ -9136,7 +9136,7 @@ "clientWindsurfTitle": "Windsurf", "clientWindsurfBullet1": "將 OmniRoute 用作 OpenAI 相容的 base URL,並保留顯式提供者字首,以實現確定性路由。", "clientWindsurfBullet2": "常規流量將模型指向 `/v1/chat/completions`,併為 Codex 風格流程保留 `/v1/responses`。", - "clientWindsurfBullet3": "使用儀表盤 -> CLI 工具獲取現成的 Windsurf 配置指南。", + "clientWindsurfBullet3": "使用儀表盤 -> CLI 工具獲取現成的 Windsurf 設定指南。", "clientClineTitle": "Cline", "clientClineBullet1": "Cline 最適合使用顯式的提供者/模型字首,這樣路由器無需猜測後端。", "clientClineBullet2": "常規模型使用 `/v1/chat/completions`,並在不同帳戶間複用同一個 OmniRoute base URL。", @@ -9158,7 +9158,7 @@ "protocolA2aStep2": "向 `POST /a2a` 傳送 `message/send` 或 `message/stream` 請求。", "protocolA2aStep3": "通過 `tasks/get` 和 `tasks/cancel` 管理任務生命週期。", "protocolTroubleshootingTitle": "協議故障排查", - "protocolTroubleshooting1": "如果 MCP 狀態為離線,請確認 stdio 程序正在執行,且心跳檔案持續更新。", + "protocolTroubleshooting1": "如果 MCP 狀態為離線,請確認 stdio 程式正在執行,且心跳檔案持續更新。", "protocolTroubleshooting2": "如果 A2A 任務長時間停留在 `working`,請檢查 `/api/a2a/tasks/:id` 和流事件中是否出現終態。", "protocolTroubleshooting3": "可使用 `/dashboard/mcp` 和 `/dashboard/a2a` 進行執行控制並檢視審計資訊。", "endpointChatNote": "OpenAI 相容聊天端點(預設)。", @@ -9169,7 +9169,7 @@ "endpointEmbeddingsNote": "文本嵌入生成(OpenAI、Cohere、Voyage)。", "endpointImagesNote": "影像生成(NanoBanana)。", "endpointRewriteChatNote": "為沒有 /v1 的客戶端重寫幫助程式。", - "endpointRewriteResponsesNote": "重寫不帶 /v1 的響應幫助程式。", + "endpointRewriteResponsesNote": "重寫不帶 /v1 的回應幫助程式。", "endpointRewriteModelsNote": "重寫模型發現助手,無需 /v1。", "mgmtProxiesListNote": "列出已儲存的代理註冊項(支援分頁)。", "mgmtProxiesCreateNote": "在登錄檔中建立可複用的代理項。", @@ -9181,7 +9181,7 @@ "modelPrefixesDescriptionStart": "在模型名稱之前使用提供者字首可路由到特定提供者。示例:", "modelPrefixesDescriptionEnd": "會被路由到 GitHub Copilot。", "provider": "提供者", - "type": "型別", + "type": "類型", "troubleshootingModelRouting": "如果客戶端在模型路由上失敗,請使用顯式 provider/model(例如:gh/gpt-5.1-codex)。", "troubleshootingAmbiguousModels": "如果您收到不明確的模型錯誤,請選擇提供者字首而不是裸模型 ID。", "troubleshootingCodexFamily": "對於 GitHub Codex 系列模型,請保持模型名為 `gh/codex-model`;路由器會自動選擇 `/responses`。", @@ -9190,7 +9190,7 @@ "troubleshootingOAuth": "對於 OAuth 提供者,如果 Token 過期,請重新認證,並檢查提供者卡片上的狀態指示器。", "endpointCompletionsNote": "用於文本生成的舊版 completions 端點。", "endpointModerationsNote": "內容稽核和安全分類。", - "endpointRerankNote": "用於檢索增強生成的文件重排序(Cohere、Jina)。", + "endpointRerankNote": "用於檢索增強生成的檔案重排序(Cohere、Jina)。", "endpointSearchNote": "通過 5 個提供者進行網頁搜尋(Serper、Brave、Exa、Tavily、Perplexity)。", "endpointSearchAnalyticsNote": "搜尋請求的分析和指標。", "endpointVideoNote": "影片生成(ComfyUI、SD WebUI 工作流)。", @@ -9217,11 +9217,11 @@ "mcpToolsRoutingTitle": "路由與發現", "mcpToolsRoutingDesc": "健康檢查、組合管理、配額監控、成本報告和模型目錄訪問。", "mcpToolsOperationsTitle": "運維與策略", - "mcpToolsOperationsDesc": "路由模擬、預算保護、策略切換、韌性配置和提供者指標。", + "mcpToolsOperationsDesc": "路由模擬、預算保護、策略切換、韌性設定和提供者指標。", "mcpToolsCacheTitle": "快取管理", "mcpToolsCacheDesc": "檢視快取統計,並清空語義快取或簽名快取。", "mcpToolsCompressionTitle": "壓縮發動機", - "mcpToolsCompressionDesc": "配置 RTK/Brotli 壓縮、交換引擎並按組合檢查壓縮分析。", + "mcpToolsCompressionDesc": "設定 RTK/Brotli 壓縮、交換引擎並按組合檢查壓縮分析。", "mcpToolsOneProxyTitle": "1代理/隧道", "mcpToolsOneProxyDesc": "管理出站代理、輪換住宅 IP 並檢查代理執行狀況。", "mcpToolsMemoryTitle": "記憶", @@ -9241,19 +9241,19 @@ "protocolAcpTitle": "ACP(Agent 通訊)", "protocolAcpDesc": "通過 ACP 登錄檔註冊和管理 Agent,用於 Agent 間通訊和工具共享。", "protocolAcpStep1": "前往儀表盤 → Agents 檢視已註冊的 ACP Agent。", - "protocolAcpStep2": "使用能力和端點配置註冊新的 Agent。", - "protocolAcpStep3": "使用 CLI 工具配置 Agent 通訊通道。" + "protocolAcpStep2": "使用能力和端點設定註冊新的 Agent。", + "protocolAcpStep3": "使用 CLI 工具設定 Agent 通訊通道。" }, "legal": { "privacyPolicy": "隱私政策", "termsOfService": "服務條款", - "providerConfigurations": "提供者配置", + "providerConfigurations": "提供者設定", "apiKeys": "API 金鑰", "usageLogs": "使用日誌", "applicationSettings": "應用程式設定", "viewExportAnalytics": "檢視和匯出使用情況分析", "clearHistory": "隨時清除使用記錄", - "configureRetention": "配置日誌保留策略", + "configureRetention": "設定日誌保留策略", "backupRestore": "備份和恢復您的資料庫", "privacyMetadataTitle": "隱私政策 | OmniRoute", "privacyMetadataDescription": "OmniRoute AI API 代理路由器的隱私政策。", @@ -9269,21 +9269,21 @@ "privacySection1Text": "OmniRoute 是一款本地優先的應用。所有資料處理和儲存都只發生在你的裝置上,不存在集中式伺服器收集你的資訊。", "privacySection2Title": "2. 我們儲存的資料", "privacyDataStoredIn": "以下資料儲存在本地", - "privacyDataProviderConfigurationsDesc": "連線 URL、提供者型別和優先順序設定", + "privacyDataProviderConfigurationsDesc": "連線 URL、提供者類型和優先順序設定", "privacyDataApiKeysDesc": "已加密並存儲在本地,用於與 AI 提供者進行身份驗證", - "privacyDataUsageLogsDesc": "請求計數、權杖使用情況、模型名稱、時間戳和響應時間", - "privacyDataApplicationSettingsDesc": "主題偏好、路由策略和組合配置", + "privacyDataUsageLogsDesc": "請求計數、權杖使用情況、模型名稱、時間戳和回應時間", + "privacyDataApplicationSettingsDesc": "主題偏好、路由策略和組合設定", "privacySection3Title": "3. 無遙測", - "privacySection3Text": "OmniRoute 不收集遙測、分析或崩潰報告。不會向我們或任何第三方傳送資料。你的使用模式、API 呼叫和配置都會保持私密。", + "privacySection3Text": "OmniRoute 不收集遙測、分析或崩潰報告。不會向我們或任何第三方傳送資料。你的使用模式、API 呼叫和設定都會保持私密。", "privacySection4Title": "4. 第三方 AI 提供者", - "privacySection4Text": "當你通過 OmniRoute 發起 API 呼叫時,請求會被轉發到你配置的 AI 提供者(例如:OpenAI、Anthropic、Google)。這些提供者有各自的隱私政策,請查閱:", + "privacySection4Text": "當你通過 OmniRoute 發起 API 呼叫時,請求會被轉發到你設定的 AI 提供者(例如:OpenAI、Anthropic、Google)。這些提供者有各自的隱私政策,請查閱:", "privacyOpenAiPolicy": "OpenAI 隱私政策", "privacyAnthropicPolicy": "Anthropic 隱私政策", "privacyGooglePolicy": "Google 隱私政策", "privacySection5Title": "5. 雲同步(可選)", - "privacySection5Text": "如果您啟用可選的雲同步功能,提供者配置和 API 金鑰可能會傳輸到配置的雲端點。此功能預設處於停用狀態,需要明確選擇加入。", + "privacySection5Text": "如果您啟用可選的雲同步功能,提供者設定和 API 金鑰可能會傳輸到設定的雲端點。此功能預設處於停用狀態,需要明確選擇加入。", "privacySection6Title": "6. 日誌記錄", - "privacyLoggingIntro": "可以通過儀表板設定配置請求日誌。您可以:", + "privacyLoggingIntro": "可以通過儀表板設定設定請求日誌。您可以:", "privacySection7Title": "7. 您的權利", "privacySection7TextStart": "由於所有資料都儲存在本地,因此您擁有完全的控制權。您可以隨時刪除您的資料,方法是刪除", "privacySection7TextEnd": "目錄或使用儀表板中的資料庫備份和恢復功能。", @@ -9294,13 +9294,13 @@ "termsResponsibilityCompliance": "你必須遵守通過 OmniRoute 訪問的每個 AI 提供者的服務條款。", "termsResponsibilitySecurity": "你需要負責本地 OmniRoute 安裝的安全,包括設定密碼和限制網路訪問。", "termsSection3Title": "3. 工作原理", - "termsSection3Text": "OmniRoute 充當中間代理。傳送到 OmniRoute 的 API 呼叫會被轉換後轉發到你配置的 AI 提供者。除必要的協議轉換外,OmniRoute 不會修改你的請求或響應內容。", + "termsSection3Text": "OmniRoute 充當中間代理。傳送到 OmniRoute 的 API 呼叫會被轉換後轉發到你設定的 AI 提供者。除必要的協議轉換外,OmniRoute 不會修改你的請求或回應內容。", "termsSection4Title": "4. 資料處理", "termsDataStoredLocally": "所有資料都儲存在你本機上的 SQLite 資料庫中。", "termsNoTransmission": "除非你明確啟用雲同步功能,否則 OmniRoute 不會將任何資料傳輸到外部伺服器。", - "termsDataLocationText": "使用日誌、API 金鑰和配置儲存在", + "termsDataLocationText": "使用日誌、API 金鑰和設定儲存在", "termsSection5Title": "5. 免責宣告", - "termsSection5Text": "OmniRoute 按“原樣”提供,不附帶任何形式的保證。我們不對 API 使用成本、服務中斷或資料丟失造成的任何損失負責。請始終為配置做好備份。", + "termsSection5Text": "OmniRoute 按“原樣”提供,不附帶任何形式的保證。我們不對 API 使用成本、服務中斷或資料丟失造成的任何損失負責。請始終為設定做好備份。", "termsSection6Title": "6. 開源", "termsSection6Text": "OmniRoute 是開源軟體。您可以根據其許可條款自由檢查、修改和分發它。" }, @@ -9350,21 +9350,21 @@ "comparisonTitle": "CLI 工具與 Agent 目標有什麼區別?", "comparisonCliToolsLabel": "CLI 工具頁面", "comparisonCliToolsTitle": "你的 IDE 通過 OmniRoute 傳送請求", - "comparisonCliToolsDesc": "配置 Claude Code、Codex、Cursor 和其他 IDE,將 OmniRoute 用作它們的 API base URL。OmniRoute 作為代理,將請求路由到你配置的提供者。", + "comparisonCliToolsDesc": "設定 Claude Code、Codex、Cursor 和其他 IDE,將 OmniRoute 用作它們的 API base URL。OmniRoute 作為代理,將請求路由到你設定的提供者。", "comparisonAgentsLabel": "當前頁面(Agent 目標)", "comparisonAgentsTitle": "OmniRoute 將請求傳送到本地 CLI 工具", "comparisonAgentsDesc": "OmniRoute 可以啟動本地 CLI 二進位制檔案(claude、codex、goose)作為執行後端。CLI 工具使用自己的認證處理請求並返回結果。", - "comparisonSummary": "簡而言之:CLI 工具 = 你配置工具指向 OmniRoute。Agent 目標 = OmniRoute 將工具用作端點。", + "comparisonSummary": "簡而言之:CLI 工具 = 你設定工具指向 OmniRoute。Agent 目標 = OmniRoute 將工具用作端點。", "agentUseCaseHint": "可通過 ACP 協議用作執行目標", "flowDiagramClient": "客戶端應用", "flowDiagramClientDesc": "SDK、API 或上游服務", "flowDiagramOmniRoute": "OmniRoute", "flowDiagramOmniRouteDesc": "接收請求並選擇目標", - "flowDiagramSpawn": "啟動程序", + "flowDiagramSpawn": "啟動程式", "flowDiagramSpawnDesc": "通過 stdio 啟動 CLI 二進位制檔案", "flowDiagramCli": "CLI 代理", "flowDiagramCliDesc": "使用自身認證/模型處理", - "fingerprintSettingsHint": "CLI 指紋匹配(偽裝成特定 CLI 工具的請求)可在以下位置配置:", + "fingerprintSettingsHint": "CLI 指紋匹配(偽裝成特定 CLI 工具的請求)可在以下位置設定:", "settingsRoutingLink": "設定/路由", "openSettings": "設定", "copyRawUrlTitle": "將原始 URL 複製到剪貼簿", @@ -9382,7 +9382,7 @@ "howToUseStep1": "在你想讓代理瞭解的技能上點選 {copyUrl}。", "howToUseStep2": "在你的 AI 代理(Claude、Cursor、Cline…)中輸入:", "howToUseStep2Code": "在 [pasted-url] 處使用該技能", - "howToUseStep3": "代理會獲取 SKILL.md 並學習 OmniRoute 的 API 或 CLI — 無需手動文件。" + "howToUseStep3": "代理會獲取 SKILL.md 並學習 OmniRoute 的 API 或 CLI — 無需手動檔案。" }, "cloudAgents": { "title": "雲代理", @@ -9416,7 +9416,7 @@ "notConnected": "未連線", "configure": "設定", "settingsTitle": "雲代理設定", - "settingsDesc": "為雲代理配置本地偏好。", + "settingsDesc": "為雲代理設定本地偏好。", "settingEnableAgents": "啟用雲代理", "settingEnableAgentsDesc": "允許 OmniRoute 編排自主編碼代理。", "settingAutoPR": "自動建立 PR", @@ -9461,7 +9461,7 @@ }, "templateNames": { "simple-chat": "簡單對話", - "streaming": "流式響應", + "streaming": "流式回應", "system-prompt": "系統提示詞", "thinking": "思考模式", "tool-calling": "工具呼叫", @@ -9471,7 +9471,7 @@ }, "templateDescriptions": { "simple-chat": "帶系統訊息的基礎對話模板", - "streaming": "用於流式響應的模板", + "streaming": "用於流式回應的模板", "system-prompt": "帶自定義系統提示詞的模板", "thinking": "帶推理/思考預算的模板", "tool-calling": "用於工具/函式呼叫的模板", @@ -9508,7 +9508,7 @@ }, "cache": { "title": "快取管理", - "description": "監控提供者側 Prompt Cache 的效率,以及本地 Semantic Cache 的響應複用情況。", + "description": "監控提供者側 Prompt Cache 的效率,以及本地 Semantic Cache 的回應複用情況。", "refresh": "重新整理", "clearAll": "清空語義快取", "memoryEntries": "記憶體條目", @@ -9529,7 +9529,7 @@ "behaviorDeterministic": "僅快取 temperature=0 的非流式請求。", "behaviorBypass": "通過請求頭 {header} 繞過快取。", "behaviorTwoTier": "雙層儲存:記憶體 LRU(快速)+ SQLite(重啟後持久化)。", - "behaviorTtl": "預設 TTL:30 分鐘。可通過 {envVar} 配置。", + "behaviorTtl": "預設 TTL:30 分鐘。可通過 {envVar} 設定。", "idempotency": "冪等層", "activeDedupKeys": "活躍去重鍵", "dedupWindow": "去重視窗", @@ -9582,8 +9582,8 @@ "tableProvider": "提供者", "tableModel": "模型", "performanceTitle": "效能", - "semanticCacheSectionDesc": "OmniRoute 自己維護的確定性響應快取。開啟後,重複的非流式、temperature=0 請求可以直接在本地命中,不再訪問上游 provider。", - "semanticCacheDisabledDesc": "Semantic Cache 當前已停用。重新在設定中開啟之前,OmniRoute 不會再做本地響應複用。", + "semanticCacheSectionDesc": "OmniRoute 自己維護的確定性回應快取。開啟後,重複的非流式、temperature=0 請求可以直接在本地命中,不再訪問上游 provider。", + "semanticCacheDisabledDesc": "Semantic Cache 當前已停用。重新在設定中開啟之前,OmniRoute 不會再做本地回應複用。", "semanticEntriesDesc": "這裡展示的是儲存在 SQLite 裡的 semantic cache 記錄,不包含 provider-side prompt cache 的活動。", "searchEntries": "搜尋條目...", "search": "搜尋", @@ -9618,7 +9618,7 @@ "reasoningView": "檢視", "reasoningDetail": "推理內容", "reasoningBehavior": "行為", - "reasoningBehaviorCapture": "從流式響應中捕獲 reasoning_content", + "reasoningBehaviorCapture": "從流式回應中捕獲 reasoning_content", "reasoningBehaviorReplay": "當客戶端省略時,在下一輪重新注入", "reasoningBehaviorFallback": "記憶體優先,並使用 SQLite 作為崩潰恢復後備", "reasoningBehaviorTtl": "TTL:2 小時 | 最大條目:2,000(記憶體)", @@ -9642,19 +9642,19 @@ }, "proxyConfigModal": { "levelGlobal": "全域性", - "levelProvider": "供應商", + "levelProvider": "提供者", "levelCombo": "組合", "levelKey": "金鑰", "levelDirect": "直接(無代理)", - "titleGlobal": "全域性代理配置", + "titleGlobal": "全域性代理設定", "titleLevel": "{level} 代理 — {label}", - "loading": "正在載入代理配置...", + "loading": "正在載入代理設定...", "inheritingFrom": "繼承自", "source": "來源", "savedProxy": "已儲存代理", "custom": "自定義", "selectSavedProxyPlaceholder": "選擇已儲存的代理...", - "proxyType": "代理型別", + "proxyType": "代理類型", "host": "主機", "hostPlaceholder": "1.2.3.4 或 proxy.example.com", "port": "埠", @@ -9674,9 +9674,9 @@ "errorSelectProxyFirst": "請先選擇代理。", "errorProxyNotFound": "所選代理未找到。", "errorClearSavedProxy": "清除已儲存代理失敗", - "errorSaveProxy": "儲存代理配置失敗", - "errorClearProxy": "清除代理配置失敗", - "errorSocks5Hidden": "SOCKS5 已配置但已隱藏,因為 NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=false。" + "errorSaveProxy": "儲存代理設定失敗", + "errorClearProxy": "清除代理設定失敗", + "errorSocks5Hidden": "SOCKS5 已設定但已隱藏,因為 NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=false。" }, "oauthModal": { "title": "連線 {providerName}", @@ -9688,13 +9688,14 @@ "deviceCodeVerificationUrl": "驗證 URL", "deviceCodeYourCode": "您的程式碼", "deviceCodeWaiting": "等待授權...", - "googleLoopbackTitle": "__MISSING__:Google sign-in can't complete from this address", - "googleLoopbackWhatHappens": "__MISSING__:Google only releases the authorization code once {redirectUri} is reachable from the browser that approves the sign-in. Here that address points at this computer, not at the OmniRoute server — so the consent screen hangs instead of redirecting, and there is no callback URL to copy.", - "googleLoopbackRecommended": "__MISSING__:Recommended — run this on your own computer, then paste the result below:", - "googleLoopbackHelperNote": "__MISSING__:It opens the Google consent locally (where 127.0.0.1 works) and prints a one-line omniroute-cred-v1.… blob. Paste that blob into the Step 2 field below — it accepts a credential blob as well as a callback URL.", - "googleLoopbackTunnelLabel": "__MISSING__:Or forward the dashboard port over SSH and reload OmniRoute through the tunnel:", - "googleLoopbackTunnelNote": "__MISSING__:Replace {userPlaceholder} with your SSH username, keep the terminal open, then open {localUrl} and connect again from there.", - "googleLoopbackHeadlessAlt": "__MISSING__:For fully headless use with no local callback at all, configure your own Google OAuth credentials plus a public base URL.", + "googleLoopbackTitle": "無法從此地址完成 Google 登入", + "googleLoopbackWhatHappens": "Google 只有在批准登入的瀏覽器能存取 {redirectUri} 時才會釋放授權碼。在這裡,該地址指向這台電腦,而非 OmniRoute 伺服器 — 因此同意畫面會卡住而不是重新導向,且沒有可複製的回呼 URL。", + "googleLoopbackRecommended": "建議 — 在您自己的電腦上執行,然後將結果貼上到下方:", + "googleLoopbackHelperNote": "它在本機開啟 Google 同意畫面(在 127.0.0.1 可運作的地方),並輸出一行 omniroute-cred-v1.… 的 blob。將該 blob 貼上到下方的步驟 2 欄位 — 它同時接受憑證 blob 和回呼 URL。", + "googleLoopbackTunnelLabel": "或透過 SSH 轉發儀表板連接埠,然後透過隧道重新載入 OmniRoute:", + "googleLoopbackTunnelNote": "將 {userPlaceholder} 替換為您的 SSH 使用者名稱,保持終端機開啟,然後開啟 {localUrl} 並從那裡重新連線。", + "googleLoopbackHeadlessAlt": "若要完全無人值守使用且沒有本機回呼,設定您自己的 Google OAuth 憑證加上公開的基礎 URL。", + "googleOAuthWarning": "遠端訪問 + Google OAuth:預設憑據僅接受重定向到 localhost。授權後,您的瀏覽器將嘗試開啟 localhost — 複製該完整 URL 並貼上到下方。要完全遠端使用而無需此手動步驟,設定您自己的 OAuth 憑據。", "remoteAccessInfo": "遠端訪問:由於您是遠端訪問 OmniRoute,授權後您會看到一個錯誤頁面(localhost 未找到)。這是正常的 — 只需從瀏覽器位址列複製完整 URL 並貼上到下方。", "loopbackMismatchTitle": "__MISSING__:Sign-in can't complete from this address", "loopbackMismatchWhatHappened": "__MISSING__:What's happening", @@ -9738,7 +9739,7 @@ "errorImportFailed": "匯入失敗" }, "pricingModal": { - "title": "定價配置", + "title": "定價設定", "loading": "正在載入定價資料...", "pricingRatesFormat": "定價費率格式", "ratesDescription": "所有費率均為 每百萬權杖美元($/1M 權杖)。示例:輸入費率為 2.50 表示每 1,000,000 個輸入權杖收費 2.50 美元。", @@ -9779,7 +9780,7 @@ "modalCreateTitle": "建立代理", "modalEditTitle": "編輯代理", "labelName": "名稱", - "labelType": "型別", + "labelType": "類型", "labelFamily": "IP 家族", "familyAuto": "自動(雙協定棧)", "familyIpv4": "僅 IPv4", @@ -9821,7 +9822,7 @@ "errorSaveFailed": "儲存代理失敗", "errorDeleteFailed": "刪除代理失敗", "errorForceDeleteConfirm": "此代理仍在分配中。強制刪除並移除所有分配?", - "errorMigrateFailed": "遷移舊版代理配置失敗", + "errorMigrateFailed": "遷移舊版代理設定失敗", "errorBulkFailed": "執行批次分配失敗", "success": "✓", "failure": "✗", @@ -9842,7 +9843,7 @@ "relayProbeSummary": "中繼探測:{alive}/{tested} 存活", "bulkImport": "批次匯入", "bulkImportTitle": "批次匯入代理", - "bulkImportDescription": "使用管道符分隔格式貼上代理配置。每行一個代理。已有代理(相同 host + port)將被更新。", + "bulkImportDescription": "使用管道符分隔格式貼上代理設定。每行一個代理。已有代理(相同 host + port)將被更新。", "bulkImportParse": "解析", "bulkImportImport": "匯入 {count} 個代理", "bulkImportImporting": "正在匯入...", @@ -9890,7 +9891,7 @@ }, "playground": { "title": "模型演練場", - "description": "直接從儀表板測試任何模型。選擇提供者、模型和端點型別,然後傳送請求以檢視原始響應。", + "description": "直接從儀表板測試任何模型。選擇提供者、模型和端點類型,然後傳送請求以檢視原始回應。", "endpoint": "端點", "provider": "提供者", "model": "模型", @@ -9906,7 +9907,7 @@ "selectAudioFile": "選擇音訊檔案進行轉錄(mp3、wav、m4a、ogg、flac…)", "clearAll": "清除全部", "request": "請求", - "response": "響應", + "response": "回應", "transcription": "轉錄", "copy": "複製", "resetToDefault": "重置為預設", @@ -9918,7 +9919,7 @@ "save": "儲存", "endpointOptions": { "chat": "聊天補全", - "responses": "響應", + "responses": "回應", "completions": "補全", "images": "圖片生成", "embeddings": "Embeddings", @@ -9944,8 +9945,8 @@ "modelPlaceholder": "例如 openai/gpt-4o", "endpointLabel": "端點", "parametersLabel": "引數", - "collapseConfig": "摺疊配置面板", - "expandConfig": "展開配置面板", + "collapseConfig": "摺疊設定面板", + "expandConfig": "展開設定面板", "temperature": "溫度", "maxTokens": "最大權杖數", "topP": "Top-p", @@ -9973,7 +9974,7 @@ "improvePromptAria": "使用 AI 改善提示詞", "confirmImprovePrompt": "確認改善提示詞", "improvePromptDescription": "這將會把您目前的系統提示詞傳送至 以生成改善版本。", - "improveQuotaWarning": "這將消耗配置中模型配額的用量。", + "improveQuotaWarning": "這將消耗設定中模型配額的用量。", "improveConfirm": "最佳化", "exportCode": "匯出程式碼", "exportCodeTitle": "匯出程式碼", @@ -10085,7 +10086,7 @@ "modeToolsTitle": "工具", "modeToolsDesc": "測試函式呼叫 — 定義工具並檢視模型如何呼叫它們。", "modeJsonTitle": "JSON", - "modeJsonDesc": "測試結構化輸出 — 將響應限制為 JSON 架構。", + "modeJsonDesc": "測試結構化輸出 — 將回應限制為 JSON 架構。", "modeBothTitle": "工具 + JSON", "modeBothDesc": "在單個請求中結合函式呼叫和結構化輸出。", "backButton": "返回", @@ -10108,7 +10109,7 @@ "copied": "已複製!", "run": "執行", "running": "執行中...", - "response": "響應", + "response": "回應", "tunnel": "隧道", "send": "傳送", "selectKey": "選擇金鑰", @@ -10200,7 +10201,7 @@ "semantic": "Semantic Cache", "upstream": "上游", "semanticCacheHit": "語義快取命中(由 OmniRoute 提供)", - "upstreamResponse": "上游提供者響應", + "upstreamResponse": "上游提供者回應", "noApiKey": "無 API key", "requestedRoutedTitle": "請求 {requested},路由為 {routed}", "statusFilters": { @@ -10240,7 +10241,7 @@ "colStatus": "狀態", "colProxy": "代理", "colTls": "TLS", - "colType": "型別", + "colType": "類型", "colLevel": "級別", "colProvider": "提供者", "colTarget": "目標", @@ -10250,7 +10251,7 @@ "recording": "記錄中", "paused": "已暫停", "searchPlaceholder": "搜尋主機、提供者、目標或 IP...", - "allTypes": "全部型別", + "allTypes": "全部類型", "allLevels": "全部級別", "allProviders": "全部 Provider", "total": "總計", @@ -10265,7 +10266,7 @@ "refresh": "重新整理", "columns": "列", "loadingProxyLogs": "正在載入代理日誌...", - "noProxyLogs": "尚無代理日誌。配置代理併發起 API 呼叫後會顯示在這裡。", + "noProxyLogs": "尚無代理日誌。設定代理併發起 API 呼叫後會顯示在這裡。", "noMatchingLogs": "沒有日誌匹配當前篩選條件。", "tlsFingerprint": "Chrome 124 TLS 指紋", "colPublicIp": "公共IP" @@ -10276,7 +10277,7 @@ "images": "影像", "chat": "聊天", "music": "音樂", - "responses": "響應", + "responses": "回應", "rerank": "重新排序", "video": "影片", "embeddings": "嵌入", @@ -10361,12 +10362,12 @@ "description": "通過百分比限制跨 API 金鑰共享提供者配額", "newPool": "新建池", "betaTitle": "Beta — UI preview.", - "betaDescription": "配置儲存在 localStorage 中(尚未持久化到伺服器)。每次請求的上限執行將在未來更新中實現。", + "betaDescription": "設定儲存在 localStorage 中(尚未持久化到伺服器)。每次請求的上限執行將在未來更新中實現。", "kpiActivePools": "活躍池", "kpiKeysAllocated": "已分配金鑰", "kpiAvgUnallocated": "平均未分配", "kpiProvidersWithQuota": "有配額的提供者", - "emptyTitle": "未配置池", + "emptyTitle": "未設定池", "emptyDescription": "建立池以分配 API 金鑰,通過百分比分配共享提供者的配額視窗。", "loading": "載入中…", "removePool": "移除池", @@ -10408,7 +10409,7 @@ "equalSplit": "平均分配", "save": "儲存分配", "betaPreviewLabel": "Beta — UI 預覽。", - "betaConfigSavedPrefix": "配置儲存在", + "betaConfigSavedPrefix": "設定儲存在", "betaConfigSavedSuffix": "(尚未保留在伺服器上)。每個請求上限的強制執行尚未連線到代理管道中。此螢幕可讓您設計和視覺化配額分配;真正的執行將在未來的迭代中通過資料庫永續性和上游呼叫攔截來實現。", "policyLabel": "政策:", "resetIn": "重置於", @@ -10417,7 +10418,7 @@ "kpiBorrowingNow": "現在借款", "conceptTitle": "配額分成是如何工作的", "conceptIntro": "配額共享通過節約型公平分享將提供者的配額分配給多個 API 金鑰:每個金鑰獲得一個按比例分配的份額,但可以在不超過全球上限的情況下從自由余額中借用。", - "conceptFairShare": "公平共享:每個鍵接收與其配置權重成比例的配額", + "conceptFairShare": "公平共享:每個鍵接收與其設定權重成比例的配額", "conceptBorrowing": "借用:金鑰可以在不違反上限的情況下消耗他人的自由余額", "conceptGlobalCap": "硬性全球上限:提供者的絕對限制永遠不會被超越", "conceptWindows": "Windows: 5小時,按小時、按日、按周、按月 — 每個獨立跟蹤", @@ -10445,7 +10446,7 @@ "wizardStep3Label": "金鑰", "wizardStep1Title": "選擇提供者連線", "wizardStep1Subtitle": "選擇此池將共享配額的提供者帳戶,設定名稱和預設策略。", - "wizardStep2Title": "配置配額維度", + "wizardStep2Title": "設定配額維度", "wizardStep2Subtitle": "為所選連線定義配額計劃維度(單位、視窗、限制)。保持不變以保持當前設定。", "wizardStep3Title": "分配 API 金鑰", "wizardStep3Subtitle": "將 API 金鑰分配給該池,設定權重 % 分配和可選上限。", @@ -10483,7 +10484,7 @@ "endpointsCollapse": "摺疊", "endpointsExpand": "展開", "endpointsAnthropicNote": "Anthropic 原生", - "endpointsResponsesNote": "OpenAI 響應 — codex/github", + "endpointsResponsesNote": "OpenAI 回應 — codex/github", "endpointsWsNote": "WebSocket — 僅限 codex", "betaText": "配額共享功能正常,但預計會有錯誤。發現一個了嗎?請報告。", "betaReportLink": "報告問題", @@ -10513,13 +10514,13 @@ "deactivateFailed": "停用 {name} 失敗", "uninstalled": "{name} 已解除安裝", "uninstallFailed": "解除安裝 {name} 失敗", - "configure": "配置:{name}", + "configure": "設定:{name}", "configurePlugin": "設定", - "noConfigSettings": "此外掛無可配置的設定。", + "noConfigSettings": "此外掛無可設定的設定。", "saving": "儲存中…", - "saveConfiguration": "儲存配置", - "configurationSaved": "配置已儲存", - "saveConfigurationFailed": "儲存配置失敗", + "saveConfiguration": "儲存設定", + "configurationSaved": "設定已儲存", + "saveConfigurationFailed": "儲存設定失敗", "pluginNotFound": "未找到外掛", "version": "版本", "author": "作者", @@ -10542,11 +10543,11 @@ }, "quotaPlans": { "title": "計劃與配額", - "description": "為每個提供者配置配額計劃 — 維度(%、請求、權杖、$)和時間視窗", + "description": "為每個提供者設定配額計劃 — 維度(%、請求、權杖、$)和時間視窗", "providerLabel": "提供者 / 連線", "detectedPlanLabel": "檢測到的計劃", "manualPlanLabel": "手動覆蓋", - "unconfiguredLabel": "未配置 — 需要手動設定", + "unconfiguredLabel": "未設定 — 需要手動設定", "dimensionLabel": "尺寸", "addDimension": "新增維度", "removeDimension": "移除", @@ -10567,7 +10568,7 @@ "useCatalogButton": "使用目錄", "saveOverrideButton": "儲存覆蓋", "revertToCatalogButton": "恢復到目錄", - "unknownProviderNotice": "在左側選擇一個提供者以配置其配額計劃。", + "unknownProviderNotice": "在左側選擇一個提供者以設定其配額計劃。", "catalogTitle": "已知目錄", "catalogDescription": "自動檢測到以下提供者的計劃:" }, @@ -10600,7 +10601,7 @@ "daysAgo": "{n} 天前" }, "eventVerb": { - "providerAdded": "{actor} 添加了提供者 {target}", + "providerAdded": "{actor} 新增了提供者 {target}", "providerRemoved": "{actor} 移除了提供者 {target}", "providerTested": "{actor} 測試了提供者 {target}", "comboCreated": "{actor} 建立了組合 {target}", @@ -10640,7 +10641,7 @@ "authLoginError": "{actor} 的登入錯誤", "authLoginFailed": "{name} 登入失敗", "authLoginLocked": "{actor} 在嘗試次數過多後被鎖定", - "authLoginMisconfigured": "身份驗證配置無效", + "authLoginMisconfigured": "身份驗證設定無效", "authLoginSetupRequired": "需要進行身份驗證設定", "authLogoutSuccess": "{actor} 已登出", "syncTokenCreated": "{actor} 建立了同步權杖", @@ -10653,7 +10654,7 @@ }, "agentBridge": { "title": "AgentBridge", - "subtitle": "使用 IDE 代理與 OmniRoute 模型 — 無需配置", + "subtitle": "使用 IDE 代理與 OmniRoute 模型 — 無需設定", "riskBannerTitle": "自行承擔風險", "riskBannerBody": "AgentBridge 攔截來自 IDE 代理的 HTTPS 流量。通過啟用它,您接受遵守每個代理服務條款的責任。切勿在禁止 TLS 檢查的裝置或網路上使用。", "riskBannerDismiss": "關閉", @@ -10700,11 +10701,11 @@ "agentHosts": "攔截的主機", "certTrusted": "證書已信任", "certNotTrusted": "證書不受信任", - "investigatingNotice": "該代理正在接受調查。主機和 API 介面仍在確認中。一旦上游 API 文件完成,設定將可用。", + "investigatingNotice": "該代理正在接受調查。主機和 API 介面仍在確認中。一旦上游 API 檔案完成,設定將可用。", "modelMappingsLabel": "模型對映", "sourceModel": "源模型(代理原生)", "targetModel": "目標模型 (OmniRoute)", - "noMappings": "未配置模型對映。執行設定嚮導以自動檢測模型。", + "noMappings": "未設定模型對映。執行設定嚮導以自動檢測模型。", "selectModel": "選擇…", "saveMappings": "儲存對映", "setupWizard": "設定嚮導", @@ -10712,7 +10713,7 @@ "stopDns": "停止 DNS", "toggling": "切換中…", "viewTraffic": "檢視流量", - "emptyNoProvidersTitle": "尚未配置提供程式", + "emptyNoProvidersTitle": "尚未設定提供程式", "emptyNoProvidersBody": "要使用 AgentBridge,首先連線至少一個提供者。它將是 IDE 請求路由的目標。", "emptyGoToProviders": "前往提供者", "wizardTitle": "設定嚮導", @@ -10722,8 +10723,8 @@ "wizardStep3Label": "對映", "wizardStep1Desc": "確認伺服器正在執行並且證書已安裝。", "wizardStep2Desc": "以下條目將被新增到 /etc/hosts 以通過 AgentBridge 重定向流量:", - "wizardStep3Desc": "您現在可以在代理卡中配置模型對映。重啟 IDE 以應用更改。", - "wizardStep3Success": "代理已配置!", + "wizardStep3Desc": "您現在可以在代理卡中設定模型對映。重啟 IDE 以應用更改。", + "wizardStep3Success": "代理已設定!", "wizardServerCheck": "AgentBridge 伺服器", "wizardRunning": "執行中", "wizardNotRunning": "未執行", @@ -10738,7 +10739,7 @@ "modelSelectorSearch": "搜尋模型…", "noModelsFound": "未找到模型", "quickLinks": "快速連結", - "quickLinkProviders": "配置提供程式", + "quickLinkProviders": "設定提供程式", "quickLinkInspector": "在流量檢查器中檢視流量", "unknownError": "未知錯誤", "maintenanceTitle": "維護與診斷", @@ -10874,12 +10875,12 @@ "tabConversation": "對話", "tabHeaders": "標題", "tabRequest": "請求", - "tabResponse": "響應", + "tabResponse": "回應", "tabTiming": "計時", "tabLlm": "LLM", "tabStats": "統計資訊", "requestHeaders": "請求頭部", - "responseHeaders": "響應頭部", + "responseHeaders": "回應頭部", "rawEvents": "原始事件", "mergedView": "合併檢視", "noBody": "沒有主體。", @@ -10895,7 +10896,7 @@ "annotationPlaceholder": "新增備註…", "contextFingerprint": "上下文指紋", "llmProvider": "檢測到的提供者", - "llmApiKind": "API 型別", + "llmApiKind": "API 類型", "llmModel": "模型", "llmMessages": "訊息", "llmStream": "流媒體", @@ -10914,7 +10915,7 @@ "backToLive": "返回直播", "untitledSession": "未命名會話", "contextHistory": "上下文歷史", - "modelResponse": "模型響應", + "modelResponse": "模型回應", "conversationNoMessages": "在此請求中未找到訊息。", "conversationNotAvailable": "對話資料不可用。這可能不是 LLM 請求,或者主體無法解析。", "loadingCharts": "載入圖表…", @@ -10925,14 +10926,14 @@ "statsSuccessful": "成功", "statsTotalRequests": "總請求數", "timingProxyOverhead": "代理開銷", - "timingUpstreamResponse": "上游響應", + "timingUpstreamResponse": "上游回應", "timingNoData": "沒有可用的時間資料。", "timingTotalLatency": "總延遲", "timingTimestamp": "時間戳", "timingMethod": "方法", "timingStatus": "狀態", "timingRequestSize": "請求大小", - "timingResponseSize": "響應大小", + "timingResponseSize": "回應大小", "pausedNewBadge": "{count} 新的", "clearContextFilter": "清除", "invalidHostname": "無效的主機名稱", @@ -10986,12 +10987,12 @@ "acp": { "title": "ACP 代理", "phrase": "OmniRoute 作為執行後端(反向流)生成的 CLI", - "flow": "客戶端 → OmniRoute → 生成 CLI (stdio/ACP) → 響應", + "flow": "客戶端 → OmniRoute → 生成 CLI (stdio/ACP) → 回應", "seeOther": "檢視 →" } }, "comparison": { - "title": "瞭解 OmniRoute 中的 3 種 CLI 型別", + "title": "瞭解 OmniRoute 中的 3 種 CLI 類型", "thisPage": "[此頁面 ✓]", "open": "開啟 →", "code": { @@ -11016,12 +11017,12 @@ "card": { "detected": "檢測到", "notDetected": "未檢測到", - "configured": "已配置", - "notConfigured": "未配置", - "configure": "配置 →", + "configured": "已設定", + "notConfigured": "未設定", + "configure": "設定 →", "howToInstall": "如何安裝 →", "versionNotFound": "找不到", - "manualConfig": "手動配置", + "manualConfig": "手動設定", "installGuide": "安裝指南", "endpointLabel": "端點", "baseUrlFull": "完整基礎 URL", @@ -11034,16 +11035,16 @@ "back": "返回", "apply": "儲存", "reset": "清除", - "manualConfig": "手動配置", - "vendor": "供應商", - "category": "型別", + "manualConfig": "手動設定", + "vendor": "提供者", + "category": "類型", "detectionStatus": "檢測", "configStatus": "設定", "baseUrlLabel": "基礎 URL", "apiKeyLabel": "API 金鑰", "modelMappingLabel": "模型對映", "noActiveProviders": "沒有活動的提供者。", - "noActiveProvidersDesc": "請前往 Providers 連線至少 1 個提供者,然後再配置 CLI。", + "noActiveProvidersDesc": "請前往 Providers 連線至少 1 個提供者,然後再設定 CLI。", "openProviders": "開啟提供者 →" } }, @@ -11084,7 +11085,7 @@ "setupGuideCustomAgentDesc": "填寫下面的表格以註冊自定義 CLI 代理。", "setupGuideCommandMissingTitle": "找不到命令", "setupGuideCommandMissingDesc": "檢查二進位制檔案是否在 PATH 中。", - "fingerprintSettingsHint": "在中配置路由和指紋", + "fingerprintSettingsHint": "在中設定路由和指紋", "settingsRoutingLink": "設定 → 路由", "installed": "已安裝", "notFound": "未找到", @@ -11112,12 +11113,12 @@ "description": "管理 API 金鑰驗證和工作階段 Token。從這裡開始,透過 Bearer Token 驗證請求、取得工作階段 Cookie,以及設定 OmniRoute API 的登入需求。" }, "omni-providers": { - "name": "供應商", - "description": "透過 REST API 管理供應商連線、API 金鑰、OAuth 流程和連線測試。列出、新增、更新、移除和測試 AI 供應商整合(OpenAI、Anthropic、Gemini 及 160 多個)。" + "name": "提供者", + "description": "透過 REST API 管理提供者連線、API 金鑰、OAuth 流程和連線測試。列出、新增、更新、移除和測試 AI 提供者整合(OpenAI、Anthropic、Gemini 及 160 多個)。" }, "omni-models": { "name": "模型", - "description": "查詢所有已設定供應商中可用的 AI 模型。列出模型、解析模型別名,以及瀏覽包含供應商特定變體的完整模型目錄。" + "description": "查詢所有已設定提供者中可用的 AI 模型。列出模型、解析模型別名,以及瀏覽包含提供者特定變體的完整模型目錄。" }, "omni-combos-routing": { "name": "組合與路由", @@ -11129,11 +11130,11 @@ }, "omni-usage-logs": { "name": "使用量與紀錄", - "description": "存取詳細的呼叫紀錄和使用量分析。依供應商、模型、時間範圍、狀態和成本篩選。匯出紀錄並彙總所有連線的 Token 使用量。" + "description": "存取詳細的呼叫紀錄和使用量分析。依提供者、模型、時間範圍、狀態和成本篩選。匯出紀錄並彙總所有連線的 Token 使用量。" }, "omni-budget": { "name": "預算與速率限制", - "description": "設定每個 API 金鑰或全域的支出限制、Token 配額和速率限制政策。檢查目前消耗量,並跨供應商執行成本控制。" + "description": "設定每個 API 金鑰或全域的支出限制、Token 配額和速率限制政策。檢查目前消耗量,並跨提供者執行成本控制。" }, "omni-settings": { "name": "設定", @@ -11141,7 +11142,7 @@ }, "omni-proxies": { "name": "代理設定", - "description": "設定上游供應商請求的 HTTP/HTTPS/SOCKS 代理。設定每供應商或全域代理規則、測試連線能力,以及管理代理輪換。" + "description": "設定上游提供者請求的 HTTP/HTTPS/SOCKS 代理。設定每提供者或全域代理規則、測試連線能力,以及管理代理輪換。" }, "omni-cache": { "name": "快取", @@ -11157,7 +11158,7 @@ }, "omni-resilience": { "name": "韌性與監控", - "description": "監控供應商健康狀態、斷路器狀態、p50/p95/p99 延遲指標和預算守衛警示。即時檢查連線冷卻時間和模型鎖定。" + "description": "監控提供者健康狀態、斷路器狀態、p50/p95/p99 延遲指標和預算守衛警示。即時檢查連線冷卻時間和模型鎖定。" }, "omni-cli-tools": { "name": "CLI 工具", @@ -11169,7 +11170,7 @@ }, "omni-sync-cloud": { "name": "雲端同步", - "description": "將 OmniRoute 設定、供應商連線和設定同步至雲端儲存或從中同步。管理雲端工作者驗證和遠端備份目標。" + "description": "將 OmniRoute 設定、提供者連線和設定同步至雲端儲存或從中同步。管理雲端工作者驗證和遠端備份目標。" }, "omni-db-backups": { "name": "資料庫與備份", @@ -11181,11 +11182,11 @@ }, "omni-mcp": { "name": "MCP 伺服器", - "description": "連線至 OmniRoute MCP 伺服器(37 個工具、3 種傳輸方式:SSE/stdio/HTTP)。涵蓋 16 個權限範圍內的路由、快取、壓縮、記憶體、技能、供應商和稽核工具。" + "description": "連線至 OmniRoute MCP 伺服器(37 個工具、3 種傳輸方式:SSE/stdio/HTTP)。涵蓋 16 個權限範圍內的路由、快取、壓縮、記憶體、技能、提供者和稽核工具。" }, "omni-agents-a2a": { "name": "代理程式與 A2A 協定", - "description": "透過 JSON-RPC 2.0 代理間協定與 OmniRoute 互動。6 個內建 A2A 技能:智慧路由、配額管理、供應商探索、成本分析、健康報告、列出能力。" + "description": "透過 JSON-RPC 2.0 代理間協定與 OmniRoute 互動。6 個內建 A2A 技能:智慧路由、配額管理、提供者探索、成本分析、健康報告、列出能力。" }, "omni-version-manager": { "name": "版本管理員", @@ -11201,23 +11202,23 @@ }, "cli-health": { "name": "CLI:健康狀態", - "description": "從 CLI 檢查伺服器健康狀態、元件狀態和即時指標。執行 `health`、`health components` 和 `health watch` 以取得斷路器和供應商狀態的即時儀表板。" + "description": "從 CLI 檢查伺服器健康狀態、元件狀態和即時指標。執行 `health`、`health components` 和 `health watch` 以取得斷路器和提供者狀態的即時儀表板。" }, "cli-providers": { - "name": "CLI:供應商", - "description": "從 CLI 管理供應商連線:列出可用/已設定的供應商、新增、測試、全部測試、驗證、輪換 API 金鑰,以及檢視各供應商指標。" + "name": "CLI:提供者", + "description": "從 CLI 管理提供者連線:列出可用/已設定的提供者、新增、測試、全部測試、驗證、輪換 API 金鑰,以及檢視各提供者指標。" }, "cli-keys": { "name": "CLI:API 金鑰", - "description": "從 CLI 建立、列出、輪換和撤銷 OmniRoute API 金鑰。管理供應商驗證的 OAuth 流程,並檢查金鑰範圍和到期日。" + "description": "從 CLI 建立、列出、輪換和撤銷 OmniRoute API 金鑰。管理提供者驗證的 OAuth 流程,並檢查金鑰範圍和到期日。" }, "cli-models": { "name": "CLI:模型", - "description": "從 CLI 查詢可用的 AI 模型、列出模型別名,以及瀏覽完整模型目錄。依供應商篩選、依能力搜尋,並解析模型名稱變體。" + "description": "從 CLI 查詢可用的 AI 模型、列出模型別名,以及瀏覽完整模型目錄。依提供者篩選、依能力搜尋,並解析模型名稱變體。" }, "cli-chat": { "name": "CLI:聊天", - "description": "從 CLI 傳送聊天完成請求、串流回應,以及啟動互動式 REPL 工作階段。支援所有 OmniRoute 供應商、組合路由和系統提示設定。" + "description": "從 CLI 傳送聊天完成請求、串流回應,以及啟動互動式 REPL 工作階段。支援所有 OmniRoute 提供者、組合路由和系統提示設定。" }, "cli-routing": { "name": "CLI:路由與組合", @@ -11225,7 +11226,7 @@ }, "cli-resilience": { "name": "CLI:韌性與配額", - "description": "從 CLI 檢查和管理斷路器狀態、連線冷卻時間、配額限制和退避等級。重設卡住的供應商並設定韌性臨界值。" + "description": "從 CLI 檢查和管理斷路器狀態、連線冷卻時間、配額限制和退避等級。重設卡住的提供者並設定韌性臨界值。" }, "cli-compression": { "name": "CLI:壓縮", @@ -11237,7 +11238,7 @@ }, "cli-cost-usage": { "name": "CLI:成本與使用量", - "description": "從 CLI 檢視成本明細、Token 使用量和呼叫紀錄。依供應商、模型或日期範圍篩選。匯出使用量報告並檢查各連線的支出。" + "description": "從 CLI 檢視成本明細、Token 使用量和呼叫紀錄。依提供者、模型或日期範圍篩選。匯出使用量報告並檢查各連線的支出。" }, "cli-mcp": { "name": "CLI:MCP", @@ -11285,7 +11286,7 @@ }, "omni-github-skills": { "name": "GitHub 技能探索", - "description": "從包含 SKILL.md、CLAUDE.md、.cursorrules 及類似代理技能檔案的 GitHub 儲存庫中搜尋、評分、掃描和匯入代理技能。在 160+ 個供應商類別中探索社群技能,使用啟發式評分評估相關性,檢查惡意程式碼或硬編碼的機密,並安裝至 Hermes、Claude Code、Gemini CLI 或 OpenCode 代理目錄。" + "description": "從包含 SKILL.md、CLAUDE.md、.cursorrules 及類似代理技能檔案的 GitHub 儲存庫中搜尋、評分、掃描和匯入代理技能。在 160+ 個提供者類別中探索社群技能,使用啟發式評分評估相關性,檢查惡意程式碼或硬編碼的機密,並安裝至 Hermes、Claude Code、Gemini CLI 或 OpenCode 代理目錄。" } }, "pageTitle": "特工技能", @@ -11293,7 +11294,7 @@ "conceptCard": { "agent": { "title": "代理技能 — 外呼", - "description": "代理技能是機器可讀的 SKILL.md 文件,外部 AI 代理(Claude Code、Cursor、Copilot 等)從 GitHub 獲取這些文件,以瞭解如何通過 REST 或 CLI 操作 OmniRoute。它們由代理讀取,而不是由 OmniRoute 執行。", + "description": "代理技能是機器可讀的 SKILL.md 檔案,外部 AI 代理(Claude Code、Cursor、Copilot 等)從 GitHub 獲取這些檔案,以瞭解如何通過 REST 或 CLI 操作 OmniRoute。它們由代理讀取,而不是由 OmniRoute 執行。", "crossLinkLabel": "瞭解區別 →" }, "omni": { @@ -11348,9 +11349,9 @@ "refresh": "重新整理", "copyUrl": "複製 URL", "viewOnGithub": "在 GitHub 上檢視", - "previewLoading": "載入技能文件…", - "previewError": "載入技能文件失敗。", - "previewEmpty": "選擇一個技能以預覽其文件。", + "previewLoading": "載入技能檔案…", + "previewError": "載入技能檔案失敗。", + "previewEmpty": "選擇一個技能以預覽其檔案。", "generateButton": "生成缺失的技能", "coverageBar": { "complete": "完成", @@ -11384,7 +11385,7 @@ "colType": "類型", "filterConfiguredOnly": "僅顯示已設定", "filterAvailableOnly": "僅可用", - "filterAvailableOnlyHelp": "隱藏所有連線皆被限速或配額用盡的供應商。", + "filterAvailableOnlyHelp": "隱藏所有連線皆被限速或配額用盡的提供者。", "configuredOnly": "僅已設定", "configuredOnlyHint": "僅顯示有活躍連線的提供者", "noConfiguredProviders": "找不到已設定的提供者。請先新增提供者連線。", @@ -11395,7 +11396,7 @@ "typeApikey": "API 金鑰", "sortTypeFirst": "最簡單優先", "sortTypeFirstHelp": "按註冊難度分組(無需註冊 → OAuth 登入 → API 金鑰),在各組內保持品質順序", - "typeLegend": "無需註冊 = 零設定 · OAuth 登入 = 使用自己的帳戶登入 · API 金鑰 = 自帶金鑰或使用該供應商的免費方案" + "typeLegend": "無需註冊 = 零設定 · OAuth 登入 = 使用自己的帳戶登入 · API 金鑰 = 自帶金鑰或使用該提供者的免費方案" }, "discovery": { "title": "提供者探索", @@ -12023,80 +12024,80 @@ "viewFullHistory": "在 GitHub 上檢視完整歷史" }, "reasoningRouting": { - "title": "Reasoning routing policies", - "apiKeyTitle": "Reasoning routing for this API key", - "subtitle": "Reroute models and reasoning effort without requiring client support. Requests remain unchanged when no rule matches.", - "loadError": "Reasoning rules could not be loaded.", - "saveError": "The rule is invalid or could not be saved.", - "saved": "Reasoning rule saved.", - "deleteConfirm": "Delete this reasoning rule?", - "deleteError": "The reasoning rule could not be deleted.", - "empty": "No matching reasoning rules configured.", - "all": "All", - "any": "Any", - "enabled": "Enabled", - "disabled": "Disabled", - "filterSearch": "Search rules", - "filterScope": "Filter by scope", - "filterStatus": "Filter by status", - "allModels": "all models", - "keepModel": "Keep model", - "otherModel": "Other model", - "combo": "Combo", - "priorityShort": "priority {value}", - "toggleAria": "Enable {name}", - "edit": "Edit", - "delete": "Delete", - "name": "Name", - "description": "Description", - "scopeLabel": "Scope", - "apiKey": "API key", - "sourceCombo": "Source combo", - "connection": "Connection", - "sourceModel": "Source model or wildcard", - "sourceModelOptional": "Empty = all models", - "sourceModelExample": "e.g. gpt-5*", - "sourceEffort": "Source effort", - "missing": "Not specified", - "signalOnly": "Non-discrete reasoning signal", - "requestTags": "Request tags", + "title": "思考路由策略", + "apiKeyTitle": "此 API 金鑰的思考路由策略", + "subtitle": "無需客戶端支援即可重新路由模型與思考強度 (Reasoning Effort)。未匹配任何規則時,請求保持不變。", + "loadError": "無法載入思考路由規則。", + "saveError": "規則無效或無法儲存。", + "saved": "思考路由規則已儲存。", + "deleteConfirm": "確定要刪除此思考路由規則嗎?", + "deleteError": "無法刪除思考路由規則。", + "empty": "尚未配置匹配的思考路由規則。", + "all": "全部", + "any": "任意", + "enabled": "已啟用", + "disabled": "已停用", + "filterSearch": "搜尋規則", + "filterScope": "依作用域篩選", + "filterStatus": "依狀態篩選", + "allModels": "所有模型", + "keepModel": "保持原模型", + "otherModel": "其他模型", + "combo": "組合", + "priorityShort": "優先級 {value}", + "toggleAria": "啟用 {name}", + "edit": "編輯", + "delete": "刪除", + "name": "名稱", + "description": "說明", + "scopeLabel": "作用域", + "apiKey": "API 金鑰", + "sourceCombo": "來源組合", + "connection": "連線", + "sourceModel": "來源模型或萬用字元", + "sourceModelOptional": "留空 = 適用於所有模型", + "sourceModelExample": "例如 gpt-5*", + "sourceEffort": "來源思考強度 (Effort)", + "missing": "未指定", + "signalOnly": "非離散思考訊號", + "requestTags": "請求標籤", "requestTagsExample": "coding, internal", - "tagMode": "Tag matching", - "effortMode": "Effort mode", - "targetEffort": "Target effort", - "routingTarget": "Routing target", - "targetModel": "Target model", - "targetCombo": "Target combo", - "budgetAction": "Thinking budget", - "budgetTokens": "Budget tokens", - "priority": "Priority", - "saveChanges": "Save changes", - "add": "Add rule", - "cancel": "Cancel", - "simulateTitle": "Simulate rule", - "model": "Model", - "effort": "Effort", - "transport": "Transport", - "simulate": "Simulate without upstream", - "extendedComboWarning": "Max/Ultra is validated separately for every combo target during routing.", - "extendedUnknownWarning": "Enter a target model to verify Max/Ultra support.", - "extendedUnsupportedWarning": "Max/Ultra is only known to be supported by suitable Codex GPT-5.6 models. Unknown custom models are accepted by the server with a warning.", + "tagMode": "標籤匹配模式", + "effortMode": "思考強度模式", + "targetEffort": "目標思考強度", + "routingTarget": "路由目標", + "targetModel": "目標模型", + "targetCombo": "目標組合", + "budgetAction": "思考預算 (Thinking Budget)", + "budgetTokens": "預算 Token 數", + "priority": "優先級", + "saveChanges": "儲存變更", + "add": "新增規則", + "cancel": "取消", + "simulateTitle": "模擬規則測試", + "model": "模型", + "effort": "思考強度", + "transport": "傳輸協定", + "simulate": "模擬執行 (不發送至上游)", + "extendedComboWarning": "Max/Ultra 模式將在路由期間對每個組合目標分別進行驗證。", + "extendedUnknownWarning": "請輸入目標模型以驗證是否支援 Max/Ultra。", + "extendedUnsupportedWarning": "Max/Ultra 目前僅確定支援合適的 Codex GPT-5.6 模型。自訂模型伺服器會接受但顯示警告。", "scope": { - "global": "Global", - "apiKey": "API key", - "combo": "Combo", - "model": "Model", - "connection": "Connection" + "global": "全域", + "apiKey": "API 金鑰", + "combo": "組合", + "model": "模型", + "connection": "連線" }, "mode": { - "inherit": "Inherit client", - "default": "Use default", - "force": "Force" + "inherit": "繼承客戶端", + "default": "使用預設值", + "force": "強制指定" }, "budget": { - "preserve": "Preserve", - "remove": "Remove", - "set": "Set fixed value" + "preserve": "保留原設定", + "remove": "移除預算", + "set": "設定固定值" } }, "chaosConfig": { @@ -12113,11 +12114,11 @@ "timeoutDesc": "每次模型呼叫的最長時間(5000-600000 毫秒)", "systemPrompt": "系統提示(選填)", "systemPromptDesc": "針對所有混沌模式模型實例的自訂指示", - "providerOverrides": "供應商覆寫", - "providerOverridesDesc": "為混沌模式選取每個供應商的特定模型", - "providerId": "供應商", + "providerOverrides": "提供者覆寫", + "providerOverridesDesc": "為混沌模式選取每個提供者的特定模型", + "providerId": "提供者", "modelId": "模型", - "addProvider": "新增供應商", + "addProvider": "新增提供者", "removeProvider": "移除", "saveConfig": "儲存設定", "configSaved": "混沌設定已成功儲存", @@ -12127,18 +12128,18 @@ "keyPermissionDesc": "允許此 API 金鑰使用混沌模式(多模型並行執行)", "testButton": "測試混沌模式", "testTask": "寫一首關於人工智慧的短詩", - "loadingProviderModels": "正在載入供應商…", + "loadingProviderModels": "正在載入提供者…", "systemPromptPlaceholder": "選填:覆寫預設混沌模式系統提示…", "enabled": "已啟用", "disabled": "已停用", "maxTokens": "最大 Token 數", "maxTokensDesc": "每個模型回應的最大 Token 數。數值越高,成本越高且耗時越久。", - "providerIdPlaceholder": "供應商 ID(輸入或選取)", + "providerIdPlaceholder": "提供者 ID(輸入或選取)", "modelIdPlaceholder": "模型 ID(選填)", "on": "開啟", "off": "關閉", - "availableProviders": "可用供應商({count})", - "noProviderOverrides": "無覆寫——所有啟用的供應商將使用其預設模型參與" + "availableProviders": "可用提供者({count})", + "noProviderOverrides": "無覆寫——所有啟用的提供者將使用其預設模型參與" }, "kimiSponsorBanner": { "title": "Kimi(Moonshot AI)是 OmniRoute 的創始開源好友", diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 92ec377cf7..f0498e4239 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -212,6 +212,36 @@ export async function warmModelCatalogCache(): Promise { } } +/** + * #8530: enumerate existing combos whose name shadows a real model id and + * log a startup warning. Never rejects/throws — #6940 documents a combo + * named after a bare model id as the supported mechanism for per-model + * provider fallback, so a collision here is expected in some deployments; + * this only gives operators who hit it accidentally a signal. + * + * Exported (rather than left inline in registerNodejs()) so it can be unit + * tested directly without exercising the rest of the startup sequence. + */ +export async function scanComboModelNameCollisionsAtBoot(): Promise { + try { + const [{ getCombos }, { scanCombosForModelCollisions }] = await Promise.all([ + import("@/lib/db/combos"), + import("@/lib/combos/modelNameCollision"), + ]); + const collisions = scanCombosForModelCollisions(await getCombos()); + if (collisions.length > 0) { + console.warn( + `[STARTUP] ${collisions.length} combo(s) share a name with a real model id (#8530) — ` + + "intentional per #6940 bare-model-id fallback, but confirm each is expected: " + + collisions.map((c) => `${c.comboName}→${c.providerId}/${c.modelId}`).join(", ") + ); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[STARTUP] Could not scan combos for model-name collisions (non-fatal):", msg); + } +} + export async function registerNodejs(): Promise { markServerStarting(); @@ -264,6 +294,8 @@ export async function registerNodejs(): Promise { console.warn("[STARTUP] Could not clear stale crash cooldowns (non-fatal):", msg); } + await scanComboModelNameCollisionsAtBoot(); + const [ { initGracefulShutdown }, { initApiBridgeServer }, diff --git a/src/lib/cli-helper/config-generator/hermes-agent.ts b/src/lib/cli-helper/config-generator/hermes-agent.ts index ee5d05fd2c..74031f0870 100644 --- a/src/lib/cli-helper/config-generator/hermes-agent.ts +++ b/src/lib/cli-helper/config-generator/hermes-agent.ts @@ -37,10 +37,37 @@ export const HERMES_AGENT_ROLES = [ description: "Orchestrator and sub-agent spawning model", }, { id: "vision", label: "Vision", description: "Image and screenshot understanding" }, - { id: "compression", label: "Compression", description: "Prompt compression and summarization" }, { id: "web_extract", label: "Web Extract", description: "Web page / content extraction" }, + { id: "compression", label: "Compression", description: "Prompt compression and summarization" }, { id: "skills_hub", label: "Skills Hub", description: "Skills and tool-use reasoning" }, { id: "approval", label: "Approval", description: "Safety and approval decisions" }, + { id: "mcp", label: "MCP", description: "MCP server tool calls" }, + { id: "title_generation", label: "Title Generation", description: "Session title generation" }, + { + id: "memory_query_rewrite", + label: "Memory Query Rewrite", + description: "Memory search query rewriting", + }, + { id: "tts_audio_tags", label: "TTS Audio Tags", description: "TTS audio tag generation" }, + { + id: "triage_specifier", + label: "Triage Specifier", + description: "Issue / PR triage specification", + }, + { + id: "kanban_decomposer", + label: "Kanban Decomposer", + description: "Kanban task decomposition", + }, + { id: "profile_describer", label: "Profile Describer", description: "User profile description" }, + { id: "goal_judge", label: "Goal Judge", description: "Goal completion judging" }, + { id: "curator", label: "Curator", description: "Skill and memory curation" }, + { id: "monitor", label: "Monitor", description: "Background monitoring" }, + { + id: "background_review", + label: "Background Review", + description: "Background code review", + }, ] as const; export type HermesAgentRole = (typeof HERMES_AGENT_ROLES)[number]["id"]; diff --git a/src/lib/combos/modelNameCollision.ts b/src/lib/combos/modelNameCollision.ts new file mode 100644 index 0000000000..a0733a63a0 --- /dev/null +++ b/src/lib/combos/modelNameCollision.ts @@ -0,0 +1,95 @@ +/** + * Combo-name / model-id collision detection (#8530). + * + * #6940 (closed by the maintainer) documents a combo named identically to a + * bare model id — e.g. combo `gpt-5.5` fanning out to + * `acme-responses/gpt-5.5`, `backup-responses/gpt-5.5` — as THE supported + * mechanism for per-model provider fallback on bare Responses model ids + * (reusing the #3227/#3233 combo-before-rewrite precedence, which is + * regression-tested in `tests/unit/responses-combo-resolution-3227.test.ts` + * and `tests/unit/combo-name-codex-responses-rewrite.test.ts`). + * + * So a colliding name is NOT rejected — it is a supported, intentional + * pattern. This module only makes the collision *observable*: callers use it + * to attach a non-blocking warning to the create/rename response and to the + * boot-time scan, instead of silently shadowing the model with zero signal. + */ +import { PROVIDER_MODELS } from "@/shared/constants/models"; + +export interface ComboModelCollision { + providerId: string; + modelId: string; +} + +let cachedIndex: Map | null = null; + +/** + * Bare model id -> first provider that registers it. Built once from the + * (lazily-generated, then cached) provider registry and memoized for the + * process lifetime — the registry is static compiled-in config, not + * runtime/DB state, so it never needs invalidation. + */ +function getModelIdIndex(): Map { + if (cachedIndex) return cachedIndex; + const index = new Map(); + for (const [providerId, models] of Object.entries(PROVIDER_MODELS)) { + for (const model of models) { + if (!index.has(model.id)) { + index.set(model.id, { providerId, modelId: model.id }); + } + } + } + cachedIndex = index; + return index; +} + +/** Test-only: force the memoized registry index to rebuild on next call. */ +export function __resetModelNameCollisionCacheForTest(): void { + cachedIndex = null; +} + +/** + * Returns the provider that registers `name` as a bare model id, or `null` + * when `name` does not collide with any known real model id. + */ +export function findCollidingModel(name: string): ComboModelCollision | null { + if (!name) return null; + return getModelIdIndex().get(name) ?? null; +} + +/** Machine-readable warning payload attached to POST/PUT combo responses. */ +export function buildComboNameCollisionWarning( + name: string +): { code: "COMBO_NAME_SHADOWS_MODEL"; modelId: string; providerId: string } | null { + const collision = findCollidingModel(name); + if (!collision) return null; + return { + code: "COMBO_NAME_SHADOWS_MODEL", + modelId: collision.modelId, + providerId: collision.providerId, + }; +} + +/** Shape of the minimal combo record the boot-time scan needs. */ +export interface ComboLike { + name?: unknown; +} + +/** + * Boot-time scan (see `src/instrumentation-node.ts`): enumerates existing + * combos whose name shadows a real model id, for a startup log — never a + * hard failure, since the shadowing pattern is intentional per #6940. + */ +export function scanCombosForModelCollisions( + combos: readonly ComboLike[] +): Array<{ comboName: string; providerId: string; modelId: string }> { + const results: Array<{ comboName: string; providerId: string; modelId: string }> = []; + for (const combo of combos) { + if (typeof combo.name !== "string" || combo.name.length === 0) continue; + const collision = findCollidingModel(combo.name); + if (collision) { + results.push({ comboName: combo.name, ...collision }); + } + } + return results; +} diff --git a/src/lib/db/compression.ts b/src/lib/db/compression.ts index 3b39ccd9e4..7c9b46732c 100644 --- a/src/lib/db/compression.ts +++ b/src/lib/db/compression.ts @@ -40,6 +40,7 @@ import { normalizePreserveSystemPromptMode, } from "@omniroute/open-sse/services/compression/preserveSystemPromptMode.ts"; import { maybePrewarmUltraSlmOnConfig } from "@omniroute/open-sse/services/compression/ultra.ts"; +import { applyDetailConfigUpdate, buildDetailConfigDefaults } from "./compressionDetailNormalizers"; const NAMESPACE = "compression"; const COMPRESSION_MODES = new Set([ @@ -612,6 +613,7 @@ export async function getCompressionSettings(): Promise { aggressive: normalizeAggressiveConfig(undefined), ultra: normalizeUltraConfig(undefined), headroom: normalizeHeadroomConfig(undefined), + ...buildDetailConfigDefaults(), contextBudget: normalizeContextBudgetConfig(undefined), contextEditing: { ...DEFAULT_CONTEXT_EDITING_CONFIG }, liveZone: { enabled: false }, @@ -724,6 +726,10 @@ export async function getCompressionSettings(): Promise { case "headroomConfig": config.headroom = normalizeHeadroomConfig(parsed); break; + case "sessionDedup": + case "ccr": + applyDetailConfigUpdate(config, key, parsed); + break; case "contextBudget": config.contextBudget = normalizeContextBudgetConfig(parsed); break; diff --git a/src/lib/db/compressionDetailNormalizers.ts b/src/lib/db/compressionDetailNormalizers.ts new file mode 100644 index 0000000000..6da34a9153 --- /dev/null +++ b/src/lib/db/compressionDetailNormalizers.ts @@ -0,0 +1,70 @@ +// Normalizers for the compression engine DETAIL settings sub-objects that persist to a +// single key_value row each (settings.sessionDedup / settings.ccr). Extracted out of +// src/lib/db/compression.ts (frozen at cap by file-size-baseline.json — see +// scripts/check/check-file-size.mjs) rather than growing that file inline. +// +// #8388: session-dedup and ccr detail fields (minBlockChars/fuzzy, minChars/ +// retrievalRampFactor) were editable on the EngineConfigPage detail form but had no +// persisted sub-object — mirrors the #8056 headroom/minRows fix (normalizeHeadroomConfig +// in compression.ts), extended to the two engines #8056 left uncovered. +import { + DEFAULT_CCR_CONFIG, + DEFAULT_SESSION_DEDUP_CONFIG, + type CcrConfig, + type CompressionConfig, + type SessionDedupConfig, +} from "@omniroute/open-sse/services/compression/types.ts"; + +function toRecord(value: unknown): Record { + return value && typeof value === "object" ? (value as Record) : {}; +} + +function boundedInt(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, Math.floor(value))); +} + +/** Matches SESSION_DEDUP_SCHEMA bounds (engines/session-dedup/index.ts). */ +export function normalizeSessionDedupConfig(value: unknown): SessionDedupConfig { + const record = toRecord(value); + return { + ...DEFAULT_SESSION_DEDUP_CONFIG, + minBlockChars: boundedInt( + record.minBlockChars, + DEFAULT_SESSION_DEDUP_CONFIG.minBlockChars, + 1, + 100000 + ), + fuzzy: typeof record.fuzzy === "boolean" ? record.fuzzy : DEFAULT_SESSION_DEDUP_CONFIG.fuzzy, + }; +} + +/** Matches CCR_SCHEMA bounds (engines/ccr/index.ts). */ +export function normalizeCcrConfig(value: unknown): CcrConfig { + const record = toRecord(value); + return { + ...DEFAULT_CCR_CONFIG, + minChars: boundedInt(record.minChars, DEFAULT_CCR_CONFIG.minChars, 100, 1_000_000), + retrievalRampFactor: boundedInt( + record.retrievalRampFactor, + DEFAULT_CCR_CONFIG.retrievalRampFactor, + 1, + 100 + ), + }; +} + +/** Default sub-objects spread into getCompressionSettings' seed config. */ +export function buildDetailConfigDefaults(): Pick { + return { sessionDedup: normalizeSessionDedupConfig(undefined), ccr: normalizeCcrConfig(undefined) }; +} + +/** Applies a stored sessionDedup/ccr row onto config during getCompressionSettings' row scan. */ +export function applyDetailConfigUpdate( + config: CompressionConfig, + key: "sessionDedup" | "ccr", + parsed: unknown +): void { + if (key === "sessionDedup") config.sessionDedup = normalizeSessionDedupConfig(parsed); + else config.ccr = normalizeCcrConfig(parsed); +} diff --git a/src/lib/db/migrations/133_call_logs_session_tag.sql b/src/lib/db/migrations/133_call_logs_session_tag.sql new file mode 100644 index 0000000000..13408b1b53 --- /dev/null +++ b/src/lib/db/migrations/133_call_logs_session_tag.sql @@ -0,0 +1,2 @@ +ALTER TABLE call_logs ADD COLUMN session_tag TEXT DEFAULT NULL; +CREATE INDEX IF NOT EXISTS idx_cl_session_tag ON call_logs(session_tag); diff --git a/src/lib/db/quotaSnapshots.ts b/src/lib/db/quotaSnapshots.ts index e8495365ae..1e77cc47fb 100644 --- a/src/lib/db/quotaSnapshots.ts +++ b/src/lib/db/quotaSnapshots.ts @@ -84,29 +84,37 @@ export function getQuotaSnapshots(opts: { } } +/** + * Returns the single latest snapshot row for each distinct `window_key` + * ever observed for this connection. + * + * Deliberately NOT a "most recent N rows across all windows" query: a + * connection with many quota windows where only a subset actively churn + * (frequent writes as they drain) and the rest stay idle/healthy (a single + * old row each, thanks to the #4438 no-op-write dedup) would otherwise have + * its recent-rows slice flooded entirely by the hot windows, silently + * evicting the idle windows from rehydration (#8431). Scoping "latest" PER + * window_key via a window function keeps every window visible regardless of + * how skewed the write frequency is across windows. + */ export function getLatestQuotaSnapshotsForConnection(connectionId: string): QuotaSnapshotRow[] { const db = getDbInstance() as unknown as DbLike; try { const rows = db .prepare( - `SELECT * FROM quota_snapshots - WHERE connection_id = ? - ORDER BY created_at DESC - LIMIT 200` + `SELECT * FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY window_key ORDER BY created_at DESC, id DESC + ) AS rn + FROM quota_snapshots + WHERE connection_id = ? + ) + WHERE rn = 1` ) .all(connectionId); - const latestByWindow = new Map(); - for (const row of rows) { - const snapshot = rowToCamel(row) as unknown as QuotaSnapshotRow; - const windowKey = - (snapshot as unknown as { windowKey?: string }).windowKey ?? snapshot.window_key; - if (!windowKey || latestByWindow.has(windowKey)) continue; - latestByWindow.set(windowKey, snapshot); - } - - return [...latestByWindow.values()]; + return rows.map((row) => rowToCamel(row) as unknown as QuotaSnapshotRow); } catch (err: any) { if (err?.message?.includes("no such table")) { return []; diff --git a/src/lib/db/schemaColumns.ts b/src/lib/db/schemaColumns.ts index d9334f05f5..a5fbd0b62a 100644 --- a/src/lib/db/schemaColumns.ts +++ b/src/lib/db/schemaColumns.ts @@ -253,6 +253,10 @@ export function ensureCallLogsColumns(db: SqliteDatabase) { db.exec("ALTER TABLE call_logs ADD COLUMN model_pinned INTEGER DEFAULT 0"); console.log("[DB] Added call_logs.model_pinned column"); } + if (!columnNames.has("session_tag")) { + db.exec("ALTER TABLE call_logs ADD COLUMN session_tag TEXT DEFAULT NULL"); + console.log("[DB] Added call_logs.session_tag column"); + } db.exec( "CREATE INDEX IF NOT EXISTS idx_call_logs_requested_model ON call_logs(requested_model)" @@ -262,6 +266,7 @@ export function ensureCallLogsColumns(db: SqliteDatabase) { "CREATE INDEX IF NOT EXISTS idx_cl_combo_target ON call_logs(combo_name, combo_execution_key, timestamp)" ); db.exec("CREATE INDEX IF NOT EXISTS idx_cl_correlation_id ON call_logs(correlation_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_cl_session_tag ON call_logs(session_tag)"); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.warn("[DB] Failed to verify call_logs schema:", message); diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 5c997e4588..03b2740304 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -165,7 +165,13 @@ export async function getSettings() { codexServiceTier: { enabled: false }, claudeFastMode: { enabled: false, - supportedModels: ["claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"], + supportedModels: [ + "claude-fable-5", + "claude-opus-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + ], }, // #7274: renamed from codexSessionAffinityTtlMs — session affinity now // applies to any provider, not just Codex. No default here on purpose: @@ -523,8 +529,10 @@ export async function resolveProxyForConnection( // Step 2: API key-level proxy (only if per-key proxy is enabled globally or per-connection) if (apiKeyId) { - // Check if per-key proxy is allowed: globally OR per-connection - const perKeyEnabled = globalPerKeyProxyEnabled || connectionPerKeyProxyEnabled; + // Check if per-key proxy is allowed: the global toggle is a true override — + // when it is off, no connection's per-key assignment may apply, regardless + // of that connection's own per_key_proxy_enabled flag (#8385). + const perKeyEnabled = globalPerKeyProxyEnabled && connectionPerKeyProxyEnabled; if (perKeyEnabled) { try { diff --git a/src/lib/guardrails/registry.ts b/src/lib/guardrails/registry.ts index 8aa7276ba5..531539ab17 100644 --- a/src/lib/guardrails/registry.ts +++ b/src/lib/guardrails/registry.ts @@ -1,9 +1,30 @@ -import { BaseGuardrail, type GuardrailContext, type GuardrailExecutionResult } from "./base"; +import { + BaseGuardrail, + type GuardrailContext, + type GuardrailExecutionResult, + type GuardrailResult, +} from "./base"; import { PIIMaskerGuardrail } from "./piiMasker"; import { PromptInjectionGuardrail } from "./promptInjection"; import { VisionBridgeGuardrail } from "./visionBridge"; import { CredentialMaskerGuardrail } from "./credentialMasker"; +/** + * `preCall`/`postCall` may legitimately return nothing — that is the documented + * "no change" signal, alongside `{}` and `{ block: false }` + * (`docs/security/GUARDRAILS.md`), and `CredentialMaskerGuardrail` still declares + * the `| void` arm. + * + * `void` is not a value the checker lets us inspect, so neither `result?.block` + * nor a truthiness test compiles against `GuardrailResult | void`. Funnel the + * return through `unknown` once, here, and hand the dispatch loops a plain + * optional. Runtime behavior is unchanged: a guardrail that returns nothing + * still yields `undefined` and is still treated as "passed". + */ +function asGuardrailResult(raw: unknown): GuardrailResult | undefined { + return raw && typeof raw === "object" ? (raw as GuardrailResult) : undefined; +} + type HeadersLike = Headers | Record | null | undefined; function isHeaderStore(headers: HeadersLike): headers is Headers { @@ -128,7 +149,7 @@ export class GuardrailRegistry { } try { - const result = await guardrail.preCall(currentPayload, context); + const result = asGuardrailResult(await guardrail.preCall(currentPayload, context)); const modified = result?.modifiedPayload !== undefined; const meta = result?.meta || null; @@ -201,7 +222,7 @@ export class GuardrailRegistry { } try { - const result = await guardrail.postCall(currentResponse, context); + const result = asGuardrailResult(await guardrail.postCall(currentResponse, context)); const modified = result?.modifiedResponse !== undefined; const meta = result?.meta || null; diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 79a1a75f2d..60f3d349dd 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -224,6 +224,13 @@ function heuristicMaxTokens(modelStr: string): boolean { return !blocked; } +/** Last path segment of a path-shaped model id (`cline-pass/kimi-k3` → `kimi-k3`). */ +function leafModelId(modelId: string | null | undefined): string | null { + if (!modelId || !modelId.includes("/")) return null; + const leaf = modelId.split("/").filter(Boolean).pop() ?? null; + return leaf && leaf !== modelId ? leaf : null; +} + function getStaticSpec(modelId: string | null, rawModel: string | null): ModelSpec | undefined { if (modelId) { const byCanonical = getModelSpec(modelId); @@ -235,6 +242,29 @@ function getStaticSpec(modelId: string | null, rawModel: string | null): ModelSp return undefined; } +/** + * #8032: vision-only leaf fallback for path-shaped routed ids. + * + * Must NOT live in getStaticSpec() — that helper also feeds supportsTools / + * supportsThinking / contextWindow / maxOutputTokens. A shared leaf lookup + * incorrectly promotes e.g. aihorde/deepseek/deepseek-v4-flash to the real + * DeepSeek V4 Flash tool-calling spec (#8212 regression). + */ +function getVisionStaticSpec( + modelId: string | null, + rawModel: string | null +): ModelSpec | undefined { + const direct = getStaticSpec(modelId, rawModel); + if (direct) return direct; + for (const candidate of [modelId, rawModel]) { + const leaf = leafModelId(candidate); + if (!leaf) continue; + const byLeaf = getModelSpec(leaf); + if (byLeaf) return byLeaf; + } + return undefined; +} + function getAuthoritativeStaticContextWindow( provider: string | null, modelId: string | null, @@ -281,9 +311,21 @@ function reverseModelsDevProviders(provider: string): string[] { // models.dev may store capabilities under a different OmniRoute provider id // that also maps from the same upstream models.dev provider. Build reverse // candidates from MODELS_DEV_PROVIDER_MAP (e.g. openai ↔ cx). + // + // MODELS_DEV_PROVIDER_MAP's RHS is inconsistent: most providers list their + // canonical id directly, but the OAuth CLI providers (codex/claude) only + // list their alias (cx/cc), never the canonical id. Also probe the + // provider's alias so a canonical id like "codex"/"claude" still matches + // the map entries keyed only by "cx"/"cc" (#8429). const out = new Set(); + const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider; for (const [modelsDevId, omniIds] of Object.entries(MODELS_DEV_PROVIDER_MAP)) { - if (omniIds.includes(provider) || modelsDevId === provider) { + if ( + omniIds.includes(provider) || + omniIds.includes(providerAlias) || + modelsDevId === provider || + modelsDevId === providerAlias + ) { out.add(modelsDevId); for (const id of omniIds) out.add(id); } @@ -306,6 +348,8 @@ function getSyncedCapabilityForResolved( const values = [candidate]; const stripped = stripLatestAlias(candidate); if (stripped) values.push(stripped); + const leaf = leafModelId(candidate); + if (leaf) values.push(leaf); // models.dev often stores OpenAI-family specialty models as qualified // ids under another mapped provider, e.g. vercel + "openai/whisper-1". if (!candidate.includes("/")) { @@ -399,6 +443,14 @@ function resolveVisionCapability( if (synced.attachment === false && modalitiesDeclareVision(allModalities)) { return true; } + // #8032: attachment=false without modalities must not beat authoritative + // registry/spec vision for path-shaped custom/routed ids (e.g. Cline Pass + // `cp/cline-pass/kimi-k3` → MODEL_SPECS["kimi-k3"].supportsVision). + if (synced.attachment === false) { + if (registryModel?.supportsVision === true) return true; + if (spec?.supportsVision === true) return true; + return false; + } return synced.attachment; } @@ -522,8 +574,12 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo const maxTokenOverride = getMaxTokenCapabilityOverride(resolved); + // Vision consults leaf static metadata for path-shaped ids; other capability + // fields keep using the non-leaf `spec` from getStaticSpec() above. + const visionSpec = getVisionStaticSpec(resolved.model, resolved.rawModel); + const supportsVision = resolveVisionCapability( - spec, + visionSpec, registryModel, synced, modalitiesInput, diff --git a/src/lib/oauth/kiroConnectionIdentity.ts b/src/lib/oauth/kiroConnectionIdentity.ts new file mode 100644 index 0000000000..d5ff76157f --- /dev/null +++ b/src/lib/oauth/kiroConnectionIdentity.ts @@ -0,0 +1,72 @@ +export type KiroConnectionLike = { + id?: unknown; + authType?: unknown; + name?: unknown; + email?: unknown; + providerSpecificData?: unknown; + [key: string]: unknown; +}; + +export type KiroConnectionIdentity = { + authType?: unknown; + profileArn?: unknown; + clientId?: unknown; + email?: unknown; + name?: unknown; +}; + +function trimmed(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function folded(value: unknown): string { + return trimmed(value).toLowerCase(); +} + +function providerData(connection: KiroConnectionLike): Record { + const value = connection.providerSpecificData; + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +/** Find an existing Kiro account without comparing OAuth tokens or API keys. */ +export function findKiroConnectionByIdentity( + connections: KiroConnectionLike[], + identity: KiroConnectionIdentity +): KiroConnectionLike | null { + const authType = folded(identity.authType); + const candidates = authType + ? connections.filter((connection) => folded(connection.authType) === authType) + : connections; + + const profileArn = trimmed(identity.profileArn); + if (profileArn) { + const match = candidates.find( + (connection) => trimmed(providerData(connection).profileArn) === profileArn + ); + if (match) return match; + } + + const clientId = trimmed(identity.clientId); + if (clientId) { + const match = candidates.find( + (connection) => trimmed(providerData(connection).clientId) === clientId + ); + if (match) return match; + } + + const email = folded(identity.email); + if (email) { + const match = candidates.find((connection) => folded(connection.email) === email); + if (match) return match; + } + + const name = folded(identity.name); + if (name) { + const match = candidates.find((connection) => folded(connection.name) === name); + if (match) return match; + } + + return null; +} diff --git a/src/lib/oauth/kiroSocialPoll.ts b/src/lib/oauth/kiroSocialPoll.ts new file mode 100644 index 0000000000..e6c332f79b --- /dev/null +++ b/src/lib/oauth/kiroSocialPoll.ts @@ -0,0 +1,43 @@ +export type KiroSocialPollData = { + error?: unknown; + accessToken?: unknown; + refreshToken?: unknown; +}; + +export type KiroSocialPollOutcome = + | { kind: "pending"; error: "authorization_pending" | "slow_down" } + | { kind: "error"; error: string; status: number } + | { kind: "success" }; + +/** + * RFC 8628 requires every later device-code poll to retain a `slow_down` + * increase. Keeping this transition pure makes the UI retry behaviour testable. + */ +export function getNextKiroSocialPollInterval(currentIntervalMs: number, error: unknown): number { + return error === "slow_down" ? currentIntervalMs + 5000 : currentIntervalMs; +} + +function errorCode(value: unknown): string { + return typeof value === "string" && value.trim() ? value.trim() : "authorization_failed"; +} + +export function classifyKiroSocialPoll( + responseOk: boolean, + responseStatus: number, + data: KiroSocialPollData +): KiroSocialPollOutcome { + if (data.error === "authorization_pending" || data.error === "slow_down") { + return { kind: "pending", error: data.error }; + } + + if (!responseOk || data.error) { + const status = responseStatus >= 400 && responseStatus <= 599 ? responseStatus : 400; + return { kind: "error", error: errorCode(data.error), status }; + } + + if (!data.accessToken && !data.refreshToken) { + return { kind: "error", error: "invalid_token_response", status: 502 }; + } + + return { kind: "success" }; +} diff --git a/src/lib/oauth/providers/kiro.ts b/src/lib/oauth/providers/kiro.ts index f90bbae0f0..a5d2df7fc2 100644 --- a/src/lib/oauth/providers/kiro.ts +++ b/src/lib/oauth/providers/kiro.ts @@ -77,6 +77,7 @@ export const kiro = { _clientId: clientInfo.clientId, _clientSecret: clientInfo.clientSecret, _region: resolvedRegion, + _authMethod: config.skipIssuerUrlForRegistration ? "idc" : "builder-id", }; }, pollToken: async (config, deviceCode, codeVerifier, extraData) => { @@ -116,6 +117,7 @@ export const kiro = { _clientId: extraData?._clientId, _clientSecret: extraData?._clientSecret, _region: tokenRegion, + _authMethod: extraData?._authMethod || "builder-id", }, }; } @@ -140,6 +142,7 @@ export const kiro = { postExchange: async (tokenData) => { const accessToken = tokenData?.access_token; if (!accessToken) return null; + if (tokenData?._authMethod === "builder-id") return null; const storedRegion = typeof tokenData?._region === "string" ? tokenData._region : undefined; const arn = await discoverKiroProfileArnAcrossRegions(accessToken, storedRegion); return arn ? { profileArn: arn } : null; @@ -152,6 +155,7 @@ export const kiro = { clientId: tokens._clientId, clientSecret: tokens._clientSecret, region: tokens._region, + authMethod: tokens._authMethod || (extra?.profileArn ? "idc" : "builder-id"), ...(extra?.profileArn ? { profileArn: extra.profileArn } : {}), }, }), diff --git a/src/lib/plugins/loader.ts b/src/lib/plugins/loader.ts index e49142bffe..71292ec237 100644 --- a/src/lib/plugins/loader.ts +++ b/src/lib/plugins/loader.ts @@ -22,6 +22,13 @@ const log = logger("PLUGIN_LOADER"); const DEFAULT_HOOK_TIMEOUT = 10_000; const SIGKILL_GRACE_MS = 3_000; +// #8395: stdout/stderr forwarding hygiene — cap how much of a plugin's own console +// output we relay per stream, so a runaway/misbehaving plugin can't flood memory or +// the log sink. Mirrors the per-plugin rate-limit hygiene already used for hooks +// (hooks.ts::isRateLimited). +const MAX_FORWARDED_LINES_PER_STREAM = 500; +const MAX_FORWARDED_LINE_LENGTH = 4_000; + /** * Compute a `sha256-` integrity hash of the given source string. * Matches the SRI (Subresource Integrity) format: `sha256-`. @@ -38,6 +45,44 @@ export interface LoadedPlugin { cleanup: () => void; } +/** + * #8395: forward a plugin child process's stdout/stderr to the parent's structured + * logger, line-buffered. Without this, plugin console.log/console.error output is + * silently discarded at the OS level (the child is spawned with that stream set to + * "ignore"), even though the plugin's hook handlers do run correctly over IPC. + * Caps total forwarded lines per stream to avoid a runaway plugin flooding the log. + */ +function forwardChildOutput( + stream: NodeJS.ReadableStream | null, + pluginName: string, + level: "info" | "error" +): void { + if (!stream) return; + + let buffer = ""; + let forwardedLines = 0; + + stream.on("data", (chunk: Buffer) => { + buffer += chunk.toString("utf-8"); + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + + if (line.length > 0 && forwardedLines < MAX_FORWARDED_LINES_PER_STREAM) { + forwardedLines++; + const truncated = + line.length > MAX_FORWARDED_LINE_LENGTH + ? `${line.slice(0, MAX_FORWARDED_LINE_LENGTH)}…` + : line; + log[level]("plugin.output", { name: pluginName, line: truncated }); + } + + newlineIndex = buffer.indexOf("\n"); + } + }); +} + // ── Plugin host script (runs in child process over IPC) ── // Uses process.send()/process.on("message") — NOT worker_threads. // Written as .mjs to force ESM execution regardless of package.json. @@ -139,9 +184,16 @@ export async function loadPlugin( const child = spawn(process.execPath, ["--no-warnings", hostScriptPath, entryPoint], { windowsHide: true, env, - stdio: ["ignore", "ignore", "ignore", "ipc"], + // #8395: stdout/stderr must be piped (not "ignore") so the plugin's own + // console.log/console.error output — the SDK's documented logging pattern + // (sdk.ts) — is observable on the parent side instead of discarded at the OS + // level. See forwardChildOutput() below. + stdio: ["ignore", "pipe", "pipe", "ipc"], }); + forwardChildOutput(child.stdout, manifest.name, "info"); + forwardChildOutput(child.stderr, manifest.name, "error"); + // Track pending calls with timeout support const pendingCalls: Map< string, diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts index 7e7d91927a..60c12c9bd4 100644 --- a/src/lib/providerModels/modelDiscovery.ts +++ b/src/lib/providerModels/modelDiscovery.ts @@ -5,6 +5,7 @@ import { type SyncedAvailableModel, } from "@/lib/db/models"; import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization"; +import { isObsoleteKiroModelAlias } from "@omniroute/open-sse/services/kiroModels.ts"; type JsonRecord = Record; @@ -71,6 +72,18 @@ const reasoningSupportedEffortsSchema = z .nullable() .optional(); +// #8347: CLIProxyAPI-style upstreams expose reasoning tiers as a top-level +// `supported_reasoning_levels` array, or nested under `thinking.levels`. Both accept +// entries that are either plain strings or `{ effort: string }` objects (the report shows +// the object form; `discovery/codex.ts:140` reads the same `supported_reasoning_levels` +// key as a bare existence check). Validate with Zod (Hard Rule #7): a malformed ENTRY is +// dropped individually rather than failing the whole array/record. +const effortEntrySchema = z.union([z.string(), z.object({ effort: z.string() })]); +const effortListSchema = z.array(z.unknown()); + +const supportedReasoningLevelsSchema = z.object({ supported_reasoning_levels: z.unknown() }); +const thinkingLevelsSchema = z.object({ thinking: z.object({ levels: z.unknown() }).partial() }); + // Maps common upstream synonyms onto OmniRoute's canonical effort vocabulary // (`src/shared/reasoning/effortStandardization.ts`). Values already in // `CANONICAL_EFFORT_VALUES`, and any unrecognized provider-native tier (e.g. @@ -82,6 +95,32 @@ function normalizeSupportedEffort(effort: string): string { return EFFORT_SYNONYMS[effort.toLowerCase()] || effort; } +/** + * #8347: shared parser for the two new upstream shapes (`supported_reasoning_levels`, + * `thinking.levels`). Accepts a list whose entries are either plain strings or + * `{ effort: string }` objects, drops malformed entries individually (never throws), and + * normalizes survivors onto the canonical vocabulary. Returns `undefined` when nothing + * usable remains, mirroring `detectSupportedThinkingEfforts`'s existing contract. + */ +function parseEffortList(rawList: unknown): string[] | undefined { + const listParsed = effortListSchema.safeParse(rawList); + if (!listParsed.success) return undefined; + + const efforts = Array.from( + new Set( + listParsed.data + .map((entry) => { + const entryParsed = effortEntrySchema.safeParse(entry); + if (!entryParsed.success) return null; + const raw = typeof entryParsed.data === "string" ? entryParsed.data : entryParsed.data.effort; + return raw.length > 0 ? normalizeSupportedEffort(raw) : null; + }) + .filter((effort): effort is string => effort !== null) + ) + ); + return efforts.length > 0 ? efforts : undefined; +} + /** * #7694: read the nested `record.reasoning.supported_efforts` shape and normalize each * tier onto the canonical vocabulary. Returns `undefined` (never throws) when the field @@ -91,19 +130,36 @@ function normalizeSupportedEffort(effort: string): string { */ export function detectSupportedThinkingEfforts(record: JsonRecord): string[] | undefined { const parsed = reasoningSupportedEffortsSchema.safeParse(record.reasoning); - if (!parsed.success || !parsed.data) return undefined; + if (parsed.success && parsed.data) { + const rawEfforts = parsed.data.supported_efforts; + if (Array.isArray(rawEfforts)) { + const efforts = Array.from( + new Set( + rawEfforts + .filter((effort): effort is string => typeof effort === "string" && effort.length > 0) + .map(normalizeSupportedEffort) + ) + ); + if (efforts.length > 0) return efforts; + } + } - const rawEfforts = parsed.data.supported_efforts; - if (!Array.isArray(rawEfforts)) return undefined; + // #8347: fall back to `supported_reasoning_levels`, then `thinking.levels` — in that + // order, per the regression guard for #7694 (the flat field and `reasoning.supported_efforts` + // both take precedence over these two and are handled above / by the caller). + const levelsParsed = supportedReasoningLevelsSchema.safeParse(record); + if (levelsParsed.success) { + const fromLevels = parseEffortList(levelsParsed.data.supported_reasoning_levels); + if (fromLevels) return fromLevels; + } - const efforts = Array.from( - new Set( - rawEfforts - .filter((effort): effort is string => typeof effort === "string" && effort.length > 0) - .map(normalizeSupportedEffort) - ) - ); - return efforts.length > 0 ? efforts : undefined; + const thinkingParsed = thinkingLevelsSchema.safeParse(record); + if (thinkingParsed.success) { + const fromThinking = parseEffortList(thinkingParsed.data.thinking?.levels); + if (fromThinking) return fromThinking; + } + + return undefined; } export function isAutoFetchModelsEnabled(providerSpecificData: unknown): boolean { @@ -177,10 +233,11 @@ export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel : {}), ...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}), ...(() => { - // #7694: the flat field (OmniRoute's own import format) wins verbatim when - // present, unchanged from its current pass-through behavior; only fall back to - // the nested `reasoning.supported_efforts` shape (normalized onto the canonical - // vocabulary) when the flat field is absent. + // #7694/#8347: the flat field (OmniRoute's own import format) wins verbatim when + // present, unchanged from its current pass-through behavior. Otherwise + // `detectSupportedThinkingEfforts` falls back in order: `reasoning.supported_efforts` + // → `supported_reasoning_levels` → `thinking.levels` (all normalized onto the + // canonical vocabulary) — never disturbing the flat field's precedence. if (Array.isArray(record.supportedThinkingEfforts)) { return { supportedThinkingEfforts: record.supportedThinkingEfforts.filter( @@ -201,12 +258,8 @@ export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel ? { supportsThinking: record.supportsThinking } : {}), ...(record.alwaysThinking === true ? { alwaysThinking: true } : {}), - ...(typeof record.supportsTools === "boolean" - ? { supportsTools: record.supportsTools } - : {}), - ...(typeof record.supportsVideo === "boolean" - ? { supportsVideo: record.supportsVideo } - : {}), + ...(typeof record.supportsTools === "boolean" ? { supportsTools: record.supportsTools } : {}), + ...(typeof record.supportsVideo === "boolean" ? { supportsVideo: record.supportsVideo } : {}), ...(supportsVision ? { supportsVision: true } : {}), }); } @@ -218,7 +271,10 @@ export async function getCachedDiscoveredModels( providerId: string, connectionId: string ): Promise { - return getSyncedAvailableModelsForConnection(providerId, connectionId); + const models = await getSyncedAvailableModelsForConnection(providerId, connectionId); + return providerId === "kiro" + ? models.filter((model) => !isObsoleteKiroModelAlias(model.id)) + : models; } export async function persistDiscoveredModels( diff --git a/src/lib/providers/claudeFastMode.ts b/src/lib/providers/claudeFastMode.ts index 8122fc53f3..a007b2fee4 100644 --- a/src/lib/providers/claudeFastMode.ts +++ b/src/lib/providers/claudeFastMode.ts @@ -3,11 +3,12 @@ type JsonRecord = Record; /** * Default models that support Anthropic Fast Mode (speed:"fast"). * - * Mirrors the binary-side gate observed in claude-code v2.1.145 (KT() check): - * only the latest Opus tiers can request the priority service path. + * Opus 5 support is public; the older entries mirror the binary-side gate + * observed in claude-code v2.1.145 (KT() check). */ export const CLAUDE_FAST_MODE_DEFAULT_MODELS = [ "claude-fable-5", + "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", @@ -29,8 +30,7 @@ function asStringArray(value: unknown): string[] | null { /** * Returns true if the user has globally opted into Claude Fast Mode. * - * Anthropic does not officially expose `speed:"fast"` via the public SDK; this - * toggle is only meaningful when paired with a CPA-side opt-in spoof that + * This toggle is meaningful when paired with the CPA-side opt-in path that * rewrites the entrypoint for SDK-shaped traffic. The flag is forwarded to CPA * via the `X-CPA-Force-Fast-Mode` outbound header. */ @@ -43,8 +43,8 @@ export function isClaudeFastModeEnabled(settings: unknown): boolean { } /** - * Returns the configured supported-model list, defaulting to the conservative - * Opus 4-8 / 4-7 / 4-6 set. + * Returns the configured supported-model list, defaulting to the supported + * flagship and Opus tiers. */ export function getClaudeFastModeSupportedModels(settings: unknown): string[] { const record = asRecord(settings); diff --git a/src/lib/providers/staticModels.ts b/src/lib/providers/staticModels.ts index ed77a0cfe3..bedda44f7b 100644 --- a/src/lib/providers/staticModels.ts +++ b/src/lib/providers/staticModels.ts @@ -36,6 +36,7 @@ const STATIC_MODEL_PROVIDERS: Record Array<{ id: string; name: str antigravity: () => ANTIGRAVITY_PUBLIC_MODELS.map((model) => ({ ...model })), claude: () => [ { id: "claude-fable-5", name: "Claude Fable 5" }, + { id: "claude-opus-5", name: "Claude Opus 5" }, { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index f1d645b4ba..e9b2cef768 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -10,13 +10,8 @@ import { providerAllowsOptionalApiKey, WEB_COOKIE_PROVIDERS, } from "@/shared/constants/providers"; -import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; -import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy"; -import { resolveNvidiaValidationModel } from "@/lib/providers/nvidiaValidationModel"; import { MODAL_DEFAULT_VALIDATION_MODEL_ID } from "@/shared/constants/modal"; -import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts"; import { validateImageProviderApiKey } from "@/lib/providers/imageValidation"; -import { KiroService } from "@/lib/oauth/services/kiro"; import { usesCcWireImage } from "@omniroute/open-sse/services/ccWireImageBuiltins.ts"; import { isAlibabaRegionalProvider, @@ -31,14 +26,7 @@ import { addModelsSuffix, resolveBaseUrl, } from "./validation/urlHelpers"; -import { STANDARD_USER_AGENT, directHttpsRequest, buildBearerHeaders } from "./validation/headers"; -import { - validationRead, - validationWrite, - toValidationErrorResult, - toWebCookieValidationErrorResult, - WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API, -} from "./validation/transport"; +import { toValidationErrorResult } from "./validation/transport"; import { validateDeepSeekWebProvider, validateQwenWebProvider, @@ -109,6 +97,25 @@ import { validateAnthropicCompatibleProvider, validateClaudeCodeCompatibleProvider, } from "./validation/anthropicFormat"; +import { + validateWebCookieProvider, + bytezValidationResultFromStatus, + validateBytezProvider, +} from "./validation/webCookie"; +import { + validateV0VercelProvider, + validateAuggieProvider, + validateQoderProvider, + validateKiroProvider, + validateGitlabProvider, + validateVertexProvider, + validateVertexPartnerProvider, + validateLongcatProvider, + validateNvidiaProvider, + validateZaiProvider, + validateXiaomiMimoProvider, + buildGitlawbValidators, +} from "./validation/specialtyInline"; // validateCommandCodeProvider + validateClaudeCodeCompatibleProvider have external importers // (provider-nodes/validate route + tests) — re-export to preserve the historical public surface. export { validateCommandCodeProvider, validateClaudeCodeCompatibleProvider }; @@ -117,212 +124,13 @@ export { validateCommandCodeProvider, validateClaudeCodeCompatibleProvider }; // here to preserve the historical public surface (tests + route handlers import them via this module). export { isRetryableProxyTarget, isSecurityBlockError } from "./validation/transport"; -/** - * Validates web-cookie providers by performing a ping request to check if the session is still valid. - * Returns SESSION_EXPIRED error code if the upstream returns 401/403. - */ -export async function validateWebCookieProvider({ - provider, - apiKey, - providerSpecificData: _providerSpecificData = {}, -}: { - provider: string; - apiKey?: string; - providerSpecificData?: Record; -}) { - try { - const entry = getRegistryEntry(provider); - const cookieProvider = WEB_COOKIE_PROVIDERS[provider as keyof typeof WEB_COOKIE_PROVIDERS]; - if (!entry && !cookieProvider) { - return { valid: false, error: "Provider not found in registry", unsupported: true }; - } +// validateWebCookieProvider + bytezValidationResultFromStatus have external importers (tests + +// the web-cookie fallback suites) — re-export to preserve the historical public surface. +export { validateWebCookieProvider, bytezValidationResultFromStatus }; - // For web-cookie providers, apiKey contains the cookie string - const cookie = (apiKey || "").trim(); - if (!cookie) { - return { valid: false, error: "Cookie required for web-cookie provider", unsupported: false }; - } - - if (!entry) { - // Providers listed in WEB_COOKIE_PROVIDERS without a providerRegistry entry (e.g. - // gemini-business, poe-web, venice-web, v0-vercel-web) only expose a - // marketing website URL, not a real API host. Probing `${website}/models` - // does not reliably signal session validity for these — - // live verification showed most return redirects or SPA 200s regardless of - // cookie validity, which would silently report an expired/garbage cookie as - // "OK" (worse than an honest "not supported"). Until each of these providers - // has a verified, side-effect-free auth probe against its real API host, report - // unsupported instead of a false positive. - return { - valid: false, - error: "Provider validation not supported", - unsupported: true, - }; - } - - // Attempt a minimal request to check if the session is valid - // Use /models endpoint or a minimal completion request depending on the provider - const baseUrl = normalizeBaseUrl(entry.baseUrl || ""); - - // Defense-in-depth: only an http(s) baseUrl without a query string is safe to - // probe by blindly appending `/models`. A ws(s):// baseUrl (e.g. copilot-web) is - // already rejected by the outbound URL guard downstream, but reject it explicitly - // here for the honest "unsupported" result instead of a confusing security-block - // message — this also covers a future http(s) baseUrl carrying a query string, - // which the guard does not currently block (#7857 acceptance criteria). - if (!/^https?:\/\//i.test(baseUrl) || baseUrl.includes("?")) { - return { - valid: false, - error: "Provider validation not supported", - unsupported: true, - }; - } - - const testUrl = `${baseUrl}/models`; - - const res = await validationRead( - testUrl, - { - method: "GET", - headers: { - "User-Agent": STANDARD_USER_AGENT, - Cookie: cookie, - }, - }, - isLocalProvider(provider) - ); - - if (res.status === 401 || res.status === 403) { - return { - valid: false, - error: "SESSION_EXPIRED", - errorCode: "AUTH_007", - unsupported: false, - }; - } - - // #7857: for providers whose baseUrl is a conversation/completion endpoint rather - // than a real API root, the /models path never existed upstream — a redirect, - // login-HTML 200, 404, 405, or 429 from it is not a meaningful auth signal and is - // indistinguishable from a genuinely valid session. Report the same honest - // "unsupported" result the !entry branch above already gives its no-registry - // siblings, instead of a false `valid: true`. - if (WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API.has(provider)) { - return { - valid: false, - error: "Provider validation not supported", - unsupported: true, - }; - } - - // Any other response (200, 404, 405, 429, ...) means the cookie was accepted — - // a 401/403 from the /models probe is the only definitive "session expired" signal - // for web-cookie auth, so a non-auth status is treated as a valid session. - return { valid: true, error: null, unsupported: false }; - } catch (error: unknown) { - return toWebCookieValidationErrorResult(provider, error); - } -} - -// #5422: Bytez key validation cannot use a chat probe. A Bytez account only serves models -// that have been added to its catalog, so even Bytez's own documented model ids return 404 -// ("Model does not exist or has yet to be added to the Bytez catalog") for a fresh/free key — -// the generic OpenAI-like chat probe misreads that 404 as "endpoint not supported". Validate -// against the model-independent, auth-only tasks endpoint instead (verified live): -// GET …/models/v2/list/tasks → 200 (valid key) | 401 { error: "Unauthorized" } (invalid). -// The pure status→result mapping is factored out so it is unit-testable without network. -export function bytezValidationResultFromStatus(status: number): { - valid: boolean; - error: string | null; -} { - if (status === 200) { - return { valid: true, error: null }; - } - if (status === 401 || status === 403) { - return { valid: false, error: "Invalid API key" }; - } - return { valid: false, error: `Validation failed: ${status}` }; -} - -export async function validateBytezProvider({ apiKey, providerSpecificData = {} }: any) { - try { - const res = await validationRead("https://api.bytez.com/models/v2/list/tasks", { - method: "GET", - headers: buildBearerHeaders(apiKey, providerSpecificData), - }); - return bytezValidationResultFromStatus(res.status); - } catch (error: unknown) { - return toValidationErrorResult(error); - } -} - -async function validateKiroApiKeyRuntimeProbe({ - apiKey, - region, - profileArn, -}: { - apiKey: string; - region: string; - profileArn?: string | null; -}) { - const endpoint = - region === "us-east-1" - ? "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse" - : `https://q.${region}.amazonaws.com/generateAssistantResponse`; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); - try { - const body = { - ...(profileArn ? { profileArn } : {}), - conversationState: { - chatTriggerType: "MANUAL", - conversationId: crypto.randomUUID(), - currentMessage: { - userInputMessage: { - content: "ping", - modelId: "auto", - origin: "AI_EDITOR", - }, - }, - history: [], - }, - inferenceConfig: { - maxTokens: 1, - }, - }; - - const res = await fetch(endpoint, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - tokentype: "API_KEY", - "Content-Type": "application/x-amz-json-1.0", - "X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse", - Accept: "application/vnd.amazon.eventstream", - "Amz-Sdk-Request": "attempt=1; max=3", - "Amz-Sdk-Invocation-Id": crypto.randomUUID(), - }, - body: JSON.stringify(body), - signal: controller.signal, - }); - - await res.body?.cancel().catch(() => undefined); - - if (res.ok) { - return { valid: true, error: null, method: "kiro_generate_assistant_response" }; - } - if (res.status === 401 || res.status === 403) { - return { valid: false, error: "Invalid Kiro API key or AWS region" }; - } - if (res.status === 400 || res.status === 422 || res.status === 429) { - return { valid: true, error: null, method: `kiro_generate_assistant_response_${res.status}` }; - } - return { valid: false, error: `Kiro validation failed: ${res.status}` }; - } finally { - clearTimeout(timeout); - } -} +// validateWebCookieProvider, bytezValidationResultFromStatus, validateBytezProvider, and +// validateKiroApiKeyRuntimeProbe now live in ./validation/webCookie and ./validation/kiro. +// They are re-exported above to preserve the historical public surface. export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) { const requiresApiKey = !providerAllowsOptionalApiKey(provider); @@ -355,161 +163,23 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi } } - /** - * Build Opengateway-style validators (xiaomi-mimo compatible). - * These providers share a POST /chat/completions auth check pattern and differ - * only in default baseUrl and test model name. - */ - function buildOpengatewayValidator(defaultBaseUrl: string, model: string) { - return async ({ apiKey, providerSpecificData }: any) => { - try { - const baseUrl = normalizeBaseUrl(providerSpecificData?.baseUrl || defaultBaseUrl); - const chatUrl = `${baseUrl.replace(/\/chat\/completions$/, "")}/chat/completions`; - const res = await validationWrite( - chatUrl, - { - method: "POST", - headers: buildBearerHeaders(apiKey, providerSpecificData), - body: JSON.stringify({ - model, - messages: [{ role: "user", content: "test" }], - max_tokens: 1, - }), - }, - isLocal - ); - if (res.status === 401 || res.status === 403) { - return { valid: false, error: "Invalid API key" }; - } - // Any non-auth response (200, 400, 422, 429) means auth passed - return { valid: true, error: null }; - } catch (error: any) { - return toValidationErrorResult(error); - } - }; - } - - // Same as buildOpengatewayValidator but returns an object spreadable into SPECIALTY_VALIDATORS. - // isLocal is captured via closure from the outer function scope. - function buildGitlawbValidators( - configs: [string, string, string][] - ): Record> { - return Object.fromEntries( - configs.map(([id, baseUrl, model]) => [id, buildOpengatewayValidator(baseUrl, model)]) - ); - } + // buildOpengatewayValidator + buildGitlawbValidators now live in ./validation/specialtyInline + // (god-file decomposition). The host still owns the SPECIALTY_VALIDATORS map below; only the + // validator bodies were extracted as leaf functions taking `isLocal` where the original + // closure captured it. // ── Specialty provider validation ── const SPECIALTY_VALIDATORS = { - "v0-vercel": async ({ apiKey, providerSpecificData }: any) => { - try { - const configuredBaseUrl = - typeof providerSpecificData?.baseUrl === "string" && providerSpecificData.baseUrl.trim() - ? providerSpecificData.baseUrl.trim() - : "https://api.v0.dev"; - - const root = normalizeBaseUrl(configuredBaseUrl) - .replace(/\/v1\/chat\/completions$/, "") - .replace(/\/v1$/, ""); - - const res = await validationRead( - `${root}/v1/chats?limit=1`, - { - method: "GET", - headers: buildBearerHeaders(apiKey, providerSpecificData), - }, - isLocal - ); - - if (res.ok) { - return { valid: true, error: null, method: "v0_platform_chats_list" }; - } - - if (res.status === 401 || res.status === 403) { - return { valid: false, error: "Invalid API key" }; - } - - return { valid: false, error: `v0 validation failed: ${res.status}` }; - } catch (error: any) { - return toValidationErrorResult(error); - } - }, + "v0-vercel": ({ apiKey, providerSpecificData }: any) => + validateV0VercelProvider({ apiKey, providerSpecificData, isLocal }), jules: validateJulesProvider, // "devin" is the Cognition cloud-agent provider (distinct from the "devin-cli" // LLM/ACP provider, which is already registered in providerRegistry). Wired here // for parity with the "jules" cloud-agent entry above — see #6142. devin: validateDevinCloudAgentProvider, - // auggie is a fully local, credential-less CLI passthrough — there is no API - // key to check upstream. The only meaningful validation is confirming the - // `auggie` binary is installed and runnable on this machine. - auggie: async () => { - const { checkAuggieCliVersion } = await import("@omniroute/open-sse/executors/auggie.ts"); - const result = await checkAuggieCliVersion(); - if (!result.ok) { - return { - valid: false, - error: result.error || "Auggie CLI not found. Install it and run `auggie login`.", - unsupported: false, - }; - } - return { valid: true, error: null, unsupported: false, method: result.version }; - }, - qoder: async ({ apiKey, providerSpecificData }: any) => { - // Bifurcate validation: PAT tokens use Cosy auth against api1.qoder.sh; - // regular API keys validate against dashscope (OpenAI-compatible endpoint). - const key = (apiKey || "").trim(); - if (key.startsWith("pt-")) { - return validateQoderCliPat({ apiKey: key, providerSpecificData }); - } - // Non-PAT token → validate against dashscope (Alibaba Cloud). - // The executor routes these tokens to dashscope.aliyuncs.com, so the - // validation must test against dashscope, NOT the Cosy PAT endpoint. - try { - const dashscopeUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1/models"; - const res = await validationRead( - dashscopeUrl, - { - headers: { - Authorization: `Bearer ${key}`, - }, - }, - false - ); - if (res.ok) return { valid: true, error: null }; - if (res.status === 401 || res.status === 403) { - return { - valid: false, - error: - "Invalid Qoder API key. Make sure you're using a valid API key from Qoder / Alibaba Cloud Dashscope.", - }; - } - // 4xx/5xx other than auth — treat as valid bypass to prevent false - // negatives from transient dashscope issues (consistent with PAT path). - return { valid: true, error: null }; - } catch (err: unknown) { - return toValidationErrorResult(err); - } - }, - kiro: async ({ apiKey, providerSpecificData }: any) => { - try { - const region = providerSpecificData?.region || "us-east-1"; - const credential = await new KiroService().validateApiKey(apiKey, region); - if (!credential.profileArn) { - return await validateKiroApiKeyRuntimeProbe({ - apiKey: credential.accessToken, - region: credential.region, - profileArn: providerSpecificData?.profileArn, - }); - } - return { - valid: true, - error: null, - method: "kiro_list_available_profiles", - }; - } catch (error: any) { - return toValidationErrorResult(error); - } - }, + auggie: validateAuggieProvider, + qoder: validateQoderProvider, + kiro: validateKiroProvider, "command-code": validateCommandCodeProvider, huggingface: validateHuggingFaceProvider, // #5422: auth-only probe — Bytez 404s on every chat model until the account adds it to @@ -595,212 +265,26 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi modelId: rerankProvider?.models?.[0]?.id || "jina-reranker-v3", }); }, - gitlab: async ({ apiKey, providerSpecificData }: any) => { - try { - const configuredBaseUrl = - typeof providerSpecificData?.baseUrl === "string" - ? providerSpecificData.baseUrl.trim() - : ""; - const root = (configuredBaseUrl || "https://gitlab.com").replace(/\/$/, ""); - const res = await validationWrite( - `${root}/api/v4/code_suggestions/direct_access`, - { - method: "POST", - headers: buildBearerHeaders(apiKey, providerSpecificData), - body: "{}", - }, - isLocal - ); - if (res.status === 401) { - return { valid: false, error: "Invalid API key" }; - } - return { valid: true, error: null }; - } catch (error: any) { - return toValidationErrorResult(error); - } - }, - vertex: async ({ apiKey }: any) => { - try { - const { parseSAFromApiKey, getAccessToken, isExpressApiKey } = - await import("@omniroute/open-sse/executors/vertex.ts"); - // Express-mode API keys are opaque strings sent directly as the ?key= query param — there is - // no JWT to mint, so accept any non-empty Express key (the live chat/media call validates it). - if (isExpressApiKey(apiKey)) { - return { valid: true, error: null }; - } - const sa = parseSAFromApiKey(apiKey); - // Validates credentials by successfully successfully exchanging them for a JWT from Google Identity - await getAccessToken(sa); - return { valid: true, error: null }; - } catch (error: any) { - return { valid: false, error: "Invalid Service Account JSON: " + error.message }; - } - }, - "vertex-partner": async ({ apiKey }: any) => { - try { - const { parseSAFromApiKey, getAccessToken, isExpressApiKey } = - await import("@omniroute/open-sse/executors/vertex.ts"); - if (isExpressApiKey(apiKey)) { - return { valid: true, error: null }; - } - const sa = parseSAFromApiKey(apiKey); - await getAccessToken(sa); - return { valid: true, error: null }; - } catch (error: any) { - return { valid: false, error: "Invalid Service Account JSON: " + error.message }; - } - }, - // LongCat AI — does not expose /v1/models; validate via chat completions directly (#592) - longcat: async ({ apiKey, providerSpecificData }: any) => { - try { - const res = await validationWrite( - "https://api.longcat.chat/openai/v1/chat/completions", - { - method: "POST", - headers: buildBearerHeaders(apiKey, providerSpecificData), - body: JSON.stringify({ - model: "LongCat-2.0", - messages: [{ role: "user", content: "test" }], - max_tokens: 1, - }), - }, - isLocal - ); - if (res.status === 401 || res.status === 403) { - return { valid: false, error: "Invalid API key" }; - } - // Any non-auth response (200, 400, 422) means auth passed - return { valid: true, error: null }; - } catch (error: any) { - return toValidationErrorResult(error); - } - }, - // NVIDIA NIM (#2463) — bypass the /models probe in favor of a direct - // chat/completions probe. NVIDIA NIM's /models endpoint returns model - // catalogs that vary by region and key-tier, and some keys 404 on it, - // which the generic flow misreads. The chat probe is also a stronger - // sanity check for streaming/key correctness. - nvidia: async ({ apiKey, providerSpecificData }: any) => { - try { - const baseUrlRaw = - providerSpecificData?.baseUrl || "https://integrate.api.nvidia.com/v1/chat/completions"; - const normalized = normalizeBaseUrl(baseUrlRaw); - const chatBase = normalized.replace(/\/models$/, ""); - const chatUrl = normalized.endsWith("/chat/completions") - ? normalized - : `${chatBase}/chat/completions`; - // #3116: probe a universally-available model rather than models[0] - // (z-ai/glm-5.1), which requires the "Public API Endpoints" account permission - // and can hang/be DEGRADED — making a *valid* key fail with "Upstream Error". - const modelId = resolveNvidiaValidationModel(providerSpecificData); - // #3226: use raw https (bypass the proxy/TLS-patched fetch) — the undici - // dispatcher stalls against NVIDIA's endpoint, causing a 504 timeout. - const res = await directHttpsRequest( - chatUrl, - { - method: "POST", - headers: buildBearerHeaders(apiKey, providerSpecificData), - body: JSON.stringify({ - model: modelId, - messages: [{ role: "user", content: "test" }], - max_tokens: 1, - }), - }, - 20000 - ); - if (res.status === 401 || res.status === 403) { - return { valid: false, error: "Invalid API key" }; - } - // Any non-auth response (200, 400, 422, 429) means auth passed - return { valid: true, error: null }; - } catch (error: any) { - return toValidationErrorResult(error); - } - }, - // Z.AI (glm) — bypass the proxy/TLS-patched fetch for the same reason as nvidia - // above (#3905): the undici dispatcher stalls against api.z.ai after the provider - // returns 502 "job timed out" responses, because z.ai silently drops idle - // keep-alive sockets without sending TCP RST. Using directHttpsRequest (native - // Node.js HTTPS, no undici pool) avoids the zombie-socket hang on validation. - // Z.AI uses the Anthropic wire format with x-api-key auth, not Bearer. - zai: async ({ apiKey, providerSpecificData }: any) => { - try { - // providerSpecificData.baseUrl allows test overrides to point at a local - // HTTP server; production always uses the fixed api.z.ai endpoint. - const messagesUrl = providerSpecificData?.baseUrl - ? `${normalizeBaseUrl(providerSpecificData.baseUrl).split("?")[0]}?beta=true` - : "https://api.z.ai/api/anthropic/v1/messages?beta=true"; - const res = await directHttpsRequest( - messagesUrl, - { - method: "POST", - headers: { - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - body: JSON.stringify({ - model: "glm-5.1", - messages: [{ role: "user", content: "test" }], - max_tokens: 1, - }), - }, - 20000 - ); - if (res.status === 401 || res.status === 403) { - return { valid: false, error: "Invalid API key" }; - } - if (res.status === 404 || res.status === 405) { - return { valid: false, error: "Provider validation endpoint not supported" }; - } - if (res.status >= 500 && res.status !== 502) { - return { valid: false, error: `Provider unavailable (${res.status})` }; - } - // Any non-auth response (200, 400, 422, 429, 502) means auth passed; - // 502 "job timed out" is z.ai's own server-side queue limit, not an auth error. - return { valid: true, error: null }; - } catch (error: any) { - return toValidationErrorResult(error); - } - }, - // Xiaomi MiMo — Token Plan keys (tp-*) only work on regional endpoints - // (e.g. token-plan-sgp, token-plan-ams), not api.xiaomimimo.com. - // /v1/models works but validate via chat/completions for stronger auth check. - "xiaomi-mimo": async ({ apiKey, providerSpecificData }: any) => { - try { - const baseUrl = normalizeBaseUrl( - providerSpecificData?.baseUrl || "https://api.xiaomimimo.com/v1" - ); - const chatUrl = `${baseUrl.replace(/\/chat\/completions$/, "")}/chat/completions`; - const res = await validationWrite( - chatUrl, - { - method: "POST", - headers: buildBearerHeaders(apiKey, providerSpecificData), - body: JSON.stringify({ - model: "mimo-v2.5-pro", - messages: [{ role: "user", content: "test" }], - max_tokens: 1, - }), - }, - isLocal - ); - if (res.status === 401 || res.status === 403) { - return { valid: false, error: "Invalid API key" }; - } - // Any non-auth response (200, 400, 422, 429) means auth passed - return { valid: true, error: null }; - } catch (error: any) { - return toValidationErrorResult(error); - } - }, + gitlab: ({ apiKey, providerSpecificData }: any) => + validateGitlabProvider({ apiKey, providerSpecificData, isLocal }), + vertex: validateVertexProvider, + "vertex-partner": validateVertexPartnerProvider, + longcat: ({ apiKey, providerSpecificData }: any) => + validateLongcatProvider({ apiKey, providerSpecificData, isLocal }), + nvidia: validateNvidiaProvider, + zai: validateZaiProvider, + "xiaomi-mimo": ({ apiKey, providerSpecificData }: any) => + validateXiaomiMimoProvider({ apiKey, providerSpecificData, isLocal }), // Gitlawb Opengateway — Xiaomi MiMo compatible, same /models endpoint limitation. // Bypass /models probe in favor of chat/completions, matching xiaomi-mimo's pattern. // Uses a factory to share validation logic across Opengateway provider variants. - ...buildGitlawbValidators([ - ["gitlawb", "https://opengateway.gitlawb.com/v1/xiaomi-mimo", "mimo-v2.5-pro"], - ["gitlawb-gmi", "https://opengateway.gitlawb.com/v1/gmi-cloud", "XiaomiMiMo/MiMo-V2.5-Pro"], - ]), + ...buildGitlawbValidators( + [ + ["gitlawb", "https://opengateway.gitlawb.com/v1/xiaomi-mimo", "mimo-v2.5-pro"], + ["gitlawb-gmi", "https://opengateway.gitlawb.com/v1/gmi-cloud", "XiaomiMiMo/MiMo-V2.5-Pro"], + ], + isLocal + ), // Search providers — use factored validator ...Object.fromEntries( Object.entries(SEARCH_VALIDATOR_CONFIGS).map(([id, configFn]) => [ diff --git a/src/lib/providers/validation/kiro.ts b/src/lib/providers/validation/kiro.ts new file mode 100644 index 0000000000..c248052575 --- /dev/null +++ b/src/lib/providers/validation/kiro.ts @@ -0,0 +1,71 @@ +// Kiro runtime probe — falls back to a generateAssistantResponse call when an API key +// cannot list profiles. Extracted from validation.ts (god-file decomposition) — top-level +// function with no dispatcher-state captures; behavior is byte-identical to the original +// inline def. +export async function validateKiroApiKeyRuntimeProbe({ + apiKey, + region, + profileArn, +}: { + apiKey: string; + region: string; + profileArn?: string | null; +}) { + const endpoint = + region === "us-east-1" + ? "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse" + : `https://q.${region}.amazonaws.com/generateAssistantResponse`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + try { + const body = { + ...(profileArn ? { profileArn } : {}), + conversationState: { + chatTriggerType: "MANUAL", + conversationId: crypto.randomUUID(), + currentMessage: { + userInputMessage: { + content: "ping", + modelId: "auto", + origin: "AI_EDITOR", + }, + }, + history: [], + }, + inferenceConfig: { + maxTokens: 1, + }, + }; + + const res = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + tokentype: "API_KEY", + "Content-Type": "application/x-amz-json-1.0", + "X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse", + Accept: "application/vnd.amazon.eventstream", + "Amz-Sdk-Request": "attempt=1; max=3", + "Amz-Sdk-Invocation-Id": crypto.randomUUID(), + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + await res.body?.cancel().catch(() => undefined); + + if (res.ok) { + return { valid: true, error: null, method: "kiro_generate_assistant_response" }; + } + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid Kiro API key or AWS region" }; + } + if (res.status === 400 || res.status === 422 || res.status === 429) { + return { valid: true, error: null, method: `kiro_generate_assistant_response_${res.status}` }; + } + return { valid: false, error: `Kiro validation failed: ${res.status}` }; + } finally { + clearTimeout(timeout); + } +} diff --git a/src/lib/providers/validation/specialtyInline.ts b/src/lib/providers/validation/specialtyInline.ts new file mode 100644 index 0000000000..191902d290 --- /dev/null +++ b/src/lib/providers/validation/specialtyInline.ts @@ -0,0 +1,378 @@ +// Inline specialty provider validators that previously lived as closures inside the +// SPECIALTY_VALIDATORS map in validation.ts. Extracted (god-file decomposition) as top-level +// functions taking `isLocal` where the original closure captured it; behavior is byte-identical +// to the original inline defs. The host dispatcher still owns the SPECIALTY_VALIDATORS map +// construction + dispatch — these are just the leaf validator bodies. +import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts"; +import { KiroService } from "@/lib/oauth/services/kiro"; +import { resolveNvidiaValidationModel } from "@/lib/providers/nvidiaValidationModel"; +import { normalizeBaseUrl } from "./urlHelpers"; +import { buildBearerHeaders, directHttpsRequest } from "./headers"; +import { toValidationErrorResult, validationRead, validationWrite } from "./transport"; +import { validateKiroApiKeyRuntimeProbe } from "./kiro"; + +export async function validateV0VercelProvider({ apiKey, providerSpecificData, isLocal }: any) { + try { + const configuredBaseUrl = + typeof providerSpecificData?.baseUrl === "string" && providerSpecificData.baseUrl.trim() + ? providerSpecificData.baseUrl.trim() + : "https://api.v0.dev"; + + const root = normalizeBaseUrl(configuredBaseUrl) + .replace(/\/v1\/chat\/completions$/, "") + .replace(/\/v1$/, ""); + + const res = await validationRead( + `${root}/v1/chats?limit=1`, + { + method: "GET", + headers: buildBearerHeaders(apiKey, providerSpecificData), + }, + isLocal + ); + + if (res.ok) { + return { valid: true, error: null, method: "v0_platform_chats_list" }; + } + + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + + return { valid: false, error: `v0 validation failed: ${res.status}` }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + +// auggie is a fully local, credential-less CLI passthrough — there is no API +// key to check upstream. The only meaningful validation is confirming the +// `auggie` binary is installed and runnable on this machine. +export async function validateAuggieProvider() { + const { checkAuggieCliVersion } = await import("@omniroute/open-sse/executors/auggie.ts"); + const result = await checkAuggieCliVersion(); + if (!result.ok) { + return { + valid: false, + error: result.error || "Auggie CLI not found. Install it and run `auggie login`.", + unsupported: false, + }; + } + return { valid: true, error: null, unsupported: false, method: result.version }; +} + +export async function validateQoderProvider({ apiKey, providerSpecificData }: any) { + // Bifurcate validation: PAT tokens use Cosy auth against api1.qoder.sh; + // regular API keys validate against dashscope (OpenAI-compatible endpoint). + const key = (apiKey || "").trim(); + if (key.startsWith("pt-")) { + return validateQoderCliPat({ apiKey: key, providerSpecificData }); + } + // Non-PAT token → validate against dashscope (Alibaba Cloud). + // The executor routes these tokens to dashscope.aliyuncs.com, so the + // validation must test against dashscope, NOT the Cosy PAT endpoint. + try { + const dashscopeUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1/models"; + const res = await validationRead( + dashscopeUrl, + { + headers: { + Authorization: `Bearer ${key}`, + }, + }, + false + ); + if (res.ok) return { valid: true, error: null }; + if (res.status === 401 || res.status === 403) { + return { + valid: false, + error: + "Invalid Qoder API key. Make sure you're using a valid API key from Qoder / Alibaba Cloud Dashscope.", + }; + } + // 4xx/5xx other than auth — treat as valid bypass to prevent false + // negatives from transient dashscope issues (consistent with PAT path). + return { valid: true, error: null }; + } catch (err: unknown) { + return toValidationErrorResult(err); + } +} + +export async function validateKiroProvider({ apiKey, providerSpecificData }: any) { + try { + const region = providerSpecificData?.region || "us-east-1"; + const credential = await new KiroService().validateApiKey(apiKey, region); + if (!credential.profileArn) { + return await validateKiroApiKeyRuntimeProbe({ + apiKey: credential.accessToken, + region: credential.region, + profileArn: providerSpecificData?.profileArn, + }); + } + return { + valid: true, + error: null, + method: "kiro_list_available_profiles", + }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + +export async function validateGitlabProvider({ apiKey, providerSpecificData, isLocal }: any) { + try { + const configuredBaseUrl = + typeof providerSpecificData?.baseUrl === "string" ? providerSpecificData.baseUrl.trim() : ""; + const root = (configuredBaseUrl || "https://gitlab.com").replace(/\/$/, ""); + const res = await validationWrite( + `${root}/api/v4/code_suggestions/direct_access`, + { + method: "POST", + headers: buildBearerHeaders(apiKey, providerSpecificData), + body: "{}", + }, + isLocal + ); + if (res.status === 401) { + return { valid: false, error: "Invalid API key" }; + } + return { valid: true, error: null }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + +export async function validateVertexProvider({ apiKey }: any) { + try { + const { parseSAFromApiKey, getAccessToken, isExpressApiKey } = + await import("@omniroute/open-sse/executors/vertex.ts"); + // Express-mode API keys are opaque strings sent directly as the ?key= query param — there is + // no JWT to mint, so accept any non-empty Express key (the live chat/media call validates it). + if (isExpressApiKey(apiKey)) { + return { valid: true, error: null }; + } + const sa = parseSAFromApiKey(apiKey); + // Validates credentials by successfully successfully exchanging them for a JWT from Google Identity + await getAccessToken(sa); + return { valid: true, error: null }; + } catch (error: any) { + return { valid: false, error: "Invalid Service Account JSON: " + error.message }; + } +} + +export async function validateVertexPartnerProvider({ apiKey }: any) { + try { + const { parseSAFromApiKey, getAccessToken, isExpressApiKey } = + await import("@omniroute/open-sse/executors/vertex.ts"); + if (isExpressApiKey(apiKey)) { + return { valid: true, error: null }; + } + const sa = parseSAFromApiKey(apiKey); + await getAccessToken(sa); + return { valid: true, error: null }; + } catch (error: any) { + return { valid: false, error: "Invalid Service Account JSON: " + error.message }; + } +} + +// LongCat AI — does not expose /v1/models; validate via chat completions directly (#592) +export async function validateLongcatProvider({ apiKey, providerSpecificData, isLocal }: any) { + try { + const res = await validationWrite( + "https://api.longcat.chat/openai/v1/chat/completions", + { + method: "POST", + headers: buildBearerHeaders(apiKey, providerSpecificData), + body: JSON.stringify({ + model: "LongCat-2.0", + messages: [{ role: "user", content: "test" }], + max_tokens: 1, + }), + }, + isLocal + ); + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + // Any non-auth response (200, 400, 422) means auth passed + return { valid: true, error: null }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + +// NVIDIA NIM (#2463) — bypass the /models probe in favor of a direct +// chat/completions probe. NVIDIA NIM's /models endpoint returns model +// catalogs that vary by region and key-tier, and some keys 404 on it, +// which the generic flow misreads. The chat probe is also a stronger +// sanity check for streaming/key correctness. +export async function validateNvidiaProvider({ apiKey, providerSpecificData }: any) { + try { + const baseUrlRaw = + providerSpecificData?.baseUrl || "https://integrate.api.nvidia.com/v1/chat/completions"; + const normalized = normalizeBaseUrl(baseUrlRaw); + const chatBase = normalized.replace(/\/models$/, ""); + const chatUrl = normalized.endsWith("/chat/completions") + ? normalized + : `${chatBase}/chat/completions`; + // #3116: probe a universally-available model rather than models[0] + // (z-ai/glm-5.1), which requires the "Public API Endpoints" account permission + // and can hang/be DEGRADED — making a *valid* key fail with "Upstream Error". + const modelId = resolveNvidiaValidationModel(providerSpecificData); + // #3226: use raw https (bypass the proxy/TLS-patched fetch) — the undici + // dispatcher stalls against NVIDIA's endpoint, causing a 504 timeout. + const res = await directHttpsRequest( + chatUrl, + { + method: "POST", + headers: buildBearerHeaders(apiKey, providerSpecificData), + body: JSON.stringify({ + model: modelId, + messages: [{ role: "user", content: "test" }], + max_tokens: 1, + }), + }, + 20000 + ); + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + // Any non-auth response (200, 400, 422, 429) means auth passed + return { valid: true, error: null }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + +// Z.AI (glm) — bypass the proxy/TLS-patched fetch for the same reason as nvidia +// above (#3905): the undici dispatcher stalls against api.z.ai after the provider +// returns 502 "job timed out" responses, because z.ai silently drops idle +// keep-alive sockets without sending TCP RST. Using directHttpsRequest (native +// Node.js HTTPS, no undici pool) avoids the zombie-socket hang on validation. +// Z.AI uses the Anthropic wire format with x-api-key auth, not Bearer. +export async function validateZaiProvider({ apiKey, providerSpecificData }: any) { + try { + // providerSpecificData.baseUrl allows test overrides to point at a local + // HTTP server; production always uses the fixed api.z.ai endpoint. + const messagesUrl = providerSpecificData?.baseUrl + ? `${normalizeBaseUrl(providerSpecificData.baseUrl).split("?")[0]}?beta=true` + : "https://api.z.ai/api/anthropic/v1/messages?beta=true"; + const res = await directHttpsRequest( + messagesUrl, + { + method: "POST", + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "glm-5.1", + messages: [{ role: "user", content: "test" }], + max_tokens: 1, + }), + }, + 20000 + ); + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + if (res.status === 404 || res.status === 405) { + return { valid: false, error: "Provider validation endpoint not supported" }; + } + if (res.status >= 500 && res.status !== 502) { + return { valid: false, error: `Provider unavailable (${res.status})` }; + } + // Any non-auth response (200, 400, 422, 429, 502) means auth passed; + // 502 "job timed out" is z.ai's own server-side queue limit, not an auth error. + return { valid: true, error: null }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + +// Xiaomi MiMo — Token Plan keys (tp-*) only work on regional endpoints +// (e.g. token-plan-sgp, token-plan-ams), not api.xiaomimimo.com. +// /v1/models works but validate via chat/completions for stronger auth check. +export async function validateXiaomiMimoProvider({ apiKey, providerSpecificData, isLocal }: any) { + try { + const baseUrl = normalizeBaseUrl( + providerSpecificData?.baseUrl || "https://api.xiaomimimo.com/v1" + ); + const chatUrl = `${baseUrl.replace(/\/chat\/completions$/, "")}/chat/completions`; + const res = await validationWrite( + chatUrl, + { + method: "POST", + headers: buildBearerHeaders(apiKey, providerSpecificData), + body: JSON.stringify({ + model: "mimo-v2.5-pro", + messages: [{ role: "user", content: "test" }], + max_tokens: 1, + }), + }, + isLocal + ); + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + // Any non-auth response (200, 400, 422, 429) means auth passed + return { valid: true, error: null }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + +/** + * Build Opengateway-style validators (xiaomi-mimo compatible). + * These providers share a POST /chat/completions auth check pattern and differ + * only in default baseUrl and test model name. + */ +export function buildOpengatewayValidator(defaultBaseUrl: string, model: string) { + return async ({ apiKey, providerSpecificData, isLocal }: any) => { + try { + const baseUrl = normalizeBaseUrl(providerSpecificData?.baseUrl || defaultBaseUrl); + const chatUrl = `${baseUrl.replace(/\/chat\/completions$/, "")}/chat/completions`; + const res = await validationWrite( + chatUrl, + { + method: "POST", + headers: buildBearerHeaders(apiKey, providerSpecificData), + body: JSON.stringify({ + model, + messages: [{ role: "user", content: "test" }], + max_tokens: 1, + }), + }, + isLocal + ); + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + // Any non-auth response (200, 400, 422, 429) means auth passed + return { valid: true, error: null }; + } catch (error: any) { + return toValidationErrorResult(error); + } + }; +} + +// Same as buildOpengatewayValidator but returns an object spreadable into SPECIALTY_VALIDATORS. +// isLocal is captured via closure from the outer function scope. +export function buildGitlawbValidators( + configs: [string, string, string][], + isLocal: boolean +): Record> { + return Object.fromEntries( + configs.map(([id, baseUrl, model]) => [ + id, + (({ apiKey, providerSpecificData }: any) => + buildOpengatewayValidator( + baseUrl, + model + )({ apiKey, providerSpecificData, isLocal })) as ReturnType< + typeof buildOpengatewayValidator + >, + ]) + ); +} diff --git a/src/lib/providers/validation/webCookie.ts b/src/lib/providers/validation/webCookie.ts new file mode 100644 index 0000000000..3e71cece93 --- /dev/null +++ b/src/lib/providers/validation/webCookie.ts @@ -0,0 +1,152 @@ +// Web-cookie session-ping validator + Bytez auth-only probe. Extracted from validation.ts +// (god-file decomposition) — top-level functions with no dispatcher-state captures; behavior is +// byte-identical to the original inline defs. +import { WEB_COOKIE_PROVIDERS, isLocalProvider } from "@/shared/constants/providers"; +import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { normalizeBaseUrl } from "./urlHelpers"; +import { STANDARD_USER_AGENT, buildBearerHeaders } from "./headers"; +import { + validationRead, + toValidationErrorResult, + toWebCookieValidationErrorResult, + WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API, +} from "./transport"; + +/** + * Validates web-cookie providers by performing a ping request to check if the session is still valid. + * Returns SESSION_EXPIRED error code if the upstream returns 401/403. + */ +export async function validateWebCookieProvider({ + provider, + apiKey, + providerSpecificData: _providerSpecificData = {}, +}: { + provider: string; + apiKey?: string; + providerSpecificData?: Record; +}) { + try { + const entry = getRegistryEntry(provider); + const cookieProvider = WEB_COOKIE_PROVIDERS[provider as keyof typeof WEB_COOKIE_PROVIDERS]; + if (!entry && !cookieProvider) { + return { valid: false, error: "Provider not found in registry", unsupported: true }; + } + + // For web-cookie providers, apiKey contains the cookie string + const cookie = (apiKey || "").trim(); + if (!cookie) { + return { valid: false, error: "Cookie required for web-cookie provider", unsupported: false }; + } + + if (!entry) { + // Providers listed in WEB_COOKIE_PROVIDERS without a providerRegistry entry (e.g. + // gemini-business, poe-web, venice-web, v0-vercel-web) only expose a + // marketing website URL, not a real API host. Probing `${website}/models` + // does not reliably signal session validity for these — + // live verification showed most return redirects or SPA 200s regardless of + // cookie validity, which would silently report an expired/garbage cookie as + // "OK" (worse than an honest "not supported"). Until each of these providers + // has a verified, side-effect-free auth probe against its real API host, report + // unsupported instead of a false positive. + return { + valid: false, + error: "Provider validation not supported", + unsupported: true, + }; + } + + // Attempt a minimal request to check if the session is valid + // Use /models endpoint or a minimal completion request depending on the provider + const baseUrl = normalizeBaseUrl(entry.baseUrl || ""); + + // Defense-in-depth: only an http(s) baseUrl without a query string is safe to + // probe by blindly appending `/models`. A ws(s):// baseUrl (e.g. copilot-web) is + // already rejected by the outbound URL guard downstream, but reject it explicitly + // here for the honest "unsupported" result instead of a confusing security-block + // message — this also covers a future http(s) baseUrl carrying a query string, + // which the guard does not currently block (#7857 acceptance criteria). + if (!/^https?:\/\//i.test(baseUrl) || baseUrl.includes("?")) { + return { + valid: false, + error: "Provider validation not supported", + unsupported: true, + }; + } + + const testUrl = `${baseUrl}/models`; + + const res = await validationRead( + testUrl, + { + method: "GET", + headers: { + "User-Agent": STANDARD_USER_AGENT, + Cookie: cookie, + }, + }, + isLocalProvider(provider) + ); + + if (res.status === 401 || res.status === 403) { + return { + valid: false, + error: "SESSION_EXPIRED", + errorCode: "AUTH_007", + unsupported: false, + }; + } + + // #7857: for providers whose baseUrl is a conversation/completion endpoint rather + // than a real API root, the /models path never existed upstream — a redirect, + // login-HTML 200, 404, 405, or 429 from it is not a meaningful auth signal and is + // indistinguishable from a genuinely valid session. Report the same honest + // "unsupported" result the !entry branch above already gives its no-registry + // siblings, instead of a false `valid: true`. + if (WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API.has(provider)) { + return { + valid: false, + error: "Provider validation not supported", + unsupported: true, + }; + } + + // Any other response (200, 404, 405, 429, ...) means the cookie was accepted — + // a 401/403 from the /models probe is the only definitive "session expired" signal + // for web-cookie auth, so a non-auth status is treated as a valid session. + return { valid: true, error: null, unsupported: false }; + } catch (error: unknown) { + return toWebCookieValidationErrorResult(provider, error); + } +} + +// #5422: Bytez key validation cannot use a chat probe. A Bytez account only serves models +// that have been added to its catalog, so even Bytez's own documented model ids return 404 +// ("Model does not exist or has yet to be added to the Bytez catalog") for a fresh/free key — +// the generic OpenAI-like chat probe misreads that 404 as "endpoint not supported". Validate +// against the model-independent, auth-only tasks endpoint instead (verified live): +// GET …/models/v2/list/tasks → 200 (valid key) | 401 { error: "Unauthorized" } (invalid). +// The pure status→result mapping is factored out so it is unit-testable without network. +export function bytezValidationResultFromStatus(status: number): { + valid: boolean; + error: string | null; +} { + if (status === 200) { + return { valid: true, error: null }; + } + if (status === 401 || status === 403) { + return { valid: false, error: "Invalid API key" }; + } + return { valid: false, error: `Validation failed: ${status}` }; +} + +export async function validateBytezProvider({ apiKey, providerSpecificData = {} }: any) { + try { + const res = await validationRead("https://api.bytez.com/models/v2/list/tasks", { + method: "GET", + headers: buildBearerHeaders(apiKey, providerSpecificData), + }); + return bytezValidationResultFromStatus(res.status); + } catch (error: unknown) { + return toValidationErrorResult(error); + } +} diff --git a/src/lib/resilience/settings.ts b/src/lib/resilience/settings.ts index 3ca3e9724d..7a3ae598af 100644 --- a/src/lib/resilience/settings.ts +++ b/src/lib/resilience/settings.ts @@ -95,8 +95,8 @@ export const DEFAULT_RESILIENCE_SETTINGS: ResilienceSettings = { }, // Wait at most 90s for a single short transient cooldown (covers Gemini-class // TPM/RPM windows, which report ~60s retry-after live — #7360), at most 5 - // redispatch cycles, never more than 300s (5 min) total. Active for - // quota-share and auto combos, and only for transient (non quota_exhausted) + // redispatch cycles, never more than 300s (5 min) total. Active for every + // combo strategy when enabled, and only for transient (non quota_exhausted) // reasons. comboCooldownWait: { enabled: true, diff --git a/src/lib/resilience/settings/types.ts b/src/lib/resilience/settings/types.ts index 99aaea9295..a1d3b9ecfd 100644 --- a/src/lib/resilience/settings/types.ts +++ b/src/lib/resilience/settings/types.ts @@ -65,12 +65,12 @@ export interface WaitForCooldownSettings { } /** - * Quota-share combo cooldown-aware retry (Variante A). A quota-share (`qtSd/…`) - * combo that would crystallize a 429 `model_cooldown` for a SHORT transient - * cooldown waits it out and re-dispatches instead. Guards (gating + the - * `quota_exhausted`/auth/not-found exclusions) live in - * open-sse/services/combo/comboCooldownRetry.ts; `maxWaitMs`/`maxAttempts`/ - * `budgetMs` bound a single wait, the retry cycles, and the total wait time. + * Combo cooldown-aware retry. When enabled, any combo strategy that would + * crystallize a 429 `model_cooldown` for a SHORT transient cooldown waits it + * out and re-dispatches instead. Guards (gating + the `quota_exhausted`/auth/ + * not-found exclusions) live in open-sse/services/combo/comboCooldownRetry.ts; + * `maxWaitMs`/`maxAttempts`/`budgetMs` bound a single wait, the retry cycles, + * and the total wait time. */ export interface ComboCooldownWaitSettings { enabled: boolean; diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index 6a0091ca7b..61b28d21ca 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -512,7 +512,7 @@ export async function checkConnection(conn) { // badge (which derives expiry from tokenExpiresAt||expiresAt) showed a confusing // cosmetic "Token Expired". Surface reality as a terminal "expired" status instead. // Guard tightly so we do NOT clobber: - // - providers that simply don't use refresh tokens (supportsTokenRefresh=false) + // - providers without refresh tokens (supportsTokenRefresh=false; #8407 devin-cli) // - connections already in a terminal/specific state (expired/banned/credits_exhausted) // - transient cooldown state (unavailable) owned by the request path const refreshCapableNeedsReauth = diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 7f555b7791..26ed4f6872 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -93,6 +93,7 @@ type CallLogSummaryRow = { resolved_account?: string | null; correlation_id?: string | null; model_pinned?: number | null; + session_tag?: string | null; }; const RESOLVED_ACCOUNT_SQL = "COALESCE(NULLIF(pc.name, ''), NULLIF(pc.email, ''), cl.account)"; @@ -535,6 +536,7 @@ function mapSummaryRow(row: CallLogSummaryRow) { hasPipelineDetails: toNumber(row.has_pipeline_details) === 1, correlationId: row.correlation_id || null, modelPinned: toNumber(row.model_pinned) === 1, + sessionTag: row.session_tag || null, }; } @@ -624,6 +626,7 @@ export async function saveCallLog(entry: any) { toStringOrNull(entry.comboExecutionKey) || toStringOrNull(entry.comboStepId), correlationId: entry.correlationId || null, modelPinned: entry.modelPinned ? 1 : 0, + sessionTag: entry.sessionTag || null, }; const requestSummary = noLogEnabled @@ -672,7 +675,7 @@ export async function saveCallLog(entry: any) { combo_name, combo_step_id, combo_execution_key, error_summary, detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256, has_request_body, has_response_body, has_pipeline_details, request_summary, - correlation_id, model_pinned + correlation_id, model_pinned, session_tag ) VALUES ( @id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider, @@ -683,7 +686,7 @@ export async function saveCallLog(entry: any) { @comboName, @comboStepId, @comboExecutionKey, @errorSummary, @detailState, @artifactRelPath, @artifactSizeBytes, @artifactSha256, @hasRequestBody, @hasResponseBody, @hasPipelineDetails, @requestSummary, - @correlationId, @modelPinned + @correlationId, @modelPinned, @sessionTag ) ` ).run({ @@ -757,6 +760,22 @@ if (shouldPersistToDisk && process.env.NODE_ENV !== "test") { scheduleCallLogRotation(); } +/** + * Pushes a `column LIKE %value%` condition (mirrors the correlationId/sessionTag substring-match + * precedent). Extracted so getCallLogs stays under the max-lines-per-function ratchet — #8249. + */ +function pushLikeFilter( + conditions: string[], + params: Record, + column: string, + paramKey: string, + value: unknown +) { + if (!value) return; + conditions.push(`cl.${column} LIKE @${paramKey}`); + params[paramKey] = `%${value}%`; +} + export async function getCallLogs(filter: any = {}) { const db = getDbInstance(); let sql = ` @@ -800,10 +819,8 @@ export async function getCallLogs(filter: any = {}) { conditions.push("(cl.api_key_name LIKE @apiKeyQ OR cl.api_key_id LIKE @apiKeyQ)"); params.apiKeyQ = `%${filter.apiKey}%`; } - if (filter.correlationId) { - conditions.push("cl.correlation_id LIKE @correlationId"); - params.correlationId = `%${filter.correlationId}%`; - } + pushLikeFilter(conditions, params, "correlation_id", "correlationId", filter.correlationId); + pushLikeFilter(conditions, params, "session_tag", "sessionTag", filter.sessionTag); if (filter.combo) { conditions.push("cl.combo_name IS NOT NULL"); } diff --git a/src/mitm/dns/dnsConfig.ts b/src/mitm/dns/dnsConfig.ts index 8d089791e3..938eae5296 100644 --- a/src/mitm/dns/dnsConfig.ts +++ b/src/mitm/dns/dnsConfig.ts @@ -256,6 +256,19 @@ export function checkDNSEntry(): boolean { return ANTIGRAVITY_HOSTS.every((h) => hasHostEntry(hostsContent, h)); } +/** + * Check whether ALL hosts for the given agent are present in /etc/hosts. + * Falls back to the Antigravity legacy hosts when `agentId` is omitted or + * unknown, via `resolveHostsForAgent()` — so callers get the same host set + * that `addDNSEntry`/`removeDNSEntry` already use for that agent. Used by + * `getMitmStatus()` to answer "are THIS agent's hosts spoofed?" instead of + * always checking the Antigravity-only set (#8466). + */ +export function checkDNSEntryForAgent(agentId?: string): boolean { + const hostsContent = readHostsFile(); + return resolveHostsForAgent(agentId).every((h) => hasHostEntry(hostsContent, h)); +} + /** * Add DNS entries for the Antigravity default hosts, or for a specific agent * when `agentId` is provided. diff --git a/src/mitm/manager.ts b/src/mitm/manager.ts index d59a443516..74e0bd81d1 100644 --- a/src/mitm/manager.ts +++ b/src/mitm/manager.ts @@ -2,7 +2,7 @@ import { spawn, type ChildProcess } from "child_process"; import path from "path"; import fs from "fs"; import { resolveMitmDataDir } from "./dataDir.ts"; -import { removeDNSEntry, removeDNSEntries } from "./dns/dnsConfig.ts"; +import { removeDNSEntry, removeDNSEntries, checkDNSEntryForAgent } from "./dns/dnsConfig.ts"; import { provisionDnsEntries } from "./dns/provision.ts"; import { generateCert } from "./cert/generate.ts"; import { installCertResult, installCaCert } from "./cert/install.ts"; @@ -353,9 +353,16 @@ export async function handleExitCleanup( } /** - * Get MITM status + * Get MITM status. + * + * @param agentId - Optional agent whose hosts should be checked in DNS. When + * omitted, preserves the legacy Antigravity-only check (unchanged behavior + * for the existing no-agentId call sites: state/route.ts, server/route.ts, + * settings/mitm/route.ts, cli-tools/antigravity-mitm/route.ts). When + * provided (e.g. by the diagnose route), checks that agent's own hosts + * instead of always checking the Antigravity host set (#8466). */ -export async function getMitmStatus(): Promise<{ +export async function getMitmStatus(agentId?: string): Promise<{ running: boolean; pid: number | null; dnsConfigured: boolean; @@ -387,11 +394,17 @@ export async function getMitmStatus(): Promise<{ } } - // Check DNS configuration + // Check DNS configuration. When an agentId is provided, check THAT agent's + // own hosts (#8466) instead of always checking the Antigravity host set — + // callers that don't pass agentId keep the legacy Antigravity-only check. let dnsConfigured = false; try { - const hostsContent = fs.readFileSync("/etc/hosts", "utf-8"); - dnsConfigured = /\bdaily-cloudcode-pa\.googleapis\.com\b/.test(hostsContent); + if (agentId) { + dnsConfigured = checkDNSEntryForAgent(agentId); + } else { + const hostsContent = fs.readFileSync("/etc/hosts", "utf-8"); + dnsConfigured = /\bdaily-cloudcode-pa\.googleapis\.com\b/.test(hostsContent); + } } catch { // Ignore } diff --git a/src/shared/components/KiroAuthModal.tsx b/src/shared/components/KiroAuthModal.tsx index bf890af46c..42f79a11b3 100644 --- a/src/shared/components/KiroAuthModal.tsx +++ b/src/shared/components/KiroAuthModal.tsx @@ -34,11 +34,17 @@ export default function KiroAuthModal({ const [importing, setImporting] = useState(false); const [importingApiKey, setImportingApiKey] = useState(false); const [autoDetecting, setAutoDetecting] = useState(false); - const [autoDetected, setAutoDetected] = useState(false); - // IDC/organization credentials returned by auto-import when the SSO cache token - // has a clientIdHash. Spread into the import POST body so the regional OIDC - // endpoint is used for token refresh instead of the social path (#2059). - const [idcCredentials, setIdcCredentials] = useState | null>(null); + + useEffect(() => { + if (isOpen) return; + setSelectedMethod(null); + setIdcStartUrl(""); + setIdcRegion("us-east-1"); + setRefreshToken(""); + setApiKey(""); + setApiKeyRegion("us-east-1"); + setError(null); + }, [isOpen]); // Auto-detect token when import method is selected useEffect(() => { @@ -47,8 +53,6 @@ export default function KiroAuthModal({ const autoDetect = async () => { setAutoDetecting(true); setError(null); - setAutoDetected(false); - setIdcCredentials(null); try { const res = await fetch( @@ -57,18 +61,9 @@ export default function KiroAuthModal({ const data = await res.json(); if (data.found) { - setRefreshToken(data.refreshToken); - setAutoDetected(true); - // Store IDC/organization credentials if present in the auto-detect response - if (data.clientId && data.clientSecret) { - setIdcCredentials({ - clientId: data.clientId, - clientSecret: data.clientSecret, - ...(data.region ? { region: data.region } : {}), - ...(data.authMethod ? { authMethod: data.authMethod } : {}), - ...(data.profileArn ? { profileArn: data.profileArn } : {}), - }); - } + onMethodSelect("import"); + onClose(); + return; } else { setError(data.error || "Could not auto-detect token"); } @@ -80,7 +75,7 @@ export default function KiroAuthModal({ }; autoDetect(); - }, [providerId, selectedMethod, isOpen]); + }, [providerId, selectedMethod, isOpen, onMethodSelect, onClose]); const handleMethodSelect = (method) => { setSelectedMethod(method); @@ -109,7 +104,6 @@ export default function KiroAuthModal({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken: refreshToken.trim(), - ...(idcCredentials || {}), }), } ); @@ -121,9 +115,10 @@ export default function KiroAuthModal({ } // Success - close modal + onMethodSelect("import"); onClose(); } catch (err) { - setError(err.message); + setError(err instanceof Error ? err.message : "Import failed"); } finally { setImporting(false); } @@ -330,68 +325,6 @@ export default function KiroAuthModal({
)} - {/* Social Login Info (Google) */} - {selectedMethod === "social-google" && ( -
-
-
- - info - -
-

- Manual Callback Required -

-

- After login, you'll need to copy the callback URL from your browser and - paste it back here. -

-
-
-
- -
- - -
-
- )} - - {/* Social Login Info (GitHub) */} - {selectedMethod === "social-github" && ( -
-
-
- - info - -
-

- Manual Callback Required -

-

- After login, you'll need to copy the callback URL from your browser and - paste it back here. -

-
-
-
- -
- - -
-
- )} - {/* Import Token */} {selectedMethod === "import" && (
@@ -413,22 +346,8 @@ export default function KiroAuthModal({ {/* Form (shown after auto-detect completes) */} {!autoDetecting && ( <> - {/* Success message if auto-detected */} - {autoDetected && ( -
-
- - check_circle - -

- Token auto-detected from {providerLabel} successfully! -

-
-
- )} - {/* Info message if not auto-detected */} - {!autoDetected && !error && ( + {!error && (
@@ -447,6 +366,7 @@ export default function KiroAuthModal({ Refresh Token * setRefreshToken(e.target.value)} placeholder="Token will be auto-filled..." @@ -485,6 +405,7 @@ export default function KiroAuthModal({ API Key * setApiKey(e.target.value)} placeholder={`Paste your ${providerLabel} API key...`} diff --git a/src/shared/components/KiroOAuthWrapper.tsx b/src/shared/components/KiroOAuthWrapper.tsx index 19b91ee85f..15afd8bbd3 100644 --- a/src/shared/components/KiroOAuthWrapper.tsx +++ b/src/shared/components/KiroOAuthWrapper.tsx @@ -58,11 +58,11 @@ export default function KiroOAuthWrapper({ setIdcConfig(null); }; - const handleSocialSuccess = () => { + const handleSocialSuccess = useCallback(() => { setAuthMethod(null); setSocialProvider(null); onSuccess?.(); - }; + }, [onSuccess]); const handleDeviceSuccess = () => { setAuthMethod(null); diff --git a/src/shared/components/KiroSocialOAuthModal.tsx b/src/shared/components/KiroSocialOAuthModal.tsx index cd8ce56475..9c457683a6 100644 --- a/src/shared/components/KiroSocialOAuthModal.tsx +++ b/src/shared/components/KiroSocialOAuthModal.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import Modal from "./Modal"; import Button from "./Button"; import { copyToClipboard } from "@/shared/utils/clipboard"; +import { getNextKiroSocialPollInterval } from "@/lib/oauth/kiroSocialPoll"; type KiroSocialOAuthModalProps = { isOpen: boolean; @@ -26,10 +27,28 @@ export default function KiroSocialOAuthModal({ const [error, setError] = useState(null); const [userCode, setUserCode] = useState(""); const [authUrl, setAuthUrl] = useState(""); - const pollRef = useRef | null>(null); + const pollRef = useRef | null>(null); + const onSuccessRef = useRef(onSuccess); + + useEffect(() => { + onSuccessRef.current = onSuccess; + }, [onSuccess]); useEffect(() => { if (!isOpen || !provider) return; + let cancelled = false; + + const stopPolling = () => { + if (pollRef.current) clearTimeout(pollRef.current); + pollRef.current = null; + }; + + const fail = (message: string) => { + stopPolling(); + if (cancelled) return; + setError(message); + setStep("error"); + }; const initAuth = async () => { try { @@ -38,6 +57,7 @@ export default function KiroSocialOAuthModal({ const res = await fetch(`/api/oauth/kiro/social-authorize?provider=${provider}`); const data = await res.json(); + if (cancelled) return; if (!res.ok) { throw new Error(data.error || "Failed to start authorization"); @@ -47,8 +67,23 @@ export default function KiroSocialOAuthModal({ setAuthUrl(data.authUrl || ""); setStep("polling"); - const interval = (data.interval || 5) * 1000; - pollRef.current = setInterval(async () => { + const baseIntervalMs = Math.max(1, Number(data.interval) || 5) * 1000; + let currentIntervalMs = baseIntervalMs; + const expiresAt = Date.now() + Math.max(1, Number(data.expiresIn) || 300) * 1000; + + const schedule = (delayMs: number) => { + if (cancelled) return; + pollRef.current = setTimeout(poll, delayMs); + }; + + const poll = async () => { + pollRef.current = null; + if (cancelled) return; + if (Date.now() >= expiresAt) { + fail("Authorization expired. Start the login flow again."); + return; + } + try { const pollRes = await fetch("/api/oauth/kiro/social-exchange", { method: "POST", @@ -56,36 +91,44 @@ export default function KiroSocialOAuthModal({ body: JSON.stringify({ deviceCode: data.deviceCode, provider, targetProvider }), }); const pollData = await pollRes.json(); + if (cancelled) return; if (pollData.success) { - if (pollRef.current) clearInterval(pollRef.current); - pollRef.current = null; + stopPolling(); setStep("success"); - onSuccess?.(); + onSuccessRef.current?.(); + return; } + + if (!pollData.pending) { + fail(pollData.error || "Authorization failed"); + return; + } + + currentIntervalMs = getNextKiroSocialPollInterval(currentIntervalMs, pollData.error); + schedule(currentIntervalMs); } catch { - // Network error, keep polling + schedule(currentIntervalMs); } - }, interval); + }; + + schedule(baseIntervalMs); } catch (err: any) { - setError(err.message); - setStep("error"); + fail(err.message); } }; initAuth(); return () => { - if (pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; - } + cancelled = true; + stopPolling(); }; - }, [isOpen, provider]); + }, [isOpen, provider, targetProvider]); const handleClose = () => { if (pollRef.current) { - clearInterval(pollRef.current); + clearTimeout(pollRef.current); pollRef.current = null; } onClose(); diff --git a/src/shared/components/compression/EngineConfigPage.tsx b/src/shared/components/compression/EngineConfigPage.tsx index 79b5859ae5..5c1981a4ec 100644 --- a/src/shared/components/compression/EngineConfigPage.tsx +++ b/src/shared/components/compression/EngineConfigPage.tsx @@ -21,14 +21,17 @@ interface EngineEntry { // Engines whose detailed config has a dedicated sub-object in the compression // settings store. The on/off + level for ALL engines now live in the panel // (/dashboard/context/settings, the `engines` map); only these have a place to -// persist the extra per-engine fields edited on this page. Other structural -// engines (lite, session-dedup, ccr, llmlingua, relevance) still have no -// dedicated sub-object — their page keeps the detail form + preview but has -// nothing extra to persist yet. +// persist the extra per-engine fields edited on this page. session-dedup and ccr +// joined headroom in #8388 (they previously rendered a real, editable detail form +// with no Save affordance — edits vanished on reload). Other structural engines +// (lite, llmlingua, relevance) still have no dedicated sub-object — their page +// keeps the detail form + preview but has nothing extra to persist yet. const SETTINGS_SUBOBJECT: Record = { aggressive: "aggressive", ultra: "ultra", headroom: "headroom", + "session-dedup": "sessionDedup", + ccr: "ccr", }; interface CompressionSettings { diff --git a/src/shared/constants/claudeCodeClient.ts b/src/shared/constants/claudeCodeClient.ts new file mode 100644 index 0000000000..0dfa308856 --- /dev/null +++ b/src/shared/constants/claudeCodeClient.ts @@ -0,0 +1,17 @@ +/** + * Wire-version data captured from the signed Claude Code binary. + * + * Keep this leaf dependency-free so server executors, compatibility bridges, + * and client-facing identity presets can share one source of truth. + */ +export const CLAUDE_CODE_CLIENT_VERSION = "2.1.219"; +export const CLAUDE_CODE_CLIENT_BUILD_REVISION = "250"; +export const CLAUDE_CODE_CLIENT_BILLING_VERSION = `${CLAUDE_CODE_CLIENT_VERSION}.${CLAUDE_CODE_CLIENT_BUILD_REVISION}`; +export const CLAUDE_CODE_SDK_PACKAGE_VERSION = "0.94.0"; +export const CLAUDE_CODE_RUNTIME_VERSION = "v26.3.0"; + +export type ClaudeCodeEntrypoint = "cli" | "sdk-cli"; + +export function getClaudeCodeUserAgent(entrypoint: ClaudeCodeEntrypoint): string { + return `claude-cli/${CLAUDE_CODE_CLIENT_VERSION} (external, ${entrypoint})`; +} diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index f2b58cd528..41bed13270 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -23,11 +23,12 @@ export const CLI_TOOLS: Record = { envVars: { baseUrl: "ANTHROPIC_BASE_URL", model: "ANTHROPIC_MODEL", + fableModel: "ANTHROPIC_DEFAULT_FABLE_MODEL", opusModel: "ANTHROPIC_DEFAULT_OPUS_MODEL", sonnetModel: "ANTHROPIC_DEFAULT_SONNET_MODEL", haikuModel: "ANTHROPIC_DEFAULT_HAIKU_MODEL", }, - modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"], + modelAliases: ["default", "fable", "sonnet", "opus", "haiku", "opusplan"], settingsFile: "~/.claude/settings.json", defaultCommand: "claude", defaultModels: [ @@ -40,11 +41,11 @@ export const CLI_TOOLS: Record = { isTopLevel: true, }, { - id: "smallFast", - name: "Small Fast Model", - alias: "smallFast", - envKey: "ANTHROPIC_SMALL_FAST_MODEL", - defaultValue: _cc.haiku ? `cc/${_cc.haiku}` : "cc/claude-haiku-4-5-20251001", + id: "fable", + name: "Claude Fable", + alias: "fable", + envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", + defaultValue: _cc.fable ? `cc/${_cc.fable}` : "cc/claude-fable-5", isTopLevel: true, }, { diff --git a/src/shared/constants/clientIdentityProfiles.ts b/src/shared/constants/clientIdentityProfiles.ts index 3788bd507b..3798ff04c2 100644 --- a/src/shared/constants/clientIdentityProfiles.ts +++ b/src/shared/constants/clientIdentityProfiles.ts @@ -14,6 +14,8 @@ * header) tries to set — no new precedence logic is needed here. */ +import { getClaudeCodeUserAgent } from "./claudeCodeClient"; + export interface ClientIdentityProfile { readonly id: string; readonly label: string; @@ -30,7 +32,7 @@ const CLAUDE_CLI_PROFILE: ClientIdentityProfile = Object.freeze({ id: "claude-cli", label: "Claude CLI", headers: Object.freeze({ - "User-Agent": "claude-cli/2.1.207 (external, cli)", + "User-Agent": getClaudeCodeUserAgent("cli"), "X-App": "cli", }), }); diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index c4703a30be..3128877453 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -22,9 +22,12 @@ export interface ModelSpec { // Model ONLY supports adaptive thinking: manual extended thinking was removed. Sending // `thinking.type:"enabled"` or any `thinking.budget_tokens` returns HTTP 400; reasoning // is steered exclusively by `output_config.effort` (low/medium/high/xhigh/max). True for - // Claude Opus 4.7 and later (Opus 4.7/4.8, Fable 5). Per Anthropic's migration guide - // (2026-05-19): "Any request that tries to set a fixed thinking budget gets a 400 error." + // Claude Opus 4.7 and later (Opus 4.7/4.8/5, Fable 5). Per Anthropic's migration guide, + // any request that tries to set a fixed thinking budget gets a 400 error. adaptiveThinkingOnly?: boolean; + // Highest effort accepted while `thinking.type:"disabled"` is present. Claude Opus 5 + // rejects disabled thinking with xhigh/max, while accepting it through high. + maxEffortWhenThinkingDisabled?: "high"; // Explicit operator override for the no-thinking gateway alias (Fase 8.1). When unset, // the catalog auto-advertises a `no-think/…` variant for // Claude-family thinking-capable models that honor `disabled`. Set `true` to force the @@ -336,6 +339,20 @@ export const MODEL_SPECS: Record = { aliases: BEDROCK_CLAUDE_ALIASES("claude-fable-5"), }, + // ── Claude Opus 5 ─────────────────────────────────────────────── + "claude-opus-5": { + maxOutputTokens: 128000, + contextWindow: 1000000, + defaultThinkingBudget: 32000, + thinkingBudgetCap: 120000, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + adaptiveThinkingOnly: true, + maxEffortWhenThinkingDisabled: "high", + aliases: BEDROCK_CLAUDE_ALIASES("claude-opus-5"), + }, + // ── Claude Opus 4.8 ───────────────────────────────────────────── "claude-opus-4-8": { maxOutputTokens: 128000, @@ -692,6 +709,13 @@ export function isAdaptiveThinkingOnly(modelId: string | null | undefined): bool return getModelSpec(modelId)?.adaptiveThinkingOnly === true; } +export function getMaxEffortWhenThinkingDisabled( + modelId: string | null | undefined +): "high" | null { + if (typeof modelId !== "string" || modelId.length === 0) return null; + return getModelSpec(modelId)?.maxEffortWhenThinkingDisabled ?? null; +} + export function capThinkingBudget(modelId: string, budget: number): number { const cap = getModelSpec(modelId)?.thinkingBudgetCap ?? budget; return Math.min(budget, cap); diff --git a/src/shared/constants/pricing/frontier-labs.ts b/src/shared/constants/pricing/frontier-labs.ts index 0c7117cb57..59e580e971 100644 --- a/src/shared/constants/pricing/frontier-labs.ts +++ b/src/shared/constants/pricing/frontier-labs.ts @@ -8,6 +8,7 @@ import { GPT_5_6_SOL_PRICING, GPT_5_6_TERRA_PRICING, CLAUDE_FABLE_5_PRICING, + CLAUDE_OPUS_5_PRICING, CLAUDE_OPUS_4_PRICING, CLAUDE_SONNET_4_PRICING, CLAUDE_OPUS_46_PRICING, @@ -212,6 +213,7 @@ export const DEFAULT_PRICING_FRONTIER = { // Intentional duplicates of dot-notation variants (e.g. claude-opus-4.6) // to cover hyphen-notation IDs (claude-opus-4-6) used by some clients "claude-fable-5": CLAUDE_FABLE_5_PRICING, + "claude-opus-5": CLAUDE_OPUS_5_PRICING, "claude-sonnet-5": CLAUDE_SONNET_5_PRICING, "claude-opus-4.8": CLAUDE_OPUS_4_PRICING, "claude-opus-4-8": CLAUDE_OPUS_4_PRICING, diff --git a/src/shared/constants/pricing/oauth-subscriptions.ts b/src/shared/constants/pricing/oauth-subscriptions.ts index bf7f2136c1..7c4a8406c4 100644 --- a/src/shared/constants/pricing/oauth-subscriptions.ts +++ b/src/shared/constants/pricing/oauth-subscriptions.ts @@ -3,6 +3,7 @@ * Pure data; merged by default-pricing.ts via spread (god-file decomposition; semantic split). */ import { + CLAUDE_OPUS_5_PRICING, GPT_5_3_CODEX_PRICING, GPT_5_5_PRICING, GPT_5_6_LUNA_PRICING, @@ -19,6 +20,7 @@ export const DEFAULT_PRICING_OAUTH = { reasoning: 50.0, cache_creation: 12.5, }, + "claude-opus-5": CLAUDE_OPUS_5_PRICING, "claude-opus-4-8": { input: 5.0, output: 25.0, @@ -361,6 +363,7 @@ export const DEFAULT_PRICING_OAUTH = { }, }, gh: { + "claude-opus-5": CLAUDE_OPUS_5_PRICING, "gpt-5": { input: 3.0, output: 12.0, @@ -447,13 +450,6 @@ export const DEFAULT_PRICING_OAUTH = { }, }, kiro: { - "claude-fable-5": { - input: 15.0, - output: 75.0, - cached: 7.5, - reasoning: 112.5, - cache_creation: 15.0, - }, "claude-sonnet-4.5": { input: 3.0, output: 15.0, @@ -468,42 +464,6 @@ export const DEFAULT_PRICING_OAUTH = { reasoning: 2.5, cache_creation: 0.5, }, - // Models from issue #334 - "claude-sonnet-4": { - input: 3.0, - output: 15.0, - cached: 1.5, - reasoning: 15.0, - cache_creation: 3.0, - }, - "claude-opus-4.8": { - input: 15.0, - output: 75.0, - cached: 7.5, - reasoning: 75.0, - cache_creation: 15.0, - }, - "claude-opus-4.7": { - input: 15.0, - output: 75.0, - cached: 7.5, - reasoning: 75.0, - cache_creation: 15.0, - }, - "claude-opus-4.6": { - input: 15.0, - output: 75.0, - cached: 7.5, - reasoning: 75.0, - cache_creation: 15.0, - }, - "claude-sonnet-4.6": { - input: 3.0, - output: 15.0, - cached: 1.5, - reasoning: 15.0, - cache_creation: 3.0, - }, "claude-sonnet-5": { input: 3.0, output: 15.0, @@ -555,22 +515,6 @@ export const DEFAULT_PRICING_OAUTH = { reasoning: 8.0, cache_creation: 2.0, }, - // Kiro "Auto" pricing — retained for both the upstream "auto" id and the - // local "auto-kiro" selector. The translator maps auto-kiro back to auto. - auto: { - input: 3.0, - output: 15.0, - cached: 1.5, - reasoning: 15.0, - cache_creation: 3.0, - }, - "auto-kiro": { - input: 3.0, - output: 15.0, - cached: 1.5, - reasoning: 15.0, - cache_creation: 3.0, - }, // Kiro's GPT-5.6 family (kiro.dev/changelog/models, 2026-07-14) — same // per-tier rates the codex/openai aliases already bill at. "gpt-5.6-sol": GPT_5_6_SOL_PRICING, diff --git a/src/shared/constants/pricing/shared-tiers.ts b/src/shared/constants/pricing/shared-tiers.ts index 596fe17446..26976b9581 100644 --- a/src/shared/constants/pricing/shared-tiers.ts +++ b/src/shared/constants/pricing/shared-tiers.ts @@ -49,6 +49,14 @@ export const CLAUDE_FABLE_5_PRICING = { cache_creation: 15.0, }; +export const CLAUDE_OPUS_5_PRICING = { + input: 5.0, + output: 25.0, + cached: 0.5, + reasoning: 25.0, + cache_creation: 6.25, +}; + export const CLAUDE_OPUS_4_PRICING = { input: 15.0, output: 75.0, diff --git a/src/shared/validation/compressionConfigSchemas.ts b/src/shared/validation/compressionConfigSchemas.ts index 2b26585f97..74c56d275a 100644 --- a/src/shared/validation/compressionConfigSchemas.ts +++ b/src/shared/validation/compressionConfigSchemas.ts @@ -161,6 +161,24 @@ export const headroomConfigSchema = z }) .strict(); +// Session Dedup / CCR detail settings (#8388 — sibling gap to headroom/#8056: the +// EngineConfigPage detail form was renderable but PUT bodies had no slot to persist +// into). Ranges mirror SESSION_DEDUP_SCHEMA / CCR_SCHEMA (engines/session-dedup, +// engines/ccr) so the validation layer stays in lockstep with the engine's own bounds. +export const sessionDedupConfigSchema = z + .object({ + minBlockChars: z.number().int().min(1).max(100000).optional(), + fuzzy: z.boolean().optional(), + }) + .strict(); + +export const ccrConfigSchema = z + .object({ + minChars: z.number().int().min(100).max(1_000_000).optional(), + retrievalRampFactor: z.number().min(1).max(100).optional(), + }) + .strict(); + const noConfigSchema = z.object({}).strict(); // Structural engines (session-dedup / ccr / headroom / relevance / llmlingua) do not @@ -344,6 +362,8 @@ export const compressionSettingsUpdateSchema = z aggressive: aggressiveConfigSchema.optional(), ultra: ultraConfigSchema.optional(), headroom: headroomConfigSchema.optional(), + sessionDedup: sessionDedupConfigSchema.optional(), + ccr: ccrConfigSchema.optional(), contextBudget: contextBudgetConfigSchema.optional(), contextEditing: contextEditingConfigSchema.optional(), liveZone: z.object({ enabled: z.boolean() }).strict().optional(), diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index b39984e545..7df36c5f02 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -68,6 +68,7 @@ const transformInjectBillingHeaderSchema = z.object({ versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]), cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]), version: z.string().max(50).optional(), + buildRevision: z.string().min(1).max(20).optional(), }); const commonSystemTransformOperationSchemas = [ diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 20e96b2684..e684730d80 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -102,7 +102,7 @@ import { generateRequestId } from "../../shared/utils/requestId"; import { logAuditEvent } from "../../lib/compliance/index"; import { enforceApiKeyPolicy } from "../../shared/utils/apiKeyPolicy"; import { hasProviderQuotaBypassScope } from "../../shared/constants/apiKeyPolicyScopes"; -import { cloneLogPayload } from "@/lib/logPayloads"; +import { cloneBoundedForLog } from "@omniroute/open-sse/utils/requestLogger.ts"; import { handleInternalUsageCommand } from "@/lib/usage/internalUsageCommand"; import { applyTaskAwareRouting, @@ -138,6 +138,7 @@ import { registerGrokWebQuotaFetcher } from "@omniroute/open-sse/services/grokQu import { registerGenericQuotaFetchers } from "@omniroute/open-sse/services/genericQuotaFetcher.ts"; import "@omniroute/open-sse/services/quotaTrackersBatch.ts"; import { + disableCooldownAwareRetry, getCooldownAwareRetryDecision, resolveCooldownAwareRetrySettings, waitForCooldownAwareRetry, @@ -957,42 +958,10 @@ export async function handleChat( return withCorrelationId(withSessionHeader(response, sessionId), reqId); } -export function buildClientRawRequest(request: Request, body: unknown) { - const url = new URL(request.url); - return { - endpoint: url.pathname, - body: cloneLogPayload(body), - headers: Object.fromEntries(request.headers.entries()), - signal: request.signal ?? null, - }; -} - -/** - * #7360 follow-up: chatCore.ts's createStreamController (and, downstream, - * withRateLimit/acquireAccountSemaphore) only ever watches - * clientRawRequest.signal — the ORIGINAL client's request signal, which stays - * open for as long as the overall combo keeps retrying elsewhere. A target - * abandoned by comboTargetTimeoutMs (open-sse/services/combo/targetTimeoutRunner.ts) - * never learns it was abandoned, and hangs forever (leaking a permanent - * "pending" dashboard entry — trackPendingRequest(false) never runs; live - * incident, log id 1784418258231-14961a). Merges the per-target - * modelAbortSignal (when present) into clientRawRequest.signal so an - * abandoned dispatch can actually observe its own abort and reach its - * cleanup path — returns clientRawRequest unchanged when there's no - * modelAbortSignal to merge in (the non-combo / non-timed-out common case). - */ -export function resolveDispatchClientRawRequest( - clientRawRequest: { signal?: AbortSignal | null } | null | undefined, - modelAbortSignal: AbortSignal | null | undefined -): typeof clientRawRequest { - if (!modelAbortSignal) return clientRawRequest; - return { - ...clientRawRequest, - signal: clientRawRequest?.signal - ? mergeAbortSignals(clientRawRequest.signal, modelAbortSignal) - : modelAbortSignal, - }; -} +// The clientRawRequest envelope lives in ./chat/clientRawRequest.ts. Imported for local use +// below and re-exported for the historical public surface. +import { buildClientRawRequest, resolveDispatchClientRawRequest } from "./chat/clientRawRequest.ts"; +export { buildClientRawRequest, resolveDispatchClientRawRequest }; /** * Handle single model chat request @@ -1225,18 +1194,13 @@ async function handleSingleModelChat( const baseRetrySettings = resolveCooldownAwareRetrySettings( runtimeOptions.cachedSettings ?? (await getCachedSettings().catch(() => ({}))) ); - const disableCooldownAwareRetry = - isCombo || forceLiveComboTest || runtimeOptions.emergencyFallbackTried === true; - const retrySettings = disableCooldownAwareRetry - ? { - ...baseRetrySettings, - enabled: false, - maxRetries: 0, - maxRetryWaitSec: 0, - maxRetryWaitMs: 0, - budgetMs: 0, - } - : baseRetrySettings; + const retrySettings = disableCooldownAwareRetry( + baseRetrySettings, + provider === "claude-web" || + isCombo || + forceLiveComboTest || + runtimeOptions.emergencyFallbackTried === true + ); const requestSignal = request?.signal ?? null; // Cumulative cap across all waits for this request (#7360 follow-up) — mirrors // combo.ts's comboCooldownBudgetLeftMs. Declared outside requestAttemptLoop so diff --git a/src/sse/handlers/chat/clientRawRequest.ts b/src/sse/handlers/chat/clientRawRequest.ts new file mode 100644 index 0000000000..0fd80cd9a8 --- /dev/null +++ b/src/sse/handlers/chat/clientRawRequest.ts @@ -0,0 +1,57 @@ +/** + * The clientRawRequest envelope — the observability snapshot of a chat request. + * + * Extracted from chat.ts (#7847). Both helpers are about the same object: one builds it, the + * other merges a per-target abort signal into it before dispatch. Neither belongs in the + * request handler proper, and chat.ts sits against a frozen file-size ratchet. + * + * chat.ts re-exports both, so the public surface and tests/unit/chat-build-client-raw-request + * are unchanged. + */ +import { mergeAbortSignals } from "@omniroute/open-sse/executors/base.ts"; +import { cloneBoundedForLog } from "@omniroute/open-sse/utils/requestLogger.ts"; + +export function buildClientRawRequest(request: Request, body: unknown) { + const url = new URL(request.url); + return { + endpoint: url.pathname, + // #7847: bounded, not a full deep clone. Every consumer of clientRawRequest.body is + // observability — reqLogger.logClientRawRequest (which re-bounds it anyway, or drops it + // entirely when the logger is disabled), trackPendingRequest's `clientRequest`, and + // recordRejectedRequestUsage's `requestBody`. None feeds dispatch, translation or the + // upstream call, so cloning the whole payload retained ~41x more than anything kept: + // 3.19 MiB vs 0.08 MiB on the incident's 3.05 MiB / 729-message request. + // Still a clone, not an alias — `body` is rewritten downstream (plugin onRequest hook, + // compression), and this has to stay a snapshot of what the client actually sent. + body: cloneBoundedForLog(body), + headers: Object.fromEntries(request.headers.entries()), + signal: request.signal ?? null, + }; +} + +/** + * #7360 follow-up: chatCore.ts's createStreamController (and, downstream, + * withRateLimit/acquireAccountSemaphore) only ever watches + * clientRawRequest.signal — the ORIGINAL client's request signal, which stays + * open for as long as the overall combo keeps retrying elsewhere. A target + * abandoned by comboTargetTimeoutMs (open-sse/services/combo/targetTimeoutRunner.ts) + * never learns it was abandoned, and hangs forever (leaking a permanent + * "pending" dashboard entry — trackPendingRequest(false) never runs; live + * incident, log id 1784418258231-14961a). Merges the per-target + * modelAbortSignal (when present) into clientRawRequest.signal so an + * abandoned dispatch can actually observe its own abort and reach its + * cleanup path — returns clientRawRequest unchanged when there's no + * modelAbortSignal to merge in (the non-combo / non-timed-out common case). + */ +export function resolveDispatchClientRawRequest( + clientRawRequest: { signal?: AbortSignal | null } | null | undefined, + modelAbortSignal: AbortSignal | null | undefined +): typeof clientRawRequest { + if (!modelAbortSignal) return clientRawRequest; + return { + ...clientRawRequest, + signal: clientRawRequest?.signal + ? mergeAbortSignals(clientRawRequest.signal, modelAbortSignal) + : modelAbortSignal, + }; +} diff --git a/src/sse/services/cooldownAwareRetry.ts b/src/sse/services/cooldownAwareRetry.ts index fcdbb63bae..51d4bb00f5 100644 --- a/src/sse/services/cooldownAwareRetry.ts +++ b/src/sse/services/cooldownAwareRetry.ts @@ -67,6 +67,21 @@ export function resolveCooldownAwareRetrySettings( }; } +export function disableCooldownAwareRetry( + settings: CooldownAwareRetrySettings, + disabled: boolean +): CooldownAwareRetrySettings { + if (!disabled) return settings; + return { + ...settings, + enabled: false, + maxRetries: 0, + maxRetryWaitSec: 0, + maxRetryWaitMs: 0, + budgetMs: 0, + }; +} + export function computeClosestRetryAfter(retryAfter: unknown): { retryAfter: string | null; retryAfterHuman: string; diff --git a/stryker.conf.json b/stryker.conf.json index f893a454fa..1ba51f2537 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -39,17 +39,23 @@ "incremental": true, "incrementalFile": "reports/mutation/stryker-incremental.json", "testRunner": "tap", - "plugins": [ - "@stryker-mutator/tap-runner" - ], + "plugins": ["@stryker-mutator/tap-runner"], "tap": { "testFiles": [ + "tests/unit/7993-noauth-proxy-routing.test.ts", + "tests/unit/8200-perplexity-web-401-cooldown.test.ts", + "tests/unit/8247-accountfallback-model-unhealthy.test.ts", + "tests/unit/8248-accountfallback-nvidia-degraded.test.ts", + "tests/unit/8332-combo-vision-fallback.test.ts", + "tests/unit/8376-econnrefused-breaker.test.ts", + "tests/unit/8396-cooldown-429-cap.test.ts", "tests/unit/account-fallback-anthropic-quota.test.ts", "tests/unit/account-fallback-lockout-eviction.test.ts", "tests/unit/account-fallback-retry-after-json.test.ts", "tests/unit/account-fallback-route-restriction-403.test.ts", "tests/unit/account-fallback-service.test.ts", "tests/unit/accountfallback-ratelimit-400-4976.test.ts", + "tests/unit/adobe-firefly.test.ts", "tests/unit/anthropic-thinking-signature-recovery.test.ts", "tests/unit/antigravity-429-quota-tdd.test.ts", "tests/unit/api-key-rotator-health.test.ts", @@ -66,6 +72,7 @@ "tests/unit/authz/route-guard-local-prefix.test.ts", "tests/unit/authz/route-guard-skills-collect.test.ts", "tests/unit/authz/route-guard-version-get-exemption.test.ts", + "tests/unit/authz/route-guard-vnc-session-local-only.test.ts", "tests/unit/authz/routeGuard.test.ts", "tests/unit/auto-combo-context-advertising.test.ts", "tests/unit/auto-combo-engine.test.ts", @@ -127,6 +134,7 @@ "tests/unit/collect-metrics-module-coverage.test.ts", "tests/unit/combo-499-abort.test.ts", "tests/unit/combo-account-allowlist-3266.test.ts", + "tests/unit/combo-attempt-body-isolation-7847.test.ts", "tests/unit/combo-auto-candidate-expansion.test.ts", "tests/unit/combo-breaker-429.test.ts", "tests/unit/combo-cache-invalidation.test.ts", @@ -185,6 +193,7 @@ "tests/unit/embeddings-auth.test.ts", "tests/unit/error-classification.test.ts", "tests/unit/error-message-sanitization.test.ts", + "tests/unit/error-sensitive-redaction.test.ts", "tests/unit/executor-antigravity.test.ts", "tests/unit/executor-web-cookie-sweep.test.ts", "tests/unit/format-provider-error-cause.test.ts", @@ -211,6 +220,7 @@ "tests/unit/model-cooldowns-route-auth.test.ts", "tests/unit/model-cooldowns-route.test.ts", "tests/unit/model-lockout-decay.test.ts", + "tests/unit/model-lockout-exact-cooldown-cap.test.ts", "tests/unit/model-lockout-max-cooldown.test.ts", "tests/unit/no-memory-header.test.ts", "tests/unit/non-streaming-sse-terminal-typescan-4459.test.ts", @@ -242,6 +252,8 @@ "tests/unit/rate-limit-enhanced.test.ts", "tests/unit/rate-limit-manager.test.ts", "tests/unit/rate-limit-queue-timeout-lockout.test.ts", + "tests/unit/repro-7503-no-choices.test.ts", + "tests/unit/repro-antigravity-404-family-cooldown-hijack.test.ts", "tests/unit/responses-handler.test.ts", "tests/unit/rotation-config-omniroute.test.ts", "tests/unit/route-explainability.test.ts", @@ -267,6 +279,7 @@ "tests/unit/session-affinity-generic-7274.test.ts", "tests/unit/settings/authz-bypass.test.ts", "tests/unit/skip-provider-breaker-consumer-2743.test.ts", + "tests/unit/sse-auth-antigravity-credits.test.ts", "tests/unit/sse-auth.test.ts", "tests/unit/strict-random-deck.test.ts", "tests/unit/strip-reasoning-header.test.ts", @@ -392,11 +405,7 @@ ".worktrees", ".stryker-tmp" ], - "reporters": [ - "progress", - "html", - "json" - ], + "reporters": ["progress", "html", "json"], "htmlReporter": { "fileName": "reports/mutation/mutation.html" }, diff --git a/tests/integration/antigravity-projectid-discovery-persist-8491.test.ts b/tests/integration/antigravity-projectid-discovery-persist-8491.test.ts new file mode 100644 index 0000000000..e2d5b402b5 --- /dev/null +++ b/tests/integration/antigravity-projectid-discovery-persist-8491.test.ts @@ -0,0 +1,191 @@ +// #8491 — a runtime-discovered Antigravity projectId must be persisted onto the +// connection so it survives the next token refresh / process restart, instead of +// being silently rediscovered (or lost) on every subsequent request. +// +// PART A: a fresh connection with an empty projectId, a mocked loadCodeAssist that +// returns a project id — after transformRequest() resolves, the connection row must +// have the discovered id written back (both the projectId column and +// providerSpecificData.projectId). +// +// PART B: once persisted, a SECOND request that re-reads credentials from the DB +// (simulating a token refresh / new request cycle, exactly what +// getProviderCredentials does in src/sse/services/auth.ts) must find the persisted +// projectId and must NOT re-invoke loadCodeAssist — the discovery branch never +// triggers because credentials.projectId is now populated from the DB. +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-8491-antigravity-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-8491-antigravity-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { AntigravityExecutor } = await import("../../open-sse/executors/antigravity.ts"); +const { clearAntigravityProjectCache } = await import( + "../../open-sse/services/antigravityProjectBootstrap.ts" +); + +test.after(() => { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } +}); + +const BOOTSTRAP_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; +const DISCOVERED_PROJECT_ID = "discovered-project-8491"; + +async function seedConnection() { + const connection = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "antigravity-8491", + email: "antigravity-8491@example.test", + accessToken: "fake-antigravity-8491-token", + refreshToken: "fake-antigravity-8491-refresh", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + providerSpecificData: { clientProfile: "ide" }, + isActive: true, + testStatus: "active", + }); + assert(connection && typeof connection.id === "string"); + return connection; +} + +test("#8491 PART A: runtime-discovered projectId must be persisted to the connection", async () => { + clearAntigravityProjectCache(); + const connection = await seedConnection(); + const executor = new AntigravityExecutor(); + + const originalFetch = globalThis.fetch; + let loadCodeAssistCalls = 0; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input instanceof Request ? input.url : input); + if (url === BOOTSTRAP_URL) { + loadCodeAssistCalls += 1; + return new Response(JSON.stringify({ cloudaicompanionProject: DISCOVERED_PROJECT_ID }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected fetch in PART A: ${url}`); + }) as typeof fetch; + + try { + const result = await executor.transformRequest( + "antigravity/gemini-3.1-pro", + { request: { contents: [] } }, + true, + { + accessToken: connection.accessToken as string, + connectionId: connection.id, + providerSpecificData: connection.providerSpecificData as Record, + } + ); + + if (result instanceof Response) { + throw new Error(`Expected an envelope but got a ${result.status} Response`); + } + assert.equal(loadCodeAssistCalls, 1, "loadCodeAssist must be called to recover the project"); + assert.equal(result.project, DISCOVERED_PROJECT_ID, "the in-flight request uses the discovered id"); + + const persisted = await providersDb.getProviderConnectionById(connection.id); + assert.equal( + persisted?.projectId, + DISCOVERED_PROJECT_ID, + "discovered projectId must be persisted onto the connection" + ); + assert.equal( + (persisted?.providerSpecificData as Record | undefined)?.projectId, + DISCOVERED_PROJECT_ID, + "discovered projectId must also be persisted onto providerSpecificData.projectId" + ); + // The pre-existing providerSpecificData field must survive the persistence write. + assert.equal( + (persisted?.providerSpecificData as Record | undefined)?.clientProfile, + "ide" + ); + } finally { + globalThis.fetch = originalFetch; + clearAntigravityProjectCache(); + } +}); + +test("#8491 PART B: a second request re-reading credentials from the DB must not need to re-discover", async () => { + clearAntigravityProjectCache(); + const connection = await seedConnection(); + const executor = new AntigravityExecutor(); + + const originalFetch = globalThis.fetch; + let loadCodeAssistCalls = 0; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input instanceof Request ? input.url : input); + if (url === BOOTSTRAP_URL) { + loadCodeAssistCalls += 1; + return new Response(JSON.stringify({ cloudaicompanionProject: DISCOVERED_PROJECT_ID }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected fetch in PART B: ${url}`); + }) as typeof fetch; + + try { + // Request 1: empty projectId, discovers + persists. + await executor.transformRequest( + "antigravity/gemini-3.1-pro", + { request: { contents: [] } }, + true, + { + accessToken: connection.accessToken as string, + connectionId: connection.id, + providerSpecificData: connection.providerSpecificData as Record, + } + ); + assert.equal(loadCodeAssistCalls, 1, "first request must discover via loadCodeAssist"); + + // Simulate a token refresh between requests (rotates the access token, exactly + // what AntigravityExecutor.refreshCredentials()'s DB write does). + await providersDb.updateProviderConnection(connection.id, { + accessToken: "fake-antigravity-8491-token-ROTATED", + }); + + // Request 2: credentials re-read from the DB (what getProviderCredentials does + // for every request) — the persisted projectId must already be populated. + const refreshed = await providersDb.getProviderConnectionById(connection.id); + assert(refreshed); + const result2 = await executor.transformRequest( + "antigravity/gemini-3.1-pro", + { request: { contents: [] } }, + true, + { + accessToken: refreshed.accessToken as string, + connectionId: refreshed.id, + projectId: refreshed.projectId as string | undefined, + providerSpecificData: refreshed.providerSpecificData as Record, + } + ); + + if (result2 instanceof Response) { + throw new Error(`Expected an envelope but got a ${result2.status} Response`); + } + assert.equal( + result2.project, + DISCOVERED_PROJECT_ID, + "the second request must reuse the persisted projectId" + ); + assert.equal( + loadCodeAssistCalls, + 1, + "a second request with a rotated access token should not need to re-discover " + + "(#8491 cache-key half) once the id was already persisted" + ); + } finally { + globalThis.fetch = originalFetch; + clearAntigravityProjectCache(); + } +}); diff --git a/tests/integration/provider-journey.contract.test.ts b/tests/integration/provider-journey.contract.test.ts new file mode 100644 index 0000000000..b346708551 --- /dev/null +++ b/tests/integration/provider-journey.contract.test.ts @@ -0,0 +1,476 @@ +/** + * #8330 — End-to-end CONTRACT test for the full provider journey. + * + * Source: Discussion #8273 (sections 2-4), Reported-by @nguyenha935. Refs #8273. + * + * WHY THIS EXISTS + * --------------- + * Module-level tests pass while the real user journey breaks. A provider is not + * functional just because the creation route returns HTTP 201 — several recent + * defects only manifest ACROSS module boundaries: + * - compatible-provider model regex (`-chat-`) drift, + * - /v1/models namespace incoherence (raw provider-node UUID leaking as the + * public identifier instead of the operator-configured prefix — #8327), + * - Topology blind to custom providers (rendering the raw UUID / a shared gray + * instead of the configured provider name — #8328 / #3198). + * + * This suite walks the WHOLE journey as ONE gate: + * + * create provider (node) -> add connection -> sync models -> select in Combo + * -> Playground -> /v1/models exposure -> call via API key -> visible in Topology + * + * Every step asserts against the SAME derived contract identity + * (`PUBLISHED_MODEL_ID` / `CONFIGURED_PREFIX` / the raw node id), so a divergence + * on ANY surface fails the suite — the whole point of a contract gate. + * + * HOW IT RUNS + * ----------- + * The primary journey (`describe("provider journey — in-process contract")`) drives + * the REAL App Router route handlers + DB layer in-process against an isolated + * DATA_DIR. It needs no live server, so it runs in CI under `test:integration` + * (collected by the top-level `tests/integration/*.test.ts` glob) as a BLOCKING gate. + * + * A second, opt-in block (`describe("provider journey — live over-the-wire")`) runs + * the same journey against a live server over HTTP. It self-skips unless + * `RUN_CONTRACT_INT=1` (same gating convention as the RUN_SERVICES_INT suites), so + * it never runs unopted in CI. + * + * Related: docs/architecture/QUALITY_GATES.md. + */ + +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"; +import { makeManagementSessionRequest } from "../helpers/managementSession.ts"; + +// --------------------------------------------------------------------------- +// Isolated storage + env — must be set BEFORE importing any DB-backed module. +// --------------------------------------------------------------------------- +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-provider-journey-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "provider-journey-contract-secret"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +// Make `isAuthRequired()` true so the /v1/models API-key gate is actually exercised. +process.env.INITIAL_PASSWORD = "provider-journey-bootstrap"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts"); +const providersRoute = await import("../../src/app/api/providers/route.ts"); +const combosRoute = await import("../../src/app/api/combos/route.ts"); +const keysRoute = await import("../../src/app/api/keys/route.ts"); +const v1ModelsRoute = await import("../../src/app/api/v1/models/route.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +const { getProviderDisplayLabel } = await import("../../src/shared/utils/providerDisplayLabel.ts"); +const { isOpenAICompatibleProvider } = await import("../../src/shared/constants/providers.ts"); + +// --------------------------------------------------------------------------- +// Minimal response shapes (JSON objects/arrays) — keeps the suite `any`-free. +// --------------------------------------------------------------------------- +type JsonObject = Record; +type CatalogModel = { id?: string; owned_by?: unknown }; +type ProviderNodeLike = { id?: string; prefix?: string; name?: string }; + +// --------------------------------------------------------------------------- +// The single contract identity every surface must agree on. +// --------------------------------------------------------------------------- +const CONFIGURED_PREFIX = "journey-compat"; +const CONFIGURED_NAME = "Journey Compatible Provider"; +const SYNCED_MODEL_ID = "journey-model-x"; +const PUBLISHED_MODEL_ID = `${CONFIGURED_PREFIX}/${SYNCED_MODEL_ID}`; +const COMBO_NAME = "journey-combo"; +const UUID_SHAPE_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +// Journey state shared across the sequential STEP tests (node:test runs +// top-level tests in declaration order). +let nodeId = ""; +let connectionId = ""; +let apiKeyValue = ""; + +async function readJsonObject(response: Response): Promise { + const text = await response.text(); + try { + const parsed = JSON.parse(text) as unknown; + return parsed && typeof parsed === "object" ? (parsed as JsonObject) : {}; + } catch { + return {}; + } +} + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : []; +} + +/** Fetch /v1/models with a fresh catalog cache so the response reflects live DB state. */ +async function fetchCatalog( + headers?: HeadersInit +): Promise<{ status: number; models: CatalogModel[]; body: JsonObject }> { + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); + const response = await v1ModelsRoute.GET( + new Request("http://localhost/api/v1/models", { headers }) + ); + const body = await readJsonObject(response); + return { status: response.status, models: asArray(body.data), body }; +} + +test.before(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + // requireLogin + requireAuthForModels ON so the API-key surface is gated. + await localDb.updateSettings({ requireLogin: true, requireAuthForModels: true, password: "" }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test.describe("provider journey — in-process contract (#8330)", () => { + test("STEP 1: create provider — POST /api/provider-nodes registers a custom compatible node", async () => { + const response = await providerNodesRoute.POST( + await makeManagementSessionRequest("http://localhost/api/provider-nodes", { + method: "POST", + body: { + type: "openai-compatible", + name: CONFIGURED_NAME, + prefix: CONFIGURED_PREFIX, + apiType: "chat", + baseUrl: "https://proxy.journey.example.com/v1", + }, + }) + ); + const body = await readJsonObject(response); + + assert.equal(response.status, 201, `create provider-node failed: ${JSON.stringify(body)}`); + const node = body.node as ProviderNodeLike; + nodeId = node.id ?? ""; + assert.ok(nodeId, "provider node must expose an id"); + assert.ok( + isOpenAICompatibleProvider(nodeId), + `node id "${nodeId}" must be recognised as an OpenAI-compatible provider` + ); + assert.equal(node.prefix, CONFIGURED_PREFIX); + }); + + test("STEP 2: add connection — POST /api/providers attaches a credential to the node", async () => { + const response = await providersRoute.POST( + await makeManagementSessionRequest("http://localhost/api/providers", { + method: "POST", + body: { + provider: nodeId, + apiKey: "sk-journey-credential", + name: "Journey Connection", + }, + }) + ); + const body = await readJsonObject(response); + + assert.equal(response.status, 201, `add connection failed: ${JSON.stringify(body)}`); + const connection = body.connection as { id?: string; provider?: string }; + connectionId = connection.id ?? ""; + assert.ok(connectionId, "connection must expose an id"); + assert.equal(connection.provider, nodeId, "connection must bind to the created node"); + + // Same surface, read side: GET /api/providers must list the connection. + const listResponse = await providersRoute.GET( + await makeManagementSessionRequest("http://localhost/api/providers") + ); + const listBody = await readJsonObject(listResponse); + assert.equal(listResponse.status, 200); + const connections = asArray<{ id?: string; provider?: string }>(listBody.connections); + assert.ok( + connections.some((c) => c.id === connectionId && c.provider === nodeId), + "the created connection must be visible via GET /api/providers" + ); + }); + + test("STEP 3: sync models — discovered model is persisted for the connection", async () => { + // The over-the-wire sync (POST /api/providers/[id]/sync-models) fetches the + // upstream /models list; against a stub host that is non-deterministic, so the + // in-process gate persists the sync RESULT directly (the real HTTP sync path is + // exercised by the opt-in live block below). What matters for the contract is + // that a synced model on this connection flows coherently to every downstream + // surface. + await modelsDb.replaceSyncedAvailableModelsForConnection(nodeId, connectionId, [ + { + id: SYNCED_MODEL_ID, + name: "Journey Model X", + source: "imported", + supportedEndpoints: ["chat"], + }, + ]); + + const synced = await modelsDb.getSyncedAvailableModelsForConnection(nodeId, connectionId); + assert.ok( + synced.some((m) => m.id === SYNCED_MODEL_ID), + `synced models for the connection must include "${SYNCED_MODEL_ID}"` + ); + }); + + test("STEP 4: select in Combo — POST /api/combos references the published model id", async () => { + const response = await combosRoute.POST( + await makeManagementSessionRequest("http://localhost/api/combos", { + method: "POST", + body: { + name: COMBO_NAME, + strategy: "priority", + models: [PUBLISHED_MODEL_ID], + }, + }) + ); + const body = await readJsonObject(response); + assert.equal(response.status, 201, `create combo failed: ${JSON.stringify(body)}`); + + // Read side: the combo must round-trip the SAME published model id — a combo + // built against a divergent namespace would silently target a dead model. + const listResponse = await combosRoute.GET( + await makeManagementSessionRequest("http://localhost/api/combos") + ); + const listBody = await readJsonObject(listResponse); + assert.equal(listResponse.status, 200); + const combos = asArray<{ name?: string; models?: unknown }>(listBody.combos); + const combo = combos.find((c) => c.name === COMBO_NAME); + assert.ok(combo, "created combo must be visible via GET /api/combos"); + const comboModelIds = asArray(combo?.models).map((m) => { + if (typeof m === "string") return m; + const step = (m ?? {}) as { model?: string; id?: string }; + return step.model ?? step.id; + }); + assert.ok( + comboModelIds.includes(PUBLISHED_MODEL_ID), + `combo must target the published model id "${PUBLISHED_MODEL_ID}", got ${JSON.stringify(comboModelIds)}` + ); + }); + + test("STEP 5: Playground — the dashboard catalog exposes the model under its prefix", async () => { + // Playground reads the same unified catalog as /v1/models, via an authenticated + // dashboard session. Assert the model is present with the operator prefix. + const headers = (await makeManagementSessionRequest("http://localhost/api/v1/models")).headers; + const { status, models } = await fetchCatalog(headers); + + assert.equal(status, 200, "authenticated catalog read (Playground surface) must succeed"); + const entry = models.find((m) => m.id === PUBLISHED_MODEL_ID); + assert.ok( + entry, + `Playground catalog must expose "${PUBLISHED_MODEL_ID}" in ${JSON.stringify(models.map((m) => m.id))}` + ); + }); + + test("STEP 6: /v1/models exposure — public id + owned_by honor the prefix, never the raw UUID (#8327)", async () => { + // Authenticated (dashboard) read is enough to inspect the published shape. + const headers = (await makeManagementSessionRequest("http://localhost/api/v1/models")).headers; + const { status, models } = await fetchCatalog(headers); + assert.equal(status, 200); + + const entry = models.find((m) => m.id === PUBLISHED_MODEL_ID); + assert.ok(entry, `/v1/models must expose "${PUBLISHED_MODEL_ID}"`); + assert.equal( + entry?.owned_by, + CONFIGURED_PREFIX, + `owned_by must be the configured prefix "${CONFIGURED_PREFIX}", not the raw node id — got "${String(entry?.owned_by)}"` + ); + + // The raw provider-node UUID must never leak on ANY surface entry. + for (const model of models) { + assert.notEqual( + model.owned_by, + nodeId, + `owned_by must never equal the raw provider-node id "${nodeId}" (id "${String(model.id)}")` + ); + assert.equal( + typeof model.owned_by === "string" && UUID_SHAPE_RE.test(model.owned_by), + false, + `owned_by "${String(model.owned_by)}" (id "${String(model.id)}") must not be a raw UUID` + ); + } + }); + + test("STEP 7: call via API key — key gates /v1/models and sees the same contract id", async () => { + // Create a real API key through the management route. + const keyResponse = await keysRoute.POST( + await makeManagementSessionRequest("http://localhost/api/keys", { + method: "POST", + body: { name: "journey-key" }, + }) + ); + const keyBody = await readJsonObject(keyResponse); + assert.equal(keyResponse.status, 201, `create key failed: ${JSON.stringify(keyBody)}`); + apiKeyValue = typeof keyBody.key === "string" ? keyBody.key : ""; + assert.match(apiKeyValue, /^sk-/, "created key must be an sk- API key"); + + // Unauthenticated /v1/models is rejected (requireAuthForModels + isAuthRequired). + const anon = await fetchCatalog(); + assert.equal(anon.status, 401, "unauthenticated /v1/models must be rejected when gated"); + + // The SAME model is exposed when calling with the API key over the public surface. + const authed = await fetchCatalog({ Authorization: `Bearer ${apiKeyValue}` }); + assert.equal(authed.status, 200, "valid API key must be accepted by /v1/models"); + const entry = authed.models.find((m) => m.id === PUBLISHED_MODEL_ID); + assert.ok( + entry, + `API-key /v1/models must expose the same "${PUBLISHED_MODEL_ID}" as the dashboard surface` + ); + assert.equal(entry?.owned_by, CONFIGURED_PREFIX); + }); + + test("STEP 8: visible in Topology — the custom provider resolves to its name, not the UUID (#8328/#3198)", async () => { + // The home Topology panel derives its provider labels via + // getProviderDisplayLabel(rawProviderId, providerNodes) — the same source + // HomePageClient.tsx feeds into . Assert the custom provider + // node is discoverable and resolves to the operator-configured name. + const providerNodes = asArray(await localDb.getCachedProviderNodes()); + const topologyNode = providerNodes.find((n) => n.id === nodeId); + assert.ok(topologyNode, "the custom provider node must be present in the topology node source"); + assert.equal(topologyNode?.prefix, CONFIGURED_PREFIX); + + const label = getProviderDisplayLabel(nodeId, providerNodes); + assert.equal( + label, + CONFIGURED_NAME, + `Topology must label the custom provider "${CONFIGURED_NAME}", not the raw UUID — got "${String(label)}"` + ); + assert.equal( + typeof label === "string" && UUID_SHAPE_RE.test(label), + false, + "Topology label must never be a raw provider-node UUID" + ); + }); +}); + +// --------------------------------------------------------------------------- +// Opt-in: the same journey over HTTP against a live server. +// +// RUN_CONTRACT_INT=1 OMNIROUTE_TEST_URL=http://localhost:20128 \ +// node --import tsx/esm --test tests/integration/provider-journey.contract.test.ts +// +// Self-skips unless RUN_CONTRACT_INT=1 (same convention as the RUN_SERVICES_INT +// suites), so it never runs unopted in CI. Expects the server in open bootstrap +// mode (no password/OIDC/INITIAL_PASSWORD) so management routes are reachable +// without a session — matching the other gated live integration suites. +// --------------------------------------------------------------------------- +const LIVE_ENABLED = process.env.RUN_CONTRACT_INT === "1"; +const LIVE_SKIP_REASON = "Set RUN_CONTRACT_INT=1 to run the live over-the-wire contract journey"; +const LIVE_BASE_URL = process.env.OMNIROUTE_TEST_URL ?? "http://localhost:20128"; + +function liveMaybeSkip(t: { skip: (reason?: string) => void }): boolean { + if (!LIVE_ENABLED) { + t.skip(LIVE_SKIP_REASON); + return true; + } + return false; +} + +async function liveFetch( + method: string, + urlPath: string, + init: { body?: unknown; headers?: Record } = {} +): Promise<{ status: number; body: JsonObject }> { + const res = await fetch(`${LIVE_BASE_URL}${urlPath}`, { + method, + headers: { + ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}), + ...(init.headers ?? {}), + }, + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, + }); + const text = await res.text(); + let body: JsonObject = {}; + try { + const parsed = JSON.parse(text) as unknown; + if (parsed && typeof parsed === "object") body = parsed as JsonObject; + } catch { + body = {}; + } + return { status: res.status, body }; +} + +test.describe("provider journey — live over-the-wire (opt-in, RUN_CONTRACT_INT=1)", () => { + const live = { nodeId: "", connectionId: "", comboName: `journey-live-${Date.now()}` }; + + test("LIVE STEP 1: create provider node", async (t) => { + if (liveMaybeSkip(t)) return; + const { status, body } = await liveFetch("POST", "/api/provider-nodes", { + body: { + type: "openai-compatible", + name: CONFIGURED_NAME, + prefix: `${CONFIGURED_PREFIX}-live`, + apiType: "chat", + baseUrl: "https://proxy.journey.example.com/v1", + }, + }); + assert.equal(status, 201, `create node failed: ${JSON.stringify(body)}`); + const node = body.node as ProviderNodeLike; + live.nodeId = node.id ?? ""; + assert.ok(isOpenAICompatibleProvider(live.nodeId)); + }); + + test("LIVE STEP 2: add connection", async (t) => { + if (liveMaybeSkip(t)) return; + const { status, body } = await liveFetch("POST", "/api/providers", { + body: { provider: live.nodeId, apiKey: "sk-journey-live", name: "Journey Live Connection" }, + }); + assert.equal(status, 201, `add connection failed: ${JSON.stringify(body)}`); + const connection = body.connection as { id?: string; provider?: string }; + live.connectionId = connection.id ?? ""; + assert.equal(connection.provider, live.nodeId); + }); + + test("LIVE STEP 3: connection is listed", async (t) => { + if (liveMaybeSkip(t)) return; + const { status, body } = await liveFetch("GET", "/api/providers"); + assert.equal(status, 200); + const connections = asArray<{ id?: string }>(body.connections); + assert.ok( + connections.some((c) => c.id === live.connectionId), + "created connection must be listed by GET /api/providers" + ); + }); + + test("LIVE STEP 4: combo targets a model under the provider prefix", async (t) => { + if (liveMaybeSkip(t)) return; + const publishedId = `${CONFIGURED_PREFIX}-live/journey-model-x`; + const { status, body } = await liveFetch("POST", "/api/combos", { + body: { name: live.comboName, strategy: "priority", models: [publishedId] }, + }); + assert.equal(status, 201, `create combo failed: ${JSON.stringify(body)}`); + + const list = await liveFetch("GET", "/api/combos"); + assert.equal(list.status, 200); + const combos = asArray<{ name?: string }>(list.body.combos); + assert.ok( + combos.some((c) => c.name === live.comboName), + "created combo must be listed by GET /api/combos" + ); + }); + + test("LIVE STEP 5: /v1/models is reachable and OpenAI-shaped (contract source)", async (t) => { + if (liveMaybeSkip(t)) return; + const { status, body } = await liveFetch("GET", "/v1/models"); + assert.equal(status, 200, "public /v1/models must be reachable"); + assert.equal(body.object, "list", "/v1/models must return an OpenAI list envelope"); + const models = asArray(body.data); + assert.ok(Array.isArray(body.data), "/v1/models must return a data array"); + // No entry may leak a raw provider-node UUID as its public owner. + for (const model of models) { + assert.notEqual(model.owned_by, live.nodeId); + } + }); + + test("LIVE STEP 6: provider node is visible to the Topology source", async (t) => { + if (liveMaybeSkip(t)) return; + const { status, body } = await liveFetch("GET", "/api/provider-nodes"); + assert.equal(status, 200); + const nodes = asArray(body.nodes); + const node = nodes.find((n) => n.id === live.nodeId); + assert.ok( + node, + "the custom provider node must be visible to the topology (provider-nodes) source" + ); + assert.equal(getProviderDisplayLabel(live.nodeId, nodes), CONFIGURED_NAME); + }); +}); diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index df81732060..97007c8965 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -28,14 +28,14 @@ "apiKey": { "Accept": "text/event-stream", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.207 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.219 (external, sdk-cli)", "X-Stainless-Arch": "", "X-Stainless-Lang": "js", "X-Stainless-OS": "MacOS", "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.3.0", + "X-Stainless-Runtime-Version": "v26.3.0", "X-Stainless-Timeout": "600", "accept-encoding": "gzip, deflate, br, zstd", "anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24", @@ -47,14 +47,14 @@ "nonStream": { "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.207 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.219 (external, sdk-cli)", "X-Stainless-Arch": "", "X-Stainless-Lang": "js", "X-Stainless-OS": "MacOS", "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.3.0", + "X-Stainless-Runtime-Version": "v26.3.0", "X-Stainless-Timeout": "600", "accept-encoding": "gzip, deflate, br, zstd", "anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24", @@ -66,14 +66,14 @@ "oauth": { "Accept": "text/event-stream", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.207 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.219 (external, sdk-cli)", "X-Stainless-Arch": "", "X-Stainless-Lang": "js", "X-Stainless-OS": "MacOS", "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.3.0", + "X-Stainless-Runtime-Version": "v26.3.0", "X-Stainless-Timeout": "600", "accept-encoding": "gzip, deflate, br, zstd", "anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24", @@ -851,7 +851,7 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.207 (external, cli)", + "User-Agent": "claude-cli/2.1.219 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "", "X-Stainless-Helper-Method": "stream", @@ -860,7 +860,7 @@ "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.3.0", + "X-Stainless-Runtime-Version": "v26.3.0", "X-Stainless-Timeout": "600", "x-api-key": "" }, @@ -869,7 +869,7 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.207 (external, cli)", + "User-Agent": "claude-cli/2.1.219 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "", "X-Stainless-Helper-Method": "stream", @@ -878,7 +878,7 @@ "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.3.0", + "X-Stainless-Runtime-Version": "v26.3.0", "X-Stainless-Timeout": "600", "x-api-key": "" }, @@ -888,7 +888,7 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.207 (external, cli)", + "User-Agent": "claude-cli/2.1.219 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "", "X-Stainless-Helper-Method": "stream", @@ -897,7 +897,7 @@ "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.3.0", + "X-Stainless-Runtime-Version": "v26.3.0", "X-Stainless-Timeout": "600", "x-api-key": "" } diff --git a/tests/unit/8368-image-token-context.test.ts b/tests/unit/8368-image-token-context.test.ts new file mode 100644 index 0000000000..947be5eace --- /dev/null +++ b/tests/unit/8368-image-token-context.test.ts @@ -0,0 +1,120 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { estimateTokens, getTokenLimit } from "../../open-sse/services/contextManager.ts"; + +function makeFakePngBase64(approxBytes: number): string { + return Buffer.alloc(approxBytes, 65).toString("base64"); +} + +test("#8368: inline base64 PNG image_url is NOT counted as raw text (bounded image-token estimate)", () => { + const base64 = makeFakePngBase64(1_900_000); // ~1.9MB, matches issue repro + const messages = [ + { role: "user", content: "Please describe this image." }, + { + role: "user", + content: [ + { type: "input_text", text: "Please describe this image." }, + { type: "input_image", image_url: `data:image/png;base64,${base64}` }, + ], + }, + ]; + const estimated = estimateTokens(messages); + assert.ok( + estimated < 5000, + `BUG #8368 reproduced: image-bearing message estimated at ${estimated} tokens (limit ${getTokenLimit( + "codex" + )})` + ); +}); + +test("#8368: plain text estimation is unaffected by the image-token fix (control)", () => { + const text = "a".repeat(4000); // 4000 chars => ~1000 tokens at CHARS_PER_TOKEN=4 + const estimated = estimateTokens(text); + assert.equal(estimated, 1000); +}); + +test("#8368: OpenAI chat.completions image_url object shape is bounded", () => { + const base64 = makeFakePngBase64(500_000); + const messages = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image_url", image_url: { url: `data:image/jpeg;base64,${base64}` } }, + ], + }, + ]; + const estimated = estimateTokens(messages); + assert.ok(estimated < 5000, `expected bounded estimate, got ${estimated}`); +}); + +test("#8368: Claude source.base64 image block shape is bounded", () => { + const base64 = makeFakePngBase64(500_000); + const messages = [ + { + role: "user", + content: [ + { type: "text", text: "Describe this." }, + { + type: "image", + source: { type: "base64", media_type: "image/png", data: base64 }, + }, + ], + }, + ]; + const estimated = estimateTokens(messages); + assert.ok(estimated < 5000, `expected bounded estimate, got ${estimated}`); +}); + +test("#8368: Gemini inlineData image block shape is bounded", () => { + const base64 = makeFakePngBase64(500_000); + const messages = [ + { + role: "user", + parts: [{ text: "Describe this." }, { inlineData: { mimeType: "image/png", data: base64 } }], + }, + ]; + const estimated = estimateTokens(messages); + assert.ok(estimated < 5000, `expected bounded estimate, got ${estimated}`); +}); + +test("#8368: multiple images accumulate a bounded sum, not one flat cap", () => { + const base64 = makeFakePngBase64(200_000); + const oneImageMessages = [ + { role: "user", content: [{ type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } }] }, + ]; + const threeImageMessages = [ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } }, + { type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } }, + { type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } }, + ], + }, + ]; + const one = estimateTokens(oneImageMessages); + const three = estimateTokens(threeImageMessages); + assert.ok(three > one, `expected 3 images to cost more than 1 (one=${one}, three=${three})`); + assert.ok(three < one * 4, `expected roughly linear scaling, got one=${one} three=${three}`); +}); + +test("#8368: remote http(s) image URLs are unaffected (still measured as text)", () => { + const messages = [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "https://example.com/cat.png" } }], + }, + ]; + const estimated = estimateTokens(messages); + // Should just be the JSON-length/4 heuristic for the short URL string, not near-zero + // and not a bounded image-token substitute — remote URLs stay on the text path. + assert.ok(estimated > 0 && estimated < 100, `expected small text-based estimate, got ${estimated}`); +}); + +test("#8368: generic long base64 text (not an image field) still uses the text path", () => { + const genericBase64 = makeFakePngBase64(100_000); + const estimated = estimateTokens(genericBase64); + const expectedTextEstimate = Math.ceil(genericBase64.length / 4); + assert.equal(estimated, expectedTextEstimate); +}); diff --git a/tests/unit/8370-priority-affinity-reorder.test.ts b/tests/unit/8370-priority-affinity-reorder.test.ts new file mode 100644 index 0000000000..760cd77071 --- /dev/null +++ b/tests/unit/8370-priority-affinity-reorder.test.ts @@ -0,0 +1,178 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + applyPromptCacheAffinity, + expandPromptCacheAffinityTargetsFromConnections, + shouldProtectOriginalFirst, +} from "../../open-sse/services/combo/promptCacheAffinity.ts"; +import type { ResolvedComboTarget } from "../../open-sse/services/combo/types.ts"; + +// #8370: a `priority` combo declares an explicit, operator-chosen model order. +// Cross-model prompt-cache affinity (a global rendezvous-hash sort over the +// fully expanded, model-blind target list) was silently reordering that +// declaration, letting a lower-priority model's single account jump ahead of +// every account belonging to the highest-priority model. This file is the +// permanent regression guard for that fix (`shouldProtectOriginalFirst` in +// `open-sse/services/combo/promptCacheAffinity.ts`, wired into +// `open-sse/services/combo.ts`'s `protectedOriginal` gate). + +function modelTarget( + stepId: string, + modelStr: string, + provider: string, + allowedConnectionIds?: string[] +): ResolvedComboTarget { + return { + kind: "model", + stepId, + executionKey: stepId, + modelStr, + provider, + providerId: null, + connectionId: null, + weight: 1, + label: null, + ...(allowedConnectionIds ? { allowedConnectionIds } : {}), + } as ResolvedComboTarget; +} + +// Mirrors combo.ts's exact application of the fix: expand model-level targets +// to concrete accounts, run affinity, then re-pin the strategy's declared +// first target ahead of the affinity-sorted list when the strategy warrants it. +function applyComboLikeAffinityPin( + orderedTargets: ResolvedComboTarget[], + connectionsByProvider: Map>>, + body: Record, + strategy: string +): ResolvedComboTarget[] { + const expanded = expandPromptCacheAffinityTargetsFromConnections( + orderedTargets, + connectionsByProvider + ); + const affinity = applyPromptCacheAffinity(expanded, body, true); + if (!affinity.applied) return affinity.targets; + + const protectedOriginal = shouldProtectOriginalFirst(false, false, strategy) && orderedTargets[0]; + + const protectedFirst = protectedOriginal + ? (affinity.targets.find( + (target) => + target === protectedOriginal || + target.executionKey === protectedOriginal.executionKey || + target.executionKey.startsWith(`${protectedOriginal.executionKey}@`) + ) ?? protectedOriginal) + : null; + + return protectedFirst + ? [protectedFirst, ...affinity.targets.filter((target) => target !== protectedFirst)] + : affinity.targets; +} + +function buildCrossModelScenario() { + // Model A (priority 1) has 5 accounts; models B and C (priority 2/3) have 1 each — + // mirrors the issue's reported 3-models-expanded-to-N-accounts shape. + const orderedTargets = [ + modelTarget("step-a", "antigravity/gemini-3-pro", "antigravity"), + modelTarget("step-b", "ollamacloud/minimax-m3", "ollamacloud"), + modelTarget("step-c", "oc/deepseek-v4", "oc"), + ]; + const connectionsByProvider = new Map>>([ + [ + "antigravity", + [ + { id: "antigravity-acct-1" }, + { id: "antigravity-acct-2" }, + { id: "antigravity-acct-3" }, + { id: "antigravity-acct-4" }, + { id: "antigravity-acct-5" }, + ], + ], + ["ollamacloud", [{ id: "minimax-acct-1" }]], + ["oc", [{ id: "deepseek-acct-1" }]], + ]); + return { orderedTargets, connectionsByProvider }; +} + +// Brute-force a prompt_cache_key whose rendezvous winner is NOT one of model +// A's accounts, so the bug (if unfixed) is guaranteed to reproduce rather +// than passing by chance of the hash landing on model A anyway. +function findKeyThatWinsOutsideModelA( + connectionsByProvider: Map>>, + orderedTargets: ResolvedComboTarget[] +): string { + const expanded = expandPromptCacheAffinityTargetsFromConnections( + orderedTargets, + connectionsByProvider + ); + for (let i = 0; i < 500; i++) { + const key = `probe-key-${i}`; + const ranked = applyPromptCacheAffinity(expanded, { prompt_cache_key: key }, true); + if (ranked.targets[0]?.provider !== "antigravity") return key; + } + throw new Error("could not find a probe key whose rendezvous winner is outside model A"); +} + +test("BUG #8370: priority combo keeps its declared model-1-first order despite cross-model affinity", () => { + const { orderedTargets, connectionsByProvider } = buildCrossModelScenario(); + const key = findKeyThatWinsOutsideModelA(connectionsByProvider, orderedTargets); + const body = { prompt_cache_key: key }; + + // Sanity: without the fix's protection, raw affinity really does let a + // model B/C account win the global sort (proves the scenario reproduces). + const expanded = expandPromptCacheAffinityTargetsFromConnections( + orderedTargets, + connectionsByProvider + ); + const rawAffinity = applyPromptCacheAffinity(expanded, body, true); + assert.notEqual( + rawAffinity.targets[0]?.provider, + "antigravity", + "test setup invariant: raw affinity must pick outside model A for this key" + ); + + const result = applyComboLikeAffinityPin(orderedTargets, connectionsByProvider, body, "priority"); + + assert.equal( + result[0]?.provider, + "antigravity", + "priority combo must keep its declared highest-priority model first, not the rendezvous winner" + ); +}); + +test("shouldProtectOriginalFirst covers priority, fill-first, and lkgp", () => { + for (const strategy of ["priority", "fill-first", "lkgp"]) { + assert.equal( + shouldProtectOriginalFirst(false, false, strategy), + true, + `expected ${strategy} to be protected` + ); + } +}); + +test("shouldProtectOriginalFirst still covers the pre-existing quota-share/weighted/sticky/auto-router cases", () => { + assert.equal(shouldProtectOriginalFirst(false, false, "quota-share"), true); + assert.equal(shouldProtectOriginalFirst(false, false, "weighted"), true); + assert.equal(shouldProtectOriginalFirst(true, false, "round-robin"), true); + assert.equal(shouldProtectOriginalFirst(false, true, "round-robin"), true); +}); + +test("round-robin combo is NOT protected — it still gets full cross-model affinity reordering", () => { + const { orderedTargets, connectionsByProvider } = buildCrossModelScenario(); + const key = findKeyThatWinsOutsideModelA(connectionsByProvider, orderedTargets); + const body = { prompt_cache_key: key }; + + assert.equal(shouldProtectOriginalFirst(false, false, "round-robin"), false); + + const result = applyComboLikeAffinityPin( + orderedTargets, + connectionsByProvider, + body, + "round-robin" + ); + + assert.notEqual( + result[0]?.provider, + "antigravity", + "round-robin combo must still let prompt-cache affinity pick the winning account across models" + ); +}); diff --git a/tests/unit/8374-plugins-status-optional.test.ts b/tests/unit/8374-plugins-status-optional.test.ts new file mode 100644 index 0000000000..f5936b7e87 --- /dev/null +++ b/tests/unit/8374-plugins-status-optional.test.ts @@ -0,0 +1,84 @@ +/** + * Regression test for #8374 — GET /api/plugins returns "Invalid status value" + * when no `?status=` filter is passed. + * + * Root cause: `URLSearchParams.get("status")` returns `null` (not `undefined`) + * when the query param is absent. `z.enum([...]).optional()` widens the schema + * to accept `undefined`, but NOT `null` — so `safeParse(null)` fails and the + * route returns HTTP 400, even though the caller passed no filter at all. + * + * Fix: coerce `null` -> `undefined` before handing it to Zod, matching the + * repo's own established idiom (see registered-keys/route.ts, + * suggested-models/route.ts, quota/preview/route.ts). + */ + +import { test, before, 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"; +import { NextRequest } from "next/server"; + +// Hermetic DB: isolate from the shared dev DATA_DIR so this test never +// touches or depends on real plugin rows, and so a fresh install has no +// configured password (isAuthRequired() -> false -> GET reachable directly). +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugins-8374-")); +const originalDataDir = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const pluginsDb = await import("../../src/lib/db/plugins.ts"); +const { GET } = await import("../../src/app/api/plugins/route.ts"); + +before(() => { + pluginsDb.insertPlugin({ + id: "test-plugin-8374", + name: "test-plugin-8374", + version: "1.0.0", + main: "index.js", + manifest: {}, + status: "active", + pluginDir: "/tmp/test-plugin-8374", + }); +}); + +after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; +}); + +test("BUG #8374: GET /api/plugins with no ?status= returns 200, not 400", async () => { + // @ts-ignore - handler accepts NextRequest at runtime + const req = new NextRequest("http://localhost:3000/api/plugins"); + const res = await GET(req); + const body = await res.json(); + assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(body)}`); + assert.ok(Array.isArray(body.plugins), "response body must contain a plugins array"); +}); + +test("GET /api/plugins with a valid ?status= filter still works and filters", async () => { + // @ts-ignore + const req = new NextRequest("http://localhost:3000/api/plugins?status=active"); + const res = await GET(req); + const body = await res.json(); + assert.equal(res.status, 200); + assert.ok( + body.plugins.every((p: { status: string }) => p.status === "active"), + "all returned plugins must have the requested status" + ); + assert.ok( + body.plugins.some((p: { id: string }) => p.id === "test-plugin-8374"), + "the seeded active plugin must be present in the filtered result" + ); +}); + +test("GET /api/plugins with an invalid ?status= still returns 400", async () => { + // @ts-ignore + const req = new NextRequest("http://localhost:3000/api/plugins?status=bogus"); + const res = await GET(req); + const body = await res.json(); + assert.equal(res.status, 400); + assert.equal(body.error, "Invalid status value"); +}); diff --git a/tests/unit/8376-econnrefused-breaker.test.ts b/tests/unit/8376-econnrefused-breaker.test.ts new file mode 100644 index 0000000000..6d99ae4e39 --- /dev/null +++ b/tests/unit/8376-econnrefused-breaker.test.ts @@ -0,0 +1,96 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { shouldRecordProviderBreakerFailure } from "../../open-sse/services/combo/comboPredicates.ts"; + +// #8376 — an unreachable upstream proxy (ECONNREFUSED) on a homogeneous same-provider +// combo pool must still trip the whole-provider circuit breaker so combo routing fails +// over to a different provider, instead of burning MAX_GLOBAL_ATTEMPTS against the same +// dead proxy and returning 503 "Maximum combo retry limit". + +test("#8376: proxy-unreachable failure on a homogeneous same-provider combo trips the breaker via isProxyUnreachable override", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: 502, + sameProviderNext: true, + skipProviderBreaker: false, + requestScopedFailure: false, + error: "connect ECONNREFUSED 127.0.0.1:8787", + isProxyUnreachable: true, + }); + assert.equal(result, true); +}); + +test("#8376 control: without the override, the SAME same-provider failure still does not trip (proves the override is additive, not a blanket bypass)", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: 502, + sameProviderNext: true, + skipProviderBreaker: false, + requestScopedFailure: false, + error: "connect ECONNREFUSED 127.0.0.1:8787", + isProxyUnreachable: false, + }); + assert.equal(result, false); +}); + +test("#8376: the override never bypasses the other AND-terms — a stream-readiness failure still does not trip even when isProxyUnreachable is true", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: true, + status: 502, + sameProviderNext: true, + skipProviderBreaker: false, + requestScopedFailure: false, + error: "connect ECONNREFUSED 127.0.0.1:8787", + isProxyUnreachable: true, + }); + assert.equal(result, false); +}); + +test("#8376: the override never bypasses skipProviderBreaker (embedded-service connection-cooldown-only hint) even when isProxyUnreachable is true", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: 502, + sameProviderNext: true, + skipProviderBreaker: true, + requestScopedFailure: false, + error: "connect ECONNREFUSED 127.0.0.1:8787", + isProxyUnreachable: true, + }); + assert.equal(result, false); +}); + +test("#8376: a genuine same-provider 5xx (not proxy-unreachable) still does NOT trip the breaker — no over-widening", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: 502, + sameProviderNext: true, + skipProviderBreaker: false, + requestScopedFailure: false, + error: "upstream returned 502", + }); + assert.equal(result, false); +}); + +test("#8376: a normal 200-derived non-breaker-status failure is unaffected by isProxyUnreachable being true (status gate still applies)", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: 200, + sameProviderNext: true, + skipProviderBreaker: false, + requestScopedFailure: false, + isProxyUnreachable: true, + }); + assert.equal(result, false); +}); + +test("#8376: a normal 429 (rate limit) is unaffected by isProxyUnreachable being true (429 intentionally excluded from breaker statuses)", () => { + const result = shouldRecordProviderBreakerFailure({ + isStreamReadinessFailure: false, + status: 429, + sameProviderNext: true, + skipProviderBreaker: false, + requestScopedFailure: false, + isProxyUnreachable: true, + }); + assert.equal(result, false); +}); diff --git a/tests/unit/8385-perkey-proxy-global-toggle.test.ts b/tests/unit/8385-perkey-proxy-global-toggle.test.ts new file mode 100644 index 0000000000..84a0226ca8 --- /dev/null +++ b/tests/unit/8385-perkey-proxy-global-toggle.test.ts @@ -0,0 +1,117 @@ +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-8385-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); + +interface ConnectionRef { + id: string; +} + +interface ProxyResolution { + level: string | null; + proxy: unknown; +} + +async function resetStorage() { + delete process.env.INITIAL_PASSWORD; + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("issue #8385: global perKeyProxyEnabled=false must override a connection's per_key_proxy_enabled=1", async () => { + await resetStorage(); + + core + .getDbInstance() + .prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'perKeyProxyEnabled', 'false')" + ) + .run(); + + const conn = (await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "conn-8385", + apiKey: "sk-8385", + })) as unknown as ConnectionRef; + await providersDb.updateProviderConnection(conn.id, { perKeyProxyEnabled: true }); + + const proxy = await proxiesDb.createProxy({ + name: "Per-Key Proxy 8385", + type: "http", + host: "perkey.8385.local", + port: 8080, + }); + + const key = await apiKeysDb.createApiKey("probe-8385-key", "machine-8385"); + await apiKeysDb.updateApiKeyPermissions(key.id, { proxyId: proxy.id }); + + const resolved = (await settingsDb.resolveProxyForConnection( + conn.id, + key.id + )) as unknown as ProxyResolution; + + assert.notEqual( + resolved?.level, + "apiKey", + `expected global-off to override per-key assignment, but got level=${resolved?.level} proxy=${JSON.stringify(resolved?.proxy)}` + ); +}); + +test("issue #8385: global perKeyProxyEnabled=true still allows the per-key assignment to apply", async () => { + await resetStorage(); + + core + .getDbInstance() + .prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'perKeyProxyEnabled', 'true')" + ) + .run(); + + const conn = (await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "conn-8385-on", + apiKey: "sk-8385-on", + })) as unknown as ConnectionRef; + await providersDb.updateProviderConnection(conn.id, { perKeyProxyEnabled: true }); + + const proxy = await proxiesDb.createProxy({ + name: "Per-Key Proxy 8385 On", + type: "http", + host: "perkey-on.8385.local", + port: 8081, + }); + + const key = await apiKeysDb.createApiKey("probe-8385-key-on", "machine-8385-on"); + await apiKeysDb.updateApiKeyPermissions(key.id, { proxyId: proxy.id }); + + const resolved = (await settingsDb.resolveProxyForConnection( + conn.id, + key.id + )) as unknown as ProxyResolution; + + assert.equal( + resolved?.level, + "apiKey", + `expected global-on to allow the per-key assignment, but got level=${resolved?.level}` + ); +}); diff --git a/tests/unit/8388-compression-detail-persist.test.ts b/tests/unit/8388-compression-detail-persist.test.ts new file mode 100644 index 0000000000..c65d29ba95 --- /dev/null +++ b/tests/unit/8388-compression-detail-persist.test.ts @@ -0,0 +1,64 @@ +// #8388 — Compression engine DETAIL settings (Headroom / session dedup / CCR) do not +// persist on save. Root cause was a two-layer gap on origin/release/v3.8.49: +// (1) the .strict() Zod schema (compressionSettingsUpdateSchema) had no `sessionDedup` +// / `ccr` top-level keys, so a PUT body carrying either was rejected outright; +// (2) even past validation, src/lib/db/compression.ts had no normalizer/switch-case +// wired for those two sub-objects (only `headroom` got the #8056 treatment), so a +// set → save → reload round-trip would silently drop the values. +// This test asserts the FULL round-trip end-to-end (schema parse -> DB write -> DB read), +// not just schema-level parsing, per the plan-file's explicit instruction. +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"; + +// Isolate DATA_DIR so this test never touches a real installed DB (see MEMORY: "teste sem +// isolateDataDir → DB REAL"). Must be set BEFORE importing anything that resolves getDbInstance(). +const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-8388-")); +process.env.DATA_DIR = tmpDataDir; + +const { compressionSettingsUpdateSchema } = await import( + "../../src/shared/validation/compressionConfigSchemas.ts" +); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { getCompressionSettings, updateCompressionSettings } = await import( + "../../src/lib/db/compression.ts" +); + +test.after(() => { + resetDbInstance(); + fs.rmSync(tmpDataDir, { recursive: true, force: true }); +}); + +test("#8388: PUT body carrying ccr detail (minChars/retrievalRampFactor) is ACCEPTED by the schema", () => { + const parsed = compressionSettingsUpdateSchema.safeParse({ + ccr: { minChars: 5000, retrievalRampFactor: 10 }, + }); + assert.equal(parsed.success, true); +}); + +test("#8388: PUT body carrying session-dedup detail (minBlockChars/fuzzy) is ACCEPTED by the schema", () => { + const parsed = compressionSettingsUpdateSchema.safeParse({ + sessionDedup: { minBlockChars: 200, fuzzy: true }, + }); + assert.equal(parsed.success, true); +}); + +test("#8388: session-dedup detail round-trips through save -> reload (DB layer)", async () => { + await updateCompressionSettings({ sessionDedup: { minBlockChars: 321, fuzzy: true } }); + const reloaded = await getCompressionSettings(); + assert.deepEqual(reloaded.sessionDedup, { minBlockChars: 321, fuzzy: true }); +}); + +test("#8388: ccr detail round-trips through save -> reload (DB layer)", async () => { + await updateCompressionSettings({ ccr: { minChars: 4242, retrievalRampFactor: 7 } }); + const reloaded = await getCompressionSettings(); + assert.deepEqual(reloaded.ccr, { minChars: 4242, retrievalRampFactor: 7 }); +}); + +test("#8388: headroom minRows STILL round-trips (proves #8056 fix stays intact, no regression)", async () => { + await updateCompressionSettings({ headroom: { minRows: 5 } }); + const reloaded = await getCompressionSettings(); + assert.deepEqual(reloaded.headroom, { minRows: 5 }); +}); diff --git a/tests/unit/8395-plugin-hooks-fire.test.ts b/tests/unit/8395-plugin-hooks-fire.test.ts new file mode 100644 index 0000000000..205134f60d --- /dev/null +++ b/tests/unit/8395-plugin-hooks-fire.test.ts @@ -0,0 +1,155 @@ +// Regression test for #8395 — "registered+active plugin hooks never fire during +// proxying". The IPC dispatch itself works (manager.ts registers loader.ts's real +// callHook-backed callables and emitHookBlocking does invoke them), but +// loader.ts::loadPlugin() spawns the plugin host with +// `stdio: ["ignore", "ignore", "ignore", "ipc"]` — stdout/stderr are discarded at the +// OS level, so a plugin following the SDK's own documented console.log pattern +// produces zero observable output. This test proves the hook body DOES execute and +// its return value DOES come back (disproving the "hooks never fire" framing), while +// pinning the real, narrower bug: the plugin's own stdout/stderr must be observable +// on the parent side after a hook call. +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { loadPlugin, type LoadedPlugin } from "../../src/lib/plugins/loader.ts"; + +test( + "loadPlugin forwards the plugin child process's stdout to an observable channel", + { timeout: 10_000 }, + async (t) => { + const pluginDir = await mkdtemp(join(tmpdir(), "omniroute-plugin-8395-")); + const entryPoint = join(pluginDir, "index.mjs"); + let loaded: LoadedPlugin | undefined; + + t.after(async () => { + loaded?.cleanup(); + await rm(pluginDir, { recursive: true, force: true }); + }); + + await writeFile( + entryPoint, + ` +export async function onRequest(ctx) { + console.log("PLUGIN_FIRED_MARKER_8395", ctx.requestId); + return { + metadata: { pluginSawRequestId: ctx.requestId }, + }; +} +`, + "utf-8" + ); + + loaded = await loadPlugin(entryPoint, { + name: "stdout-forward-test", + version: "1.0.0", + license: "MIT", + main: "index.mjs", + source: "local", + tags: [], + requires: { permissions: [] }, + hooks: { onRequest: true, onResponse: false, onError: false }, + skills: [], + enabledByDefault: false, + configSchema: {}, + }); + + // Capture everything written to the parent process's stdout while the hook runs. + const originalWrite = process.stdout.write.bind(process.stdout); + let captured = ""; + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + captured += typeof chunk === "string" ? chunk : String(chunk); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (originalWrite as any)(chunk, ...rest); + }) as typeof process.stdout.write; + + let result: unknown; + try { + result = await loaded.plugin.onRequest?.({ + requestId: "req-8395-marker", + body: { model: "gpt-4" }, + model: "gpt-4", + metadata: {}, + }); + + // Give the async stdout "data" event a tick to arrive after the IPC "result" + // message (they race over two independent channels of the same child process). + await new Promise((resolve) => setTimeout(resolve, 300)); + } finally { + process.stdout.write = originalWrite; + } + + // 1) The IPC round trip itself works: the hook body ran and its return value + // came back correctly. This disproves the "hook dispatch is broken" theory. + assert.deepEqual(result, { + metadata: { pluginSawRequestId: "req-8395-marker" }, + }); + + // 2) The actual #8395 symptom: the plugin's own console.log output must be + // observable on the parent side (forwarded from the child's stdout), not + // silently discarded by `stdio: ["ignore", "ignore", "ignore", "ipc"]`. + assert.ok( + captured.includes("PLUGIN_FIRED_MARKER_8395") && captured.includes("req-8395-marker"), + `expected the plugin's own stdout output to be forwarded/logged somewhere ` + + `observable on the parent side; captured=${JSON.stringify(captured)}` + ); + } +); + +test( + "loadPlugin no longer spawns the plugin host with stdout/stderr fully ignored", + async () => { + const source = await readFile( + join(import.meta.dirname, "../../src/lib/plugins/loader.ts"), + "utf-8" + ); + // The original bug: stdio: ["ignore", "ignore", "ignore", "ipc"] discards + // stdout (fd 1) and stderr (fd 2) at the OS level unconditionally. + assert.doesNotMatch( + source, + /stdio:\s*\[\s*["']ignore["']\s*,\s*["']ignore["']\s*,\s*["']ignore["']\s*,\s*["']ipc["']\s*\]/, + "loader.ts must not spawn the plugin host with stdout+stderr both set to " + + "'ignore' — that silently discards all plugin console.log/console.error output" + ); + } +); + +// Secondary #8395 finding: runPluginOnResponseHook was only wired into chatCore.ts's +// STREAMING success path — the non-streaming (stream:false) JSON-return branch +// returned without ever calling it, so onResponse never fired for stream:false +// requests at all. chatCore.ts is a very large, heavily-mocked-provider-dependent +// handler (4800+ lines) — a full handleChatCore() integration harness for this one +// call site would dwarf the fix. Instead, structurally pin that both success +// branches call the hook exactly once, complementing the existing behavioral +// contract test for runPluginOnResponseHook itself +// (tests/unit/chatcore-plugin-onresponse.test.ts). +test("chatCore.ts calls runPluginOnResponseHook from both the non-streaming and streaming success paths", async () => { + const source = await readFile( + join(import.meta.dirname, "../../open-sse/handlers/chatCore.ts"), + "utf-8" + ); + + const nonStreamingReturnIndex = source.indexOf("buildNonStreamingJsonResponse(translatedResponse"); + const hookCallIndex = source.indexOf( + "await runPluginOnResponseHook({ requestId: traceId, body, model, provider, apiKeyInfo });" + ); + const secondHookCallIndex = source.indexOf( + "await runPluginOnResponseHook({ requestId: traceId, body, model, provider, apiKeyInfo });", + hookCallIndex + 1 + ); + + assert.notEqual(hookCallIndex, -1, "expected at least one runPluginOnResponseHook call site"); + assert.notEqual( + secondHookCallIndex, + -1, + "expected TWO runPluginOnResponseHook call sites — one per success branch " + + "(non-streaming JSON return and streaming SSE return)" + ); + assert.ok( + hookCallIndex < nonStreamingReturnIndex, + "the non-streaming branch must call runPluginOnResponseHook BEFORE returning " + + "buildNonStreamingJsonResponse(...), not skip it" + ); +}); diff --git a/tests/unit/8396-cooldown-429-cap.test.ts b/tests/unit/8396-cooldown-429-cap.test.ts new file mode 100644 index 0000000000..bbdc9a0157 --- /dev/null +++ b/tests/unit/8396-cooldown-429-cap.test.ts @@ -0,0 +1,80 @@ +// Regression guard for #8396: after a burst of retryable failures (e.g. 429s), the +// connection-level cooldown computed by checkFallbackError() must never exceed +// profile.maxCooldownMs. Before the fix, getScaledBaseCooldown() scaled +// baseCooldownMs * 2^backoffLevel with NO absolute ceiling on the connection-level +// path (unlike the model-lockout path, which already clamps to maxCooldownMs). A +// legacy-migrated OAuth profile with baseCooldownMs=60000 and maxBackoffSteps=8 +// produced cooldownMs = 60000 * 2^8 = 15,360,000ms (~4.27h), blowing straight past +// an operator-configured maxCooldownMs of 10 minutes. +import test from "node:test"; +import assert from "node:assert/strict"; +import { checkFallbackError, type ProviderProfile } from "../../open-sse/services/accountFallback.ts"; + +const legacyMigratedOAuthProfile: ProviderProfile = { + baseCooldownMs: 60000, + useUpstreamRetryHints: false, + maxCooldownMs: 600000, // operator-configured 10-minute ceiling + maxBackoffSteps: 8, + failureThreshold: 3, + resetTimeoutMs: 30 * 60 * 1000, + transientCooldown: 5000, + rateLimitCooldown: 60000, + maxBackoffLevel: 8, + circuitBreakerThreshold: 3, + circuitBreakerReset: 60000, + providerFailureThreshold: 3, + providerFailureWindowMs: 60000, + providerCooldownMs: 60000, +}; + +test("#8396: connection-level 429 cooldown after a high-failureIndex burst is capped at profile.maxCooldownMs", () => { + const result = checkFallbackError( + 429, + "", + 8, // backoffLevel — a large failureIndex from a sustained 429 burst + "some-model", + "test-oauth-provider", + null, + legacyMigratedOAuthProfile + ); + + assert.equal(result.shouldFallback, true); + assert.ok( + result.cooldownMs <= legacyMigratedOAuthProfile.maxCooldownMs, + `expected cooldownMs to be capped at ${legacyMigratedOAuthProfile.maxCooldownMs}ms ` + + `(profile.maxCooldownMs), but got ${result.cooldownMs}ms — no absolute ceiling was ` + + "applied on the connection-level 429 cooldown path" + ); +}); + +test("#8396: an upstream Retry-After hint is honored (bypasses the exponential scale entirely)", () => { + const apikeyProfile: ProviderProfile = { + ...legacyMigratedOAuthProfile, + useUpstreamRetryHints: true, + }; + const headers = new Headers({ "retry-after": "30" }); + + const result = checkFallbackError(429, "", 8, "some-model", "test-apikey-provider", headers, apikeyProfile); + + assert.equal(result.usedUpstreamRetryHint, true); + assert.ok( + result.cooldownMs <= 31000 && result.cooldownMs >= 29000, + `expected the ~30s upstream Retry-After hint to be honored, got ${result.cooldownMs}ms` + ); +}); + +test("#8396: a single 429 (low backoffLevel) still cools down normally, well under the cap", () => { + const result = checkFallbackError( + 429, + "", + 0, // first failure + "some-model", + "test-oauth-provider", + null, + legacyMigratedOAuthProfile + ); + + assert.equal(result.shouldFallback, true); + assert.equal(result.cooldownMs, legacyMigratedOAuthProfile.baseCooldownMs); + assert.ok(result.cooldownMs < legacyMigratedOAuthProfile.maxCooldownMs); +}); diff --git a/tests/unit/8431-multiwindow-quota-eviction.test.ts b/tests/unit/8431-multiwindow-quota-eviction.test.ts new file mode 100644 index 0000000000..ebe0767a50 --- /dev/null +++ b/tests/unit/8431-multiwindow-quota-eviction.test.ts @@ -0,0 +1,119 @@ +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"; + +/** + * #8431 — a provider with many quota windows (e.g. codebuddy-cn: 1 Monthly + + * up to 8 Bonus Packs) can be wrongly reported as fully exhausted after a + * fresh boot (empty in-memory cache) even though most windows still have + * balance. + * + * Root cause: `getLatestQuotaSnapshotsForConnection()` fetched the most + * recent 200 rows for the connection (across ALL windows), then deduped by + * `window_key` *inside* that slice. A connection where a few windows churn + * frequently (draining, so they keep writing fresh rows) and the rest stay + * idle/healthy (a single old row each) can have its top-200 slice entirely + * flooded by the hot windows once they collectively accumulate >200 rows. + * The idle-but-healthy windows' only row falls outside the slice and is + * silently dropped from rehydration, so `isExhausted()` — which is correct + * on the data it's given — reports the connection exhausted because every + * window it was handed genuinely is at 0%. + * + * Regression guard: without the fix, only the 3 hot windows survive + * rehydration (of 9 total) and `isQuotaExhaustedForRequest` wrongly reports + * `true`. With the fix, all 9 windows survive and the request stays + * eligible because 6 of the 9 windows still have balance. + */ +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-quota-8431-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const coreDb = await import("../../src/lib/db/core.ts"); +const quotaSnapshotsDb = await import("../../src/lib/db/quotaSnapshots.ts"); +const quotaCache = await import("../../src/domain/quotaCache.ts"); + +test.after(() => { + coreDb.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +const COLD_WINDOWS = ["Bonus Pack 1", "Bonus Pack 2", "Bonus Pack 3", "Bonus Pack 4", "Weekly", "Daily"]; +const HOT_WINDOWS = ["Monthly", "Bonus Pack 5", "Bonus Pack 6"]; + +test("#8431 idle healthy windows survive rehydration even when hot windows accumulate >200 rows", () => { + const connectionId = "conn-codebuddy-cn-8431"; + const provider = "codebuddy-cn"; + + // 6 cold, healthy windows — each written exactly once (mirrors the #4438 + // no-op-write dedup for windows whose value never changes) and BEFORE the + // hot rows below. + for (const windowKey of COLD_WINDOWS) { + quotaSnapshotsDb.saveQuotaSnapshot({ + provider, + connection_id: connectionId, + window_key: windowKey, + remaining_percentage: 80, + is_exhausted: 0, + next_reset_at: "2099-01-01T00:00:00.000Z", + window_duration_ms: null, + raw_data: null, + }); + } + + // 3 hot, actively-draining windows — 70 iterations x 3 windows = 210 rows, + // all created after the cold rows, exceeding the old LIMIT 200. + for (let i = 0; i < 70; i++) { + for (const windowKey of HOT_WINDOWS) { + quotaSnapshotsDb.saveQuotaSnapshot({ + provider, + connection_id: connectionId, + window_key: windowKey, + remaining_percentage: 0, + is_exhausted: 1, + next_reset_at: "2099-01-08T00:00:00.000Z", + window_duration_ms: null, + raw_data: null, + }); + } + } + + const rehydrated = quotaSnapshotsDb.getLatestQuotaSnapshotsForConnection(connectionId); + const rehydratedKeys = rehydrated + .map((s) => (s as unknown as { windowKey?: string }).windowKey ?? s.window_key) + .sort(); + + assert.equal( + rehydrated.length, + 9, + `expected all 9 windows to survive rehydration, got ${rehydrated.length}: ${rehydratedKeys.join(", ")}` + ); + + assert.equal( + quotaCache.isQuotaExhaustedForRequest(connectionId, provider, "deepseek-v4-pro"), + false, + "6 of 9 windows still have balance — the connection must not be reported as exhausted" + ); +}); + +test("#8431 a single-window provider is still correctly reported exhausted", () => { + const connectionId = "conn-single-window-8431"; + const provider = "openai"; + + quotaSnapshotsDb.saveQuotaSnapshot({ + provider, + connection_id: connectionId, + window_key: "weekly", + remaining_percentage: 0, + is_exhausted: 1, + next_reset_at: null, + window_duration_ms: null, + raw_data: null, + }); + + assert.equal( + quotaCache.isQuotaExhaustedForRequest(connectionId, provider, "gpt-5"), + true, + "single depleted window must still correctly report exhaustion" + ); +}); diff --git a/tests/unit/8510-adobe-firefly-edits-route.test.ts b/tests/unit/8510-adobe-firefly-edits-route.test.ts new file mode 100644 index 0000000000..5e80cad4d4 --- /dev/null +++ b/tests/unit/8510-adobe-firefly-edits-route.test.ts @@ -0,0 +1,230 @@ +// #8510 (artickc, feat/adobe-firefly-reference-images): route-level coverage for the Adobe +// Firefly branch that /v1/images/edits gained in this PR. Exercises the actual +// POST(request) handler (not the inner handleAdobeFireflyImageGeneration helper directly, +// which tests/unit/adobe-firefly.test.ts already covers) so the credentials / +// rate-limit / 4-reference-cap branches added to route.ts itself are proven, not just the +// downstream service call. +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-adobe-firefly-edits-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "adobe-firefly-edits-test-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 imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +const { + ADOBE_FIREFLY_IMAGE_UPLOAD_URL, + ADOBE_FIREFLY_IMAGE_SUBMIT_URL, +} = await import("../../open-sse/services/adobeFireflyClient.ts"); + +interface ErrorResponseBody { + error: { message: string; code?: string }; +} + +interface ImageResponseBody { + data: Array<{ b64_json?: string; url?: string }>; +} + +const originalFetch = globalThis.fetch; + +async function resetStorage() { + globalThis.fetch = originalFetch; + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +async function seedAdobeFireflyConnection( + overrides: { apiKey?: string; rateLimitedUntil?: string | null } = {} +) { + return providersDb.createProviderConnection({ + provider: "adobe-firefly", + authType: "apikey", + name: "adobe-firefly-test", + apiKey: overrides.apiKey ?? userImsJwt(), + isActive: true, + testStatus: "active", + rateLimitedUntil: overrides.rateLimitedUntil ?? null, + }); +} + +// Mirrors tests/unit/adobe-firefly.test.ts's userImsJwt() helper — a synthetic, +// non-guest IMS access token shape so resolveAdobeAccessToken() accepts it directly +// without needing a live cookie->token exchange call. +function userImsJwt(userId = "0EB@AdobeID"): string { + return ( + `eyJhbGciOiJSUzI1NiJ9.` + + Buffer.from( + JSON.stringify({ user_id: userId, type: "access_token", client_id: "clio-playground-web" }) + ).toString("base64url") + + `.` + + "sig".padEnd(40, "x") + ); +} + +function dataUrlPng(bytes: number[]): string { + return `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`; +} + +const REF_A = dataUrlPng([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]); +const REF_B = dataUrlPng([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 2]); + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#8510 v1 image edit POST uploads Adobe Firefly reference images and dispatches referenceBlobs", async () => { + await seedAdobeFireflyConnection(); + + const uploadedIds: string[] = []; + let submitBody: Record | null = null; + + globalThis.fetch = async (url, init: RequestInit = {}) => { + const stringUrl = String(url); + if (stringUrl === ADOBE_FIREFLY_IMAGE_UPLOAD_URL) { + const id = `blob-${uploadedIds.length + 1}`; + uploadedIds.push(id); + return new Response(JSON.stringify({ images: [{ id }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (stringUrl === ADOBE_FIREFLY_IMAGE_SUBMIT_URL) { + submitBody = JSON.parse(String(init.body || "{}")); + return new Response(JSON.stringify({ links: { result: "https://poll.example/job/img1" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (stringUrl === "https://poll.example/job/img1") { + return new Response( + JSON.stringify({ + status: "COMPLETED", + outputs: [{ image: { presignedUrl: "https://cdn.example/edited-out.png" } }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "adobe-firefly/nano-banana-pro", + prompt: "combine these two references", + images: [REF_A, REF_B], + }), + }) + ); + const body = (await response.json()) as ImageResponseBody; + + assert.equal(response.status, 200); + assert.equal(body.data[0].url, "https://cdn.example/edited-out.png"); + + // referenceBlobs upload path: both distinct reference images must have been uploaded + // to Firefly storage and forwarded as referenceBlobs on the generate-async dispatch. + assert.equal(uploadedIds.length, 2, "both reference images must be uploaded individually"); + assert.ok(submitBody, "generate-async must have been called"); + const referenceBlobs = (submitBody as Record).referenceBlobs as Array<{ + id: string; + }>; + assert.ok(Array.isArray(referenceBlobs), "generate-async payload must carry referenceBlobs"); + assert.deepEqual( + referenceBlobs.map((r) => r.id).sort(), + [...uploadedIds].sort() + ); +}); + +test("#8510 v1 image edit POST rejects more than 4 Adobe Firefly reference images", async () => { + await seedAdobeFireflyConnection(); + globalThis.fetch = async () => { + throw new Error("Over-cap Adobe Firefly reference sets must not reach upstream"); + }; + + const images = Array.from({ length: 5 }, (_, i) => dataUrlPng([0x89, 0x50, 0x4e, 0x47, i + 1])); + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "adobe-firefly/nano-banana-pro", + prompt: "combine these references", + images, + }), + }) + ); + const body = (await response.json()) as ErrorResponseBody; + + assert.equal(response.status, 400); + assert.match(body.error.message, /Adobe Firefly image edit supports at most 4 reference images/); + // Hard Rule #12 — every error response routes through buildErrorBody/sanitizeErrorMessage + // and must never leak a raw stack trace. + assert.ok(!body.error.message.includes("at /")); +}); + +test("#8510 v1 image edit POST surfaces missing Adobe Firefly credentials", async () => { + // No adobe-firefly connection seeded at all. + globalThis.fetch = async () => { + throw new Error("Missing-credentials path must not reach upstream"); + }; + + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "adobe-firefly/nano-banana-pro", + prompt: "edit this", + images: [REF_A], + }), + }) + ); + const body = (await response.json()) as ErrorResponseBody; + + assert.equal(response.status, 401); + assert.match(body.error.message, /No credentials for provider: adobe-firefly/); + assert.ok(!body.error.message.includes("at /")); +}); + +test("#8510 v1 image edit POST surfaces Adobe Firefly rate-limit sentinel", async () => { + await seedAdobeFireflyConnection({ rateLimitedUntil: new Date(Date.now() + 60_000).toISOString() }); + globalThis.fetch = async () => { + throw new Error("Rate-limited path must not reach upstream"); + }; + + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "adobe-firefly/nano-banana-pro", + prompt: "edit this", + images: [REF_A], + }), + }) + ); + const body = (await response.json()) as ErrorResponseBody; + + assert.equal(response.status, 429); + assert.match(body.error.message, /All accounts rate limited/); + assert.ok(!body.error.message.includes("at /")); +}); diff --git a/tests/unit/adobe-firefly.test.ts b/tests/unit/adobe-firefly.test.ts index 68f973c0dd..825d7c2dcc 100644 --- a/tests/unit/adobe-firefly.test.ts +++ b/tests/unit/adobe-firefly.test.ts @@ -9,19 +9,24 @@ import { buildAdobeImagePayload, buildAdobePollHeaders, buildAdobeSubmitHeaders, + buildAdobeUploadHeaders, buildAdobeVideoPayload, extractAdobeAccountIdFromToken, extractAdobeCredentialToken, extractAdobeMediaUrl, extractAdobeResultLink, + extractAdobeSourceImageSources, looksLikeAdobeJwt, normalizeAdobeAspectRatio, normalizeAdobeOutputResolution, normalizeAdobePollUrl, parseAdobeCreditsBalance, parseAdobeModelsDiscovery, + parseAdobeImageSourceBytes, + parseAdobeStorageUploadResponse, resolveAdobeImageModel, resolveAdobeVideoModel, + resolveAdobeSourceImageIds, adobeFireflyGenerateImage, adobeFireflyGenerateVideo, exchangeAdobeCookieForAccessToken, @@ -31,6 +36,7 @@ import { generateAdobeNonce, extractAdobeArpSessionId, resolveAdobeAccessToken, + ADOBE_FIREFLY_IMAGE_UPLOAD_URL, } from "../../open-sse/services/adobeFireflyClient.ts"; import { ADOBE_FIREFLY_FALLBACK_MODELS, @@ -186,6 +192,115 @@ test("buildAdobeImagePayload produces nano and gpt-image shapes", () => { assert.equal(gpt.outputResolution, undefined); }); +test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_images capture", () => { + // Live: referenceBlobs usage "general", module stays text2image for nano + const nano = buildAdobeImagePayload({ + prompt: "teest", + aspectRatio: "1:1", + outputResolution: "1K", + modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana"], + sourceImageIds: [ + "2a4f1025-e0dc-4671-a11a-7dfd3c07bd94", + "84c11d1a-e798-4300-a63e-c06504ca2068", + ], + }); + assert.deepEqual(nano.referenceBlobs, [ + { id: "2a4f1025-e0dc-4671-a11a-7dfd3c07bd94", usage: "general" }, + { id: "84c11d1a-e798-4300-a63e-c06504ca2068", usage: "general" }, + ]); + assert.equal( + (nano.generationMetadata as Record).module, + "text2image" + ); + + const gpt = buildAdobeImagePayload({ + prompt: "edit me", + aspectRatio: "1:1", + outputResolution: "1K", + modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"], + sourceImageIds: ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"], + }); + assert.deepEqual(gpt.referenceBlobs, [ + { id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "subject" }, + ]); + assert.equal( + (gpt.generationMetadata as Record).module, + "image2image" + ); +}); + +test("extractAdobeSourceImageSources reads Media page image fields", () => { + const tinyPng = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const sources = extractAdobeSourceImageSources({ + prompt: "x", + image_url: tinyPng, + image_urls: [tinyPng, "https://cdn.example/b.png"], + provider_options: { images: ["https://cdn.example/c.png"] }, + }); + assert.ok(sources.includes(tinyPng)); + assert.ok(sources.includes("https://cdn.example/b.png")); + assert.ok(sources.includes("https://cdn.example/c.png")); + assert.equal(sources.length, 3); // tinyPng deduped from image_url + image_urls[0] +}); + +test("parseAdobeImageSourceBytes + parseAdobeStorageUploadResponse", () => { + const tinyPng = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const { buffer, contentType } = parseAdobeImageSourceBytes(tinyPng); + assert.ok(buffer.length > 10); + assert.equal(contentType, "image/png"); + assert.equal( + parseAdobeStorageUploadResponse({ + images: [{ id: "2a4f1025-e0dc-4671-a11a-7dfd3c07bd94" }], + }), + "2a4f1025-e0dc-4671-a11a-7dfd3c07bd94" + ); + assert.equal(parseAdobeStorageUploadResponse({}), ""); +}); + +test("buildAdobeUploadHeaders uses image content-type not json", () => { + const h = buildAdobeUploadHeaders("tok", "image/png", { arpSessionId: "arp" }); + assert.equal(h["content-type"], "image/png"); + assert.equal(h.Authorization, "Bearer tok"); + assert.equal(h["x-api-key"], "clio-playground-web"); + assert.equal(h["x-arp-session-id"], "arp"); + assert.ok(h["x-nonce"]); +}); + +test("resolveAdobeSourceImageIds uploads data URLs then returns blob ids", async () => { + const tinyPng = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + let uploadCalls = 0; + const fetchImpl = async (url: string | URL, init?: RequestInit) => { + const u = String(url); + if (u.includes("/v2/storage/image")) { + uploadCalls += 1; + assert.equal(init?.method, "POST"); + const headers = init?.headers as Record; + assert.match(String(headers["content-type"] || headers["Content-Type"] || ""), /image\//); + assert.ok(init?.body); + return new Response( + JSON.stringify({ images: [{ id: `blob-${uploadCalls}` }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`unexpected fetch ${u}`); + }; + + const ids = await resolveAdobeSourceImageIds({ + accessToken: "tok", + body: { image_url: tinyPng, image_urls: [tinyPng] }, + max: 4, + prompt: "ref", + fetchImpl: fetchImpl as typeof fetch, + }); + // Same data URL deduped → one upload + assert.deepEqual(ids, ["blob-1"]); + assert.equal(uploadCalls, 1); + assert.equal(ADOBE_FIREFLY_IMAGE_UPLOAD_URL.includes("storage/image"), true); +}); + test("buildAdobeVideoPayload produces sora and veo shapes", () => { const sora = buildAdobeVideoPayload({ prompt: "ocean waves", @@ -468,6 +583,50 @@ test("handleAdobeFireflyImageGeneration submit+poll happy path (mocked)", async assert.ok(calls >= 2); }); +test("handleAdobeFireflyImageGeneration uploads refs and submits referenceBlobs", async () => { + const tinyPng = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + + let sawGenerateBody: Record | null = null; + let uploadCalls = 0; + const fetchImpl = async (url: string | URL, init?: RequestInit) => { + const u = String(url); + if (u.includes("/v2/storage/image")) { + uploadCalls += 1; + return jsonResponse(200, { images: [{ id: "ref-blob-1" }] }); + } + if (u.includes("generate-async")) { + sawGenerateBody = JSON.parse(String(init?.body || "{}")); + return jsonResponse( + 200, + { links: { result: { href: "https://poll.example/j1" } } }, + { "x-override-status-link": "https://poll.example/j1" } + ); + } + if (u.includes("poll.example")) { + return jsonResponse(200, { + status: "COMPLETED", + outputs: [{ image: { presignedUrl: "https://cdn.example/out.png" } }], + }); + } + throw new Error(`unexpected fetch ${u}`); + }; + + const result = await handleAdobeFireflyImageGeneration({ + model: "nano-banana", + provider: "adobe-firefly", + body: { prompt: "teest", image_url: tinyPng }, + credentials: { apiKey: userImsJwt() }, + fetchImpl: fetchImpl as typeof fetch, + }); + + assert.equal(result.success, true); + assert.equal(uploadCalls, 1); + assert.ok(sawGenerateBody); + const refs = sawGenerateBody!.referenceBlobs as Array<{ id: string; usage: string }>; + assert.deepEqual(refs, [{ id: "ref-blob-1", usage: "general" }]); +}); + test("adobeFireflyGenerateVideo submit+poll happy path (mocked)", async () => { const fetchImpl = async (url: string) => { const u = String(url); diff --git a/tests/unit/agent-bridge-dns-per-agent-8466.test.ts b/tests/unit/agent-bridge-dns-per-agent-8466.test.ts new file mode 100644 index 0000000000..c9ce228bab --- /dev/null +++ b/tests/unit/agent-bridge-dns-per-agent-8466.test.ts @@ -0,0 +1,111 @@ +/** + * Regression test for issue #8466: AgentBridge diagnostics `dnsConfigured` + * was hard-wired to the Antigravity host regex (src/mitm/manager.ts) instead + * of being computed per-agent via `resolveHostsForAgent(agentId)` + * (src/mitm/dns/dnsConfig.ts). + * + * We mock fs.readFileSync so the real /etc/hosts is never touched, then + * exercise getMitmStatus() -- the exact function named in the issue -- with + * hosts-file contents representing each failure direction, plus the + * diagnose route to prove the agentId query param is threaded through. + */ +import { test, mock, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; + +afterEach(() => { + mock.restoreAll(); +}); + +test("FALSE NEGATIVE: Claude Code host correctly spoofed, no Antigravity host present -> dnsConfigured should be true when checked for claude-code", async () => { + const realReadFileSync = fs.readFileSync.bind(fs); + mock.method(fs, "readFileSync", (p: string, enc?: BufferEncoding) => { + if (p === "/etc/hosts") { + // Claude Code target hosts = ["api.anthropic.com"] (src/mitm/targets/claudeCode.ts). + // The user correctly spoofed it. No Antigravity host present at all. + return "127.0.0.1 localhost\n127.0.0.1 api.anthropic.com\n::1 api.anthropic.com\n"; + } + return realReadFileSync(p, enc); + }); + + const { getMitmStatus } = await import("../../src/mitm/manager.ts?probe=8466-negative"); + const status = await getMitmStatus("claude-code"); + + assert.equal( + status.dnsConfigured, + true, + "dnsConfigured must be true when the agent being diagnosed (Claude Code) has its own host spoofed" + ); +}); + +test("FALSE POSITIVE: only a leftover Antigravity host is present, Claude Code host is missing -> dnsConfigured should be false when checked for claude-code", async () => { + const realReadFileSync = fs.readFileSync.bind(fs); + mock.method(fs, "readFileSync", (p: string, enc?: BufferEncoding) => { + if (p === "/etc/hosts") { + // Leftover Antigravity entry from a previous setup. Claude Code's host + // (api.anthropic.com) is NOT present. + return "127.0.0.1 localhost\n127.0.0.1 daily-cloudcode-pa.googleapis.com\n"; + } + return realReadFileSync(p, enc); + }); + + const { getMitmStatus } = await import("../../src/mitm/manager.ts?probe=8466-positive"); + const status = await getMitmStatus("claude-code"); + + assert.equal( + status.dnsConfigured, + false, + "dnsConfigured must be false when the agent being diagnosed (Claude Code) has no host spoofed, even if a leftover Antigravity host is present" + ); +}); + +test("no-agentId call sites keep legacy Antigravity-only behavior unchanged", async () => { + const realReadFileSync = fs.readFileSync.bind(fs); + mock.method(fs, "readFileSync", (p: string, enc?: BufferEncoding) => { + if (p === "/etc/hosts") { + // Claude Code host spoofed, but NO Antigravity host present. A caller + // that omits agentId (state/route.ts, server/route.ts, settings/mitm, + // cli-tools/antigravity-mitm) must still evaluate the legacy + // Antigravity-only regex, so this should remain false. + return "127.0.0.1 localhost\n127.0.0.1 api.anthropic.com\n::1 api.anthropic.com\n"; + } + return realReadFileSync(p, enc); + }); + + const { getMitmStatus } = await import("../../src/mitm/manager.ts?probe=8466-legacy"); + const status = await getMitmStatus(); + + assert.equal( + status.dnsConfigured, + false, + "callers that omit agentId must keep the legacy Antigravity-only check" + ); +}); + +test("diagnose route: threads ?agentId= query param through to getMitmStatus", async () => { + const realReadFileSync = fs.readFileSync.bind(fs); + mock.method(fs, "readFileSync", (p: string, enc?: BufferEncoding) => { + if (p === "/etc/hosts") { + return "127.0.0.1 localhost\n127.0.0.1 api.anthropic.com\n::1 api.anthropic.com\n"; + } + return realReadFileSync(p, enc); + }); + + const { GET } = await import( + "../../src/app/api/tools/agent-bridge/diagnose/route.ts?probe=8466-route" + ); + const res = await GET( + new Request("http://localhost/api/tools/agent-bridge/diagnose?agentId=claude-code") + ); + const body = (await res.json()) as { + checks: Array<{ name: string; ok: boolean }>; + }; + const dnsCheck = body.checks.find((c) => c.name === "dns-configured"); + + assert.ok(dnsCheck, "expected a dns-configured check in the diagnostics report"); + assert.equal( + dnsCheck?.ok, + true, + "diagnose route must pass agentId through to getMitmStatus so dns-configured reflects the diagnosed agent's hosts" + ); +}); diff --git a/tests/unit/anthropic-cache-fingerprint.test.ts b/tests/unit/anthropic-cache-fingerprint.test.ts index 110a97e642..8272cb1bb2 100644 --- a/tests/unit/anthropic-cache-fingerprint.test.ts +++ b/tests/unit/anthropic-cache-fingerprint.test.ts @@ -1,74 +1,10 @@ -/** - * Tests for Anthropic billing header fingerprint stability (#1638). - * - * Validates that the billing header fingerprint is stable across different - * messages within the same day, preventing prompt-cache prefix invalidation. - */ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -// Replicate the stabilized fingerprint logic from base.ts -function computeStableFingerprint(ccVersion: string): string { - const dayStamp = new Date().toISOString().slice(0, 10); // YYYY-MM-DD - return createHash("sha256").update(`${dayStamp}${ccVersion}`).digest("hex").slice(0, 3); -} - -// The old implementation for comparison -function computeOldFingerprint(firstUserMessageText: string, version: string): string { - const FINGERPRINT_SALT = "59cf53e54c78"; - const indices = [4, 7, 20]; - const chars = indices.map((i) => firstUserMessageText[i] || "0").join(""); - const input = `${FINGERPRINT_SALT}${chars}${version}`; - return createHash("sha256").update(input).digest("hex").slice(0, 3); -} +import { CLAUDE_CODE_CLIENT_BILLING_VERSION } from "../../src/shared/constants/claudeCodeClient.ts"; describe("Anthropic billing header fingerprint (#1638)", () => { - const ccVersion = "2.1.137"; - - it("should produce the same fingerprint for different messages (stable)", () => { - const fp1 = computeStableFingerprint(ccVersion); - const fp2 = computeStableFingerprint(ccVersion); - assert.equal(fp1, fp2, "Same-day fingerprints should be identical"); - }); - - it("should produce a 3-character hex fingerprint", () => { - const fp = computeStableFingerprint(ccVersion); - assert.equal(fp.length, 3, "Fingerprint should be 3 chars"); - assert.ok(/^[a-f0-9]{3}$/.test(fp), `Fingerprint '${fp}' should be lowercase hex`); - }); - - it("old implementation produces DIFFERENT fingerprints for different messages", () => { - const msg1 = "Hello, how can I help you with your code?"; - const msg2 = "Please fix the bug in my application"; - const fp1 = computeOldFingerprint(msg1, ccVersion); - const fp2 = computeOldFingerprint(msg2, ccVersion); - assert.notEqual(fp1, fp2, "Old method should differ per message — this was the bug"); - }); - - it("new implementation produces SAME fingerprint regardless of message content", () => { - // The new implementation doesn't use message content at all - const fp1 = computeStableFingerprint(ccVersion); - const fp2 = computeStableFingerprint(ccVersion); - const fp3 = computeStableFingerprint(ccVersion); - assert.equal(fp1, fp2); - assert.equal(fp2, fp3); - }); - - it("billing header line should be deterministic within the same day", () => { - const fp = computeStableFingerprint(ccVersion); - const billingLine1 = `x-anthropic-billing-header: cc_version=${ccVersion}.${fp}; cc_entrypoint=cli; cch=00000;`; - const billingLine2 = `x-anthropic-billing-header: cc_version=${ccVersion}.${fp}; cc_entrypoint=cli; cch=00000;`; - assert.equal(billingLine1, billingLine2, "Billing lines should be byte-identical"); - }); - - it("should produce different fingerprints for different days", () => { - // Simulate different days by computing manually - const day1 = "2026-04-27"; - const day2 = "2026-04-28"; - const fp1 = createHash("sha256").update(`${day1}${ccVersion}`).digest("hex").slice(0, 3); - const fp2 = createHash("sha256").update(`${day2}${ccVersion}`).digest("hex").slice(0, 3); - // They should be different (extremely high probability with SHA-256) - assert.notEqual(fp1, fp2, "Different days should produce different fingerprints"); + it("uses the immutable build revision captured from the signed CLI", () => { + assert.equal(CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.219.250"); }); }); diff --git a/tests/unit/auggie-executor.test.ts b/tests/unit/auggie-executor.test.ts index f8fcd25ea5..b44c3ec94f 100644 --- a/tests/unit/auggie-executor.test.ts +++ b/tests/unit/auggie-executor.test.ts @@ -293,6 +293,34 @@ test("resolveAuggieModel resolves every pre-v0.32.0 alias to an allowlisted v0.3 } }); +// The failure arm is read through an `isAuggieModelFailure()` type predicate — this +// workspace compiles with `strictNullChecks: false`, where `!result.ok` narrows the +// success branch but not the failure one, so `.error` was unreachable to the checker. +// The predicate is a one-liner whose control flow inverts on a stray `!`, so pin both +// arms: the failure arm must carry a usable message, the success arm must not claim one. +test("resolveAuggieModel's failure arm carries a readable error message", () => { + const result = resolveAuggieModel("totally-not-a-real-model"); + assert.equal(result.ok, false); + if (!result.ok) { + assert.equal(typeof result.error, "string"); + assert.ok(result.error.length > 0, "the rejection must explain itself"); + assert.match(result.error, /Unknown Auggie model/); + } +}); + +test("resolveAuggieModel's success arm resolves a model and reports no error", () => { + const result = resolveAuggieModel("haiku4.5"); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.model, "haiku4.5"); + assert.equal( + (result as unknown as { error?: unknown }).error, + undefined, + "the success arm must not carry a failure message" + ); + } +}); + // ─── execute(): model allowlist (argument-injection defense) ────────────── test("execute() rejects a model not in the registry allowlist and never spawns", async () => { diff --git a/tests/unit/base-executor-buildheaders-extra-keys-8493.test.ts b/tests/unit/base-executor-buildheaders-extra-keys-8493.test.ts new file mode 100644 index 0000000000..be947e85d4 --- /dev/null +++ b/tests/unit/base-executor-buildheaders-extra-keys-8493.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { BaseExecutor } from "../../open-sse/executors/base.ts"; + +/** + * Generic BaseExecutor consumer — no buildHeaders() override — representing + * every provider (xai, cliproxyapi, chipotle, mimocode, ninerouter, theoldllm, + * gitlab, ...) that relies on BaseExecutor.buildHeaders() as-is. + * + * Regression guard for #8467/#8493: resolveEffectiveKey() already rotates to + * an extra key when the primary apiKey is empty, but the header-write gate in + * buildHeaders() tested the raw `credentials.apiKey` instead of the resolved + * `effectiveKey` — so with an empty primary + populated extras, no + * Authorization header was ever written, even though a valid key existed. + */ +class GenericExecutor extends BaseExecutor { + constructor() { + super("generic-provider", { + baseUrls: ["https://default.example/v1/chat/completions"], + }); + } + + async transformRequest(model: string, body: unknown, stream: boolean) { + return body; + } +} + +test("buildHeaders: writes Authorization from the rotated extra key when primary apiKey is empty (#8467/#8493)", () => { + const executor = new GenericExecutor(); + const headers = executor.buildHeaders({ + apiKey: "", + connectionId: "generic-empty-primary", + providerSpecificData: { extraApiKeys: ["sk-extra-only"] }, + }); + + assert.equal(headers["Authorization"], "Bearer sk-extra-only"); +}); diff --git a/tests/unit/bootstrap-env.test.ts b/tests/unit/bootstrap-env.test.ts index 204ceea672..93dee00dd5 100644 --- a/tests/unit/bootstrap-env.test.ts +++ b/tests/unit/bootstrap-env.test.ts @@ -70,14 +70,14 @@ test("bootstrapEnv strips matching quotes from env values", () => { fs.mkdirSync(dataDir, { recursive: true }); fs.writeFileSync( path.join(dataDir, "server.env"), - 'JWT_SECRET="jwt-from-server-env"\nCLAUDE_USER_AGENT="claude-cli/2.1.145 (external, cli)"\n', + 'JWT_SECRET="jwt-from-server-env"\nCLAUDE_USER_AGENT="claude-cli/2.1.219 (external, cli)"\n', "utf8" ); const env = bootstrapEnv({ quiet: true }); assert.equal(env.JWT_SECRET, "jwt-from-server-env"); - assert.equal(env.CLAUDE_USER_AGENT, "claude-cli/2.1.145 (external, cli)"); + assert.equal(env.CLAUDE_USER_AGENT, "claude-cli/2.1.219 (external, cli)"); }); }); diff --git a/tests/unit/call-logs-session-tag.test.ts b/tests/unit/call-logs-session-tag.test.ts new file mode 100644 index 0000000000..4e466af1f2 --- /dev/null +++ b/tests/unit/call-logs-session-tag.test.ts @@ -0,0 +1,132 @@ +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"; + +// #8249: caller session tag (X-OmniRoute-Session-Id header) propagated into call_logs +// so operators can attribute cost per caller session. Isolated DATA_DIR per PII learnings §3 +// (resetDbInstance() + handle cleanup in test.after so the node:test runner doesn't hang). +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-session-tag-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const callLogs = await import("../../src/lib/usage/callLogs.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("saveCallLog persists sessionTag when explicitly supplied", async () => { + const testId = `test-sessiontag-${Date.now()}`; + + await callLogs.saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + sessionTag: "sess-abc", + }); + + const db = core.getDbInstance(); + const row = db + .prepare("SELECT id, session_tag FROM call_logs WHERE id = ?") + .get(testId) as Record; + assert.ok(row, "row should exist in call_logs"); + assert.equal(row.session_tag, "sess-abc"); +}); + +test("saveCallLog stores NULL session_tag when absent (never synthesized)", async () => { + const testId = `test-nosessiontag-${Date.now()}`; + + await callLogs.saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + }); + + const db = core.getDbInstance(); + const row = db + .prepare("SELECT id, session_tag FROM call_logs WHERE id = ?") + .get(testId) as Record; + assert.ok(row, "row should exist in call_logs"); + assert.equal(row.session_tag, null, "session_tag must be null when no header was supplied"); +}); + +test("getCallLogs returns sessionTag on the mapped row", async () => { + const testId = `test-getsessiontag-${Date.now()}`; + + await callLogs.saveCallLog({ + id: testId, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + sessionTag: "sess-roundtrip", + }); + + const logs = await callLogs.getCallLogs({ limit: 200 }); + const found = logs.find((l: { id: string }) => l.id === testId); + assert.ok(found, "log entry should be found via getCallLogs"); + assert.equal(found.sessionTag, "sess-roundtrip"); +}); + +test("getCallLogs filters by sessionTag (substring match, mirroring correlationId)", async () => { + const idMatch = `test-filter-match-${Date.now()}`; + const idOther = `test-filter-other-${Date.now()}`; + + await callLogs.saveCallLog({ + id: idMatch, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + sessionTag: "customer-42-session", + }); + await callLogs.saveCallLog({ + id: idOther, + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "test-model", + provider: "test-provider", + duration: 100, + tokens: { in: 10, out: 5 }, + sessionTag: "unrelated-session", + }); + + const results = await callLogs.getCallLogs({ sessionTag: "customer-42" }); + const ids = results.map((r: { id: string }) => r.id); + assert.ok(ids.includes(idMatch), "matching sessionTag row must be returned"); + assert.ok(!ids.includes(idOther), "non-matching sessionTag row must be excluded"); +}); + +test("schemaColumns self-heal ALTER for session_tag is idempotent", async () => { + const db = core.getDbInstance(); + // Re-run the exact idempotent guard twice; must not throw either time. + const { ensureCallLogsColumns } = await import("../../src/lib/db/schemaColumns.ts"); + assert.doesNotThrow(() => ensureCallLogsColumns(db)); + assert.doesNotThrow(() => ensureCallLogsColumns(db)); + + const columns = db.prepare("PRAGMA table_info(call_logs)").all() as Array<{ name: string }>; + assert.ok( + columns.some((c) => c.name === "session_tag"), + "call_logs.session_tag column must exist" + ); +}); diff --git a/tests/unit/catalog-updates-v3x.test.ts b/tests/unit/catalog-updates-v3x.test.ts index 9e3c3558fe..c95581b569 100644 --- a/tests/unit/catalog-updates-v3x.test.ts +++ b/tests/unit/catalog-updates-v3x.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { getModelsByProviderId } from "../../open-sse/config/providerModels.ts"; import { resolveCanonicalProviderModel } from "../../open-sse/services/model.ts"; +import { getStaticModelsForProvider } from "../../src/lib/providers/staticModels.ts"; import { DEFAULT_PRICING } from "../../src/shared/constants/pricing.ts"; test("Pollinations catalog mirrors the current public text model lineup", () => { @@ -66,6 +67,34 @@ test("Fable 5 catalog exposes claude-fable-5 in cc — but NOT via Kiro (fabrica ); }); +test("Opus 5 catalog is limited to verified first-party, web, and Copilot providers", () => { + for (const providerId of ["claude", "github", "claude-web", "anthropic"]) { + const model = getModelsByProviderId(providerId).find((entry) => entry.id === "claude-opus-5"); + assert.ok(model, `${providerId} must expose claude-opus-5`); + } + + const claude = getModelsByProviderId("claude").find((entry) => entry.id === "claude-opus-5"); + assert.equal(claude?.contextLength, 1000000); + assert.equal(claude?.maxOutputTokens, 128000); + assert.ok( + getStaticModelsForProvider("claude")?.some((entry) => entry.id === "claude-opus-5"), + "claude OAuth discovery must expose claude-opus-5" + ); + + const github = getModelsByProviderId("github").find((entry) => entry.id === "claude-opus-5"); + assert.equal(github?.targetFormat, "claude"); + + const kiroIds = new Set(getModelsByProviderId("kiro").map((entry) => entry.id)); + assert.equal(kiroIds.has("claude-opus-5"), false, "do not fabricate Kiro availability"); + + const pricing = DEFAULT_PRICING as Record>; + for (const providerId of ["cc", "gh", "anthropic"]) { + const price = pricing[providerId]["claude-opus-5"] as { input: number; output: number }; + assert.equal(price.input, 5.0, `${providerId} Opus 5 input price`); + assert.equal(price.output, 25.0, `${providerId} Opus 5 output price`); + } +}); + test("Sonnet 5 catalog exposes claude-sonnet-5 across cc/kiro/anthropic/blackbox with Sonnet-tier pricing", () => { // Sonnet 5 must be wired everywhere the last flagship (Fable 5) was — but as a // Sonnet-tier model: $3/$15 pricing (NOT the Opus/Fable $15/$75), 1M ctx / 128K out. diff --git a/tests/unit/cc-bridge-transforms.test.ts b/tests/unit/cc-bridge-transforms.test.ts index 01bcb814f3..680500a9cd 100644 --- a/tests/unit/cc-bridge-transforms.test.ts +++ b/tests/unit/cc-bridge-transforms.test.ts @@ -52,7 +52,7 @@ test("DEFAULT_CC_BRIDGE_PIPELINE places billing header at [0] and identity at [1 DEFAULT_CC_BRIDGE_PIPELINE ); const blocks = result.body.system as any[]; - assert.ok(blocks[0].text.startsWith("x-anthropic-billing-header:")); + assert.ok(blocks[0].text.startsWith("x-anthropic-billing-header: cc_version=2.1.219.250;")); assert.equal(blocks[1].text, CLAUDE_AGENT_SDK_IDENTITY); }); diff --git a/tests/unit/chat-cooldown-aware-retry.test.ts b/tests/unit/chat-cooldown-aware-retry.test.ts index ac276ebed6..0ab22ec703 100644 --- a/tests/unit/chat-cooldown-aware-retry.test.ts +++ b/tests/unit/chat-cooldown-aware-retry.test.ts @@ -9,6 +9,8 @@ process.env.STREAM_READINESS_TIMEOUT_MS = "50"; const harness = await createChatPipelineHarness("chat-cooldown-aware-retry"); const auth = await import("../../src/sse/services/auth.ts"); const { getProviderConnectionById } = await import("../../src/lib/db/providers.ts"); +const { __setTlsFetchOverrideForTesting } = + await import("../../open-sse/services/claudeTlsClient.ts"); const { BaseExecutor, buildOpenAIResponse, @@ -57,6 +59,7 @@ test.beforeEach(async () => { }); test.afterEach(async () => { + __setTlsFetchOverrideForTesting(null); BaseExecutor.RETRY_CONFIG.maxAttempts = originalRetryConfig.maxAttempts; BaseExecutor.RETRY_CONFIG.delayMs = originalRetryConfig.delayMs; await resetStorage(); @@ -66,6 +69,49 @@ test.after(async () => { await harness.cleanup(); }); +test("handleChat does not probe Claude Web repeatedly after an upstream 429", async () => { + await seedConnection("claude-web", { + apiKey: "sessionKey=fake-session", + }); + await settingsDb.updateSettings({ + requestRetry: 3, + maxRetryIntervalSec: 3, + }); + + let completionCalls = 0; + __setTlsFetchOverrideForTesting(async (url) => { + if (url.endsWith("/organizations")) { + return { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + text: JSON.stringify([{ uuid: "org-test" }]), + body: null, + }; + } + + completionCalls += 1; + return { + status: 429, + headers: new Headers({ "Content-Type": "application/json" }), + text: JSON.stringify({ error: { message: "Rate limited." } }), + body: null, + }; + }); + + const response = await handleChat( + buildRequest({ + body: { + model: "claude-web/claude-opus-5", + stream: false, + messages: [{ role: "user", content: "do not retry a Claude Web 429" }], + }, + }) + ); + + assert.equal(response.status, 429); + assert.equal(completionCalls, 1); +}); + test("handleChat waits for a short cooldown and retries once within the configured budget", async () => { await seedConnection("openai", { apiKey: "sk-openai-cooldown-short", diff --git a/tests/unit/chatcore-client-usage-buffer.test.ts b/tests/unit/chatcore-client-usage-buffer.test.ts index 4cda023536..5b82af1130 100644 --- a/tests/unit/chatcore-client-usage-buffer.test.ts +++ b/tests/unit/chatcore-client-usage-buffer.test.ts @@ -26,14 +26,14 @@ function makeDeps(overrides: Record = {}) { return { ...(u as object), _filtered: true }; }, ...overrides, - } as Parameters[3]; + } as Parameters[4]; return { deps, calls }; } test("usage present → buffer then filter, mutates in place", () => { const { deps, calls } = makeDeps(); const resp: Record = { usage: { prompt_tokens: 5 } }; - applyClientUsageBuffer(resp, { messages: [] }, "openai", deps); + applyClientUsageBuffer(resp, { messages: [] }, "openai", {}, deps); assert.equal(calls.buffer.length, 1); assert.equal(calls.estimate.length, 0); assert.equal((resp.usage as Record)._buffered, true); @@ -46,7 +46,7 @@ test("all-zero usage stub → estimate (not constant buffer-only 2000)", () => { usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, choices: [{ message: { content: "PONG" } }], }; - applyClientUsageBuffer(resp, { messages: [{ role: "user", content: "hi" }] }, "openai", deps); + applyClientUsageBuffer(resp, { messages: [{ role: "user", content: "hi" }] }, "openai", {}, deps); assert.equal(calls.buffer.length, 0, "must not buffer zeros into USAGE_TOKEN_BUFFER"); assert.equal(calls.estimate.length, 1); assert.equal((resp.usage as Record)._estimated, true); @@ -57,7 +57,7 @@ test("no usage but content present → estimate then filter", () => { const resp: Record = { choices: [{ message: { content: "hello world" } }], }; - applyClientUsageBuffer(resp, { messages: [] }, "openai", deps); + applyClientUsageBuffer(resp, { messages: [] }, "openai", {}, deps); assert.equal(calls.buffer.length, 0); assert.equal(calls.estimate.length, 1); assert.equal((resp.usage as Record)._estimated, true); @@ -71,7 +71,7 @@ test("no usage but content present → estimate then filter", () => { test("empty content → JSON.stringify('') length 2 > 0 still estimates", () => { const { deps, calls } = makeDeps(); const resp: Record = {}; - applyClientUsageBuffer(resp, {}, "claude", deps); + applyClientUsageBuffer(resp, {}, "claude", {}, deps); // content "" → JSON.stringify("") = '""' length 2 → contentLength 2 > 0 assert.equal(calls.estimate.length, 1); const args = calls.estimate[0] as unknown[]; @@ -83,8 +83,51 @@ test("content length is computed from choices[0].message.content", () => { const resp: Record = { choices: [{ message: { content: "abc" } }], }; - applyClientUsageBuffer(resp, {}, "openai", deps); + applyClientUsageBuffer(resp, {}, "openai", {}, deps); // JSON.stringify("abc") = '"abc"' → length 5 const args = calls.estimate[0] as unknown[]; assert.equal(args[1], 5); }); + +// #8331/#8356 added the `options` parameter between `clientResponseFormat` and `deps`, +// which is what silently broke the five call sites above (the injected spies landed in +// the `options` slot, so the real implementations ran and no spy was ever recorded). +// Cover the option itself so the new parameter is exercised, not just tolerated. + +test("preserveContextBudgetInVisibleUsage folds context_budget_* back into visible fields", () => { + const { deps, calls } = makeDeps({ + addBufferToUsage: (u: unknown) => ({ + ...(u as object), + context_budget_prompt_tokens: 2005, + context_budget_input_tokens: 2005, + context_budget_total_tokens: 2010, + }), + }); + const resp: Record = { + usage: { prompt_tokens: 5, input_tokens: 5, total_tokens: 10 }, + }; + + applyClientUsageBuffer(resp, { messages: [] }, "openai", { + preserveContextBudgetInVisibleUsage: true, + }, deps); + + const filtered = calls.filter[0] as Record; + assert.equal(filtered.prompt_tokens, 2005, "Claude-Code path re-folds the buffered value"); + assert.equal(filtered.input_tokens, 2005); + assert.equal(filtered.total_tokens, 2010); +}); + +test("without the option the visible usage keeps the real unbuffered #8331 numbers", () => { + const { deps, calls } = makeDeps({ + addBufferToUsage: (u: unknown) => ({ + ...(u as object), + context_budget_prompt_tokens: 2005, + }), + }); + const resp: Record = { usage: { prompt_tokens: 5 } }; + + applyClientUsageBuffer(resp, { messages: [] }, "openai", {}, deps); + + const filtered = calls.filter[0] as Record; + assert.equal(filtered.prompt_tokens, 5, "default path must not inflate client-visible metering"); +}); diff --git a/tests/unit/check-db-rules-classification.test.ts b/tests/unit/check-db-rules-classification.test.ts index d04935e71e..3c22485d1c 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 36 audited modules", () => { +test("INTENTIONALLY_INTERNAL contains the expected 37 audited modules", () => { const expected = [ "_rowTypes", "accessTokens", @@ -133,6 +133,7 @@ test("INTENTIONALLY_INTERNAL contains the expected 36 audited modules", () => { "comboForecast", "commandCodeAuth", "compression", + "compressionDetailNormalizers", "detailedLogs", "discovery", "domainState", diff --git a/tests/unit/claude-adaptive-sampling-params.test.ts b/tests/unit/claude-adaptive-sampling-params.test.ts index 753d019490..d26e4952e8 100644 --- a/tests/unit/claude-adaptive-sampling-params.test.ts +++ b/tests/unit/claude-adaptive-sampling-params.test.ts @@ -1,5 +1,5 @@ /** - * Claude Opus 4.7+/Fable 5 sampling-param strip + adaptive-only flag. + * Claude Opus 4.7+/Opus 5/Fable 5 sampling-param strip + adaptive-only flag. * * Anthropic's Opus 4.7+ generation rejects non-default `temperature`/`top_p`/`top_k` with a * 400 (sampling is fixed; reasoning is steered by output_config.effort). These tests pin both @@ -15,7 +15,7 @@ import { isAdaptiveThinkingOnly } from "../../src/shared/constants/modelSpecs.ts const SAMPLING = ["temperature", "top_p", "top_k"]; test("claude registry strips temperature/top_p/top_k for Opus 4.7+/Fable 5", () => { - for (const model of ["claude-opus-4-8", "claude-opus-4-7", "claude-fable-5"]) { + for (const model of ["claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-fable-5"]) { const unsupported = getUnsupportedParams("claude", model); for (const param of SAMPLING) { assert.ok( @@ -26,10 +26,12 @@ test("claude registry strips temperature/top_p/top_k for Opus 4.7+/Fable 5", () } }); -test("anthropic registry (dotted ids) strips sampling params for Opus 4.7", () => { - const unsupported = getUnsupportedParams("anthropic", "claude-opus-4.7"); - for (const param of SAMPLING) { - assert.ok(unsupported.includes(param), `claude-opus-4.7 must list ${param} as unsupported`); +test("anthropic registry strips sampling params for current adaptive Opus models", () => { + for (const model of ["claude-opus-5", "claude-opus-4.8", "claude-opus-4.7"]) { + const unsupported = getUnsupportedParams("anthropic", model); + for (const param of SAMPLING) { + assert.ok(unsupported.includes(param), `${model} must list ${param} as unsupported`); + } } }); @@ -52,7 +54,7 @@ test("pre-4.7 Claude models still accept sampling params (regression guard)", () }); test("isAdaptiveThinkingOnly is true only for Opus 4.7+/Fable 5", () => { - for (const model of ["claude-opus-4-8", "claude-opus-4-7", "claude-fable-5"]) { + for (const model of ["claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-fable-5"]) { assert.equal(isAdaptiveThinkingOnly(model), true, `${model} is adaptive-only`); } for (const model of [ @@ -68,5 +70,6 @@ test("isAdaptiveThinkingOnly is true only for Opus 4.7+/Fable 5", () => { }); test("isAdaptiveThinkingOnly resolves Bedrock/dated aliases", () => { + assert.equal(isAdaptiveThinkingOnly("anthropic.claude-opus-5"), true); assert.equal(isAdaptiveThinkingOnly("anthropic.claude-opus-4-8"), true); }); diff --git a/tests/unit/claude-adaptive-thinking-normalize.test.ts b/tests/unit/claude-adaptive-thinking-normalize.test.ts index d7f9c67c7c..ce594204b9 100644 --- a/tests/unit/claude-adaptive-thinking-normalize.test.ts +++ b/tests/unit/claude-adaptive-thinking-normalize.test.ts @@ -2,14 +2,17 @@ * Claude adaptive-thinking normalization — `normalizeClaudeAdaptiveThinking`. * * Claude Opus 4.7+/Fable 5 removed manual extended thinking: `thinking.type:"enabled"` and - * any `thinking.budget_tokens` return HTTP 400 (Anthropic migration guide, 2026-05-19). + * any `thinking.budget_tokens` return HTTP 400. * These tests pin the final guard that collapses any manual thinking that reached the * dispatch point to `{type:"adaptive"}`, while leaving non-adaptive-only models and * already-adaptive bodies untouched. */ import test from "node:test"; import assert from "node:assert/strict"; -import { normalizeClaudeAdaptiveThinking } from "../../open-sse/services/claudeAdaptiveThinking.ts"; +import { + normalizeClaudeAdaptiveThinking, + normalizeClaudeDisabledThinkingEffort, +} from "../../open-sse/services/claudeAdaptiveThinking.ts"; test("manual thinking:{type:'enabled', budget_tokens} → adaptive, budget dropped (Opus 4.8)", () => { const body = { @@ -37,6 +40,51 @@ test("type:'enabled' with no budget still flips to adaptive (manual mode is gone assert.deepEqual(result.thinking, { type: "adaptive" }); }); +test("Opus 5 manual thinking is adaptive and drops fixed budgets", () => { + const body = { + model: "claude-opus-5", + messages: [], + thinking: { type: "enabled", budget_tokens: 64000 }, + }; + const result = normalizeClaudeAdaptiveThinking(body, "claude-opus-5"); + assert.deepEqual(result.thinking, { type: "adaptive" }); +}); + +test("direct Anthropic API providers clamp Opus 5 disabled thinking to high effort", () => { + for (const provider of ["anthropic", "claude"]) { + for (const effort of ["xhigh", "max"]) { + const body = { + model: "claude-opus-5", + thinking: { type: "disabled" }, + output_config: { effort, format: "compact" }, + }; + const result = normalizeClaudeDisabledThinkingEffort(body, "claude-opus-5", provider); + assert.deepEqual(result.thinking, { type: "disabled" }); + assert.deepEqual(result.output_config, { effort: "high", format: "compact" }); + } + } +}); + +test("GitHub Copilot and Claude Web do not inherit the Anthropic API effort cap", () => { + for (const provider of ["github", "claude-web"]) { + const body = { + model: "claude-opus-5", + thinking: { type: "disabled" }, + output_config: { effort: "max" }, + }; + assert.equal(normalizeClaudeDisabledThinkingEffort(body, "claude-opus-5", provider), body); + } +}); + +test("direct Anthropic API leaves Opus 5 disabled thinking at high effort untouched", () => { + const body = { + model: "claude-opus-5", + thinking: { type: "disabled" }, + output_config: { effort: "high" }, + }; + assert.equal(normalizeClaudeDisabledThinkingEffort(body, "claude-opus-5", "anthropic"), body); +}); + test("thinking:{type:'adaptive'} is returned UNTOUCHED (same reference)", () => { const body = { model: "claude-opus-4-8", messages: [], thinking: { type: "adaptive" } }; const result = normalizeClaudeAdaptiveThinking(body, "claude-opus-4-8"); diff --git a/tests/unit/claude-beta-flags-2454.test.ts b/tests/unit/claude-beta-flags-2454.test.ts index d38f194af6..75fd35c402 100644 --- a/tests/unit/claude-beta-flags-2454.test.ts +++ b/tests/unit/claude-beta-flags-2454.test.ts @@ -41,13 +41,25 @@ test("#2454 Sonnet full-agent includes heavy-agent flags but omits context-1m", assert.ok(flags.includes("thinking-token-count-2026-05-13")); assert.ok(flags.includes("redact-thinking-2026-02-12"), "Sonnet sends redact-thinking"); assert.ok(!flags.includes("afk-mode-2026-01-31"), "afk-mode removed — not in any CC capture"); - assert.ok(!flags.includes("mid-conversation-system-2026-04-07"), "Sonnet must NOT receive mid-conversation-system"); + assert.ok( + !flags.includes("mid-conversation-system-2026-04-07"), + "Sonnet must NOT receive mid-conversation-system" + ); }); test("#2454 Opus full-agent includes context-1m and mid-conversation-system", () => { const flags = selectBetaFlags(fullAgentBody("claude-opus-4-7")); assert.ok(flags.includes("context-1m-2025-08-07"), "Opus should receive context-1m"); - assert.ok(flags.includes("mid-conversation-system-2026-04-07"), "Opus should receive mid-conversation-system"); + assert.ok( + flags.includes("mid-conversation-system-2026-04-07"), + "Opus should receive mid-conversation-system" + ); +}); + +test("Opus 5 full-agent omits the legacy context-1m beta", () => { + const flags = selectBetaFlags(fullAgentBody("claude-opus-5")); + assert.ok(!flags.includes("context-1m-2025-08-07")); + assert.ok(flags.includes("mid-conversation-system-2026-04-07")); }); test("#2454 explicit model arg overrides body.model for tiering", () => { diff --git a/tests/unit/claude-cli-defaults.test.ts b/tests/unit/claude-cli-defaults.test.ts index 552b69b712..244fa715a3 100644 --- a/tests/unit/claude-cli-defaults.test.ts +++ b/tests/unit/claude-cli-defaults.test.ts @@ -6,11 +6,15 @@ test("getClaudeCodeDefaultModels returns expected default models", () => { const models = getClaudeCodeDefaultModels(); // They should be non-empty strings because providerRegistry is populated statically + assert.ok(typeof models.fable === "string"); assert.ok(typeof models.opus === "string"); assert.ok(typeof models.sonnet === "string"); assert.ok(typeof models.haiku === "string"); // Check that the returned IDs match the expected patterns + if (models.fable) { + assert.match(models.fable, /fable/i); + } if (models.opus) { assert.match(models.opus, /opus/i); } diff --git a/tests/unit/claude-codex-identity-version-sync.test.ts b/tests/unit/claude-codex-identity-version-sync.test.ts index cd64c72506..756b7faeae 100644 --- a/tests/unit/claude-codex-identity-version-sync.test.ts +++ b/tests/unit/claude-codex-identity-version-sync.test.ts @@ -18,9 +18,11 @@ const hdr = await import("../../open-sse/config/anthropicHeaders.ts"); const compat = await import("../../open-sse/services/claudeCodeCompatible.ts"); const bridge = await import("../../open-sse/services/ccBridgeTransforms.ts"); const codexCfg = await import("../../open-sse/config/codexClient.ts"); +const canonical = await import("../../src/shared/constants/claudeCodeClient.ts"); test("Claude CLI version constants are in lockstep across all 4 sources", () => { - const V = id.CLAUDE_CODE_VERSION; + const V = canonical.CLAUDE_CODE_CLIENT_VERSION; + assert.equal(id.CLAUDE_CODE_VERSION, V, "claudeIdentity.CLAUDE_CODE_VERSION drift"); assert.equal(hdr.CLAUDE_CLI_VERSION, V, "anthropicHeaders.CLAUDE_CLI_VERSION drift"); assert.equal(compat.CLAUDE_CODE_COMPATIBLE_VERSION, V, "claudeCodeCompatible version drift"); assert.equal(bridge.DEFAULT_CLAUDE_CODE_VERSION, V, "ccBridgeTransforms version drift"); @@ -36,8 +38,23 @@ test("Claude CLI version constants are in lockstep across all 4 sources", () => ); }); -test("Claude CLI is pinned to the captured 2.1.207 release", () => { - assert.equal(id.CLAUDE_CODE_VERSION, "2.1.207"); +test("Claude CLI wire versions match the captured 2.1.219 binary", () => { + assert.equal(canonical.CLAUDE_CODE_CLIENT_VERSION, "2.1.219"); + assert.equal(canonical.CLAUDE_CODE_CLIENT_BUILD_REVISION, "250"); + assert.equal(canonical.CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.219.250"); + assert.equal(canonical.CLAUDE_CODE_SDK_PACKAGE_VERSION, "0.94.0"); + assert.equal(canonical.CLAUDE_CODE_RUNTIME_VERSION, "v26.3.0"); + assert.equal( + compat.CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION, + canonical.CLAUDE_CODE_SDK_PACKAGE_VERSION + ); + assert.equal( + compat.CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION, + canonical.CLAUDE_CODE_RUNTIME_VERSION + ); + assert.equal(hdr.CLAUDE_CLI_STAINLESS_PACKAGE_VERSION, canonical.CLAUDE_CODE_SDK_PACKAGE_VERSION); + assert.equal(hdr.CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, canonical.CLAUDE_CODE_RUNTIME_VERSION); + assert.equal(hdr.CLAUDE_CLI_BILLING_VERSION, canonical.CLAUDE_CODE_CLIENT_BILLING_VERSION); }); test("Codex client is pinned to the captured 0.144.1 release", () => { diff --git a/tests/unit/claude-context-1m-supported-models.test.ts b/tests/unit/claude-context-1m-supported-models.test.ts index dbac4c55e2..202f90ba63 100644 --- a/tests/unit/claude-context-1m-supported-models.test.ts +++ b/tests/unit/claude-context-1m-supported-models.test.ts @@ -5,18 +5,19 @@ import path from "node:path"; const REPO_ROOT = path.resolve(import.meta.dirname, "../.."); -// ─── Parse CONTEXT_1M_SUPPORTED_MODELS from source (no module import needed) ─── +// ─── Parse 1M model lists from source (no module import needed) ─── -function parseSupportedModels(): string[] { - const src = fs.readFileSync( - path.join(REPO_ROOT, "open-sse/services/claudeCodeCompatible.ts"), - "utf8" - ); +function parseModelList(constantName: string): string[] { + const sourceFile = + constantName === "CONTEXT_1M_NATIVE_MODELS" + ? "open-sse/config/claudeCodeCompatibleIdentity.ts" + : "open-sse/services/claudeCodeCompatible.ts"; + const src = fs.readFileSync(path.join(REPO_ROOT, sourceFile), "utf8"); // Strip type annotations before matching to handle `const X: string[] = [...]` const match = src .replace(/:\s*\w+(\[\])?/, "") - .match(/CONTEXT_1M_SUPPORTED_MODELS\s*=\s*\[([\s\S]*?)\]/); - if (!match) throw new Error("CONTEXT_1M_SUPPORTED_MODELS not found in source"); + .match(new RegExp(`${constantName}\\s*=\\s*\\[([\\s\\S]*?)\\]`)); + if (!match) throw new Error(`${constantName} not found in source`); return match[1] .split(",") .map((s) => s.replace(/["'\s]/g, "").toLowerCase()) @@ -107,8 +108,11 @@ test("parser finds at least one Claude model in the registry", () => { // ─── Forward: every high-context Claude model must be in the allowlist ─── -test("every Claude model with contextLength > 200K is in CONTEXT_1M_SUPPORTED_MODELS", () => { - const supportedModels = parseSupportedModels(); +test("every Claude model with contextLength > 200K is in a known 1M model list", () => { + const supportedModels = [ + ...parseModelList("CONTEXT_1M_SUPPORTED_MODELS"), + ...parseModelList("CONTEXT_1M_NATIVE_MODELS"), + ]; const claudeModels = parseClaudeRegistryModels(); const violations: string[] = []; @@ -121,16 +125,19 @@ test("every Claude model with contextLength > 200K is in CONTEXT_1M_SUPPORTED_MO assert.deepEqual( violations, [], - `Claude models with contextLength > 200K missing from CONTEXT_1M_SUPPORTED_MODELS.\n` + - `Add the model prefix to the allowlist in claudeCodeCompatible.ts.\n` + + `Claude models with contextLength > 200K missing from the beta/native 1M lists.\n` + + `Add the model prefix to the correct source list.\n` + `Violations:\n ${violations.join("\n ")}` ); }); // ─── Reverse: every allowlist entry must have a matching high-context model ─── -test("every CONTEXT_1M_SUPPORTED_MODELS entry has a matching Claude model with contextLength > 200K", () => { - const supportedModels = parseSupportedModels(); +test("every known 1M model entry has a matching high-context Claude model", () => { + const supportedModels = [ + ...parseModelList("CONTEXT_1M_SUPPORTED_MODELS"), + ...parseModelList("CONTEXT_1M_NATIVE_MODELS"), + ]; const claudeModels = parseClaudeRegistryModels(); const orphans: string[] = []; @@ -146,8 +153,13 @@ test("every CONTEXT_1M_SUPPORTED_MODELS entry has a matching Claude model with c assert.deepEqual( orphans, [], - `CONTEXT_1M_SUPPORTED_MODELS entries with no matching high-context Claude model.\n` + + `Known 1M entries with no matching high-context Claude model.\n` + `Remove stale entries or check model id spelling.\n` + `Orphans:\n ${orphans.join("\n ")}` ); }); + +test("Claude Opus 5 uses native 1M context without the legacy beta header", () => { + assert.equal(parseModelList("CONTEXT_1M_SUPPORTED_MODELS").includes("claude-opus-5"), false); + assert.equal(parseModelList("CONTEXT_1M_NATIVE_MODELS").includes("claude-opus-5"), true); +}); diff --git a/tests/unit/claude-fast-mode.test.ts b/tests/unit/claude-fast-mode.test.ts index 92b6be05e2..ad545f222a 100644 --- a/tests/unit/claude-fast-mode.test.ts +++ b/tests/unit/claude-fast-mode.test.ts @@ -27,6 +27,10 @@ test("shouldRequestClaudeFastMode returns true for claude-opus-4-8 exact match", assert.equal(shouldRequestClaudeFastMode(enabledSettings, "claude-opus-4-8"), true); }); +test("shouldRequestClaudeFastMode returns true for claude-opus-5", () => { + assert.equal(shouldRequestClaudeFastMode(enabledSettings, "claude-opus-5"), true); +}); + test("shouldRequestClaudeFastMode prefix-matches claude-opus-4-8 with dated suffix", () => { assert.equal(shouldRequestClaudeFastMode(enabledSettings, "claude-opus-4-8-20260528"), true); assert.equal(shouldRequestClaudeFastMode(enabledSettings, "claude-opus-4-8-20260101"), true); @@ -45,6 +49,10 @@ test("shouldRequestClaudeFastMode returns false for non-Opus models", () => { }); test("CLAUDE_FAST_MODE_DEFAULT_MODELS includes claude-opus-4-8", () => { + assert.ok( + CLAUDE_FAST_MODE_DEFAULT_MODELS.includes("claude-opus-5"), + "claude-opus-5 must be in CLAUDE_FAST_MODE_DEFAULT_MODELS" + ); assert.ok( CLAUDE_FAST_MODE_DEFAULT_MODELS.includes("claude-opus-4-8"), "claude-opus-4-8 must be in CLAUDE_FAST_MODE_DEFAULT_MODELS" @@ -53,6 +61,7 @@ test("CLAUDE_FAST_MODE_DEFAULT_MODELS includes claude-opus-4-8", () => { test("getClaudeFastModeSupportedModels returns default list when none configured", () => { const models = getClaudeFastModeSupportedModels({}); + assert.ok(models.includes("claude-opus-5")); assert.ok(models.includes("claude-opus-4-8")); assert.ok(models.includes("claude-opus-4-7")); assert.ok(models.includes("claude-opus-4-6")); diff --git a/tests/unit/claude-identity-version-sync.test.ts b/tests/unit/claude-identity-version-sync.test.ts index b695826fc7..91fa3fce1d 100644 --- a/tests/unit/claude-identity-version-sync.test.ts +++ b/tests/unit/claude-identity-version-sync.test.ts @@ -1,10 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -// Claude-Code identity version is hand-bumped in lockstep across several modules -// (2.1.158 → .187 → .195 → .207 …). A silent partial bump makes one surface advertise a stale -// `claude-cli/` and can break Anthropic identity gating. This guard fails on drift. -// (quota-share-hardening Phase 2 — gaps v3.8.42.) +// Claude-Code identity aliases are consumed across several transports. They now +// resolve through one captured profile; this guard protects the compatibility +// exports and their generated User-Agent strings from drift. const claudeIdentity = await import("../../open-sse/executors/claudeIdentity.ts"); const ccBridge = await import("../../open-sse/services/ccBridgeTransforms.ts"); const claudeCompat = await import("../../open-sse/services/claudeCodeCompatible.ts"); @@ -13,7 +12,7 @@ const glmProvider = await import("../../open-sse/config/glmProvider.ts"); const CANONICAL = claudeIdentity.CLAUDE_CODE_VERSION; -// "claude-cli/2.1.207 (external, sdk-cli)" → "2.1.207". String ops only — never a RegExp over +// "claude-cli/2.1.219 (external, sdk-cli)" → "2.1.219". String ops only — never a RegExp over // the value, per the project's anti-ReDoS contract. function versionFromUserAgent(userAgent: string): string { const afterSlash = userAgent.split("claude-cli/")[1] ?? ""; diff --git a/tests/unit/claude-to-openai-think-close-5123.test.ts b/tests/unit/claude-to-openai-think-close-5123.test.ts index 545bb5a70b..9bf84af088 100644 --- a/tests/unit/claude-to-openai-think-close-5123.test.ts +++ b/tests/unit/claude-to-openai-think-close-5123.test.ts @@ -25,8 +25,13 @@ function newState() { }; } -function collectChunks(results: ReturnType[]): unknown[] { - return results.flatMap((r) => (Array.isArray(r) ? r : r ? [r] : [])); +/** Minimal shape these assertions read off a translated SSE chunk. */ +type StreamChunk = { choices?: Array<{ delta?: { content?: unknown } }> }; + +function collectChunks(results: ReturnType[]): StreamChunk[] { + // The translator returns a chunk, an array of chunks, or nothing; narrow once here so + // the call sites below can read `choices[0].delta.content` without per-callback casts. + return results.flatMap((r) => (Array.isArray(r) ? r : r ? [r] : [])) as StreamChunk[]; } // ─── Case (a): thinking block followed by tool_use ─────────────────────────── @@ -95,7 +100,7 @@ test("thinking block followed by tool_use: must NOT appear in any conte const chunks = collectChunks(allResults); const spuriousThinkClose = chunks.filter( - (chunk: any) => chunk?.choices?.[0]?.delta?.content === "" + (chunk) => chunk?.choices?.[0]?.delta?.content === "" ); assert.equal( @@ -168,7 +173,7 @@ test("thinking block followed by text: emitted when suppressThinkClose= const chunks = collectChunks(allResults); const hasThinkClose = chunks.some( - (chunk: any) => chunk?.choices?.[0]?.delta?.content === "" + (chunk) => chunk?.choices?.[0]?.delta?.content === "" ); assert.ok( @@ -217,9 +222,9 @@ test("thinking block followed by text: suppressed when suppressThinkClo const chunks = collectChunks(allResults); const hasThinkClose = chunks.some( - (chunk: any) => chunk?.choices?.[0]?.delta?.content === "" + (chunk) => chunk?.choices?.[0]?.delta?.content === "" ); assert.equal(hasThinkClose, false, "marker must not leak into content under #8245 default"); - const hasText = chunks.some((chunk: any) => chunk?.choices?.[0]?.delta?.content === "Hello!"); + const hasText = chunks.some((chunk) => chunk?.choices?.[0]?.delta?.content === "Hello!"); assert.ok(hasText, "assistant text must still be emitted"); }); diff --git a/tests/unit/claude-web-live-alignment.test.ts b/tests/unit/claude-web-live-alignment.test.ts index 5f943e5551..18c4d26127 100644 --- a/tests/unit/claude-web-live-alignment.test.ts +++ b/tests/unit/claude-web-live-alignment.test.ts @@ -48,6 +48,39 @@ afterEach(() => { }); describe("Claude Web live request alignment", () => { + it("uses the current Claude Web Opus 5 high/auto defaults", () => { + const payload = transformToClaude( + { messages: [{ role: "user", content: "Hello" }] }, + "claude-opus-5" + ); + + assert.equal(payload.effort, "high"); + assert.equal(payload.thinking_mode, "auto"); + }); + + it("keeps Opus 5 thinking on auto when the caller selects an effort", () => { + const payload = transformToClaude( + { + messages: [{ role: "user", content: "Think carefully" }], + reasoning_effort: "max", + }, + "claude-opus-5" + ); + + assert.equal(payload.effort, "max"); + assert.equal(payload.thinking_mode, "auto"); + }); + + it("does not apply the Opus 5 thinking contract to other Claude Web models", () => { + const payload = transformToClaude( + { messages: [{ role: "user", content: "Hello" }] }, + "claude-sonnet-5" + ); + + assert.equal(payload.effort, "low"); + assert.equal(payload.thinking_mode, "off"); + }); + it("maps an explicit reasoning effort to Claude Web extended thinking", () => { const payload = transformToClaude( { diff --git a/tests/unit/claude-web-slow-first-byte.test.ts b/tests/unit/claude-web-slow-first-byte.test.ts new file mode 100644 index 0000000000..7a67e74151 --- /dev/null +++ b/tests/unit/claude-web-slow-first-byte.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { writeFile } from "node:fs/promises"; +import test from "node:test"; + +import { tlsFetchStreaming } from "../../open-sse/services/claudeTlsClient.ts"; + +const SLOW_FIRST_BYTE_MS = 5_100; +const SSE_BODY = [ + 'event: message_start\ndata: {"type":"message_start"}', + 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"OK"}}', + 'event: message_stop\ndata: {"type":"message_stop"}', + "data: [DONE]", + "", +].join("\n\n"); + +test("Claude Web keeps waiting when the first Opus SSE event takes longer than five seconds", async () => { + const client = { + request: async (_url: string, options: Record) => { + await new Promise((resolve) => setTimeout(resolve, SLOW_FIRST_BYTE_MS)); + await writeFile(String(options.streamOutputPath), SSE_BODY); + return { + status: 200, + headers: {}, + body: "", + cookies: {}, + text: async () => "", + json: async () => ({}), + bytes: async () => new Uint8Array(), + }; + }, + }; + + const result = await tlsFetchStreaming( + client, + "https://claude.ai/api/organizations/x/chat_conversations/y/completion", + { method: "POST" }, + "[DONE]", + null, + 7_000 + ); + + assert.equal(result.status, 200); + assert.ok(result.body); + assert.match(await new Response(result.body).text(), /"text":"OK"/); +}); diff --git a/tests/unit/claude-web-sonnet5-registry-6209.test.ts b/tests/unit/claude-web-sonnet5-registry-6209.test.ts index b449cd232e..9930a04ae2 100644 --- a/tests/unit/claude-web-sonnet5-registry-6209.test.ts +++ b/tests/unit/claude-web-sonnet5-registry-6209.test.ts @@ -11,6 +11,7 @@ test("claude-web registry matches the current selectable model set", () => { [ "claude-fable-5", "claude-haiku-4-5-20251001", + "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", diff --git a/tests/unit/claude-web-stream.test.ts b/tests/unit/claude-web-stream.test.ts index 477643764b..f09162ef9c 100644 --- a/tests/unit/claude-web-stream.test.ts +++ b/tests/unit/claude-web-stream.test.ts @@ -40,7 +40,10 @@ function validEvents(): Array> { { type: "content_block_delta", index: 0, - delta: { type: "thinking_summary_delta", summary: "summary" }, + delta: { + type: "thinking_summary_delta", + summary: { summary: "summary" }, + }, }, { type: "content_block_stop", index: 0 }, { type: "content_block_start", index: 1, content_block: { type: "text" } }, diff --git a/tests/unit/cli-expanded-commands.test.ts b/tests/unit/cli-expanded-commands.test.ts index 18ae9003af..74bfa2711e 100644 --- a/tests/unit/cli-expanded-commands.test.ts +++ b/tests/unit/cli-expanded-commands.test.ts @@ -82,6 +82,117 @@ test("backup auto status sem arquivo retorna 0", async () => { } }); +// Regressão #8512: `backup` re-declara os mesmos nomes de opção que `create`/ +// `auto enable` no fallback legacy ("omniroute backup" sem subcomando) — o +// Commander resolve a opção no ancestral mais próximo que a declara, então o +// valor do subcomando é descartado silenciosamente e substituído pelo default +// da própria opção do subcomando (null/false/[]). +test("backup auto enable — nenhuma opção é sombreada pelo parent backup", async () => { + const { registerBackup } = await import("../../bin/cli/commands/backup.mjs"); + const { Command } = await import("commander"); + const { mkdtempSync, readFileSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + + const dataDir = mkdtempSync(join(tmpdir(), "omniroute-backup-test-")); + const origDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = dataDir; + try { + const prog = new Command().exitOverride(); + registerBackup(prog); + await prog.parseAsync( + [ + "node", + "x", + "backup", + "auto", + "enable", + "--cron", + "0 4 * * *", + "--cloud", + "--encrypt", + "--retention", + "7", + ], + { from: "node" } + ); + const schedule = JSON.parse(readFileSync(join(dataDir, "backup-schedule.json"), "utf8")); + assert.equal(schedule.cron, "0 4 * * *"); + assert.equal(schedule.cloud, true, "--cloud não deve ser sombreado pelo parent backup"); + assert.equal(schedule.encrypt, true, "--encrypt não deve ser sombreado pelo parent backup"); + assert.equal(schedule.retention, 7, "--retention não deve ser sombreado pelo parent backup"); + } finally { + if (origDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = origDataDir; + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("backup create — nenhuma opção é sombreada pelo parent backup", async () => { + const { registerBackup } = await import("../../bin/cli/commands/backup.mjs"); + const { Command } = await import("commander"); + const prog = new Command().exitOverride(); + registerBackup(prog); + const backupCmd = prog.commands.find((c) => c.name() === "backup"); + const createCmd = backupCmd.commands.find((c) => c.name() === "create"); + let capturedOpts = null; + createCmd.action((opts) => { + capturedOpts = opts; + }); + await prog.parseAsync( + [ + "node", + "x", + "backup", + "create", + "--name", + "foo", + "--cloud", + "--encrypt", + "--retention", + "7", + "--exclude", + "*.log", + ], + { from: "node" } + ); + assert.ok(capturedOpts, "action deve ter sido chamada"); + assert.equal(capturedOpts.name, "foo", "--name não deve ser sombreado"); + assert.equal(capturedOpts.cloud, true, "--cloud não deve ser sombreado"); + assert.equal(capturedOpts.encrypt, true, "--encrypt não deve ser sombreado"); + assert.equal(capturedOpts.retention, 7, "--retention não deve ser sombreado"); + assert.deepEqual(capturedOpts.exclude, ["*.log"], "--exclude não deve ser sombreado"); +}); + +// Regressão de documentação: `omniroute backup` sem subcomando é o uso +// canônico documentado em USER_GUIDE.md / CLI-TOOLS.md / AGENT-SKILLS.md +// ("omniroute backup # Snapshot config + DB"). Remover só as opções +// duplicadas do parent (causa real do #8512) não pode remover essa ação. +test("backup — sem subcomando ainda cria um backup (uso legado documentado)", async () => { + const { registerBackup } = await import("../../bin/cli/commands/backup.mjs"); + const { Command } = await import("commander"); + const { mkdtempSync, existsSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + + const dataDir = mkdtempSync(join(tmpdir(), "omniroute-backup-bare-test-")); + const origDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = dataDir; + try { + const prog = new Command().exitOverride(); + registerBackup(prog); + await prog.parseAsync(["node", "x", "backup"], { from: "node" }); + assert.ok( + existsSync(join(dataDir, "backups")), + "omniroute backup deve criar o diretório de backups" + ); + } finally { + if (origDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = origDataDir; + rmSync(dataDir, { recursive: true, force: true }); + } +}); + test("tunnel — registerTunnel registra list/create/stop/status/logs/info/rotate", async () => { const { registerTunnel } = await import("../../bin/cli/commands/tunnel.mjs"); const { Command } = await import("commander"); diff --git a/tests/unit/cli-helper/config-generator.test.ts b/tests/unit/cli-helper/config-generator.test.ts index 36d1a2b785..ccc0989652 100644 --- a/tests/unit/cli-helper/config-generator.test.ts +++ b/tests/unit/cli-helper/config-generator.test.ts @@ -1,7 +1,34 @@ import { describe, it } from "node:test"; import assert from "node:assert"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import * as generator from "../../../src/lib/cli-helper/config-generator/index.ts"; +// The UI's HERMES_ROLES catalog (HermesAgentToolCard.tsx) is a "use client" component +// module — importing it in the Node test runner would pull in React/JSX. Instead we +// extract the id list straight from source text, which is enough to diff catalogs +// without executing the component. +function readUiHermesRoleIds(): string[] { + const uiFilePath = fileURLToPath( + new URL( + "../../../src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx", + import.meta.url + ) + ); + const source = readFileSync(uiFilePath, "utf-8"); + const arrayMatch = source.match(/const HERMES_ROLES: Role\[\] = \[([\s\S]*?)\n\];/); + assert.ok(arrayMatch, "could not locate HERMES_ROLES array in HermesAgentToolCard.tsx"); + const body = arrayMatch[1]; + return Array.from(body.matchAll(/id:\s*"([a-z0-9_]+)"/g)).map((m) => m[1]); +} + +function readEnMessages(): { cliTools?: Record } { + const enJsonPath = fileURLToPath( + new URL("../../../src/i18n/messages/en.json", import.meta.url) + ); + return JSON.parse(readFileSync(enJsonPath, "utf-8")); +} + describe("config-generator", () => { describe("validateBaseUrl", () => { it("accepts http URLs", async () => { @@ -133,6 +160,93 @@ describe("config-generator", () => { assert.ok(ids.includes("delegation")); assert.ok(ids.includes("vision")); assert.ok(ids.includes("approval")); + // Full catalog, including the 11 auxiliary roles added alongside HERMES_AGENT_ROLES + // (mcp, title_generation, memory_query_rewrite, tts_audio_tags, triage_specifier, + // kanban_decomposer, profile_describer, goal_judge, curator, monitor, + // background_review). Listed explicitly (not just parity-diffed against the UI + // below) so a role dropped from BOTH catalogs at once still fails this test. + const expectedIds = [ + "default", + "delegation", + "vision", + "web_extract", + "compression", + "skills_hub", + "approval", + "mcp", + "title_generation", + "memory_query_rewrite", + "tts_audio_tags", + "triage_specifier", + "kanban_decomposer", + "profile_describer", + "goal_judge", + "curator", + "monitor", + "background_review", + ]; + assert.deepStrictEqual([...ids].sort(), [...expectedIds].sort()); + }); + + it("keeps the backend HERMES_AGENT_ROLES catalog in sync with the UI's HERMES_ROLES catalog", async () => { + // The UI (HermesAgentToolCard.tsx) maintains its own parallel role catalog for + // rendering the role dropdowns. Nothing at the type level keeps the two catalogs + // in sync, so a role added to one and not the other would ship silently (the + // backend would accept a role the UI never offers, or the UI would offer a role + // the backend config generator doesn't know how to place in the YAML). Diffing + // the id lists turns that drift into a CI failure instead. + const hermesAgent = + await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts"); + const backendIds: string[] = hermesAgent.HERMES_AGENT_ROLES.map((r) => r.id); + const uiIds = readUiHermesRoleIds(); + + const missingFromUi = backendIds.filter((id) => !uiIds.includes(id)); + const missingFromBackend = uiIds.filter((id) => !backendIds.includes(id)); + + assert.deepStrictEqual( + missingFromUi, + [], + `role ids present in backend HERMES_AGENT_ROLES but missing from UI HERMES_ROLES: ${missingFromUi.join(", ")}` + ); + assert.deepStrictEqual( + missingFromBackend, + [], + `role ids present in UI HERMES_ROLES but missing from backend HERMES_AGENT_ROLES: ${missingFromBackend.join(", ")}` + ); + }); + + it("resolves labelKey/descriptionKey for every HERMES_AGENT_ROLES id in en.json's cliTools namespace", async () => { + // Generalizes the exact bug class the contributor had to hand-fix in their 2nd + // commit (missing vi/pt-BR translations for the new roles): every role's + // labelKey/descriptionKey must exist as a real key under cliTools in en.json, + // the source-of-truth locale, or the UI silently renders the raw key string. + const uiFilePath = fileURLToPath( + new URL( + "../../../src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx", + import.meta.url + ) + ); + const source = readFileSync(uiFilePath, "utf-8"); + const arrayMatch = source.match(/const HERMES_ROLES: Role\[\] = \[([\s\S]*?)\n\];/); + assert.ok(arrayMatch, "could not locate HERMES_ROLES array in HermesAgentToolCard.tsx"); + const body = arrayMatch[1]; + const roleEntries = Array.from( + body.matchAll(/id:\s*"([a-z0-9_]+)"[\s\S]*?labelKey:\s*"([A-Za-z0-9]+)"[\s\S]*?descriptionKey:\s*"([A-Za-z0-9]+)"/g) + ).map((m) => ({ id: m[1], labelKey: m[2], descriptionKey: m[3] })); + assert.ok(roleEntries.length > 0, "expected at least one role entry to be parsed"); + + const en = readEnMessages(); + const cliTools = en.cliTools || {}; + const missing: string[] = []; + for (const { id, labelKey, descriptionKey } of roleEntries) { + if (typeof cliTools[labelKey] !== "string") { + missing.push(`${id}: labelKey "${labelKey}"`); + } + if (typeof cliTools[descriptionKey] !== "string") { + missing.push(`${id}: descriptionKey "${descriptionKey}"`); + } + } + assert.deepStrictEqual(missing, [], `missing en.json cliTools keys: ${missing.join("; ")}`); }); it("getCurrentHermesAgentRoles returns an object", async () => { diff --git a/tests/unit/cli-setup-command.test.ts b/tests/unit/cli-setup-command.test.ts index abfc980052..365b8dc5c1 100644 --- a/tests/unit/cli-setup-command.test.ts +++ b/tests/unit/cli-setup-command.test.ts @@ -165,3 +165,78 @@ test("setup command can test provider and persist active status", async () => { assert.equal(provider.last_error, null); }); }); + +test("setup command reads the admin password from INITIAL_PASSWORD when --password is not set", async () => { + const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; + await withTempEnv(async (dataDir) => { + process.env.INITIAL_PASSWORD = "env-var-secret"; + + const loggedLines: string[] = []; + const originalConsoleLog = console.log; + console.log = (...args: unknown[]) => { + loggedLines.push(args.map(String).join(" ")); + }; + + try { + const { runSetupCommand } = await import("../../bin/cli/commands/setup.mjs"); + + const exitCode = await runSetupCommand({ nonInteractive: true }); + + assert.equal(exitCode, 0); + + const db = new Database(path.join(dataDir, "storage.sqlite")); + const rows = db + .prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'") + .all() as Array<{ key: string; value: string }>; + const settings = Object.fromEntries(rows.map((row) => [row.key, JSON.parse(row.value)])); + db.close(); + + assert.equal(settings.requireLogin, true); + assert.equal(await bcrypt.compare("env-var-secret", settings.password as string), true); + + // The raw password must never be echoed to stdout while resolving/setting it. + assert.ok( + !loggedLines.some((line) => line.includes("env-var-secret")), + "INITIAL_PASSWORD value must not be logged during setup" + ); + } finally { + console.log = originalConsoleLog; + } + }); + if (ORIGINAL_INITIAL_PASSWORD === undefined) { + delete process.env.INITIAL_PASSWORD; + } else { + process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; + } +}); + +test("setup command prioritizes an explicit --password flag over INITIAL_PASSWORD", async () => { + const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; + await withTempEnv(async (dataDir) => { + process.env.INITIAL_PASSWORD = "env-var-should-lose"; + + const { runSetupCommand } = await import("../../bin/cli/commands/setup.mjs"); + + const exitCode = await runSetupCommand({ + nonInteractive: true, + password: "flag-should-win", + }); + + assert.equal(exitCode, 0); + + const db = new Database(path.join(dataDir, "storage.sqlite")); + const passwordRow = db + .prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'password'") + .get() as { value: string }; + db.close(); + + const storedHash = JSON.parse(passwordRow.value) as string; + assert.equal(await bcrypt.compare("flag-should-win", storedHash), true); + assert.equal(await bcrypt.compare("env-var-should-lose", storedHash), false); + }); + if (ORIGINAL_INITIAL_PASSWORD === undefined) { + delete process.env.INITIAL_PASSWORD; + } else { + process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; + } +}); diff --git a/tests/unit/cli-sqlite-no-fallback-7586.test.ts b/tests/unit/cli-sqlite-no-fallback-7586.test.ts new file mode 100644 index 0000000000..67541973da --- /dev/null +++ b/tests/unit/cli-sqlite-no-fallback-7586.test.ts @@ -0,0 +1,81 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { register } from "node:module"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +// #7586: `omniroute doctor`'s "Database" and "Storage/encryption" checks call +// readDatabaseHealth()/readEncryptedCredentialSamples() from bin/cli/sqlite.mjs, +// which — unlike the real server's driver cascade +// (src/lib/db/adapters/driverFactory.ts::tryOpenSync, which tries +// bun:sqlite -> better-sqlite3 -> node:sqlite) — used to have NO fallback beyond +// better-sqlite3. On any machine where better-sqlite3's native binary is +// unavailable (Windows without a prebuilt addon, per @jmaxdev's report), doctor +// would ALWAYS report "FAIL Database" / "FAIL Storage/encryption" even when the +// real server was perfectly healthy via its own resilient driver selection. +// +// This test simulates that exact machine by intercepting the ESM specifier +// "better-sqlite3" (the one `bin/cli/sqlite.mjs::loadSqlite()` imports) so it +// throws the same "Could not locate the bindings file" error jmax hit, then +// proves readDatabaseHealth() still succeeds by falling back to node:sqlite. +register( + "data:text/javascript," + + encodeURIComponent(` + export async function resolve(specifier, context, nextResolve) { + if (specifier === "better-sqlite3") { + return { + url: + "data:text/javascript," + + encodeURIComponent( + "throw new Error(" + + JSON.stringify( + "Could not locate the bindings file. Tried:\\n \\u2192 /fake/path/better_sqlite3.node" + ) + + ");" + ), + shortCircuit: true, + }; + } + return nextResolve(specifier, context); + } + `), + import.meta.url +); + +const { readDatabaseHealth } = await import("../../bin/cli/sqlite.mjs"); + +test("#7586: readDatabaseHealth() falls back to node:sqlite when better-sqlite3 is unavailable", async (t) => { + const dbPath = path.join(os.tmpdir(), `cli-sqlite-no-fallback-7586-${Date.now()}.sqlite`); + t.after(() => { + try { + fs.unlinkSync(dbPath); + } catch {} + }); + + // Seed a normal, healthy DB using node:sqlite directly — simulating what the + // ACTUAL server (which DOES have a node:sqlite fallback) would have created + // on a machine without a better-sqlite3 native binary. + const seed = new DatabaseSync(dbPath); + seed.exec( + "CREATE TABLE _omniroute_migrations (version TEXT PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT);" + + "INSERT INTO _omniroute_migrations (version, name, applied_at) VALUES ('001', 'initial_schema', datetime('now'));" + ); + seed.close(); + + // Exercise the CLI helper's own health check — the exact function + // `omniroute doctor` calls (bin/cli/commands/doctor.mjs:checkDatabase -> + // readDatabaseHealth). Before the fix this threw "better-sqlite3 is not + // installed..." even though the DB is perfectly healthy. + const result = await readDatabaseHealth(dbPath); + + assert.equal( + result.quickCheckValue, + "ok", + "readDatabaseHealth() should report a healthy DB via the node:sqlite fallback, " + + "not throw, when better-sqlite3 is unavailable (#7586)" + ); + assert.equal(result.hasMigrationTable, true); + assert.deepEqual(result.appliedMigrationVersions, ["001"]); +}); diff --git a/tests/unit/client-identity-profiles.test.ts b/tests/unit/client-identity-profiles.test.ts index d42e250d95..d592e68a61 100644 --- a/tests/unit/client-identity-profiles.test.ts +++ b/tests/unit/client-identity-profiles.test.ts @@ -39,7 +39,7 @@ test("getClientIdentityProfileHeaders: unknown profile id falls back to no heade test("getClientIdentityProfileHeaders: known CLI profiles expose their preset headers", () => { const claudeCli = getClientIdentityProfileHeaders("claude-cli"); - assert.equal(claudeCli["User-Agent"], "claude-cli/2.1.207 (external, cli)"); + assert.equal(claudeCli["User-Agent"], "claude-cli/2.1.219 (external, cli)"); assert.equal(claudeCli["X-App"], "cli"); const codexCli = getClientIdentityProfileHeaders("codex-cli"); @@ -55,7 +55,7 @@ test("getClientIdentityProfileHeaders: returns a fresh mutable copy (catalog sta headers["User-Agent"] = "tampered"; assert.equal( CLIENT_IDENTITY_PROFILES["claude-cli"].headers["User-Agent"], - "claude-cli/2.1.207 (external, cli)" + "claude-cli/2.1.219 (external, cli)" ); }); @@ -100,7 +100,7 @@ test("profile headers merged into customHeaders survive applyCustomHeaders sanit true ) as Record; - assert.equal(headers["User-Agent"], "claude-cli/2.1.207 (external, cli)"); + assert.equal(headers["User-Agent"], "claude-cli/2.1.219 (external, cli)"); assert.equal(headers["X-App"], "cli"); assert.equal(headers["Authorization"], "Bearer test-key"); }); diff --git a/tests/unit/combo-antigravity-missing-project-reset-8486.test.ts b/tests/unit/combo-antigravity-missing-project-reset-8486.test.ts new file mode 100644 index 0000000000..b645d309ac --- /dev/null +++ b/tests/unit/combo-antigravity-missing-project-reset-8486.test.ts @@ -0,0 +1,113 @@ +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-combo-8486-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-8486-test-secret"; + +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); + +const noop = () => {}; +const log = { info: noop, warn: noop, debug: noop, error: noop }; + +function makeCombo(models: string[]) { + return { + name: "test-combo-8486", + strategy: "priority", + models: models.map((m) => ({ model: m })), + }; +} + +async function runScenario(models: string[]) { + const longRetryAfterMs = (21 * 3600 + 47 * 60 + 32) * 1000; + const longRetryAfterIso = new Date(Date.now() + longRetryAfterMs).toISOString(); + + const missingProjectBody = { + error: { + message: + "Missing Google projectId for Antigravity account. Auto-discovery via loadCodeAssist " + + "found no Cloud Code project. Please reconnect OAuth in Providers → Antigravity (and " + + "ensure the Google account has completed Gemini Code Assist onboarding).", + type: "oauth_missing_project_id", + code: "missing_project_id", + }, + }; + + const modelsCalled: string[] = []; + const handleSingleModel = async (_body: unknown, modelStr: string) => { + modelsCalled.push(modelStr); + if (modelStr.includes("account-a")) { + return new Response( + JSON.stringify({ + error: { message: "Your quota will reset after 21h47m32s." }, + retryAfter: longRetryAfterIso, + }), + { status: 429, headers: { "Content-Type": "application/json" } } + ); + } + return new Response(JSON.stringify(missingProjectBody), { + status: 422, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await handleComboChat({ + body: { model: "test", messages: [{ role: "user", content: "hi" }] }, + combo: makeCombo(models), + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + + return { result, modelsCalled }; +} + +test("#8486 Part B: combo unavailableResponse must not attach an unrelated target's long retryAfter to the antigravity missing-projectId 422", async () => { + const { result, modelsCalled } = await runScenario([ + "antigravity/account-a-model", + "antigravity/account-b-model", + ]); + + assert.ok( + modelsCalled.some((m) => m.includes("account-a")) && + modelsCalled.some((m) => m.includes("account-b")), + `expected both targets to be tried, got: ${JSON.stringify(modelsCalled)}` + ); + + const text = await result.clone().text(); + + assert.ok( + !/reset after/i.test(text) || !/missing google projectid/i.test(text), + "a config-class antigravity error (missing_project_id, no retryAfter of its own) " + + "must not be decorated with an unrelated target's long retry-after window — " + + `got body: ${text}` + ); +}); + +test("#8486 Part B (reverse order): the config-class 422 must not swallow a genuinely rate-limited sibling's message either", async () => { + const { result, modelsCalled } = await runScenario([ + "antigravity/account-b-model", + "antigravity/account-a-model", + ]); + + assert.ok( + modelsCalled.some((m) => m.includes("account-a")) && + modelsCalled.some((m) => m.includes("account-b")), + `expected both targets to be tried, got: ${JSON.stringify(modelsCalled)}` + ); + + const text = await result.clone().text(); + + // The surfaced status/message pair must always originate from the SAME + // (last-attempted) target: here that's account-a (429, real retryAfter), + // so the response must carry ITS message and MAY carry its own retry-after + // — but must never resurrect the unrelated account-b 422 text alongside it. + assert.ok( + !/missing google projectid/i.test(text), + `expected the last target's (account-a, 429) own message, not the unrelated account-b 422 text — got body: ${text}` + ); +}); diff --git a/tests/unit/combo-attempt-body-isolation-7847.test.ts b/tests/unit/combo-attempt-body-isolation-7847.test.ts new file mode 100644 index 0000000000..6238baf8ff --- /dev/null +++ b/tests/unit/combo-attempt-body-isolation-7847.test.ts @@ -0,0 +1,243 @@ +// Target isolation for the combo attempt body (#7847). +// +// combo.ts deep-clones the request body per target (`attemptBody = structuredClone(body)`) so one +// target's mutations cannot reach the next. On a 3.05 MiB agent request that costs 9.53 MiB at +// only 3 targets — 3x the wire size — and it scales with the target count. +// +// A shallow per-target copy would give the same isolation for ~nothing, because every known +// in-place mutation on this path is a TOP-LEVEL SCALAR: +// combo.ts bodyRecord.max_tokens = ... (reasoning buffer) +// chatCore.ts body.model = ... (Background Task Redirection T41) +// +// These tests lock the isolation invariant itself, so the clone strategy can be changed +// underneath them without anyone having to trust a grep for mutation sites. +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-combo-cow-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const core = await import("../../src/lib/db/core.ts"); +const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts"); +const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); +const { resetAll: resetAllSemaphores } = + await import("../../open-sse/services/rateLimitSemaphore.ts"); +const { _resetAllDecks } = await import("../../src/shared/utils/shuffleDeck.ts"); +const { clearSessions } = await import("../../open-sse/services/sessionManager.ts"); + +function createLog() { + const entries: unknown[] = []; + const push = (level: string) => (tag: unknown, msg: unknown) => entries.push({ level, tag, msg }); + return { + info: push("info"), + warn: push("warn"), + error: push("error"), + debug: push("debug"), + entries, + }; +} + +const okResponse = () => + new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +const errResponse = (status: number) => + new Response(JSON.stringify({ error: { message: `Error ${status}` } }), { + status, + headers: { "content-type": "application/json" }, + }); + +/** A body with nested structure, so a shallow copy is visibly different from a deep clone. */ +function agentBody() { + return { + model: "openai/gpt-4o-mini", + max_tokens: 100, + messages: [ + { role: "user", content: "first" }, + { role: "assistant", content: "second" }, + ], + tools: [{ type: "function", function: { name: "t", parameters: { type: "object" } } }], + }; +} + +function deepFreeze(value: T): T { + if (value && typeof value === "object") { + Object.getOwnPropertyNames(value).forEach((k) => deepFreeze((value as never)[k])); + Object.freeze(value); + } + return value; +} + +const MODELS = ["openai/gpt-4o-mini", "claude/sonnet", "gemini/flash"]; + +function comboOf(strategy: string, name: string) { + return { + name, + strategy, + models: MODELS, + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }; +} + +test.beforeEach(() => { + resetAllComboMetrics(); + resetAllCircuitBreakers(); + resetAllSemaphores(); + _resetAllDecks(); + clearSessions(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; +}); + +// ── The invariant: one target's writes must never reach the next ────────────── +// The stub reproduces chatCore.ts's `body.model = model` (Background Task Redirection), +// which is the real downstream mutation this isolation exists to contain. +for (const strategy of ["priority", "fill-first", "round-robin"]) { + test(`${strategy}: a target's in-place write must not leak into the next target`, async () => { + const seen: { model: string; maxTokens: number }[] = []; + + await handleComboChat({ + body: agentBody(), + combo: comboOf(strategy, `cow-isolation-${strategy}`), + handleSingleModel: async (received: Record, modelStr: string) => { + seen.push({ + model: received.model as string, + maxTokens: received.max_tokens as number, + }); + // Simulate chatCore.ts:693 (`body.model = model`) and the reasoning buffer write. + received.model = `mutated-by-${modelStr}`; + received.max_tokens = 999; + return seen.length < MODELS.length ? errResponse(503) : okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.ok(seen.length >= 2, `expected at least 2 targets to run, got ${seen.length}`); + for (const [i, s] of seen.entries()) { + assert.equal( + s.model, + "openai/gpt-4o-mini", + `target ${i} received model "${s.model}" — a previous target's write leaked through` + ); + assert.equal( + s.maxTokens, + 100, + `target ${i} received max_tokens ${s.maxTokens} — a previous target's write leaked through` + ); + } + }); +} + +// ── The caller's body is an input, not scratch space ────────────────────────── +test("the caller's body object is never mutated by the combo loop", async () => { + const body = agentBody(); + const before = JSON.stringify(body); + + await handleComboChat({ + body, + combo: comboOf("priority", "cow-caller-body"), + handleSingleModel: async (received: Record) => { + received.model = "mutated"; + received.max_tokens = 1; + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(JSON.stringify(body), before, "handleComboChat must treat `body` as read-only"); +}); + +// ── The copy must stay shallow — that is the whole point ───────────────────── +test("the per-target copy shares the nested payload instead of deep-cloning it", async () => { + const body = agentBody(); + const received: Record[] = []; + + await handleComboChat({ + body, + combo: comboOf("priority", "cow-shallow"), + handleSingleModel: async (b: Record) => { + received.push(b); + return received.length < 2 ? errResponse(503) : okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.ok(received.length >= 2, "need at least two targets to compare"); + for (const [i, b] of received.entries()) { + assert.notEqual( + b, + body, + `target ${i} received the caller's object — isolation requires a copy` + ); + } + assert.notEqual(received[0], received[1], "each target needs its own top-level object"); + + // The memory property (#7847): `messages` is the expensive part of an agent request, and + // duplicating it per target is what cost 9.53 MiB at 3 targets. Every target must therefore + // point at the SAME array. (handleComboChat rebuilds the messages container once during + // setup, before the per-target loop, so this is not necessarily the caller's own array — + // what matters is that it is not rebuilt per target.) + assert.equal( + received[0].messages, + received[1].messages, + "targets got different messages arrays — the per-target copy must stay shallow (#7847)" + ); + assert.equal(received[0].tools, body.tools, "tools must be shared, not copied"); + + // And nothing deep-clones: the message objects themselves are still the caller's. + const first = (body.messages as unknown[])[0]; + assert.equal( + (received[0].messages as unknown[])[0], + first, + "message objects were cloned — nothing on this path mutates them, so they must be shared" + ); +}); + +// ── Freeze probe: nothing on the combo-owned path may write in place ────────── +// Scope note: handleSingleModel is stubbed, so this covers combo.ts's own handling of the body +// (compression, handoff injection, the reasoning-buffer write) — NOT chatCore or the executors. +// The isolation tests above are what cover a mutating downstream. +test("freeze probe: combo's own body handling performs no in-place writes", async () => { + const frozen = deepFreeze(agentBody()); + let threw: unknown = null; + + try { + await handleComboChat({ + body: frozen, + combo: comboOf("priority", "cow-freeze-probe"), + handleSingleModel: async () => okResponse(), + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + } catch (err) { + threw = err; + } + + assert.equal( + threw, + null, + `combo wrote to a frozen body: ${threw instanceof Error ? threw.message : String(threw)}` + ); +}); diff --git a/tests/unit/combo-config.test.ts b/tests/unit/combo-config.test.ts index 5545beebac..03888e5ec9 100644 --- a/tests/unit/combo-config.test.ts +++ b/tests/unit/combo-config.test.ts @@ -14,6 +14,9 @@ const { const { createComboSchema, updateComboDefaultsSchema } = await import("../../src/shared/validation/schemas.ts"); const { MAX_TIMER_TIMEOUT_MS } = await import("../../src/shared/utils/runtimeTimeouts.ts"); +const { ROUTING_STRATEGY_VALUES, INTERNAL_ROUTING_STRATEGY_VALUES } = + await import("../../src/shared/constants/routingStrategies.ts"); +const ALL_COMBO_STRATEGIES = [...ROUTING_STRATEGY_VALUES, ...INTERNAL_ROUTING_STRATEGY_VALUES]; test("getDefaultComboConfig returns a fresh copy of the defaults", () => { const first = getDefaultComboConfig(); @@ -321,40 +324,32 @@ test("resolveComboTargetTimeoutMs falls back to the saner combo default when uns assert.equal(resolveComboTargetTimeoutMs({}, 0, 120000), 0); }); -// #7360 follow-up: a "default" auto-strategy combo hitting Gemini TPM/RPM on both -// targets waits out cooldowns for up to comboCooldownWait.budgetMs (default 130s), but -// DEFAULT_COMBO_TARGET_TIMEOUT_MS (120s) is shorter — the per-target timeout was cutting -// the wait off early and returning a synthetic 524 instead of letting the wait finish. -test("isComboCooldownWaitEligible only engages for quota-share/auto with the feature enabled", () => { - assert.equal(isComboCooldownWaitEligible("auto", { enabled: true }), true); - assert.equal(isComboCooldownWaitEligible("quota-share", { enabled: true }), true); - assert.equal(isComboCooldownWaitEligible("auto", { enabled: false }), false); - assert.equal(isComboCooldownWaitEligible("fill-first", { enabled: true }), false); - assert.equal(isComboCooldownWaitEligible("priority", { enabled: true }), false); +// #7360 / #7301: any strategy with comboCooldownWait enabled waits out cooldowns for up +// to comboCooldownWait.budgetMs, so the per-target timeout floor must cover that budget +// (DEFAULT_COMBO_TARGET_TIMEOUT_MS alone would cut a long wait short into a 524). +test("isComboCooldownWaitEligible engages for every strategy when the feature is enabled", () => { + for (const strategy of ALL_COMBO_STRATEGIES) { + assert.equal(isComboCooldownWaitEligible(strategy, { enabled: true }), true); + assert.equal(isComboCooldownWaitEligible(strategy, { enabled: false }), false); + } }); -test("resolveComboTargetTimeoutMsForCombo raises the floor to cover the cooldown-wait budget for eligible strategies", () => { +test("resolveComboTargetTimeoutMsForCombo raises the floor to cover the cooldown-wait budget when enabled", () => { const comboCooldownWait = { enabled: true, budgetMs: 130000 }; + const floor = 130000 + COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS; + const disabled = { enabled: false, budgetMs: 130000 }; - // Wait-eligible strategy: floor is budget + buffer (150s), not the 120s default. - assert.equal( - resolveComboTargetTimeoutMsForCombo({}, 600000, "auto", comboCooldownWait), - 130000 + COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS - ); - assert.equal( - resolveComboTargetTimeoutMsForCombo({}, 600000, "quota-share", comboCooldownWait), - 130000 + COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS - ); - - // Not wait-eligible (wrong strategy, or feature disabled): unchanged 120s default. - assert.equal( - resolveComboTargetTimeoutMsForCombo({}, 600000, "fill-first", comboCooldownWait), - DEFAULT_COMBO_TARGET_TIMEOUT_MS - ); - assert.equal( - resolveComboTargetTimeoutMsForCombo({}, 600000, "auto", { enabled: false, budgetMs: 130000 }), - DEFAULT_COMBO_TARGET_TIMEOUT_MS - ); + // Feature on: floor is budget + buffer for every strategy; off: 120s default. + for (const strategy of ALL_COMBO_STRATEGIES) { + assert.equal( + resolveComboTargetTimeoutMsForCombo({}, 600000, strategy, comboCooldownWait), + floor + ); + assert.equal( + resolveComboTargetTimeoutMsForCombo({}, 600000, strategy, disabled), + DEFAULT_COMBO_TARGET_TIMEOUT_MS + ); + } // A small budget below the 120s default never lowers the floor. assert.equal( @@ -364,12 +359,7 @@ test("resolveComboTargetTimeoutMsForCombo raises the floor to cover the cooldown // Explicit per-combo targetTimeoutMs still wins over the derived floor. assert.equal( - resolveComboTargetTimeoutMsForCombo( - { targetTimeoutMs: 45000 }, - 600000, - "auto", - comboCooldownWait - ), + resolveComboTargetTimeoutMsForCombo({ targetTimeoutMs: 45000 }, 600000, "auto", comboCooldownWait), 45000 ); diff --git a/tests/unit/combo-empty-content-failover-5085.test.ts b/tests/unit/combo-empty-content-failover-5085.test.ts index e8188c7a0e..d7e3c5db66 100644 --- a/tests/unit/combo-empty-content-failover-5085.test.ts +++ b/tests/unit/combo-empty-content-failover-5085.test.ts @@ -40,7 +40,13 @@ function healthy200(model: string) { id: "ok", object: "chat.completion", model, - choices: [{ index: 0, message: { role: "assistant", content: "hello from " + model }, finish_reason: "stop" }], + choices: [ + { + index: 0, + message: { role: "assistant", content: "hello from " + model }, + finish_reason: "stop", + }, + ], }), { status: 200, headers: { "Content-Type": "application/json" } } ); @@ -92,7 +98,8 @@ test("#5085 combo fails over to the next leg when leg 1 returns empty-content 50 // connection exhausted and skips every REMAINING SAME-PROVIDER leg (#1731v2). // An empty completion arrived on a HEALTHY connection (HTTP 200, no content) and // must not be treated as a bad connection. -const { applyComboTargetExhaustion } = await import("../../open-sse/services/combo/targetExhaustion.ts"); +const { applyComboTargetExhaustion } = + await import("../../open-sse/services/combo/targetExhaustion.ts"); function makeTarget(provider: string, modelStr: string, connectionId: string | null = null) { return { @@ -118,18 +125,21 @@ function freshSets() { test("#5085 empty-content 502 must NOT mark the provider/connection exhausted (model-level, not connection-level)", () => { const sets = freshSets(); - const providerExhausted = applyComboTargetExhaustion(makeTarget("nvidia", "nvidia/minimaxai/minimax-m3"), { - result: { status: 502, headers: new Headers() }, - fallbackResult: { reason: "server_error" }, - errorText: "Provider returned empty content", - rawModel: "minimaxai/minimax-m3", - isTokenLimitBreach: false, - allAccountsRateLimited: false, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - }); + const providerExhausted = applyComboTargetExhaustion( + makeTarget("nvidia", "nvidia/minimaxai/minimax-m3"), + { + result: { status: 502, headers: new Headers() }, + fallbackResult: { reason: "server_error" }, + errorText: "Provider returned empty content", + rawModel: "minimaxai/minimax-m3", + isTokenLimitBreach: false, + allAccountsRateLimited: false, + sets, + log, + tag: "COMBO", + exhaustedLogLevel: "info", + } + ); assert.equal(providerExhausted, false, "empty-content is not a quota exhaustion"); assert.equal( @@ -139,6 +149,37 @@ test("#5085 empty-content 502 must NOT mark the provider/connection exhausted (m ); }); +test("#8397 empty-response 502 (no usable choices/output) must NOT mark provider/connection exhausted", () => { + const sets = freshSets(); + const providerExhausted = applyComboTargetExhaustion( + makeTarget("nvidia", "nvidia/minimaxai/minimax-m3"), + { + result: { status: 502, headers: new Headers() }, + fallbackResult: { reason: "server_error" }, + errorText: "upstream returned an empty response without usable output", + rawModel: "minimaxai/minimax-m3", + isTokenLimitBreach: false, + allAccountsRateLimited: false, + sets, + log, + tag: "COMBO", + exhaustedLogLevel: "info", + } + ); + + assert.equal(providerExhausted, false, "empty-response is not a quota exhaustion"); + assert.equal( + sets.exhaustedProviders.has("nvidia"), + false, + "empty-response 502 must NOT mark the whole provider exhausted" + ); + assert.equal( + sets.exhaustedConnections.size, + 0, + "empty-response 502 must NOT mark any connection exhausted" + ); +}); + test("#5085 a real connection-level 502 (gateway error) STILL marks the provider exhausted", () => { const sets = freshSets(); applyComboTargetExhaustion(makeTarget("nvidia", "nvidia/minimaxai/minimax-m3"), { diff --git a/tests/unit/combo-fallback-token-estimate-7847.test.ts b/tests/unit/combo-fallback-token-estimate-7847.test.ts new file mode 100644 index 0000000000..1a8e57838c --- /dev/null +++ b/tests/unit/combo-fallback-token-estimate-7847.test.ts @@ -0,0 +1,92 @@ +// combo's fallback-compression trigger must estimate tokens from the request OBJECT (#7847). +// +// It used to call `estimateTokens(JSON.stringify(attemptBody))`, which takes the string branch of +// estimateTokens — `ceil(length / CHARS_PER_TOKEN)` over the raw JSON. An inline base64 image is +// then charged as if every character of the data URL were prose, the same over-count #8368/#8401 +// fixed on the request path. On a 200 KB inline image that read ~50k tokens instead of ~1.2k, +// tripping fallback compression on a request nowhere near the context window. +// +// Passing the object instead routes through extractImageTokens, which charges images structurally. +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-token-estimate-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const { estimateTokens } = await import("../../open-sse/services/contextManager.ts"); +const core = await import("../../src/lib/db/core.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; +}); + +const imageBody = (base64Chars: number) => ({ + model: "claude-opus-5", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "describe this screenshot" }, + { + type: "image_url", + image_url: { url: `data:image/png;base64,${"A".repeat(base64Chars)}` }, + }, + ], + }, + ], +}); + +const textBody = () => ({ + model: "claude-opus-5", + max_tokens: 100, + messages: [{ role: "user", content: "hello world ".repeat(500) }], + tools: [{ type: "function", function: { name: "t", description: "does a thing" } }], +}); + +test("an inline image is charged structurally, not as raw data-URL text", () => { + const small = estimateTokens(imageBody(10_000)); + const large = estimateTokens(imageBody(200_000)); + + // A 20x larger base64 payload must not cost 20x the tokens — the image is charged as an image. + assert.ok( + large < small * 2, + `estimate scaled with the base64 length (${small} -> ${large}); the data URL is being counted as text` + ); + assert.ok(large < 10_000, `expected a bounded image charge, got ${large} tokens`); +}); + +test("the string path is what over-counts — this is why the call site must pass the object", () => { + const body = imageBody(200_000); + const viaObject = estimateTokens(body); + const viaString = estimateTokens(JSON.stringify(body)); + + assert.ok( + viaString > viaObject * 10, + `expected the string path to over-count heavily (object=${viaObject}, string=${viaString}) — ` + + "if this ever stops being true, the regression guard below is measuring nothing" + ); +}); + +test("text-only bodies are unaffected: object and string paths agree", () => { + const body = textBody(); + assert.equal( + estimateTokens(body), + estimateTokens(JSON.stringify(body)), + "the switch to the object path must be a no-op for the common text-only request" + ); +}); + +test("estimateTokens still handles the plain shapes", () => { + assert.equal(estimateTokens(null), 0); + assert.equal(estimateTokens(undefined), 0); + assert.equal(estimateTokens(""), 0); + assert.equal(estimateTokens("abcd"), 1); + assert.ok(estimateTokens({ a: "x".repeat(400) }) > 0); +}); diff --git a/tests/unit/combo-input-bound-failure-8375.test.ts b/tests/unit/combo-input-bound-failure-8375.test.ts new file mode 100644 index 0000000000..80697e598b --- /dev/null +++ b/tests/unit/combo-input-bound-failure-8375.test.ts @@ -0,0 +1,82 @@ +/** + * #8375 — A combo whose first target returns `context_length_exceeded` for an + * oversized input must propagate the 400 immediately instead of re-dispatching + * the identical oversized request against other accounts of the same model. + * + * Without this fix: + * - The 400 `context_length_exceeded` is request-scoped and deterministic for + * the same input — every account of the same model will reject it identically. + * - `isRequestScopedUpstreamFailure()` correctly classifies it, but the combo + * loop never acts on that classification to short-circuit. + * - The combo retries MAX_GLOBAL_ATTEMPTS=30 times, burning all attempts, and + * returns a misleading 503 "Maximum combo retry limit reached". + * + * Fix: new `isInputBoundRequestFailure()` predicate that detects input-bound + * deterministic errors. When it fires, the combo returns `{ ok: false, response }` + * from `executeTarget`, which the outer loop treats as fatal — stopping the + * combo and propagating the original 400. + */ +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-combo-8375-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-8375-test-secret"; + +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); + +const noop = () => {}; +const log = { info: noop, warn: noop, debug: noop, error: noop }; + +function contextLengthExceededResponse() { + return new Response( + JSON.stringify({ + error: { + code: "context_length_exceeded", + message: + "Input exceeds the context window for nvidia/z-ai/glm-5.2: estimated 159324 input tokens, limit 128000.", + }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); +} + +function makeCombo(models: string[]) { + return { + name: "test-combo-8375", + strategy: "priority", + models: models.map((m) => ({ model: m })), + }; +} + +test("#8375 combo stops at the first context_length_exceeded instead of re-dispatching", async () => { + const modelsCalled: string[] = []; + const handleSingleModel = async (_body: unknown, modelStr: string) => { + modelsCalled.push(modelStr); + return contextLengthExceededResponse(); + }; + + const result = await handleComboChat({ + body: { model: "test", messages: [{ role: "user", content: "hi" }] }, + combo: makeCombo(["nvidia/z-ai/glm-5.2", "nvidia/z-ai/glm-5.2", "nvidia/z-ai/glm-5.2"]), + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + + // The guard must short-circuit after the FIRST target — never reach #2 or #3. + assert.equal( + modelsCalled.length, + 1, + `input-bound 400 must stop the combo at target 1, but it tried: ${modelsCalled.join(", ")}` + ); + assert.equal( + result.status, + 400, + "the combo must surface the original 400 to the client, not a 503" + ); +}); diff --git a/tests/unit/combo-input-bound-heterogeneous-8375.test.ts b/tests/unit/combo-input-bound-heterogeneous-8375.test.ts new file mode 100644 index 0000000000..877eb634fa --- /dev/null +++ b/tests/unit/combo-input-bound-heterogeneous-8375.test.ts @@ -0,0 +1,111 @@ +/** + * #8375 (regression) — the input-bound short-circuit added for the homogeneous + * same-model pool case must NOT fire across a heterogeneous combo whose remaining + * targets are different models with (potentially) larger context windows. + * + * Without this scoping: + * - `isInputBoundFailure` in open-sse/services/combo.ts fires unconditionally on + * the first `context_length_exceeded`/`context_window_exceeded`, even when a + * later target in the combo is a different model that could still succeed. + * - This regresses the intentional heterogeneous-combo behavior protected by + * `isContextOverflow400` (#6637): a small-context model failing must not abort + * the whole combo when a larger-context model is still queued. + * + * Fix: the short-circuit only fires when every remaining target shares the same + * `modelStr` as the one that just failed (a true homogeneous remainder) — + * see `remainderIsHomogeneous` in combo.ts. + */ +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-combo-hetero-8375-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-hetero-8375-test-secret"; + +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); + +const noop = () => {}; +const log = { info: noop, warn: noop, debug: noop, error: noop }; + +function contextLengthExceededResponse() { + return new Response( + JSON.stringify({ + error: { + code: "context_length_exceeded", + message: "Input exceeds the context window: estimated 159324 input tokens, limit 128000.", + }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); +} + +function healthyResponse() { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +test("#8375 heterogeneous combo: small-ctx model fails, larger-ctx model must still be tried", async () => { + const modelsCalled: string[] = []; + const handleSingleModel = async (_body: unknown, modelStr: string) => { + modelsCalled.push(modelStr); + if (modelStr === "providerA/model-small-ctx") return contextLengthExceededResponse(); + return healthyResponse(); + }; + + const result = await handleComboChat({ + body: { model: "test", messages: [{ role: "user", content: "hi" }] }, + combo: { + name: "test-combo-hetero-8375", + strategy: "priority", + models: [{ model: "providerA/model-small-ctx" }, { model: "providerB/model-huge-ctx" }], + }, + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + + assert.equal( + modelsCalled.length, + 2, + `expected the combo to still try the larger-context target 2, but tried: ${modelsCalled.join(", ")}` + ); + assert.equal(result.status, 200); +}); + +test("#8375 homogeneous remainder still short-circuits (no regression on the original fix)", async () => { + const modelsCalled: string[] = []; + const handleSingleModel = async (_body: unknown, modelStr: string) => { + modelsCalled.push(modelStr); + return contextLengthExceededResponse(); + }; + + const result = await handleComboChat({ + body: { model: "test", messages: [{ role: "user", content: "hi" }] }, + combo: { + name: "test-combo-homogeneous-8375", + strategy: "priority", + models: [ + { model: "nvidia/z-ai/glm-5.2" }, + { model: "nvidia/z-ai/glm-5.2" }, + { model: "nvidia/z-ai/glm-5.2" }, + ], + }, + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + + assert.equal( + modelsCalled.length, + 1, + `input-bound 400 on a homogeneous remainder must still stop at target 1, but tried: ${modelsCalled.join(", ")}` + ); + assert.equal(result.status, 400); +}); diff --git a/tests/unit/combo-model-name-collision-8530.test.ts b/tests/unit/combo-model-name-collision-8530.test.ts new file mode 100644 index 0000000000..fcdf09a827 --- /dev/null +++ b/tests/unit/combo-model-name-collision-8530.test.ts @@ -0,0 +1,190 @@ +// #8530 — combo name / model id collision guard. +// +// #6940 (closed by the maintainer) documents a combo named identically to a +// bare model id (e.g. combo `gpt-5.5`) as THE supported mechanism for +// per-model provider fallback — see `tests/unit/responses-combo-resolution-3227.test.ts` +// and `tests/unit/combo-name-codex-responses-rewrite.test.ts` for the +// combo-before-rewrite precedence this relies on. So creation/rename must +// NEVER hard-reject a colliding name (that would regress #6940); it must +// only make the collision observable via a non-blocking `warning` field. +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-combo-collision-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); +const createRoute = await import("../../src/app/api/combos/route.ts"); +const comboRoute = await import("../../src/app/api/combos/[id]/route.ts"); +const collision = await import("../../src/lib/combos/modelNameCollision.ts"); + +// A model id that really is registered by multiple providers (verified via +// PROVIDER_MODELS at write time — see open-sse/config/providers/*). +const REAL_MODEL_ID = "gpt-5.5"; +// Not a registered bare model id anywhere in the provider registry. +const NON_COLLIDING_NAME = "not-a-real-model-8530-guard-probe"; + +interface ComboResponseBody { + name?: string; + warning?: { code: string; modelId: string; providerId: string }; +} + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function makeCreateRequest(body: unknown) { + return new Request("http://localhost/api/combos", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function makeUpdateRequest(body: unknown) { + return new Request("http://localhost/api/combos/combo-1", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("POST /api/combos: name colliding with a real model id is created (#6940 pattern), with a warning", async () => { + const response = await createRoute.POST( + makeCreateRequest({ + name: REAL_MODEL_ID, + strategy: "priority", + models: [ + { providerId: "codex", model: REAL_MODEL_ID }, + { providerId: "openai", model: REAL_MODEL_ID }, + ], + }) + ); + const body = (await response.json()) as ComboResponseBody; + + // Legitimate/sanctioned shape MUST still pass — never a 4xx (would regress #6940). + assert.equal(response.status, 201); + assert.equal(body.name, REAL_MODEL_ID); + const stored = await combosDb.getComboByName(REAL_MODEL_ID); + assert.equal(stored?.name, REAL_MODEL_ID); + + // But it is now observable via a non-blocking warning. + assert.equal(body.warning?.code, "COMBO_NAME_SHADOWS_MODEL"); + assert.equal(body.warning?.modelId, REAL_MODEL_ID); + assert.equal(typeof body.warning?.providerId, "string"); +}); + +test("POST /api/combos: non-colliding name is created with no warning field at all", async () => { + const response = await createRoute.POST( + makeCreateRequest({ + name: NON_COLLIDING_NAME, + strategy: "priority", + models: [{ providerId: "claude", model: "claude-sonnet-4-6" }], + }) + ); + const body = (await response.json()) as ComboResponseBody; + + assert.equal(response.status, 201); + assert.equal(body.name, NON_COLLIDING_NAME); + assert.equal("warning" in body, false); +}); + +test("PUT /api/combos/[id]: renaming to a real model id is applied (#6940 pattern), with a warning", async () => { + const combo = await combosDb.createCombo({ + name: "claude-plain", + models: [{ provider: "claude", model: "claude-sonnet-4-6" }], + }); + + const response = await comboRoute.PUT(makeUpdateRequest({ name: REAL_MODEL_ID }), { + params: Promise.resolve({ id: combo.id }), + }); + const body = (await response.json()) as ComboResponseBody; + + // Legitimate/sanctioned rename MUST still pass — never a 4xx. + assert.equal(response.status, 200); + assert.equal(body.name, REAL_MODEL_ID); + const stored = await combosDb.getComboByName(REAL_MODEL_ID); + assert.equal(stored?.id, combo.id); + + assert.equal(body.warning?.code, "COMBO_NAME_SHADOWS_MODEL"); + assert.equal(body.warning?.modelId, REAL_MODEL_ID); +}); + +test("PUT /api/combos/[id]: renaming to a non-colliding name has no warning field", async () => { + const combo = await combosDb.createCombo({ + name: "claude-plain", + models: [{ provider: "claude", model: "claude-sonnet-4-6" }], + }); + + const response = await comboRoute.PUT(makeUpdateRequest({ name: NON_COLLIDING_NAME }), { + params: Promise.resolve({ id: combo.id }), + }); + const body = (await response.json()) as ComboResponseBody; + + assert.equal(response.status, 200); + assert.equal(body.name, NON_COLLIDING_NAME); + assert.equal("warning" in body, false); +}); + +test("scanCombosForModelCollisions: reports existing combos that shadow a real model id", () => { + const results = collision.scanCombosForModelCollisions([ + { name: REAL_MODEL_ID }, + { name: NON_COLLIDING_NAME }, + { name: "" }, + ]); + assert.equal(results.length, 1); + assert.equal(results[0].comboName, REAL_MODEL_ID); + assert.equal(results[0].modelId, REAL_MODEL_ID); +}); + +test("findCollidingModel: returns null for a name with no real-model-id collision", () => { + assert.equal(collision.findCollidingModel(NON_COLLIDING_NAME), null); +}); + +test("scanComboModelNameCollisionsAtBoot: logs a startup warning enumerating existing collisions", async () => { + await combosDb.createCombo({ + name: REAL_MODEL_ID, + models: [{ provider: "codex", model: REAL_MODEL_ID }], + }); + await combosDb.createCombo({ + name: NON_COLLIDING_NAME, + models: [{ provider: "claude", model: "claude-sonnet-4-6" }], + }); + + const { scanComboModelNameCollisionsAtBoot } = await import("../../src/instrumentation-node.ts"); + const originalWarn = console.warn; + const warnings: unknown[][] = []; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + try { + await scanComboModelNameCollisionsAtBoot(); + } finally { + console.warn = originalWarn; + } + + const collisionWarning = warnings.find((args) => + String(args[0]).includes("share a name with a real model id (#8530)") + ); + assert.ok(collisionWarning, "expected a startup warning about the model-name collision"); + assert.ok(String(collisionWarning![0]).includes(REAL_MODEL_ID)); + assert.ok( + !String(collisionWarning![0]).includes(NON_COLLIDING_NAME), + "non-colliding combo must not be reported" + ); +}); diff --git a/tests/unit/combo-quota-share-cooldown-wait.test.ts b/tests/unit/combo-quota-share-cooldown-wait.test.ts index 320dd29095..ba33def470 100644 --- a/tests/unit/combo-quota-share-cooldown-wait.test.ts +++ b/tests/unit/combo-quota-share-cooldown-wait.test.ts @@ -9,8 +9,8 @@ * 2. A 403 (quota_exhausted, locked until midnight) → NO wait, the 403/429 is * propagated immediately (the helper's critical exclusion). * 3. Client abort DURING the wait → 499 "Request aborted". - * 4. strategy="priority" (non quota-share) → unchanged: the 429 is propagated - * immediately with NO wait. + * 4. strategy="priority" (and every other strategy) also waits out a SHORT + * transient 429 when comboCooldownWait is enabled — same decision helper. * 5. comboCooldownWait disabled in settings → unchanged: 429 propagated, no wait. * * The waits use a real (short) cooldown so the real setTimeout in diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index 41cda10850..9e2893d28b 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -889,7 +889,7 @@ test("handleComboChat records per-target metrics separately when the same model assert.equal(metrics.byTarget[secondStep.id].connectionId, "conn-openai-b"); }); -test("handleComboChat preserves the first failure status but surfaces the last error message plus per-model diagnostics", async () => { +test("handleComboChat surfaces the last failing target's status AND error message together, not a cross-target mismatch (#8486)", async () => { const result = await handleComboChat({ body: {}, combo: { @@ -910,7 +910,7 @@ test("handleComboChat preserves the first failure status but surfaces the last e const payload = (await result.json()) as any; - assert.equal(result.status, 500); + assert.equal(result.status, 429); // #8486: status/message from the SAME (last) failing target // The last error message is preserved and now carries an aggregated // per-model diagnostics suffix (status codes for every target attempted // in this set try), added alongside the global comboTimeoutMs feature. @@ -1671,7 +1671,7 @@ test("handleComboChat round-robin falls through generic 400s when a later model assert.deepEqual(calls, ["model-a", "model-b"]); }); -test("handleComboChat round-robin falls through 400s and returns the final error payload when no target recovers", async () => { +test("handleComboChat round-robin falls through 400s and returns the LAST target's status+message together, not a cross-target mismatch (#8486)", async () => { const calls: any[] = []; const result = await handleComboChat({ @@ -1709,7 +1709,7 @@ test("handleComboChat round-robin falls through 400s and returns the final error }); const payload = (await result.json()) as any; - assert.equal(result.status, 400); + assert.equal(result.status, 500); // #8486: status/message from the SAME (last) failing target assert.equal(payload.error.message, "rr-final-fail"); assert.deepEqual(calls, ["model-a", "model-b"]); }); diff --git a/tests/unit/context-manager.test.ts b/tests/unit/context-manager.test.ts index e16a16584e..bad89f19ea 100644 --- a/tests/unit/context-manager.test.ts +++ b/tests/unit/context-manager.test.ts @@ -34,6 +34,39 @@ test("getTokenLimit: default fallback", () => { assert.equal(getTokenLimit("unknown"), 128000); }); +// Regression for #8496: hyperagent Claude-family agents (fable/opus/sonnet) must +// resolve to the 1M context window for every fallback model id, driven solely by +// the registry's `defaultContextLength` (open-sse/config/providers/registry/hyperagent) — +// not by a provider-unscoped model-name substring match, which previously collided +// with unrelated providers serving the same Claude model ids (see +// "getTokenLimit: does not force 1M onto non-hyperagent providers" below). +const HYPERAGENT_FALLBACK_MODEL_IDS = [ + "fable-latest", + "claude-fable-5", + "opus-latest", + "claude-opus-4-8", + "sonnet-latest", + "claude-sonnet-5", +]; + +for (const modelId of HYPERAGENT_FALLBACK_MODEL_IDS) { + test(`getTokenLimit: hyperagent/${modelId} resolves to 1M context`, () => { + assert.equal(getTokenLimit("hyperagent", modelId), 1_000_000); + }); + + test(`getTokenLimit: ha (alias)/${modelId} resolves to 1M context`, () => { + assert.equal(getTokenLimit("ha", modelId), 1_000_000); + }); +} + +test("getTokenLimit: does not force 1M onto non-hyperagent providers serving the same model ids", () => { + // windsurf declares an explicit per-model contextLength of 200000 for this exact id — + // a provider-unscoped substring match on "claude-opus-4" would have clobbered it to 1M. + assert.equal(getTokenLimit("windsurf", "claude-opus-4.7-max"), 200000); + // bluesminds likewise pins its own claude-opus-4-5 entry to 200000. + assert.equal(getTokenLimit("bluesminds", "claude-opus-4-5"), 200000); +}); + // ─── compressContext ──────────────────────────────────────────────────────── test("compressContext: returns unchanged if fits", () => { diff --git a/tests/unit/designer-web-empty-response-502.test.ts b/tests/unit/designer-web-empty-response-502.test.ts new file mode 100644 index 0000000000..32df360c50 --- /dev/null +++ b/tests/unit/designer-web-empty-response-502.test.ts @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { handleDesignerWebImageGeneration } = await import( + "../../open-sse/handlers/imageGeneration/providers/designerWeb.ts" +); + +/** + * `stepDesignerWebPoll` classifies an unrecognized upstream body as a terminal + * 502 — the "empty" arm of the step union, alongside pending / ready / upstream + * failure. + * + * `microsoft-designer-web-6672.test.ts` covers every other arm end-to-end + * (400 no prompt, 401 no token, immediate ready, poll-then-ready, non-OK + * upstream, 504 timeout) but tests "empty" only at the parser level + * (`parseDesignerWebResponse: unrecognized shape is 'empty'`) — it never drives + * the handler with one, so the 502 the handler synthesizes from it was + * unasserted. + */ + +function jsonResponse(status: number, body: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + } as Response; +} + +const BASE = { + model: "dall-e-3", + provider: "microsoft-designer-web", + providerConfig: { baseUrl: "https://designerapp.officeapps.live.com/designerapp/DallE.ashx" }, + credentials: { apiKey: "tok-abc" }, +}; + +test("a 200 with an unrecognized body is a terminal 502, not a retry", async () => { + let calls = 0; + const result = await handleDesignerWebImageGeneration({ + ...BASE, + body: { prompt: "a cat astronaut", timeout_ms: 5_000, poll_interval_ms: 1 }, + fetchImpl: async () => { + calls += 1; + return jsonResponse(200, { unexpected: true }); + }, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502, "an unparseable success body is a bad-gateway, not a timeout"); + assert.match(String(result.error), /did not contain image data or polling metadata/); + assert.equal(calls, 1, "the empty classification is terminal — it must not keep polling"); +}); + +test("a 200 with neither images nor polling metadata does not fall through to 504", async () => { + // The distinction matters: 502 says "the upstream answered with something we + // cannot use", 504 says "the upstream never finished". A timeout_ms generous + // enough to allow several polls proves the 502 came from classification, not + // from the deadline. + const result = await handleDesignerWebImageGeneration({ + ...BASE, + body: { prompt: "a cat astronaut", timeout_ms: 10_000, poll_interval_ms: 1 }, + fetchImpl: async () => jsonResponse(200, { polling_response: {} }), + }); + + assert.equal(result.status, 502); + assert.notEqual(result.status, 504); +}); diff --git a/tests/unit/error-classification.test.ts b/tests/unit/error-classification.test.ts index bc267a4050..90d18f7c4d 100644 --- a/tests/unit/error-classification.test.ts +++ b/tests/unit/error-classification.test.ts @@ -98,13 +98,21 @@ test("502 transient: exponential backoff doubles until the configured max backof assert.equal(result.newBackoffLevel, level + 1); assert.equal(result.reason, RateLimitReason.SERVER_ERROR); } + // #8396: the scaled cooldown is now clamped by capScaledCooldownMs + // (open-sse/services/accountFallback/cooldownCap.ts). With no provider the + // ceiling is BACKOFF_CONFIG.max, so the doubling stops there instead of + // running on to transientInitial * 32. + assert.ok( + COOLDOWN_MS.transientInitial * 32 > BACKOFF_CONFIG.max, + "precondition: the 6th step must exceed the cap, or this test proves nothing" + ); assert.deepEqual(cooldowns, [ COOLDOWN_MS.transientInitial, COOLDOWN_MS.transientInitial * 2, COOLDOWN_MS.transientInitial * 4, COOLDOWN_MS.transientInitial * 8, COOLDOWN_MS.transientInitial * 16, - COOLDOWN_MS.transientInitial * 32, + BACKOFF_CONFIG.max, ]); }); @@ -237,8 +245,14 @@ test("subscription quota uses long cooldown when upstream retry hints are disabl test("high transient backoff levels clamp to the configured maxBackoffSteps", () => { const result = checkFallbackError(502, "", BACKOFF_CONFIG.maxLevel + 5, null, null); assert.equal(result.newBackoffLevel, BACKOFF_CONFIG.maxLevel); + // #8396: the level still clamps at maxLevel, but the resulting duration is + // additionally capped — unclamped this would be ~45.5h, which is the blackout + // that PR removed. assert.equal( result.cooldownMs, - COOLDOWN_MS.transientInitial * Math.pow(2, BACKOFF_CONFIG.maxLevel) + Math.min( + COOLDOWN_MS.transientInitial * Math.pow(2, BACKOFF_CONFIG.maxLevel), + BACKOFF_CONFIG.max + ) ); }); diff --git a/tests/unit/executor-claude-identity.test.ts b/tests/unit/executor-claude-identity.test.ts index 6ed2dc23f6..8d6ed1755d 100644 --- a/tests/unit/executor-claude-identity.test.ts +++ b/tests/unit/executor-claude-identity.test.ts @@ -178,6 +178,15 @@ describe("claudeIdentity — selectBetaFlags", () => { assert.ok(flags.includes("context-1m-2025-08-07")); }); + it("omits the legacy context-1m beta for Opus 5", () => { + const body = { + system: "test", + tools: [{ name: "test_tool" }], + }; + const flags = mod.selectBetaFlags(body, "claude-opus-5"); + assert.ok(!flags.includes("context-1m-2025-08-07")); + }); + it("does not include context-1m for sonnet", () => { const body = { system: "test", @@ -188,20 +197,6 @@ describe("claudeIdentity — selectBetaFlags", () => { }); }); -describe("claudeIdentity — buildHashFor", () => { - it("returns 3-char hex string", () => { - const hash = mod.buildHashFor("1.0.0", "2026-01-01"); - assert.equal(hash.length, 3); - assert.ok(/^[0-9a-f]{3}$/.test(hash)); - }); - - it("returns same hash for same inputs", () => { - const a = mod.buildHashFor("1.0.0", "2026-01-01"); - const b = mod.buildHashFor("1.0.0", "2026-01-01"); - assert.equal(a, b); - }); -}); - describe("claudeIdentity — stripProxyToolPrefix", () => { it("strips proxy_ prefix from tools", () => { const body = { tools: [{ name: "proxy_search" }, { name: "native_tool" }] }; diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index f73f66be58..110d40a65d 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -645,6 +645,23 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1 }, extendedContext: true, }); + await cc.execute({ + model: "claude-opus-5", + body: { + model: "claude-opus-5", + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + }, + stream: false, + credentials: { + apiKey: "cc-key", + providerSpecificData: { + ccSessionId: "session-1", + requestDefaults: { context1m: true }, + }, + }, + extendedContext: true, + }); } finally { globalThis.fetch = originalFetch; } @@ -664,6 +681,8 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1 // gets the context-1m beta header (shouldForwardExtendedContext in base.ts), same as any other // 1M-capable model. assert.equal(calls[2].headers["anthropic-beta"].includes(CONTEXT_1M_BETA_HEADER), true); + // Opus 5 has a native 1M window and must not receive the legacy context beta. + assert.equal(calls[3].headers["anthropic-beta"].includes(CONTEXT_1M_BETA_HEADER), false); }); test("DefaultExecutor.execute reports the exact serialized provider request before fetch", async () => { @@ -1450,10 +1469,12 @@ test("DefaultExecutor.execute does not produce duplicate anthropic-version heade const executor = new DefaultExecutor("claude"); const originalFetch = globalThis.fetch; let capturedHeaders: Record = {}; + let capturedBody = ""; globalThis.fetch = async (_url, init = {}) => { // Capture raw headers without normalisation so case-variant duplicate keys are visible. capturedHeaders = (init.headers as Record) || {}; + capturedBody = String(init.body ?? ""); return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -1486,4 +1507,12 @@ test("DefaultExecutor.execute does not produce duplicate anthropic-version heade ); assert.equal(versionKeys.length, 1, "Duplicate anthropic-version header keys found"); assert.equal(capturedHeaders[versionKeys[0]], "2023-06-01"); + assert.equal(capturedHeaders["X-Stainless-Runtime-Version"], "v26.3.0"); + assert.equal(capturedHeaders["X-Stainless-Package-Version"], "0.94.0"); + + const sentBody = JSON.parse(capturedBody) as { system?: Array<{ text?: string }> }; + assert.match( + sentBody.system?.[0]?.text ?? "", + /^x-anthropic-billing-header: cc_version=2\.1\.219\.250; cc_entrypoint=cli; cch=[0-9a-f]{5};$/ + ); }); diff --git a/tests/unit/executor-devin-cli-acp-protocol-8406.test.ts b/tests/unit/executor-devin-cli-acp-protocol-8406.test.ts new file mode 100644 index 0000000000..ae6a3a7072 --- /dev/null +++ b/tests/unit/executor-devin-cli-acp-protocol-8406.test.ts @@ -0,0 +1,120 @@ +// #8406: devin-cli ACP wire-format protocol fixes +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"; +import { writeFileSync } from "node:fs"; + +const mod = await import("../../open-sse/executors/devin-cli.ts"); + +type AcpFrame = { + method?: string; + params?: Record; +}; + +test("#8406: DevinCliExecutor emits correct ACP wire-format frames and handles agent_message_chunk", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devin-acp-test-")); + const framesFile = path.join(tmpDir, "received_frames.json"); + const scriptFile = path.join(tmpDir, "mock-devin"); + + const scriptContent = `#!/usr/bin/env node +const fs = require('fs'); +const readline = require('readline'); + +const frames = []; +const rl = readline.createInterface({ input: process.stdin }); + +rl.on('line', (line) => { + if (!line.trim()) return; + let msg; + try { msg = JSON.parse(line); } catch (e) { return; } + frames.push(msg); + fs.writeFileSync(${JSON.stringify(framesFile)}, JSON.stringify(frames, null, 2)); + + if (msg.method === 'initialize') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', result: {}, id: msg.id }) + '\\n'); + } else if (msg.method === 'session/new') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', result: { sessionId: 'sess-8406' }, id: msg.id }) + '\\n'); + } else if (msg.method === 'session/prompt') { + const updateMsg = { + jsonrpc: '2.0', + method: 'session/update', + params: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hello ACP 8406' } + } + } + }; + process.stdout.write(JSON.stringify(updateMsg) + '\\n'); + const resultMsg = { + jsonrpc: '2.0', + result: { stopReason: 'end_turn' }, + id: msg.id + }; + process.stdout.write(JSON.stringify(resultMsg) + '\\n'); + } +}); +`; + + writeFileSync(scriptFile, scriptContent, { mode: 0o755 }); + + const oldBin = process.env.CLI_DEVIN_BIN; + process.env.CLI_DEVIN_BIN = scriptFile; + + try { + const executor = new mod.DevinCliExecutor(); + const res = await executor.execute({ + model: "swe-1-6-fast", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: true, + credentials: { apiKey: "test-key" }, + }); + + const reader = res.response.body!.getReader(); + const decoder = new TextDecoder(); + let sseOutput = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + sseOutput += decoder.decode(value, { stream: true }); + } + + const receivedFrames: AcpFrame[] = JSON.parse(fs.readFileSync(framesFile, "utf-8")); + + const newFrame = receivedFrames.find((f) => f.method === "session/new"); + assert.ok(newFrame, "session/new frame must be sent"); + assert.ok( + Array.isArray(newFrame.params.mcpServers), + "session/new must include mcpServers array" + ); + + const promptFrame = receivedFrames.find((f) => f.method === "session/prompt"); + assert.ok(promptFrame, "session/prompt frame must be sent"); + assert.ok( + Array.isArray(promptFrame.params.prompt), + "session/prompt must name the field prompt" + ); + assert.equal( + promptFrame.params.content, + undefined, + "session/prompt must not use content field" + ); + + assert.match( + sseOutput, + /Hello ACP 8406/, + "SSE output must contain chunk text from agent_message_chunk" + ); + assert.match(sseOutput, /data: \[DONE\]/, "SSE output must terminate with [DONE]"); + } finally { + if (oldBin !== undefined) { + process.env.CLI_DEVIN_BIN = oldBin; + } else { + delete process.env.CLI_DEVIN_BIN; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/executor-notion-web-thread-sessions.test.ts b/tests/unit/executor-notion-web-thread-sessions.test.ts index 7f8bde63f5..14764c3ad4 100644 --- a/tests/unit/executor-notion-web-thread-sessions.test.ts +++ b/tests/unit/executor-notion-web-thread-sessions.test.ts @@ -34,6 +34,29 @@ function installNotionTlsMock( return () => __setTlsFetchOverrideForTesting(null); } +function okNdjson(text: string): string { + return [ + JSON.stringify({ type: "patch-start", data: { s: [] } }), + JSON.stringify({ + type: "record-map", + recordMap: { + thread_message: { + m1: { + value: { + value: { + step: { + type: "agent-inference", + value: [{ type: "text", content: text }], + }, + }, + }, + }, + }, + }, + }), + ].join("\n"); +} + describe("Notion thread session continuity", () => { const { __resetNotionThreadSessionsForTests, @@ -144,7 +167,7 @@ describe("Notion thread session continuity", () => { const executor = new NotionWebExecutor(); const captured: Array<{ createThread?: boolean; threadId?: string }> = []; let n = 0; - const restore = installNotionTlsMock(async (_url, opts) => { + const restoreTls = installNotionTlsMock(async (_url, opts) => { const body = JSON.parse(String(opts.body)) as { createThread?: boolean; threadId?: string; @@ -163,27 +186,7 @@ describe("Notion thread session continuity", () => { }), }; } - const ndjson = [ - JSON.stringify({ type: "patch-start", data: { s: [] } }), - JSON.stringify({ - type: "record-map", - recordMap: { - thread_message: { - m1: { - value: { - value: { - step: { - type: "agent-inference", - value: [{ type: "text", content: "recovered" }], - }, - }, - }, - }, - }, - }, - }), - ].join("\n"); - return { status: 200, text: ndjson }; + return { status: 200, text: okNdjson("recovered") }; }); try { const result = await executor.execute({ @@ -201,7 +204,90 @@ describe("Notion thread session continuity", () => { const json = (await result.response.json()) as { choices?: { message?: { content?: string } }[] }; assert.match(String(json.choices?.[0]?.message?.content || ""), /recovered/); } finally { - restore(); + restoreTls(); + __resetNotionThreadSessionsForTests(); + } + }); + + it("new first-turn after confirmed chat with same opener mints a fresh thread", () => { + __resetNotionThreadSessionsForTests(); + const { + resolveNotionThreadBinding, + notionThreadMarkCreateAttempted, + notionThreadMarkConfirmed, + } = mod as typeof mod & { + resolveNotionThreadBinding: ( + spaceKey: string, + messages: { role: string; content: string }[], + clientThreadId?: string + ) => { threadId: string; createThread: boolean; rootKey: string | null }; + notionThreadMarkCreateAttempted: (rootKey: string | null, threadId: string) => void; + notionThreadMarkConfirmed: (rootKey: string | null, threadId: string) => void; + }; + + const spaceId = "space-new-session"; + const hi = [{ role: "user", content: "hi" }]; + + const b1 = resolveNotionThreadBinding(spaceId, hi); + assert.equal(b1.createThread, true); + notionThreadMarkCreateAttempted(b1.rootKey, b1.threadId); + notionThreadMarkConfirmed(b1.rootKey, b1.threadId); + + // Claude Code "New session" + same first message must NOT fork the prior Notion chat + const b2 = resolveNotionThreadBinding(spaceId, hi); + assert.equal(b2.createThread, true); + assert.notEqual(b2.threadId, b1.threadId); + + // Multi-turn of the *new* session still sticks to b2 via prefix / sticky history + mod.notionThreadSessionStore( + spaceId, + [{ role: "user", content: "hi" }], + "hello from session 2", + b2.threadId + ); + const multi = [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello from session 2" }, + { role: "user", content: "next" }, + ]; + const b3 = resolveNotionThreadBinding(spaceId, multi); + assert.equal(b3.createThread, false); + assert.equal(b3.threadId, b2.threadId); + }); + + it("execute: two sequential first-turns with same text get distinct Notion threads", async () => { + __resetNotionThreadSessionsForTests(); + const executor = new mod.NotionWebExecutor(); + const captured: Array<{ createThread?: boolean; threadId?: string }> = []; + const restoreTls = installNotionTlsMock(async (_url, opts) => { + captured.push(JSON.parse(String(opts.body))); + return { status: 200, text: okNdjson("pong") }; + }); + try { + const creds = { apiKey: COOKIE_WITH_SPACE }; + const r1 = await executor.execute({ + model: "fable-5", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: creds, + signal: null, + } as never); + assert.equal(r1.response.status, 200); + assert.equal(captured[0]!.createThread, true); + + // Brand-new Claude Code session, same opener text only + const r2 = await executor.execute({ + model: "fable-5", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: creds, + signal: null, + } as never); + assert.equal(r2.response.status, 200); + assert.equal(captured[1]!.createThread, true); + assert.notEqual(captured[0]!.threadId, captured[1]!.threadId); + } finally { + restoreTls(); __resetNotionThreadSessionsForTests(); } }); @@ -229,29 +315,9 @@ describe("Notion thread session continuity", () => { __resetNotionThreadSessionsForTests(); const executor = new mod.NotionWebExecutor(); const captured: Array<{ createThread?: boolean; threadId?: string }> = []; - const restore = installNotionTlsMock(async (_url, opts) => { + const restoreTls = installNotionTlsMock(async (_url, opts) => { captured.push(JSON.parse(String(opts.body))); - const ndjson = [ - JSON.stringify({ type: "patch-start", data: { s: [] } }), - JSON.stringify({ - type: "record-map", - recordMap: { - thread_message: { - m1: { - value: { - value: { - step: { - type: "agent-inference", - value: [{ type: "text", content: "ok" }], - }, - }, - }, - }, - }, - }, - }), - ].join("\n"); - return { status: 200, text: ndjson }; + return { status: 200, text: okNdjson("ok") }; }); try { const r1 = await executor.execute({ @@ -262,8 +328,8 @@ describe("Notion thread session continuity", () => { signal: null, } as never); assert.equal(r1.response.status, 200); - assert.equal(captured[0].createThread, true); - const t1 = captured[0].threadId; + assert.equal(captured[0]!.createThread, true); + const t1 = captured[0]!.threadId; assert.ok(t1 && t1.length > 10); const json1 = (await r1.response.json()) as { notion_thread_id?: string; id?: string }; @@ -283,10 +349,10 @@ describe("Notion thread session continuity", () => { signal: null, } as never); assert.equal(r2.response.status, 200); - assert.equal(captured[1].createThread, false); - assert.equal(captured[1].threadId, t1); + assert.equal(captured[1]!.createThread, false); + assert.equal(captured[1]!.threadId, t1); } finally { - restore(); + restoreTls(); __resetNotionThreadSessionsForTests(); } }); @@ -297,34 +363,14 @@ describe("Notion thread session continuity", () => { const pinned = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; let capturedCreateThread: boolean | undefined; let capturedThreadId: string | undefined; - const restore = installNotionTlsMock(async (_url, opts) => { + const restoreTls = installNotionTlsMock(async (_url, opts) => { const body = JSON.parse(String(opts.body)) as { createThread?: boolean; threadId?: string; }; capturedCreateThread = body.createThread; capturedThreadId = body.threadId; - const ndjson = [ - JSON.stringify({ type: "patch-start", data: { s: [] } }), - JSON.stringify({ - type: "record-map", - recordMap: { - thread_message: { - m1: { - value: { - value: { - step: { - type: "agent-inference", - value: [{ type: "text", content: "ok" }], - }, - }, - }, - }, - }, - }, - }), - ].join("\n"); - return { status: 200, text: ndjson }; + return { status: 200, text: okNdjson("ok") }; }); try { // Real ExecuteInput shape: clientHeaders only (headers is undefined). @@ -342,7 +388,7 @@ describe("Notion thread session continuity", () => { // Client-supplied thread id must force follow-up mode (createThread=false). assert.equal(capturedCreateThread, false); } finally { - restore(); + restoreTls(); __resetNotionThreadSessionsForTests(); } }); diff --git a/tests/unit/executor-qwen-web.test.ts b/tests/unit/executor-qwen-web.test.ts index 785b2e7a6e..10b5efe72e 100644 --- a/tests/unit/executor-qwen-web.test.ts +++ b/tests/unit/executor-qwen-web.test.ts @@ -301,7 +301,7 @@ describe("QwenWebExecutor (v2 migration)", () => { assert.deepEqual(qwen38, { id: "qwen3.8-max-preview", name: "Qwen3.8 Max Preview", - toolCalling: true, + toolCalling: false, supportsReasoning: true, supportsVision: true, contextLength: 1_000_000, diff --git a/tests/unit/executor-zai-web.test.ts b/tests/unit/executor-zai-web.test.ts index 2634c73e20..ceb7e4a7b5 100644 --- a/tests/unit/executor-zai-web.test.ts +++ b/tests/unit/executor-zai-web.test.ts @@ -120,7 +120,7 @@ describe("ZaiWebExecutor", () => { signal: null, }); - assert.equal(capturedUrl, "https://chat.z.ai/api/chat/completions"); + assert.equal(capturedUrl, "https://chat.z.ai/api/v2/chat/completions"); const headers = capturedInit?.headers as Record; assert.equal(headers.Cookie, "token=abc123; foo=bar"); assert.equal(headers.Authorization, "Bearer abc123"); diff --git a/tests/unit/gemini-business-provider.test.ts b/tests/unit/gemini-business-provider.test.ts index b87d96bda0..2c7b41a992 100644 --- a/tests/unit/gemini-business-provider.test.ts +++ b/tests/unit/gemini-business-provider.test.ts @@ -77,6 +77,92 @@ test("GeminiBusinessExecutor.execute returns 400 when no user message is provide assert.ok(text.includes("No user message found")); }); +// ─── Upstream request path ────────────────────────────────────────────────── + +/** + * Regression guard: `execute()` built its fetch options with `combineAbortSignals(...)`, + * a function that exists nowhere in the codebase (the module imports `mergeAbortSignals` + * and never used it). Evaluating the options object threw `ReferenceError` *before* fetch + * was called; the surrounding try/catch turned that into a generic 502 "network error", + * so every Gemini Business request failed while looking like an upstream outage. + * + * It went unnoticed because `open-sse/tsconfig.json` could not be type-checked (the + * deprecated `baseUrl` aborted the run with TS5101) and `typecheck:core` only covers a + * curated 26-file allowlist that excludes this executor. + */ +test("GeminiBusinessExecutor.execute reaches the upstream fetch and passes an abort signal", async () => { + const ex = new GeminiBusinessExecutor(); + const originalFetch = globalThis.fetch; + let fetchCalled = false; + let receivedSignal: unknown; + + // Same wire shape the parseStreamResponse tests below use: inner[4][0] is a + // [metadata, text_list] pair. An empty body would take the "returned no text" 502 + // branch and mask what this test is actually asserting. + const inner = new Array(80).fill(null); + inner[4] = [[null, ["Hello from Gemini Business"]]]; + const upstreamBody = `[["wrb.fr", null, ${JSON.stringify(JSON.stringify(inner))}]]`; + + globalThis.fetch = (async (_url: unknown, init?: { signal?: unknown }) => { + fetchCalled = true; + receivedSignal = init?.signal; + return new Response(upstreamBody, { status: 200 }); + }) as typeof globalThis.fetch; + + try { + const result = await ex.execute({ + model: "gemini-2.5-pro", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: false, + credentials: { apiKey: "__Secure-1PSID=fake; __Secure-1PSIDTS=fake" }, + signal: new AbortController().signal, + }); + + assert.equal(fetchCalled, true, "execute() must reach the upstream fetch"); + assert.ok( + receivedSignal instanceof AbortSignal, + "the upstream fetch must receive a combined AbortSignal" + ); + assert.notEqual( + result.response.status, + 502, + "a ReferenceError while building fetch options must not surface as an upstream 502" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("GeminiBusinessExecutor.execute still applies a timeout when the caller passes no signal", async () => { + const ex = new GeminiBusinessExecutor(); + const originalFetch = globalThis.fetch; + let receivedSignal: unknown; + + globalThis.fetch = (async (_url: unknown, init?: { signal?: unknown }) => { + receivedSignal = init?.signal; + return new Response("", { status: 200 }); + }) as typeof globalThis.fetch; + + try { + // `ExecuteInput.signal` is `AbortSignal | null | undefined`; mergeAbortSignals() + // requires two real signals, so the null case must fall back to the timeout alone. + await ex.execute({ + model: "gemini-2.5-pro", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: false, + credentials: { apiKey: "__Secure-1PSID=fake; __Secure-1PSIDTS=fake" }, + signal: null, + }); + + assert.ok( + receivedSignal instanceof AbortSignal, + "a timeout signal must still be applied when the caller supplies none" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + // ─── parseStreamResponse ──────────────────────────────────────────────────── test("parseStreamResponse extracts text from a single wrb.fr chunk", () => { diff --git a/tests/unit/ghe-copilot.test.ts b/tests/unit/ghe-copilot.test.ts index 2347557855..aed9f933ed 100644 --- a/tests/unit/ghe-copilot.test.ts +++ b/tests/unit/ghe-copilot.test.ts @@ -1,9 +1,22 @@ import test from "node:test"; import assert from "node:assert/strict"; import { GheCopilotExecutor } from "../../open-sse/executors/ghe-copilot.ts"; +import { gheCopilotProvider } from "../../open-sse/config/providers/registry/ghe-copilot/index.ts"; import { GHE_COPILOT_TARGET } from "../../src/mitm/targets/ghe-copilot.ts"; import type { ProviderCredentials } from "../../open-sse/executors/base.ts"; +test("GHE Copilot registry exposes Claude Opus 5", () => { + const opus5 = gheCopilotProvider.models.find((model) => model.id === "claude-opus-5"); + + assert.deepStrictEqual(opus5, { + id: "claude-opus-5", + name: "Claude Opus 5", + contextLength: 1000000, + maxOutputTokens: 64000, + unsupportedParams: ["temperature", "top_p", "top_k"], + }); +}); + test("GHE_COPILOT_TARGET has correct id and patterns", () => { assert.strictEqual(GHE_COPILOT_TARGET.id, "ghe-copilot"); assert.deepStrictEqual(GHE_COPILOT_TARGET.endpointPatterns, [ diff --git a/tests/unit/github-copilot-model-discovery.test.ts b/tests/unit/github-copilot-model-discovery.test.ts index 1c612eb7ab..5e0cb3c9fe 100644 --- a/tests/unit/github-copilot-model-discovery.test.ts +++ b/tests/unit/github-copilot-model-discovery.test.ts @@ -130,6 +130,7 @@ test("curated Copilot allowlist contains the final approved model ids only", () [...GITHUB_COPILOT_MODEL_ALLOWLIST], [ "claude-fable-5", + "claude-opus-5", "claude-opus-4.8-fast", "claude-opus-4.8", "claude-opus-4.7", @@ -159,7 +160,7 @@ test("curated Copilot allowlist contains the final approved model ids only", () }); test("newly approved Copilot models survive live and fallback discovery", async () => { - const expected = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]; + const expected = ["claude-opus-5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]; const live = parseGitHubCopilotModels({ data: expected.map((id) => ({ id, name: id })) }); assert.deepEqual( live.map((model) => model.id), diff --git a/tests/unit/glm-executor.test.ts b/tests/unit/glm-executor.test.ts index 0ae127d7e8..f0b0d9e188 100644 --- a/tests/unit/glm-executor.test.ts +++ b/tests/unit/glm-executor.test.ts @@ -181,7 +181,7 @@ test("GlmExecutor separates OpenAI-compatible coding headers from Anthropic head assert.equal(anthropicHeaders["anthropic-version"], "2023-06-01"); assert.match(anthropicHeaders["anthropic-beta"], /claude-code-20250219/); assert.equal(anthropicHeaders["anthropic-dangerous-direct-browser-access"], "true"); - assert.match(anthropicHeaders["User-Agent"], /^claude-cli\/2\.1\.207 \(external, sdk-cli\)$/); + assert.match(anthropicHeaders["User-Agent"], /^claude-cli\/2\.1\.219 \(external, sdk-cli\)$/); assert.equal(anthropicHeaders["X-Stainless-Lang"], "js"); assert.equal(anthropicHeaders["X-Stainless-Runtime"], "node"); }); diff --git a/tests/unit/guardrails-void-no-change-contract.test.ts b/tests/unit/guardrails-void-no-change-contract.test.ts new file mode 100644 index 0000000000..080e532a23 --- /dev/null +++ b/tests/unit/guardrails-void-no-change-contract.test.ts @@ -0,0 +1,121 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { BaseGuardrail, GuardrailRegistry } from "../../src/lib/guardrails/index.ts"; + +/** + * `docs/security/GUARDRAILS.md`: "A guardrail signals 'no change' by returning + * either `void`, `{}`, or ...". + * + * The `void` arm of that contract had no test — `guardrails-registry.test.ts` + * covers guardrails that return a result and one that throws, but never one + * that simply returns nothing. These tests pin it, because the registry's + * dispatch reads the return value's properties and must keep treating "nothing" + * as a clean pass rather than a block or a modification. + */ + +class SilentGuardrail extends BaseGuardrail { + constructor(name = "silent") { + super(name, { priority: 10 }); + } + + override async preCall() { + // no return — the documented "no change" signal + } + + override async postCall() { + // no return — the documented "no change" signal + } +} + +class EmptyResultGuardrail extends BaseGuardrail { + constructor(name = "empty") { + super(name, { priority: 10 }); + } + + override async preCall() { + return {}; + } + + override async postCall() { + return {}; + } +} + +test("a preCall returning nothing passes the payload through untouched", async () => { + const registry = new GuardrailRegistry(); + registry.register(new SilentGuardrail()); + + const payload = { messages: [{ role: "user", content: "hello" }] }; + const result = await registry.runPreCallHooks(payload, {}); + + assert.equal(result.blocked, false, "returning nothing must not block"); + assert.deepEqual(result.payload, payload, "payload must be forwarded unchanged"); + + const execution = result.results[0]; + assert.equal(execution?.guardrail, "silent"); + assert.equal(execution?.blocked, false); + assert.equal(execution?.modified, false, "nothing returned means nothing modified"); + assert.equal(execution?.skipped, false, "the guardrail ran — it is not 'skipped'"); + assert.equal(execution?.error, undefined, "a silent pass is not an error"); + assert.equal(execution?.stage, "pre"); +}); + +test("a postCall returning nothing passes the response through untouched", async () => { + const registry = new GuardrailRegistry(); + registry.register(new SilentGuardrail()); + + const response = { choices: [{ message: { content: "hi" } }] }; + const result = await registry.runPostCallHooks(response, {}); + + assert.equal(result.blocked, false); + assert.deepEqual(result.response, response); + + const execution = result.results[0]; + assert.equal(execution?.modified, false); + assert.equal(execution?.skipped, false); + assert.equal(execution?.error, undefined); + assert.equal(execution?.stage, "post"); +}); + +test("returning an empty object behaves identically to returning nothing", async () => { + const payload = { messages: [{ role: "user", content: "hello" }] }; + + const silent = new GuardrailRegistry(); + silent.register(new SilentGuardrail("g")); + const silentResult = await silent.runPreCallHooks(payload, {}); + + const empty = new GuardrailRegistry(); + empty.register(new EmptyResultGuardrail("g")); + const emptyResult = await empty.runPreCallHooks(payload, {}); + + assert.deepEqual( + silentResult.results, + emptyResult.results, + "the two documented no-change signals must produce the same execution record" + ); + assert.deepEqual(silentResult.payload, emptyResult.payload); +}); + +test("a silent guardrail does not stop later guardrails from modifying", async () => { + class AppendGuardrail extends BaseGuardrail { + constructor() { + super("append", { priority: 20 }); + } + + override async preCall(payload: unknown) { + return { modifiedPayload: { ...(payload as Record), seen: true } }; + } + } + + const registry = new GuardrailRegistry(); + registry.register(new SilentGuardrail()); + registry.register(new AppendGuardrail()); + + const result = await registry.runPreCallHooks({ messages: [] }, {}); + + assert.equal((result.payload as Record).seen, true); + assert.equal(result.results.length, 2); + assert.equal(result.results[0]?.modified, false, "silent guardrail ran first, modified nothing"); + assert.equal(result.results[1]?.modified, true, "the later guardrail still applied"); +}); diff --git a/tests/unit/heap-benchmark-corpus.test.ts b/tests/unit/heap-benchmark-corpus.test.ts new file mode 100644 index 0000000000..eb53ad06b3 --- /dev/null +++ b/tests/unit/heap-benchmark-corpus.test.ts @@ -0,0 +1,69 @@ +// Guards the #7847 heap-benchmark corpus (scripts/perf/agentPayloadCorpus.ts). +// +// The benchmark's whole value is before/after comparability: if the corpus drifts between runs, +// a "10 MiB improvement" could just be a smaller payload. These tests lock the two properties +// that comparability depends on — byte-stability across runs, and the incident wire size. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { buildAgentPayload, INCIDENT_SHAPE } = await import("../../scripts/perf/agentPayloadCorpus.ts"); + +const wireBytes = (v: unknown) => Buffer.byteLength(JSON.stringify(v), "utf8"); + +test("corpus is byte-identical across repeated builds (no Math.random)", () => { + const a = buildAgentPayload(40, 6, 30); + const b = buildAgentPayload(40, 6, 30); + assert.equal( + JSON.stringify(a), + JSON.stringify(b), + "corpus must be deterministic or before/after heap numbers are not comparable" + ); +}); + +test("corpus is byte-identical across separate module instances", async () => { + // A fresh import must not reseed differently (e.g. from a module-level counter). + const fresh = await import(`../../scripts/perf/agentPayloadCorpus.ts?cachebust=${1}`); + assert.equal( + JSON.stringify(buildAgentPayload(20, 3, 15)), + JSON.stringify(fresh.buildAgentPayload(20, 3, 15)) + ); +}); + +test("default shape reproduces the #7847 incident (3.05 MiB, 729 messages, 86 tools)", () => { + assert.equal(INCIDENT_SHAPE.messages, 729); + assert.equal(INCIDENT_SHAPE.tools, 86); + + const body = buildAgentPayload() as { messages: unknown[]; tools: unknown[] }; + assert.equal(body.messages.length, 729); + assert.equal(body.tools.length, 86); + + // The incident payload was 3.05 MiB. Allow a small band so unrelated shape tweaks do not + // fail the suite, but catch a drift large enough to invalidate the comparison. + const mib = wireBytes(body) / (1024 * 1024); + assert.ok( + mib > 2.9 && mib < 3.2, + `expected ~3.05 MiB to match the incident, got ${mib.toFixed(2)} MiB — recalibrate INCIDENT_SHAPE.contentWords` + ); +}); + +test("payload is shaped like a coding-agent request (alternating roles, tool schemas)", () => { + const body = buildAgentPayload(6, 2, 5) as { + messages: { role: string; content: string }[]; + tools: { type: string; function: { name: string; parameters: unknown } }[]; + }; + assert.deepEqual( + body.messages.map((m) => m.role), + ["user", "assistant", "user", "assistant", "user", "assistant"] + ); + assert.ok(body.messages.every((m) => m.content.length > 0)); + assert.equal(body.tools[0].type, "function"); + assert.equal(body.tools[0].function.name, "tool_0"); + assert.ok(body.tools[0].function.parameters, "tools must carry a JSON schema — they dominate size"); +}); + +test("size scales with the knobs the benchmark exposes", () => { + const small = wireBytes(buildAgentPayload(10, 2, 20)); + assert.ok(wireBytes(buildAgentPayload(20, 2, 20)) > small, "more messages must grow the payload"); + assert.ok(wireBytes(buildAgentPayload(10, 8, 20)) > small, "more tools must grow the payload"); + assert.ok(wireBytes(buildAgentPayload(10, 2, 80)) > small, "longer content must grow the payload"); +}); diff --git a/tests/unit/i18n-glossary-consistency-check.test.ts b/tests/unit/i18n-glossary-consistency-check.test.ts index af3a46fa0e..0b9b8a8f2d 100644 --- a/tests/unit/i18n-glossary-consistency-check.test.ts +++ b/tests/unit/i18n-glossary-consistency-check.test.ts @@ -4,6 +4,10 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { checkGlossaryConsistency } from "../../scripts/i18n/check-glossary-consistency.mjs"; +import { + hasUnblockedOccurrence, + normalizeLocaleText, +} from "../../scripts/i18n/glossary-normalize.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, "..", ".."); @@ -103,10 +107,89 @@ test("real zh-CN.json + real glossary + real protected terms pass the gate", () const realProtected = JSON.parse( readFileSync(path.join(ROOT, "scripts/i18n/glossary/protected-terms.json"), "utf8") ); - const { violations } = checkGlossaryConsistency( - realMessages, - realGlossary, - realProtected.terms - ); + const { violations } = checkGlossaryConsistency(realMessages, realGlossary, realProtected.terms); assert.deepEqual(violations, []); }); + +// --------------------------------------------------------------------------- +// zh-TW terminology normalization +// --------------------------------------------------------------------------- + +const zhTwGlossary = { + version: 1, + locale: "zh-TW", + terms: { + type: { canonical: "類型", synonyms: ["型別"], blockedPrefixes: ["模", "本"] }, + upstream: { canonical: "上游", synonyms: ["上遊"] }, + }, +}; + +test("blockedPrefixes suppresses a synonym match inside an unrelated compound", () => { + // 模型別名 ("model alias") contains 型別 across the character boundary, and + // 基本型別 is the correct rendering of a programming data type. + const messages = { common: { alias: "模型別名", primitive: "基本型別值" } }; + const { violations } = checkGlossaryConsistency(messages, zhTwGlossary, []); + assert.deepEqual(violations, []); +}); + +test("blockedPrefixes still flags the synonym in a genuine UI label", () => { + const messages = { common: { eventType: "事件型別" } }; + const { violations } = checkGlossaryConsistency(messages, zhTwGlossary, []); + assert.equal(violations.length, 1); + assert.equal(violations[0].concept, "type"); + assert.equal(violations[0].canonical, "類型"); +}); + +test("a value mixing a blocked and an unblocked occurrence is still flagged", () => { + const messages = { common: { mixed: "模型別名與事件型別" } }; + const { violations } = checkGlossaryConsistency(messages, zhTwGlossary, []); + assert.equal(violations.length, 1); + assert.equal(violations[0].found, "型別"); +}); + +test("hasUnblockedOccurrence handles a synonym at index 0", () => { + assert.equal(hasUnblockedOccurrence("型別標籤", "型別", ["模", "本"]), true); + assert.equal(hasUnblockedOccurrence("模型別名", "型別", ["模", "本"]), false); + assert.equal(hasUnblockedOccurrence("沒有相關詞", "型別", []), false); +}); + +test("normalizeLocaleText rewrites mainland habits and wrong-homophone conversions", () => { + assert.equal(normalizeLocaleText("默認的儀錶板緩存", "zh-TW"), "預設的儀表板快取"); + // 上遊 is not a Chinese word — the correct rendering of "upstream" is 上游. + assert.equal(normalizeLocaleText("向上遊發起請求", "zh-TW"), "向上游發起請求"); +}); + +test("normalizeLocaleText never corrupts legitimate uses of a blocked term", () => { + // Regression guard for the blanket /代碼/g rule this replaced: it turned + // 控制代碼 (handle) into 控制程式碼 and 語系代碼 (locale code) into + // 語系程式碼. 代碼 is seeded with no synonyms, so it must pass through. + assert.equal(normalizeLocaleText("控制代碼與語系代碼", "zh-TW"), "控制代碼與語系代碼"); + assert.equal(normalizeLocaleText("模型別名與事件型別", "zh-TW"), "模型別名與事件類型"); + assert.equal(normalizeLocaleText("基本型別值", "zh-TW"), "基本型別值"); +}); + +test("normalizeLocaleText leaves locales without a glossary untouched", () => { + assert.equal(normalizeLocaleText("默認", "de"), "默認"); + assert.equal(normalizeLocaleText("默認", ""), "默認"); +}); + +test("real zh-TW.json + real zh-TW glossary pass the gate", () => { + const realMessages = JSON.parse( + readFileSync(path.join(ROOT, "src/i18n/messages/zh-TW.json"), "utf8") + ); + const realGlossary = JSON.parse( + readFileSync(path.join(ROOT, "scripts/i18n/glossary/zh-TW.json"), "utf8") + ); + const realProtected = JSON.parse( + readFileSync(path.join(ROOT, "scripts/i18n/glossary/protected-terms.json"), "utf8") + ); + const { violations } = checkGlossaryConsistency(realMessages, realGlossary, realProtected.terms); + assert.deepEqual(violations, []); +}); + +test("regression: src/i18n/messages/zh-TW.json is free of the retired renderings", () => { + const raw = readFileSync(path.join(ROOT, "src/i18n/messages/zh-TW.json"), "utf8"); + for (const retired of ["默認", "內存", "儀錶板", "鏈接", "上遊", "後臺", "供應商", "提供商"]) { + assert.equal(raw.includes(retired), false, `zh-TW catalog still contains ${retired}`); + } +}); diff --git a/tests/unit/i18n-missing-placeholder-fallback.test.ts b/tests/unit/i18n-missing-placeholder-fallback.test.ts index f600ea49f3..601982c2ce 100644 --- a/tests/unit/i18n-missing-placeholder-fallback.test.ts +++ b/tests/unit/i18n-missing-placeholder-fallback.test.ts @@ -43,46 +43,13 @@ function collectPlaceholderLeaves(node: unknown, pathPrefix: string, out: string } // --------------------------------------------------------------------------- -// 1. Focused repro: the exact keys from the issue report +// 1. (Retired) The original repro asserted zh-TW.json STILL carried raw +// __MISSING__: placeholders. That translation backlog has since been filled, so +// the sentinel no longer ships on disk — the invariant "no locale has a raw +// __MISSING__: leaf" (test 3 below) is the durable guard. Keeping a test that +// requires the backlog to EXIST would fail exactly when the content is healthy. // --------------------------------------------------------------------------- -test("#7258 repro: a raw __MISSING__: placeholder is detected before the fix (deepMergeFallback) is exercised", () => { - // This originally loaded the real zh-TW.json and asserted it still carried - // leftover __MISSING__: placeholders (the translation content backlog that - // was present when #7258 was filed). #8024 completed the Traditional - // Chinese translation to 100%, invalidating that premise — and re-coupling - // this repro to whatever completeness zh-TW.json happens to have on a given - // day (it necessarily carries fresh __MISSING__: stubs again whenever - // scripts/i18n/sync-ui-keys.mjs discovers new keys, until those are - // translated) would make the test flaky either way. - // - // Use a synthetic fixture instead — same style as the deepMergeFallback - // fixtures below — standing in for a locale JSON shipped with untranslated - // content, the exact shape scripts/i18n/sync-ui-keys.mjs produces for any - // key a locale doesn't have yet. This proves the same underlying behavior - // the repro always proved: collectPlaceholderLeaves() finds a raw, - // untranslated placeholder leaf when the fix (deepMergeFallback) has not - // been exercised on it. - const rawLocaleFixture: Record = { - settings: { - localUsageCommand: `${PLACEHOLDER_PREFIX}Run this command locally`, - }, - }; - - const leaves: string[] = []; - collectPlaceholderLeaves(rawLocaleFixture, "", leaves); - - assert.ok( - leaves.length > 0, - "expected the raw locale fixture to still contain __MISSING__: placeholders before deepMergeFallback is applied" - ); - assert.deepEqual( - leaves, - ["settings.localUsageCommand"], - "collectPlaceholderLeaves should surface the exact dotted path of the raw placeholder" - ); -}); - test("#7258: deepMergeFallback replaces an untranslated __MISSING__ placeholder with the EN fallback value", () => { const target: Record = { localUsageCommand: `${PLACEHOLDER_PREFIX}Run this command locally`, diff --git a/tests/unit/json-size-exactness.test.ts b/tests/unit/json-size-exactness.test.ts new file mode 100644 index 0000000000..dc582bb09d Binary files /dev/null and b/tests/unit/json-size-exactness.test.ts differ diff --git a/tests/unit/kimi-cn-region-baseurl.test.ts b/tests/unit/kimi-cn-region-baseurl.test.ts new file mode 100644 index 0000000000..89650105f9 --- /dev/null +++ b/tests/unit/kimi-cn-region-baseurl.test.ts @@ -0,0 +1,50 @@ +// #7447 — Kimi/Moonshot CN-region API keys were rejected because the built-in +// "kimi"/"moonshot" registry entries are hard-coded to the international host +// (api.moonshot.ai) and neither provider exposed a base-URL field at +// Add-connection time, so a CN-region key (issued on platform.kimi.com / +// moonshot.cn — a separate account/keyspace) had no supported way to point a +// new connection at api.moonshot.cn. Fix: expose the existing generic +// providerSpecificData.baseUrl override affordance for "kimi" and "moonshot" +// via CONFIGURABLE_BASE_URL_PROVIDERS, defaulting to the unchanged +// international host so existing users see no behavior change. +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getRegistryEntry } from "../../open-sse/config/providerRegistry.ts"; +import { + isBaseUrlConfigurableProvider, + getProviderBaseUrlDefault, + getProviderBaseUrlPlaceholder, +} from "../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts"; + +test("kimi/moonshot registry entries still default to the international api.moonshot.ai host", () => { + const kimi = getRegistryEntry("kimi"); + const moonshot = getRegistryEntry("moonshot"); + assert.ok(kimi, "expected a registered 'kimi' provider entry"); + assert.ok(moonshot, "expected a registered 'moonshot' provider entry"); + assert.equal(kimi!.baseUrl, "https://api.moonshot.ai/v1/chat/completions"); + assert.equal(moonshot!.baseUrl, "https://api.moonshot.ai/v1/chat/completions"); +}); + +test("kimi and moonshot are base-URL configurable at Add-connection time (regression guard for #7447)", () => { + assert.equal( + isBaseUrlConfigurableProvider("kimi"), + true, + "expected 'kimi' to be base-URL configurable so a CN-region key can be pointed at api.moonshot.cn" + ); + assert.equal( + isBaseUrlConfigurableProvider("moonshot"), + true, + "expected 'moonshot' to be base-URL configurable so a CN-region key can be pointed at api.moonshot.cn" + ); +}); + +test("default base URL for kimi/moonshot stays international (no behavior change for existing users)", () => { + assert.equal(getProviderBaseUrlDefault("kimi"), "https://api.moonshot.ai/v1"); + assert.equal(getProviderBaseUrlDefault("moonshot"), "https://api.moonshot.ai/v1"); +}); + +test("placeholder surfaces the CN-region alternative host for kimi/moonshot", () => { + assert.equal(getProviderBaseUrlPlaceholder("kimi"), "https://api.moonshot.cn/v1"); + assert.equal(getProviderBaseUrlPlaceholder("moonshot"), "https://api.moonshot.cn/v1"); +}); diff --git a/tests/unit/kiro-auto-import-name-dedup-3615.test.ts b/tests/unit/kiro-auto-import-name-dedup-3615.test.ts index e491b6d309..f354514acc 100644 --- a/tests/unit/kiro-auto-import-name-dedup-3615.test.ts +++ b/tests/unit/kiro-auto-import-name-dedup-3615.test.ts @@ -35,8 +35,15 @@ test.after(() => { import { deriveKiroConnectionName, findKiroConnectionByProfileArn, + resolveKiroCliAuthMethod, } from "../../src/app/api/oauth/kiro/auto-import/route.ts"; +test("kiro-cli source keeps Builder ID and IdC as distinct auth methods", () => { + assert.equal(resolveKiroCliAuthMethod(undefined), "builder-id"); + assert.equal(resolveKiroCliAuthMethod(null), "builder-id"); + assert.equal(resolveKiroCliAuthMethod("arn:aws:codewhisperer:eu-central-1:1:profile/IDC"), "idc"); +}); + // ── (a) Display name derivation ─────────────────────────────────────────────── test("derives email as name when email is present", () => { @@ -132,10 +139,7 @@ test("findKiroConnectionByProfileArn returns the matching connection", async () }); test("findKiroConnectionByProfileArn returns null when no match exists", async () => { - const result = await findKiroConnectionByProfileArn( - [fakeConnectionNoArn], - FAKE_PROFILE_ARN - ); + const result = await findKiroConnectionByProfileArn([fakeConnectionNoArn], FAKE_PROFILE_ARN); assert.equal(result, null); }); @@ -145,9 +149,6 @@ test("findKiroConnectionByProfileArn returns null for empty connection list", as }); test("findKiroConnectionByProfileArn returns null when profileArn arg is undefined", async () => { - const result = await findKiroConnectionByProfileArn( - [fakeConnectionWithArn], - undefined - ); + const result = await findKiroConnectionByProfileArn([fakeConnectionWithArn], undefined); assert.equal(result, null); }); diff --git a/tests/unit/kiro-available-models.test.ts b/tests/unit/kiro-available-models.test.ts index 58a0841f34..d1cf7ae0cb 100644 --- a/tests/unit/kiro-available-models.test.ts +++ b/tests/unit/kiro-available-models.test.ts @@ -7,9 +7,10 @@ import { buildKiroModelsEndpoints, fetchKiroAvailableModels, clearKiroModelCache, + isObsoleteKiroModelAlias, } from "../../open-sse/services/kiroModels.ts"; -const FALLBACK = [{ id: "auto-kiro", name: "Auto" }, { id: "claude-sonnet-4.6" }]; +const FALLBACK = [{ id: "claude-sonnet-4.5" }, { id: "deepseek-3.2" }]; beforeEach(() => { clearKiroModelCache(); @@ -75,14 +76,7 @@ test("fetchKiroAvailableModels: simple (Builder ID) account, us-east-1, origin-o }); assert.equal(result.source, "api"); - assert.deepEqual(result.models.map((m) => m.id).sort(), [ - "auto", - "auto-thinking", - "claude-sonnet-4.6", - "claude-sonnet-4.6-agentic", - "claude-sonnet-4.6-thinking", - "claude-sonnet-4.6-thinking-agentic", - ]); + assert.deepEqual(result.models.map((m) => m.id).sort(), ["auto", "claude-sonnet-4.6"]); assert.deepEqual(calls, [ "https://q.us-east-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR", ]); @@ -106,12 +100,7 @@ test("fetchKiroAvailableModels: IAM Identity Center account, region-matched endp assert.equal(result.source, "api"); assert.deepEqual( result.models.map((m) => m.id), - [ - "claude-opus-4.8", - "claude-opus-4.8-thinking", - "claude-opus-4.8-agentic", - "claude-opus-4.8-thinking-agentic", - ] + ["claude-opus-4.8"] ); assert.equal( calls[0], @@ -142,12 +131,7 @@ test("fetchKiroAvailableModels: retries with profileArn when origin-only fails", assert.equal(result.source, "api"); assert.deepEqual( result.models.map((m) => m.id), - [ - "claude-sonnet-4.6", - "claude-sonnet-4.6-thinking", - "claude-sonnet-4.6-agentic", - "claude-sonnet-4.6-thinking-agentic", - ] + ["claude-sonnet-4.6"] ); // origin-only attempted first, then profileArn retry. assert.equal(calls.length, 2); @@ -155,6 +139,58 @@ test("fetchKiroAvailableModels: retries with profileArn when origin-only fails", assert.ok(calls[1].includes("profileArn=arn%3Aaws%3Acodewhisperer")); }); +test("fetchKiroAvailableModels only exposes a functional Thinking alias", async () => { + const fetchImpl = (async () => + jsonResponse({ + models: [ + { modelId: "claude-sonnet-5" }, + { modelId: "claude-sonnet-4.5" }, + { modelId: "deepseek-3.2" }, + ], + })) as unknown as typeof fetch; + + const result = await fetchKiroAvailableModels({ + accessToken: "tok", + providerSpecificData: { authMethod: "builder-id" }, + fetchImpl, + }); + + assert.deepEqual( + result.models.map((model) => model.id), + ["claude-sonnet-5", "claude-sonnet-5-thinking", "claude-sonnet-4.5", "deepseek-3.2"] + ); +}); + +test("isObsoleteKiroModelAlias filters stale cached aliases", () => { + assert.equal(isObsoleteKiroModelAlias("auto-kiro"), true); + assert.equal(isObsoleteKiroModelAlias("claude-sonnet-5-agentic"), true); + assert.equal(isObsoleteKiroModelAlias("claude-sonnet-4.5-thinking"), true); + assert.equal(isObsoleteKiroModelAlias("claude-sonnet-5-thinking"), false); + assert.equal(isObsoleteKiroModelAlias("claude-sonnet-4.5"), false); +}); + +test("fetchKiroAvailableModels sends auth-method headers for API key and External IdP", async () => { + const seen: Array = []; + const fetchImpl = (async (_url: string, init?: RequestInit) => { + seen.push(new Headers(init?.headers)); + return jsonResponse({ models: [{ modelId: "claude-sonnet-5" }] }); + }) as unknown as typeof fetch; + + await fetchKiroAvailableModels({ + accessToken: "api-key", + providerSpecificData: { authMethod: "api_key", clientId: "api-client" }, + fetchImpl, + }); + await fetchKiroAvailableModels({ + accessToken: "external-token", + providerSpecificData: { authMethod: "external_idp", clientId: "external-client" }, + fetchImpl, + }); + + assert.equal(seen[0].get("tokentype"), "API_KEY"); + assert.equal(seen[1].get("tokentype"), "EXTERNAL_IDP"); +}); + test("fetchKiroAvailableModels: falls back to static catalog when no token", async () => { const result = await fetchKiroAvailableModels({ accessToken: "", @@ -164,7 +200,7 @@ test("fetchKiroAvailableModels: falls back to static catalog when no token", asy assert.equal(result.source, "fallback"); assert.deepEqual( result.models.map((m) => m.id), - ["auto-kiro", "claude-sonnet-4.6"] + ["claude-sonnet-4.5", "deepseek-3.2"] ); }); @@ -180,6 +216,6 @@ test("fetchKiroAvailableModels: falls back when every upstream attempt fails", a assert.equal(result.source, "fallback"); assert.deepEqual( result.models.map((m) => m.id), - ["auto-kiro", "claude-sonnet-4.6"] + ["claude-sonnet-4.5", "deepseek-3.2"] ); }); diff --git a/tests/unit/kiro-connection-identity.test.ts b/tests/unit/kiro-connection-identity.test.ts new file mode 100644 index 0000000000..c5ac2b134f --- /dev/null +++ b/tests/unit/kiro-connection-identity.test.ts @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { findKiroConnectionByIdentity } from "@/lib/oauth/kiroConnectionIdentity"; + +const connections = [ + { + id: "profile-match", + authType: "oauth", + name: "Kiro profile", + email: "profile@example.com", + providerSpecificData: { profileArn: "arn:aws:codewhisperer:us-east-1:1:profile/A" }, + }, + { + id: "builder-match", + authType: "oauth", + name: "Kiro Builder ID", + providerSpecificData: { clientId: "builder-client" }, + }, + { + id: "email-match", + authType: "oauth", + name: "Kiro Social", + email: "social@example.com", + providerSpecificData: {}, + }, + { + id: "name-match", + authType: "apikey", + name: "Kiro API Key (us-east-1, abc123)", + providerSpecificData: { authMethod: "api_key" }, + }, +]; + +test("findKiroConnectionByIdentity prefers an exact trimmed profile ARN", () => { + const match = findKiroConnectionByIdentity(connections, { + profileArn: " arn:aws:codewhisperer:us-east-1:1:profile/A ", + clientId: "builder-client", + }); + assert.equal(match?.id, "profile-match"); +}); + +test("findKiroConnectionByIdentity deduplicates profileless Builder ID by clientId", () => { + const match = findKiroConnectionByIdentity(connections, { clientId: " builder-client " }); + assert.equal(match?.id, "builder-match"); +}); + +test("findKiroConnectionByIdentity falls back to email and API-key fingerprint name", () => { + assert.equal( + findKiroConnectionByIdentity(connections, { email: "SOCIAL@EXAMPLE.COM" })?.id, + "email-match" + ); + assert.equal( + findKiroConnectionByIdentity(connections, { + name: "kiro api key (us-east-1, ABC123)", + })?.id, + "name-match" + ); +}); + +test("findKiroConnectionByIdentity never matches empty identity values", () => { + assert.equal(findKiroConnectionByIdentity(connections, {}), null); +}); + +test("findKiroConnectionByIdentity never overwrites a different authentication type", () => { + assert.equal( + findKiroConnectionByIdentity(connections, { + authType: "apikey", + profileArn: "arn:aws:codewhisperer:us-east-1:1:profile/A", + email: "profile@example.com", + }), + null + ); + assert.equal( + findKiroConnectionByIdentity(connections, { + authType: "oauth", + name: "Kiro API Key (us-east-1, abc123)", + }), + null + ); +}); diff --git a/tests/unit/kiro-iam-profilearn-usage.test.ts b/tests/unit/kiro-iam-profilearn-usage.test.ts index 9bfc7f4132..50e3f265b1 100644 --- a/tests/unit/kiro-iam-profilearn-usage.test.ts +++ b/tests/unit/kiro-iam-profilearn-usage.test.ts @@ -131,6 +131,52 @@ test("discoverKiroProfileArn returns undefined for empty profiles or non-ok resp } }); +test("getKiroUsage fetches Builder ID quotas without a profile ARN", async () => { + const originalFetch = globalThis.fetch; + const authMethods = ["builder-id"]; + const requests: Array<{ target: string; body: Record }> = []; + + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + const headers = init?.headers as Record; + const target = String(headers?.["x-amz-target"] || ""); + const body = JSON.parse(String(init?.body || "{}")) as Record; + requests.push({ target, body }); + return new Response( + JSON.stringify({ + subscriptionInfo: { subscriptionTitle: "Kiro Pro" }, + usageBreakdownList: [ + { + resourceType: "AGENTIC_REQUEST", + currentUsageWithPrecision: 3, + usageLimitWithPrecision: 10, + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + try { + for (const authMethod of authMethods) { + const result = (await getKiroUsage("profileless-token", { + authMethod, + region: "us-east-1", + })) as { plan?: string; quotas?: Record }; + assert.equal(result.plan, "Kiro Pro"); + assert.equal(result.quotas?.agentic_request.used, 3); + assert.equal(result.quotas?.agentic_request.total, 10); + } + + assert.equal(requests.length, authMethods.length); + for (const request of requests) { + assert.equal(request.target, "AmazonCodeWhispererService.GetUsageLimits"); + assert.equal("profileArn" in request.body, false); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + // Regression: when a Kiro account added via Google/GitHub social-auth (authMethod "imported" // with provider "Google" or "Github" — set by /api/oauth/kiro/social-exchange/route.ts) has its // token rejected by the AWS CodeWhisperer quota API (401/403), surface a clear "auth expired, diff --git a/tests/unit/kiro-iam-region.test.ts b/tests/unit/kiro-iam-region.test.ts index a3e8bf8611..d252ee91d7 100644 --- a/tests/unit/kiro-iam-region.test.ts +++ b/tests/unit/kiro-iam-region.test.ts @@ -91,7 +91,28 @@ test("kiro.postExchange returns null when no profile is available (AWS Builder I } }); -test("kiro.postExchange never throws on network failure", async () => { +test("kiro.postExchange skips profile discovery for an identified Builder ID flow", async () => { + const originalFetch = global.fetch; + let calls = 0; + global.fetch = (async () => { + calls += 1; + throw new Error("Builder ID must not probe ListAvailableProfiles"); + }) as typeof fetch; + + try { + const extra = await kiro.postExchange({ + access_token: "builder-token", + _region: "us-east-1", + _authMethod: "builder-id", + }); + assert.equal(extra, null); + assert.equal(calls, 0); + } finally { + global.fetch = originalFetch; + } +}); + +test("kiro.postExchange never throws on network failure", async () => { const originalFetch = global.fetch; global.fetch = (async () => { throw new Error("network down"); @@ -112,6 +133,7 @@ test("kiro.mapTokens stores the discovered profileArn from postExchange extra", mapped.providerSpecificData.profileArn, "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ" ); + assert.equal(mapped.providerSpecificData.authMethod, "idc"); }); test("kiro.mapTokens omits profileArn when postExchange found none", () => { @@ -120,4 +142,5 @@ test("kiro.mapTokens omits profileArn when postExchange found none", () => { null ); assert.equal("profileArn" in mapped.providerSpecificData, false); + assert.equal(mapped.providerSpecificData.authMethod, "builder-id"); }); diff --git a/tests/unit/kiro-model-aliases.test.ts b/tests/unit/kiro-model-aliases.test.ts new file mode 100644 index 0000000000..b9635719c6 --- /dev/null +++ b/tests/unit/kiro-model-aliases.test.ts @@ -0,0 +1,18 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildKiroPayload } from "../../open-sse/translator/request/openai-to-kiro.ts"; + +const body = { messages: [{ role: "user", content: "Hello" }] }; + +test("buildKiroPayload rejects removed or non-functional Kiro aliases", () => { + assert.throws(() => buildKiroPayload("auto-kiro", body, true, {}), /not a real Kiro/); + assert.throws( + () => buildKiroPayload("claude-sonnet-5-agentic", body, true, {}), + /agentic aliases are not supported/ + ); + assert.throws( + () => buildKiroPayload("claude-sonnet-4.5-thinking", body, true, {}), + /does not support the '-thinking' alias/ + ); +}); diff --git a/tests/unit/kiro-social-poll.test.ts b/tests/unit/kiro-social-poll.test.ts new file mode 100644 index 0000000000..359cfa6d0a --- /dev/null +++ b/tests/unit/kiro-social-poll.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { classifyKiroSocialPoll, getNextKiroSocialPollInterval } from "@/lib/oauth/kiroSocialPoll"; + +test("slow_down permanently increases the polling interval for this authorization flow", () => { + const slowed = getNextKiroSocialPollInterval(5_000, "slow_down"); + assert.equal(slowed, 10_000); + assert.equal(getNextKiroSocialPollInterval(slowed, "authorization_pending"), 10_000); + assert.equal(getNextKiroSocialPollInterval(slowed, "network_error"), 10_000); +}); + +test("classifyKiroSocialPoll keeps only documented pending states retryable", () => { + assert.deepEqual(classifyKiroSocialPoll(false, 400, { error: "authorization_pending" }), { + kind: "pending", + error: "authorization_pending", + }); + assert.deepEqual(classifyKiroSocialPoll(false, 429, { error: "slow_down" }), { + kind: "pending", + error: "slow_down", + }); +}); + +test("classifyKiroSocialPoll stops on denied, expired and malformed responses", () => { + assert.deepEqual(classifyKiroSocialPoll(false, 403, { error: "access_denied" }), { + kind: "error", + error: "access_denied", + status: 403, + }); + assert.deepEqual(classifyKiroSocialPoll(true, 200, { error: "expired_token" }), { + kind: "error", + error: "expired_token", + status: 400, + }); + assert.deepEqual(classifyKiroSocialPoll(true, 200, {}), { + kind: "error", + error: "invalid_token_response", + status: 502, + }); +}); + +test("classifyKiroSocialPoll accepts a token response", () => { + assert.deepEqual(classifyKiroSocialPoll(true, 200, { accessToken: "token" }), { + kind: "success", + }); +}); diff --git a/tests/unit/lmarena-provider.test.ts b/tests/unit/lmarena-provider.test.ts index 3fb8081b3e..e778e8821b 100644 --- a/tests/unit/lmarena-provider.test.ts +++ b/tests/unit/lmarena-provider.test.ts @@ -30,7 +30,7 @@ const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9 type LMArenaExecutorTestAccess = { provider: string; buildUrl: (model: string, credentials: unknown) => string; - buildHeaders: (model: string, credentials: unknown, body: unknown) => Record; + buildRequestHeaders: (model: string, credentials: unknown, body: unknown) => Record; transformRequest: ( body: unknown, model: string, @@ -133,7 +133,7 @@ describe("LMArena Executor", () => { it("builds headers with cookie", () => { const executor = new LMArenaExecutor(); - const headers = access(executor).buildHeaders("gpt-4", { cookie: "session=abc123" }, {}); + const headers = access(executor).buildRequestHeaders("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"); @@ -142,7 +142,7 @@ describe("LMArena Executor", () => { it("builds headers without cookie when not provided", () => { const executor = new LMArenaExecutor(); - const headers = access(executor).buildHeaders("gpt-4", {}, {}); + const headers = access(executor).buildRequestHeaders("gpt-4", {}, {}); assert.ok(!headers.Cookie, "Should not have Cookie header when no cookie provided"); }); @@ -151,19 +151,19 @@ describe("LMArena Executor", () => { const ex = access(executor); // Direct cookie field - let headers = ex.buildHeaders("gpt-4", { cookie: "session=abc" }, {}); + let headers = ex.buildRequestHeaders("gpt-4", { cookie: "session=abc" }, {}); assert.equal(headers.Cookie, "session=abc"); // apiKey field (dashboard form) - headers = ex.buildHeaders("gpt-4", { apiKey: "session=def" }, {}); + headers = ex.buildRequestHeaders("gpt-4", { apiKey: "session=def" }, {}); assert.equal(headers.Cookie, "session=def"); // providerSpecificData.cookie - headers = ex.buildHeaders("gpt-4", { providerSpecificData: { cookie: "session=ghi" } }, {}); + headers = ex.buildRequestHeaders("gpt-4", { providerSpecificData: { cookie: "session=ghi" } }, {}); assert.equal(headers.Cookie, "session=ghi"); // Priority: direct > apiKey > providerSpecificData - headers = ex.buildHeaders("gpt-4", { cookie: "session=abc", apiKey: "session=def" }, {}); + headers = ex.buildRequestHeaders("gpt-4", { cookie: "session=abc", apiKey: "session=def" }, {}); assert.equal(headers.Cookie, "session=abc"); }); diff --git a/tests/unit/lmarena-split-cookie-4271.test.ts b/tests/unit/lmarena-split-cookie-4271.test.ts index 85c4350f50..5d6c216aef 100644 --- a/tests/unit/lmarena-split-cookie-4271.test.ts +++ b/tests/unit/lmarena-split-cookie-4271.test.ts @@ -22,7 +22,7 @@ import { getWebSessionCredentialRequirement } from "../../src/shared/providers/w function cookieHeaderFor(credentials: unknown): string | undefined { const executor = new LMArenaExecutor(); - const headers = (executor as any).buildHeaders("gpt-4", credentials, {}); + const headers = (executor as any).buildRequestHeaders("gpt-4", credentials, {}); return headers.Cookie; } diff --git a/tests/unit/model-capabilities-path-shaped-vision-8032.test.ts b/tests/unit/model-capabilities-path-shaped-vision-8032.test.ts new file mode 100644 index 0000000000..ae53ebef86 --- /dev/null +++ b/tests/unit/model-capabilities-path-shaped-vision-8032.test.ts @@ -0,0 +1,169 @@ +/** + * #8032 — path-shaped custom/routed multimodal ids (e.g. `cp/cline-pass/kimi-k3`) + * must resolve supportsVision=true from leaf static/registry metadata even when + * models.dev sync stores attachment=false with empty modalities for that key. + * + * Without this, Vision Bridge activates and reroutes to openai/gpt-4o-mini. + */ +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-path-shaped-vision-8032-")); +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"); + +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 }); +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#8032 cp/cline-pass/kimi-k3: attachment=false empty modalities → vision via leaf/registry", () => { + modelsDevSync.saveModelsDevCapabilities({ + clinepass: { + "cline-pass/kimi-k3": buildCapability({ + attachment: false, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + tool_call: true, + limit_context: 1048576, + }), + }, + }); + + const caps = modelCapabilities.getResolvedModelCapabilities("cp/cline-pass/kimi-k3"); + assert.equal(caps.supportsVision, true); +}); + +test("#8032 leaf fallback: cline-pass/kimi-k3 resolves MODEL_SPECS kimi-k3 vision", () => { + // No synced row — leaf static spec + registry must still confirm vision. + const caps = modelCapabilities.getResolvedModelCapabilities("cline-pass/kimi-k3"); + assert.equal(caps.supportsVision, true); +}); + +test("#8032 leaf fallback is vision-only: aihorde/deepseek/deepseek-v4-flash keeps tools=false", () => { + // Regression guard from PR review (#8495 / #8212): shared getStaticSpec leaf + // lookup previously promoted this live-discovered AI Horde id to the real + // DeepSeek V4 Flash supportsTools:true spec. Leaf lookup must stay vision-only. + const caps = modelCapabilities.getResolvedModelCapabilities( + "aihorde/deepseek/deepseek-v4-flash" + ); + assert.equal(caps.toolCalling, false); + assert.equal(caps.supportsTools, false); + assert.equal( + caps.contextWindow, + null, + "leaf MODEL_SPECS context must not leak onto unrelated path-shaped ids" + ); +}); + +test("#8032 #4071 text-only override still wins over path-shaped sync noise", () => { + modelsDevSync.saveModelsDevCapabilities({ + xiaomi: { + "mimo-v2.5-pro": buildCapability({ + attachment: true, + modalities_input: JSON.stringify(["text", "image"]), + modalities_output: JSON.stringify(["text"]), + }), + }, + }); + + const caps = modelCapabilities.getResolvedModelCapabilities("xiaomi/mimo-v2.5-pro"); + assert.equal(caps.supportsVision, false); +}); + +test("#8032 Vision Bridge skips describe/reroute for cp/cline-pass/kimi-k3 with image", async () => { + modelsDevSync.saveModelsDevCapabilities({ + clinepass: { + "cline-pass/kimi-k3": buildCapability({ + attachment: false, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + tool_call: true, + }), + }, + }); + + const model = "cp/cline-pass/kimi-k3"; + assert.equal(modelCapabilities.getResolvedModelCapabilities(model).supportsVision, true); + + const { VisionBridgeGuardrail } = await import("../../src/lib/guardrails/visionBridge.ts"); + let visionCallCount = 0; + const guardrail = new VisionBridgeGuardrail({ + deps: { + getSettings: async () => ({ + visionBridgeEnabled: true, + visionBridgeModel: "openai/gpt-4o-mini", + visionBridgePrompt: "Describe this image concisely.", + visionBridgeTimeout: 30000, + visionBridgeMaxImages: 10, + }), + callVisionModel: async () => { + visionCallCount++; + return "should not run"; + }, + hasUsableCredentials: async () => null, + }, + }); + + const result = await guardrail.preCall( + { + model, + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { + type: "image_url", + image_url: { url: "https://example.com/photo.png" }, + }, + ], + }, + ], + }, + { model, log: console } + ); + + assert.equal(result.block, false); + assert.equal(visionCallCount, 0); + assert.equal(result.modifiedPayload, undefined); +}); diff --git a/tests/unit/model-discovery-reasoning-levels.test.ts b/tests/unit/model-discovery-reasoning-levels.test.ts new file mode 100644 index 0000000000..1e0bd34518 --- /dev/null +++ b/tests/unit/model-discovery-reasoning-levels.test.ts @@ -0,0 +1,129 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + detectSupportedThinkingEfforts, + normalizeDiscoveredModels, +} from "@/lib/providerModels/modelDiscovery"; +import { buildProviderModelsUrl } from "@/app/api/providers/[id]/models/discoveryClientVersion"; + +// #8347: generic openai-compatible / custom-node discovery had no metadata-mapping layer +// for `supported_reasoning_levels` (CLIProxyAPI-style upstreams) or `thinking.levels`. This +// suite proves the two new shapes are parsed onto the existing `supportedThinkingEfforts` +// pipeline, that the #7694 precedence order is preserved, and that the `client_version` +// opt-in touches the model-list URL only — never inference URLs — and defaults off. + +test("supported_reasoning_levels as {effort} objects is parsed into supportedThinkingEfforts", () => { + const record = { + id: "model-a", + supported_reasoning_levels: [{ effort: "low" }, { effort: "high" }], + }; + assert.deepEqual(detectSupportedThinkingEfforts(record), ["low", "high"]); +}); + +test("supported_reasoning_levels as plain strings is parsed into supportedThinkingEfforts", () => { + const record = { + id: "model-b", + supported_reasoning_levels: ["low", "medium"], + }; + assert.deepEqual(detectSupportedThinkingEfforts(record), ["low", "medium"]); +}); + +test("thinking.levels is parsed into supportedThinkingEfforts", () => { + const record = { + id: "model-c", + thinking: { levels: ["medium", "high"] }, + }; + assert.deepEqual(detectSupportedThinkingEfforts(record), ["medium", "high"]); +}); + +test("duplicates are deduped, max canonicalizes to xhigh, unknown native tier retained", () => { + const record = { + id: "model-d", + supported_reasoning_levels: [{ effort: "max" }, { effort: "max" }, { effort: "ultra" }], + }; + assert.deepEqual(detectSupportedThinkingEfforts(record), ["xhigh", "ultra"]); +}); + +test("a malformed entry inside an otherwise-valid array is dropped, the rest survive, no throw", () => { + const record = { + id: "model-e", + supported_reasoning_levels: [{ effort: "low" }, { effort: 42 }, null, "high", 7], + }; + assert.doesNotThrow(() => detectSupportedThinkingEfforts(record)); + assert.deepEqual(detectSupportedThinkingEfforts(record), ["low", "high"]); +}); + +test("a fully malformed shape degrades to undefined instead of throwing", () => { + const record = { id: "model-f", supported_reasoning_levels: "not-an-array" }; + assert.doesNotThrow(() => detectSupportedThinkingEfforts(record)); + assert.equal(detectSupportedThinkingEfforts(record), undefined); +}); + +test("precedence: flat supportedThinkingEfforts wins over reasoning.supported_efforts and both new shapes (#7694 regression guard)", () => { + const models = normalizeDiscoveredModels([ + { + id: "model-g", + supportedThinkingEfforts: ["none"], + reasoning: { supported_efforts: ["low"] }, + supported_reasoning_levels: ["medium"], + thinking: { levels: ["high"] }, + }, + ]); + assert.deepEqual(models[0].supportedThinkingEfforts, ["none"]); +}); + +test("precedence: reasoning.supported_efforts wins over supported_reasoning_levels and thinking.levels", () => { + const models = normalizeDiscoveredModels([ + { + id: "model-h", + reasoning: { supported_efforts: ["low"] }, + supported_reasoning_levels: ["medium"], + thinking: { levels: ["high"] }, + }, + ]); + assert.deepEqual(models[0].supportedThinkingEfforts, ["low"]); +}); + +test("precedence: supported_reasoning_levels wins over thinking.levels when both present", () => { + const models = normalizeDiscoveredModels([ + { + id: "model-i", + supported_reasoning_levels: ["medium"], + thinking: { levels: ["high"] }, + }, + ]); + assert.deepEqual(models[0].supportedThinkingEfforts, ["medium"]); +}); + +test("thinking.levels alone still populates supportedThinkingEfforts via normalizeDiscoveredModels", () => { + const models = normalizeDiscoveredModels([ + { + id: "model-j", + thinking: { levels: ["high"] }, + }, + ]); + assert.deepEqual(models[0].supportedThinkingEfforts, ["high"]); +}); + +test("client_version is absent from the model-list URL by default", () => { + const url = buildProviderModelsUrl("https://example.com/v1/models", undefined); + assert.equal(url, "https://example.com/v1/models"); + assert.ok(!url.includes("client_version")); +}); + +test("client_version is present on the model-list URL only when the connection opts in", () => { + const url = buildProviderModelsUrl("https://example.com/v1/models", { + discoveryClientVersionEnabled: true, + discoveryClientVersion: "1.2.3", + }); + const parsed = new URL(url); + assert.equal(parsed.searchParams.get("client_version"), "1.2.3"); +}); + +test("client_version opt-in without an explicit version still defaults off (no gate silently mutates every request)", () => { + const url = buildProviderModelsUrl("https://example.com/v1/models", { + discoveryClientVersionEnabled: false, + discoveryClientVersion: "1.2.3", + }); + assert.ok(!url.includes("client_version")); +}); diff --git a/tests/unit/model-family-fallback-notation.test.ts b/tests/unit/model-family-fallback-notation.test.ts index b52e58e8d1..e1fdc815b2 100644 --- a/tests/unit/model-family-fallback-notation.test.ts +++ b/tests/unit/model-family-fallback-notation.test.ts @@ -10,6 +10,11 @@ import assert from "node:assert/strict"; // removed the fabricated ids; `anthropic` genuinely serves them in dot notation. const { getNextFamilyFallback } = await import("../../open-sse/services/modelFamilyFallback.ts"); +test("Opus 5 falls back to the previous Opus tier first", () => { + const next = getNextFamilyFallback("cc/claude-opus-5", new Set(["cc/claude-opus-5"])); + assert.equal(next, "claude/claude-opus-4-8"); +}); + test("Fable 5 falls back to the next-best Opus tier first (not Sonnet) — cc→claude", () => { // `cc` is an alias parseModel normalizes to the `claude` provider. const next = getNextFamilyFallback("cc/claude-fable-5", new Set(["cc/claude-fable-5"])); @@ -18,13 +23,19 @@ test("Fable 5 falls back to the next-best Opus tier first (not Sonnet) — cc→ test("Fable 5 fallback resolves to anthropic's dot-notation model id", () => { // anthropic registry exposes `claude-opus-4.8` (dot), not `claude-opus-4-8`. - const next = getNextFamilyFallback("anthropic/claude-fable-5", new Set(["anthropic/claude-fable-5"])); + const next = getNextFamilyFallback( + "anthropic/claude-fable-5", + new Set(["anthropic/claude-fable-5"]) + ); assert.equal(next, "anthropic/claude-opus-4.8"); }); test("dot-notation current model is normalized for the family lookup", () => { // anthropic/claude-opus-4.8 must find the claude-opus-4-8 family entry. - const next = getNextFamilyFallback("anthropic/claude-opus-4.8", new Set(["anthropic/claude-opus-4.8"])); + const next = getNextFamilyFallback( + "anthropic/claude-opus-4.8", + new Set(["anthropic/claude-opus-4.8"]) + ); assert.equal(next, "anthropic/claude-opus-4.7"); }); diff --git a/tests/unit/multipart-body-arraybuffer-backing.test.ts b/tests/unit/multipart-body-arraybuffer-backing.test.ts new file mode 100644 index 0000000000..b267416f06 --- /dev/null +++ b/tests/unit/multipart-body-arraybuffer-backing.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildMultipartBody } = await import("../../open-sse/handlers/audioTranscription.ts"); + +/** + * `buildMultipartBody` is handed straight to `fetch` as the request body by six + * call sites across `audioTranscription.ts` and `audioTranslation.ts`, so its + * result must satisfy `BodyInit` — which requires an `ArrayBuffer`-backed view, + * not merely "some Uint8Array". A `SharedArrayBuffer`-backed view, or a slice + * into a shared pool, is not a valid request body. + * + * The four existing `buildMultipartBody` tests in + * `audio-transcription-handler.test.ts` decode the payload and assert its + * *contents* (boundary, filename sanitization, MIME fallback). None of them + * assert the backing, which is the property the declared + * `Uint8Array` return type exists to guarantee — and the one a + * plausible "optimization" to a pooled `Buffer.concat` would break. + */ + +function audioFile(name = "clip.wav", type = "audio/wav") { + return Object.assign(new Blob([new Uint8Array([1, 2, 3, 4])], { type }), { name }); +} + +test("the assembled body is backed by a plain ArrayBuffer, never a shared one", async () => { + const { body } = await buildMultipartBody(audioFile(), { model: "whisper-1" }); + + assert.ok(body instanceof Uint8Array); + assert.ok( + body.buffer instanceof ArrayBuffer, + "a SharedArrayBuffer-backed view is not accepted as a fetch BodyInit" + ); +}); + +test("the assembled body owns its whole buffer — not a view into a shared pool", async () => { + const { body } = await buildMultipartBody(audioFile(), { model: "whisper-1" }); + + // `new Uint8Array(totalLength)` allocates exclusively. A pooled allocation + // (e.g. switching to Buffer.concat) would leave a non-zero byteOffset and a + // buffer larger than the payload, so the bytes handed to fetch would no + // longer be the whole buffer. + assert.equal(body.byteOffset, 0, "body must start at offset 0 of its own buffer"); + assert.equal( + body.byteLength, + body.buffer.byteLength, + "body must span its entire buffer, with no pooled remainder" + ); +}); + +test("the backing holds for a larger payload than a single pool slab", async () => { + // 64 KiB — comfortably past Node's 8 KiB Buffer pool threshold, so a pooled + // implementation would behave differently here than for the small case above. + const big = Object.assign(new Blob([new Uint8Array(64 * 1024)], { type: "audio/wav" }), { + name: "big.wav", + }); + const { body } = await buildMultipartBody(big, { model: "whisper-1" }); + + assert.ok(body.buffer instanceof ArrayBuffer); + assert.equal(body.byteOffset, 0); + assert.equal(body.byteLength, body.buffer.byteLength); + assert.ok(body.byteLength > 64 * 1024, "payload carries the file plus the multipart envelope"); +}); diff --git a/tests/unit/oauth-kiro-idc.test.ts b/tests/unit/oauth-kiro-idc.test.ts index 015ffe259c..b0e082fa73 100644 --- a/tests/unit/oauth-kiro-idc.test.ts +++ b/tests/unit/oauth-kiro-idc.test.ts @@ -49,6 +49,7 @@ test("kiro.requestDeviceCode returns resolved region for IDC token endpoint", as }); assert.equal(result._region, "ap-southeast-1"); + assert.equal(result._authMethod, "idc"); assert.equal(result._clientId, "client-ap"); assert.equal(result._clientSecret, "secret-ap"); @@ -80,13 +81,19 @@ test("kiro.pollToken uses region provided by extraData", async () => { { tokenUrl: "https://oidc.us-east-1.amazonaws.com/token" }, "device-code", null, - { _clientId: "cid", _clientSecret: "csecret", _region: "ap-southeast-1" } + { + _clientId: "cid", + _clientSecret: "csecret", + _region: "ap-southeast-1", + _authMethod: "idc", + } ); assert.equal(requestedUrl, "https://oidc.ap-southeast-1.amazonaws.com/token"); assert.equal(result.ok, true); assert.equal(result.data.access_token, "access"); assert.equal(result.data._region, "ap-southeast-1"); + assert.equal(result.data._authMethod, "idc"); } finally { global.fetch = originalFetch; } @@ -100,6 +107,7 @@ test("kiro.mapTokens persists region into providerSpecificData", () => { _clientId: "cid", _clientSecret: "csec", _region: "ap-southeast-1", + _authMethod: "idc", }); assert.equal(mapped.accessToken, "at"); @@ -108,6 +116,7 @@ test("kiro.mapTokens persists region into providerSpecificData", () => { assert.equal(mapped.providerSpecificData.clientId, "cid"); assert.equal(mapped.providerSpecificData.clientSecret, "csec"); assert.equal(mapped.providerSpecificData.region, "ap-southeast-1"); + assert.equal(mapped.providerSpecificData.authMethod, "idc"); }); test("kiro.mapTokens defaults region to undefined when not provided", () => { @@ -120,4 +129,5 @@ test("kiro.mapTokens defaults region to undefined when not provided", () => { }); assert.equal(mapped.providerSpecificData.region, undefined); + assert.equal(mapped.providerSpecificData.authMethod, "builder-id"); }); diff --git a/tests/unit/oauth-providers-error-handling.test.ts b/tests/unit/oauth-providers-error-handling.test.ts index fdc9644e8b..aa41173c27 100644 --- a/tests/unit/oauth-providers-error-handling.test.ts +++ b/tests/unit/oauth-providers-error-handling.test.ts @@ -240,8 +240,11 @@ test("P3: refreshWindsurfToken parses Firebase USER_DISABLED/TOKEN_EXPIRED error // ─── isUnrecoverableRefreshError consistency ────────────────────────────────── +// isUnrecoverableRefreshError moved to tokenRefresh/shared.ts in the god-file +// decomposition (tokenRefresh.ts re-exports it, so the public surface is unchanged); +// this source-text assertion has to follow it to the file that defines the body. test("isUnrecoverableRefreshError detects the normalized sentinel shape", async () => { - const src = await read("open-sse/services/tokenRefresh.ts"); + const src = await read("open-sse/services/tokenRefresh/shared.ts"); const fnMatch = src.match(/export\s+function\s+isUnrecoverableRefreshError\([\s\S]+?\n\}/); assert.ok(fnMatch, "isUnrecoverableRefreshError function body not found"); assert.match( diff --git a/tests/unit/oauth-test-config-8408.test.ts b/tests/unit/oauth-test-config-8408.test.ts new file mode 100644 index 0000000000..7bec8a8576 --- /dev/null +++ b/tests/unit/oauth-test-config-8408.test.ts @@ -0,0 +1,46 @@ +// #8408: Guard against missing OAUTH_TEST_CONFIG entries for OAuth providers +import test from "node:test"; +import assert from "node:assert/strict"; +import { OAUTH_PROVIDERS } from "../../src/shared/constants/providers/oauth.ts"; +import { OAUTH_TEST_CONFIG } from "../../src/app/api/providers/[id]/test/oauthTestConfig.ts"; + +// NOT a design decision — this is a grandfathered backlog. These six ids are simply the +// providers that still lack an OAUTH_TEST_CONFIG entry today, captured so this guard can be +// enforced from now on without a big-bang change. Each one is a candidate for the same +// treatment devin-cli and agy get here; removing an id from this list is the fix, not a +// regression. Do not add new ids to it — a provider added without a test config should fail +// this test at the time it is added, which is the entire point. +const GRANDFATHERED_WITHOUT_TEST_CONFIG = new Set([ + "qoder", + "zed", + "zed-hosted", + "trae", + "windsurf", + "xai-oauth", +]); + +test("#8408: devin-cli and agy are present in OAUTH_TEST_CONFIG", () => { + assert.ok( + (OAUTH_TEST_CONFIG as Record)["devin-cli"], + "devin-cli must have an entry in OAUTH_TEST_CONFIG" + ); + assert.ok( + (OAUTH_TEST_CONFIG as Record)["agy"], + "agy must have an entry in OAUTH_TEST_CONFIG" + ); +}); + +test("#8408: every OAuth provider ID has an OAUTH_TEST_CONFIG entry (or is grandfathered)", () => { + const providerIds = Object.keys(OAUTH_PROVIDERS); + const testConfigKeys = new Set(Object.keys(OAUTH_TEST_CONFIG)); + + for (const providerId of providerIds) { + const isCovered = + testConfigKeys.has(providerId) || GRANDFATHERED_WITHOUT_TEST_CONFIG.has(providerId); + assert.ok( + isCovered, + `OAuth provider '${providerId}' must have an entry in OAUTH_TEST_CONFIG. ` + + 'Without one, Test Connection persists testStatus="error" on a healthy account (#8408).' + ); + } +}); diff --git a/tests/unit/opencode-go-effort-aliases-8353.test.ts b/tests/unit/opencode-go-effort-aliases-8353.test.ts new file mode 100644 index 0000000000..ee248b504a --- /dev/null +++ b/tests/unit/opencode-go-effort-aliases-8353.test.ts @@ -0,0 +1,190 @@ +/** + * Issue #8353 — Missing OpenCode Go reasoning variants. + * + * OpenCode's local Go registry exposes effort-tier aliases that OmniRoute did + * not register or resolve. These tests cover: + * 1. Catalog exposure on opencode-go (and absence on opencode-zen) + * 2. parseEffortLevel → base + effort for every listed alias + * 3. transformRequest rewrite + reasoning_effort injection + * 4. MiniMax M3 stays out of the effort-alias path + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { parseEffortLevel, OpencodeExecutor } = (await import( + "../../open-sse/executors/opencode.ts" +)) as { + parseEffortLevel: (model: string) => { baseModel: string; effort: string } | null; + OpencodeExecutor: new (provider: string) => { + transformRequest: ( + model: string, + body: Record, + stream: boolean, + credentials: unknown + ) => Record; + }; +}; + +const { REGISTRY } = (await import("../../open-sse/config/providerRegistry.ts")) as { + REGISTRY: Record< + string, + { models?: Array<{ id: string; name?: string; targetFormat?: string }> } + >; +}; + +/** Exact alias set from #8353 (MiniMax M3 intentionally excluded). */ +const ISSUE_ALIASES: ReadonlyArray<{ alias: string; base: string; effort: string }> = [ + { alias: "deepseek-v4-flash-high", base: "deepseek-v4-flash", effort: "high" }, + { alias: "deepseek-v4-flash-max", base: "deepseek-v4-flash", effort: "max" }, + { alias: "grok-4.5-low", base: "grok-4.5", effort: "low" }, + { alias: "grok-4.5-medium", base: "grok-4.5", effort: "medium" }, + { alias: "grok-4.5-high", base: "grok-4.5", effort: "high" }, + { alias: "hy3-none", base: "hy3", effort: "none" }, + { alias: "hy3-low", base: "hy3", effort: "low" }, + { alias: "hy3-high", base: "hy3", effort: "high" }, + { alias: "kimi-k3-max", base: "kimi-k3", effort: "max" }, + { alias: "qwen3.6-plus-high", base: "qwen3.6-plus", effort: "high" }, + { alias: "qwen3.6-plus-max", base: "qwen3.6-plus", effort: "max" }, + { alias: "qwen3.7-max-high", base: "qwen3.7-max", effort: "high" }, + { alias: "qwen3.7-max-max", base: "qwen3.7-max", effort: "max" }, + { alias: "qwen3.7-plus-high", base: "qwen3.7-plus", effort: "high" }, + { alias: "qwen3.7-plus-max", base: "qwen3.7-plus", effort: "max" }, +]; + +const NEW_BASES = ["grok-4.5", "hy3", "kimi-k3", "qwen3.7-plus"] as const; + +function goModelIds(): string[] { + const entry = REGISTRY["opencode-go"]; + assert.ok(entry, "opencode-go registry entry must exist"); + return (entry.models ?? []).map((m) => m.id); +} + +function zenModelIds(): string[] { + const entry = REGISTRY["opencode-zen"]; + assert.ok(entry, "opencode-zen registry entry must exist"); + return (entry.models ?? []).map((m) => m.id); +} + +// ─── Catalog exposure ────────────────────────────────────────────────────── + +test("#8353 catalog: every listed alias is registered on opencode-go", () => { + const ids = new Set(goModelIds()); + for (const { alias } of ISSUE_ALIASES) { + assert.ok(ids.has(alias), `opencode-go must expose ${alias}`); + } +}); + +test("#8353 catalog: new base models are registered on opencode-go", () => { + const ids = new Set(goModelIds()); + for (const base of NEW_BASES) { + assert.ok(ids.has(base), `opencode-go must expose base model ${base}`); + } +}); + +test("#8353 catalog: hy3 base is distinct from hy3-preview", () => { + const ids = new Set(goModelIds()); + assert.ok(ids.has("hy3"), "hy3 Go-tier base must exist"); + assert.ok(ids.has("hy3-preview"), "hy3-preview must remain"); + assert.equal( + parseEffortLevel("hy3-preview"), + null, + "hy3-preview must not parse as an effort alias" + ); +}); + +test("#8353 catalog: aliases are NOT synthesized on opencode-zen", () => { + const zenIds = new Set(zenModelIds()); + for (const { alias } of ISSUE_ALIASES) { + assert.equal(zenIds.has(alias), false, `opencode-zen must not expose ${alias}`); + } + for (const base of NEW_BASES) { + assert.equal(zenIds.has(base), false, `opencode-zen must not expose base ${base}`); + } +}); + +test("#8353 catalog: qwen effort aliases keep Claude targetFormat", () => { + const models = REGISTRY["opencode-go"]?.models ?? []; + for (const id of [ + "qwen3.6-plus-high", + "qwen3.6-plus-max", + "qwen3.7-max-high", + "qwen3.7-max-max", + "qwen3.7-plus-high", + "qwen3.7-plus-max", + ]) { + const entry = models.find((m) => m.id === id); + assert.ok(entry, `${id} must exist`); + assert.equal(entry.targetFormat, "claude", `${id} must keep targetFormat: claude`); + } +}); + +// ─── parseEffortLevel ────────────────────────────────────────────────────── + +for (const { alias, base, effort } of ISSUE_ALIASES) { + test(`#8353 parseEffortLevel: ${alias} → ${effort}`, () => { + assert.deepEqual(parseEffortLevel(alias), { baseModel: base, effort }); + }); +} + +test("#8353 parseEffortLevel: unsupported tiers stay null", () => { + assert.equal(parseEffortLevel("deepseek-v4-flash-low"), null); + assert.equal(parseEffortLevel("grok-4.5-max"), null); + assert.equal(parseEffortLevel("hy3-max"), null); + assert.equal(parseEffortLevel("kimi-k3-high"), null); + assert.equal(parseEffortLevel("qwen3.6-plus-low"), null); +}); + +test("#8353 parseEffortLevel: existing DeepSeek V4 Pro / GLM / MiMo aliases still work", () => { + assert.deepEqual(parseEffortLevel("deepseek-v4-pro-max"), { + baseModel: "deepseek-v4-pro", + effort: "max", + }); + assert.deepEqual(parseEffortLevel("glm-5.2-high"), { baseModel: "glm-5.2", effort: "high" }); + assert.deepEqual(parseEffortLevel("mimo-v2.5-max"), { baseModel: "mimo-v2.5", effort: "max" }); +}); + +test("#8353 parseEffortLevel: MiniMax M3 has no effort-tier aliases", () => { + assert.equal(parseEffortLevel("minimax-m3-thinking"), null); + assert.equal(parseEffortLevel("minimax-m3-none"), null); + assert.equal(parseEffortLevel("minimax-m3-high"), null); +}); + +// ─── transformRequest rewrite ────────────────────────────────────────────── + +const CREDENTIALS = { apiKey: "k" } as Record; + +const TRANSFORM_SAMPLES = [ + { alias: "deepseek-v4-flash-high", base: "deepseek-v4-flash", effort: "high" }, + { alias: "grok-4.5-medium", base: "grok-4.5", effort: "medium" }, + { alias: "hy3-none", base: "hy3", effort: "none" }, + { alias: "kimi-k3-max", base: "kimi-k3", effort: "max" }, + { alias: "qwen3.7-plus-max", base: "qwen3.7-plus", effort: "max" }, + { alias: "qwen3.7-max-high", base: "qwen3.7-max", effort: "high" }, +] as const; + +for (const { alias, base, effort } of TRANSFORM_SAMPLES) { + test(`#8353 transformRequest: ${alias} → model=${base}, reasoning_effort=${effort}`, () => { + const executor = new OpencodeExecutor("opencode-go"); + const body = { model: alias, messages: [{ role: "user", content: "hi" }] }; + + const out = executor.transformRequest(alias, body, true, CREDENTIALS); + + assert.equal(out.model, base, "model id must be rewritten to the base id"); + assert.equal(out.reasoning_effort, effort, "reasoning_effort must be injected from the alias"); + }); +} + +test("#8353 transformRequest: does not clobber an already-set reasoning_effort", () => { + const executor = new OpencodeExecutor("opencode-go"); + const body = { + model: "deepseek-v4-flash-max", + reasoning_effort: "caller-supplied", + messages: [{ role: "user", content: "hi" }], + }; + + const out = executor.transformRequest("deepseek-v4-flash-max", body, true, CREDENTIALS); + + assert.equal(out.model, "deepseek-v4-flash"); + assert.equal(out.reasoning_effort, "caller-supplied"); +}); diff --git a/tests/unit/pricing-cc-anthropic-rates.test.ts b/tests/unit/pricing-cc-anthropic-rates.test.ts index 4c905ede02..bb008f1f33 100644 --- a/tests/unit/pricing-cc-anthropic-rates.test.ts +++ b/tests/unit/pricing-cc-anthropic-rates.test.ts @@ -13,6 +13,15 @@ import { getDefaultPricing } from "../../src/shared/constants/pricing.ts"; // - cache hit (cached) = 0.1x input // - reasoning tokens are billed at the OUTPUT rate +test("cc/claude-opus-5 matches Anthropic Opus 5 pricing", () => { + const p = getDefaultPricing().cc["claude-opus-5"]; + assert.equal(p.input, 5.0); + assert.equal(p.output, 25.0); + assert.equal(p.cached, 0.5); + assert.equal(p.reasoning, 25.0); + assert.equal(p.cache_creation, 6.25); +}); + test("cc/claude-opus-4-6 matches Anthropic Opus 4.6 pricing", () => { const p = getDefaultPricing().cc["claude-opus-4-6"]; assert.equal(p.input, 5.0); diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index 1e2a307a24..2345283959 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -88,6 +88,7 @@ test("GitHub Copilot registry reflects the current supported model lineup", () = const ids = githubModels.map((model) => model.id); assert.deepEqual(ids, [...GITHUB_COPILOT_MODEL_ALLOWLIST]); + assert.equal(getModelTargetFormat("gh", "claude-opus-5"), "claude"); assert.equal(getModelTargetFormat("gh", "gpt-5.3-codex"), "openai-responses"); // "claude-opus-4.6" is not a real Copilot model id (unlike claude-sonnet-4.6); // it never appears in the registry, so its target format stays null. @@ -110,6 +111,16 @@ test("GitHub Copilot registry reflects the current supported model lineup", () = assert.equal(ids.includes("gemini-3-flash-preview"), false); }); +test("Claude flagship catalogs keep Fable 5 first", () => { + for (const provider of ["anthropic", "cc", "cw", "gh", "ghe-copilot"]) { + assert.equal( + getProviderModels(provider)[0]?.id, + "claude-fable-5", + `${provider} must list the strongest Claude model first` + ); + } +}); + test("Kiro registry exposes the current CLI model lineup with context windows", () => { const kiroModels = getProviderModels("kr"); const byId = new Map(kiroModels.map((model) => [model.id, model])); @@ -143,6 +154,8 @@ test("Claude max effort support excludes Haiku family and non-Claude IDs", () => test("xhigh effort support defaults to pass-through and opts out explicit false models", () => { const claudeModels = new Set(getModelsByProviderId("claude").map((model) => model.id)); + assert.ok(claudeModels.has("claude-opus-5")); + assert.equal(supportsXHighEffort("claude", "claude-opus-5"), true); assert.ok(claudeModels.has("claude-opus-4-8")); assert.equal(supportsXHighEffort("claude", "claude-opus-4-8"), true); assert.equal(supportsXHighEffort("claude", "claude-opus-4-7"), true); diff --git a/tests/unit/provider-registry-github-copilot-targetformat.test.ts b/tests/unit/provider-registry-github-copilot-targetformat.test.ts index 22fd9c30f0..10433e1da8 100644 --- a/tests/unit/provider-registry-github-copilot-targetformat.test.ts +++ b/tests/unit/provider-registry-github-copilot-targetformat.test.ts @@ -30,6 +30,7 @@ function githubModel(id: string): ModelEntry | undefined { // Claude/Gemini models that must NOT route through the Responses API. const MUST_NOT_BE_RESPONSES = [ "claude-fable-5", + "claude-opus-5", "claude-opus-4.7", "claude-opus-4.8", "claude-opus-4.8-fast", diff --git a/tests/unit/proxy-registry.test.ts b/tests/unit/proxy-registry.test.ts index 3c3812feb3..35e1c63940 100644 --- a/tests/unit/proxy-registry.test.ts +++ b/tests/unit/proxy-registry.test.ts @@ -304,6 +304,7 @@ test("resolveProxyForConnection uses apiKey proxy before account-level proxy", a name: "api-key-proxy", apiKey: "sk-apikey-proxy", }); + const connId = (conn as any).id; const accountProxy = await proxiesDb.createProxy({ name: "Account Proxy", @@ -311,17 +312,20 @@ test("resolveProxyForConnection uses apiKey proxy before account-level proxy", a host: "account.local", port: 8081, }); - await proxiesDb.assignProxyToScope("account", (conn as any).id, accountProxy.id); + await proxiesDb.assignProxyToScope("account", connId, accountProxy.id); const key = await apiKeysDb.createApiKey("proxy-test-key", "machine-p1"); - // Enable per-key proxy globally so the API key's proxy_id is honored + // Enable per-key proxy globally (master gate) and on the connection itself + // (#8385: the global toggle is a true AND-override, not an independent + // opt-in path — both must be on for the api-key-level proxy to apply). core .getDbInstance() .prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'perKeyProxyEnabled', 'true')" ) .run(); + await providersDb.updateProviderConnection(connId, { perKeyProxyEnabled: true }); const apiKeyProxy = await proxiesDb.createProxy({ name: "API Key Proxy", @@ -331,7 +335,7 @@ test("resolveProxyForConnection uses apiKey proxy before account-level proxy", a }); await apiKeysDb.updateApiKeyPermissions(key.id, { proxyId: apiKeyProxy.id }); - const resolved = await settingsDb.resolveProxyForConnection((conn as any).id, key.id); + const resolved = await settingsDb.resolveProxyForConnection(connId, key.id); assert.ok(resolved); assert.equal((resolved as any).level, "apiKey"); assert.equal((resolved as any).proxy.host, "apikey.local"); diff --git a/tests/unit/refactor-buildHeaders-opencode.test.ts b/tests/unit/refactor-buildHeaders-opencode.test.ts index ecb1a0ae50..8b5ad1a852 100644 --- a/tests/unit/refactor-buildHeaders-opencode.test.ts +++ b/tests/unit/refactor-buildHeaders-opencode.test.ts @@ -108,3 +108,88 @@ test("OpencodeExecutor.buildHeaders: preserves x-opencode-client from client hea }); assert.equal(headers["x-opencode-client"], "desktop"); }); + +// --------------------------------------------------------------------------- +// #8467 — Extra API Keys rotation (resolveEffectiveKey) +// --------------------------------------------------------------------------- + +test("OpencodeExecutor.buildHeaders: rotates extra API keys (Bearer)", () => { + const executor = new OpencodeExecutor("opencode-zen"); + const credentials = { + apiKey: "primary-key", + connectionId: "opencode-rotation-bearer", + providerSpecificData: { extraApiKeys: ["extra-key"] } as Record, + }; + + // Clear sticky selectedKeyId between calls so getValidApiKey round-robin is exercised. + const seen = new Set(); + for (let i = 0; i < 4; i++) { + delete credentials.providerSpecificData.selectedKeyId; + const headers = executor.buildHeaders(credentials, true); + const token = headers["Authorization"]?.replace(/^Bearer /, ""); + assert.ok(token === "primary-key" || token === "extra-key", `unexpected token: ${token}`); + seen.add(token ?? ""); + } + assert.ok(seen.has("primary-key")); + assert.ok(seen.has("extra-key")); + assert.ok( + typeof credentials.providerSpecificData.selectedKeyId === "string" && + credentials.providerSpecificData.selectedKeyId.length > 0, + "selectedKeyId should be persisted after rotation" + ); +}); + +test("OpencodeExecutor.buildHeaders: rotates extra API keys (claude x-api-key)", () => { + const executor = new OpencodeExecutor("opencode-zen"); + executor._requestFormat = "claude"; + const credentials = { + apiKey: "primary-key", + connectionId: "opencode-rotation-claude", + providerSpecificData: { extraApiKeys: ["extra-key"] } as Record, + }; + + const seen = new Set(); + for (let i = 0; i < 4; i++) { + delete credentials.providerSpecificData.selectedKeyId; + const headers = executor.buildHeaders(credentials, true); + const key = headers["x-api-key"]; + assert.ok(key === "primary-key" || key === "extra-key", `unexpected x-api-key: ${key}`); + seen.add(key ?? ""); + } + assert.ok(seen.has("primary-key")); + assert.ok(seen.has("extra-key")); +}); + +test("OpencodeExecutor.buildHeaders: empty primary + extras still sends Authorization", () => { + const executor = new OpencodeExecutor("opencode-go"); + const headers = executor.buildHeaders( + { + apiKey: "", + connectionId: "opencode-empty-primary", + providerSpecificData: { extraApiKeys: ["only-extra-key"] }, + }, + true + ); + assert.equal(headers["Authorization"], "Bearer only-extra-key"); +}); + +test("OpencodeExecutor.buildHeaders: #8467 guard — override uses resolveEffectiveKey path", async () => { + // Source-level guard: OpencodeExecutor overrides buildHeaders and must not + // reintroduce a direct credentials.apiKey read that bypasses extra-keys rotation. + const fs = await import("node:fs"); + const path = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const here = path.dirname(fileURLToPath(import.meta.url)); + const source = fs.readFileSync( + path.resolve(here, "../../open-sse/executors/opencode.ts"), + "utf8" + ); + const buildHeadersStart = source.indexOf("buildHeaders("); + assert.ok(buildHeadersStart >= 0); + const buildHeadersBody = source.slice(buildHeadersStart, buildHeadersStart + 1200); + assert.match(buildHeadersBody, /resolveEffectiveKey\s*\(/); + assert.doesNotMatch( + buildHeadersBody, + /credentials\?\.apiKey\s*\|\|\s*credentials\?\.accessToken/ + ); +}); diff --git a/tests/unit/refactor-buildHeaders-preamble.test.ts b/tests/unit/refactor-buildHeaders-preamble.test.ts index d3b3a7d91f..1f4b13abf5 100644 --- a/tests/unit/refactor-buildHeaders-preamble.test.ts +++ b/tests/unit/refactor-buildHeaders-preamble.test.ts @@ -70,6 +70,18 @@ test("resolveEffectiveKey: returns accessToken when apiKey is undefined", () => assert.equal(result, undefined); }); +test("resolveEffectiveKey: empty primary + extras returns an extra key (#8467)", () => { + const executor = new TestExecutor(); + const credentials = { + apiKey: "", + connectionId: "preamble-empty-primary", + providerSpecificData: { extraApiKeys: ["sk-extra-only"] } as Record, + }; + const result = executor.publicResolveEffectiveKey(credentials); + assert.equal(result, "sk-extra-only"); + assert.equal(credentials.providerSpecificData.selectedKeyId, "extra_0"); +}); + // --------------------------------------------------------------------------- // buildHeadersPreamble tests // --------------------------------------------------------------------------- diff --git a/tests/unit/repro-7503-no-choices.test.ts b/tests/unit/repro-7503-no-choices.test.ts new file mode 100644 index 0000000000..1f7b82673c --- /dev/null +++ b/tests/unit/repro-7503-no-choices.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { hasStreamReadinessSignal } = await import("@omniroute/open-sse/utils/streamReadiness"); +const { buildErrorBody } = await import("@omniroute/open-sse/utils/error"); +const { AuggieExecutor } = await import("@omniroute/open-sse/executors/auggie"); + +test("hasStreamReadinessSignal wrongly reports readiness for a choices-less mid-stream error frame", () => { + const errorBody = buildErrorBody(502, "Auggie CLI not found: auggie.cmd"); + assert.equal("choices" in errorBody, false, "sanity check: buildErrorBody() output really has no choices key"); + + const sse = `data: ${JSON.stringify(errorBody)}\n\ndata: [DONE]\n\n`; + const ready = hasStreamReadinessSignal(sse); + + assert.equal( + ready, + false, + "a stream carrying only a choices-less error frame should NOT be reported as 'ready' " + + "(successful) — it gives the combo router no signal to fail over to the next candidate, " + + "and the client accumulates zero `choices` for the whole request, which is exactly what " + + "makes strict OpenAI-SDK clients (VS Code Copilot, Cline) throw 'Response contained no choices.'" + ); +}); + +test("AuggieExecutor's real streaming error path emits an SSE body with zero populated `choices` (end-to-end)", async () => { + const executor = new AuggieExecutor(); + const previousBin = process.env.AUGGIE_BIN; + process.env.AUGGIE_BIN = "/definitely/does/not/exist/auggie-xyz-7503"; + try { + const { response } = await executor.execute({ + model: "claude-sonnet-4.6", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: true, + credentials: {} as never, + signal: null, + log: { info: () => {}, debug: () => {}, warn: () => {} }, + }); + + const text = await response.text(); + const dataLines = text.split("\n").filter((l) => l.startsWith("data: ")) + .map((l) => l.slice("data: ".length).trim()).filter((d) => d !== "[DONE]"); + assert.ok(dataLines.length > 0, "expected at least one SSE data event"); + + const anyPopulatedChoices = dataLines.some((d) => { + const parsed = JSON.parse(d); + return Array.isArray(parsed.choices) && parsed.choices.length > 0 && + (parsed.choices[0]?.delta?.content || parsed.choices[0]?.message?.content); + }); + + assert.equal(anyPopulatedChoices, false, + "AuggieExecutor's CLI-failure SSE stream never carries a populated `choices` entry — " + + "it is only a choices-less {error:...} frame + [DONE]"); + } finally { + if (previousBin === undefined) delete process.env.AUGGIE_BIN; + else process.env.AUGGIE_BIN = previousBin; + } +}); diff --git a/tests/unit/repro-7847-bound-client-raw-request.test.ts b/tests/unit/repro-7847-bound-client-raw-request.test.ts new file mode 100644 index 0000000000..165c87ae68 --- /dev/null +++ b/tests/unit/repro-7847-bound-client-raw-request.test.ts @@ -0,0 +1,116 @@ +// Repro + regression guard for #7847: buildClientRawRequest deep-clones the ENTIRE request body +// on every chat request, unbounded, even though every consumer of clientRawRequest.body is +// observability that either discards it or re-clones it *bounded*. +// +// Consumers traced at the time of writing — none feeds dispatch, translation or the upstream call: +// 1. chatCore.ts -> reqLogger.logClientRawRequest(...) (no-op when the logger is off, +// otherwise re-clones via cloneBoundedForLog) +// 2. chatCore.ts -> trackPendingRequest({ clientRequest }) -> /api/logs/[id] +// 3. chat.ts -> recordRejectedRequestUsage({ requestBody }) +// +// The incident: a 3.05 MiB request (729 messages / 86 tools) reached ~12,282 MiB of V8 heap. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { buildClientRawRequest } = await import("../../src/sse/handlers/chat.ts"); +const { cloneBoundedForLog, MAX_LOG_ARRAY_ITEMS } = await import( + "../../open-sse/utils/requestLogger.ts" +); + +const MESSAGES = 800; + +function makeRequest(): Request { + return new Request("http://localhost:20128/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + }); +} + +function longHistoryBody() { + return { + model: "claude-opus-5", + messages: Array.from({ length: MESSAGES }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: `message ${i} `.repeat(200), + })), + }; +} + +test("#7847: buildClientRawRequest must not retain an unbounded copy of the message history", () => { + const captured = buildClientRawRequest(makeRequest(), longHistoryBody()) as { + body: { messages: unknown[] }; + }; + + // The bounded clone keeps a truncation marker plus the tail, never the whole history. + assert.ok( + captured.body.messages.length <= MAX_LOG_ARRAY_ITEMS + 1, + `clientRawRequest.body retained ${captured.body.messages.length} of ${MESSAGES} messages — ` + + `every consumer is observability and keeps at most ${MAX_LOG_ARRAY_ITEMS}` + ); +}); + +test("#7847: the retained snapshot does not grow with the message count", () => { + // The amplification in #7847 is that retention scaled with history length. Ten times the + // history must not cost ten times the retained snapshot. + const size = (v: unknown) => JSON.stringify(v).length; + const at = (messages: number) => + size( + ( + buildClientRawRequest(makeRequest(), { + model: "claude-opus-5", + messages: Array.from({ length: messages }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: `message ${i} `.repeat(200), + })), + }) as { body: unknown } + ).body + ); + + const small = at(MESSAGES); + const tenfold = at(MESSAGES * 10); + // Not exactly equal: only the tail is retained, and the tail of the longer history carries + // wider index numbers ("message 7999" vs "message 799"). What matters is that the growth is + // a rounding error rather than the 10x a proportional retention would cost. + assert.ok( + tenfold < small * 1.5, + `retained ${tenfold} bytes for ${MESSAGES * 10} messages vs ${small} for ${MESSAGES} — ` + + `retention must be bounded; proportional retention would be ~${small * 10}` + ); +}); + +test("bounding at the entry does not change what the request logger ultimately stores", () => { + // chatCore hands clientRawRequest.body to reqLogger.logClientRawRequest, which applies + // cloneBoundedForLog itself. Bounding earlier may only be safe if that second pass is a + // no-op — otherwise the persisted log payload would change shape. + const body = longHistoryBody(); + const once = cloneBoundedForLog(body); + const twice = cloneBoundedForLog(once); + assert.deepEqual(twice, once, "cloneBoundedForLog must be idempotent for the entry clone to be safe"); +}); + +test("buildClientRawRequest still carries endpoint, headers and signal", () => { + const req = new Request("http://localhost:20128/v1/chat/completions?x=1", { + method: "POST", + headers: { "content-type": "application/json", "x-omniroute-session-id": "sess-1" }, + }); + const out = buildClientRawRequest(req, { model: "m", messages: [] }) as { + endpoint: string; + headers: Record; + signal: unknown; + }; + assert.equal(out.endpoint, "/v1/chat/completions"); + assert.equal(out.headers["x-omniroute-session-id"], "sess-1"); + assert.equal(out.headers["content-type"], "application/json"); + assert.ok("signal" in out); +}); + +test("the captured body is a snapshot — later mutation of the request body must not leak in", () => { + // chatCore rewrites `body` downstream (plugin onRequest hook, compression). The captured + // snapshot must not alias it, or the log would show post-mutation content. + const body = { model: "m", messages: [{ role: "user", content: "original" }] }; + const captured = buildClientRawRequest(makeRequest(), body) as { + body: { messages: { content: string }[] }; + }; + body.messages[0].content = "mutated by a downstream plugin"; + assert.equal(captured.body.messages[0].content, "original"); +}); diff --git a/tests/unit/repro-8429-capability-canonicalization.test.ts b/tests/unit/repro-8429-capability-canonicalization.test.ts new file mode 100644 index 0000000000..5e36bd57be --- /dev/null +++ b/tests/unit/repro-8429-capability-canonicalization.test.ts @@ -0,0 +1,74 @@ +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-repro-8429-")); +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"); + +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 }); +} + +test.beforeEach(() => { resetStorage(); }); +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#8429: synced model_capabilities row written under models.dev mapping is unreachable via the canonical 'codex' provider id", () => { + modelsDevSync.saveModelsDevCapabilities({ + openai: { "gpt-6-codex-preview": buildCapability({ tool_call: false }) }, + cx: { "gpt-6-codex-preview": buildCapability({ tool_call: false }) }, + }); + const viaAlias = modelsDevSync.getSyncedCapability("cx", "gpt-6-codex-preview"); + assert.equal(viaAlias?.tool_call, false, "row must exist under the alias 'cx'"); + + const resolved = modelCapabilities.getResolvedModelCapabilities({ + provider: "codex", + model: "gpt-6-codex-preview", + }); + + assert.equal( + resolved.supportsTools, + false, + `expected synced tool_call=false to be resolved for provider "codex" (it was ${resolved.supportsTools})` + ); +}); + +test("#8429: synced model_capabilities row is unreachable via the canonical 'claude' provider id (same class, alias 'cc')", () => { + modelsDevSync.saveModelsDevCapabilities({ + anthropic: { "claude-preview-9-9": buildCapability({ tool_call: false }) }, + cc: { "claude-preview-9-9": buildCapability({ tool_call: false }) }, + }); + const viaAlias = modelsDevSync.getSyncedCapability("cc", "claude-preview-9-9"); + assert.equal(viaAlias?.tool_call, false, "row must exist under the alias 'cc'"); + + const resolved = modelCapabilities.getResolvedModelCapabilities({ + provider: "claude", + model: "claude-preview-9-9", + }); + + assert.equal( + resolved.supportsTools, + false, + `expected synced tool_call=false to be resolved for provider "claude" (it was ${resolved.supportsTools})` + ); +}); diff --git a/tests/unit/request-logger-bounded-idempotence.test.ts b/tests/unit/request-logger-bounded-idempotence.test.ts new file mode 100644 index 0000000000..e8aa54169a --- /dev/null +++ b/tests/unit/request-logger-bounded-idempotence.test.ts @@ -0,0 +1,95 @@ +// cloneBoundedForLog must be idempotent (#7847). +// +// Since buildClientRawRequest now bounds the body at the entry point, the request logger applies +// cloneBoundedForLog to an ALREADY bounded value. If the second pass were not a no-op, the +// persisted log payload would change shape versus before the fix — and it did not used to be: +// +// arrays : [marker, ...24 items] is 25 entries, over the 24 limit, so a second pass dropped +// the marker plus one real item and rewrote originalLength as 25 instead of 800. +// objects : 80 keys + _omniroute_truncated_keys is 81, so a second pass evicted a real key to +// make room and reported 1 dropped instead of the true count. +// strings : the truncation marker was appended AFTER slicing to maxLength, so the "bounded" +// string was longer than the bound and got truncated again. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { cloneBoundedForLog, MAX_LOG_ARRAY_ITEMS } = await import( + "../../open-sse/utils/requestLogger.ts" +); + +const MAX_KEYS = 80; +const MAX_STRING = 64 * 1024; + +test("arrays: second pass preserves the marker, the tail, and the true originalLength", () => { + const input = { messages: Array.from({ length: 800 }, (_, i) => ({ role: "user", n: i })) }; + const once = cloneBoundedForLog(input) as { messages: Record[] }; + const twice = cloneBoundedForLog(once) as { messages: Record[] }; + + assert.deepEqual(twice, once, "re-bounding must be a no-op"); + assert.equal(twice.messages.length, MAX_LOG_ARRAY_ITEMS + 1, "marker plus the retained tail"); + assert.equal( + twice.messages[0].originalLength, + 800, + "originalLength must keep describing the ORIGINAL array, not the bounded one" + ); + // The tail must still be the last items of the real history, not shifted by the marker. + assert.equal((twice.messages.at(-1) as { n: number }).n, 799); +}); + +test("objects: second pass keeps the real keys and the true dropped count", () => { + const input = Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`k${i}`, i])); + const once = cloneBoundedForLog(input) as Record; + const twice = cloneBoundedForLog(once) as Record; + + assert.deepEqual(twice, once, "re-bounding must be a no-op"); + assert.equal(once._omniroute_truncated_keys, 20, "100 keys minus the 80 retained"); + assert.equal(twice._omniroute_truncated_keys, 20, "the dropped count must not be recomputed"); + assert.equal( + Object.keys(twice).filter((k) => k !== "_omniroute_truncated_keys").length, + MAX_KEYS, + "a real key must not be evicted to make room for the marker" + ); +}); + +test("strings: the bounded result respects the bound, so a second pass is a no-op", () => { + const once = cloneBoundedForLog("x".repeat(200_000)) as string; + const twice = cloneBoundedForLog(once) as string; + + assert.equal(twice, once, "re-bounding must be a no-op"); + assert.ok( + once.length <= MAX_STRING, + `bounded string is ${once.length} chars, over the ${MAX_STRING} bound — the marker must fit inside the budget` + ); + assert.match(once, /\[\.\.\.truncated \d+ chars\.\.\.\]/); +}); + +test("values already within the bounds are returned unchanged", () => { + const input = { model: "m", messages: [{ role: "user", content: "hi" }], n: 1, ok: true }; + assert.deepEqual(cloneBoundedForLog(input), input); + assert.deepEqual(cloneBoundedForLog(cloneBoundedForLog(input)), input); +}); + +test("the tools exemption survives re-bounding", () => { + // tools are deliberately exempt from array truncation (debug-critical inventory); a second + // pass must not start truncating them. + const input = { tools: Array.from({ length: 200 }, (_, i) => ({ name: `tool_${i}` })) }; + const once = cloneBoundedForLog(input) as { tools: unknown[] }; + const twice = cloneBoundedForLog(once) as { tools: unknown[] }; + + assert.equal(once.tools.length, 200, "tools must not be truncated"); + assert.equal(twice.tools.length, 200, "and must stay untruncated on a second pass"); + assert.deepEqual(twice, once); +}); + +test("nested structures stay stable across repeated bounding", () => { + const input = { + messages: Array.from({ length: 50 }, (_, i) => ({ + role: "user", + content: "y".repeat(100_000), + meta: Object.fromEntries(Array.from({ length: 90 }, (_, k) => [`m${k}`, `${i}-${k}`])), + })), + }; + const once = cloneBoundedForLog(input); + assert.deepEqual(cloneBoundedForLog(once), once); + assert.deepEqual(cloneBoundedForLog(cloneBoundedForLog(once)), once, "stable under repetition"); +}); diff --git a/tests/unit/rerank-object-documents-response-path.test.ts b/tests/unit/rerank-object-documents-response-path.test.ts new file mode 100644 index 0000000000..6ae64afdac --- /dev/null +++ b/tests/unit/rerank-object-documents-response-path.test.ts @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { getRerankProvider } = await import("../../open-sse/config/rerankRegistry.ts"); +const { transformResponseFromProvider } = await import("../../open-sse/handlers/rerank.ts"); + +/** + * The Cohere-compatible rerank API accepts each document as either a bare string + * or `{ text }`, and the DeepInfra/Voyage **response** adapters synthesize + * `document.text` from the caller's originals (upstreams either omit documents + * or echo them in their own shape — #7809/#7811). + * + * The `{ text }` form was only ever exercised through the *request* adapter + * (`#5332 deepinfra request adapter`, `#7809 voyage request adapter handles + * {text} object documents`). Every response-adapter test passed plain strings, + * so the `typeof doc === "string" ? doc : doc?.text` branch on the response side + * was uncovered — which is also the branch that gives + * `RerankResponseOptions.documents` its union type. + */ + +test("deepinfra response adapter resolves document text from {text} objects", () => { + const cfg = getRerankProvider("deepinfra"); + const out = transformResponseFromProvider( + cfg, + { scores: [0.1, 0.9] }, + { documents: [{ text: "Washington DC" }, { text: "Paris" }], return_documents: true } + ); + + assert.equal(out.results[0].index, 1, "0.9 ranks first"); + assert.equal(out.results[0].document.text, "Paris"); + assert.equal(out.results[1].document.text, "Washington DC"); +}); + +test("deepinfra response adapter handles a mixed string/{text} document array", () => { + const cfg = getRerankProvider("deepinfra"); + const out = transformResponseFromProvider( + cfg, + { scores: [0.2, 0.8] }, + { documents: ["plain string", { text: "object form" }], return_documents: true } + ); + + assert.equal(out.results[0].document.text, "object form"); + assert.equal(out.results[1].document.text, "plain string"); +}); + +test("deepinfra response adapter falls back to empty text for a {text}-less object", () => { + const cfg = getRerankProvider("deepinfra"); + const out = transformResponseFromProvider( + cfg, + { scores: [0.5] }, + { documents: [{} as { text?: string }], return_documents: true } + ); + + assert.equal(out.results[0].document.text, "", "a document with no text must not yield undefined"); +}); + +test("voyage response adapter resolves document text from {text} objects", () => { + const cfg = getRerankProvider("voyage-ai"); + const out = transformResponseFromProvider( + cfg, + { data: [{ index: 0, relevance_score: 0.4 }, { index: 1, relevance_score: 0.7 }] }, + { documents: [{ text: "alpha" }, { text: "beta" }], return_documents: true } + ); + + assert.equal(out.results[0].index, 1, "0.7 ranks first"); + assert.equal(out.results[0].document.text, "beta"); + assert.equal(out.results[1].document.text, "alpha"); +}); + +test("voyage response adapter remaps indices past an empty {text} document", () => { + // The request adapter drops exact-empty documents before sending, so the + // upstream indices refer to the filtered array. The response adapter rebuilds + // that filter to map back — this must work for the object form too, not just + // strings. + const cfg = getRerankProvider("voyage-ai"); + const out = transformResponseFromProvider( + cfg, + { data: [{ index: 1, relevance_score: 0.9 }] }, + { documents: [{ text: "kept" }, { text: "" }, { text: "also kept" }], return_documents: true } + ); + + assert.equal(out.results[0].index, 2, "filtered index 1 maps back to original index 2"); + assert.equal(out.results[0].document.text, "also kept"); +}); diff --git a/tests/unit/resolve-proxy-family.test.ts b/tests/unit/resolve-proxy-family.test.ts index 803aee5bb0..6e12f618ad 100644 --- a/tests/unit/resolve-proxy-family.test.ts +++ b/tests/unit/resolve-proxy-family.test.ts @@ -115,12 +115,16 @@ test("api-key-level proxy carries family=ipv6 (Step 2 object literal)", async () name: "key-ipv6", apiKey: "sk-key-ipv6", }); + const connId = (conn as any).id; core .getDbInstance() .prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'perKeyProxyEnabled', 'true')" ) .run(); + // #8385: the global toggle is a true AND-override — the connection's own + // per_key_proxy_enabled must also be on for the api-key-level proxy to apply. + await providersDb.updateProviderConnection(connId, { perKeyProxyEnabled: true }); const proxy = await proxiesDb.createProxy({ name: "IPv6 API Key Proxy", type: "https", @@ -131,7 +135,7 @@ test("api-key-level proxy carries family=ipv6 (Step 2 object literal)", async () const key = await apiKeysDb.createApiKey("family-key", "machine-f1"); await apiKeysDb.updateApiKeyPermissions(key.id, { proxyId: proxy.id }); - const resolved = await settingsDb.resolveProxyForConnection((conn as any).id, key.id); + const resolved = await settingsDb.resolveProxyForConnection(connId, key.id); assert.ok(resolved); assert.equal((resolved as any).level, "apiKey"); assert.equal((resolved as any).proxy.family, "ipv6"); diff --git a/tests/unit/responses-input-item-status-8083.test.ts b/tests/unit/responses-input-item-status-8083.test.ts new file mode 100644 index 0000000000..ddbedc9500 --- /dev/null +++ b/tests/unit/responses-input-item-status-8083.test.ts @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Kept in its own file rather than growing the frozen +// tests/unit/translator-openai-responses-req.test.ts (file-size ratchet). +const { openaiToOpenAIResponsesRequest } = await import( + "../../open-sse/translator/request/openai-responses.ts" +); + +// --- Issue #8083: strict Responses-compatible upstreams reject items whose +// `type` is set without an accompanying `status`, returning +// 400 MissingParameter input.status. `status` is optional per OpenAI's own +// schema, so setting it to "completed" on translated history items (which by +// construction always represent an already-completed prior turn) is a safe +// superset fix that also satisfies stricter third-party validators. +test("Chat -> Responses sets status:completed on every input item (#8083)", () => { + const result = openaiToOpenAIResponsesRequest( + "gpt-4o", + { + messages: [ + { role: "system", content: "Rules" }, + // Mid-conversation developer turn -> developer-role message item. + { role: "developer", content: "Follow up instruction" }, + { role: "user", content: "Hello" }, + { + role: "assistant", + content: "Done", + tool_calls: [ + { id: "call_1", type: "function", function: { name: "read_file", arguments: "{}" } }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "ok" }, + // Deprecated function_call / function role pair. + { + role: "assistant", + function_call: { name: "legacy_fn", arguments: "{}" }, + }, + { role: "function", name: "legacy_fn", content: "legacy ok" }, + ], + }, + false, + null + ) as Record; + + const input = result.input as Array>; + assert.ok(input.length > 0, "input array must not be empty"); + for (const item of input) { + assert.equal( + item.status, + "completed", + `input item of type "${item.type}" is missing status:"completed" — strict Responses-compatible ` + + `upstreams reject this with 400 MissingParameter input.status (#8083)` + ); + } +}); diff --git a/tests/unit/serial/combo-quota-share-cooldown-wait-timing.test.ts b/tests/unit/serial/combo-quota-share-cooldown-wait-timing.test.ts index 7fa49675d2..3cf0953089 100644 --- a/tests/unit/serial/combo-quota-share-cooldown-wait-timing.test.ts +++ b/tests/unit/serial/combo-quota-share-cooldown-wait-timing.test.ts @@ -20,9 +20,8 @@ * The non-quota-share (priority) scenario was UPDATED for the "universal * cooldown-aware retry" change: comboCooldownWait is no longer gated on * `strategy === "quota-share"` — every combo strategy now waits out a SHORT - * transient 429 and re-dispatches (using a "rate_limit" reason and the - * earliest retry-after hint directly, since non quota-share strategies have no - * per-connection model-lockout tracking to consult). It used to assert the + * transient 429 and re-dispatches via the same resolveComboCooldownWaitDecision + * path (real model-lockout reason + allow-list). It used to assert the * OPPOSITE (immediate propagation, no wait) — that assertion is now testing * dead behavior, so it was rewritten to assert the new intended behavior * instead of being deleted or weakened. @@ -147,11 +146,8 @@ test("non quota-share (priority): short 429 cooldown → waits and re-dispatches const handleSingleModel = async () => { calls += 1; // 1st dispatch: transient 429 with a short retry-after hint. 2nd dispatch - // (after the universal cooldown wait): success. Priority combos have no - // per-connection model-lockout tracking, so this exercises the - // `shouldWaitForComboCooldown({ reason: "rate_limit", ... })` path fed - // directly by the earliest retry-after hint (not resolveComboCooldownWaitDecision's - // per-target lock lookup, which stays quota-share-only). + // (after the universal cooldown wait): success. Exercises the shared + // resolveComboCooldownWaitDecision path (real model-lockout reason). return calls === 1 ? rateLimitResponse(429) : okResponse(); }; @@ -182,18 +178,18 @@ test("non quota-share (priority): a quota_exhausted lock drives the decision wit // // Barrier 1 = the reason allow-list. Barrier 2 = the maxWaitMs ceiling. // This scenario is engineered so ONLY barrier 1 can stop the wait: - // - modelLockout.errorCodes is [403] ONLY, so model-a's 429 crystallizes - // status 429 (the sole status that opens the cooldown-wait branch) WITHOUT - // recording a competing `rate_limit` lock. + // - modelLockout.errorCodes is [403] ONLY, so model-a's 429 contributes a + // retry-after hint (opens the cooldown-wait decision) WITHOUT recording a + // competing `rate_limit` lock. // - model-b's 403 records the only lock in play: `quota_exhausted`. It is // therefore the lock resolveComboCooldownWaitDecision picks, so its reason // is what drives the decision. // - The resulting wait is SHORT (well under maxWaitMs=5000), so barrier 2 // lets it through. Only the allow-list can reject it. // - // With the reason hardcoded to "rate_limit" (as the non-quota-share path did - // before), barrier 1 is gone and this exact input waits + redispatches against - // a quota-exhausted model — verified: the same test yields 6 dispatches. + // With the reason hardcoded to "rate_limit", barrier 1 is gone and this exact + // input waits + redispatches against a quota-exhausted model — verified: the + // same test yields 6 dispatches. const calls: string[] = []; const handleSingleModel = async (_body: unknown, modelStr: string) => { calls.push(modelStr); @@ -222,7 +218,10 @@ test("non quota-share (priority): a quota_exhausted lock drives the decision wit allCombos: null, }); - assert.equal(res.status, 429, "the crystallized 429 must be propagated, not retried"); + // Final status is the last target's 403 (aggregation last-wins). The security + // invariant is that the allow-list rejected the wait — not that a peer 429 is + // re-surfaced as the HTTP status. + assert.equal(res.status, 403, "quota_exhausted target status crystallizes; wait must not redispatch"); // Deterministic proof (no wall-clock dependency, so it cannot flake under // CI-runner contention): each target is dispatched EXACTLY ONCE. Had the wait // fired, the whole set loop would re-run — maxAttempts=2 within the 8s budget diff --git a/tests/unit/settings-i18n-keys.test.ts b/tests/unit/settings-i18n-keys.test.ts index 34a91da9f7..21b87fb7fe 100644 --- a/tests/unit/settings-i18n-keys.test.ts +++ b/tests/unit/settings-i18n-keys.test.ts @@ -95,10 +95,10 @@ const resilienceTabSettingsKeys = [ ]; const quotaShareResilienceSettingsMessages = { - resilienceComboCooldownWaitTitle: "Quota-share combo cooldown wait", + resilienceComboCooldownWaitTitle: "Combo cooldown wait", resilienceComboCooldownWaitDesc: - "For quota-share combos only: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.", - resilienceComboCooldownWaitToggleDesc: "Quota-share combos only; never waits on quota_exhausted.", + "For all combo strategies: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.", + resilienceComboCooldownWaitToggleDesc: "All combo strategies; never waits on quota_exhausted.", resilienceComboCooldownMaxWaitMs: "Maximum wait per attempt", resilienceComboCooldownBudgetMs: "Total wait budget", resilienceQuotaShareConcurrencyTitle: "Quota-share per-connection concurrency", diff --git a/tests/unit/settings-transform-schema.test.ts b/tests/unit/settings-transform-schema.test.ts index a1eb6d1615..33c8d9ecb9 100644 --- a/tests/unit/settings-transform-schema.test.ts +++ b/tests/unit/settings-transform-schema.test.ts @@ -44,6 +44,7 @@ const commonOperations = [ versionFormat: "ex-machina", cchAlgo: "sha256-first-user", version: "1.0.0", + buildRevision: "250", }, ] as const; diff --git a/tests/unit/shared/components/KiroAuthModal.test.tsx b/tests/unit/shared/components/KiroAuthModal.test.tsx index 948072d217..f363e234e7 100644 --- a/tests/unit/shared/components/KiroAuthModal.test.tsx +++ b/tests/unit/shared/components/KiroAuthModal.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { act } from "react"; import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const cleanupCallbacks: Array<() => void> = []; @@ -21,6 +22,14 @@ function setInputValue(input: HTMLInputElement, value: string): void { input.dispatchEvent(new Event("input", { bubbles: true })); } +function withIntl(children: React.ReactNode) { + return ( + + {children} + + ); +} + describe("KiroAuthModal", () => { beforeEach(() => { ( @@ -43,7 +52,9 @@ describe("KiroAuthModal", () => { const onMethodSelect = vi.fn(); await act(async () => { - root.render(); + root.render( + withIntl() + ); }); const googleButton = Array.from(container.querySelectorAll("button")).find((button) => @@ -78,7 +89,9 @@ describe("KiroAuthModal", () => { try { await act(async () => { - root.render(); + root.render( + withIntl() + ); }); const apiKeyButton = Array.from(container.querySelectorAll("button")).find( @@ -98,6 +111,8 @@ describe("KiroAuthModal", () => { setInputValue(apiKeyInput, "ksk_test_key"); }); + expect(apiKeyInput.type).toBe("password"); + await act(async () => { saveButton?.click(); }); @@ -109,4 +124,93 @@ describe("KiroAuthModal", () => { globalThis.fetch = originalFetch; } }); + + it("treats successful auto-import as completed instead of importing twice", async () => { + const { default: KiroAuthModal } = await import("@/shared/components/KiroAuthModal"); + const container = makeContainer(); + const root = createRoot(container); + const calls: string[] = []; + const onMethodSelect = vi.fn(() => calls.push("select")); + const onClose = vi.fn(() => calls.push("close")); + const originalFetch = globalThis.fetch; + + globalThis.fetch = vi.fn(async () => { + return new Response(JSON.stringify({ found: true, source: "kiro-cli-sqlite" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + + try { + await act(async () => { + root.render( + withIntl() + ); + }); + + const importButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.querySelector("h3")?.textContent === "Import Token" + ); + await act(async () => { + importButton?.click(); + }); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/oauth/kiro/auto-import?targetProvider=kiro" + ); + expect(onMethodSelect).toHaveBeenCalledWith("import"); + expect(calls).toEqual(["select", "close"]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("does not restart an active social login when the wrapper rerenders", async () => { + const { default: KiroOAuthWrapper } = await import("@/shared/components/KiroOAuthWrapper"); + const container = makeContainer(); + const root = createRoot(container); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/social-authorize")) { + return new Response( + JSON.stringify({ + userCode: "ABCD-EFGH", + authUrl: "https://example.test/authorize", + deviceCode: "device-code", + interval: 60, + expiresIn: 300, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + globalThis.fetch = fetchMock as typeof fetch; + + try { + await act(async () => { + root.render(withIntl()); + }); + + const googleButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Google Account") + ); + await act(async () => { + googleButton?.click(); + }); + + await act(async () => { + root.render(withIntl()); + }); + + expect( + fetchMock.mock.calls.filter(([input]) => String(input).includes("/social-authorize")) + ).toHaveLength(1); + } finally { + await act(async () => root.unmount()); + globalThis.fetch = originalFetch; + } + }); }); diff --git a/tests/unit/sync-env.test.ts b/tests/unit/sync-env.test.ts index be958e6117..415e689a3b 100644 --- a/tests/unit/sync-env.test.ts +++ b/tests/unit/sync-env.test.ts @@ -25,7 +25,7 @@ function writeEnvExample(rootDir: string) { "MACHINE_ID_SALT=", "CLAUDE_OAUTH_CLIENT_ID=claude-default", "CODEX_OAUTH_CLIENT_ID=codex-default", - 'CLAUDE_USER_AGENT="claude-cli/2.1.145 (external, cli)"', + 'CLAUDE_USER_AGENT="claude-cli/2.1.219 (external, cli)"', "# COMMENTED_KEY=skip-me", "", ].join("\n"), @@ -72,7 +72,7 @@ test("syncEnv creates .env from .env.example and generates install-time secrets" assert.match(envContent, /^MACHINE_ID_SALT=omniroute-/m); assert.match(envContent, /^CLAUDE_OAUTH_CLIENT_ID=claude-default$/m); assert.match(envContent, /^CODEX_OAUTH_CLIENT_ID=codex-default$/m); - assert.match(envContent, /^CLAUDE_USER_AGENT="claude-cli\/2\.1\.145 \(external, cli\)"$/m); + assert.match(envContent, /^CLAUDE_USER_AGENT="claude-cli\/2\.1\.219 \(external, cli\)"$/m); assert.doesNotMatch(envContent, /^COMMENTED_KEY=/m); } finally { process.env.DATA_DIR = origDataDir; @@ -107,7 +107,7 @@ test("syncEnv appends only missing keys and preserves existing values", () => { assert.match(envContent, /^STORAGE_ENCRYPTION_KEY=$/m); assert.match(envContent, /^MACHINE_ID_SALT=omniroute-/m); assert.match(envContent, /^CODEX_OAUTH_CLIENT_ID=codex-default$/m); - assert.match(envContent, /^CLAUDE_USER_AGENT=claude-cli\/2\.1\.145 \(external, cli\)$/m); + assert.match(envContent, /^CLAUDE_USER_AGENT=claude-cli\/2\.1\.219 \(external, cli\)$/m); assert.match(envContent, /Auto-added by sync-env/); } finally { process.env.DATA_DIR = origDataDir; @@ -130,7 +130,7 @@ test("syncEnv treats quoted and unquoted values as equivalent", () => { "MACHINE_ID_SALT=machine-salt", "CLAUDE_OAUTH_CLIENT_ID=claude-default", "CODEX_OAUTH_CLIENT_ID=codex-default", - 'CLAUDE_USER_AGENT="claude-cli/2.1.145 (external, cli)"', + 'CLAUDE_USER_AGENT="claude-cli/2.1.219 (external, cli)"', "", ].join("\n"), "utf8" diff --git a/tests/unit/system-transforms.test.ts b/tests/unit/system-transforms.test.ts index 8c576b4e4b..30b5a178c7 100644 --- a/tests/unit/system-transforms.test.ts +++ b/tests/unit/system-transforms.test.ts @@ -542,6 +542,7 @@ const UI_DEFAULTS_SNAPSHOT = { entrypoint: "sdk-cli", versionFormat: "ex-machina", cchAlgo: "sha256-first-user", + buildRevision: "250", }, ], }, diff --git a/tests/unit/t12-pricing-updates.test.ts b/tests/unit/t12-pricing-updates.test.ts index 799f7cec76..5f66d6ea18 100644 --- a/tests/unit/t12-pricing-updates.test.ts +++ b/tests/unit/t12-pricing-updates.test.ts @@ -37,6 +37,10 @@ test("T12: pricing table includes current Codex, MiniMax, GLM and Kimi entries", assert.ok(pricing.kimi["kimi-k2.5-thinking"], "missing kimi/kimi-k2.5-thinking"); assert.ok(pricing.kimi["kimi-for-coding"], "missing kimi/kimi-for-coding"); + assert.ok(pricing.anthropic["claude-opus-5"], "missing anthropic/claude-opus-5"); + assert.equal(pricing.anthropic["claude-opus-5"].input, 5); + assert.equal(pricing.anthropic["claude-opus-5"].output, 25); + assert.ok(pricing.gh["claude-opus-5"], "missing gh/claude-opus-5"); assert.ok(pricing.anthropic["claude-opus-4.8"], "missing anthropic/claude-opus-4.8"); assert.ok(pricing.anthropic["claude-opus-4-8"], "missing anthropic/claude-opus-4-8"); assert.ok(pricing.anthropic["claude-opus-4-7"], "missing anthropic/claude-opus-4-7"); diff --git a/tests/unit/t31-t33-t34-t38-model-specs.test.ts b/tests/unit/t31-t33-t34-t38-model-specs.test.ts index 998a2d6157..f47f22d8b2 100644 --- a/tests/unit/t31-t33-t34-t38-model-specs.test.ts +++ b/tests/unit/t31-t33-t34-t38-model-specs.test.ts @@ -52,6 +52,7 @@ test("T34: max output tokens are capped by model spec", () => { assert.equal(capMaxOutputTokens("gemini-3-flash", 131072), 65536); assert.equal(capMaxOutputTokens("gemini-3-flash"), 65536); assert.equal(capMaxOutputTokens("gemini-3.1-pro-high", 131072), 65535); + assert.equal(capMaxOutputTokens("claude-opus-5", 200000), 128000); assert.equal(capMaxOutputTokens("claude-opus-4-8", 200000), 128000); assert.equal(capMaxOutputTokens("claude-opus-4-7", 200000), 128000); assert.equal(capMaxOutputTokens("anthropic.claude-sonnet-4-6", 200000), 64000); @@ -67,6 +68,8 @@ test("T38: modelSpecs exposes centralized helpers with alias and prefix lookup", assert.equal(getModelSpec("gemini-3-flash-preview").maxOutputTokens, 65536); assert.equal(getModelSpec("gemini-3.1-pro-preview").maxOutputTokens, 65535); assert.equal(getModelSpec("gemini-3.1-pro-preview-customtools").maxOutputTokens, 65535); + assert.equal(getModelSpec("claude-opus-5").contextWindow, 1000000); + assert.equal(getModelSpec("anthropic.claude-opus-5").maxOutputTokens, 128000); assert.equal(getModelSpec("claude-opus-4-7").contextWindow, 1000000); assert.equal(getModelSpec("claude-opus-4.8").maxOutputTokens, 128000); assert.equal(getModelSpec("claude-opus-4.7").maxOutputTokens, 128000); diff --git a/tests/unit/thundering-herd.test.ts b/tests/unit/thundering-herd.test.ts index e3af4f81f1..140efa04a6 100644 --- a/tests/unit/thundering-herd.test.ts +++ b/tests/unit/thundering-herd.test.ts @@ -39,9 +39,15 @@ test("API profile has shorter transient cooldown", () => { test("Exponential backoff clamps to the configured maxBackoffLevel", () => { const result = checkFallbackError(502, "", 20, null, null); assert.equal(result.newBackoffLevel, BACKOFF_CONFIG.maxLevel); + // #8396: the level still clamps at maxLevel, but capScaledCooldownMs also + // bounds the duration — with no provider profile the ceiling is + // BACKOFF_CONFIG.max rather than the unbounded baseCooldownMs * 2^level. assert.equal( result.cooldownMs, - COOLDOWN_MS.transientInitial * Math.pow(2, BACKOFF_CONFIG.maxLevel) + Math.min( + COOLDOWN_MS.transientInitial * Math.pow(2, BACKOFF_CONFIG.maxLevel), + BACKOFF_CONFIG.max + ) ); }); diff --git a/tests/unit/token-health-check-devin-cli-8407.test.ts b/tests/unit/token-health-check-devin-cli-8407.test.ts new file mode 100644 index 0000000000..9812b21615 --- /dev/null +++ b/tests/unit/token-health-check-devin-cli-8407.test.ts @@ -0,0 +1,67 @@ +// #8407: devin-cli must not be treated as refresh-capable, so the health sweep +// never force-expires local CLI connections that legitimately have no refresh token. +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"; + +process.env.NODE_ENV = "test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-hc-devin-cli-8407-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const tokenHealthCheck = await import("../../src/lib/tokenHealthCheck.ts"); +const { supportsTokenRefresh } = await import("../../open-sse/services/tokenRefresh.ts"); + +async function resetStorage() { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function getCreatedConnectionId(connection: { id?: unknown }): string { + assert.equal(typeof connection.id, "string"); + return connection.id as string; +} + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("supportsTokenRefresh excludes devin-cli (#8407)", () => { + // Root fix: drop "devin-cli" from the explicit set so the health sweep's + // supportsTokenRefresh=false guard applies (same idea as not listing a + // non-refresh local-CLI provider). windsurf stays refresh-capable. + assert.equal( + supportsTokenRefresh("devin-cli"), + false, + "devin-cli is local import-token / CLI-owned — not refresh-capable" + ); + assert.equal(supportsTokenRefresh("windsurf"), true); +}); + +test("checkConnection leaves a devin-cli connection with no refresh token untouched (#8407)", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "devin-cli", + authType: "oauth", + name: "Devin CLI Local Account", + accessToken: "local-cli-access-token", + refreshToken: null, + testStatus: "active", + isActive: true, + }); + + await tokenHealthCheck.checkConnection(connection); + + const updated = await providersDb.getProviderConnectionById(getCreatedConnectionId(connection)); + assert.equal(updated?.testStatus, "active", "devin-cli testStatus must remain active"); + assert.notEqual(updated?.errorCode, "no_refresh_token", "devin-cli must not be marked no_refresh_token"); +}); diff --git a/tests/unit/token-refresh-cas-guard.test.ts b/tests/unit/token-refresh-cas-guard.test.ts new file mode 100644 index 0000000000..babe37b6c0 --- /dev/null +++ b/tests/unit/token-refresh-cas-guard.test.ts @@ -0,0 +1,124 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Unit tests for the CAS guard leaf extracted from tokenRefresh.ts (#4038). +// The CAS guard re-reads the row's refresh_token right before persisting and +// SKIPS the write when a concurrent writer already rotated it past the token +// the caller presented — preventing a revert that would invalidate the token +// family on rotating-token providers (Auth0/Anthropic). + +const { + runWithCasGuard, + getActiveCasGuard, + getCasGuardStats, + _resetCasGuardStats, + casGuardShouldSkipPersist, +} = await import("../../open-sse/services/tokenRefresh/casGuard.ts"); + +const silentLog = { info() {}, warn() {}, error() {} }; + +test.beforeEach(() => { + _resetCasGuardStats(); +}); + +test("getActiveCasGuard returns undefined outside a guard context", () => { + assert.equal(getActiveCasGuard(), undefined); +}); + +test("runWithCasGuard exposes the guard via getActiveCasGuard inside the closure", async () => { + const guard = { expectedRefreshToken: "R0", reread: async () => "R0" }; + await runWithCasGuard(guard, async () => { + assert.equal(getActiveCasGuard(), guard); + }); + assert.equal(getActiveCasGuard(), undefined, "guard is cleared after the closure resolves"); +}); + +test("runWithCasGuard with a null/undefined guard runs the function unchanged", async () => { + let ran = false; + await runWithCasGuard(null, async () => { + ran = true; + }); + assert.equal(ran, true); + assert.equal(getActiveCasGuard(), undefined); +}); + +test("casGuardShouldSkipPersist returns false when no guard is active", async () => { + assert.equal(await casGuardShouldSkipPersist(silentLog), false); + assert.equal(getCasGuardStats().skipped, 0); + assert.equal(getCasGuardStats().persisted, 0); +}); + +test("casGuardShouldSkipPersist returns false when the guard has no expectedRefreshToken", async () => { + const guard = { expectedRefreshToken: null, reread: async () => "R0" }; + await runWithCasGuard(guard, async () => { + assert.equal(await casGuardShouldSkipPersist(silentLog), false); + }); + assert.equal(getCasGuardStats().skipped, 0); + assert.equal(getCasGuardStats().persisted, 0); +}); + +test("casGuardShouldSkipPersist SKIPS when the row rotated past the presented token", async () => { + const guard = { expectedRefreshToken: "R0", reread: async () => "R_CONCURRENT" }; + await runWithCasGuard(guard, async () => { + assert.equal(await casGuardShouldSkipPersist(silentLog), true); + }); + assert.equal(getCasGuardStats().skipped, 1); + assert.equal(getCasGuardStats().persisted, 0); +}); + +test("casGuardShouldSkipPersist PERSISTS when the row still holds the presented token", async () => { + const guard = { expectedRefreshToken: "R0", reread: async () => "R0" }; + await runWithCasGuard(guard, async () => { + assert.equal(await casGuardShouldSkipPersist(silentLog), false); + }); + assert.equal(getCasGuardStats().skipped, 0); + assert.equal(getCasGuardStats().persisted, 1); +}); + +test("casGuardShouldSkipPersist falls through to persist when reread throws (best-effort)", async () => { + const guard = { + expectedRefreshToken: "R0", + reread: async () => { + throw new Error("db unavailable"); + }, + }; + await runWithCasGuard(guard, async () => { + assert.equal(await casGuardShouldSkipPersist(silentLog), false); + }); + // reread failure returns false (do not skip persist) WITHOUT touching the + // persisted counter — the counter only advances on a successful reread that + // confirms the row is unchanged. The key guarantee is skipped stays 0. + assert.equal(getCasGuardStats().skipped, 0, "reread failure must never block recovery"); + assert.equal(getCasGuardStats().persisted, 0); +}); + +test("casGuardShouldSkipPersist treats an empty reread as not-rotated", async () => { + const guard = { expectedRefreshToken: "R0", reread: async () => null }; + await runWithCasGuard(guard, async () => { + assert.equal(await casGuardShouldSkipPersist(silentLog), false); + }); + assert.equal(getCasGuardStats().persisted, 1); +}); + +test("getCasGuardStats returns a snapshot copy (not the live counters)", () => { + const guard = { expectedRefreshToken: "R0", reread: async () => "R0" }; + return runWithCasGuard(guard, async () => { + await casGuardShouldSkipPersist(silentLog); + const snap = getCasGuardStats(); + assert.equal(snap.persisted, 1); + // Mutating the snapshot must not affect future stats. + snap.persisted = 999; + assert.equal(getCasGuardStats().persisted, 1); + }); +}); + +test("_resetCasGuardStats zeroes both counters", async () => { + const guard = { expectedRefreshToken: "R0", reread: async () => "R_CONCURRENT" }; + await runWithCasGuard(guard, async () => { + await casGuardShouldSkipPersist(silentLog); + }); + assert.equal(getCasGuardStats().skipped, 1); + _resetCasGuardStats(); + assert.equal(getCasGuardStats().skipped, 0); + assert.equal(getCasGuardStats().persisted, 0); +}); diff --git a/tests/unit/token-refresh-circuit-breaker.test.ts b/tests/unit/token-refresh-circuit-breaker.test.ts new file mode 100644 index 0000000000..fb88a7d6c4 --- /dev/null +++ b/tests/unit/token-refresh-circuit-breaker.test.ts @@ -0,0 +1,176 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Unit tests for the circuit breaker + refreshWithRetry leaf extracted from +// tokenRefresh.ts. refreshWithRetry wraps a refresh attempt with exponential +// backoff, a 30s per-attempt timeout, and a per-provider circuit breaker +// (5 consecutive failures → 30min pause). Unrecoverable refresh errors +// short-circuit retries. + +const { isProviderBlocked, getCircuitBreakerStatus, refreshWithRetry } = + await import("../../open-sse/services/tokenRefresh/circuitBreaker.ts"); + +const silentLog = { + info() {}, + warn() {}, + error() {}, + debug() {}, +}; + +function makeLog() { + const entries = []; + const log = (level) => (scope, message) => entries.push({ level, scope, message }); + return { + entries, + debug: log("debug"), + info: log("info"), + warn: log("warn"), + error: log("error"), + }; +} + +test("isProviderBlocked returns false for an unknown provider", () => { + assert.equal(isProviderBlocked("never-seen"), false); +}); + +test("getCircuitBreakerStatus returns an empty object when no failures recorded", () => { + assert.deepEqual(getCircuitBreakerStatus(), {}); +}); + +test("refreshWithRetry returns the result on the first success and clears prior failures", async () => { + const provider = "cb-success-" + Math.random().toString(36).slice(2); + // Seed a failure so we can verify success clears it. + await refreshWithRetry(async () => null, 1, silentLog, provider); + assert.equal(getCircuitBreakerStatus()[provider].failures, 1); + + const result = await refreshWithRetry( + async () => ({ accessToken: "ok" }), + 3, + silentLog, + provider + ); + assert.equal(result.accessToken, "ok"); + assert.equal(getCircuitBreakerStatus()[provider], undefined, "success resets the breaker"); +}); + +test("refreshWithRetry retries to success within maxRetries", async () => { + const provider = "cb-retry-" + Math.random().toString(36).slice(2); + let attempts = 0; + const result = await refreshWithRetry( + async () => { + attempts++; + if (attempts < 2) return null; + return { accessToken: "ok-after-retry" }; + }, + 3, + silentLog, + provider + ); + assert.equal(result.accessToken, "ok-after-retry"); + assert.equal(attempts, 2); + assert.equal(getCircuitBreakerStatus()[provider], undefined); +}); + +test("refreshWithRetry bails immediately on an unrecoverable error without retrying", async () => { + const provider = "cb-unrecoverable-" + Math.random().toString(36).slice(2); + let attempts = 0; + const result = await refreshWithRetry( + async () => { + attempts++; + return { error: "invalid_grant" }; + }, + 3, + silentLog, + provider + ); + assert.equal(attempts, 1, "unrecoverable errors must not be retried"); + assert.equal(result.error, "invalid_grant"); + assert.equal( + getCircuitBreakerStatus()[provider], + undefined, + "no failure recorded for unrecoverable" + ); +}); + +test("refreshWithRetry bails immediately on refresh_token_reused", async () => { + const provider = "cb-reused-" + Math.random().toString(36).slice(2); + let attempts = 0; + const result = await refreshWithRetry( + async () => { + attempts++; + return { error: "refresh_token_reused" }; + }, + 3, + silentLog, + provider + ); + assert.equal(attempts, 1); + assert.equal(result.error, "refresh_token_reused"); +}); + +test("refreshWithRetry trips the circuit breaker after repeated failures", async () => { + const provider = "cb-trip-" + Math.random().toString(36).slice(2); + // 5 consecutive single-retry failures trip the breaker. + for (let i = 0; i < 5; i++) { + await refreshWithRetry(async () => null, 1, silentLog, provider); + } + assert.equal(isProviderBlocked(provider), true); + assert.equal(getCircuitBreakerStatus()[provider].blocked, true); + assert.ok(getCircuitBreakerStatus()[provider].blockedUntil); + + // A blocked provider short-circuits without calling refreshFn. + let called = false; + const blocked = await refreshWithRetry( + async () => { + called = true; + return { accessToken: "x" }; + }, + 1, + silentLog, + provider + ); + assert.equal(called, false, "refreshFn must not run while the breaker is open"); + assert.equal(blocked, null); +}); + +test("refreshWithRetry records a failure when all retries are exhausted", async () => { + const provider = "cb-exhaust-" + Math.random().toString(36).slice(2); + const log = makeLog(); + const result = await refreshWithRetry(async () => null, 2, log, provider); + assert.equal(result, null); + assert.equal(getCircuitBreakerStatus()[provider].failures, 1); + assert.ok( + log.entries.some((e) => e.level === "error" && /All 2 retry attempts failed/.test(e.message)) + ); +}); + +test("refreshWithRetry propagates thrown errors as retry failures (not crashes)", async () => { + const provider = "cb-throw-" + Math.random().toString(36).slice(2); + const log = makeLog(); + let attempts = 0; + const result = await refreshWithRetry( + async () => { + attempts++; + throw new Error("upstream boom"); + }, + 2, + log, + provider + ); + assert.equal(result, null); + assert.equal(attempts, 2, "thrown errors are retried, not fatal"); + assert.equal(getCircuitBreakerStatus()[provider].failures, 1); + assert.ok(log.entries.some((e) => e.level === "warn" && /failed: upstream boom/.test(e.message))); +}); + +test("refreshWithRetry defaults: maxRetries=3, provider='unknown'", async () => { + // With defaults, an always-null refresh exhausts 3 attempts and records a + // failure under the "unknown" provider. + let attempts = 0; + await refreshWithRetry(async () => { + attempts++; + return null; + }); + assert.equal(attempts, 3); + assert.ok(getCircuitBreakerStatus()["unknown"], "default provider is 'unknown'"); +}); diff --git a/tests/unit/token-refresh-rotation-map.test.ts b/tests/unit/token-refresh-rotation-map.test.ts new file mode 100644 index 0000000000..d38a9c64ac --- /dev/null +++ b/tests/unit/token-refresh-rotation-map.test.ts @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Unit tests for the token rotation map leaf extracted from tokenRefresh.ts. +// The rotation map caches RECENT refresh_token rotations so a stale caller can +// be redirected to the new tokens WITHOUT re-hitting upstream (which would +// trigger Auth0 family revocation on rotating-token providers like Codex). + +const { + getRefreshCacheKey, + lookupRotation, + recordRotation, + _getTokenRotationMapStats, + _clearTokenRotationMap, +} = await import("../../open-sse/services/tokenRefresh/rotationMap.ts"); + +test.beforeEach(() => { + _clearTokenRotationMap(); +}); + +test("getRefreshCacheKey is deterministic and provider-scoped", () => { + const a = getRefreshCacheKey("codex", "refresh-1"); + const b = getRefreshCacheKey("codex", "refresh-1"); + const c = getRefreshCacheKey("openai", "refresh-1"); + assert.equal(a, b, "same (provider, token) must hash to the same key"); + assert.notEqual(a, c, "different provider must produce a different key"); + assert.match(a, /^codex:/, "key is prefixed with the provider id"); + // The raw refresh token must NOT appear in the key (it is hashed). + assert.doesNotMatch(a, /refresh-1/); +}); + +test("recordRotation stores a rotation keyed by the OLD refresh token", () => { + recordRotation("codex", "old-rt", { + accessToken: "new-access", + refreshToken: "new-rt", + expiresIn: 3600, + }); + const stats = _getTokenRotationMapStats(); + assert.equal(stats.size, 1); + const hit = lookupRotation("codex", "old-rt"); + assert.ok(hit, "lookup by the old refresh token must find the cached rotation"); + assert.equal(hit.result.accessToken, "new-access"); + assert.equal(hit.result.refreshToken, "new-rt"); + assert.equal(hit.result.expiresIn, 3600); +}); + +test("recordRotation is a no-op when the refresh token did not rotate", () => { + recordRotation("codex", "same-rt", { + accessToken: "new-access", + refreshToken: "same-rt", + expiresIn: 3600, + }); + assert.equal(_getTokenRotationMapStats().size, 0, "no rotation recorded when token unchanged"); + assert.equal(lookupRotation("codex", "same-rt"), undefined); +}); + +test("recordRotation is a no-op when the old refresh token is empty", () => { + recordRotation("codex", "", { + accessToken: "new-access", + refreshToken: "new-rt", + }); + assert.equal(_getTokenRotationMapStats().size, 0); +}); + +test("recordRotation is a no-op when the new refresh token is empty", () => { + recordRotation("codex", "old-rt", { + accessToken: "new-access", + refreshToken: "", + }); + assert.equal(_getTokenRotationMapStats().size, 0); +}); + +test("lookupRotation returns undefined for an unknown token", () => { + assert.equal(lookupRotation("codex", "never-recorded"), undefined); +}); + +test("lookupRotation returns undefined for a different provider", () => { + recordRotation("codex", "shared-rt", { + accessToken: "a", + refreshToken: "new-rt", + }); + assert.equal(lookupRotation("openai", "shared-rt"), undefined, "rotation map is provider-scoped"); + assert.ok(lookupRotation("codex", "shared-rt"), "the original provider still hits"); +}); + +test("_clearTokenRotationMap empties the map", () => { + recordRotation("codex", "old-rt", { accessToken: "a", refreshToken: "new-rt" }); + assert.equal(_getTokenRotationMapStats().size, 1); + _clearTokenRotationMap(); + assert.equal(_getTokenRotationMapStats().size, 0); + assert.equal(lookupRotation("codex", "old-rt"), undefined); +}); + +test("_getTokenRotationMapStats reports the live entry count", () => { + assert.equal(_getTokenRotationMapStats().size, 0); + recordRotation("codex", "old-1", { accessToken: "a1", refreshToken: "new-1" }); + recordRotation("codex", "old-2", { accessToken: "a2", refreshToken: "new-2" }); + assert.equal(_getTokenRotationMapStats().size, 2); + assert.equal(_getTokenRotationMapStats().entries, 2); +}); diff --git a/tests/unit/topology-connection-health.test.ts b/tests/unit/topology-connection-health.test.ts index a7ed1c01bc..3b2db2f3e0 100644 --- a/tests/unit/topology-connection-health.test.ts +++ b/tests/unit/topology-connection-health.test.ts @@ -37,19 +37,52 @@ test("HomeProviderTopologySection forwards the status field on each provider", ( }); test("ProviderTopology renders a connection-health base layer under the traffic signals", () => { - // Traffic (live/recent/error) must still take precedence over the static health colour. + // Live traffic and traffic errors still take precedence over the static health colour, + // but `last` (most recently routed) must NOT: it used to null out `healthy`, and since + // the node had no `last` visual the just-used provider rendered as idle grey with an + // amber edge — less connected-looking than an untouched peer. Health owns the border, + // recency owns the dot. assert.match( providerTopologySrc, - /const healthy =\s*!active && !trafficError && !last && !healthError && p\.status === "active"/, - "healthy is only shown when there is no stronger traffic signal" + /const healthy =\s*!active && !trafficError && !healthError && p\.status === "active"/, + "healthy must survive the last-used annotation" + ); + assert.doesNotMatch( + providerTopologySrc, + /const healthy =[^;]*!last/, + "last-used must not suppress the health colour" + ); + assert.match( + providerTopologySrc, + /const healthError =\s*!active && !trafficError && p\.status === "error"/, + "healthError must survive the last-used annotation" ); assert.match( providerTopologySrc, /edgeStyle\(active, last, error, healthy\)/, "the healthy state must reach the edge palette" ); + // The node must render the health state (green border / static dot) — a non-pulsing dot // distinguishes "connected" from "active". assert.match(providerTopologySrc, /pulse=\{active \|\| error\}/); - assert.match(providerTopologySrc, /active \|\| error \|\| healthy/); + assert.match(providerTopologySrc, /active \|\| error \|\| healthy \|\| last/); +}); + +test("ProviderTopology marks the last-routed provider with an amber dot, not a grey node", () => { + assert.match( + providerTopologySrc, + /const AMBER = FLOW_EDGE_COLORS\.last/, + "recency reuses the shared amber from the edge palette" + ); + assert.match( + providerTopologySrc, + /const dotColor = active \? color : last \? AMBER : GREEN/, + "the dot encodes recency while the border keeps encoding health" + ); + assert.match( + providerTopologySrc, + /borderColor: error \? RED : active \? color : healthy \? GREEN : "var\(--color-border\)"/, + "border stays health-driven — grey is reserved for genuinely idle/unconfigured" + ); }); diff --git a/tests/unit/translator-openai-responses-image-output-8459.test.ts b/tests/unit/translator-openai-responses-image-output-8459.test.ts new file mode 100644 index 0000000000..5a705d754e --- /dev/null +++ b/tests/unit/translator-openai-responses-image-output-8459.test.ts @@ -0,0 +1,185 @@ +/** + * #8459 — Responses->Chat translation of tool-call outputs containing input_image + * must strip the image and replace with a placeholder, not embed raw base64 as text. + * + * Without this fix: + * - `function_call_output` and `custom_tool_call_output` with an array output + * containing `input_image` parts get `JSON.stringify`'d into the `tool` message + * content, embedding the raw ~52KB base64 data URI as inert text. + * - The model never receives the image (no structured `image_url` part). + * - A single screenshot pushes ~50KB+ of meaningless base64 into the prompt. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIRequest } = + await import("../../open-sse/translator/request/openai-responses.ts"); + +const IMAGE_PLACEHOLDER = "[Image omitted: not supported on Chat Completions tool results]"; +const SAMPLE_BASE64 = "AAAA" + "a".repeat(100); // small but realistic-looking base64 + +test("#8459 function_call_output strips input_image and preserves input_text", () => { + const result = openaiResponsesToOpenAIRequest( + "gpt-5.2", + { + input: [ + { + type: "function_call", + call_id: "call_abc123", + name: "bash", + arguments: '{"command":"ls"}', + }, + { + type: "function_call_output", + call_id: "call_abc123", + output: [ + { type: "input_text", text: "Script completed\nFile: screenshot.png" }, + { + type: "input_image", + image_url: `data:image/png;base64,${SAMPLE_BASE64}`, + detail: "original", + }, + ], + }, + ], + }, + false, + {} + ); + + const messages = (result as Record).messages as Record[]; + // Should have: user message (placeholder) + function_call + tool result = 3 messages + // Actually: instructions is empty, so no system message. + // user placeholder (from input normalization) + assistant (from function_call) + tool result + const toolMsg = messages.find((m) => m.role === "tool"); + assert.ok(toolMsg, "should have a tool message"); + assert.equal(typeof toolMsg.content, "string"); + assert.doesNotMatch( + toolMsg.content as string, + /base64|AAAA/, + "tool content must not contain raw base64" + ); + assert.ok( + (toolMsg.content as string).includes("Script completed"), + "text parts must be preserved" + ); + assert.ok( + (toolMsg.content as string).includes(IMAGE_PLACEHOLDER), + "image parts must be replaced with placeholder" + ); +}); + +test("#8459 custom_tool_call_output strips input_image and preserves input_text", () => { + const result = openaiResponsesToOpenAIRequest( + "gpt-5.2", + { + input: [ + { + type: "custom_tool_call", + call_id: "call_def456", + name: "take_screenshot", + input: "{}", + }, + { + type: "custom_tool_call_output", + call_id: "call_def456", + output: [ + { type: "input_text", text: "Screenshot captured" }, + { + type: "input_image", + image_url: `data:image/png;base64,${SAMPLE_BASE64}`, + detail: "original", + }, + ], + }, + ], + }, + false, + {} + ); + + const messages = (result as Record).messages as Record[]; + const toolMsg = messages.find((m) => m.role === "tool"); + assert.ok(toolMsg, "should have a tool message"); + assert.equal(typeof toolMsg.content, "string"); + assert.doesNotMatch( + toolMsg.content as string, + /base64|AAAA/, + "tool content must not contain raw base64" + ); + assert.ok( + (toolMsg.content as string).includes("Screenshot captured"), + "text parts must be preserved" + ); + assert.ok( + (toolMsg.content as string).includes(IMAGE_PLACEHOLDER), + "image parts must be replaced with placeholder" + ); +}); + +test("#8459 string output is unchanged", () => { + const result = openaiResponsesToOpenAIRequest( + "gpt-5.2", + { + input: [ + { + type: "function_call", + call_id: "call_ghi789", + name: "grep", + arguments: '{"pattern":"foo"}', + }, + { + type: "function_call_output", + call_id: "call_ghi789", + output: "Found 3 matches", + }, + ], + }, + false, + {} + ); + + const messages = (result as Record).messages as Record[]; + const toolMsg = messages.find((m) => m.role === "tool"); + assert.ok(toolMsg, "should have a tool message"); + assert.equal(toolMsg.content, "Found 3 matches", "string output must pass through unchanged"); +}); + +test("#8459 JSON object output is unchanged (not an array of content parts)", () => { + const result = openaiResponsesToOpenAIRequest( + "gpt-5.2", + { + input: [ + { + type: "function_call", + call_id: "call_jkl012", + name: "read_file", + arguments: '{"path":"file.txt"}', + }, + { + type: "function_call_output", + call_id: "call_jkl012", + output: { result: "file content", metadata: { size: 123 } }, + }, + ], + }, + false, + {} + ); + + const messages = (result as Record).messages as Record[]; + const toolMsg = messages.find((m) => m.role === "tool"); + assert.ok(toolMsg, "should have a tool message"); + // JSON object should still be stringified, but NOT an array of content parts + assert.equal(typeof toolMsg.content, "string"); + assert.ok( + (toolMsg.content as string).includes("file content"), + "JSON output should be stringified" + ); + // No image placeholder for non-content-part arrays + assert.doesNotMatch( + toolMsg.content as string, + /\[Image omitted/, + "non-content-part array must not be treated as image-bearing" + ); +}); diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index 9618961bcc..b12038606b 100644 --- a/tests/unit/translator-openai-responses-req.test.ts +++ b/tests/unit/translator-openai-responses-req.test.ts @@ -360,22 +360,26 @@ test("Chat -> Responses converts messages, tool calls, tool outputs, tools and p { type: "input_image", image_url: "https://example.com/cat.png", detail: "high" }, { type: "input_file", file_data: "abc", filename: "doc.txt" }, ], + status: "completed", }, { type: "message", role: "assistant", content: [{ type: "output_text", text: "Done" }], + status: "completed", }, { type: "function_call", call_id: "call_1", name: "read_file", arguments: '{"path":"/tmp/a"}', + status: "completed", }, { type: "function_call_output", call_id: "call_1", output: [{ type: "input_text", text: "ok" }], + status: "completed", }, ]); assert.deepEqual((result as any).tools, [ @@ -520,6 +524,7 @@ test("Chat -> Responses converts assistant image_url history parts to output_tex { type: "output_text", text: "I inspected the screenshot." }, { type: "output_text", text: "[Image: https://example.com/scope.png]" }, ], + status: "completed", }, ]); assert.equal(JSON.stringify(result).includes('"image_url"'), false); diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index 4cf19efebb..803aa39fd2 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -966,7 +966,7 @@ test("OpenAI -> Kiro serializes non-string role:tool content to non-empty text ( }); // Only Claude models support images in Kiro. Non-Claude Kiro models -// (deepseek-3.2, minimax-m2.5, glm-5, qwen3-coder-next, auto-kiro) must NOT +// (deepseek-3.2, minimax-m2.5, glm-5, qwen3-coder-next) must NOT // receive image attachments — attaching them is wrong for those models. const PNG_DATA_URL = "data:image/png;base64,aGVsbG8="; @@ -1016,8 +1016,8 @@ test("OpenAI -> Kiro drops images for non-Claude models (deepseek)", () => { ); }); -test("OpenAI -> Kiro drops images for non-Claude models (glm / auto-kiro)", () => { - for (const model of ["glm-5", "minimax-m2.5", "qwen3-coder-next", "auto-kiro"]) { +test("OpenAI -> Kiro drops images for other non-Claude Kiro models", () => { + for (const model of ["glm-5", "minimax-m2.5", "qwen3-coder-next"]) { const result = buildImageRequest(model); const images = result.conversationState.currentMessage.userInputMessage.images; assert.ok( @@ -1031,7 +1031,7 @@ test("buildKiroPayload rejects the Anthropic-only [1m] context suffix before Bed const body = { messages: [{ role: "user", content: "Hello" }] }; assert.throws( - () => buildKiroPayload("claude-opus-4.7-thinking-agentic[1m]", body, true, {}), + () => buildKiroPayload("claude-sonnet-5-thinking[1m]", body, true, {}), /\[1m\]' suffix is not supported by Kiro upstream/, "kr/* model ids carrying [1m] must be rejected, not forwarded to AWS Bedrock" ); @@ -1046,14 +1046,14 @@ test("buildKiroPayload accepts kr/* model ids without the [1m] suffix", () => { ); }); -test("buildKiroPayload strips local Kiro selector suffixes before upstream", () => { +test("buildKiroPayload strips the supported Thinking selector before upstream", () => { const body = { messages: [{ role: "user", content: "Hello" }] }; - const result = buildKiroPayload("claude-sonnet-5-thinking-agentic", body, true, {}); + const result = buildKiroPayload("claude-sonnet-5-thinking", body, true, {}); assert.equal( result.conversationState.currentMessage.userInputMessage.modelId, "claude-sonnet-5", - "local -thinking/-agentic aliases must not be forwarded to Kiro" + "the local -thinking alias must not be forwarded to Kiro" ); assert.equal( result.additionalModelRequestFields?.output_config?.effort, @@ -1062,13 +1062,6 @@ test("buildKiroPayload strips local Kiro selector suffixes before upstream", () ); }); -test("buildKiroPayload maps auto-kiro selector to Kiro auto upstream id", () => { - const body = { messages: [{ role: "user", content: "Hello" }] }; - - const result = buildKiroPayload("auto-kiro", body, true, {}); - assert.equal(result.conversationState.currentMessage.userInputMessage.modelId, "auto"); -}); - // Regression for upstream decolua/9router PR #2270: the dash->dot normalization's // trailing minor-version group must be bounded (1-2 digits), otherwise a // date-suffixed Claude model id (e.g. claude-opus-4-20250514) gets corrupted into diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 3659a82fb5..0e5cffc293 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -97,6 +97,57 @@ test("OpenAI -> Responses: emits lifecycle, reasoning, text, tool calls and comp assert.equal(completed.data.response.usage.input_tokens_details.cached_tokens, 2); }); +// Regression guard for the OpenRouter/nemotron "reasoning_content + tool_calls in the +// final chunk" case reported via the /dashboard/logs/timeline UI: the SSE events sent to +// the client were always correct, but stream.ts's completion-log summary builder +// (open-sse/utils/stream.ts) reads the shared `state.toolCalls` Map — populated by the +// openai-to-claude / claude-to-openai / gemini-to-openai translators — to report +// finish_reason and message.tool_calls in the persisted call-log. This translator alone +// tracked tool calls in its own funcCallIds/funcNames/funcArgsBuf bookkeeping without +// ever writing to the shared Map, so every openai->openai-responses translated stream +// with a tool call was logged as finish_reason "stop" with no tool_calls, even though the +// client received the tool call correctly. +test("OpenAI -> Responses: closing a tool call also records it in the shared state.toolCalls map", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + const chunks = [ + { + id: "chatcmpl-4", + model: "nvidia/nemotron", + choices: [{ index: 0, delta: { reasoning_content: "thinking" }, finish_reason: null }], + }, + { + id: "chatcmpl-4", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call-abc123", + type: "function", + function: { name: "openclaw", arguments: '{"message":"hi"}' }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }, + ]; + + for (const chunk of chunks) { + openaiToOpenAIResponsesResponse(chunk, state); + } + + assert.equal(state.toolCalls.size, 1, "state.toolCalls should carry the completed tool call"); + const recorded = [...state.toolCalls.values()][0]; + assert.equal(recorded.id, "call-abc123"); + assert.equal(recorded.function.name, "openclaw"); + assert.equal(recorded.function.arguments, '{"message":"hi"}'); +}); + test("OpenAI -> Responses: flush on null closes text content and emits response.completed", () => { const events = collectEvents([ { diff --git a/tests/unit/ts7-executor-override-signatures.test.ts b/tests/unit/ts7-executor-override-signatures.test.ts new file mode 100644 index 0000000000..515c44f324 --- /dev/null +++ b/tests/unit/ts7-executor-override-signatures.test.ts @@ -0,0 +1,90 @@ +/** + * Guards the executor override signatures fixed for TS 7 readiness. + * + * Three executors declared a *private/protected* `buildHeaders()` helper whose signature + * has nothing to do with `BaseExecutor.buildHeaders(credentials, stream?, clientHeaders?, + * model?, health?)`: + * + * hailuo-web (token: string, yy: string) + * lmarena (_model: string, credentials: unknown, _body: unknown) + * qwen-web (token: string, cookieHeader: string, chatId?: string) + * + * They were name collisions, not overrides — each shadowed the inherited member with an + * incompatible signature (TS2416). `BaseExecutor` calls `this.buildHeaders(credentials, + * false)` from `countTokens()`, so the shadow sat on a live dispatch path; it was never + * reached only because `buildCountTokensUrl()` returns null unless `config.format` is + * `"claude"`, and none of these three is. Latent rather than live — but one `format` + * change away from passing a credentials object where a token string was expected. + * + * The helpers are renamed, so these assertions pin both halves: the inherited method is + * no longer shadowed, and the early return that kept it harmless still holds. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { BaseExecutor } from "../../open-sse/executors/base.ts"; +import { HailuoWebExecutor } from "../../open-sse/executors/hailuo-web.ts"; +import { LMArenaExecutor } from "../../open-sse/executors/lmarena.ts"; +import { QwenWebExecutor } from "../../open-sse/executors/qwen-web.ts"; + +const CASES = [ + { name: "hailuo-web", make: () => new HailuoWebExecutor(), helper: "buildStreamHeaders" }, + { name: "lmarena", make: () => new LMArenaExecutor(), helper: "buildRequestHeaders" }, + { name: "qwen-web", make: () => new QwenWebExecutor(), helper: "buildApiHeaders" }, +]; + +for (const { name, make, helper } of CASES) { + test(`${name}: buildHeaders resolves to BaseExecutor, not a local helper`, () => { + const executor = make() as unknown as Record; + + assert.equal( + executor.buildHeaders, + BaseExecutor.prototype.buildHeaders, + `${name} must not shadow BaseExecutor.buildHeaders — countTokens() dispatches through it` + ); + }); + + test(`${name}: its own header helper is still present under the renamed key`, () => { + const executor = make() as unknown as Record; + + assert.equal( + typeof executor[helper], + "function", + `${name} should keep its provider-specific header builder as ${helper}()` + ); + assert.notEqual( + executor[helper], + BaseExecutor.prototype.buildHeaders, + "the renamed helper must be the provider's own function, not the inherited one" + ); + }); + + test(`${name}: countTokens() short-circuits before reaching buildHeaders`, async () => { + const executor = make(); + + // buildCountTokensUrl() returns null unless config.format === "claude" and the URL + // carries /messages. That early return is what kept the old shadow unreachable; if it + // ever changes, the inherited buildHeaders must be the one that runs. + const result = await executor.countTokens({ + model: "whatever", + body: { messages: [] }, + credentials: {}, + signal: new AbortController().signal, + log: null, + }); + + assert.equal(result, null, `${name} does not support the Anthropic count_tokens endpoint`); + }); +} + +test("LMArenaExecutor does not narrow visibility of inherited members", () => { + // TS2415: a subclass may widen a member's visibility but never narrow it. `buildUrl` and + // `transformRequest` were `protected` here while public on BaseExecutor — masked behind + // the buildHeaders TS2416 until that cleared. Runtime has no visibility, so this asserts + // the members are reachable, which is what the type change encodes. + const executor = new LMArenaExecutor() as unknown as Record; + + for (const member of ["buildUrl", "transformRequest"]) { + assert.equal(typeof executor[member], "function", `${member} must stay callable`); + } +}); diff --git a/tests/unit/ts7-executor-result-contract.test.ts b/tests/unit/ts7-executor-result-contract.test.ts new file mode 100644 index 0000000000..0c38eccfde --- /dev/null +++ b/tests/unit/ts7-executor-result-contract.test.ts @@ -0,0 +1,89 @@ +/** + * Guards the executor `execute()` result contract. + * + * `normalizeExecutorResult()` has always accepted `Response | { response, ... }` — the + * bare arm is what the web/scraping executors return from their error and passthrough + * paths (see `chatcore-upstream-timeouts.test.ts`, which covers the normalizer itself). + * `BaseExecutor.execute` nonetheless *inferred* only the object shape from its single + * return statement, so every override returning a bare `Response` was reported as + * incompatible (TS2416) and DuckDuckGo's 14 valid `return`s as TS2739. + * + * Declaring `ExecutorExecuteResult` on the base fixed that, and required the two + * subclasses that consume `super.execute()` to narrow before reading `.response`. + * These tests pin the runtime behavior of that narrowing so a future "simplification" + * cannot quietly drop the bare-Response arm. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { BaseExecutor } from "../../open-sse/executors/base.ts"; +import { GithubExecutor } from "../../open-sse/executors/github.ts"; + +type ExecuteFn = typeof BaseExecutor.prototype.execute; + +/** Swap BaseExecutor.execute for the duration of one call, then restore it. */ +async function withBaseExecuteStub(stub: ExecuteFn, run: () => Promise): Promise { + const original = BaseExecutor.prototype.execute; + BaseExecutor.prototype.execute = stub; + try { + return await run(); + } finally { + BaseExecutor.prototype.execute = original; + } +} + +const INPUT = { + model: "gpt-4o", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: {}, + signal: new AbortController().signal, +}; + +test("GithubExecutor.execute passes a bare Response through untouched", async () => { + const bare = new Response("upstream body", { status: 503 }); + + const result = await withBaseExecuteStub( + (async () => bare) as ExecuteFn, + () => new GithubExecutor().execute(INPUT) + ); + + assert.equal( + result, + bare, + "the bare-Response arm has no capture object to materialize and must be returned as-is" + ); +}); + +test("GithubExecutor.execute still materializes the capture-object arm", async () => { + const captured = { + response: new Response("hello", { status: 200, statusText: "OK" }), + url: "https://api.githubcopilot.com/chat/completions", + headers: { "x-req": "1" }, + transformedBody: { a: 1 }, + }; + + const result = await withBaseExecuteStub( + (async () => captured) as ExecuteFn, + () => new GithubExecutor().execute(INPUT) + ); + + assert.ok(!(result instanceof Response), "the object arm must stay an object"); + const obj = result as typeof captured; + + // The body is re-wrapped into a native Response so downstream reads work after + // wreq-js clone/text semantics have consumed the original. + assert.equal(obj.response.status, 200); + assert.equal(await obj.response.text(), "hello"); + assert.equal(obj.url, captured.url, "the capture fields must survive materialization"); + assert.deepEqual(obj.transformedBody, { a: 1 }); +}); + +test("GithubExecutor.execute tolerates a nullish result without throwing", async () => { + const result = await withBaseExecuteStub( + (async () => undefined) as unknown as ExecuteFn, + () => new GithubExecutor().execute(INPUT) + ); + + assert.equal(result, undefined, "a nullish base result must short-circuit, not throw"); +}); diff --git a/tests/unit/ts7-executor-shared-shapes.test.ts b/tests/unit/ts7-executor-shared-shapes.test.ts new file mode 100644 index 0000000000..98a2fdd077 --- /dev/null +++ b/tests/unit/ts7-executor-shared-shapes.test.ts @@ -0,0 +1,145 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +const { OpencodeExecutor } = await import("../../open-sse/executors/opencode.ts"); +const { MimocodeExecutor } = await import("../../open-sse/executors/mimocode.ts"); + +/** + * Behavioral guards for the three type-only fixes in the TS 7 executor slice + * (see #8484). Each fix restored a type the code already depended on at runtime; + * these tests pin the runtime contracts so a future "simplification" of the + * annotations cannot silently change behavior. + * + * The zed-hosted `SseEnqueueTarget` fix is already covered end-to-end by + * `zed-hosted-think-close-marker.test.ts`, which drives the same TransformStream + * that failed to type-check — no duplicate added here. + */ + +describe("OpencodeExecutor — tools truncation survives the narrowing fix", () => { + const executor = new OpencodeExecutor("opencode-go"); + const CREDENTIALS = { apiKey: "k" } as Record; + + const tools = (n: number) => + Array.from({ length: n }, (_, i) => ({ + type: "function", + function: { name: `tool_${i}`, parameters: {} }, + })); + + function bodyWith(toolCount: number) { + return { + model: "oc/kimi-k2.6", + stream: true, + messages: [{ role: "user", content: "hi" }], + tools: tools(toolCount), + }; + } + + it("truncates an over-long tools array to 128 entries", () => { + const out = executor.transformRequest("oc/kimi-k2.6", bodyWith(200), true, CREDENTIALS) as { + tools: unknown[]; + }; + assert.equal(out.tools.length, 128, "upstream rejects more than 128 tools"); + assert.deepEqual( + (out.tools[127] as { function: { name: string } }).function.name, + "tool_127", + "truncation keeps the first 128 in order, not an arbitrary slice" + ); + }); + + it("leaves a within-limit tools array untouched", () => { + const out = executor.transformRequest("oc/kimi-k2.6", bodyWith(10), true, CREDENTIALS) as { + tools: unknown[]; + }; + assert.equal(out.tools.length, 10); + }); + + it("is a no-op when the body carries no tools", () => { + const body = { + model: "oc/kimi-k2.6", + stream: true, + messages: [{ role: "user", content: "hi" }], + }; + const out = executor.transformRequest("oc/kimi-k2.6", body, true, CREDENTIALS) as Record< + string, + unknown + >; + assert.equal("tools" in out, false); + assert.ok(Array.isArray(out.messages), "messages preserved"); + }); + + it("leaves an array-shaped body alone (pins the !Array.isArray guard)", () => { + // The pre-fix condition reached `.tools` on any object, arrays included, and + // relied on `Array.isArray(undefined)` short-circuiting. The explicit + // !Array.isArray() guard must keep that outcome identical. + const arrayBody = [{ role: "user", content: "hi" }] as unknown as Record; + const out = executor.transformRequest("oc/kimi-k2.6", arrayBody, true, CREDENTIALS); + assert.ok(Array.isArray(out), "array body must pass through as an array"); + assert.equal((out as unknown[]).length, 1); + }); +}); + +describe("MimocodeExecutor — AccountState.proxy is always present (#3837/#5521)", () => { + const FP = "fingerprint-1"; + + function accountsOf(exec: unknown): Array> { + return (exec as { accounts: Array> }).accounts; + } + + function sync(exec: unknown, credentials: unknown): void { + (exec as { syncAccountsFromCredentials(c: unknown): void }).syncAccountsFromCredentials( + credentials + ); + } + + it("defaults proxy to null — not undefined — when no accountProxies are configured", () => { + const exec = new MimocodeExecutor(); + accountsOf(exec).length = 0; + accountsOf(exec).push({ + fingerprint: FP, + jwt: "", + expiresAt: 0, + cooldownUntil: 0, + consecutiveFails: 0, + }); + + sync(exec, { providerSpecificData: {} }); + + const account = accountsOf(exec)[0]; + assert.ok("proxy" in account, "every account must expose a proxy key"); + assert.equal(account.proxy, null, "unconfigured proxy is null, never undefined"); + }); + + it("resolves a configured proxy onto the matching account", () => { + const exec = new MimocodeExecutor(); + accountsOf(exec).length = 0; + accountsOf(exec).push({ + fingerprint: FP, + jwt: "", + expiresAt: 0, + cooldownUntil: 0, + consecutiveFails: 0, + }); + + const proxy = { type: "http", host: "p1.example.com", port: 1080 }; + sync(exec, { providerSpecificData: { accountProxies: [{ fingerprint: FP, proxy }] } }); + + assert.deepEqual(accountsOf(exec)[0].proxy, proxy); + }); + + it("clears a previously-resolved proxy back to null when config drops it", () => { + const exec = new MimocodeExecutor(); + accountsOf(exec).length = 0; + accountsOf(exec).push({ + fingerprint: FP, + jwt: "", + expiresAt: 0, + cooldownUntil: 0, + consecutiveFails: 0, + proxy: { type: "http", host: "stale.example.com", port: 8080 }, + }); + + sync(exec, { providerSpecificData: { accountProxies: [] } }); + + assert.equal(accountsOf(exec)[0].proxy, null, "a removed proxy must not linger on the account"); + }); +}); diff --git a/tests/unit/ts7-open-sse-type-fixes.test.ts b/tests/unit/ts7-open-sse-type-fixes.test.ts new file mode 100644 index 0000000000..a7af0328da --- /dev/null +++ b/tests/unit/ts7-open-sse-type-fixes.test.ts @@ -0,0 +1,151 @@ +/** + * Behavioral guards for the TS7-readiness type fixes in `open-sse/utils` and + * `open-sse/translator` (slice 1 of the TypeScript 7 migration). + * + * Most of that change is behavior-preserving refactoring, already covered by the + * existing keepalive/heartbeat suites. Three things are NOT covered elsewhere and are + * exactly the parts a future "just make the checker happy" edit would silently break: + * + * 1. `transformer.cancel()` — the WHATWG Streams hook that clears heartbeat/progress + * intervals when an SSE client disconnects. `lib.dom.d.ts` omits it from + * `Transformer`, so it is patched in `open-sse/types.d.ts`. If someone deletes the + * handlers instead of the type patch, every abandoned stream leaks a timer. + * 2. `sanitizeToolResultId()` — now coerces a non-string id instead of throwing. + * 3. The `Read` tool-call shim's `limit` clamping — the narrowing fix rewrote the + * comparison to read a local, which must stay behavior-identical at the bounds. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { sanitizeToolResultId } from "../../open-sse/translator/request/openai-to-claude/sanitizeToolResultId.ts"; +import { applyToolCallShimToBuffer } from "../../open-sse/translator/helpers/toolCallShim.ts"; + +// --------------------------------------------------------------------------- +// 1. transformer.cancel() runtime contract +// --------------------------------------------------------------------------- + +test("TransformStream invokes transformer.cancel() when the readable side is cancelled", async () => { + let cancelled = false; + let seenReason: unknown; + + const ts = new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk); + }, + cancel(reason) { + cancelled = true; + seenReason = reason; + }, + }); + + const writer = ts.writable.getWriter(); + const reader = ts.readable.getReader(); + void writer.write("chunk"); + await reader.read(); + + const reason = new Error("client disconnect"); + await reader.cancel(reason); + + assert.equal( + cancelled, + true, + "transformer.cancel() must fire on readable cancel — the heartbeat/progress " + + "interval cleanup in sseHeartbeat.ts and progressTracker.ts depends on it" + ); + assert.equal(seenReason, reason, "cancel() should receive the cancellation reason"); +}); + +test("a transformer cancel handler can clear an interval (the leak this guards)", async (t) => { + let ticks = 0; + let stopped = false; + // Held in a local, not on the stream: `start()` runs inside the TransformStream + // constructor, before the `const` binding is initialized. + let stop: (() => void) | undefined; + + // Belt-and-braces: a stray interval keeps node:test's event loop alive forever. + t.after(() => stop?.()); + + const ts = new TransformStream({ + start() { + const id = setInterval(() => { + ticks += 1; + }, 5); + stop = () => { + clearInterval(id); + stopped = true; + }; + }, + transform(chunk, controller) { + controller.enqueue(chunk); + }, + cancel() { + stop?.(); + }, + }); + + const reader = ts.readable.getReader(); + await reader.cancel(new Error("disconnect")); + + assert.equal(stopped, true, "cancel() should have cleared the interval"); + + const before = ticks; + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(ticks, before, "interval must not keep firing after cancel()"); +}); + +// --------------------------------------------------------------------------- +// 2. sanitizeToolResultId() +// --------------------------------------------------------------------------- + +test("sanitizeToolResultId returns null for falsy ids so orphan tool_results stay skipped", () => { + assert.equal(sanitizeToolResultId(undefined), null); + assert.equal(sanitizeToolResultId(null), null); + assert.equal(sanitizeToolResultId(""), null); + assert.equal(sanitizeToolResultId(0), null); +}); + +test("sanitizeToolResultId passes a well-formed string id through unchanged", () => { + assert.equal(sanitizeToolResultId("toolu_abc-123"), "toolu_abc-123"); +}); + +test("sanitizeToolResultId replaces characters outside [A-Za-z0-9_-]", () => { + assert.equal(sanitizeToolResultId("call:with spaces//slashes"), "call_with_spaces__slashes"); +}); + +test("sanitizeToolResultId coerces a non-string id instead of throwing", () => { + // Previously this reached `id.replace()` on a number and threw a TypeError. + assert.equal(sanitizeToolResultId(12345), "12345"); +}); + +// --------------------------------------------------------------------------- +// 3. Read shim `limit` clamping +// --------------------------------------------------------------------------- + +function readShim(args: Record): Record { + return JSON.parse(applyToolCallShimToBuffer("Read", JSON.stringify(args))); +} + +test("Read shim clamps a limit above the 2000-line cap", () => { + assert.equal(readShim({ file_path: "/a.txt", limit: 5000 }).limit, 2000); +}); + +test("Read shim leaves an in-range limit untouched at both bounds", () => { + assert.equal(readShim({ file_path: "/a.txt", limit: 1 }).limit, 1); + assert.equal(readShim({ file_path: "/a.txt", limit: 2000 }).limit, 2000); + assert.equal(readShim({ file_path: "/a.txt", limit: 500 }).limit, 500); +}); + +test("Read shim drops a limit below 1", () => { + assert.equal("limit" in readShim({ file_path: "/a.txt", limit: 0 }), false); + assert.equal("limit" in readShim({ file_path: "/a.txt", limit: -10 }), false); +}); + +test("Read shim coerces numeric-string limit/offset before clamping", () => { + const out = readShim({ file_path: "/a.txt", limit: "9999", offset: "-5" }); + assert.equal(out.limit, 2000); + assert.equal(out.offset, 0); +}); + +test("Read shim floors a negative offset at 0", () => { + assert.equal(readShim({ file_path: "/a.txt", offset: -1 }).offset, 0); +}); diff --git a/tests/unit/tsconfig-ts7-readiness.test.ts b/tests/unit/tsconfig-ts7-readiness.test.ts new file mode 100644 index 0000000000..180cce9a23 --- /dev/null +++ b/tests/unit/tsconfig-ts7-readiness.test.ts @@ -0,0 +1,148 @@ +/** + * TS 7.0 readiness guard for tsconfig files. + * + * TypeScript 6.x raises `TS5101: Option 'baseUrl' is deprecated and will stop + * functioning in TypeScript 7.0` for any tsconfig that still declares + * `compilerOptions.baseUrl`. The only offender was `open-sse/tsconfig.json`, + * which paired it with `ignoreDeprecations: "5.0"` to silence the warning. + * + * Dropping `baseUrl` changes how `paths` are resolved: without it, every path + * mapping is resolved relative to the directory containing the tsconfig rather + * than relative to `baseUrl`. So asserting "no baseUrl" alone is not enough — + * the mappings have to keep pointing at real directories, or `@/*` and + * `@omniroute/open-sse/*` silently stop resolving. Both halves are asserted here. + */ +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 ts from "typescript"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +/** Directories that hold generated/vendored copies of tsconfig files. */ +const SKIP_DIRS = new Set([ + "node_modules", + ".build", + ".next", + ".next-playwright", + ".claude", + ".git", + "dist", + "dist-electron", + "coverage", + ".source", + ".tmp", + "_tasks", + "_ideia", + "_mono_repo", + "_references", +]); + +function collectTsconfigs(dir: string, found: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue; + collectTsconfigs(path.join(dir, entry.name), found); + } else if (/^tsconfig(\..+)?\.json$/.test(entry.name)) { + found.push(path.join(dir, entry.name)); + } + } + return found; +} + +/** + * tsconfig is JSONC (comments + trailing commas allowed), and glob values like + * `"**​/*.ts"` contain `/*` — so a hand-rolled comment stripper corrupts them. + * Use TypeScript's own parser. `extends` is deliberately NOT resolved: each file + * is asserted on what it literally declares. + */ +function readTsconfig(file: string): { compilerOptions?: Record } { + const { config, error } = ts.readConfigFile(file, (p) => fs.readFileSync(p, "utf8")); + assert.equal( + error, + undefined, + `${path.relative(REPO_ROOT, file)} is not parseable: ${ + error && ts.flattenDiagnosticMessageText(error.messageText, " ") + }` + ); + return config as { compilerOptions?: Record }; +} + +const TSCONFIGS = collectTsconfigs(REPO_ROOT); + +test("repo actually has tsconfig files to check", () => { + assert.ok(TSCONFIGS.length > 0, "no tsconfig files discovered — the walker is broken"); +}); + +test("no tsconfig declares the TS 7.0-removed 'baseUrl' option", () => { + const offenders = TSCONFIGS.filter( + (file) => readTsconfig(file).compilerOptions?.baseUrl !== undefined + ).map((file) => path.relative(REPO_ROOT, file)); + + assert.deepEqual( + offenders, + [], + `'baseUrl' is removed in TypeScript 7.0 (TS5101). Drop it and rewrite 'paths' ` + + `relative to the tsconfig's own directory. Offenders: ${offenders.join(", ")}` + ); +}); + +test("no tsconfig needs 'ignoreDeprecations' to silence removed options", () => { + const offenders = TSCONFIGS.filter( + (file) => readTsconfig(file).compilerOptions?.ignoreDeprecations !== undefined + ).map((file) => path.relative(REPO_ROOT, file)); + + assert.deepEqual( + offenders, + [], + `'ignoreDeprecations' only suppresses the symptom — remove the deprecated ` + + `option itself. Offenders: ${offenders.join(", ")}` + ); +}); + +test("every tsconfig 'paths' mapping resolves to a real file or directory", () => { + const broken: string[] = []; + + for (const file of TSCONFIGS) { + const compilerOptions = readTsconfig(file).compilerOptions; + const paths = compilerOptions?.paths as Record | undefined; + if (!paths) continue; + + // Without baseUrl, path mappings resolve relative to the tsconfig's directory. + const resolveRoot = path.dirname(file); + + for (const [alias, targets] of Object.entries(paths)) { + for (const target of targets) { + // Strip the trailing wildcard segment: "../src/*" -> "../src" + const concrete = target.replace(/\/?\*+$/, ""); + const absolute = path.resolve(resolveRoot, concrete); + if (!fs.existsSync(absolute)) { + broken.push(`${path.relative(REPO_ROOT, file)}: "${alias}" -> "${target}"`); + } + } + } + } + + assert.deepEqual( + broken, + [], + `path alias targets that do not exist on disk:\n ${broken.join("\n ")}` + ); +}); + +test("open-sse aliases point at the repo-root src/ and open-sse/ directories", () => { + const file = path.join(REPO_ROOT, "open-sse/tsconfig.json"); + const paths = readTsconfig(file).compilerOptions?.paths as Record; + + assert.ok(paths, "open-sse/tsconfig.json must keep its path aliases"); + + const resolveRoot = path.dirname(file); + const resolveAlias = (alias: string) => + path.resolve(resolveRoot, paths[alias][0].replace(/\/?\*+$/, "")); + + assert.equal(resolveAlias("@/*"), path.join(REPO_ROOT, "src")); + assert.equal(resolveAlias("@omniroute/open-sse"), path.join(REPO_ROOT, "open-sse")); + assert.equal(resolveAlias("@omniroute/open-sse/*"), path.join(REPO_ROOT, "open-sse")); +}); diff --git a/tests/unit/ui/highlightableProviderCard.test.tsx b/tests/unit/ui/highlightableProviderCard.test.tsx new file mode 100644 index 0000000000..cefafdce64 --- /dev/null +++ b/tests/unit/ui/highlightableProviderCard.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +/** + * HighlightableProviderCard — the wrapper owning the back-navigation + * highlight concern: per-instance highlighted-id state initialized from + * history.state, ref wiring into ProviderCard's imperative handle, and + * click-time navigation recording. + * + * Complements providerPageHighlightLogic.test.tsx (pure utils) and + * providerCardHandle.test.tsx (imperative handle) by covering the wiring + * between them. + */ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import HighlightableProviderCard from "@/app/(dashboard)/dashboard/providers/components/HighlightableProviderCard"; + +vi.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })); +vi.mock("@/shared/components/ProviderTestSlideOver", () => ({ default: () => null })); +vi.mock("@/shared/components/ProviderIcon", () => ({ default: () => null })); +// Deterministic anchor so the click path does not depend on next/link's router. +vi.mock("next/link", () => ({ + __esModule: true, + default: React.forwardRef(function MockLink( + { href, children, ...rest }: { href: string; children: React.ReactNode }, + ref: React.Ref + ) { + return ( + + {children} + + ); + }), +})); + +// jsdom does not implement scrollIntoView or animate. Plain no-ops (not +// vi.fn()) so per-test vi.spyOn never inherits call history through +// vi.restoreAllMocks() restoring a shared mock. +if (typeof Element.prototype.scrollIntoView === "undefined") { + Object.defineProperty(Element.prototype, "scrollIntoView", { + value: () => {}, + writable: true, + configurable: true, + }); +} +if (typeof Element.prototype.animate === "undefined") { + Object.defineProperty(Element.prototype, "animate", { + value: () => ({ cancel: () => {}, finished: Promise.resolve() }), + writable: true, + configurable: true, + }); +} + +function renderCards(...providerIds: string[]) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + <> + {providerIds.map((id) => ( + {}} + /> + ))} + + ); + }); + return container; +} + +describe("HighlightableProviderCard", () => { + let container: HTMLDivElement | null = null; + + afterEach(() => { + vi.restoreAllMocks(); + window.history.replaceState(null, ""); + if (container) { + document.body.removeChild(container); + container = null; + } + }); + + it("scrolls and highlights on mount when history.state.providerId matches", () => { + window.history.replaceState({ providerId: "openai" }, ""); + const scrollSpy = vi.spyOn(Element.prototype, "scrollIntoView"); + const animateSpy = vi.spyOn(Element.prototype, "animate"); + + container = renderCards("openai"); + + expect(scrollSpy).toHaveBeenCalledTimes(1); + expect(scrollSpy).toHaveBeenCalledWith({ behavior: "auto", block: "center" }); + expect(animateSpy).toHaveBeenCalled(); + }); + + it("does not scroll or highlight when history.state holds a different provider id", () => { + window.history.replaceState({ providerId: "anthropic" }, ""); + const scrollSpy = vi.spyOn(Element.prototype, "scrollIntoView"); + const animateSpy = vi.spyOn(Element.prototype, "animate"); + + container = renderCards("openai"); + + expect(scrollSpy).not.toHaveBeenCalled(); + expect(animateSpy).not.toHaveBeenCalled(); + }); + + it("does nothing when history.state is null", () => { + const scrollSpy = vi.spyOn(Element.prototype, "scrollIntoView"); + const animateSpy = vi.spyOn(Element.prototype, "animate"); + + container = renderCards("openai"); + + expect(scrollSpy).not.toHaveBeenCalled(); + expect(animateSpy).not.toHaveBeenCalled(); + }); + + it("among several cards only the one matching history.state.providerId reacts", () => { + window.history.replaceState({ providerId: "cursor" }, ""); + const scrollSpy = vi.spyOn(Element.prototype, "scrollIntoView"); + const animateSpy = vi.spyOn(Element.prototype, "animate"); + + container = renderCards("openai", "cursor", "kimi-coding"); + + expect(scrollSpy).toHaveBeenCalledTimes(1); + expect(animateSpy).toHaveBeenCalledTimes(1); + }); + + it("records the clicked provider id into history.state", () => { + const replaceSpy = vi.spyOn(window.history, "replaceState"); + container = renderCards("openai"); + const link = container.querySelector("a"); + expect(link).not.toBeNull(); + + act(() => { + link!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(replaceSpy).toHaveBeenCalledWith({ providerId: "openai" }, ""); + }); +}); diff --git a/tests/unit/ui/home-topology-last-used-node-color.test.tsx b/tests/unit/ui/home-topology-last-used-node-color.test.tsx new file mode 100644 index 0000000000..7f835c0f10 --- /dev/null +++ b/tests/unit/ui/home-topology-last-used-node-color.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +// +// The home topology painted the most recently routed provider as an idle grey node with +// no status dot, while its edge was amber — so the provider you had just used looked +// *less* connected than an untouched one. `last` was ANDed into `healthy`, and the node +// component had no `last` state to fall back on. Health now owns the border and recency +// owns the dot; this renders the real ProviderNode and reads the computed styles rather +// than pattern-matching the source. +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { FLOW_EDGE_COLORS } from "../../../src/shared/components/flow/edgeStyles"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); +vi.mock("@/shared/components/ProviderIcon", () => ({ + default: () => , +})); +// Render each node through its registered node type directly: ReactFlow's own layout +// needs real measurement, which jsdom cannot provide, and it is not what we're asserting. +vi.mock("@/shared/components/flow/FlowCanvas", () => ({ + FlowCanvas: ({ + nodes, + edges, + nodeTypes, + }: { + nodes: Array<{ id: string; type?: string; data: Record }>; + edges: Array<{ id: string; target: string; style?: { stroke?: string } }>; + nodeTypes?: Record }>>; + }) => ( +
+ {nodes.map((n) => { + const Comp = n.type ? nodeTypes?.[n.type] : undefined; + const edge = edges.find((e) => e.target === n.id); + return ( +
+ {Comp ? : null} +
+ ); + })} +
+ ), +})); +vi.mock("@xyflow/react", () => ({ + Handle: () => null, + Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" }, +})); + +const ProviderTopology = ( + await import("../../../src/app/(dashboard)/home/ProviderTopology") +).default; + +// jsdom normalises inline hex colours to `rgb(...)`, so compare in that space. +const rgb = (hex: string) => { + const n = parseInt(hex.slice(1), 16); + return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`; +}; +const GREEN = rgb(FLOW_EDGE_COLORS.active); +const AMBER = rgb(FLOW_EDGE_COLORS.last); +const RED = rgb(FLOW_EDGE_COLORS.error); + +let container: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +type Entry = { id: string; provider: string; name?: string; status?: string }; + +function render(providers: Entry[], lastProvider = "") { + act(() => { + root.render( + + ); + }); +} + +const node = (provider: string) => + container.querySelector(`[data-testid="provider-${provider}"]`) as HTMLElement; +const box = (provider: string) => node(provider).querySelector("div.border-2") as HTMLElement; +const dot = (provider: string) => + node(provider).querySelector("span.rounded-full:not(.animate-ping)") as HTMLElement | null; + +it("keeps the connected border on the last-routed provider and marks recency on the dot", () => { + render( + [ + { id: "a", provider: "devin-cli", name: "Devin CLI", status: "active" }, + { id: "b", provider: "claude", name: "Claude Code", status: "active" }, + ], + "devin-cli" + ); + + // The just-used provider: still green (connected), amber dot (most recent). + expect(box("devin-cli").style.borderColor).toBe(GREEN); + expect(dot("devin-cli")).not.toBeNull(); + expect(dot("devin-cli")!.style.backgroundColor).toBe(AMBER); + // Its edge keeps the amber last-used stroke (raw attribute, not a normalised style). + expect(node("devin-cli").dataset.edgeStroke).toBe(FLOW_EDGE_COLORS.last); + + // An untouched but connected peer is unchanged: green border, green dot. + expect(box("claude").style.borderColor).toBe(GREEN); + expect(dot("claude")!.style.backgroundColor).toBe(GREEN); +}); + +it("still greys out a provider that is genuinely idle", () => { + render([{ id: "a", provider: "kimi-coding", name: "Kimi", status: "idle" }]); + expect(box("kimi-coding").style.borderColor).toBe("var(--color-border)"); + expect(dot("kimi-coding")).toBeNull(); +}); + +it("shows an errored connection as red even when it was the last one routed", () => { + render([{ id: "a", provider: "agy", name: "Antigravity", status: "error" }], "agy"); + expect(box("agy").style.borderColor).toBe(RED); + expect(node("agy").dataset.edgeStroke).toBe(FLOW_EDGE_COLORS.error); +}); diff --git a/tests/unit/ui/providerCardHandle.test.tsx b/tests/unit/ui/providerCardHandle.test.tsx new file mode 100644 index 0000000000..ce8f4b7fd7 --- /dev/null +++ b/tests/unit/ui/providerCardHandle.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +/** + * ProviderCardHandle imperative API — highlight(), scrollIntoView(), getProviderId(). + * + * NOTE on placement: see providerCardKimiPartnerAccent.test.tsx for rationale + * on keeping tests in tests/unit/ui/ (both vitest configs discover it here). + */ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import ProviderCard, { + type ProviderCardHandle, +} from "@/app/(dashboard)/dashboard/providers/components/ProviderCard"; + +vi.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })); +vi.mock("@/shared/components/ProviderTestSlideOver", () => ({ default: () => null })); +vi.mock("@/shared/components/ProviderIcon", () => ({ default: () => null })); + +// jsdom does not implement scrollIntoView or animate +if (typeof Element.prototype.scrollIntoView === "undefined") { + Object.defineProperty(Element.prototype, "scrollIntoView", { + value: vi.fn(), + writable: true, + configurable: true, + }); +} +if (typeof Element.prototype.animate === "undefined") { + Object.defineProperty(Element.prototype, "animate", { + value: () => ({ cancel: () => {}, finished: Promise.resolve() }), + writable: true, + configurable: true, + }); +} + +describe("ProviderCardHandle imperative API", () => { + let container: HTMLDivElement | null = null; + let handle: ProviderCardHandle | null = null; + + afterEach(() => { + handle = null; + if (container) { + document.body.removeChild(container); + container = null; + } + }); + + const PROVIDER_ID = "openai"; + const PROVIDER_NAME = "OpenAI"; + + function renderAndCapture() { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + { + handle = h; + }} + providerId={PROVIDER_ID} + provider={{ id: PROVIDER_ID, name: PROVIDER_NAME }} + stats={{ total: 1, connected: 1, error: 0, warning: 0 }} + authType="apikey" + onToggle={() => {}} + /> + ); + }); + } + + it("getProviderId returns the providerId passed as a prop", () => { + renderAndCapture(); + expect(handle).not.toBeNull(); + expect(handle!.getProviderId()).toBe(PROVIDER_ID); + }); + + it("scrollIntoView calls Element.scrollIntoView on the wrapper div", () => { + renderAndCapture(); + const spy = vi.spyOn(Element.prototype, "scrollIntoView"); + act(() => { + handle!.scrollIntoView(); + }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ behavior: "auto", block: "center" }); + spy.mockRestore(); + }); + + it("highlight calls animate on the Card surface", () => { + renderAndCapture(); + const spy = vi.spyOn(Element.prototype, "animate"); + act(() => { + handle!.highlight(); + }); + expect(spy).toHaveBeenCalledTimes(1); + const keyframes = spy.mock.calls[0][0] as Keyframe[]; + expect(keyframes).toHaveLength(3); + expect((keyframes[0] as Record).backgroundColor).toBe("rgba(59,130,246,0.22)"); + expect((keyframes[2] as Record).backgroundColor).toBe("transparent"); + spy.mockRestore(); + }); + + it("highlight focuses the link element", () => { + renderAndCapture(); + const focusSpy = vi.fn(); + const link = container!.querySelector("a"); + if (link) link.focus = focusSpy; + act(() => { + handle!.highlight(); + }); + expect(focusSpy).toHaveBeenCalledTimes(1); + }); + + it("getProviderId returns the correct id for a different provider", () => { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let h: ProviderCardHandle | null = null; + act(() => { + root.render( + { + h = n; + }} + providerId="kimi-coding" + provider={{ id: "kimi-coding", name: "Kimi Code CLI" }} + stats={{ total: 0, connected: 0, error: 0, warning: 0 }} + authType="oauth" + onToggle={() => {}} + /> + ); + }); + expect(h!.getProviderId()).toBe("kimi-coding"); + }); +}); diff --git a/tests/unit/ui/providerPageHighlightLogic.test.tsx b/tests/unit/ui/providerPageHighlightLogic.test.tsx new file mode 100644 index 0000000000..7052474a84 --- /dev/null +++ b/tests/unit/ui/providerPageHighlightLogic.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +/** + * Tests for the page-level highlighted-card matching/clearing logic + * extracted into providerPageHighlightUtils.ts. + * + * These import the real production functions — not copied code. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ProviderCardHandle } from "@/app/(dashboard)/dashboard/providers/components/ProviderCard"; +import { + recordProviderNavigation, + resolveHighlightedCard, +} from "@/app/(dashboard)/dashboard/providers/providerPageHighlightUtils"; + +function createMockHandle( + id: string, + callbacks?: { onScroll?(): void; onHighlight?(): void } +): ProviderCardHandle { + return { + getProviderId() { + return id; + }, + scrollIntoView() { + callbacks?.onScroll?.(); + }, + highlight() { + callbacks?.onHighlight?.(); + }, + }; +} + +describe("resolveHighlightedCard", () => { + it("calls scrollIntoView + highlight when handle matches highlighted id", () => { + const onScroll = vi.fn(); + const onHighlight = vi.fn(); + const onAfterHighlight = vi.fn(); + const handle = createMockHandle("openai", { onScroll, onHighlight }); + + resolveHighlightedCard(handle, "openai", onAfterHighlight); + + expect(onScroll).toHaveBeenCalledTimes(1); + expect(onHighlight).toHaveBeenCalledTimes(1); + expect(onAfterHighlight).toHaveBeenCalledTimes(1); + }); + + it("does NOT call scrollIntoView or highlight when ids do not match", () => { + const onScroll = vi.fn(); + const onHighlight = vi.fn(); + const onAfterHighlight = vi.fn(); + const handle = createMockHandle("openai", { onScroll, onHighlight }); + + resolveHighlightedCard(handle, "anthropic", onAfterHighlight); + + expect(onScroll).not.toHaveBeenCalled(); + expect(onHighlight).not.toHaveBeenCalled(); + expect(onAfterHighlight).toHaveBeenCalledTimes(1); + }); + + it("calls onAfterHighlight even when handle is null", () => { + const onAfterHighlight = vi.fn(); + resolveHighlightedCard(null, "openai", onAfterHighlight); + expect(onAfterHighlight).toHaveBeenCalledTimes(1); + }); + + it("calls onAfterHighlight even when ids do not match", () => { + const onAfterHighlight = vi.fn(); + const handle = createMockHandle("kimi-coding"); + resolveHighlightedCard(handle, "openai", onAfterHighlight); + expect(onAfterHighlight).toHaveBeenCalledTimes(1); + }); + + it("only the matching handle triggers scroll + highlight among multiple", () => { + const calls: string[] = []; + const onAfterHighlight = vi.fn(); + const handles = [ + createMockHandle("openai", { + onScroll: () => calls.push("openai-scroll"), + onHighlight: () => calls.push("openai-highlight"), + }), + createMockHandle("anthropic", { + onScroll: () => calls.push("anthropic-scroll"), + onHighlight: () => calls.push("anthropic-highlight"), + }), + createMockHandle("cursor", { + onScroll: () => calls.push("cursor-scroll"), + onHighlight: () => calls.push("cursor-highlight"), + }), + ]; + + resolveHighlightedCard(handles[0], "cursor", onAfterHighlight); + resolveHighlightedCard(handles[1], "cursor", onAfterHighlight); + resolveHighlightedCard(handles[2], "cursor", onAfterHighlight); + + expect(calls).toEqual(["cursor-scroll", "cursor-highlight"]); + expect(onAfterHighlight).toHaveBeenCalledTimes(3); + }); +}); + +describe("recordProviderNavigation", () => { + const originalReplaceState = window.history.replaceState; + + afterEach(() => { + window.history.replaceState = originalReplaceState; + }); + + it("calls history.replaceState with the provider id", () => { + const replaceSpy = vi.fn(); + window.history.replaceState = replaceSpy; + + recordProviderNavigation("openai"); + + expect(replaceSpy).toHaveBeenCalledTimes(1); + expect(replaceSpy).toHaveBeenCalledWith({ providerId: "openai" }, ""); + }); + + it("sets different provider ids on successive calls", () => { + const replaceSpy = vi.fn(); + window.history.replaceState = replaceSpy; + + recordProviderNavigation("openai"); + recordProviderNavigation("anthropic"); + recordProviderNavigation("kimi-coding"); + + expect(replaceSpy).toHaveBeenCalledTimes(3); + expect(replaceSpy.mock.calls[0][0]).toEqual({ providerId: "openai" }); + expect(replaceSpy.mock.calls[1][0]).toEqual({ providerId: "anthropic" }); + expect(replaceSpy.mock.calls[2][0]).toEqual({ providerId: "kimi-coding" }); + }); +}); diff --git a/tests/unit/usage-bailian-split.test.ts b/tests/unit/usage-bailian-split.test.ts new file mode 100644 index 0000000000..f61cc4c23d --- /dev/null +++ b/tests/unit/usage-bailian-split.test.ts @@ -0,0 +1,59 @@ +// Characterization of the services/usage.ts bailian split (god-file decomposition): the Bailian +// (Alibaba Token Plan) triple-window fetcher (getBailianCodingPlanUsage) moved into +// services/usage/bailian.ts so usage.ts stays a thin dispatcher. Behavior-preserving move — this +// locks the export surface and the worst-case window selection; the full triple-window matrix +// is covered via getUsageForProvider in bailian-usage.test.ts. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const B = await import("../../open-sse/services/usage/bailian.ts"); + +test("module exposes getBailianCodingPlanUsage", () => { + assert.equal(typeof B.getBailianCodingPlanUsage, "function"); +}); + +test("getBailianCodingPlanUsage picks the most restrictive window as the surfaced quota", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + code: "Success", + data: { + codingPlanInstanceInfos: [ + { + planName: "Qwen3 Coder Next", + codingPlanQuotaInfo: { + per5HourUsedQuota: 60, + per5HourTotalQuota: 100, + per5HourQuotaNextRefreshTime: 1718304000, + perWeekUsedQuota: 80, + perWeekTotalQuota: 100, + perWeekQuotaNextRefreshTime: 1718563200, + perBillMonthUsedQuota: 40, + perBillMonthTotalQuota: 100, + perBillMonthQuotaNextRefreshTime: 1719772800, + }, + }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + try { + const r = (await B.getBailianCodingPlanUsage("conn", "sk-test", { consoleApiKey: "ck" })) as { + plan?: string; + used?: number; + total?: number; + remaining?: number; + remainingPercentage?: number; + unlimited?: boolean; + }; + assert.equal(r.plan, "Alibaba Token Plan"); + assert.equal(r.unlimited, false); + // weekly 80% is the most restrictive → used/total = 0.8 + assert.equal(r.used! / r.total!, 0.8); + assert.equal(r.remainingPercentage, 20); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/usage-crof-split.test.ts b/tests/unit/usage-crof-split.test.ts new file mode 100644 index 0000000000..ac56867b65 --- /dev/null +++ b/tests/unit/usage-crof-split.test.ts @@ -0,0 +1,69 @@ +// Characterization of the services/usage.ts crof split (god-file decomposition): the CrofAI +// /usage_api/ fetcher (getCrofUsage) + its overridable URL moved into services/usage/crof.ts so +// usage.ts stays a thin dispatcher. Behavior-preserving move — these locks pin the export surface +// and the no-key fail-open message already covered via getUsageForProvider in crof-usage.test.ts. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const C = await import("../../open-sse/services/usage/crof.ts"); + +test("module exposes getCrofUsage", () => { + assert.equal(typeof C.getCrofUsage, "function"); +}); + +test("getCrofUsage returns a friendly message when the api key is missing", async () => { + const r = (await C.getCrofUsage("")) as { message?: string }; + assert.match(r.message ?? "", /CrofAI API key not available/); +}); + +test("getCrofUsage surfaces Requests Today + Credits for a subscription account", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ usable_requests: 450, credits: 12.3456 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + try { + const r = (await C.getCrofUsage("test-key")) as { + quotas?: Record< + string, + { remaining: number; total: number; used: number; displayName?: string; unlimited: boolean } + >; + }; + assert.ok(r.quotas?.["Requests Today"]); + assert.equal(r.quotas!["Requests Today"].remaining, 450); + assert.equal(r.quotas!["Requests Today"].total, 1000); + assert.equal(r.quotas!["Requests Today"].used, 550); + assert.ok(r.quotas?.["Credits"]?.unlimited); + assert.match(r.quotas!["Credits"].displayName!, /\$12\.3456/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getCrofUsage omits Requests Today for pay-as-you-go (usable_requests=null)", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ usable_requests: null, credits: 5.5 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + try { + const r = (await C.getCrofUsage("test-key")) as { quotas?: Record }; + assert.equal(r.quotas?.["Requests Today"], undefined); + assert.ok(r.quotas?.["Credits"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getCrofUsage returns a rejected-key message on 401/403", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response("nope", { status: 401 }); + try { + const r = (await C.getCrofUsage("bad-key")) as { message?: string }; + assert.match(r.message ?? "", /rejected/); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/usage-deepseek-split.test.ts b/tests/unit/usage-deepseek-split.test.ts new file mode 100644 index 0000000000..308c332f7f --- /dev/null +++ b/tests/unit/usage-deepseek-split.test.ts @@ -0,0 +1,82 @@ +// Characterization of the services/usage.ts deepseek split (god-file decomposition): the DeepSeek +// balance fetcher (getDeepseekUsage) moved into services/usage/deepseek.ts so usage.ts stays a +// thin dispatcher. Behavior-preserving move — this locks the export surface and the +// credits-style shaping; the full balance matrix is covered via getUsageForProvider in +// usage-service-deepseek.test.ts. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const D = await import("../../open-sse/services/usage/deepseek.ts"); + +test("module exposes getDeepseekUsage", () => { + assert.equal(typeof D.getDeepseekUsage, "function"); +}); + +test("getDeepseekUsage surfaces USD + CNY balances as unlimited credits entries", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + is_available: true, + balance_infos: [ + { + currency: "CNY", + total_balance: "1000.00", + granted_balance: "0.00", + topped_up_balance: "1000.00", + }, + { + currency: "USD", + total_balance: "50.00", + granted_balance: "5.00", + topped_up_balance: "45.00", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + try { + const r = (await D.getDeepseekUsage("conn-multi", "k")) as { + plan?: string; + quotas?: Record; + }; + assert.equal(r.plan, "DeepSeek"); + assert.equal(r.quotas?.credits_usd.remaining, 50); + assert.equal(r.quotas?.credits_usd.currency, "USD"); + assert.equal(r.quotas?.credits_cny.remaining, 1000); + assert.equal(r.quotas?.credits_usd.unlimited, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getDeepseekUsage labels the plan '(Insufficient Balance)' when is_available is false", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + is_available: false, + balance_infos: [ + { + currency: "USD", + total_balance: "0.00", + granted_balance: "0.00", + topped_up_balance: "0.00", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + try { + const r = (await D.getDeepseekUsage("conn-empty", "k")) as { + plan?: string; + isAvailable?: boolean; + limitReached?: boolean; + }; + assert.equal(r.plan, "DeepSeek (Insufficient Balance)"); + assert.equal(r.isAvailable, false); + assert.equal(r.limitReached, true); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/usage-github-split.test.ts b/tests/unit/usage-github-split.test.ts new file mode 100644 index 0000000000..4298dea28c --- /dev/null +++ b/tests/unit/usage-github-split.test.ts @@ -0,0 +1,71 @@ +// Characterization of the services/usage.ts github split (god-file decomposition): the GitHub +// Copilot fetcher (getGitHubUsage) + the paid/limited quota snapshot formatter +// (formatGitHubQuotaSnapshot) + the plan-name inference (inferGitHubPlanName) + the display gate +// (shouldDisplayGitHubQuota) moved into services/usage/github.ts so usage.ts stays a thin +// dispatcher. Behavior-preserving move — these locks pin the export surface and the pure-helper +// edges already covered via __testing in usage-utils / usage-service-hardening. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const G = await import("../../open-sse/services/usage/github.ts"); + +test("module exposes the four github helpers", () => { + for (const name of [ + "getGitHubUsage", + "formatGitHubQuotaSnapshot", + "inferGitHubPlanName", + "shouldDisplayGitHubQuota", + ]) { + assert.equal(typeof (G as Record)[name], "function", `missing ${name}`); + } +}); + +test("shouldDisplayGitHubQuota hides unlimited-with-zero-total and empty quotas", () => { + assert.equal(G.shouldDisplayGitHubQuota(null), false); + assert.equal( + G.shouldDisplayGitHubQuota({ used: 0, total: 0, resetAt: null, unlimited: true }), + false + ); + assert.equal( + G.shouldDisplayGitHubQuota({ used: 0, total: 0, resetAt: null, unlimited: false }), + false + ); + assert.equal( + G.shouldDisplayGitHubQuota({ used: 0, total: 100, resetAt: null, unlimited: false }), + true + ); + assert.equal( + G.shouldDisplayGitHubQuota({ + used: 0, + total: 0, + remainingPercentage: 50, + resetAt: null, + unlimited: false, + }), + true + ); +}); + +test("formatGitHubQuotaSnapshot returns null for empty objects and synthesizes total from percent", () => { + assert.equal(G.formatGitHubQuotaSnapshot({}), null); + const fromPercent = G.formatGitHubQuotaSnapshot({ percent_remaining: 30 })!; + assert.equal(fromPercent.total, 100); + assert.equal(fromPercent.used, 70); + assert.equal(fromPercent.remaining, 30); + assert.equal(fromPercent.remainingPercentage, 30); +}); + +test("inferGitHubPlanName maps sku/plan strings and entitlement tiers", () => { + assert.equal( + G.inferGitHubPlanName({ access_type_sku: "copilot_business_seat" }, null), + "Copilot Business" + ); + assert.equal( + G.inferGitHubPlanName( + { copilot_plan: "individual" }, + { used: 0, total: 300, resetAt: null, unlimited: false } + ), + "Copilot Pro" + ); + assert.equal(G.inferGitHubPlanName({}, null), "GitHub Copilot"); +}); diff --git a/tests/unit/usage-nanogpt-split.test.ts b/tests/unit/usage-nanogpt-split.test.ts new file mode 100644 index 0000000000..e98568db9b --- /dev/null +++ b/tests/unit/usage-nanogpt-split.test.ts @@ -0,0 +1,73 @@ +// Characterization of the services/usage.ts nanogpt split (god-file decomposition): the NanoGPT +// subscription usage fetcher (getNanoGptUsage) + its API config moved into +// services/usage/nanogpt.ts so usage.ts stays a thin dispatcher. Behavior-preserving move — +// these locks pin the export surface and the no-key / 401 fail-open messages. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const N = await import("../../open-sse/services/usage/nanogpt.ts"); + +test("module exposes getNanoGptUsage", () => { + assert.equal(typeof N.getNanoGptUsage, "function"); +}); + +test("getNanoGptUsage returns a friendly message when the api key is missing", async () => { + const r = (await N.getNanoGptUsage("")) as { message?: string }; + assert.match(r.message ?? "", /NanoGPT API key not available/); +}); + +test("getNanoGptUsage returns FREE plan with no quotas for inactive subscriptions", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ active: false }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + try { + const r = (await N.getNanoGptUsage("k")) as { plan?: string; quotas?: Record }; + assert.equal(r.plan, "FREE"); + assert.deepEqual(r.quotas ?? {}, {}); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getNanoGptUsage surfaces Daily Tokens + Daily Images for PRO accounts", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + active: true, + dailyInputTokens: { used: 10, remaining: 90, percentUsed: 0.1, resetAt: 1_700_000_000 }, + dailyImages: { used: 2, remaining: 8, percentUsed: 0.2, resetAt: 1_700_000_000 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + try { + const r = (await N.getNanoGptUsage("k")) as { + plan?: string; + quotas?: Record< + string, + { total: number; used: number; remaining: number; remainingPercentage: number } + >; + }; + assert.equal(r.plan, "PRO"); + assert.ok(r.quotas?.["Daily Tokens"]); + assert.equal(r.quotas!["Daily Tokens"].total, 100); + assert.equal(r.quotas!["Daily Tokens"].remaining, 90); + assert.equal(r.quotas!["Daily Images"].total, 10); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getNanoGptUsage returns Invalid API key message on 401", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response("nope", { status: 401 }); + try { + const r = (await N.getNanoGptUsage("bad")) as { message?: string }; + assert.match(r.message ?? "", /Invalid NanoGPT API key/); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/usage-opencode-split.test.ts b/tests/unit/usage-opencode-split.test.ts new file mode 100644 index 0000000000..912b2a8e8c --- /dev/null +++ b/tests/unit/usage-opencode-split.test.ts @@ -0,0 +1,18 @@ +// Characterization of the services/usage.ts opencode split (god-file decomposition): the +// OpenCode / OpenCode Zen triple-window fetcher (getOpencodeUsage) moved into +// services/usage/opencode.ts so usage.ts stays a thin dispatcher. Behavior-preserving move — +// this locks the export surface and the no-key fail-open message; the window-shaping edges are +// exercised via fetchOpencodeQuota stubs in opencode-quota-fetcher tests. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const O = await import("../../open-sse/services/usage/opencode.ts"); + +test("module exposes getOpencodeUsage", () => { + assert.equal(typeof O.getOpencodeUsage, "function"); +}); + +test("getOpencodeUsage returns a no-api-key message when the key is missing", async () => { + const r = (await O.getOpencodeUsage("conn", "")) as { message?: string }; + assert.match(r.message ?? "", /OpenCode API key not available/); +}); diff --git a/tests/unit/usage-qoder-split.test.ts b/tests/unit/usage-qoder-split.test.ts new file mode 100644 index 0000000000..d9487bff68 --- /dev/null +++ b/tests/unit/usage-qoder-split.test.ts @@ -0,0 +1,61 @@ +// Characterization of the services/usage.ts qoder split (god-file decomposition): the Qoder +// /api/v3/user/status fetcher (getQoderUsage) + the pure status→quotas mapper +// (parseQoderUserStatusUsage) + the plan-label prettifier moved into services/usage/qoder.ts so +// usage.ts stays a thin dispatcher. Behavior-preserving move — these locks pin the export surface +// and the pure parser edges already covered via __testing in qoder-usage-quota.test.ts. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const Q = await import("../../open-sse/services/usage/qoder.ts"); + +test("module exposes getQoderUsage + parseQoderUserStatusUsage", () => { + assert.equal(typeof Q.getQoderUsage, "function"); + assert.equal(typeof Q.parseQoderUserStatusUsage, "function"); +}); + +test("getQoderUsage returns a Personal Access Token prompt when no token is present", async () => { + const r = (await Q.getQoderUsage(undefined, {})) as { message?: string }; + assert.match(r.message ?? "", /Personal Access Token/i); +}); + +test("parseQoderUserStatusUsage maps a Teams/pooled seat to an unlimited plan entry", () => { + const { plan, quotas } = Q.parseQoderUserStatusUsage({ + userType: "teams", + userTag: "Teams", + plan: "PLAN_TIER_TEAM", + quota: 0, + isQuotaExceeded: false, + nextResetAt: 1784736000000, + }); + assert.equal(plan, "Teams"); + assert.deepEqual(Object.keys(quotas), ["Plan"]); + assert.equal(quotas.Plan.unlimited, true); + assert.equal(quotas.Plan.remainingPercentage, 100); +}); + +test("parseQoderUserStatusUsage maps an individual plan with remaining quota to a Requests entry", () => { + const { plan, quotas } = Q.parseQoderUserStatusUsage({ + userType: "individual", + plan: "PLAN_TIER_PRO", + quota: 42, + isQuotaExceeded: false, + nextResetAt: 1784736000000, + }); + assert.equal(plan, "Pro"); + assert.deepEqual(Object.keys(quotas), ["Requests"]); + assert.equal(quotas.Requests.remaining, 42); + assert.equal(quotas.Requests.unlimited, false); +}); + +test("parseQoderUserStatusUsage flags an exceeded quota", () => { + const { quotas } = Q.parseQoderUserStatusUsage({ + userType: "individual", + plan: "PLAN_TIER_FREE", + quota: 100, + isQuotaExceeded: true, + nextResetAt: 1784736000000, + }); + assert.deepEqual(Object.keys(quotas), ["Quota"]); + assert.equal(quotas.Quota.remaining, 0); + assert.match(quotas.Quota.displayName!, /exceeded/i); +}); diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index c2185d9e7a..1110065263 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -743,9 +743,11 @@ test("usage service covers Codex, Kiro and Kimi usage parsing and error branches const kiroNoArn: any = await usageService.getUsageForProvider({ provider: "kiro", accessToken: "kiro-token", - providerSpecificData: {}, + providerSpecificData: { authMethod: "builder-id", region: "us-east-1" }, }); - assert.match(kiroNoArn.message, /Profile ARN not available/i); + assert.equal(kiroNoArn.plan, "Kiro Pro"); + assert.equal(kiroNoArn.quotas.agentic_request.used, 12); + assert.equal(kiroNoArn.quotas.agentic_request_freetrial.remaining, 3); const kiro: any = await usageService.getUsageForProvider({ provider: "kiro", diff --git a/tests/unit/usage-vertex-split.test.ts b/tests/unit/usage-vertex-split.test.ts new file mode 100644 index 0000000000..63f6e665f7 --- /dev/null +++ b/tests/unit/usage-vertex-split.test.ts @@ -0,0 +1,78 @@ +// Characterization of the services/usage.ts vertex split (god-file decomposition): the Vertex AI +// self-tracked spend fetcher (getVertexUsage) moved into services/usage/vertex.ts so usage.ts +// stays a thin dispatcher. Behavior-preserving move — this locks the export surface and the +// missing-connection-id fail-open; the spend aggregation + message shape is covered via +// __testing.getVertexUsage in vertex-spend-usage.test.ts. +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +// DATA_DIR must be set before any module that opens the DB is imported. +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vertex-split-")); +process.env.DATA_DIR = TMP; + +const core = await import("../../src/lib/db/core.ts"); +const V = await import("../../open-sse/services/usage/vertex.ts"); + +function insertUsage( + connectionId: string, + provider: string, + model: string, + tokensIn: number, + tokensOut: number, + success = 1 +) { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(provider, model, connectionId, tokensIn, tokensOut, success, new Date().toISOString()); +} + +describe("vertex leaf self-tracked spend", () => { + before(() => { + core.getDbInstance(); // trigger migrations + insertUsage("conn-leaf", "vertex", "gemini-2.5-flash", 1_000_000, 500_000, 1); + }); + + after(() => { + core.resetDbInstance(); + try { + fs.rmSync(TMP, { recursive: true, force: true }); + } catch { + // best-effort temp cleanup + } + }); + + it("module exposes getVertexUsage", () => { + assert.equal(typeof V.getVertexUsage, "function"); + }); + + it("returns a message when connection id is missing", async () => { + const r = (await V.getVertexUsage("", "vertex")) as { message?: string; quotas?: unknown }; + assert.ok(r.message && !r.quotas, "no spend quota without a connection id"); + }); + + it("returns a spend quota + $ message for a used connection", async () => { + const r = (await V.getVertexUsage("conn-leaf", "vertex")) as { + plan?: string; + message?: string; + quotas?: Record; + }; + assert.ok(r.quotas?.spend, "spend quota present"); + assert.equal(r.quotas!.spend.quotaSource, "localUsageHistory"); + assert.ok(r.message && r.message.includes("$")); + assert.ok(r.message!.includes("1 request")); + }); + + it("reports no-usage cleanly when nothing was routed", async () => { + const r = (await V.getVertexUsage("conn-empty", "vertex")) as { + message?: string; + quotas?: Record; + }; + assert.ok(r.message && /no usage/i.test(r.message)); + assert.equal(r.quotas?.spend.used, 0); + }); +}); diff --git a/tests/unit/usage-xai-split.test.ts b/tests/unit/usage-xai-split.test.ts new file mode 100644 index 0000000000..00bf99db03 --- /dev/null +++ b/tests/unit/usage-xai-split.test.ts @@ -0,0 +1,72 @@ +// Characterization of the services/usage.ts xai split (god-file decomposition): the xAI (Grok) +// self-tracked cumulative usage fetcher (getXaiUsage) moved into services/usage/xai.ts so +// usage.ts stays a thin dispatcher. Behavior-preserving move — this locks the export surface and +// the missing-connection-id fail-open; the cumulative unlimited shaping is covered via +// __testing.getXaiUsage in xai-usage.test.ts. +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +// DATA_DIR must be set before any module that opens the DB is imported. +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "omni-xai-split-")); +process.env.DATA_DIR = TMP; + +const core = await import("../../src/lib/db/core.ts"); +const X = await import("../../open-sse/services/usage/xai.ts"); + +function insertUsage( + connectionId: string, + provider: string, + tokensIn: number, + tokensOut: number, + timestamp: string +) { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO usage_history (provider, connection_id, tokens_input, tokens_output, timestamp) + VALUES (?, ?, ?, ?, ?)` + ).run(provider, connectionId, tokensIn, tokensOut, timestamp); +} + +describe("xai leaf self-tracked usage", () => { + before(() => { + core.getDbInstance(); // trigger migrations + insertUsage("conn-leaf", "xai", 2_000_000, 300_000, new Date().toISOString()); + }); + + after(() => { + core.resetDbInstance(); + try { + fs.rmSync(TMP, { recursive: true, force: true }); + } catch { + // best-effort temp cleanup + } + }); + + it("module exposes getXaiUsage", () => { + assert.equal(typeof X.getXaiUsage, "function"); + }); + + it("returns a message when connection id is missing", async () => { + const r = (await X.getXaiUsage("")) as { message?: string; quotas?: unknown }; + assert.ok(r.message && !r.quotas, "no quota without a connection id"); + }); + + it("returns a cumulative unlimited quota scoped to the connection", async () => { + const r = (await X.getXaiUsage("conn-leaf")) as { + plan?: string; + quotas?: Record< + string, + { used: number; total: number; remaining: number; unlimited: boolean } + >; + message?: string; + }; + assert.ok(r.quotas, `expected quotas, got message: ${r.message}`); + const m = r.quotas!.monthly; + assert.equal(m.used, 2_300_000); + assert.equal(m.unlimited, true, "xAI has no fixed monthly cap"); + assert.equal(m.remaining, 100, "unlimited rows report remaining: 100"); + }); +}); diff --git a/tests/unit/usage-xiaomi-mimo-split.test.ts b/tests/unit/usage-xiaomi-mimo-split.test.ts new file mode 100644 index 0000000000..9a2f9fe91f --- /dev/null +++ b/tests/unit/usage-xiaomi-mimo-split.test.ts @@ -0,0 +1,79 @@ +// Characterization of the services/usage.ts xiaomi-mimo split (god-file decomposition): the +// Xiaomi MiMo self-tracked monthly quota fetcher (getXiaomiMimoUsage) moved into +// services/usage/xiaomi-mimo.ts so usage.ts stays a thin dispatcher. Behavior-preserving move — +// this locks the export surface and the missing-connection-id fail-open; the monthly aggregation +// + 4.1B limit comparison is covered via __testing.getXiaomiMimoUsage in +// xiaomi-mimo-selftrack-usage.test.ts. +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +// DATA_DIR must be set before any module that opens the DB is imported. +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "omni-xiaomi-split-")); +process.env.DATA_DIR = TMP; + +const core = await import("../../src/lib/db/core.ts"); +const X = await import("../../open-sse/services/usage/xiaomi-mimo.ts"); + +function insertUsage( + connectionId: string, + provider: string, + tokensIn: number, + tokensOut: number, + timestamp: string +) { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO usage_history (provider, connection_id, tokens_input, tokens_output, timestamp) + VALUES (?, ?, ?, ?, ?)` + ).run(provider, connectionId, tokensIn, tokensOut, timestamp); +} + +describe("xiaomi-mimo leaf self-tracked quota", () => { + before(() => { + core.getDbInstance(); // trigger migrations + insertUsage("conn-leaf", "xiaomi-mimo", 1_000_000, 500_000, new Date().toISOString()); + }); + + after(() => { + core.resetDbInstance(); + try { + fs.rmSync(TMP, { recursive: true, force: true }); + } catch { + // best-effort temp cleanup + } + }); + + it("module exposes getXiaomiMimoUsage", () => { + assert.equal(typeof X.getXiaomiMimoUsage, "function"); + }); + + it("returns a message when connection id is missing", async () => { + const r = (await X.getXiaomiMimoUsage("")) as { message?: string; quotas?: unknown }; + assert.ok(r.message && !r.quotas, "no quota without a connection id"); + }); + + it("returns a monthly quota against the 4.1B limit", async () => { + const r = (await X.getXiaomiMimoUsage("conn-leaf")) as { + plan?: string; + quotas?: Record< + string, + { + used: number; + total: number; + remaining?: number; + remainingPercentage?: number; + resetAt: string | null; + } + >; + message?: string; + }; + assert.ok(r.quotas, `expected quotas, got message: ${r.message}`); + const m = r.quotas!.monthly; + assert.equal(m.total, 4_100_000_000); + assert.equal(m.used, 1_500_000); + assert.ok(m.resetAt && m.resetAt.endsWith("T00:00:00.000Z"), "reset = first of next month UTC"); + }); +}); diff --git a/tests/unit/validation-kiro-split.test.ts b/tests/unit/validation-kiro-split.test.ts new file mode 100644 index 0000000000..e0777964cb --- /dev/null +++ b/tests/unit/validation-kiro-split.test.ts @@ -0,0 +1,100 @@ +// Characterization of the validation.ts kiro runtime-probe split (god-file decomposition): +// validateKiroApiKeyRuntimeProbe moved into validation/kiro.ts as a top-level function (it was +// previously a module-private helper inside validation.ts). Behavior-preserving move — the lock +// here is module surface; the runtime behavior stays covered by the provider-validation-specialty +// kiro suites. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const M = await import("../../src/lib/providers/validation/kiro.ts"); +const HOST = await import("../../src/lib/providers/validation.ts"); + +test("kiro leaf exposes validateKiroApiKeyRuntimeProbe", () => { + assert.equal(typeof M.validateKiroApiKeyRuntimeProbe, "function"); +}); + +test("host dispatcher surface stays intact after the move", () => { + assert.equal(typeof (HOST as Record).validateProviderApiKey, "function"); +}); + +test("validateKiroApiKeyRuntimeProbe: 200 returns valid with method tag", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(new ReadableStream(), { status: 200 })) as typeof fetch; + try { + const result = await M.validateKiroApiKeyRuntimeProbe({ + apiKey: "ksk-valid", + region: "us-east-1", + }); + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(result.method, "kiro_generate_assistant_response"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("validateKiroApiKeyRuntimeProbe: 401/403 → invalid Kiro key/region", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ message: "denied" }), { status: 403 })) as typeof fetch; + try { + const result = await M.validateKiroApiKeyRuntimeProbe({ + apiKey: "ksk-bad", + region: "eu-west-1", + }); + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid Kiro API key or AWS region"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("validateKiroApiKeyRuntimeProbe: 400/422/429 treated as valid (auth passed)", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response("{}", { status: 429 })) as typeof fetch; + try { + const result = await M.validateKiroApiKeyRuntimeProbe({ + apiKey: "ksk-rate-limited", + region: "us-east-1", + }); + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.match(result.method || "", /kiro_generate_assistant_response_429/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("validateKiroApiKeyRuntimeProbe: us-east-1 targets the codewhisperer endpoint", async () => { + const originalFetch = globalThis.fetch; + let calledUrl = ""; + globalThis.fetch = (async (url: URL | string) => { + calledUrl = String(url); + return new Response(new ReadableStream(), { status: 200 }); + }) as typeof fetch; + try { + await M.validateKiroApiKeyRuntimeProbe({ apiKey: "k", region: "us-east-1" }); + assert.equal( + calledUrl, + "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("validateKiroApiKeyRuntimeProbe: non-us-east-1 targets the regional q endpoint", async () => { + const originalFetch = globalThis.fetch; + let calledUrl = ""; + globalThis.fetch = (async (url: URL | string) => { + calledUrl = String(url); + return new Response(new ReadableStream(), { status: 200 }); + }) as typeof fetch; + try { + await M.validateKiroApiKeyRuntimeProbe({ apiKey: "k", region: "eu-west-1" }); + assert.equal(calledUrl, "https://q.eu-west-1.amazonaws.com/generateAssistantResponse"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/validation-specialty-inline-split.test.ts b/tests/unit/validation-specialty-inline-split.test.ts new file mode 100644 index 0000000000..39c734c961 --- /dev/null +++ b/tests/unit/validation-specialty-inline-split.test.ts @@ -0,0 +1,69 @@ +// Characterization of the validation.ts specialty-inline split (god-file decomposition): the +// inline SPECIALTY_VALIDATORS closures (v0-vercel, auggie, qoder, kiro wrapper, gitlab, vertex, +// vertex-partner, longcat, nvidia, zai, xiaomi-mimo, gitlawb factory) moved into +// validation/specialtyInline.ts as top-level functions taking `isLocal` where the original +// closure captured it. Behavior-preserving move — the locks here are module surface; the runtime +// behavior stays covered by provider-validation-specialty / provider-validation-azure-vertex / +// nvidia-validation-* / zai-validator / xiaomi-mimo-provider suites. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const M = await import("../../src/lib/providers/validation/specialtyInline.ts"); +const HOST = await import("../../src/lib/providers/validation.ts"); + +test("specialtyInline exposes the extracted leaf validators", () => { + for (const name of [ + "validateV0VercelProvider", + "validateAuggieProvider", + "validateQoderProvider", + "validateKiroProvider", + "validateGitlabProvider", + "validateVertexProvider", + "validateVertexPartnerProvider", + "validateLongcatProvider", + "validateNvidiaProvider", + "validateZaiProvider", + "validateXiaomiMimoProvider", + "buildOpengatewayValidator", + "buildGitlawbValidators", + ]) { + assert.equal(typeof (M as Record)[name], "function", `missing ${name}`); + } +}); + +test("host dispatcher surface stays intact after the move", () => { + assert.equal(typeof (HOST as Record).validateProviderApiKey, "function"); +}); + +test("buildGitlawbValidators returns one entry per config id", () => { + const map = M.buildGitlawbValidators( + [ + ["gitlawb", "https://opengateway.gitlawb.com/v1/xiaomi-mimo", "mimo-v2.5-pro"], + ["gitlawb-gmi", "https://opengateway.gitlawb.com/v1/gmi-cloud", "XiaomiMiMo/MiMo-V2.5-Pro"], + ], + false + ); + assert.deepEqual(Object.keys(map).sort(), ["gitlawb", "gitlawb-gmi"]); + assert.equal(typeof map.gitlawb, "function"); + assert.equal(typeof map["gitlawb-gmi"], "function"); +}); + +test("validateVertexProvider: Express-mode key is accepted without JWT mint", async () => { + const result = await M.validateVertexProvider({ apiKey: "opaque-express-key" }); + assert.equal(result.valid, true); + assert.equal(result.error, null); +}); + +test("validateVertexPartnerProvider: Express-mode key is accepted without JWT mint", async () => { + const result = await M.validateVertexPartnerProvider({ apiKey: "opaque-express-key" }); + assert.equal(result.valid, true); + assert.equal(result.error, null); +}); + +test("validateVertexProvider: malformed Service Account JSON is rejected", async () => { + const result = await M.validateVertexProvider({ + apiKey: '{"type":"service_account","project_id":"p"}', + }); + assert.equal(result.valid, false); + assert.match(result.error || "", /Invalid Service Account JSON/i); +}); diff --git a/tests/unit/validation-web-cookie-split.test.ts b/tests/unit/validation-web-cookie-split.test.ts new file mode 100644 index 0000000000..483ce74af1 --- /dev/null +++ b/tests/unit/validation-web-cookie-split.test.ts @@ -0,0 +1,52 @@ +// Characterization of the validation.ts web-cookie + bytez split (god-file decomposition): +// validateWebCookieProvider, bytezValidationResultFromStatus, and validateBytezProvider moved +// into validation/webCookie.ts. Behavior-preserving move — the locks here are module surface + +// the pure status→result mapping; the runtime behavior stays covered by the +// provider-validation-web-cookie-auth007 / web-cookie-validation-fallback / bytez-validation-5422 +// suites. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const M = await import("../../src/lib/providers/validation/webCookie.ts"); +const HOST = await import("../../src/lib/providers/validation.ts"); + +test("webCookie exposes validateWebCookieProvider, bytezValidationResultFromStatus, validateBytezProvider", () => { + for (const name of [ + "validateWebCookieProvider", + "bytezValidationResultFromStatus", + "validateBytezProvider", + ]) { + assert.equal(typeof (M as Record)[name], "function", `missing ${name}`); + } +}); + +test("host re-exports validateWebCookieProvider + bytezValidationResultFromStatus (historical public surface)", () => { + assert.equal( + (HOST as Record).validateWebCookieProvider, + (M as Record).validateWebCookieProvider + ); + assert.equal( + (HOST as Record).bytezValidationResultFromStatus, + (M as Record).bytezValidationResultFromStatus + ); +}); + +test("bytezValidationResultFromStatus: 200 valid, 401/403 invalid key, other generic failure", () => { + assert.deepEqual(M.bytezValidationResultFromStatus(200), { valid: true, error: null }); + assert.deepEqual(M.bytezValidationResultFromStatus(401), { + valid: false, + error: "Invalid API key", + }); + assert.deepEqual(M.bytezValidationResultFromStatus(403), { + valid: false, + error: "Invalid API key", + }); + assert.deepEqual(M.bytezValidationResultFromStatus(500), { + valid: false, + error: "Validation failed: 500", + }); +}); + +test("host dispatcher surface stays intact after the move", () => { + assert.equal(typeof (HOST as Record).validateProviderApiKey, "function"); +}); diff --git a/tests/unit/vscode-token-routes-responses-listing.test.ts b/tests/unit/vscode-token-routes-responses-listing.test.ts new file mode 100644 index 0000000000..db08e1f77a --- /dev/null +++ b/tests/unit/vscode-token-routes-responses-listing.test.ts @@ -0,0 +1,153 @@ +// #7587: PR #7012 widened only the `/models` route's isUsableChatModel copy to +// accept Responses-API-format models (e.g. Codex-discovery-synced GPT models with +// apiFormat "responses"); the other 4 duplicated copies (tags/show, token + raw) +// still rejected anything that wasn't literally "chat-completions", silently +// dropping every OpenAI/Codex "responses" model from the Ollama-compatible +// listing endpoints VS Code's "Ollama" provider import flow actually uses. +// +// Split into its own file (rather than appended to vscode-token-routes.test.ts) +// because that file is frozen by check-file-size.mjs's testFrozen baseline. +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-vscode-responses-listing-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "vscode-responses-listing-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const vscodeModelsRoute = await import("../../src/app/api/v1/vscode/[token]/models/route.ts"); +const vscodeTagsRoute = await import("../../src/app/api/v1/vscode/[token]/api/tags/route.ts"); +const vscodeShowRoute = await import("../../src/app/api/v1/vscode/[token]/api/show/route.ts"); +const vscodeRawTagsRoute = + await import("../../src/app/api/v1/vscode/raw/[token]/api/tags/route.ts"); +const vscodeRawShowRoute = + await import("../../src/app/api/v1/vscode/raw/[token]/api/show/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("vscode Ollama-compatible tags/show routes (token + raw) expose Codex-discovered responses-format GPT models", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + + // Note: the id deliberately avoids the "gpt-5.4" family — it collides with + // CODEX_DISCOVERY_EXCLUDED_ID_PREFIXES (src/shared/services/codexDiscoveryPolicy.ts) + // and would be dropped from the catalog before ever reaching the listing filter + // this test targets. + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "apikey", + name: "codex-vscode-responses-listing", + apiKey: "sk-test", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + await modelsDb.replaceSyncedAvailableModelsForConnection("codex", connection.id, [ + { + id: "gpt-6.9-responses-probe", + name: "GPT 6.9 Responses Probe", + apiFormat: "responses", + supportedEndpoints: ["responses"], + inputTokenLimit: 400000, + outputTokenLimit: 128000, + }, + ]); + const key = await apiKeysDb.createApiKey( + "vscode-responses-listing", + "machine-vscode-responses-listing" + ); + + const modelsResponse = await vscodeModelsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/models`) + ); + const modelsBody = (await modelsResponse.json()) as { + data?: Array<{ id?: string; owned_by?: string; api_format?: string }>; + }; + const responsesModel = (modelsBody.data || []).find( + (model) => model.owned_by === "codex" && model.api_format === "responses" + ); + assert.ok( + responsesModel, + "precondition failed: expected the responses-format GPT model on /models (PR #7012 fix)" + ); + + const tagsResponse = await vscodeTagsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/tags`) + ); + const tagsBody = (await tagsResponse.json()) as { models?: Array<{ name?: string }> }; + const tagNames = new Set((tagsBody.models || []).map((model) => model.name)); + assert.ok( + tagNames.has(responsesModel!.id), + `expected /api/tags (Ollama flow) to also expose the responses-format GPT model, got: ${JSON.stringify( + Array.from(tagNames) + )}` + ); + + const rawTagsResponse = await vscodeRawTagsRoute.GET( + new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}/api/tags`) + ); + const rawTagsBody = (await rawTagsResponse.json()) as { models?: Array<{ name?: string }> }; + const rawTagNames: string[] = (rawTagsBody.models || []) + .map((model) => model.name) + .filter((name): name is string => typeof name === "string"); + const rawResponsesModelName = rawTagNames.find((name) => + name.includes("gpt-6.9-responses-probe") + ); + assert.ok( + rawResponsesModelName, + `expected raw /api/tags to also expose the responses-format GPT model, got: ${JSON.stringify( + rawTagNames + )}` + ); + + const showResponse = await vscodeShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + body: JSON.stringify({ name: responsesModel!.id }), + }) + ); + assert.equal( + showResponse.status, + 200, + "expected /api/show to find the responses-format GPT model by its tag name" + ); + + const rawShowResponse = await vscodeRawShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + body: JSON.stringify({ name: rawResponsesModelName }), + }) + ); + assert.equal( + rawShowResponse.status, + 200, + "expected raw /api/show to find the responses-format GPT model by its tag name" + ); +}); diff --git a/tests/unit/web-fetch-quota-fallback.test.ts b/tests/unit/web-fetch-quota-fallback.test.ts new file mode 100644 index 0000000000..4c7a77f595 --- /dev/null +++ b/tests/unit/web-fetch-quota-fallback.test.ts @@ -0,0 +1,274 @@ +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-web-fetch-fallback-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const webFetchRoute = await import("../../src/app/api/v1/web/fetch/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedConnection( + provider: string, + overrides: { + apiKey?: string | null; + rateLimitedUntil?: string | null; + } = {} +) { + return providersDb.createProviderConnection({ + provider, + authType: "apikey", + name: `${provider}-${Math.random().toString(16).slice(2, 8)}`, + apiKey: overrides.apiKey ?? "test-key", + isActive: true, + testStatus: "active", + rateLimitedUntil: overrides.rateLimitedUntil ?? null, + providerSpecificData: {}, + }); +} + +function postWebFetch(body: Record) { + return webFetchRoute.POST( + new Request("http://localhost/api/v1/web/fetch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "https://example.com", ...body }), + }) + ); +} + +interface WebFetchTestBody { + provider?: string; + content?: string; + error?: { message: string }; +} + +async function readJson(response: Response): Promise { + return (await response.json()) as WebFetchTestBody; +} + +const FUTURE_ISO = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── (a) credential-time: rate-limited stub is skipped, not short-circuited ── + +test("auto-select skips a rate-limited firecrawl and falls to jina-reader", async () => { + await seedConnection("firecrawl", { rateLimitedUntil: FUTURE_ISO }); + await seedConnection("jina-reader", { apiKey: "jina-key" }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("api.firecrawl.dev")) { + throw new Error("firecrawl should never be called once rate-limited"); + } + if (u.includes("r.jina.ai")) { + return new Response( + JSON.stringify({ data: { content: "jina content", links: [] } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`unexpected fetch to ${u}`); + }; + + try { + const response = await postWebFetch({}); + const body = await readJson(response); + + assert.equal(response.status, 200); + assert.equal(body.provider, "jina-reader"); + assert.equal(body.content, "jina content"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ── (b) request-time: credentialed provider returns 429 → falls through ──── + +test("auto-select falls through to jina-reader when firecrawl returns 429 at request time", async () => { + await seedConnection("firecrawl", { apiKey: "fc-key" }); + await seedConnection("jina-reader", { apiKey: "jina-key" }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("api.firecrawl.dev")) { + return new Response(JSON.stringify({ error: "rate limited" }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + } + if (u.includes("r.jina.ai")) { + return new Response( + JSON.stringify({ data: { content: "jina content", links: [] } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`unexpected fetch to ${u}`); + }; + + try { + const response = await postWebFetch({}); + const body = await readJson(response); + + assert.equal(response.status, 200); + assert.equal(body.provider, "jina-reader"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ── (c) provider-specific quota status (402/403) triggers fallback; plain 400 does NOT ── + +test("auto-select falls through to jina-reader when firecrawl returns 403 (quota-style)", async () => { + await seedConnection("firecrawl", { apiKey: "fc-key" }); + await seedConnection("jina-reader", { apiKey: "jina-key" }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("api.firecrawl.dev")) { + return new Response(JSON.stringify({ error: "quota exceeded" }), { + status: 403, + headers: { "content-type": "application/json" }, + }); + } + if (u.includes("r.jina.ai")) { + return new Response( + JSON.stringify({ data: { content: "jina content", links: [] } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`unexpected fetch to ${u}`); + }; + + try { + const response = await postWebFetch({}); + const body = await readJson(response); + + assert.equal(response.status, 200); + assert.equal(body.provider, "jina-reader"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("auto-select does NOT fall through when firecrawl returns a plain 400 bad request", async () => { + await seedConnection("firecrawl", { apiKey: "fc-key" }); + await seedConnection("jina-reader", { apiKey: "jina-key" }); + + let jinaWasCalled = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("api.firecrawl.dev")) { + return new Response(JSON.stringify({ error: "bad url" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + if (u.includes("r.jina.ai")) { + jinaWasCalled = true; + return new Response( + JSON.stringify({ data: { content: "jina content", links: [] } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`unexpected fetch to ${u}`); + }; + + try { + const response = await postWebFetch({}); + const body = await readJson(response); + + assert.equal(response.status, 400); + assert.equal(jinaWasCalled, false, "jina-reader must not be tried for a non-quota 400"); + assert.ok(!(body.error?.message ?? "").includes("at /"), "error must not leak stack paths"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ── (d) explicit rate-limited provider → 429, no silent fallback ─────────── + +test("explicit rate-limited provider request returns 429 without falling back", async () => { + await seedConnection("firecrawl", { rateLimitedUntil: FUTURE_ISO }); + await seedConnection("jina-reader", { apiKey: "jina-key" }); + + let jinaWasCalled = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("r.jina.ai")) { + jinaWasCalled = true; + } + throw new Error(`unexpected fetch to ${u}`); + }; + + try { + const response = await postWebFetch({ provider: "firecrawl" }); + const body = await readJson(response); + + assert.equal(response.status, 429); + assert.equal(jinaWasCalled, false, "explicit provider request must never fall back"); + assert.ok(response.headers.get("Retry-After"), "should include a Retry-After header"); + assert.ok(!(body.error?.message ?? "").includes("at /"), "error must not leak stack paths"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ── (e) whole pool exhausted at request time → single 429 with retry-after ─ + +test("auto-select returns a single 429 with retry-after when the whole pool is exhausted", async () => { + await seedConnection("firecrawl", { apiKey: "fc-key" }); + await seedConnection("jina-reader", { apiKey: "jina-key" }); + await seedConnection("tavily-search", { apiKey: "tavily-key" }); + await seedConnection("tinyfish", { apiKey: "tf-key" }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + return new Response(JSON.stringify({ error: "rate limited" }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const response = await postWebFetch({}); + const body = await readJson(response); + + assert.equal(response.status, 429); + assert.ok(response.headers.get("Retry-After"), "should include a Retry-After header"); + assert.ok(!(body.error?.message ?? "").includes("at /"), "error must not leak stack paths"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// ── No credentials at all → generic 400 (unchanged behavior) ────────────── + +test("auto-select returns 400 when no web-fetch provider is configured", async () => { + const response = await postWebFetch({}); + const body = await readJson(response); + + assert.equal(response.status, 400); + assert.ok((body.error?.message ?? "").includes("No credentials configured")); +}); diff --git a/tests/unit/zai-web-chat-endpoint-8014-probe.test.ts b/tests/unit/zai-web-chat-endpoint-8014-probe.test.ts new file mode 100644 index 0000000000..82394c4e46 --- /dev/null +++ b/tests/unit/zai-web-chat-endpoint-8014-probe.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const mod = await import("../../open-sse/executors/zai-web.ts"); + +test("#8014 RED: ZaiWebExecutor must POST to the current chat.z.ai v2 chat-completions endpoint, not the stale unversioned path", async () => { + const originalFetch = globalThis.fetch; + let capturedUrl = ""; + globalThis.fetch = (async (url: string) => { + capturedUrl = String(url); + if (capturedUrl === "https://chat.z.ai/api/chat/completions") { + return new Response(JSON.stringify({ detail: "Not Found" }), { status: 404 }); + } + return new Response("data: [DONE]\n\n", { + headers: { "Content-Type": "text/event-stream" }, + }); + }) as typeof fetch; + + try { + const executor = new mod.ZaiWebExecutor(); + const result = await executor.execute({ + model: "glm-4.6", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: false, + credentials: { apiKey: "token=abc123" }, + signal: null, + }); + + assert.equal( + capturedUrl, + "https://chat.z.ai/api/v2/chat/completions", + `zai-web executor POSTed to a stale endpoint (${capturedUrl}) — matches #8014's model-independent 404 "Not Found"` + ); + assert.notEqual( + result.response.status, + 404, + "chat call must not 404 when the endpoint path is correct" + ); + } finally { + globalThis.fetch = originalFetch; + } +});