mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-27 10:26:03 +03:00
Compare commits
35 Commits
pr/fix-qod
...
fix/candid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c61065db15 | ||
|
|
24caf2269b | ||
|
|
d4b9ce6016 | ||
|
|
2614e8a223 | ||
|
|
3be0a5d290 | ||
|
|
a095ebc43d | ||
|
|
ca79344b40 | ||
|
|
ac5691f04c | ||
|
|
5bffb57b44 | ||
|
|
a08b5cdf4e | ||
|
|
26d50f1913 | ||
|
|
2d48bb6dca | ||
|
|
fae10668ba | ||
|
|
a784c52d34 | ||
|
|
7b3ef477f3 | ||
|
|
4c9292e66e | ||
|
|
cbdf1fc835 | ||
|
|
4bf47c9b80 | ||
|
|
1cfbfc044a | ||
|
|
c06cd83022 | ||
|
|
1a7079599c | ||
|
|
533f8051a4 | ||
|
|
074fd6de88 | ||
|
|
f0b08f95c3 | ||
|
|
56185a8a49 | ||
|
|
6b5e7d562e | ||
|
|
11f79b65cd | ||
|
|
647afe327b | ||
|
|
d628105503 | ||
|
|
2cf462c1a1 | ||
|
|
0312fe2a40 | ||
|
|
fd739ab008 | ||
|
|
1bd1af2f48 | ||
|
|
06326a3c80 | ||
|
|
a51cd06322 |
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -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)
|
||||
|
||||
@@ -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 <name>", t("backup.nameOpt"))
|
||||
.option("--cloud", t("backup.cloudOpt"))
|
||||
.option("--encrypt", t("backup.encryptOpt"))
|
||||
.option("--key-file <path>", t("backup.keyFileOpt"))
|
||||
.option("--exclude <pattern>", t("backup.excludeOpt"), (v, prev = []) => [...prev, v], [])
|
||||
.option("--retention <n>", t("backup.retentionOpt"), parseInt);
|
||||
}
|
||||
|
||||
export function registerRestore(program) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
1
changelog.d/features/adobe-firefly-reference-images.md
Normal file
1
changelog.d/features/adobe-firefly-reference-images.md
Normal file
@@ -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).
|
||||
1
changelog.d/fixes/7587-vscode-models-missing-openai.md
Normal file
1
changelog.d/fixes/7587-vscode-models-missing-openai.md
Normal file
@@ -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)
|
||||
1
changelog.d/fixes/7847-bound-client-raw-request.md
Normal file
1
changelog.d/fixes/7847-bound-client-raw-request.md
Normal file
@@ -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
|
||||
1
changelog.d/fixes/7847-combo-attempt-body-cow.md
Normal file
1
changelog.d/fixes/7847-combo-attempt-body-cow.md
Normal file
@@ -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
|
||||
1
changelog.d/fixes/7847-structural-json-size.md
Normal file
1
changelog.d/fixes/7847-structural-json-size.md
Normal file
@@ -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
|
||||
1
changelog.d/fixes/8032-path-shaped-vision.md
Normal file
1
changelog.d/fixes/8032-path-shaped-vision.md
Normal file
@@ -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
|
||||
1
changelog.d/fixes/8467-opencode-extra-keys.md
Normal file
1
changelog.d/fixes/8467-opencode-extra-keys.md
Normal file
@@ -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
|
||||
1
changelog.d/fixes/8491-antigravity-projectid-persist.md
Normal file
1
changelog.d/fixes/8491-antigravity-projectid-persist.md
Normal file
@@ -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)
|
||||
1
changelog.d/fixes/8512-backup-option-shadowing.md
Normal file
1
changelog.d/fixes/8512-backup-option-shadowing.md
Normal file
@@ -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
|
||||
1
changelog.d/fixes/8530-combo-model-name-collision.md
Normal file
1
changelog.d/fixes/8530-combo-model-name-collision.md
Normal file
@@ -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)
|
||||
@@ -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
|
||||
1
changelog.d/maintenance/combo-predicates-extract.md
Normal file
1
changelog.d/maintenance/combo-predicates-extract.md
Normal file
@@ -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.
|
||||
1
changelog.d/maintenance/decomp-provider-validation.md
Normal file
1
changelog.d/maintenance/decomp-provider-validation.md
Normal file
@@ -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
|
||||
1
changelog.d/maintenance/decomp-token-refresh.md
Normal file
1
changelog.d/maintenance/decomp-token-refresh.md
Normal file
@@ -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
|
||||
1
changelog.d/maintenance/decomp-usage-service.md
Normal file
1
changelog.d/maintenance/decomp-usage-service.md
Normal file
@@ -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
|
||||
1
changelog.d/maintenance/heap-benchmark-request-body.md
Normal file
1
changelog.d/maintenance/heap-benchmark-request-body.md
Normal file
@@ -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
|
||||
@@ -410,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
|
||||
|
||||
@@ -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,
|
||||
@@ -196,7 +197,8 @@
|
||||
"_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": 1966,
|
||||
"open-sse/services/adobeFireflyClient.ts": 1958,
|
||||
"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,
|
||||
@@ -205,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 <cap) and quotaShareStrategy.ts (per-model bucket gating via isBucketSaturated + DRR proportional to weight + P2C over in-flight, ~240 LOC <cap). Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors the headroom/reset-aware/reset-window/context-optimized branches); not extractable without hiding the call site. ZERO existing strategy cases were modified — only this branch was added, and the qtSd/ combos switched from fill-first to quota-share in src/lib/quota/quotaCombos.ts. Covered by tests/unit/quota-share-strategy.test.ts (gating, DRR fairness, P2C in-flight, fail-open, activation). Structural shrink of combo.ts tracked in #3501.",
|
||||
"_rebaseline_2026_06_24_task_aware_routing": "Task-aware routing strategy (port PR #2045, OmniRoute #4945): combo.ts 3190->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 <cap). Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors quota-share/headroom/reset-aware branches). ZERO existing strategy cases modified. Covered by tests/unit/combo-task-aware.test.ts (35 tests). Structural shrink of combo.ts tracked in #3501.",
|
||||
"_rebaseline_2026_07_22_8213_combo_cooldown_wait_recording": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: open-sse/services/combo.ts 3548->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": 3642,
|
||||
"_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 (<cap); the residual growth is the duplicated thin-wrapper signatures + the extracted bodies' dispatch boundary, guarded by a byte-identical parity test (riskGateIntegration). Default off (DEFAULT_COMPRESSION_CONFIG unchanged). Not extractable without hiding the dispatch boundary, mirroring prior compression rebaselines. Structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_06_29_5286_memoization": "PR #5286 own growth: strategySelector.ts 899->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 (<cap); the chokepoint wiring here is not extractable. Structural shrink of this hot-path file tracked in #3501.",
|
||||
@@ -341,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.",
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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 <email>` 尾部進行署名;upstream-port 工作流(`/port-upstream-features`、`/port-upstream-issues`)依賴於此。
|
||||
|
||||
@@ -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/` 中撰寫單元測試,至少涵蓋:
|
||||
|
||||
- 提供商註冊
|
||||
- 提供者註冊
|
||||
- 請求/回應轉換
|
||||
- 錯誤處理
|
||||
|
||||
|
||||
@@ -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,支援自動切換備援。**
|
||||
|
||||
<br/>
|
||||
|
||||
**RTK + Caveman 壓縮可節省 15–95% 的 Token。永遠不會達到限制。**
|
||||
**RTK + Caveman 堆疊壓縮可節省 15–95% 的 Token(平均約 89%),告別額度上限痛點。**
|
||||
|
||||
<br/>
|
||||
|
||||
**~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))
|
||||
|
||||
<br/>
|
||||
|
||||
[](#-231-ai-providers--50-free)
|
||||
[](#-231-ai-providers--50-free)
|
||||
[](../../reference/FREE_TIERS.md)
|
||||
[](#-290-ai-providers--90-free)
|
||||
[](#-290-ai-providers--90-free)
|
||||
[](../../reference/FREE_TIERS.md)
|
||||
[](#%EF%B8%8F-save-1595-tokens--automatically)
|
||||
[](#-combos--the-flagship)
|
||||
[](#-combos--the-flagship)
|
||||
[](#-quick-start)
|
||||
|
||||
<br/>
|
||||
@@ -42,7 +42,7 @@
|
||||
[](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)**
|
||||
**問題、提供者技巧、路線圖與支援 → [Discord](https://discord.gg/U47eFqAXCn) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 全球](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 巴西](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz)**
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
<br/>
|
||||
|
||||
[**🚀 快速開始**](#-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 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 💰 ~1.6B 免費 Token / 月
|
||||
# 💰 ~1.53B 免費 Token / 月
|
||||
|
||||
</div>
|
||||
|
||||
> 手動堆疊免費層很痛苦 — 數十個 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/月**,兩者獨立列出,絕不誇大統計數字。
|
||||
- **按模型細分**,當月**已用/剩餘**即時顯示,以及每個提供者的透明**條款標記**。
|
||||
|
||||

|
||||
|
||||
> 預覽模型 — 實際截圖將在 `/dashboard/free-tiers` 頁面驗證後上線。完整方法論(池去重、信用層級、供應商條款):**[docs/reference/FREE_TIERS.md](../../reference/FREE_TIERS.md)**。
|
||||
> 預覽模型 — 實際截圖將在 `/dashboard/free-tiers` 頁面驗證後上線。完整方法論(池去重、信用層級、提供者條款):**[docs/reference/FREE_TIERS.md](../../reference/FREE_TIERS.md)**。
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -144,18 +144,18 @@
|
||||
|
||||
</div>
|
||||
|
||||
> 一個端點。**231 個供應商。** 永遠不要停止建構 — 讓 OmniRoute 選擇最便宜且有效的方案。
|
||||
> 單一端點。**290 家提供者。** 讓開發流程暢行無阻 — 由 OmniRoute 自動挑選最划算且可行的最佳方案。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top"><b>🚫 永遠不會達到限制</b><br/><sub>跨 231 個供應商毫秒級自動備援。配額用盡?下一個供應商立即接管 — 零停機。</sub></td>
|
||||
<td width="33%" valign="top"><b>🚫 告別配額限制</b><br/><sub>跨 290 家提供者毫秒級自動備援。配額用盡?下一個提供者立即接管,實現零中斷體驗。</sub></td>
|
||||
<td width="33%" valign="top"><b>💸 節省高達 95% 的 Token</b><br/><sub>RTK + Caveman 堆疊壓縮可削減 15–95% 的合格 Token(工具密集型會話平均約 89%)。</sub></td>
|
||||
<td width="33%" valign="top"><b>🆓 零成本開始</b><br/><sub>50+ 供應商提供免費層,11 個永久免費(Kiro、Qoder、Pollinations、LongCat…)。無需信用卡。</sub></td>
|
||||
<td width="33%" valign="top"><b>🆓 零成本輕鬆上手</b><br/><sub>90+ 提供者包含免費層,11 家永久免費(Kiro、Qoder、Pollinations、LongCat…)。無須綁定信用卡。</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33%" valign="top"><b>🔌 每個工具都相容</b><br/><sub>16+ 編碼代理 — Claude Code、Codex、Cursor、Cline、Copilot、Antigravity — 透過一個設定即可使用。</sub></td>
|
||||
<td width="33%" valign="top"><b>🧩 一個端點</b><br/><sub>OpenAI ↔ Claude ↔ Gemini ↔ Responses API 轉換。將任何工具指向 <code>/v1</code> 即可使用。</sub></td>
|
||||
<td width="33%" valign="top"><b>🛡️ 生產級別</b><br/><sub>斷路器、TLS 隱身、MCP(87 工具)、A2A、記憶、護欄、評估。14,965 個測試。</sub></td>
|
||||
<td width="33%" valign="top"><b>🔌 廣泛相容各式工具</b><br/><sub>16+ AI Coding Agent — Claude Code、Codex、Cursor、Cline、Copilot、Antigravity — 單一設定隨插即用。</sub></td>
|
||||
<td width="33%" valign="top"><b>🧩 單一統一端點</b><br/><sub>OpenAI ↔ Claude ↔ Gemini ↔ Responses API 雙向轉換。將任何工具指向 <code>/v1</code> 即可直接運作。</sub></td>
|
||||
<td width="33%" valign="top"><b>🛡️ 生產級穩定架構</b><br/><sub>斷路器、TLS 指紋隱身、MCP(104 工具)、A2A、對話記憶、護欄、評估套件。</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -168,16 +168,16 @@
|
||||
|
||||
</div>
|
||||
|
||||
> 告別管理 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 服務 |
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
Codex, Copilot Groq, xAI MiniMax $0.2 Pollinations
|
||||
quota out? ───▶ budget hit? ─▶ budget hit? ─▶ always on
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
@@ -209,43 +210,43 @@
|
||||
|
||||
</div>
|
||||
|
||||
> **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` |
|
||||
|
||||
<sub>Auto-Combo 引擎根據 **9 項因素**(健康度、配額、成本、延遲、成功率、新鮮度…)對每個候選模型評分 — 參見 [`docs/routing/AUTO-COMBO.md`](../../routing/AUTO-COMBO.md)。</sub>
|
||||
|
||||
### 🧱 內建彈性(3 個獨立層)
|
||||
|
||||
| 層 | 範圍 | 作用 |
|
||||
|---|---|---|
|
||||
| 🔌 **斷路器** | 整個供應商 | 停止重複呼叫上游失敗的供應商;自動探測恢復 |
|
||||
| 💤 **連線冷卻** | 一個帳戶/金鑰 | 跳過速率限制的金鑰,其他金鑰繼續提供服務 |
|
||||
| 🎯 **模型鎖定** | 供應商 + 模型 | 僅隔離配額受限的模型,不影響整個連線 |
|
||||
| 層 | 範圍 | 作用 |
|
||||
| --------------- | ------------- | ------------------------------------------ |
|
||||
| 🔌 **斷路器** | 整個提供者 | 停止重複呼叫上游失敗的提供者;自動探測恢復 |
|
||||
| 💤 **連線冷卻** | 一個帳戶/金鑰 | 跳過速率限制的金鑰,其他金鑰繼續提供服務 |
|
||||
| 🎯 **模型鎖定** | 提供者 + 模型 | 僅隔離配額受限的模型,不影響整個連線 |
|
||||
|
||||
```
|
||||
Combo: "always-on" Strategy: priority
|
||||
@@ -266,20 +267,20 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
</div>
|
||||
|
||||
| 功能 | 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 |
|
||||
|
||||
<sub>📊 與 LiteLLM、OpenRouter 和 Portkey 的詳細比較 → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](../../comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
|
||||
|
||||
@@ -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/<category>:<tier>` 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/<category>:<tier>` 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)
|
||||
|
||||
<br/>
|
||||
@@ -344,11 +345,11 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 🌐 231 個 AI 供應商 — 50+ 免費
|
||||
# 🌐 290 個 AI 提供者 — 90+ 免費
|
||||
|
||||
</div>
|
||||
|
||||
> 最完整的開源路由器目錄:**231 個供應商**、**50+ 具有免費層**、**11 個永久免費**。
|
||||
> 最完整的開源路由器目錄:**290 個提供者**、**90+ 具有免費層**、**40+ 永久免費**。
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -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` | 參與開發 |
|
||||
|
||||
<sub>📖 [Docker Guide](../../guides/DOCKER_GUIDE.md) · [Desktop](../../electron/README.md) · [Termux](../../guides/TERMUX_GUIDE.md) · [PWA](../../guides/PWA_GUIDE.md) · [OpenCode](../../frameworks/OPENCODE.md)</sub>
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><b>💰 價格一覽與 $0 免費堆疊(11 個供應商)</b></summary>
|
||||
<summary><b>💰 價格一覽與 $0 免費堆疊(11 個提供者)</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| 層級 | 範例 | 成本 |
|
||||
|---|---|---|
|
||||
| 💳 **訂閱** | 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。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -751,7 +752,7 @@ podman compose --profile base up -d
|
||||
```
|
||||
|
||||
**24/7 無中斷:** 串聯 2 個訂閱 → 廉價 → 免費,5 層備援。
|
||||
**被封鎖地區:** 免費供應商 + 全域/每供應商代理 → 從任何國家存取 AI。
|
||||
**被封鎖地區:** 免費提供者 + 全域/每提供者代理 → 從任何國家存取 AI。
|
||||
**最大節省:** 訂閱 + 廉價備用 + `ultra` 壓縮 (~75%) → 重度使用者每月節省 ~$150–300。
|
||||
|
||||
</details>
|
||||
@@ -761,7 +762,7 @@ podman compose --profile base up -d
|
||||
|
||||
<br/>
|
||||
|
||||
🇷🇺 🇨🇳 🇮🇷 🇨🇺 🇹🇷 在被封鎖的地區?OmniRoute 的 **3 層代理**(全域 / 每供應商 / 每連線)代理 API 請求、OAuth 流程、連線測試、Token 重新整理和模型同步。
|
||||
🇷🇺 🇨🇳 🇮🇷 🇨🇺 🇹🇷 在被封鎖的地區?OmniRoute 的 **3 層代理**(全域 / 每提供者 / 每連線)代理 API 請求、OAuth 流程、連線測試、Token 重新整理和模型同步。
|
||||
|
||||
- **協定:** HTTP/HTTPS、SOCKS5、認證代理
|
||||
- **🆓 1proxy 市場** — 數百個免費驗證代理、品質評分、自動輪換
|
||||
@@ -777,8 +778,8 @@ podman compose --profile base up -d
|
||||
<br/>
|
||||
|
||||
**路由:** 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
|
||||
|
||||
<br/>
|
||||
|
||||
| 環境變數 | 預設值 | 用途 |
|
||||
|---|---|---|
|
||||
| `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
|
||||
|
||||
<br/>
|
||||
|
||||
| 問題 | 快速修復 |
|
||||
|---|---|
|
||||
| "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
|
||||
|
||||
<br/>
|
||||
|
||||
| 頁面 | 截圖 | 頁面 | 截圖 |
|
||||
|---|---|---|---|
|
||||
| Providers |  | Combos |  |
|
||||
| Analytics |  | Health |  |
|
||||
| Translator |  | Settings |  |
|
||||
| CLI Tools |  | Usage Logs |  |
|
||||
| 頁面 | 截圖 | 頁面 | 截圖 |
|
||||
| ---------- | -------------------------------------------------- | ---------- | ---------------------------------------------- |
|
||||
| Providers |  | Combos |  |
|
||||
| Analytics |  | Health |  |
|
||||
| Translator |  | Settings |  |
|
||||
| CLI Tools |  | Usage Logs |  |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -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 測試套件 |
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -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) 中回報錯誤和分享意見回饋**
|
||||
|
||||
## 📄 授權
|
||||
|
||||
@@ -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 標籤解析(`<think>...</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 版本範圍時,登入頁面會顯示警告橫幅。
|
||||
|
||||
@@ -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/<file>.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/<file>.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`。
|
||||
|
||||
@@ -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`,為背景摘要請求預留空間
|
||||
- 將此功能視為連續性輔助,而非持久化記憶體的替代方案
|
||||
|
||||
@@ -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`。
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🎨 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。
|
||||
|
||||

|
||||
|
||||
@@ -84,7 +84,7 @@ OpenRouter 連線可在「進階設定」中儲存各連線的 `preset`。設定
|
||||
|
||||
## 🏥 系統健康度
|
||||
|
||||
即時監控:運作時間、記憶體、版本、延遲百分位數(p50/p95/p99)、快取統計、供應商斷路器狀態、活躍配額監控階段及 Combo 目標健康度。
|
||||
即時監控:運作時間、記憶體、版本、延遲百分位數(p50/p95/p99)、快取統計、提供者斷路器狀態、活躍配額監控階段及 Combo 目標健康度。
|
||||
|
||||

|
||||
|
||||
@@ -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 背景模式降級
|
||||
|
||||

|
||||
@@ -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 用量、延遲及回應詳細資料。
|
||||
|
||||

|
||||
|
||||
@@ -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` 發送。
|
||||
|
||||
@@ -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. **回報誤判** — <https://www.avast.com/false-positive-file-form.php>,附上被隔離的 `README.md`。這能幫助所有人,因為這是供應商的啟發式掃描對文字檔案的過度反應。
|
||||
2. **回報誤判** — <https://www.avast.com/false-positive-file-form.php>,附上被隔離的 `README.md`。這能幫助所有人,因為這是提供者的啟發式掃描對文字檔案的過度反應。
|
||||
|
||||
**為什麼我們不在這邊「修復」這個問題:** 範例全都是 `http://localhost`,而 localhost 若要使用 `https` 會需要自簽憑證,增加使用摩擦。為了避開某家廠商的啟發式掃描而修改文件,會損害所有讀者的閱讀體驗,只為了一個掃描器的錯誤。
|
||||
|
||||
@@ -82,8 +82,8 @@ Avast 和 AVG 執行啟發式掃描,會將包含大量類似 HTTP 請求連結
|
||||
|
||||
被標記的檔案是桌面應用程式所捆綁的、已聲明的開源依賴的標準組件,例如:
|
||||
|
||||
- `resources/app/.build/next/node_modules/playwright-<hash>/lib/…/agentParser.js` 和 `workerProcessEntry.js` — [Playwright](https://playwright.dev),用於應用程式內供應商登入和瀏覽器支援聊天的瀏覽器自動化函式庫。
|
||||
- `resources/app/.build/next/node_modules/tls-client-node-<hash>/bin/tls-client-windows-64-<ver>.dll` — 來自 `tls-client-node` 的原生二進位檔案,用於某些網路供應商的 Cloudflare 相容 HTTP。
|
||||
- `resources/app/.build/next/node_modules/playwright-<hash>/lib/…/agentParser.js` 和 `workerProcessEntry.js` — [Playwright](https://playwright.dev),用於應用程式內提供者登入和瀏覽器支援聊天的瀏覽器自動化函式庫。
|
||||
- `resources/app/.build/next/node_modules/tls-client-node-<hash>/bin/tls-client-windows-64-<ver>.dll` — 來自 `tls-client-node` 的原生二進位檔案,用於某些網路提供者的 Cloudflare 相容 HTTP。
|
||||
|
||||
**為什麼會觸發:** Windows 安裝程式**尚未進行程式碼簽署**,因此未簽署的 NSIS 安裝程式沒有信譽,行為啟發式掃描會以最大強度執行。加上捆綁的原生 DLL 和數百個寫入 `%LOCALAPPDATA%\Programs\OmniRoute` 的 `.js` 檔案(包括 Next.js 獨立建置的雜湊後綴套件目錄),這就足以觸發啟發式掃描。程式碼簽署已規劃中;在完成之前,新版本可能會重複觸發此問題。
|
||||
|
||||
@@ -160,11 +160,11 @@ omniroute
|
||||
|
||||
<a name="proxy-問題"></a>
|
||||
|
||||
### 供應商驗證顯示「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/`
|
||||
- 應用程式日誌:`<repo>/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 提供了專門處理這些標頭的功能,因此較舊的版本會將其誤讀為一般的速率限制提示。
|
||||
|
||||
**修復方式:**
|
||||
|
||||
|
||||
@@ -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` | 舊版請求日誌(選用) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
<NEXT_PUBLIC_BASE_URL>/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 流程將在瀏覽器重新導向步驟失敗。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 '<value>' # 直接設定管理員密碼
|
||||
omniroute setup --add-provider \
|
||||
--provider openai \
|
||||
--api-key '<value>' \
|
||||
--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 <id|name> # 測試一個已設定的連線
|
||||
@@ -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 <k> | set <k> <v> # 檢查 / 設定環境變數(暫時性)
|
||||
|
||||
omniroute test # 提供商連線冒煙測試
|
||||
omniroute test # 提供者連線冒煙測試
|
||||
omniroute update # 檢查更新
|
||||
omniroute completion # 產生 Shell 補全
|
||||
```
|
||||
|
||||
### 常用旗標
|
||||
|
||||
| 旗標 | 說明 |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `--no-open` | 啟動時不自動開啟瀏覽器 |
|
||||
| `--port <n>` | 覆寫 API 通訊埠(預設 20128) |
|
||||
| `--mcp` | 以 MCP 伺服器模式透過 stdio 執行(用於 IDE)|
|
||||
| `--non-interactive` | CI 模式(無提示;從環境變數/旗標讀取) |
|
||||
| `--json` | 機器可讀的 JSON 輸出(doctor、providers 等)|
|
||||
| `--help`、`-h` | 顯示指令專屬說明 |
|
||||
| `--version`、`-v` | 顯示已安裝版本 |
|
||||
| 旗標 | 說明 |
|
||||
| ------------------- | -------------------------------------------- |
|
||||
| `--no-open` | 啟動時不自動開啟瀏覽器 |
|
||||
| `--port <n>` | 覆寫 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 <command>` |
|
||||
| 儀表板在安裝後顯示「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 <command>` |
|
||||
| 儀表板在安裝後顯示「not detected」 | 快取過期 | 點選儀表板中的「⟳ 重新整理偵測」 |
|
||||
| 舊連結 `/dashboard/cli-tools` | v3.8.6 之前的書籤 | 自動重新導向至 `/dashboard/cli-code`(308) |
|
||||
| 舊連結 `/dashboard/agents` | v3.8.6 之前的書籤 | 自動重新導向至 `/dashboard/acp-agents`(308) |
|
||||
|
||||
@@ -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**。
|
||||
|
||||

|
||||
|
||||
> 來源:[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` | 整合點:自動前綴短路處理 |
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<AuggieModelResolution, { ok: false }> {
|
||||
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);
|
||||
|
||||
@@ -372,12 +372,15 @@ export class BaseExecutor {
|
||||
(credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
|
||||
const selectedKeyId = (credentials.providerSpecificData as Record<string, unknown> | 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;
|
||||
@@ -432,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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown>).text;
|
||||
if (typeof text === "string") return text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractResultText(result: Record<string, unknown>): string {
|
||||
// Common result shapes:
|
||||
// { message: { content: "..." } }
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export class GithubExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("github", PROVIDERS.github);
|
||||
@@ -367,7 +387,7 @@ export class GithubExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshCredentials(credentials, log) {
|
||||
async refreshCredentials(credentials, log): Promise<RefreshedCopilotCredentials | null> {
|
||||
let copilotResult = await this.refreshCopilotToken(credentials.accessToken, log);
|
||||
|
||||
if (!copilotResult && credentials.refreshToken) {
|
||||
|
||||
@@ -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<string, string> {
|
||||
private buildStreamHeaders(token: string, yy: string): Record<string, string> {
|
||||
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. */
|
||||
|
||||
@@ -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<string, unknown>) : {};
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<GraphqlResult, { ok: false }> {
|
||||
return !result.ok;
|
||||
}
|
||||
|
||||
async function graphqlPost(
|
||||
docId: string,
|
||||
variables: Record<string, unknown>,
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string> | null | undefined) ??
|
||||
((input as { headers?: Record<string, string> }).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<string, unknown> }
|
||||
| {
|
||||
ok: false;
|
||||
errorResult: ReturnType<typeof makeErrorResult>;
|
||||
retryable: boolean;
|
||||
reqBody: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// `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<NotionAttempt, { ok: false }> => !attempt.ok;
|
||||
|
||||
const runOnce = async (opts: {
|
||||
createThread: boolean;
|
||||
threadId: string;
|
||||
}): Promise<
|
||||
| { ok: true; finalText: string; reqBody: Record<string, unknown> }
|
||||
| { ok: false; errorResult: ReturnType<typeof makeErrorResult>; retryable: boolean; reqBody: Record<string, unknown> }
|
||||
> => {
|
||||
}): Promise<NotionAttempt> => {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -263,7 +263,11 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
model?: string
|
||||
) {
|
||||
const headers: Record<string, string> = { "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") {
|
||||
@@ -338,13 +342,11 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
) {
|
||||
delete (modifiedBody as Record<string, unknown>).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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<ArrayBuffer>`, 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<ArrayBufferLike>`, which admits
|
||||
* `SharedArrayBuffer` and is therefore rejected as a request body.
|
||||
*/
|
||||
function grpcWebFrame(payload: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
const frame = new Uint8Array(5 + payload.length);
|
||||
frame[0] = 0x00; // compression flag: no compression
|
||||
const view = new DataView(frame.buffer);
|
||||
|
||||
@@ -117,8 +117,18 @@ function createErrorChunk(model: string, message: string): Record<string, unknow
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The single controller capability these SSE helpers use. They only ever enqueue —
|
||||
* never `close()`, never read `desiredSize` — so typing them by that one method lets
|
||||
* the same code serve both stream kinds. The wider
|
||||
* `ReadableStreamDefaultController` annotation rejected every call site, because the
|
||||
* helpers are driven from a TransformStream and `TransformStreamDefaultController`
|
||||
* has no `close()`.
|
||||
*/
|
||||
type SseEnqueueTarget = Pick<ReadableStreamDefaultController<Uint8Array>, "enqueue">;
|
||||
|
||||
function enqueueSseObject(
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
controller: SseEnqueueTarget,
|
||||
encoder: TextEncoder,
|
||||
chunk: unknown
|
||||
): void {
|
||||
@@ -202,7 +212,7 @@ function wrapZedCompletionStream(
|
||||
let buffer = "";
|
||||
let done = false;
|
||||
|
||||
const finish = (controller: ReadableStreamDefaultController<Uint8Array>) => {
|
||||
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<Uint8Array>) => {
|
||||
const processLine = (line: string, controller: SseEnqueueTarget) => {
|
||||
if (done) return;
|
||||
const payload = unwrapZedLine(line);
|
||||
if (!payload) return;
|
||||
|
||||
@@ -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<ArrayBuffer>`, 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<string, string>,
|
||||
fileFieldName = "file"
|
||||
): Promise<{ body: Uint8Array; contentType: string }> {
|
||||
): Promise<{ body: Uint8Array<ArrayBuffer>; contentType: string }> {
|
||||
const boundary = "----OmniRouteAudioBoundary" + Date.now().toString(36);
|
||||
const parts: Uint8Array[] = [];
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DesignerWebStepResult | { done: true; success: false; status: 504; error: string }> {
|
||||
): Promise<DesignerWebOutcome> {
|
||||
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,
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, unknown>>,
|
||||
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<string, unknown> = {
|
||||
@@ -735,13 +741,19 @@ export function buildAdobeImagePayload(opts: {
|
||||
parameters: { addWatermark: false },
|
||||
aspectRatio: ratio,
|
||||
},
|
||||
referenceBlobs: [],
|
||||
referenceBlobs: [] as Array<Record<string, unknown>>,
|
||||
};
|
||||
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<string, string> {
|
||||
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<string, unknown>;
|
||||
const po =
|
||||
b.provider_options && typeof b.provider_options === "object" && !Array.isArray(b.provider_options)
|
||||
? (b.provider_options as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
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<string, unknown>;
|
||||
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<string, unknown>).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<string, unknown>).content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== "object") continue;
|
||||
const p = part as Record<string, unknown>;
|
||||
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<string, unknown>).images;
|
||||
if (Array.isArray(images) && images.length > 0) {
|
||||
const first = images[0];
|
||||
if (first && typeof first === "object") {
|
||||
const id = (first as Record<string, unknown>).id;
|
||||
if (typeof id === "string" && id.trim()) return id.trim();
|
||||
}
|
||||
}
|
||||
const id = (body as Record<string, unknown>).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<string> {
|
||||
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<string[]> {
|
||||
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) {
|
||||
|
||||
42
open-sse/services/antigravityProjectPersist.ts
Normal file
42
open-sse/services/antigravityProjectPersist.ts
Normal file
@@ -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<string, unknown> | null
|
||||
): Promise<void> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,7 @@ import {
|
||||
shouldSkipForPredictedTtft,
|
||||
shouldRecordProviderBreakerFailure,
|
||||
isRequestScopedUpstreamFailure,
|
||||
isInputBoundRequestFailure,
|
||||
shouldSkipConnDisable,
|
||||
resolveDelayMs,
|
||||
comboModelNotFoundResponse,
|
||||
@@ -166,7 +167,21 @@ 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";
|
||||
@@ -301,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<string, unknown>;
|
||||
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<string, unknown>)) {
|
||||
if (!windowInfo || typeof windowInfo !== "object") continue;
|
||||
const percentUsed = Number((windowInfo as Record<string, unknown>).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<string, unknown> | 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,
|
||||
@@ -681,48 +638,6 @@ async function isPinnedModelDurablyUnhealthy(pinnedModel: string): Promise<boole
|
||||
// output limits, so the request should fall through to the next target instead of
|
||||
// being short-circuited. Exported as pure predicates so the guard is unit-testable.
|
||||
/** @param {string} errorText */
|
||||
export function isContextOverflow400(errorText) {
|
||||
return (
|
||||
/\bcontext.*(?:length_exceeded|too long|overflow|exceeded|window|limit)\b/i.test(errorText) ||
|
||||
/exceeds.*context/i.test(errorText) ||
|
||||
/your input exceeds/i.test(errorText) ||
|
||||
// Reuse accountFallback.ts's CONTEXT_OVERFLOW_PATTERNS (single source of truth)
|
||||
// so wording like Kimi's "exceeded model token limit" — which never says the
|
||||
// literal word "context" — is still recognized as an overflow/fallback-worthy
|
||||
// 400 instead of halting the whole combo (issue #6637).
|
||||
CONTEXT_OVERFLOW_PATTERNS.some((p) => 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({
|
||||
@@ -1804,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<string, unknown>) } as typeof body;
|
||||
|
||||
// Proactive Context Compression for fallbacks (Zero-Latency optimization)
|
||||
if (
|
||||
@@ -1819,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(
|
||||
@@ -2271,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,
|
||||
@@ -2709,63 +2655,63 @@ export async function handleComboChat({
|
||||
: "";
|
||||
const msg = (lastError || "All combo models unavailable") + comboErrorSummary;
|
||||
|
||||
if (earliestRetryAfter && isRetryAfterEligibleStatus(status)) {
|
||||
// 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);
|
||||
@@ -2924,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");
|
||||
@@ -3182,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<string, unknown>) } as typeof body;
|
||||
{
|
||||
const bodyRecord = body as Record<string, unknown>;
|
||||
const bodyRecord = attemptBody as Record<string, unknown>;
|
||||
const currentMaxTokens = toPositiveInteger(bodyRecord.max_tokens);
|
||||
const bufferedMaxTokens = resolveReasoningBufferedMaxTokens(
|
||||
modelStr,
|
||||
@@ -3196,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}`
|
||||
|
||||
@@ -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.
|
||||
@@ -196,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
|
||||
@@ -301,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<string, unknown>;
|
||||
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<string, unknown>)) {
|
||||
if (!windowInfo || typeof windowInfo !== "object") continue;
|
||||
const percentUsed = Number((windowInfo as Record<string, unknown>).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<string, unknown> | 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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<ComboCooldownWaitSettings, "enabled">
|
||||
): boolean {
|
||||
return (strategy === "quota-share" || strategy === "auto") && comboCooldownWait.enabled;
|
||||
return comboCooldownWait.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, number> = {
|
||||
@@ -14,6 +15,10 @@ const DEFAULT_LIMITS: Record<string, number> = {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -146,7 +151,10 @@ function extractImageTokens(node: unknown, seen: Set<unknown>): { node: unknown;
|
||||
|
||||
const record = node as Record<string, unknown>;
|
||||
if (isInlineBase64ImageBlock(record)) {
|
||||
return { node: { __image_token_estimate__: IMAGE_TOKEN_ESTIMATE }, tokens: IMAGE_TOKEN_ESTIMATE };
|
||||
return {
|
||||
node: { __image_token_estimate__: IMAGE_TOKEN_ESTIMATE },
|
||||
tokens: IMAGE_TOKEN_ESTIMATE,
|
||||
};
|
||||
}
|
||||
|
||||
let tokens = 0;
|
||||
@@ -173,8 +181,10 @@ export function estimateTokens(text: string | object | null | undefined): number
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
||||
}
|
||||
const { node, tokens: imageTokens } = extractImageTokens(text, new Set());
|
||||
const str = JSON.stringify(node);
|
||||
return Math.ceil(str.length / CHARS_PER_TOKEN) + imageTokens;
|
||||
// #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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,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);
|
||||
@@ -213,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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<result>
|
||||
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<string, RotationEntry>();
|
||||
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<string | null | undefined>;
|
||||
};
|
||||
const casGuardStore = new AsyncLocalStorage<CasGuard>();
|
||||
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<T>(
|
||||
guard: CasGuard | undefined | null,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
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<boolean> {
|
||||
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
|
||||
@@ -485,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).
|
||||
@@ -829,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<object|null>} Token result or null if all retries fail
|
||||
*/
|
||||
|
||||
// ─── Circuit Breaker State ──────────────────────────────────────────────────
|
||||
const _circuitBreaker: Record<string, { failures: number; blockedUntil: number }> = {};
|
||||
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).
|
||||
@@ -886,114 +722,3 @@ export function getConnectionRefreshMutexStatus(): Record<string, { waiters: num
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit breaker status for all providers (for diagnostics).
|
||||
*/
|
||||
export function getCircuitBreakerStatus(): Record<string, CircuitBreakerStatusEntry> {
|
||||
const result: Record<string, CircuitBreakerStatusEntry> = {};
|
||||
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<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T | null> {
|
||||
return await new Promise<T | null>((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;
|
||||
}
|
||||
|
||||
84
open-sse/services/tokenRefresh/casGuard.ts
Normal file
84
open-sse/services/tokenRefresh/casGuard.ts
Normal file
@@ -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<string | null | undefined>;
|
||||
};
|
||||
const casGuardStore = new AsyncLocalStorage<CasGuard>();
|
||||
const casGuardStats = { skipped: 0, persisted: 0 };
|
||||
|
||||
export function runWithCasGuard<T>(
|
||||
guard: CasGuard | undefined | null,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
168
open-sse/services/tokenRefresh/circuitBreaker.ts
Normal file
168
open-sse/services/tokenRefresh/circuitBreaker.ts
Normal file
@@ -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<string, { failures: number; blockedUntil: number }> = {};
|
||||
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<string, CircuitBreakerStatusEntry> {
|
||||
const result: Record<string, CircuitBreakerStatusEntry> = {};
|
||||
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<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T | null> {
|
||||
return await new Promise<T | null>((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<object|null>} 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;
|
||||
}
|
||||
85
open-sse/services/tokenRefresh/rotationMap.ts
Normal file
85
open-sse/services/tokenRefresh/rotationMap.ts
Normal file
@@ -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<string, RotationEntry>();
|
||||
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();
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/<provider>.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<string, unknown>;
|
||||
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<string, UsageQuota> = {};
|
||||
|
||||
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<string, unknown>
|
||||
) {
|
||||
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<string, UsageQuota> = {};
|
||||
|
||||
// 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<string, UsageQuota> = {};
|
||||
|
||||
// $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<string, UsageQuota> = {};
|
||||
|
||||
// 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<string, UsageQuota> = {};
|
||||
|
||||
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<string, UsageQuota> = {};
|
||||
|
||||
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<string, UsageQuota>;
|
||||
} {
|
||||
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<string, UsageQuota> = {};
|
||||
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,
|
||||
|
||||
50
open-sse/services/usage/bailian.ts
Normal file
50
open-sse/services/usage/bailian.ts
Normal file
@@ -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<string, unknown>
|
||||
) {
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
123
open-sse/services/usage/crof.ts
Normal file
123
open-sse/services/usage/crof.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
// 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<string, UsageQuota> = {};
|
||||
|
||||
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 };
|
||||
}
|
||||
62
open-sse/services/usage/deepseek.ts
Normal file
62
open-sse/services/usage/deepseek.ts
Normal file
@@ -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<string, UsageQuota> = {};
|
||||
|
||||
// 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}` };
|
||||
}
|
||||
}
|
||||
230
open-sse/services/usage/github.ts
Normal file
230
open-sse/services/usage/github.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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<string, UsageQuota> = {};
|
||||
|
||||
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<string, UsageQuota> = {};
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
96
open-sse/services/usage/nanogpt.ts
Normal file
96
open-sse/services/usage/nanogpt.ts
Normal file
@@ -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<string, UsageQuota> = {};
|
||||
|
||||
// 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}` };
|
||||
}
|
||||
}
|
||||
86
open-sse/services/usage/opencode.ts
Normal file
86
open-sse/services/usage/opencode.ts
Normal file
@@ -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<string, UsageQuota> = {};
|
||||
|
||||
// $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)}` };
|
||||
}
|
||||
}
|
||||
145
open-sse/services/usage/qoder.ts
Normal file
145
open-sse/services/usage/qoder.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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<string, UsageQuota>;
|
||||
} {
|
||||
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<string, UsageQuota> = {};
|
||||
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);
|
||||
}
|
||||
61
open-sse/services/usage/vertex.ts
Normal file
61
open-sse/services/usage/vertex.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
/**
|
||||
* 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}` };
|
||||
}
|
||||
}
|
||||
53
open-sse/services/usage/xai.ts
Normal file
53
open-sse/services/usage/xai.ts
Normal file
@@ -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}` };
|
||||
}
|
||||
}
|
||||
51
open-sse/services/usage/xiaomi-mimo.ts
Normal file
51
open-sse/services/usage/xiaomi-mimo.ts
Normal file
@@ -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}` };
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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);
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
127
open-sse/utils/jsonSize.ts
Normal file
127
open-sse/utils/jsonSize.ts
Normal file
@@ -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<object>());
|
||||
}
|
||||
|
||||
function lengthOf(value: unknown, seen: Set<object>): 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<string, unknown>)[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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { jsonLength } from "./jsonSize.ts";
|
||||
import { getRegistryEntry } from "../config/providerRegistry.ts";
|
||||
|
||||
type StreamReadinessBody = Record<string, unknown> | 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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"`);
|
||||
}
|
||||
|
||||
@@ -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/<locale>.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() {
|
||||
|
||||
127
scripts/i18n/glossary-normalize.mjs
Normal file
127
scripts/i18n/glossary-normalize.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* OmniRoute — shared glossary terminology normalization.
|
||||
*
|
||||
* Single implementation of the "canonical term" rules declared in
|
||||
* scripts/i18n/glossary/<locale>.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/<locale>.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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user