mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
Merge remote-tracking branch 'origin/release/v3.8.50' into HEAD
This commit is contained in:
@@ -52,8 +52,11 @@ export class ServerSupervisor {
|
||||
// silently, so a boot that never becomes ready looked like a dead hang with zero
|
||||
// output even at APP_LOG_LEVEL=debug. Pipe stdout too and buffer it alongside
|
||||
// stderr so a readiness timeout can surface what the child actually printed.
|
||||
// #9156: macOS launchd cannot resolve bare "node" because its PATH is
|
||||
// minimal. Always use process.execPath (the absolute path to the running
|
||||
// Node.js binary) so the supervisor never depends on PATH resolution.
|
||||
this.child = spawn(
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
process.execPath,
|
||||
process.versions.bun
|
||||
? [this.serverPath]
|
||||
: buildNodeRuntimeArgs(process.env, this.memoryLimit, this.serverPath),
|
||||
|
||||
1
changelog.d/fixes/7754-best-free-fallback.md
Normal file
1
changelog.d/fixes/7754-best-free-fallback.md
Normal file
@@ -0,0 +1 @@
|
||||
- test(combo): guard auto/best-free never leaks the combo name as a model (#7754)
|
||||
1
changelog.d/fixes/8847-bun-prebuilds.md
Normal file
1
changelog.d/fixes/8847-bun-prebuilds.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(build): include better-sqlite3 prebuilds in standalone bun bundle
|
||||
1
changelog.d/fixes/8906-quota-pool-combo-cleanup.md
Normal file
1
changelog.d/fixes/8906-quota-pool-combo-cleanup.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(quota):** Deleting a quota pool now removes its scoped managed combos without racing in-flight pool mutations ([#8906](https://github.com/diegosouzapw/OmniRoute/pull/8906)) — thanks @xiaoyaner0201
|
||||
1
changelog.d/fixes/9140-fix.plan.md
Normal file
1
changelog.d/fixes/9140-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(vscode): allow built-in auto-routing models in VS Code model filter (#9140)
|
||||
1
changelog.d/fixes/9142-fix.plan.md
Normal file
1
changelog.d/fixes/9142-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(background): detect Anthropic top-level system prompts for background task detection (#9142)
|
||||
1
changelog.d/fixes/9156-macos-autostart-execpath.md
Normal file
1
changelog.d/fixes/9156-macos-autostart-execpath.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(cli): use process.execPath for macOS launchd autostart
|
||||
1
changelog.d/fixes/9160-fix.plan.md
Normal file
1
changelog.d/fixes/9160-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(model-discovery): ingest capabilities.effort_tiers for synced models (#9160)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(translator): buffer and normalize upstream tool-call argument deltas so optional null values are stripped before reaching the client (#9168)
|
||||
1
changelog.d/fixes/9177-fix.plan.md
Normal file
1
changelog.d/fixes/9177-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(translator): avoid double-normalizing tool names in Gemini-to-Claude response path (#9177)
|
||||
1
changelog.d/fixes/9305-fix.plan.md
Normal file
1
changelog.d/fixes/9305-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): broaden OMNIROUTE_SSE_COMMENTS to accept 'false','0','no' and gate metadata comment emission (#9305)
|
||||
1
changelog.d/fixes/9486-claude-400-quota.md
Normal file
1
changelog.d/fixes/9486-claude-400-quota.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): classify 400 out of extra usage as quota_exhausted for Anthropic OAuth
|
||||
1
changelog.d/fixes/9623-connection-test-recovery.md
Normal file
1
changelog.d/fixes/9623-connection-test-recovery.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(resilience): failed connection test now sets a short cooldown so connections recover after transient outages (#9623)
|
||||
1
changelog.d/fixes/9624-telemetry-cleanup-wiring.md
Normal file
1
changelog.d/fixes/9624-telemetry-cleanup-wiring.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): wire telemetry cleanup scheduler in Next.js startup path (#9624)
|
||||
1
changelog.d/fixes/9625-domain-cost-ms.md
Normal file
1
changelog.d/fixes/9625-domain-cost-ms.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): align domain_cost_history cleanup cutoff with millisecond column (#9625)
|
||||
1
changelog.d/fixes/9626-playground-errors.md
Normal file
1
changelog.d/fixes/9626-playground-errors.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(playground): surface provider model loading errors and offer retry (#9626)
|
||||
1
changelog.d/fixes/9633-npm-build-files.md
Normal file
1
changelog.d/fixes/9633-npm-build-files.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(build): add build-next-isolated.mjs sibling imports to package.json files array
|
||||
@@ -408,7 +408,8 @@
|
||||
"src/lib/tokenHealthCheck.ts": 1053,
|
||||
"open-sse/executors/default.ts": 1042,
|
||||
"open-sse/executors/kiro.ts": 1069,
|
||||
"open-sse/translator/request/openai-to-kiro.ts": 1057
|
||||
"open-sse/translator/request/openai-to-kiro.ts": 1057,
|
||||
"open-sse/utils/sseHeartbeat.ts": 149
|
||||
},
|
||||
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
|
||||
"_rebaseline_2026_07_27_v3849_train3": "Merge-train 3 (13 PRs) — owner-approved 2026-07-27. Both entries are genuine irreducible growth at existing chokepoints, not new branches: src/lib/db/apiKeys.ts 1518->1529 (#8805 cx/* ≡ codex/* API-key model permissions); open-sse/handlers/chatCore.ts 5006->5020 (#8806 real response payload into plugin onResponse hooks). Covered by tests/unit/db-apiKeys-crud.test.ts (4 new cases) and the two plugin-hook test files updated in #8806 respectively.",
|
||||
@@ -432,5 +433,131 @@
|
||||
"_rebaseline_2026_08_06_v3850_inherited_drift_reconcile": "Reconciliacao 2026-08-06 do drift ACUMULADO da release/v3.8.50 apos o lote de merges de 08-05/06: 13 arquivos acima do frozen no tip puro 8180b49ce1 (medidos pelo proprio gate). O modo PR base-relative (#8522) deixa PRs inocentes passarem, e os rebaselines individuais dos PRs se perderam nas resolucoes sucessivas de conflito deste hot-file — o drift so aparece no modo absoluto (nightly/local). Crescimentos funcionais dos PRs mergeados: #9024 topology click-nav src/app/(dashboard)/dashboard/HomePageClient.tsx; #9324 OpenRouter enrich src/app/(dashboard)/dashboard/providers/page.tsx; #9329 quota card ordering src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx; #9193 context-window suffixes src/sse/handlers/chat.ts; #9332 nested Claude server tool ids open-sse/executors/base.ts; #9228 strip orphaned tool outputs open-sse/executors/codex.ts; #9236 nvidia tool-name normalize open-sse/executors/default.ts; #9314 nested tool_call validation open-sse/executors/kiro.ts; #9260 caller identity REST hops open-sse/mcp-server/server.ts; #8934 cache breakpoints tests tests/unit/chatcore-translation-paths.test.ts; #9193 suffix tests tests/unit/combo-routing-engine.test.ts; #9196 reasoning-on-tool-finish tests tests/unit/sse-auth.test.ts; #9163 GPT-5.6 Max reasoning tests tests/unit/translator-openai-to-kiro.test.ts. default.ts e kiro.ts entram no frozen (estavam sem entrada, acima do cap 1000). Atualizacao pos-medicao (a base avancou durante o ciclo do PR): src/sse/handlers/chat.ts 1857->1877 (#9184 affinity EOF evict) e open-sse/executors/default.ts 1027->1042 (#9005 Kimi K3 tool-name backfill).",
|
||||
"_rebaseline_2026_08_06b_v3850_sweepreds_drift": "Segunda reconciliacao de 2026-08-06 (/sweep-reds sobre o tip puro 2ddbbc61a6): 3 arquivos voltaram a passar do frozen apos os merges do mesmo dia, com atribuicao 1:1 por commit. (1) src/app/(dashboard)/dashboard/providers/page.tsx 1928->1944 e (2) open-sse/executors/base.ts 1635->1640, ambos do #9515 (feat(radar): flag-gated signed free-model catalog overlay, commit e7f6b1d130) — o overlay do Radar entra por wiring nos chokepoints ja existentes (a resolucao/verificacao do catalogo assinado mora fora destes dois arquivos); +16 e +5 linhas liquidas nao sao extraiveis sem inventar um leaf por callsite. (3) open-sse/services/accountFallback.ts 1966->1972 do #8704 (commit c4527f97bd), +6 linhas de dados em CREDITS_EXHAUSTED_SIGNALS ('has been exhausted', fixes #8631). src/sse/handlers/chat.ts 1880>1877 tambem estava violando e NAO entra aqui de proposito: e drenado por encolhimento na PR #9598, sem rebaseline. Crescimento proprio DESTA PR: src/lib/db/migrationRunner.ts 1077->1084 (+7) — o guard retroativo em isSchemaAlreadyApplied para os arquivos renumerados 137/138, exigido pela propria mensagem de erro de colisao do runner (ambas as migracoes sao ALTER TABLE ADD COLUMN puro, nao idempotente). Dois `case` + dois `return hasColumn(...)` + 3 linhas de comentario dentro do switch existente; nao extraivel.",
|
||||
"_rebaseline_2026_08_06c_v3850_sweepreds_pr2": "Segunda PR do /sweep-reds (fix/release-v3.8.50-basereds-0806b): tests/unit/provider-models-route.test.ts 1784->1787 (medido pelo gate, que conta split(\"\\n\").length) (+2 apos compressao de comentarios) — alinhamento de contrato forcado por dois merges do dia: #9106 tornou gemini-3.1-pro-high user-callable (a entry do alias entra na lista esperada do teste de discovery-retry, +1 linha de dado + 1 de comentario) e ff012ff420 adicionou onboardUser como bootstrap hop (exclusao no mock, ja comprimida a 1 linha). Nao ha o que encolher sem apagar o comentario que explica o porque.",
|
||||
"_rebaseline_2026_08_07_v3850_sweepreds_pr2_toolnamemap": "tests/unit/translator-openai-to-gemini.test.ts 1616->1619 (+3). O frozen estava EXATAMENTE no tamanho da base, entao qualquer linha nova viola. #9568 (c9a3361e5a) fez buildChangedToolNameMap emitir entradas IDENTIDADE (o Gemini minusculiza nomes de tool nas respostas, entao o tradutor de resposta precisa da chave para mapear de volta), o que passou a incluir `_toolNameMap` no envelope Antigravity de qualquer request com tools. As 3 linhas sao: a chave nova na lista esperada de Object.keys, 1 comentario explicando POR QUE ela aparece (sem ele o proximo leitor tenta remove-la de novo) e 1 assert do CONTEUDO do map — presenca de chave sozinha nao provaria a entrada identidade, que e justamente o comportamento novo. Nao ha o que extrair: e alinhamento de contrato dentro de um teste existente."
|
||||
"_rebaseline_2026_08_07_v3850_sweepreds_pr2_toolnamemap": "tests/unit/translator-openai-to-gemini.test.ts 1616->1619 (+3). O frozen estava EXATAMENTE no tamanho da base, entao qualquer linha nova viola. #9568 (c9a3361e5a) fez buildChangedToolNameMap emitir entradas IDENTIDADE (o Gemini minusculiza nomes de tool nas respostas, entao o tradutor de resposta precisa da chave para mapear de volta), o que passou a incluir `_toolNameMap` no envelope Antigravity de qualquer request com tools. As 3 linhas sao: a chave nova na lista esperada de Object.keys, 1 comentario explicando POR QUE ela aparece (sem ele o proximo leitor tenta remove-la de novo) e 1 assert do CONTEUDO do map — presenca de chave sozinha nao provaria a entrada identidade, que e justamente o comportamento novo. Nao ha o que extrair: e alinhamento de contrato dentro de um teste existente.",
|
||||
"_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.",
|
||||
"_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\\\"tool\\\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.",
|
||||
"_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \\\"headroom\\\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, <cap) and the async orderer orderTargetsByHeadroom is appended to the existing open-sse/services/combo/quotaStrategies.ts (<cap) next to its sibling reset-aware/reset-window orderers (reuses their connection-expansion machinery). headroom = 1 - max(util_5h, util_7d) from getSaturation (src/lib/quota/saturationSignals.ts), prefers the connection with the most free capacity. Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors the reset-aware/reset-window/context-optimized branches); not extractable without hiding the call site. fill-first stays default; all existing strategies untouched. Covered by tests/unit/combo-headroom-ranking.test.ts (pure helper) + tests/unit/combo-headroom-strategy.test.ts (orderer, saturation injected). Structural shrink of combo.ts tracked in #3501.",
|
||||
"_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_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_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.",
|
||||
"_rebaseline_2026_06_27_5193_antigravity_basered": "Base-red (pre-existing release drift, fast-gate PR->release skips check:file-size): accountFallback.ts 1773->1777 and src/app/api/providers/[id]/test/route.ts 924->940 were already over their frozen caps on release/v3.8.39 independent of any antigravity change. Owner chose to rebaseline (keep the documented issue-reference comments #1846/#1449/#347 etc.) rather than accept the contributor comment-stripping in #5200/#5198. Reverted #5200 to restore the comments; bumped these two frozen caps to the actual base sizes. No logic change.",
|
||||
"_rebaseline_2026_06_28_5237_impersonation_ua_refresh": "PR #5237 (refresh impersonation UAs): grok-web.ts 1871->1873 (+2), muse-spark-web.ts 1284->1302 (+18), perplexity-web.ts 1013->1032 (+19). Net semantic change in each file is a single User-Agent constant (Chrome 147->149 for grok/muse; perplexity kept at Firefox 148 to stay matched with the firefox_148 TLS profile — the contributor's 152 bump was reverted to avoid a UA-vs-JA3 mismatch, #2459). The growth is Prettier reflow that lint-staged unavoidably applies to these grandfathered long-line files the moment they are touched; not extractable. src/sse/services/auth.ts 2336->2401 in the same reconcile is #5222's antigravity-LRU-retry growth that merged via --admin without a baseline bump.",
|
||||
"_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_28_5275_correlation_id_extract": "Extraction of the safe CorrelationId subset of #5275 (hartmark) — request correlation id stored in call_logs (migration 109) and returned via the X-Correlation-Id response header, WITHOUT the combo/resilience or build/lazy-loading changes (those stay in #5275). Own growth: callLogs.ts 975->985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 791<cap pre-feature). Cohesive request/logging chokepoint wiring; structural shrink of chat.ts tracked in #3501.",
|
||||
"_rebaseline_2026_06_29_4038_cas_guard": "PR (#4038) own growth: tokenRefresh.ts 2103->2181 (+78 = the compare-and-swap guard on the refresh persist — runWithCasGuard/getActiveCasGuard AsyncLocalStorage pair mirroring runWithOnPersist, casGuardShouldSkipPersist that rereads the row right before persisting and skips the write when a concurrent writer already rotated the refresh_token past the one presented, plus getCasGuardStats counters). Fixes the sibling-rotation-revert → token-family-revocation storm. Gated behind an active guard (opt-in; no guard => byte-identical). Wiring lives at the two persist chokepoints inside getAccessToken; the comparison reuses wasRefreshTokenRotated from refreshSerializer. Not extractable without splitting the refresh hot path.",
|
||||
"_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.",
|
||||
"_rebaseline_2026_07_01_v3843_release_5609": "Rebaseline v3.8.43 (PR #5609 release reconciliation). DRIFT dos 109 commits do ciclo: 8 god-files existentes cresceram (ApiManagerPageClient 2983->3017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui.",
|
||||
"_rebaseline_2026_07_02_5816_qoder": "PR #5816 (@AgentKiller45, qoder PAT via qodercli): qoderCli.ts 666->989, new-above-cap frozen (owner-approved baseline freeze). The growth is the legitimate PAT job-token exchange + quota parsing CLI transport (the pure-JS Cosy path 500'd on every PAT request); extracting the spawn/parse helpers now would just add indirection to a contributor PR mid-merge. Test frozen also raised for this PR's coverage growth: providers-page-utils.test.ts 1052->1092. Additionally clears an inherited base-red from the already-merged #5933 (codex json_schema->text.format): translator-openai-responses-req.test.ts 1097->1172 (+75 regression tests, no offending branch left). All remain frozen (cannot grow further); release captain's rebaseline-at-release supersedes.",
|
||||
"_rebaseline_2026_07_09_6126_clinepass_dual_auth": "PR #6126 (@hajilok, dual-auth ClinePass) own growth: tokenRefresh.ts 2181->2182 (+1 = a single `case \\\"clinepass\\\":` fallthrough label added to the existing `case \\\"cline\\\":` in _getAccessTokenInternal's provider switch, so clinepass token refresh dispatches to the already-shared refreshClineToken() instead of silently falling through to the generic OAuth refresh). Irreducible 1-line switch-case wiring at the existing chokepoint; the header-building logic for the same feature was extracted to a new leaf src/shared/utils/clineAuth.ts::buildClinepassHeaders() (well under cap) to avoid growing open-sse/executors/default.ts. Covered by tests/unit/clinepass-provider.test.ts.",
|
||||
"_rebaseline_2026_07_09_6363_kiro_external_idp": "PR #6363 (@artickc, Kiro external IdP) own growth: tokenRefresh.ts 2182->2249 (+67 = the external_idp refresh branch inside refreshKiroToken — standard public-client OAuth2 refresh_token grant against the org IdP tokenEndpoint via buildExternalIdpRefreshParams/isExternalIdpAuthMethod from the new leaf open-sse/services/kiroExternalIdp.ts, with invalid_grant/invalid_client -> unrecoverable_refresh_error mapping). Cohesive addition at the existing refreshKiroToken chokepoint. Covered by tests/unit/kiro-external-idp.test.ts.",
|
||||
"_rebaseline_2026_07_09_6587_kiro_api_key_auth": "PR #6587 (@strangersp) own growth for Kiro long-lived API-key auth, merged onto v3.8.47 tip: openai-to-kiro.ts 890->912 (+22, auth-header selection for API-key-vs-OAuth-token connections), providerLimits.ts 998->1000 (+2, API-key auth-type branch), translator-openai-to-kiro.test.ts 1234->1257 (+23), providers-page-utils.test.ts 1109->1107 (net -2 after merging with parallel release drift; connectionMatchesProviderCard api_key coverage added), provider-validation-specialty.test.ts 2856->2980 (+124 net after merge with parallel release drift; this PR also removed the file's `@typescript-eslint/no-explicit-any` eslint-suppression entry by fixing all `any` usages, adding typed replacements). Cohesive additive feature growth, well tested; not extractable without splitting the existing chokepoints mid-merge.",
|
||||
"_rebaseline_2026_07_09_6678_routing_strategy_9router": "#6678 (SeaXen) — 9router-parity Routing Strategy settings card + per-provider/combo sticky-round-robin override. Own growth: ProviderDetailPageClient.tsx 784->786 (single ProviderAccountRoutingCard mount + import), auth.ts 2448->2458 (providerStrategies override resolution: fallbackStrategy/stickyRoundRobinLimit per-provider cascade in getProviderCredentials). Both additive, zero unrelated refactor; new UI/logic lives in new files (ProviderAccountRoutingCard.tsx, RoutingStrategyCard.tsx, rrState.ts::resolveComboStickyRoundRobinLimit). chat.ts value below reflects the current release tip (grown by other concurrent PRs, e.g. #6640), not this PR own change.",
|
||||
"_rebaseline_2026_07_10_6318_omp_letta": "PR #6318 (@hamsa0x7, omp+letta CLI integrations) own growth: cliTools.ts (+53 = 2 registry entries incl. omp docsUrl) and cliRuntime.ts (+18 = runtime-detection wiring for the 2 new tools). Cohesive registry/wiring growth at the existing chokepoints; scope reduced from the original 5 tools (pi/codewhale/jcode shipped separately).",
|
||||
"_rebaseline_2026_07_10_gcf_v3_2_decode": "PR #6838 own growth: new vendored file open-sse/services/compression/engines/headroom/gcf/decode_generic.ts frozen at 880 (> 800 cap). It is the vendored GCF generic-profile decoder (spec v3.2 nested flattening plus the prototype-pollution / hasOwnProperty hardening added in this PR's Gemini review). Kept as one file faithful to upstream gcf-typescript so re-vendoring stays a clean copy rather than a re-split each cycle (sibling generic.ts/scalar.ts stay < cap; extraction would also fragment the file's frozen eslint no-explicit-any suppressions). Round-trip + prototype-pollution regression coverage in tests/unit/compression/headroom-smartcrusher.test.ts. Frozen: only shrinks from here.",
|
||||
"_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail (owner-approved): src/lib/localDb.ts NEW>800 (799->805, +6 re-exports countFreeProxies + recordFreeProxySyncErrors/clearFreeProxySyncErrors/getFreeProxySyncErrors + FreeProxySyncErrors type for #6909 free-pool relay-repair; re-export-only per Hard Rule #2, not extractable).",
|
||||
"_rebaseline_2026_07_15_7070_combos_memo": "PR #7070 (perf/p1-memo) own growth: src/app/(dashboard)/dashboard/combos/page.tsx 4655->4656 (+1 = React.memo wrapping of ComboCard). Covered by tests/unit/ui/combos-page-smoke.test.tsx.",
|
||||
"_rebaseline_2026_07_18_7399_xai_oauth_modal": "PR #7399 (xAI OAuth PKCE) own growth: OAuthModal.tsx 993->998 (+5 = provider entry + PKCE flow branch wiring at the existing provider-switch chokepoint; the provider logic itself lives in src/lib/oauth/providers/xai-oauth.ts, new leaf). Third irreducible wiring bump on this modal (969->989->993->998); structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_07_19_6636_codex_session_json": "#6636 own growth: OAuthModal.tsx 998->1030 (gate units, split(\\\"\\\\n\\\").length incl. trailing newline; +32 = session-JSON paste branch for handleManualSubmit plus a shared submitCodexAccessToken() helper extracted from the pre-existing bare-JWT branch, mirroring the #5203 oauthBlobSubmit.ts extraction precedent; the normalizer logic itself lives in the new src/lib/oauth/utils/codexSessionImport.ts leaf module, not here). Fourth irreducible wiring bump on this modal (969->989->993->998->1030); structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_07_19_7546_ghe_copilot_modal": "PR #7546 (GHE Copilot OAuth provider) own growth: OAuthModal.tsx 1030->1056 (gate units). Adds a gheUrl input state, routes ghe-copilot through the existing device-code branch, and threads gheUrl into the device-code request/poll extraData at the existing provider-switch chokepoints (+~24 lines, cohesive with the same pattern as #7399/#6636). The standalone GHE enterprise-URL config step JSX (originally +31 lines inline) was extracted to the new src/shared/components/oauthModal/GheConfigStep.tsx leaf component to minimize the bump; what remains is the irreducible provider-branch wiring. Fifth bump on this modal (969->989->993->998->1030->1056); structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_07_19_7787_ic2_localdb_reexports": "PR #7787 (IC2 raw connections cache + lazy-decrypt) own growth: localDb.ts 805->807 (gate units, +2). localDb.ts is the re-export-only layer (hard rule #2 — no logic); the PR adds 4 new db/readCache re-exports (touchConnectionLastUsed, getCachedRawProviderConnections, getCachedProviderConnectionById, getCachedProviderNodes) required by existing barrel importers. Irreducible for a re-export list; frozen so it can only shrink.",
|
||||
"_rebaseline_2026_07_20_7779_routingcombo_thread": "PR #7779 own growth: chatHelpers.ts 876->877 (+1, thread routingComboId into executeChatWithBreaker for compression-combo assignment). Frozen so it can only shrink.",
|
||||
"_rebaseline_2026_07_20_7819_autocandidateoverrides_reexport": "PR for #7819 (Level 1+2: read-only auto/* candidate transparency + per-API-key exclusions) own growth: localDb.ts 807->808 (+1). Adds a single `export * from \\\"./db/autoCandidateOverrides\\\"` barrel re-export (hard rule #2 — no logic) for the new DB module backing per-apiKey candidate exclusions. Irreducible for a re-export list; frozen so it can only shrink.",
|
||||
"_rebaseline_2026_07_21_8027_grok_cli_auth_json_paste": "PR #8027 (RaviTharuma, fix(grok-cli) #7610) own growth: OAuthModal.tsx 1080->1100 (gate units). Requires the full ~/.grok/auth.json (with refresh_token) on the paste-import path instead of a bare JWT, at the existing paste-token chokepoint (renamed tab label, updated instructions/placeholder, textarea for the auth.json blob, inline error surface). The validation logic itself (parseGrokCliPasteToken, previously an inline ~75-line function) was extracted to the new src/lib/oauth/utils/grokCliAuthJson.ts leaf module — mirroring the #6636/#7546 extraction precedent — so only the irreducible UI wiring remains here. Sixth bump on this modal (969->989->993->998->1030->1056->1100); structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_07_21_8034_compression_exclusions_sidebar": "#8034 (compression exclusions dashboard tab) own growth: sections.ts 796->806 (+10, one new COMPRESSION_CONTEXT_GROUP sidebar item linking /dashboard/compression/exclusions). The file was already 796/800 before this PR (organic growth from prior sidebar entries), so a single new nav item pushed it 6 lines over cap. Freezing at 806 (cannot grow further); the sidebar item array is data, not extractable logic.",
|
||||
"_rebaseline_2026_07_22_7936_namespace_roundtrip": "#7936 (@RCrushMe, Responses-Chat namespace round-trip identity seam) own growth: open-sse/translator/response/openai-responses.ts 1092->1125 (+33) and open-sse/utils/stream.ts 2814->2869 (+55) — threading the namespace-identity seam through the Responses↔Chat translation + stream paths so tool-call namespaces survive the round-trip. Cohesive translation/stream wiring at existing chokepoints, frozen at new size.",
|
||||
"_rebaseline_2026_07_22_8010_codex_responses_engine": "PR #8010 (@JxnLexn) own growth: open-sse/mcp-server/schemas/tools.ts 1497->1505 (+8 = threading the new \\\"codex-responses\\\" literal into the compressionConfigureInput strategy/autoTriggerMode Zod enums and setCompressionEngineInput engine enum, mirroring the existing rtk/omniglyph enum entries; no new tool). open-sse/services/compression/strategySelector.ts 1043->1054 (+11 = one new `if (mode === \\\"codex-responses\\\")` dispatch branch in runCompression that delegates 100% to the new codexResponsesEngine.apply, mirroring the existing rtk single-mode dispatch, plus threading config.codexResponsesConfig.preserveToolNames into the shared adaptBodyForCompression call at the 3 existing call sites). src/lib/db/compression.ts (untracked, new-file cap 800) 794->845 (+51 = normalizeCodexResponsesConfig, mirroring the existing normalizeRtkConfig normalizer, plus registering \\\"codex-responses\\\" in the COMPRESSION_MODES/STACKED_PIPELINE_ENGINE_IDS/SINGLE_MODE_ENGINE sets and the getCompressionSettings load/save switch) — added to the baseline at its current size. All three are cohesive dispatch/normalizer wiring at existing chokepoints (mirroring the prior compression-mode rebaselines #6534/#6556), not extractable without hiding the mode-dispatch boundary. Covered by tests/unit/compression/codex-responses.test.ts (6) + omniglyph-registries.test.ts/types.test.ts (22, updated for the new mode).",
|
||||
"_rebaseline_2026_07_22_8034_compression_exclusions_persistence": "#8034 (compression exclusions) own growth: src/lib/db/compression.ts 845->850 (+5 = threading the new compressionExclusions field through the existing getCompressionSettings/saveCompressionSettings load/save switch over the shared key_value compression namespace — no new table, no raw SQL). Mirrors the prior compression-field rebaselines (#8010 codex-responses normalizer at the same chokepoint); the load/save switch is a single dispatch boundary, not extractable without hiding it. Covered by the PR's 8 node:test + 3 vitest cases.",
|
||||
"_rebaseline_2026_07_22_8050_model_lockout_exact_family": "#8050 (@AndrianBalanescu) own growth: accountFallback.ts 1864->1892 (+28) — exact-vs-family model-lockout scoping (getModelLockKey/isModelLocked/clearModelLock/getModelLockoutInfo) so an Antigravity 404 for one bare model no longer hijacks the whole family cooldown. Cohesive lockout logic; frozen at new size.",
|
||||
"_rebaseline_2026_07_22_8081_reasoning_placeholder_guard": "#8081 (@Dingding-leo) own growth: openai-responses.ts 1125->1137 (+12) restructuring the reasoning-placeholder guard so it skips only the empty content block and still emits finish_reason/tool_calls in the same chunk. Cohesive translator wiring; frozen at new size.",
|
||||
"_rebaseline_2026_07_22_8210_openrouter_midstream_error": "PR #8210 (hartmark, fix/openrouter-midstream-error-surfacing) own growth: open-sse/translator/response/openai-responses.ts 1137->1163 (+26) measured on the merged tip (release 1137 + this PR own growth). Adds a single new branch inside openaiToOpenAIResponsesResponse() that detects an OpenRouter-style mid-stream aggregator error (HTTP 200 SSE chunk with empty choices + a top-level error object) and surfaces it as state.upstreamError instead of silently falling through to the no-op/awaitingTrailingUsage path, which previously masked the failure as a false empty-success completion and skipped combo fallback. Irreducible call-site addition at the existing chunk-dispatch chokepoint (mirrors the Gemini-to-OpenAI translator's #4177 precedent for the same class of upstream error surfacing). Note: this baseline entry does NOT cover the separate pre-existing +11 drift already on the release tip from #8081/#8162 (1125->1136, unrelated reasoning-placeholder-stripping fix merged after this PR branched) — that drift belongs to the maintainer's rebaseline, not this PR.",
|
||||
"_rebaseline_2026_07_22_8211_gemini_malformed_tool_choice": "PR #8211 (hartmark, fix/gemini-malformed-function-call-tool-choice) own growth: open-sse/translator/response/gemini-to-openai.ts 771->821 (+50, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Adds MALFORMED_FUNCTION_CALL/UNEXPECTED_TOOL_CALL handling inside geminiToOpenAIResponse(): synthesizes a `malformed_tool_call` tool_calls entry so finish_reason normalizes to the standard \\\"tool_calls\\\" instead of an unrecognized raw enum value that OpenAI-compatible clients (e.g. OpenClaw) silently ignore, and always synthesizes (rather than skipping when a real tool call already exists) so a malformed attempt alongside a real one in the same turn is not silently discarded. Irreducible cohesive addition at the existing candidate/finishReason translation chokepoint (mirrors the 9router#2462 raw-finish-reason precedent immediately below it in the same function). Covered by the PR's own tests/unit test additions for both the malformed-only and malformed-plus-real-call cases.",
|
||||
"_rebaseline_2026_07_22_8213_chat_abandoned_target_abort": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/sse/handlers/chat.ts 1794->1860 (+66, measured against the PR's own merge-base — the release tip separately carries an unrelated -5 net shrink from #8013's antigravity callable-catalog alignment, which this PR's branch does not include and this entry does not cover). Adds resolveDispatchClientRawRequest(): merges a per-target modelAbortSignal into clientRawRequest.signal (via mergeAbortSignals) so a combo target abandoned by comboTargetTimeoutMs actually observes its own abort and reaches its cleanup path, instead of hanging forever inside withRateLimit/acquireAccountSemaphore and leaking a permanent 'pending' dashboard entry (live incident, log id 1784418258231-14961a). Also wires combo-exhausted rejection logging to capture request body + attempted models via the new rejectedRequestUsage helper. Irreducible additions at the existing chat dispatch chokepoint. Covered by the PR's own combo-config + integration test additions.",
|
||||
"_rebaseline_2026_07_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.",
|
||||
"_rebaseline_2026_07_22_8213_gemini_tpm_quota_cooldown_wait": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: open-sse/services/accountFallback.ts 1857->1932 on the merged tip (release 1892 incl #8050 +35, plus this PR own growth +40); measured against the PR own merge-base was 1857->1898 (+41 — the release tip separately carries an unrelated +34 from #8050's antigravity 404 model-not-found lockout scoping, which this PR's branch does not include and this entry does not cover). Own growth is the Gemini TPM-ceiling classification + cooldown-wait wiring feeding into the combo cooldown-wait state machine (rate-limit wedge recovery) introduced by this PR's commit series. Irreducible additions at the existing account-fallback/model-lockout chokepoint. Covered by the PR's own gemini-rate-limit-tracker and TPM-ceiling benchmark test additions.",
|
||||
"_rebaseline_2026_07_22_8213_health_unblock_model_cooldowns": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/app/(dashboard)/dashboard/health/page.tsx 1094->1165 (+71, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Adds handleUnblockAll/handleUnblockOne dashboard actions (DELETE /api/resilience/model-cooldowns) so an operator can manually clear a Gemini TPM-wedge model lockout surfaced by this PR's cooldown-wait fixes, instead of waiting out the ceiling. Irreducible UI wiring at the existing health-page action chokepoint. Covered by the PR's own dashboard/resilience test additions.",
|
||||
"_rebaseline_2026_07_22_8213_requestloggerdetail_unblock_ui": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: src/shared/components/RequestLoggerDetail.tsx 799->941 (+142, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip; crosses the general 800-line new-file cap so is frozen here for the first time). Adds a collapsible section header (open/expand-less toggle) plus per-log-entry unblock (`unblocking`/`cleared` state, isCombo503 detection) so the request-logger detail panel surfaces the same Gemini TPM cooldown-wait / model-lockout unblock action introduced by this PR at the individual-request level (mirrors the health-page bulk unblock action added in the same PR). Covered by the PR's own dashboard/resilience test additions.",
|
||||
"_rebaseline_2026_07_22_fusion_8013_8098_antigravity": "Fusion of #8013 (backryun, catalog/IDE-CLI-split rewrite) + #8098 (nguyenha935, protocol-fidelity/fail-closed/credits/tool-cloaking): open-sse/services/usage/antigravity.ts NEW 802 (>cap 800, +2 — #8098 credits/tier usage service on #8013's profile-aware headers). Test growth (models-catalog-route 1605->1608, provider-models-route 1752->1757 from #8013 Gemini 3.6 catalog) tracked in testFrozen.",
|
||||
"_rebaseline_2026_07_23_8127_grok_weekly_quota": "#8127 (@apoapostolov) own growth: src/sse/handlers/chat.ts 1861->1865 (+4) — weekly quota tracking for grok-web wires a quota-fetch hook at the existing dispatch chokepoint. Thin wiring mirroring adjacent provider-quota branches; not extractable. Covered by tests/unit/grok-quota-fetcher.test.ts.",
|
||||
"_rebaseline_2026_07_23_8143_empty_catch_logging": "#8143 (@chirag127) own growth: open-sse/utils/stream.ts 2869->2887 (+18) — replacing empty catch blocks in the SSE stream subsystem with console.debug logging (Rule #6 silent-swallow fix, issues #8138-#8142). Cohesive logging additions at the existing catch chokepoints, not extractable; frozen at new size. Covered by tests/unit/stream-handler-catch-logging-8143.test.ts.",
|
||||
"_rebaseline_2026_07_23_8219_cache_ttl_settings_sidebar": "#8219 (@oyi77) own growth: sections.ts 806->813 (+7) — configurable model-catalog cache-TTL settings adds a new sidebar nav entry + its visibility wiring. Sidebar item array is data, not extractable logic; frozen at new size.",
|
||||
"_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.",
|
||||
"_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_8266_alibaba_media": "#8266 (@backryun) own growth: imageRegistry.ts 821->979 (+158) — Alibaba-family media models (Qwen image/video, Bailian, Wan) added to the image/video registry. Registry model data, not extractable logic; frozen at new size.",
|
||||
"_rebaseline_2026_07_24_8388_compression_detail_persist": "#8388 (compression engine DETAIL settings — Headroom/session-dedup/CCR — dropped on save) own growth: src/lib/db/compression.ts 866->872 (+6 = irreducible call-site wiring at the existing getCompressionSettings/updateCompressionSettings chokepoint: one import line, one `...buildDetailConfigDefaults()` spread in the seed config, and one `case \\\"sessionDedup\\\": case \\\"ccr\\\": applyDetailConfigUpdate(config, key, parsed); break;` load-switch case, mirroring the existing headroom/#8056 case immediately above it). The actual normalizer logic (normalizeSessionDedupConfig/normalizeCcrConfig, matching SESSION_DEDUP_SCHEMA/CCR_SCHEMA bounds) was EXTRACTED into a new leaf src/lib/db/compressionDetailNormalizers.ts (well under cap) so this frozen file only carries the minimal dispatch wiring. Covered by tests/unit/8388-compression-detail-persist.test.ts (schema-accept + full DB save->reload round-trip for both new sub-objects, plus a no-regression assertion on the existing headroom round-trip).",
|
||||
"_rebaseline_2026_07_24_responses_toolcalls_log_summary": "hartmark, fix/responses-tool-calls-log-summary own growth: open-sse/translator/response/openai-responses.ts 1163->1174 (+11). closeToolCall() now also writes the completed tool call into the shared state.toolCalls Map (already populated by the openai-to-claude / claude-to-openai / gemini-to-openai response translators) so stream.ts's completion-log summary builder (which reads state.toolCalls, not this translator's own funcCallIds/funcNames/funcArgsBuf bookkeeping) reports finish_reason \\\"tool_calls\\\" and message.tool_calls for openai->openai-responses translated streams instead of always logging \\\"stop\\\" with no tool_calls — the actual client-facing SSE events were already correct; only the persisted call-log summary was wrong. Irreducible call-site addition at the existing tool-call-close chokepoint. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts.",
|
||||
"_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).",
|
||||
"_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.",
|
||||
"_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).",
|
||||
"_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.",
|
||||
"open-sse/executors/antigravity.ts": "1528",
|
||||
"open-sse/executors/base.ts": "1640",
|
||||
"open-sse/executors/chatgpt-web.ts": "3241",
|
||||
"open-sse/executors/codex.ts": "1562",
|
||||
"open-sse/executors/cursor.ts": "1563",
|
||||
"open-sse/executors/deepseek-web.ts": "1148",
|
||||
"open-sse/executors/grok-web.ts": "1044",
|
||||
"open-sse/executors/muse-spark-web.ts": "1405",
|
||||
"open-sse/handlers/chatCore.ts": "5034",
|
||||
"open-sse/handlers/imageGeneration.ts": "3101",
|
||||
"open-sse/handlers/responseSanitizer.ts": "1128",
|
||||
"open-sse/handlers/search.ts": "1536",
|
||||
"open-sse/handlers/videoGeneration.ts": "1063",
|
||||
"open-sse/mcp-server/schemas/tools.ts": "1553",
|
||||
"open-sse/mcp-server/server.ts": "1448",
|
||||
"open-sse/mcp-server/tools/advancedTools.ts": "1120",
|
||||
"open-sse/services/accountFallback.ts": "1978",
|
||||
"open-sse/services/adobeFireflyClient.ts": "2385",
|
||||
"open-sse/services/claudeCodeCompatible.ts": "1202",
|
||||
"open-sse/services/combo.ts": "3648",
|
||||
"open-sse/services/compression/strategySelector.ts": "1060",
|
||||
"open-sse/services/rateLimitManager.ts": "1167",
|
||||
"open-sse/translator/response/openai-responses.ts": "1204",
|
||||
"open-sse/utils/cursorAgentProtobuf.ts": "1505",
|
||||
"open-sse/utils/stream.ts": "2889",
|
||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": "1388",
|
||||
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": "1031",
|
||||
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": "3117",
|
||||
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": "1067",
|
||||
"src/app/(dashboard)/dashboard/combos/page.tsx": "4703",
|
||||
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": "1283",
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": "1022",
|
||||
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": "2615",
|
||||
"src/app/(dashboard)/dashboard/health/page.tsx": "1165",
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": "1324",
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": "1944",
|
||||
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": "1201",
|
||||
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": "1019",
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": "1470",
|
||||
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": "1123",
|
||||
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": "1629",
|
||||
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": "1573",
|
||||
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": "1028",
|
||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": "2148",
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": "1119",
|
||||
"src/app/api/providers/[id]/models/route.ts": "2361",
|
||||
"src/app/api/v1/models/catalog.ts": "1590",
|
||||
"src/lib/tokenHealthCheck.ts": "1053",
|
||||
"src/lib/db/apiKeys.ts": "1529",
|
||||
"src/lib/db/core.ts": "1639",
|
||||
"src/lib/db/migrationRunner.ts": "1094",
|
||||
"src/lib/db/models.ts": "1097",
|
||||
"src/lib/db/providers.ts": "1034",
|
||||
"src/lib/memory/retrieval.ts": "1073",
|
||||
"src/lib/tailscaleTunnel.ts": "1202",
|
||||
"src/lib/usage/providerLimits.ts": "1013",
|
||||
"src/shared/components/OAuthModal.tsx": "1134",
|
||||
"src/shared/components/RequestLoggerV2.tsx": "1629",
|
||||
"src/shared/components/analytics/charts.tsx": "1035",
|
||||
"src/shared/services/cliRuntime.ts": "1122",
|
||||
"src/sse/handlers/chat.ts": "1904",
|
||||
"src/sse/services/auth.ts": "2508",
|
||||
"tests/unit/account-fallback-service.test.ts": "1572",
|
||||
"tests/unit/provider-validation-specialty.test.ts": "2985",
|
||||
"open-sse/executors/hyperagent.ts": "1026",
|
||||
"open-sse/executors/default.ts": "1042",
|
||||
"open-sse/executors/kiro.ts": "1069",
|
||||
"open-sse/translator/request/openai-to-kiro.ts": "1057",
|
||||
"open-sse/utils/sseHeartbeat.ts": "142",
|
||||
"_rebaseline_2026_08_04_9305_sse_comments": "#9305 fix: broadened sseCommentsEnabled()"
|
||||
}
|
||||
|
||||
@@ -149,6 +149,18 @@ export const ERROR_RULES: ErrorRule[] = [
|
||||
backoff: true,
|
||||
reason: "quota_exhausted",
|
||||
},
|
||||
{
|
||||
id: "out_of_extra_usage",
|
||||
text: "out of extra usage",
|
||||
backoff: true,
|
||||
reason: "quota_exhausted",
|
||||
},
|
||||
{
|
||||
id: "extra_usage_required",
|
||||
text: "extra usage required",
|
||||
backoff: true,
|
||||
reason: "quota_exhausted",
|
||||
},
|
||||
{ id: "capacity", text: "capacity", backoff: true, reason: "model_capacity" },
|
||||
{ id: "overloaded", text: "overloaded", backoff: true, reason: "model_capacity" },
|
||||
{ id: "high_demand", text: "high demand", backoff: true, reason: "model_capacity" },
|
||||
|
||||
@@ -8,9 +8,32 @@ export const gemini_webProvider: RegistryEntry = {
|
||||
baseUrl: "https://gemini.google.com/app",
|
||||
authType: "apikey",
|
||||
authHeader: "cookie",
|
||||
// #9356: `supportsReasoning: false` is a live-behavior statement, not a guess
|
||||
// about the underlying Gemini model. The executor drives the gemini.google.com
|
||||
// web UI by typing a prompt, so it has no thinking-budget control to set and
|
||||
// never surfaces `reasoning_content` — agent routers reading /v1/models must
|
||||
// not select these for reasoning work. `toolCalling: false` is the matching
|
||||
// statement for native function calling; the prompt-emulation shim (#7286)
|
||||
// stays available and is advertised separately as `toolCalling: "emulated"`
|
||||
// on the provider constant (src/shared/constants/providers/web-cookie.ts).
|
||||
models: [
|
||||
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", toolCalling: false },
|
||||
{ id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", toolCalling: false },
|
||||
{ id: "gemini-3.1-flash-lite", name: "Gemini 3.1 Flash-Lite", toolCalling: false },
|
||||
{
|
||||
id: "gemini-3.1-pro",
|
||||
name: "Gemini 3.1 Pro",
|
||||
toolCalling: false,
|
||||
supportsReasoning: false,
|
||||
},
|
||||
{
|
||||
id: "gemini-3.5-flash",
|
||||
name: "Gemini 3.5 Flash",
|
||||
toolCalling: false,
|
||||
supportsReasoning: false,
|
||||
},
|
||||
{
|
||||
id: "gemini-3.1-flash-lite",
|
||||
name: "Gemini 3.1 Flash-Lite",
|
||||
toolCalling: false,
|
||||
supportsReasoning: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -14,9 +14,13 @@
|
||||
*/
|
||||
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { prepareToolMessages } from "../translator/webTools.ts";
|
||||
import { buildToolModeResponse } from "./chatgptWebTools.ts";
|
||||
import {
|
||||
checkGeminiWebUnsupportedControls,
|
||||
GEMINI_WEB_UNSUPPORTED_CONTROL_CODE,
|
||||
} from "./gemini-web/capabilities.ts";
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -406,6 +410,33 @@ export class GeminiWebExecutor extends BaseExecutor {
|
||||
const { model, body, stream, credentials, signal, log, onCredentialsRefreshed } = input;
|
||||
const requestBody = body as GeminiRequestBody;
|
||||
|
||||
// #9356: fail fast on controls this provider cannot honor (reasoning_effort
|
||||
// above "minimal", forced tool_choice). Runs before the credential check and
|
||||
// before Playwright launches — the request is unservable no matter which
|
||||
// cookie is used, and answering 200 with ordinary prose made agents believe
|
||||
// their reasoning/tool requirements had been met. See ./gemini-web/capabilities.ts.
|
||||
const violation = checkGeminiWebUnsupportedControls(body as Record<string, unknown>);
|
||||
if (violation) {
|
||||
log?.warn?.(
|
||||
"GEMINI-WEB",
|
||||
`Rejected request: "${violation.param}" is not supported by this provider`
|
||||
);
|
||||
return {
|
||||
response: new Response(
|
||||
JSON.stringify(
|
||||
buildErrorBody(400, violation.message, null, {
|
||||
type: "invalid_request_error",
|
||||
code: GEMINI_WEB_UNSUPPORTED_CONTROL_CODE,
|
||||
})
|
||||
),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
),
|
||||
url: GEMINI_URL,
|
||||
headers: {},
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
|
||||
const cookie = resolveGeminiWebCookie(credentials);
|
||||
if (!cookie) {
|
||||
return {
|
||||
|
||||
121
open-sse/executors/gemini-web/capabilities.ts
Normal file
121
open-sse/executors/gemini-web/capabilities.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Request-contract guards for the Gemini Web executor (#9356).
|
||||
*
|
||||
* gemini-web is not an API client. It launches Playwright, types ONE flat
|
||||
* prompt string into the gemini.google.com `.ql-editor` contenteditable,
|
||||
* presses Enter, and captures the first `StreamGenerate` response off the page
|
||||
* (see ../gemini-web.ts). There is no JSON request body on the wire, which
|
||||
* makes two OpenAI controls structurally impossible to honor:
|
||||
*
|
||||
* • `reasoning_effort` — no field exists to carry a thinking budget. Unlike
|
||||
* deepseek-web or perplexity-web, which post a real payload and can flip a
|
||||
* `thinking_enabled` flag or swap the model preference, there is nothing
|
||||
* here to set.
|
||||
* • forced `tool_choice` — the tools support gemini-web does have is the
|
||||
* prompt-emulation shim (`translator/webTools.ts`, #7286): it ASKS the
|
||||
* model, in prose, to answer with `<tool>{...}</tool>` and parses whatever
|
||||
* comes back. That is best-effort by construction. "required" / "any" /
|
||||
* a named function is a GUARANTEE, and a prompt cannot make one.
|
||||
*
|
||||
* Before this module both were accepted and quietly ignored, so an agent got a
|
||||
* 200 with `finish_reason: "stop"`, no `reasoning_content`, and `tool_calls: []`
|
||||
* and concluded its requirements had been met (#9356). Failing the request is
|
||||
* the honest answer: the caller can drop the control, or route to a model that
|
||||
* actually implements it.
|
||||
*
|
||||
* Deliberately NOT rejected — these are already satisfied or already work:
|
||||
* • `reasoning_effort: "none" | "minimal"` — asking for as little reasoning as
|
||||
* possible is something a non-thinking provider trivially complies with.
|
||||
* • `tool_choice: "auto" | "none"` and plain `tools[]` — the #7286 emulation
|
||||
* path, which several shipped combos depend on (#5240, #8488). Untouched.
|
||||
*
|
||||
* Pure and dependency-free so the whole contract is unit-testable without a
|
||||
* browser.
|
||||
*/
|
||||
|
||||
/** `error.code` on every compatibility rejection raised here. */
|
||||
export const GEMINI_WEB_UNSUPPORTED_CONTROL_CODE = "unsupported_control_for_provider";
|
||||
|
||||
/** Effort levels a non-thinking provider already complies with. */
|
||||
const SATISFIED_EFFORT_LEVELS = new Set(["none", "minimal"]);
|
||||
|
||||
/** `tool_choice` strings that demand a tool call rather than merely offering one. */
|
||||
const FORCING_TOOL_CHOICE_STRINGS = new Set(["required", "any"]);
|
||||
|
||||
/** `tool_choice: { type }` values that pin the model to a specific/any tool. */
|
||||
const FORCING_TOOL_CHOICE_TYPES = new Set(["function", "tool", "any"]);
|
||||
|
||||
export interface GeminiWebCapabilityViolation {
|
||||
/** Which request field could not be honored. */
|
||||
param: "reasoning_effort" | "tool_choice";
|
||||
/** Client-facing explanation — already safe to put in a response body. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim().toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `tool_choice` demands a tool call. Covers the OpenAI strings
|
||||
* ("required"), the Anthropic-flavored ones the translators also emit ("any"),
|
||||
* and the object forms that name a function or force any tool. "auto" / "none"
|
||||
* and every unrecognized shape are treated as non-forcing — this guard only
|
||||
* blocks contracts it is certain gemini-web cannot keep.
|
||||
*/
|
||||
export function isForcingToolChoice(toolChoice: unknown): boolean {
|
||||
const asString = normalizeString(toolChoice);
|
||||
if (asString) return FORCING_TOOL_CHOICE_STRINGS.has(asString);
|
||||
|
||||
if (toolChoice && typeof toolChoice === "object" && !Array.isArray(toolChoice)) {
|
||||
const type = normalizeString((toolChoice as Record<string, unknown>).type);
|
||||
return type !== null && FORCING_TOOL_CHOICE_TYPES.has(type);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True when `reasoning_effort` asks for MORE thinking than "none at all". */
|
||||
export function requestsThinkingBudget(reasoningEffort: unknown): boolean {
|
||||
const effort = normalizeString(reasoningEffort);
|
||||
if (effort === null) return false;
|
||||
return !SATISFIED_EFFORT_LEVELS.has(effort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect an OpenAI-shaped request body for controls gemini-web cannot honor.
|
||||
* Returns the first violation found, or `null` when the request is servable.
|
||||
*
|
||||
* `reasoning_effort` is checked before `tool_choice` only for determinism; a
|
||||
* request carrying both is rejected either way.
|
||||
*/
|
||||
export function checkGeminiWebUnsupportedControls(
|
||||
body: Record<string, unknown> | null | undefined
|
||||
): GeminiWebCapabilityViolation | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
|
||||
if (requestsThinkingBudget(body.reasoning_effort)) {
|
||||
return {
|
||||
param: "reasoning_effort",
|
||||
message:
|
||||
'Model provider "gemini-web" does not support "reasoning_effort". It drives the ' +
|
||||
"gemini.google.com web UI through a typed prompt and has no thinking-budget control " +
|
||||
'to set, so any effort above "minimal" would be silently ignored. Remove ' +
|
||||
'"reasoning_effort" (or send "none"/"minimal") or route to a reasoning-capable model.',
|
||||
};
|
||||
}
|
||||
|
||||
if (isForcingToolChoice(body.tool_choice)) {
|
||||
return {
|
||||
param: "tool_choice",
|
||||
message:
|
||||
'Model provider "gemini-web" cannot guarantee a forced tool call. Its tool support is ' +
|
||||
"prompt-emulated — the model is asked to emit a tool block and may answer with prose " +
|
||||
'instead — so "tool_choice" values that require one ("required", "any", or a named ' +
|
||||
'function) cannot be honored. Use "auto" to keep best-effort tool calling, or route to ' +
|
||||
"a model with native function calling.",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -190,15 +190,31 @@ export function getBackgroundTaskReason(
|
||||
const messages = toMessageArray(typedBody.messages ?? typedBody.input ?? []);
|
||||
if (!Array.isArray(messages) || messages.length === 0) return null;
|
||||
|
||||
// Find system message
|
||||
// Derive system content from messages array (OpenAI format) or top-level
|
||||
// system field (Anthropic format).
|
||||
const systemMsg = messages.find(
|
||||
(message: BackgroundMessage) => message.role === "system" || message.role === "developer"
|
||||
);
|
||||
if (!systemMsg) return null;
|
||||
|
||||
const systemContent =
|
||||
typeof systemMsg.content === "string" ? systemMsg.content.toLowerCase() : "";
|
||||
|
||||
let systemContent = "";
|
||||
if (systemMsg && typeof systemMsg.content === "string") {
|
||||
systemContent = systemMsg.content.toLowerCase();
|
||||
} else if (!systemMsg) {
|
||||
// Anthropic top-level system field: string or array of text blocks
|
||||
const raw = (typedBody as Record<string, unknown>).system;
|
||||
if (typeof raw === "string") {
|
||||
systemContent = raw.toLowerCase();
|
||||
} else if (Array.isArray(raw)) {
|
||||
systemContent = raw
|
||||
.map((part) =>
|
||||
part && typeof (part as { text?: unknown }).text === "string"
|
||||
? (part as { text: string }).text
|
||||
: ""
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
if (!systemContent) return null;
|
||||
|
||||
// Check against detection patterns
|
||||
|
||||
@@ -190,10 +190,26 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an `error` field carries a real failure signal. A key-presence check
|
||||
* (`!= null`) false-positives on benign values some backends emit on every
|
||||
* chunk (`{}`, `""`, `false`, `0`) — e.g. tool-call turns where a chunk with
|
||||
* real tool_calls content also carries `"error": {}`. Only substantive values
|
||||
* are treated as upstream failures.
|
||||
*/
|
||||
function isSubstantiveError(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
return Object.keys(value as Record<string, unknown>).length > 0;
|
||||
}
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function isStreamingUpstreamError(parsed: unknown, eventType: string): boolean {
|
||||
if (eventType === "response.failed" || eventType === "error") return true;
|
||||
if (!isRecord(parsed)) return false;
|
||||
if (parsed.error != null) return true;
|
||||
if (isSubstantiveError(parsed.error)) return true;
|
||||
|
||||
const nestedResponse = isRecord(parsed.response) ? parsed.response : null;
|
||||
return nestedResponse?.status === "failed" && nestedResponse.error != null;
|
||||
|
||||
@@ -112,7 +112,7 @@ export function geminiToClaudeResponse(chunk, state) {
|
||||
// When the toolNameMap provides a match (e.g., lowercase "bash" → "Bash"),
|
||||
// use it directly without passing through normalizeToolName(), which would
|
||||
// reverse TitleCase back to lowercase via REVERSE_MAP (#9568).
|
||||
const restoredToolName = mappedName || normalizeToolName(rawToolName);
|
||||
const restoredToolName = mappedName ?? normalizeToolName(rawToolName);
|
||||
const idx = state.contentBlockIndex++;
|
||||
const toolId = fc.id || `toolu_${Date.now()}_${idx}`;
|
||||
|
||||
|
||||
@@ -874,6 +874,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
if (state.currentToolCallId) state.toolCallIdsSeen.add(state.currentToolCallId);
|
||||
|
||||
const toolName = normalizeToolName(item.name);
|
||||
state.currentToolName = toolName; // track for schema lookup at done time
|
||||
if (!toolName) {
|
||||
// Some Responses providers briefly emit placeholder/empty tool names.
|
||||
// Defer emission until output_item.done in case the final name is populated there.
|
||||
@@ -919,26 +920,9 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta;
|
||||
if (state.currentToolCallDeferred) return null;
|
||||
|
||||
return {
|
||||
id: state.chatId,
|
||||
object: "chat.completion.chunk",
|
||||
created: state.created,
|
||||
model: state.model || "gpt-4",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: state.toolCallIndex,
|
||||
function: { arguments: argsDelta },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
// #9168: buffer arguments until output_item.done for schema-aware null normalization
|
||||
// Previously emitted raw null values for optional enum fields (e.g. isolation: null).
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function call done — emit args chunk from item.arguments when no deltas were received,
|
||||
@@ -1011,6 +995,35 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
if (item.arguments != null && !buffered) {
|
||||
const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName, toolSchema);
|
||||
|
||||
const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit);
|
||||
if (argsStr) {
|
||||
return {
|
||||
id: state.chatId,
|
||||
object: "chat.completion.chunk",
|
||||
created: state.created,
|
||||
model: state.model || "gpt-4",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: currentIndex,
|
||||
function: { arguments: argsStr },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
} else if (buffered) {
|
||||
// #9168: deltas were buffered — normalize against the original client schema
|
||||
// and emit the cleaned arguments once, stripping optional null values that
|
||||
// would otherwise reach the client raw.
|
||||
const argsToEmit = stripEmptyOptionalToolArgs(buffered, toolName, toolSchema);
|
||||
|
||||
const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit);
|
||||
if (argsStr) {
|
||||
return {
|
||||
|
||||
@@ -79,7 +79,8 @@ export function sseCommentsEnabled(): boolean {
|
||||
if (typeof process === "undefined") return true;
|
||||
const v = process.env.OMNIROUTE_SSE_COMMENTS;
|
||||
if (v === undefined || v === "") return true;
|
||||
return v.trim().toLowerCase() !== "off";
|
||||
const normalized = v.trim().toLowerCase();
|
||||
return normalized !== "off" && normalized !== "false" && normalized !== "0" && normalized !== "no";
|
||||
}
|
||||
|
||||
export function createSseHeartbeatTransform({
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "./streamHelpers.ts";
|
||||
import { calculateCost } from "@/lib/usage/costCalculator";
|
||||
import { buildOmniRouteSseMetadataComment } from "@/domain/omnirouteResponseMeta";
|
||||
import { sseCommentsEnabled } from "./sseHeartbeat.ts";
|
||||
import {
|
||||
createStructuredSSECollector,
|
||||
buildStreamSummaryFromEvents,
|
||||
@@ -1001,6 +1002,11 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
controller: TransformStreamDefaultController,
|
||||
finalUsage: UsageTokenRecord | Record<string, unknown> | null | undefined
|
||||
) => {
|
||||
// Skip SSE metadata comment lines when OMNIROUTE_SSE_COMMENTS is disabled
|
||||
// (e.g., "off", "false", "0", "no"). Strict OpenAI-compatible clients that
|
||||
// JSON.parse every SSE line will crash on `: x-omniroute-*` comment lines.
|
||||
if (!sseCommentsEnabled()) return;
|
||||
|
||||
const costUsd = finalUsage ? await calculateCost(provider, model, finalUsage) : 0;
|
||||
const comment = buildOmniRouteSseMetadataComment({
|
||||
provider,
|
||||
|
||||
@@ -34,8 +34,11 @@
|
||||
"scripts/dev/tls-options.mjs",
|
||||
"scripts/check/check-supported-node-runtime.ts",
|
||||
"scripts/dev/sync-env.mjs",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/assembleStandalone.mjs",
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-next-isolated.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/runtime-env.mjs",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
|
||||
@@ -89,6 +89,15 @@ const NATIVE_ASSET_ENTRIES = [
|
||||
src: ["node_modules", "better-sqlite3", "build"],
|
||||
dest: ["node_modules", "better-sqlite3", "build"],
|
||||
},
|
||||
{
|
||||
// #8847: Bun (and npx -g global installs) resolve better-sqlite3's native
|
||||
// binary from prebuilds/ instead of build/Release/, so the compiled build/
|
||||
// copy alone leaves a hollow package that falls back to sql.js (OOM under
|
||||
// Bun). Ship the prebuilds alongside the compiled binary.
|
||||
label: "better-sqlite3 prebuilds (Bun / global installs)",
|
||||
src: ["node_modules", "better-sqlite3", "prebuilds"],
|
||||
dest: ["node_modules", "better-sqlite3", "prebuilds"],
|
||||
},
|
||||
{
|
||||
// TPROXY IP_TRANSPARENT addon (Fase 3 / Epic A). Built by build-tproxy-native
|
||||
// before assembly; Linux-only + opt-in, so the source is absent on non-Linux
|
||||
|
||||
@@ -121,6 +121,9 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
|
||||
// shipped via package.json "files", so it must be allowed in the tarball.
|
||||
"open-sse/utils/setupPolyfill.ts",
|
||||
"package.json",
|
||||
"scripts/build/assembleStandalone.mjs",
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
"scripts/build/build-next-isolated.mjs",
|
||||
"scripts/check/check-supported-node-runtime.ts",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
|
||||
@@ -609,14 +609,6 @@ async function main() {
|
||||
args: ["run", "check:pack-artifact"],
|
||||
timeout: 20 * 60 * 1000,
|
||||
});
|
||||
// WS1.2 (#7065 class): boot the REAL packed tarball from a clean install —
|
||||
// the runtime gate structure checks cannot provide. Reuses the same dist/ build.
|
||||
slow.push({
|
||||
id: "pack-boot",
|
||||
label: "Tarball boot-smoke (installed CLI serves /health)",
|
||||
args: ["run", "check:pack-boot"],
|
||||
timeout: 15 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
slow.forEach((g) => announce(`${g.label} [parallel]`));
|
||||
const slowResults = await Promise.all(
|
||||
@@ -633,6 +625,41 @@ async function main() {
|
||||
detail: code === 0 ? "pass" : firstFailureLine(out),
|
||||
});
|
||||
});
|
||||
|
||||
if (WITH_BUILD) {
|
||||
// WS1.2 (#7065 class): boot the REAL packed tarball from a clean install.
|
||||
// check:pack-artifact is the builder for dist/ when staging is absent, so the
|
||||
// boot smoke MUST run after it completes. Running both in the parallel wave
|
||||
// races check:pack-boot against dist/server.js creation on clean worktrees.
|
||||
const packArtifactIndex = slow.findIndex((g) => g.id === "pack-artifact");
|
||||
const packArtifactResult = slowResults[packArtifactIndex];
|
||||
const bootLabel = "Tarball boot-smoke (installed CLI serves /health)";
|
||||
|
||||
if (!packArtifactResult || packArtifactResult.code !== 0) {
|
||||
const out = "skipped because package-artifact did not produce a valid dist/ build";
|
||||
saveGateLog("pack-boot", out);
|
||||
record({
|
||||
id: "pack-boot",
|
||||
label: bootLabel,
|
||||
kind: "hard",
|
||||
ok: false,
|
||||
detail: out,
|
||||
});
|
||||
} else {
|
||||
announce(bootLabel);
|
||||
const { code, out } = await runAsync(npmCmd, ["run", "check:pack-boot"], {
|
||||
timeout: 15 * 60 * 1000,
|
||||
});
|
||||
saveGateLog("pack-boot", out);
|
||||
record({
|
||||
id: "pack-boot",
|
||||
label: bootLabel,
|
||||
kind: "hard",
|
||||
ok: code === 0,
|
||||
detail: code === 0 ? "pass" : firstFailureLine(out),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (WITH_BUILD) {
|
||||
// --with-build without the suites (--quick): still verify the package artifact.
|
||||
const { code, out } = await runAsync(npmCmd, ["run", "check:pack-artifact"], {
|
||||
|
||||
@@ -134,7 +134,7 @@ export function LlmChatCard({
|
||||
}: Props) {
|
||||
const t = useTranslations("miniPlayground");
|
||||
const { keys } = useApiKey();
|
||||
const { models } = useProviderModels(providerId);
|
||||
const { models, loading, error, retry } = useProviderModels(providerId);
|
||||
|
||||
const [internalSelectedKey, setInternalSelectedKey] = useState<string>("");
|
||||
const [internalModel, setInternalModel] = useState<string>(initialModel ?? "");
|
||||
@@ -392,15 +392,31 @@ export function LlmChatCard({
|
||||
<select
|
||||
value={model || firstModel}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
disabled={loading}
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-60"
|
||||
>
|
||||
{modelOptions.length === 0 && <option value="">{initialModel || "—"}</option>}
|
||||
{modelOptions.length === 0 && !loading && <option value="">{initialModel || "—"}</option>}
|
||||
{loading && <option value="">{t("loading") ?? "Loading…"}</option>}
|
||||
{modelOptions.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1" role="alert">
|
||||
<span className="truncate max-w-[180px]" title={String(error)}>
|
||||
{String(error)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={retry}
|
||||
className="shrink-0 text-xs text-primary hover:text-primary-strong underline"
|
||||
>
|
||||
{t("retry") ?? "Retry"}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Key select */}
|
||||
{keys.length > 0 && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
@@ -18,6 +18,8 @@ interface UseProviderModelsResult {
|
||||
models: ProviderModel[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** Re-runs the model fetch for the current provider. Useful for a Retry action. */
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,15 +34,14 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||
const [models, setModels] = useState<ProviderModel[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Cancels any in-flight load (component unmount or a retry superseding the
|
||||
// previous request) so a stale response never overwrites a newer one.
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providerId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const load = useCallback(() => {
|
||||
cleanupRef.current?.();
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -109,11 +110,33 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
void run();
|
||||
const cleanup = () => {
|
||||
cancelled = true;
|
||||
};
|
||||
cleanupRef.current = cleanup;
|
||||
return cleanup;
|
||||
}, [providerId]);
|
||||
|
||||
return { models, loading, error };
|
||||
useEffect(() => {
|
||||
if (!providerId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
return load();
|
||||
}, [providerId, load]);
|
||||
|
||||
// Release the current in-flight cleanup on unmount so no state updates leak.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
if (!providerId) return;
|
||||
load();
|
||||
}, [providerId, load]);
|
||||
|
||||
return { models, loading, error, retry };
|
||||
}
|
||||
|
||||
@@ -701,6 +701,17 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
? makeDiagnosis("ok", "local", null, null)
|
||||
: classifyFailure({ error: result.error, statusCode: result.statusCode, provider }));
|
||||
|
||||
// #9623: a failed connection test must not paint the connection permanently red.
|
||||
// Previously a non-terminal failure wrote `testStatus: "error"` with
|
||||
// `rateLimitedUntil: null` — since the cooldown filter only ever skips entries
|
||||
// whose rateLimitedUntil is in the future, a null cooldown left the connection
|
||||
// permanently unavailable after a transient outage. Give non-terminal test
|
||||
// failures a short cooldown so the lazy-recovery path retries them.
|
||||
const terminalTestStatuses = new Set(["banned", "expired", "credits_exhausted"]);
|
||||
const isTerminalFailure =
|
||||
!result.valid && terminalTestStatuses.has(String(diagnosis.code ?? diagnosis.type ?? "").toLowerCase());
|
||||
const testFailureCooldownMs = result.valid ? 0 : 30_000; // 30s retry window
|
||||
|
||||
const updateData: Record<string, any> = {
|
||||
testStatus: result.valid ? "active" : "error",
|
||||
lastError: result.valid ? null : result.error,
|
||||
@@ -709,7 +720,12 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
lastErrorType: result.valid ? null : diagnosis.type,
|
||||
lastErrorSource: result.valid ? null : diagnosis.source,
|
||||
errorCode: result.valid ? null : diagnosis.code || result.statusCode || null,
|
||||
rateLimitedUntil: result.valid ? null : connection.rateLimitedUntil || null,
|
||||
rateLimitedUntil:
|
||||
result.valid || isTerminalFailure
|
||||
? result.valid
|
||||
? null
|
||||
: connection.rateLimitedUntil || null
|
||||
: new Date(Date.now() + testFailureCooldownMs).toISOString(),
|
||||
};
|
||||
|
||||
if (result.valid) {
|
||||
|
||||
@@ -73,9 +73,7 @@ export async function PATCH(request: Request, { params }: RouteParams): Promise<
|
||||
// helpers. Without the pre-update removal, a group/provider switch would leave
|
||||
// orphan qtSd/ combos a quota key still sees. Guarded + non-fatal.
|
||||
const combosNeedResync =
|
||||
body !== null &&
|
||||
typeof body === "object" &&
|
||||
("connectionIds" in body || "groupId" in body);
|
||||
body !== null && typeof body === "object" && ("connectionIds" in body || "groupId" in body);
|
||||
if (combosNeedResync) {
|
||||
try {
|
||||
const { removeQuotaCombosForPool } = await import("@/lib/quota/quotaCombos");
|
||||
@@ -106,7 +104,7 @@ export async function PATCH(request: Request, { params }: RouteParams): Promise<
|
||||
id,
|
||||
prevApiKeyIds,
|
||||
nextApiKeyIds,
|
||||
parsed.data.exclusive ?? false,
|
||||
parsed.data.exclusive ?? false
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +130,7 @@ export async function DELETE(request: Request, { params }: RouteParams): Promise
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const existed = deletePool(id);
|
||||
const existed = await deletePool(id);
|
||||
if (!existed) {
|
||||
return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 });
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
// happen once.
|
||||
|
||||
export type UsableChatModelCandidate = {
|
||||
id?: string;
|
||||
root?: string;
|
||||
name?: string;
|
||||
owned_by?: string;
|
||||
parent?: string | null;
|
||||
type?: string;
|
||||
@@ -44,9 +47,19 @@ function excludesTextOutputModality(model: UsableChatModelCandidate) {
|
||||
);
|
||||
}
|
||||
|
||||
function isBuiltinAutoModel(model: UsableChatModelCandidate): boolean {
|
||||
const id = model.id || model.root || model.name || "";
|
||||
const normalized = id.trim().toLowerCase();
|
||||
return normalized === "auto" || normalized.startsWith("auto/");
|
||||
}
|
||||
|
||||
export function isUsableChatModel(model: UsableChatModelCandidate) {
|
||||
if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") {
|
||||
return false;
|
||||
// Allow built-in auto-routing models (e.g. auto, auto/best-coding)
|
||||
// while still excluding operator-created combos.
|
||||
if (!isBuiltinAutoModel(model)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typeof model.parent === "string" && model.parent.length > 0) return false;
|
||||
if (typeof model.type === "string" && model.type !== "chat") return false;
|
||||
|
||||
@@ -10191,6 +10191,8 @@
|
||||
"copied": "Copied!",
|
||||
"run": "Run",
|
||||
"running": "Running...",
|
||||
"loading": "Loading...",
|
||||
"retry": "Retry",
|
||||
"response": "Response",
|
||||
"tunnel": "Tunnel",
|
||||
"send": "Send",
|
||||
|
||||
@@ -10191,6 +10191,8 @@
|
||||
"copied": "Copiado!",
|
||||
"run": "Executar",
|
||||
"running": "Executando...",
|
||||
"loading": "Carregando...",
|
||||
"retry": "Tentar novamente",
|
||||
"response": "Resposta",
|
||||
"tunnel": "Tunnel",
|
||||
"send": "Enviar",
|
||||
|
||||
@@ -10191,6 +10191,8 @@
|
||||
"copied": "Đã sao chép!",
|
||||
"run": "Chạy",
|
||||
"running": "Đang chạy...",
|
||||
"loading": "Đang tải...",
|
||||
"retry": "Thử lại",
|
||||
"response": "Phản hồi",
|
||||
"tunnel": "Đường hầm",
|
||||
"send": "Gửi",
|
||||
|
||||
@@ -306,6 +306,7 @@ export async function registerNodejs(): Promise<void> {
|
||||
{ applyRuntimeSettings },
|
||||
{ startRuntimeConfigHotReload },
|
||||
{ startSpendBatchWriter },
|
||||
{ startCleanupScheduler },
|
||||
{ registerDefaultGuardrails },
|
||||
{ ensurePersistentManagementPasswordHash },
|
||||
{ skillExecutor },
|
||||
@@ -320,6 +321,7 @@ export async function registerNodejs(): Promise<void> {
|
||||
import("@/lib/config/runtimeSettings"),
|
||||
import("@/lib/config/hotReload"),
|
||||
import("@/lib/spend/batchWriter"),
|
||||
import("@/lib/db/cleanup"),
|
||||
import("@/lib/guardrails"),
|
||||
import("@/lib/auth/managementPassword"),
|
||||
import("@/lib/skills/executor"),
|
||||
@@ -489,6 +491,17 @@ export async function registerNodejs(): Promise<void> {
|
||||
console.warn("[STARTUP] Could not initialize vacuum scheduler (non-fatal):", msg);
|
||||
}
|
||||
|
||||
// Retention cleanup scheduler (#4691/#6988, #9624): runs the general retention
|
||||
// cleanup once after startup and then every 6 hours. Previously this was only
|
||||
// wired into the unused src/server-init.ts, so telemetry tables grew unboundedly
|
||||
// even with retention.autoCleanupEnabled=true. Idempotent (guarded internally).
|
||||
try {
|
||||
startCleanupScheduler();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[STARTUP] Could not start cleanup scheduler (non-fatal):", msg);
|
||||
}
|
||||
|
||||
// Warm the model catalog's durable, apiKey-independent sub-caches at
|
||||
// startup — see warmModelCatalogCache() for why the top-level Response
|
||||
// cache alone doesn't deliver this. Fire-and-forget, non-fatal.
|
||||
|
||||
@@ -253,14 +253,15 @@ export async function cleanupMemoryEntries(): Promise<CleanupResult> {
|
||||
|
||||
/**
|
||||
* Clean up old domain_cost_history based on retention settings. (#6848)
|
||||
* Uses unix-epoch `timestamp` column (INTEGER).
|
||||
* The `timestamp` column stores epoch milliseconds (saveCostEntry default
|
||||
* is Date.now()), so the cutoff must be in milliseconds to match. (#9625)
|
||||
*/
|
||||
export async function cleanupDomainCostHistory(): Promise<CleanupResult> {
|
||||
const db = getDbInstance();
|
||||
const retention = getRetentionSettings();
|
||||
|
||||
const retentionDays = retention.domainCostHistory;
|
||||
const cutoffEpoch = Math.floor(Date.now() / 1000) - retentionDays * 86_400;
|
||||
const cutoffEpoch = Date.now() - retentionDays * 86_400_000;
|
||||
|
||||
const result: CleanupResult = { deleted: 0, errors: 0 };
|
||||
|
||||
|
||||
@@ -11,8 +11,26 @@
|
||||
import { getDbInstance } from "./core";
|
||||
// Phase B2: auto-mint/prune quotaShared-* combos when pool allocations change.
|
||||
// Imported lazily (dynamic import in the hook) to avoid circular-dependency
|
||||
// risk between db/ and quota/ modules. The import is fire-and-forget; combo
|
||||
// failures never break pool CRUD.
|
||||
// risk between db/ and quota/ modules. Sync hooks are fire-and-forget; deletion
|
||||
// awaits its guarded cleanup while metadata is available. Combo failures never
|
||||
// break pool CRUD.
|
||||
const quotaComboMaintenance = new Map<string, Promise<unknown>>();
|
||||
const deletingPools = new Set<string>();
|
||||
|
||||
function serializeQuotaComboMaintenance<T>(
|
||||
poolId: string,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const previous = quotaComboMaintenance.get(poolId);
|
||||
const current = previous ? previous.catch(() => undefined).then(operation) : operation();
|
||||
quotaComboMaintenance.set(poolId, current);
|
||||
const cleanup = () => {
|
||||
if (quotaComboMaintenance.get(poolId) === current) quotaComboMaintenance.delete(poolId);
|
||||
};
|
||||
void current.then(cleanup, cleanup);
|
||||
return current;
|
||||
}
|
||||
|
||||
async function syncQuotaCombosGuarded(poolId: string): Promise<void> {
|
||||
try {
|
||||
const { syncQuotaCombos } = await import("@/lib/quota/quotaCombos");
|
||||
@@ -400,7 +418,7 @@ export function createPool(input: PoolCreate): QuotaPool {
|
||||
);
|
||||
|
||||
// Phase B2: fire-and-forget combo sync; failures are logged but never thrown.
|
||||
void syncQuotaCombosGuarded(id);
|
||||
void serializeQuotaComboMaintenance(id, () => syncQuotaCombosGuarded(id));
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -412,6 +430,8 @@ export function createPool(input: PoolCreate): QuotaPool {
|
||||
* connection_id (primary) is synced to connectionIds[0].
|
||||
*/
|
||||
export function updatePool(id: string, input: PoolUpdate): QuotaPool | null {
|
||||
if (deletingPools.has(id)) return null;
|
||||
|
||||
const database = getDb();
|
||||
const existing = database
|
||||
.prepare<PoolRow>(
|
||||
@@ -475,7 +495,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null {
|
||||
const result = rowToPool(existing, getAllocations(id));
|
||||
|
||||
// Phase B2: fire-and-forget combo sync; failures are logged but never thrown.
|
||||
void syncQuotaCombosGuarded(id);
|
||||
void serializeQuotaComboMaintenance(id, () => syncQuotaCombosGuarded(id));
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -485,28 +505,38 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null {
|
||||
* Also removes join rows in quota_pool_connections.
|
||||
* Returns true if a row was deleted, false if not found.
|
||||
*/
|
||||
export function deletePool(id: string): boolean {
|
||||
// Phase B2: remove quota combos BEFORE deleting the pool row so that
|
||||
// removeQuotaCombosForPool can still resolve the pool name → slug.
|
||||
void removeQuotaCombosGuarded(id);
|
||||
export async function deletePool(id: string): Promise<boolean> {
|
||||
if (deletingPools.has(id)) return false;
|
||||
const exists = getDb().prepare<{ id: string }>("SELECT id FROM quota_pools WHERE id = ?").get(id);
|
||||
if (!exists) return false;
|
||||
deletingPools.add(id);
|
||||
|
||||
const database = getDb();
|
||||
const doDelete = database.transaction(() => {
|
||||
database.prepare("DELETE FROM quota_pool_connections WHERE pool_id = ?").run(id);
|
||||
// Prune this pool id from every key's allowed_quotas JSON array.
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE api_keys SET allowed_quotas = COALESCE(
|
||||
const deletion = serializeQuotaComboMaintenance(id, async () => {
|
||||
// Phase B2: remove quota combos BEFORE deleting the pool row so that
|
||||
// removeQuotaCombosForPool can still resolve the pool name → slug.
|
||||
await removeQuotaCombosGuarded(id);
|
||||
|
||||
const database = getDb();
|
||||
const doDelete = database.transaction(() => {
|
||||
database.prepare("DELETE FROM quota_pool_connections WHERE pool_id = ?").run(id);
|
||||
// Prune this pool id from every key's allowed_quotas JSON array.
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE api_keys SET allowed_quotas = COALESCE(
|
||||
(SELECT json_group_array(value) FROM json_each(api_keys.allowed_quotas) WHERE value != ?),
|
||||
'[]')
|
||||
WHERE allowed_quotas IS NOT NULL AND allowed_quotas != '[]'
|
||||
AND EXISTS (SELECT 1 FROM json_each(api_keys.allowed_quotas) WHERE value = ?)`
|
||||
)
|
||||
.run(id, id);
|
||||
return database.prepare("DELETE FROM quota_pools WHERE id = ?").run(id);
|
||||
)
|
||||
.run(id, id);
|
||||
return database.prepare("DELETE FROM quota_pools WHERE id = ?").run(id);
|
||||
});
|
||||
const result = doDelete();
|
||||
return result.changes > 0;
|
||||
});
|
||||
const result = doDelete();
|
||||
return result.changes > 0;
|
||||
const clearDeleting = () => deletingPools.delete(id);
|
||||
void deletion.then(clearDeleting, clearDeleting);
|
||||
return deletion;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -546,6 +576,8 @@ export function deletePool(id: string): boolean {
|
||||
* Runs atomically: all pool writes are inside a single SQLite transaction.
|
||||
*/
|
||||
export function upsertAllocations(poolId: string, allocations: PoolAllocation[]): void {
|
||||
if (deletingPools.has(poolId)) return;
|
||||
|
||||
const database = getDb();
|
||||
|
||||
// Normalize: when all weights are 0, distribute equally so the pool is usable
|
||||
@@ -602,7 +634,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[])
|
||||
|
||||
// Phase B2: fire-and-forget combo sync for the target pool only; failures are
|
||||
// logged but never thrown. Sibling pools' combos are synced on their own lifecycle.
|
||||
void syncQuotaCombosGuarded(poolId);
|
||||
void serializeQuotaComboMaintenance(poolId, () => syncQuotaCombosGuarded(poolId));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -112,7 +112,8 @@ function parseEffortList(rawList: unknown): string[] | undefined {
|
||||
.map((entry) => {
|
||||
const entryParsed = effortEntrySchema.safeParse(entry);
|
||||
if (!entryParsed.success) return null;
|
||||
const raw = typeof entryParsed.data === "string" ? entryParsed.data : entryParsed.data.effort;
|
||||
const raw =
|
||||
typeof entryParsed.data === "string" ? entryParsed.data : entryParsed.data.effort;
|
||||
return raw.length > 0 ? normalizeSupportedEffort(raw) : null;
|
||||
})
|
||||
.filter((effort): effort is string => effort !== null)
|
||||
@@ -144,6 +145,16 @@ export function detectSupportedThinkingEfforts(record: JsonRecord): string[] | u
|
||||
}
|
||||
}
|
||||
|
||||
// #9160: fall back to `capabilities.effort_tiers` before the legacy fields.
|
||||
// OmniRoute's own catalog surfaces effort tiers inside `capabilities.effort_tiers`,
|
||||
// which the existing `parseEffortList` already handles (string arrays).
|
||||
const capabilitiesRecord = asRecord(record.capabilities);
|
||||
const capabilitiesParsed = effortListSchema.safeParse(capabilitiesRecord.effort_tiers);
|
||||
if (capabilitiesParsed.success) {
|
||||
const fromCapabilities = parseEffortList(capabilitiesRecord.effort_tiers);
|
||||
if (fromCapabilities) return fromCapabilities;
|
||||
}
|
||||
|
||||
// #8347: fall back to `supported_reasoning_levels`, then `thinking.levels` — in that
|
||||
// order, per the regression guard for #7694 (the flat field and `reasoning.supported_efforts`
|
||||
// both take precedence over these two and are handled above / by the caller).
|
||||
|
||||
@@ -152,7 +152,10 @@ export async function syncQuotaCombos(poolId: string): Promise<void> {
|
||||
for (const connId of pool.connectionIds) {
|
||||
let connection: Record<string, unknown> | null = null;
|
||||
try {
|
||||
connection = (await getCachedProviderConnectionById(connId)) as Record<string, unknown> | null;
|
||||
connection = (await getCachedProviderConnectionById(connId)) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
} catch {
|
||||
// Connection lookup failure — skip this connection.
|
||||
continue;
|
||||
@@ -202,6 +205,10 @@ export async function syncQuotaCombos(poolId: string): Promise<void> {
|
||||
}));
|
||||
try {
|
||||
const existing = await getComboByName(comboName);
|
||||
// A pool may be deleted while this fire-and-forget sync is awaiting combo
|
||||
// lookups. Re-check immediately before the synchronous DB upsert so stale
|
||||
// create/update work cannot recreate managed combos after delete cleanup.
|
||||
if (!getPool(poolId)) return;
|
||||
const payload = {
|
||||
name: comboName,
|
||||
models: steps,
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
"tests/unit/anthropic-thinking-signature-recovery.test.ts",
|
||||
"tests/unit/antigravity-429-quota-tdd.test.ts",
|
||||
"tests/unit/antigravity-prefer-stored-project.test.ts",
|
||||
"tests/unit/api-key-policy-noauth-allowed-connections.test.ts",
|
||||
"tests/unit/api-key-rotator-health.test.ts",
|
||||
"tests/unit/api-key-policy-noauth-allowed-connections.test.ts",
|
||||
"tests/unit/appearance-widget-settings-schema.test.ts",
|
||||
"tests/unit/auth-antigravity-account-retry-v2.test.ts",
|
||||
"tests/unit/auth-clear-account-error.test.ts",
|
||||
@@ -213,7 +215,9 @@
|
||||
"tests/unit/executor-web-cookie-sweep.test.ts",
|
||||
"tests/unit/format-provider-error-cause.test.ts",
|
||||
"tests/unit/forwarded-header-budget.test.ts",
|
||||
"tests/unit/gemini-web-capabilities-9356.test.ts",
|
||||
"tests/unit/gemini-web-missing-browser-3516.test.ts",
|
||||
"tests/unit/gemini-web-capabilities-9356.test.ts",
|
||||
"tests/unit/grok-cli-oauth.test.ts",
|
||||
"tests/unit/guardrails-api-3496.test.ts",
|
||||
"tests/unit/headroom-codex-quota-snapshot-6379.test.ts",
|
||||
@@ -273,7 +277,9 @@
|
||||
"tests/unit/rate-limit-manager.test.ts",
|
||||
"tests/unit/rate-limit-queue-timeout-lockout.test.ts",
|
||||
"tests/unit/repro-7503-no-choices.test.ts",
|
||||
"tests/unit/repro-9486.test.ts",
|
||||
"tests/unit/repro-9630-combo-false-503.test.ts",
|
||||
"tests/unit/repro-9486.test.ts",
|
||||
"tests/unit/repro-antigravity-404-family-cooldown-hijack.test.ts",
|
||||
"tests/unit/responses-handler.test.ts",
|
||||
"tests/unit/rotation-config-omniroute.test.ts",
|
||||
|
||||
427
tests/integration/quota-pool-delete-combo-cleanup.test.ts
Normal file
427
tests/integration/quota-pool-delete-combo-cleanup.test.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-quota-pool-delete-combo-cleanup-")
|
||||
);
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-quota-pool-delete-combo-cleanup-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const combosDb = await import("../../src/lib/db/combos.ts");
|
||||
const groupsDb = await import("../../src/lib/db/quotaGroups.ts");
|
||||
const poolsDb = await import("../../src/lib/db/quotaPools.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const compliance = await import("../../src/lib/compliance/index.ts");
|
||||
const poolIdRoute = await import("../../src/app/api/quota/pools/[id]/route.ts");
|
||||
const { removeQuotaCombosForPool, syncQuotaCombos } =
|
||||
await import("../../src/lib/quota/quotaCombos.ts");
|
||||
const { parseQuotaModelName, quotaGroupSlug } =
|
||||
await import("../../src/lib/quota/quotaModelNaming.ts");
|
||||
|
||||
type Combo = Awaited<ReturnType<typeof combosDb.getCombos>>[number];
|
||||
|
||||
type Db = {
|
||||
prepare: (sql: string) => {
|
||||
all: (...params: unknown[]) => unknown[];
|
||||
get: (...params: unknown[]) => unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function resetDb() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function quotaNamesFor(combos: Combo[], groupName: string, provider: string): string[] {
|
||||
const groupSlug = quotaGroupSlug(groupName);
|
||||
return combos
|
||||
.map((combo) => (typeof combo.name === "string" ? combo.name : ""))
|
||||
.filter((name) => {
|
||||
const parsed = parseQuotaModelName(name);
|
||||
return parsed?.groupSlug === groupSlug && parsed.provider === provider;
|
||||
})
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function createConnection(provider: "openrouter" | "baidu", name: string) {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name,
|
||||
apiKey: `test-only-${name}`,
|
||||
});
|
||||
const id = (connection as Record<string, unknown>).id;
|
||||
assert.equal(typeof id, "string", `${provider} connection should have an id`);
|
||||
return id as string;
|
||||
}
|
||||
|
||||
async function deletePoolThroughRoute(poolId: string): Promise<Response> {
|
||||
const request = await makeManagementSessionRequest(`http://localhost/api/quota/pools/${poolId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return poolIdRoute.DELETE(request, { params: Promise.resolve({ id: poolId }) });
|
||||
}
|
||||
|
||||
function getAllowedQuotas(apiKeyId: string): string[] {
|
||||
const db = core.getDbInstance() as unknown as Db;
|
||||
const row = db.prepare("SELECT allowed_quotas FROM api_keys WHERE id = ?").get(apiKeyId) as {
|
||||
allowed_quotas: string;
|
||||
};
|
||||
return JSON.parse(row.allowed_quotas) as string[];
|
||||
}
|
||||
|
||||
function countRows(sql: string, id: string): number {
|
||||
const db = core.getDbInstance() as unknown as Db;
|
||||
const row = db.prepare(sql).get(id) as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
function nextImmediate(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetDb();
|
||||
compliance.initAuditLog();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("DELETE pool waits for scoped quota-combo cleanup before returning 204", async () => {
|
||||
const targetGroup = groupsDb.createGroup("Delete Target Group");
|
||||
const otherGroup = groupsDb.createGroup("Delete Other Group");
|
||||
const targetConnectionId = await createConnection("openrouter", "delete-target-openrouter");
|
||||
const sameGroupConnectionId = await createConnection("baidu", "delete-control-baidu");
|
||||
const otherGroupConnectionId = await createConnection("openrouter", "delete-control-openrouter");
|
||||
const apiKey = await apiKeysDb.createApiKey("Delete Pool Key", "delete-pool-machine");
|
||||
|
||||
const targetPool = poolsDb.createPool({
|
||||
connectionId: targetConnectionId,
|
||||
name: "Delete Target Pool",
|
||||
groupId: targetGroup.id,
|
||||
allocations: [{ apiKeyId: apiKey.id, weight: 100, policy: "hard" }],
|
||||
});
|
||||
const sameGroupPool = poolsDb.createPool({
|
||||
connectionId: sameGroupConnectionId,
|
||||
name: "Same Group Different Provider",
|
||||
groupId: targetGroup.id,
|
||||
});
|
||||
const otherGroupPool = poolsDb.createPool({
|
||||
connectionId: otherGroupConnectionId,
|
||||
name: "Different Group Same Provider",
|
||||
groupId: otherGroup.id,
|
||||
});
|
||||
await apiKeysDb.updateApiKeyPermissions(apiKey.id, {
|
||||
allowedQuotas: [targetPool.id, otherGroupPool.id],
|
||||
});
|
||||
|
||||
await syncQuotaCombos(targetPool.id);
|
||||
await syncQuotaCombos(sameGroupPool.id);
|
||||
await syncQuotaCombos(otherGroupPool.id);
|
||||
await nextImmediate();
|
||||
await nextImmediate();
|
||||
const ordinaryCombo = await combosDb.createCombo({
|
||||
name: "ordinary-delete-control",
|
||||
models: [{ kind: "model", model: "openrouter/control-model", weight: 100 }],
|
||||
strategy: "priority",
|
||||
});
|
||||
|
||||
const before = await combosDb.getCombos();
|
||||
const targetNames = quotaNamesFor(before, targetGroup.name, "openrouter");
|
||||
const sameGroupControlNames = quotaNamesFor(before, targetGroup.name, "baidu");
|
||||
const otherGroupControlNames = quotaNamesFor(before, otherGroup.name, "openrouter");
|
||||
assert.ok(targetNames.length > 0, "target openrouter quota combos must exist before DELETE");
|
||||
assert.ok(sameGroupControlNames.length > 0, "same-group baidu control combos must exist");
|
||||
assert.ok(otherGroupControlNames.length > 0, "other-group openrouter control combos must exist");
|
||||
assert.ok(
|
||||
await combosDb.getComboByName(ordinaryCombo.name as string),
|
||||
"ordinary combo must exist"
|
||||
);
|
||||
assert.ok(poolsDb.getPool(targetPool.id), "target pool row must exist before DELETE");
|
||||
assert.equal(
|
||||
countRows(
|
||||
"SELECT count(*) AS count FROM quota_pool_connections WHERE pool_id = ?",
|
||||
targetPool.id
|
||||
),
|
||||
1
|
||||
);
|
||||
assert.equal(
|
||||
countRows("SELECT count(*) AS count FROM quota_allocations WHERE pool_id = ?", targetPool.id),
|
||||
1
|
||||
);
|
||||
assert.deepEqual(getAllowedQuotas(apiKey.id), [targetPool.id, otherGroupPool.id]);
|
||||
|
||||
const response = await deletePoolThroughRoute(targetPool.id);
|
||||
|
||||
assert.equal(response.status, 204);
|
||||
const after = await combosDb.getCombos();
|
||||
assert.deepEqual(
|
||||
quotaNamesFor(after, targetGroup.name, "openrouter"),
|
||||
[],
|
||||
"DELETE must not return while target group+provider quota combos remain"
|
||||
);
|
||||
assert.deepEqual(
|
||||
quotaNamesFor(after, targetGroup.name, "baidu"),
|
||||
sameGroupControlNames,
|
||||
"same-group combos for another provider must remain byte/name-identical"
|
||||
);
|
||||
assert.deepEqual(
|
||||
quotaNamesFor(after, otherGroup.name, "openrouter"),
|
||||
otherGroupControlNames,
|
||||
"same-provider combos for another group must remain byte/name-identical"
|
||||
);
|
||||
assert.deepEqual(
|
||||
await combosDb.getComboByName(ordinaryCombo.name as string),
|
||||
ordinaryCombo,
|
||||
"ordinary user combo must remain unchanged"
|
||||
);
|
||||
assert.equal(poolsDb.getPool(targetPool.id), null);
|
||||
assert.equal(
|
||||
countRows(
|
||||
"SELECT count(*) AS count FROM quota_pool_connections WHERE pool_id = ?",
|
||||
targetPool.id
|
||||
),
|
||||
0
|
||||
);
|
||||
assert.equal(
|
||||
countRows("SELECT count(*) AS count FROM quota_allocations WHERE pool_id = ?", targetPool.id),
|
||||
0
|
||||
);
|
||||
assert.deepEqual(getAllowedQuotas(apiKey.id), [otherGroupPool.id]);
|
||||
const auditEvents = compliance.getAuditLog({ action: "quota.pool.deleted", limit: 10 });
|
||||
assert.ok(
|
||||
auditEvents.some(
|
||||
(event) =>
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
(event as Record<string, unknown>).target === targetPool.id
|
||||
),
|
||||
"successful DELETE must record quota.pool.deleted audit event"
|
||||
);
|
||||
});
|
||||
|
||||
test("DELETE prevents an in-flight create sync from recreating quota combos", async () => {
|
||||
const group = groupsDb.createGroup("Immediate Create Delete Group");
|
||||
const connectionId = await createConnection("openrouter", "immediate-create-delete");
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId,
|
||||
name: "Immediate Create Delete Pool",
|
||||
groupId: group.id,
|
||||
});
|
||||
|
||||
const deleted = await poolsDb.deletePool(pool.id);
|
||||
await nextImmediate();
|
||||
await nextImmediate();
|
||||
|
||||
assert.equal(deleted, true);
|
||||
assert.equal(poolsDb.getPool(pool.id), null);
|
||||
assert.deepEqual(
|
||||
quotaNamesFor(await combosDb.getCombos(), group.name, "openrouter"),
|
||||
[],
|
||||
"a create sync already in flight must not mint quota combos after pool deletion"
|
||||
);
|
||||
});
|
||||
|
||||
test("DELETE prevents an in-flight update sync from recreating quota combos", async () => {
|
||||
const group = groupsDb.createGroup("Immediate Update Delete Group");
|
||||
const connectionId = await createConnection("openrouter", "immediate-update-delete");
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId,
|
||||
name: "Immediate Update Delete Pool",
|
||||
groupId: group.id,
|
||||
});
|
||||
await syncQuotaCombos(pool.id);
|
||||
await nextImmediate();
|
||||
await nextImmediate();
|
||||
await removeQuotaCombosForPool(pool.id);
|
||||
assert.deepEqual(quotaNamesFor(await combosDb.getCombos(), group.name, "openrouter"), []);
|
||||
|
||||
assert.ok(poolsDb.updatePool(pool.id, { name: "Updated Then Deleted Pool" }));
|
||||
const deleted = await poolsDb.deletePool(pool.id);
|
||||
await nextImmediate();
|
||||
await nextImmediate();
|
||||
|
||||
assert.equal(deleted, true);
|
||||
assert.equal(poolsDb.getPool(pool.id), null);
|
||||
assert.deepEqual(
|
||||
quotaNamesFor(await combosDb.getCombos(), group.name, "openrouter"),
|
||||
[],
|
||||
"an update sync already in flight must not recreate quota combos after pool deletion"
|
||||
);
|
||||
});
|
||||
|
||||
test("DELETE rejects a synchronous pool update once deletion has started", async () => {
|
||||
const oldGroup = groupsDb.createGroup("Deleting Pool Old Group");
|
||||
const newGroup = groupsDb.createGroup("Deleting Pool New Group");
|
||||
const connectionId = await createConnection("openrouter", "delete-update-race");
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId,
|
||||
name: "Delete Update Race Pool",
|
||||
groupId: oldGroup.id,
|
||||
});
|
||||
await syncQuotaCombos(pool.id);
|
||||
await nextImmediate();
|
||||
await nextImmediate();
|
||||
assert.ok(quotaNamesFor(await combosDb.getCombos(), oldGroup.name, "openrouter").length > 0);
|
||||
|
||||
const deleting = poolsDb.deletePool(pool.id);
|
||||
const updated = poolsDb.updatePool(pool.id, { groupId: newGroup.id });
|
||||
const deleted = await deleting;
|
||||
await nextImmediate();
|
||||
await nextImmediate();
|
||||
|
||||
assert.equal(updated, null, "a pool must become immutable as soon as deletion starts");
|
||||
assert.equal(deleted, true);
|
||||
assert.equal(poolsDb.getPool(pool.id), null);
|
||||
assert.deepEqual(quotaNamesFor(await combosDb.getCombos(), oldGroup.name, "openrouter"), []);
|
||||
assert.deepEqual(quotaNamesFor(await combosDb.getCombos(), newGroup.name, "openrouter"), []);
|
||||
});
|
||||
|
||||
test("DELETE makes a synchronous allocation upsert a no-op once deletion has started", async () => {
|
||||
const group = groupsDb.createGroup("Deleting Pool Allocation Group");
|
||||
const targetConnectionId = await createConnection("openrouter", "delete-allocation-target");
|
||||
const siblingConnectionId = await createConnection("baidu", "delete-allocation-sibling");
|
||||
const targetPool = poolsDb.createPool({
|
||||
connectionId: targetConnectionId,
|
||||
name: "Delete Allocation Target",
|
||||
groupId: group.id,
|
||||
});
|
||||
const siblingPool = poolsDb.createPool({
|
||||
connectionId: siblingConnectionId,
|
||||
name: "Delete Allocation Sibling",
|
||||
groupId: group.id,
|
||||
});
|
||||
const apiKey = await apiKeysDb.createApiKey("Delete Allocation Key", "delete-allocation-key");
|
||||
await nextImmediate();
|
||||
await nextImmediate();
|
||||
|
||||
const deleting = poolsDb.deletePool(targetPool.id);
|
||||
poolsDb.upsertAllocations(targetPool.id, [{ apiKeyId: apiKey.id, weight: 100, policy: "hard" }]);
|
||||
const deleted = await deleting;
|
||||
|
||||
assert.equal(deleted, true);
|
||||
assert.equal(poolsDb.getPool(targetPool.id), null);
|
||||
assert.deepEqual(
|
||||
poolsDb.getPool(siblingPool.id)?.allocations,
|
||||
[],
|
||||
"an allocation upsert on a deleting pool must not mutate sibling pools"
|
||||
);
|
||||
});
|
||||
|
||||
test("concurrent DELETE calls report one deletion and one missing pool", async () => {
|
||||
const group = groupsDb.createGroup("Concurrent Delete Group");
|
||||
const connectionId = await createConnection("openrouter", "concurrent-delete");
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId,
|
||||
name: "Concurrent Delete Pool",
|
||||
groupId: group.id,
|
||||
});
|
||||
|
||||
const results = await Promise.all([poolsDb.deletePool(pool.id), poolsDb.deletePool(pool.id)]);
|
||||
|
||||
assert.deepEqual(results, [true, false]);
|
||||
assert.equal(poolsDb.getPool(pool.id), null);
|
||||
assert.deepEqual(quotaNamesFor(await combosDb.getCombos(), group.name, "openrouter"), []);
|
||||
});
|
||||
|
||||
test("DELETE nonexistent pool returns sanitized 404 without changing combos", async () => {
|
||||
const missingPoolId = "pool-that-never-existed";
|
||||
const group = groupsDb.createGroup("Missing Pool Control Group");
|
||||
const connectionId = await createConnection("baidu", "missing-pool-control-baidu");
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId,
|
||||
name: "Missing Pool Control",
|
||||
groupId: group.id,
|
||||
});
|
||||
const apiKey = await apiKeysDb.createApiKey("Missing Pool Key", "missing-pool-key");
|
||||
await apiKeysDb.updateApiKeyPermissions(apiKey.id, {
|
||||
allowedQuotas: [missingPoolId, pool.id],
|
||||
});
|
||||
await syncQuotaCombos(pool.id);
|
||||
await nextImmediate();
|
||||
await nextImmediate();
|
||||
await combosDb.createCombo({
|
||||
name: "ordinary-missing-delete-control",
|
||||
models: [{ kind: "model", model: "baidu/control-model", weight: 100 }],
|
||||
strategy: "priority",
|
||||
});
|
||||
const before = await combosDb.getCombos();
|
||||
assert.ok(quotaNamesFor(before, group.name, "baidu").length > 0);
|
||||
|
||||
const response = await deletePoolThroughRoute(missingPoolId);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 404);
|
||||
assert.equal(body.error?.message, "Pool not found");
|
||||
assert.doesNotMatch(JSON.stringify(body), /\s+at\s+\//, "404 must not expose a stack trace");
|
||||
assert.deepEqual(await combosDb.getCombos(), before);
|
||||
assert.deepEqual(
|
||||
getAllowedQuotas(apiKey.id),
|
||||
[missingPoolId, pool.id],
|
||||
"a missing-pool DELETE must not mutate API key permissions"
|
||||
);
|
||||
assert.ok(poolsDb.getPool(pool.id), "unrelated pool must remain");
|
||||
});
|
||||
|
||||
test("DELETE keeps relational cleanup non-fatal when quota-combo listing fails", async () => {
|
||||
const group = groupsDb.createGroup("Cleanup Failure Group");
|
||||
const connectionId = await createConnection("openrouter", "cleanup-failure-openrouter");
|
||||
const apiKey = await apiKeysDb.createApiKey("Cleanup Failure Key", "cleanup-failure-machine");
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId,
|
||||
name: "Cleanup Failure Pool",
|
||||
groupId: group.id,
|
||||
allocations: [{ apiKeyId: apiKey.id, weight: 100, policy: "hard" }],
|
||||
});
|
||||
await apiKeysDb.updateApiKeyPermissions(apiKey.id, { allowedQuotas: [pool.id] });
|
||||
await syncQuotaCombos(pool.id);
|
||||
await nextImmediate();
|
||||
await nextImmediate();
|
||||
assert.ok(quotaNamesFor(await combosDb.getCombos(), group.name, "openrouter").length > 0);
|
||||
|
||||
const db = core.getDbInstance();
|
||||
const originalPrepare = db.prepare.bind(db);
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
db.prepare = ((sql: string) => {
|
||||
if (sql.startsWith("SELECT data, sort_order, context_cache_protection FROM combos ORDER BY")) {
|
||||
throw new Error("forced quota combo listing failure");
|
||||
}
|
||||
return originalPrepare(sql);
|
||||
}) as typeof db.prepare;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await deletePoolThroughRoute(pool.id);
|
||||
await nextImmediate();
|
||||
await nextImmediate();
|
||||
} finally {
|
||||
db.prepare = originalPrepare as typeof db.prepare;
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
}
|
||||
|
||||
assert.equal(response!.status, 204);
|
||||
assert.deepEqual(unhandled, [], "guarded combo failure must not produce unhandledRejection");
|
||||
assert.equal(poolsDb.getPool(pool.id), null);
|
||||
assert.equal(
|
||||
countRows("SELECT count(*) AS count FROM quota_pool_connections WHERE pool_id = ?", pool.id),
|
||||
0
|
||||
);
|
||||
assert.equal(
|
||||
countRows("SELECT count(*) AS count FROM quota_allocations WHERE pool_id = ?", pool.id),
|
||||
0
|
||||
);
|
||||
assert.deepEqual(getAllowedQuotas(apiKey.id), []);
|
||||
});
|
||||
@@ -255,10 +255,7 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image
|
||||
{ id: "2a4f1025-e0dc-4671-a11a-7dfd3c07bd94", usage: "general" },
|
||||
{ id: "84c11d1a-e798-4300-a63e-c06504ca2068", usage: "general" },
|
||||
]);
|
||||
assert.equal(
|
||||
(nano.generationMetadata as Record<string, unknown>).module,
|
||||
"text2image"
|
||||
);
|
||||
assert.equal((nano.generationMetadata as Record<string, unknown>).module, "text2image");
|
||||
|
||||
const gpt = buildAdobeImagePayload({
|
||||
prompt: "edit me",
|
||||
@@ -270,10 +267,7 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image
|
||||
assert.deepEqual(gpt.referenceBlobs, [
|
||||
{ id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "subject" },
|
||||
]);
|
||||
assert.equal(
|
||||
(gpt.generationMetadata as Record<string, unknown>).module,
|
||||
"image2image"
|
||||
);
|
||||
assert.equal((gpt.generationMetadata as Record<string, unknown>).module, "image2image");
|
||||
|
||||
// gpt-image: only first 2 subject refs survive (extra screenshots hang colligo).
|
||||
const gptMany = buildAdobeImagePayload({
|
||||
@@ -312,10 +306,7 @@ test("adobeFireflyMaxImageRefs + adaptive image timeout", () => {
|
||||
DEFAULT_IMAGE_TIMEOUT_MS + 2 * ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS
|
||||
);
|
||||
assert.equal(adobeFireflyImageTimeoutMs({ timeoutMs: 120_000, refCount: 5 }), 120_000);
|
||||
assert.equal(
|
||||
adobeFireflyImageTimeoutMs({ refCount: 99 }),
|
||||
ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS
|
||||
);
|
||||
assert.equal(adobeFireflyImageTimeoutMs({ refCount: 99 }), ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS);
|
||||
});
|
||||
|
||||
test("extractAdobeSourceImageSources reads Media page image fields", () => {
|
||||
@@ -369,10 +360,10 @@ test("resolveAdobeSourceImageIds uploads data URLs then returns blob ids", async
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
assert.match(String(headers["content-type"] || headers["Content-Type"] || ""), /image\//);
|
||||
assert.ok(init?.body);
|
||||
return new Response(
|
||||
JSON.stringify({ images: [{ id: `blob-${uploadCalls}` }] }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
return new Response(JSON.stringify({ images: [{ id: `blob-${uploadCalls}` }] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch ${u}`);
|
||||
};
|
||||
@@ -476,8 +467,7 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => {
|
||||
type: "access_token",
|
||||
client_id: "clio-playground-web",
|
||||
})
|
||||
)
|
||||
.toString("base64url");
|
||||
).toString("base64url");
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url");
|
||||
const token = `${header}.${payload}.${"x".repeat(40)}`;
|
||||
// Pad token length for looksLikeAdobeJwt (>=80)
|
||||
@@ -509,8 +499,7 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => {
|
||||
});
|
||||
|
||||
test("normalizeAdobePollUrl rewrites firefly-epo jobs/result to BKS", () => {
|
||||
const raw =
|
||||
"https://firefly-epo855232.adobe.io/jobs/result/4ae9fd2a-0864-46dd-9834-cfc16e91faa6";
|
||||
const raw = "https://firefly-epo855232.adobe.io/jobs/result/4ae9fd2a-0864-46dd-9834-cfc16e91faa6";
|
||||
const out = normalizeAdobePollUrl(raw);
|
||||
assert.match(out, /^https:\/\/bks-epo8552\.adobe\.io\/v2\/jobs\/result\/4ae9fd2a/);
|
||||
assert.match(out, /host=firefly-epo855232\.adobe\.io/);
|
||||
@@ -592,9 +581,9 @@ test("fallback catalog has image and video entries from get_models capture", ()
|
||||
|
||||
test("extractAdobeAccountIdFromToken reads user_id claim", () => {
|
||||
// {"user_id":"0EB@AdobeID"} base64url
|
||||
const payload = Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token" })).toString(
|
||||
"base64url"
|
||||
);
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token" })
|
||||
).toString("base64url");
|
||||
const jwt = `eyJhbGciOiJub25lIn0.${payload}.sig`;
|
||||
assert.equal(extractAdobeAccountIdFromToken(jwt), "0EB@AdobeID");
|
||||
});
|
||||
@@ -602,18 +591,7 @@ test("extractAdobeAccountIdFromToken reads user_id claim", () => {
|
||||
// --- Handlers (mocked fetch) ----------------------------------------------
|
||||
|
||||
function jsonResponse(status: number, body: unknown, headerMap: Record<string, string> = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: {
|
||||
get: (name: string) => {
|
||||
const key = Object.keys(headerMap).find((k) => k.toLowerCase() === name.toLowerCase());
|
||||
return key ? headerMap[key] : null;
|
||||
},
|
||||
},
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as unknown as Response;
|
||||
return new Response(JSON.stringify(body) ?? null, { status, headers: headerMap });
|
||||
}
|
||||
|
||||
test("handleAdobeFireflyImageGeneration returns 400 when prompt is missing", async () => {
|
||||
@@ -779,13 +757,19 @@ test("guest JWT without AdobeID is detected", () => {
|
||||
const emptyPayload = Buffer.from("{}").toString("base64url");
|
||||
const guestJwt = `eyJhbGciOiJub25lIn0.${emptyPayload}.sig`;
|
||||
// Pad to lookLikeAdobeJwt length if needed
|
||||
const longGuest = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url")}.` + "x".repeat(40);
|
||||
const longGuest =
|
||||
`eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url")}.` +
|
||||
"x".repeat(40);
|
||||
assert.equal(isAdobeGuestAccessToken(longGuest), true);
|
||||
const userJwt =
|
||||
`eyJhbGciOiJSUzI1NiJ9.` +
|
||||
Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" })).toString(
|
||||
"base64url"
|
||||
) +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
user_id: "0EB@AdobeID",
|
||||
type: "access_token",
|
||||
client_id: "clio-playground-web",
|
||||
})
|
||||
).toString("base64url") +
|
||||
`.` +
|
||||
"y".repeat(40);
|
||||
assert.equal(isAdobeGuestAccessToken(userJwt), false);
|
||||
@@ -832,7 +816,13 @@ test("cookie exchange rejects guest IMS tokens", async () => {
|
||||
});
|
||||
|
||||
test("isAdobeTransientSubmitError detects 408 system under load", () => {
|
||||
assert.equal(isAdobeTransientSubmitError(408, '{"error_code":"timeout_error","message":"system under load"}'), true);
|
||||
assert.equal(
|
||||
isAdobeTransientSubmitError(
|
||||
408,
|
||||
'{"error_code":"timeout_error","message":"system under load"}'
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(isAdobeTransientSubmitError(429, "rate"), true);
|
||||
assert.equal(isAdobeTransientSubmitError(400, "bad request"), false);
|
||||
assert.ok(generateAdobeNonce().length === 64);
|
||||
@@ -880,11 +870,7 @@ test("image submit retries on 408 then succeeds", async () => {
|
||||
if (submits < 3) {
|
||||
return jsonResponse(408, { error_code: "timeout_error", message: "system under load" });
|
||||
}
|
||||
return jsonResponse(
|
||||
200,
|
||||
{ links: { result: { href: "https://poll.example/job/r1" } } },
|
||||
{}
|
||||
);
|
||||
return jsonResponse(200, { links: { result: { href: "https://poll.example/job/r1" } } }, {});
|
||||
}
|
||||
if (u.includes("poll.example")) {
|
||||
return jsonResponse(200, {
|
||||
@@ -909,7 +895,11 @@ test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async ()
|
||||
const userTok =
|
||||
`eyJhbGciOiJSUzI1NiJ9.` +
|
||||
Buffer.from(
|
||||
JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" })
|
||||
JSON.stringify({
|
||||
user_id: "0EB@AdobeID",
|
||||
type: "access_token",
|
||||
client_id: "clio-playground-web",
|
||||
})
|
||||
).toString("base64url") +
|
||||
`.` +
|
||||
"s".repeat(40);
|
||||
@@ -931,11 +921,7 @@ test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async ()
|
||||
? (init.headers as Record<string, string>).Authorization
|
||||
: auth;
|
||||
assert.equal(headerAuth, `Bearer ${userTok}`);
|
||||
return jsonResponse(
|
||||
200,
|
||||
{},
|
||||
{ "x-override-status-link": "https://poll.example/job/c1" }
|
||||
);
|
||||
return jsonResponse(200, {}, { "x-override-status-link": "https://poll.example/job/c1" });
|
||||
}
|
||||
if (String(url).includes("poll.example")) {
|
||||
return jsonResponse(200, {
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-claude-rendering-"));
|
||||
const previousDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { openaiResponsesToOpenAIResponse } =
|
||||
await import("../../open-sse/translator/response/openai-responses.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
const { createSSETransformStreamWithLogger } = await import("../../open-sse/utils/stream.ts");
|
||||
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
|
||||
test.after(() => {
|
||||
resetDbInstance();
|
||||
if (previousDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = previousDataDir;
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("Responses->Chat: output_item.done emits arguments when no delta chunks were sent", () => {
|
||||
const state = {
|
||||
@@ -38,7 +53,7 @@ test("Responses->Chat: output_item.done emits arguments when no delta chunks wer
|
||||
assert.equal(state.toolCallIndex, 1);
|
||||
});
|
||||
|
||||
test("Responses->Chat: output_item.done does not re-emit arguments already streamed via deltas", () => {
|
||||
test("Responses->Chat: buffered argument deltas emit once at output_item.done", () => {
|
||||
const state = {
|
||||
started: true,
|
||||
chatId: "chatcmpl-test",
|
||||
@@ -46,9 +61,19 @@ test("Responses->Chat: output_item.done does not re-emit arguments already strea
|
||||
toolCallIndex: 0,
|
||||
finishReasonSent: false,
|
||||
currentToolCallId: "call_abc",
|
||||
currentToolCallArgsBuffer: '{"query":"search"}',
|
||||
currentToolCallArgsBuffer: "",
|
||||
};
|
||||
|
||||
const deltaResult = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
delta: '{"query":"search"}',
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.equal(deltaResult, null);
|
||||
|
||||
const chunk = {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
@@ -62,7 +87,8 @@ test("Responses->Chat: output_item.done does not re-emit arguments already strea
|
||||
|
||||
const result = openaiResponsesToOpenAIResponse(chunk, state);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.ok(result);
|
||||
assert.equal(result.choices[0].delta.tool_calls[0].function.arguments, '{"query":"search"}');
|
||||
assert.equal(state.toolCallIndex, 1);
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ test("ServerSupervisor starts Node with IPv4-first DNS", async () => {
|
||||
|
||||
assert.deepEqual(spawnCalls, [
|
||||
{
|
||||
command: "node",
|
||||
command: process.execPath,
|
||||
args: ["--dns-result-order=ipv4first", "--max-old-space-size=2048", "/app/server.js"],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -80,6 +80,8 @@ test("#7849: the two-message pathological pair stays bounded", () => {
|
||||
Date.now() - started < 4000,
|
||||
"the pathological pair must stay fast; quadratic work would take seconds"
|
||||
);
|
||||
assert.strictEqual(result.body, body, "bounded processing must preserve the input body");
|
||||
assert.equal(result.compressed, false, "the non-deduplicable pair must fail open");
|
||||
assert.ok(Array.isArray((result.body as { messages?: unknown[] }).messages));
|
||||
});
|
||||
|
||||
|
||||
@@ -137,15 +137,15 @@ test("updatePool returns null for unknown id", () => {
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("deletePool removes pool and returns true", () => {
|
||||
test("deletePool removes pool and returns true", async () => {
|
||||
const pool = poolsDb.createPool({ connectionId: "c6", name: "Deletable" });
|
||||
const deleted = poolsDb.deletePool(pool.id);
|
||||
const deleted = await poolsDb.deletePool(pool.id);
|
||||
assert.equal(deleted, true);
|
||||
assert.equal(poolsDb.getPool(pool.id), null);
|
||||
});
|
||||
|
||||
test("deletePool returns false for unknown id", () => {
|
||||
const result = poolsDb.deletePool("ghost-pool");
|
||||
test("deletePool returns false for unknown id", async () => {
|
||||
const result = await poolsDb.deletePool("ghost-pool");
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
@@ -190,14 +190,14 @@ test("upsertAllocations with empty array removes all allocations", () => {
|
||||
// FK CASCADE: delete pool → allocations gone
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("deletePool cascades to allocations", () => {
|
||||
test("deletePool cascades to allocations", async () => {
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId: "c9",
|
||||
name: "With Allocs",
|
||||
allocations: [{ apiKeyId: "k-cascade", weight: 100, policy: "hard" }],
|
||||
});
|
||||
|
||||
poolsDb.deletePool(pool.id);
|
||||
await poolsDb.deletePool(pool.id);
|
||||
|
||||
// After pool is deleted, listAllocationsForApiKey should find nothing for k-cascade
|
||||
const remaining = poolsDb.listAllocationsForApiKey("k-cascade");
|
||||
|
||||
258
tests/unit/gemini-web-capabilities-9356.test.ts
Normal file
258
tests/unit/gemini-web-capabilities-9356.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
// Capability enforcement for the Gemini Web executor (#9356).
|
||||
//
|
||||
// Reported: gemini-web silently ACCEPTS `reasoning_effort` and
|
||||
// `tool_choice: "required"` and answers with ordinary prose — HTTP 200, no
|
||||
// `reasoning_content`, `tool_calls: []`, `finish_reason: "stop"`. An
|
||||
// AgentChakra/OpenClaw agent then believes its reasoning and tool requirements
|
||||
// were honored when they were not.
|
||||
//
|
||||
// Why neither can be implemented for THIS provider: gemini-web is not an API
|
||||
// client. It launches Playwright, types a single flat prompt string into the
|
||||
// gemini.google.com `.ql-editor` contenteditable, presses Enter, and captures
|
||||
// the first `StreamGenerate` response off the page. There is no request payload
|
||||
// to carry a thinking budget, and no function-calling channel to force — the
|
||||
// tools support it does have is the prompt-emulation shim (`webTools.ts`, #7286),
|
||||
// which ASKS the model to emit `<tool>{...}</tool>` and cannot GUARANTEE it.
|
||||
//
|
||||
// So this suite pins the issue's option (b) for both controls: reject the
|
||||
// requests we cannot honor, and keep honoring the ones we can. The line drawn:
|
||||
//
|
||||
// reasoning_effort none | minimal → allowed (gemini-web not thinking
|
||||
// IS compliance with "spend little")
|
||||
// low | medium | high… → 400, a positive request to think
|
||||
// tool_choice absent | auto | none → allowed (emulation path, #7286)
|
||||
// required | any | {fn} → 400, a guarantee we cannot make
|
||||
//
|
||||
// The guard must run BEFORE Playwright launches, so every executor assertion
|
||||
// here completes without a browser.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { GeminiWebExecutor } = await import("../../open-sse/executors/gemini-web.ts");
|
||||
const { checkGeminiWebUnsupportedControls, GEMINI_WEB_UNSUPPORTED_CONTROL_CODE } =
|
||||
await import("../../open-sse/executors/gemini-web/capabilities.ts");
|
||||
const { gemini_webProvider } =
|
||||
await import("../../open-sse/config/providers/registry/gemini/web/index.ts");
|
||||
const { supportsReasoning, supportsToolCalling } =
|
||||
await import("../../src/lib/modelCapabilities.ts");
|
||||
const { providerSupportsEmulatedToolCalling } =
|
||||
await import("../../open-sse/services/combo/comboStructure.ts");
|
||||
|
||||
const GET_WEATHER_TOOL = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get the current weather for a city",
|
||||
parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
},
|
||||
};
|
||||
|
||||
interface ErrorBodyLike {
|
||||
error: { message: string; type: string; code: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the executor with valid-looking credentials. Every case in this suite is
|
||||
* expected to short-circuit on the capability guard, so Playwright is never
|
||||
* reached — a test that hangs here means the guard did not fire.
|
||||
*/
|
||||
async function run(body: Record<string, unknown>) {
|
||||
return new GeminiWebExecutor().execute({
|
||||
model: "gemini-3.6-flash",
|
||||
body: { messages: [{ role: "user", content: "hi" }], stream: false, ...body },
|
||||
stream: false,
|
||||
credentials: { apiKey: "__Secure-1PSID=test-cookie" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Pure checker: reasoning_effort ─────────────────────────────────────────
|
||||
|
||||
test("#9356 reasoning_effort low/medium/high/xhigh are rejected as unsupported", () => {
|
||||
for (const effort of ["low", "medium", "high", "xhigh"]) {
|
||||
const violation = checkGeminiWebUnsupportedControls({ reasoning_effort: effort });
|
||||
assert.equal(
|
||||
violation?.param,
|
||||
"reasoning_effort",
|
||||
`reasoning_effort="${effort}" asks gemini-web to think harder, which a typed browser ` +
|
||||
`prompt cannot express — it must be rejected, not silently dropped`
|
||||
);
|
||||
assert.match(violation!.message, /reasoning_effort/);
|
||||
}
|
||||
});
|
||||
|
||||
test("#9356 reasoning_effort none/minimal and absent stay allowed", () => {
|
||||
assert.equal(checkGeminiWebUnsupportedControls({}), null);
|
||||
assert.equal(checkGeminiWebUnsupportedControls({ reasoning_effort: null }), null);
|
||||
assert.equal(checkGeminiWebUnsupportedControls({ reasoning_effort: "none" }), null);
|
||||
assert.equal(
|
||||
checkGeminiWebUnsupportedControls({ reasoning_effort: "minimal" }),
|
||||
null,
|
||||
'"minimal" means spend as little reasoning as possible — a non-thinking provider ' +
|
||||
"already satisfies it, so rejecting it would be gratuitous"
|
||||
);
|
||||
assert.equal(checkGeminiWebUnsupportedControls({ reasoning_effort: " NONE " }), null);
|
||||
});
|
||||
|
||||
// ─── Pure checker: tool_choice ──────────────────────────────────────────────
|
||||
|
||||
test("#9356 tool_choice required/any is rejected as unsupported", () => {
|
||||
for (const choice of ["required", "any"]) {
|
||||
const violation = checkGeminiWebUnsupportedControls({
|
||||
tools: [GET_WEATHER_TOOL],
|
||||
tool_choice: choice,
|
||||
});
|
||||
assert.equal(
|
||||
violation?.param,
|
||||
"tool_choice",
|
||||
`tool_choice="${choice}" is a guarantee the prompt-emulation shim cannot make`
|
||||
);
|
||||
assert.match(violation!.message, /tool_choice/);
|
||||
}
|
||||
});
|
||||
|
||||
test("#9356 a forced-function tool_choice object is rejected as unsupported", () => {
|
||||
const violation = checkGeminiWebUnsupportedControls({
|
||||
tools: [GET_WEATHER_TOOL],
|
||||
tool_choice: { type: "function", function: { name: "get_weather" } },
|
||||
});
|
||||
assert.equal(violation?.param, "tool_choice");
|
||||
|
||||
// Anthropic-style forcing, which the translators also emit.
|
||||
assert.equal(
|
||||
checkGeminiWebUnsupportedControls({
|
||||
tools: [GET_WEATHER_TOOL],
|
||||
tool_choice: { type: "any" },
|
||||
})?.param,
|
||||
"tool_choice"
|
||||
);
|
||||
});
|
||||
|
||||
test("#9356 tool_choice auto/none and absent keep the #7286 emulation path open", () => {
|
||||
assert.equal(checkGeminiWebUnsupportedControls({ tools: [GET_WEATHER_TOOL] }), null);
|
||||
assert.equal(
|
||||
checkGeminiWebUnsupportedControls({ tools: [GET_WEATHER_TOOL], tool_choice: "auto" }),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
checkGeminiWebUnsupportedControls({ tools: [GET_WEATHER_TOOL], tool_choice: "none" }),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("#9356 forcing is rejected on its own terms, even with no tools[] array", () => {
|
||||
// An agent that sets tool_choice without tools is already malformed, but the
|
||||
// point stands: never report success for a forcing contract we ignore.
|
||||
assert.equal(
|
||||
checkGeminiWebUnsupportedControls({ tool_choice: "required" })?.param,
|
||||
"tool_choice"
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Executor wiring ────────────────────────────────────────────────────────
|
||||
|
||||
test("#9356 executor returns 400 for reasoning_effort=high before launching a browser", async () => {
|
||||
const result = await run({ reasoning_effort: "high" });
|
||||
|
||||
assert.equal(result.response.status, 400);
|
||||
const body = (await result.response.json()) as ErrorBodyLike;
|
||||
assert.equal(body.error.code, GEMINI_WEB_UNSUPPORTED_CONTROL_CODE);
|
||||
assert.match(body.error.message, /reasoning_effort/);
|
||||
assert.equal(
|
||||
body.error.message.includes("at /"),
|
||||
false,
|
||||
"error bodies must stay sanitized — no stack traces"
|
||||
);
|
||||
});
|
||||
|
||||
test("#9356 executor returns 400 for tool_choice=required before launching a browser", async () => {
|
||||
const result = await run({ tools: [GET_WEATHER_TOOL], tool_choice: "required" });
|
||||
|
||||
assert.equal(result.response.status, 400);
|
||||
const body = (await result.response.json()) as ErrorBodyLike;
|
||||
assert.equal(body.error.code, GEMINI_WEB_UNSUPPORTED_CONTROL_CODE);
|
||||
assert.match(body.error.message, /tool_choice/);
|
||||
});
|
||||
|
||||
test("#9356 the capability guard runs ahead of the credential check", async () => {
|
||||
// A request that is BOTH uncredentialed and incompatible must report the
|
||||
// incompatibility: adding a cookie would not make it work.
|
||||
const result = await new GeminiWebExecutor().execute({
|
||||
model: "gemini-3.6-flash",
|
||||
body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" },
|
||||
stream: false,
|
||||
credentials: {},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 400);
|
||||
const body = (await result.response.json()) as ErrorBodyLike;
|
||||
assert.equal(body.error.code, GEMINI_WEB_UNSUPPORTED_CONTROL_CODE);
|
||||
});
|
||||
|
||||
test("#9356 a supported request still falls through the guard untouched", async () => {
|
||||
// tool_choice:"auto" + tools[] is the #7286 emulation contract. It must NOT
|
||||
// be blocked — reaching the (missing) credential check proves the guard let
|
||||
// it pass, without needing a browser to prove it.
|
||||
const result = await new GeminiWebExecutor().execute({
|
||||
model: "gemini-3.6-flash",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [GET_WEATHER_TOOL],
|
||||
tool_choice: "auto",
|
||||
},
|
||||
stream: false,
|
||||
credentials: {},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 401, "should reach the cookie check, not the guard");
|
||||
});
|
||||
|
||||
// ─── Catalog metadata ───────────────────────────────────────────────────────
|
||||
|
||||
test("#9356 registry advertises no native tool calling and no reasoning for gemini-web", () => {
|
||||
assert.ok(gemini_webProvider.models.length > 0);
|
||||
for (const model of gemini_webProvider.models) {
|
||||
assert.equal(
|
||||
model.toolCalling,
|
||||
false,
|
||||
`${model.id} must not advertise native tool calling — /v1/models feeds agent routers`
|
||||
);
|
||||
assert.equal(
|
||||
model.supportsReasoning,
|
||||
false,
|
||||
`${model.id} must advertise reasoning:false so agent routers stop selecting it for ` +
|
||||
"reasoning work (the executor has no thinking control to drive)"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("#9356 resolved capabilities — not just the raw registry — report no reasoning/tools", () => {
|
||||
// The registry literal is only the input; `getResolvedModelCapabilities` is what
|
||||
// the catalog, the combo compatibility filter and the thinking-budget translator
|
||||
// actually read. Assert the resolved view so a downstream default cannot quietly
|
||||
// re-advertise a capability the executor does not have.
|
||||
for (const model of gemini_webProvider.models) {
|
||||
const input = { provider: "gemini-web", model: model.id };
|
||||
assert.equal(supportsReasoning(input), false, `${model.id} resolved reasoning must be false`);
|
||||
assert.equal(
|
||||
supportsToolCalling(input),
|
||||
false,
|
||||
`${model.id} resolved NATIVE tool calling must be false — prompt emulation is advertised ` +
|
||||
'separately as toolCalling:"emulated" on the provider constant'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("#9356 the provider still advertises emulated tool calling, so #7286 combos keep routing", () => {
|
||||
// Guard against over-correcting: dropping the emulation advertisement here would
|
||||
// make filterTargetsByRequestCompatibility fail these targets closed and break
|
||||
// emulation-only combos (#5240 / #8488).
|
||||
assert.equal(providerSupportsEmulatedToolCalling("gemini-web"), true);
|
||||
assert.equal(providerSupportsEmulatedToolCalling("gweb"), true);
|
||||
});
|
||||
@@ -97,6 +97,21 @@ test("package.json files[] excludes nested node_modules from the published packa
|
||||
);
|
||||
});
|
||||
|
||||
test("build-next-isolated sibling imports are allowed in the published package", () => {
|
||||
const buildDependencies = [
|
||||
"scripts/build/assembleStandalone.mjs",
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
];
|
||||
|
||||
const unexpectedPaths = findUnexpectedArtifactPaths(buildDependencies, {
|
||||
exactPaths: PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
|
||||
prefixPaths: PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
|
||||
});
|
||||
|
||||
assert.deepEqual(unexpectedPaths, []);
|
||||
});
|
||||
|
||||
test("webdav-handler.mjs is allowed in staging dist/ (server-ws.mjs dependency, missed in 3.8.22 build)", () => {
|
||||
const unexpectedPaths = findUnexpectedArtifactPaths(["webdav-handler.mjs"], {
|
||||
exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS,
|
||||
|
||||
@@ -212,7 +212,7 @@ test("#8969: mocked execute posts Chat Completions with Bearer, no Cookie, strip
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
const rawBody = typeof init?.body === "string" ? init.body : "{}";
|
||||
const rawBody = await new Request(input, init).text();
|
||||
seen.push({
|
||||
url: String(input),
|
||||
method: (init?.method || "GET").toUpperCase(),
|
||||
|
||||
167
tests/unit/quality-validation-benign-error.test.ts
Normal file
167
tests/unit/quality-validation-benign-error.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* TDD regression guard — quality validation false-positive on benign `error`
|
||||
* fields in streaming SSE chunks.
|
||||
*
|
||||
* `isStreamingUpstreamError` treats ANY non-null `error` field as an upstream
|
||||
* failure: `parsed.error != null` is true for `{}`, `""`, `false`, and `0`.
|
||||
* When a client like opencode issues a tool-call turn, the upstream SSE opens
|
||||
* with role-only frames (no recognized content) and a later chunk that carries
|
||||
* real tool_calls content PLUS a benign empty `error` field (a field some
|
||||
* backends emit on every chunk). The error gate runs BEFORE the content
|
||||
* recognizers, so that single frame short-circuits to "error" → 502
|
||||
* "streaming upstream error" — while the same combo via kilocode (different
|
||||
* wire format) never emits the empty `error` field and works fine.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { validateResponseQuality } = await import("../../open-sse/services/combo.ts");
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const silentLog = { warn: () => {} };
|
||||
|
||||
function openAiSseStream(events: string[]): ReadableStream<Uint8Array> {
|
||||
const body = events.join("\n") + "\n";
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(body));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible tool-call stream that ALSO carries a benign empty `error`
|
||||
* field on the tool_calls chunk. Some backends emit `"error": {}` or
|
||||
* `"error": ""` alongside every chunk; that is not a real upstream failure.
|
||||
* The frame must be treated as CONTENT (valid), not ERROR.
|
||||
*/
|
||||
function makeToolCallStreamWithBenignError(): Response {
|
||||
const events = [
|
||||
// role-only first chunk — no recognized content, widens the peek window
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_1",
|
||||
object: "chat.completion.chunk",
|
||||
created: 123,
|
||||
model: "gpt-4o",
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
})}`,
|
||||
"",
|
||||
// tool_calls delta + benign empty `error` field (the bug trigger)
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_2",
|
||||
object: "chat.completion.chunk",
|
||||
created: 123,
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: 0, id: "call_1", type: "function", function: { name: "Bash", arguments: "" } },
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
error: {},
|
||||
})}`,
|
||||
"",
|
||||
`data: [DONE]`,
|
||||
"",
|
||||
];
|
||||
return new Response(openAiSseStream(events), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
test("OpenAI stream with tool_calls + benign empty error:{} field is VALID (not 502)", async () => {
|
||||
const res = makeToolCallStreamWithBenignError();
|
||||
const out = await validateResponseQuality(res, true, silentLog);
|
||||
assert.equal(
|
||||
out.valid,
|
||||
true,
|
||||
`expected valid for tool_calls chunk with benign error:{}, got valid=false (reason: ${out.reason})`
|
||||
);
|
||||
assert.ok(out.clonedResponse, "clonedResponse must be present for valid streaming response");
|
||||
});
|
||||
|
||||
test("OpenAI stream with tool_calls + benign empty error:'' field is VALID", async () => {
|
||||
const events = [
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_3",
|
||||
object: "chat.completion.chunk",
|
||||
created: 123,
|
||||
model: "gpt-4o",
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
})}`,
|
||||
"",
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_4",
|
||||
object: "chat.completion.chunk",
|
||||
created: 123,
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: 0, id: "call_2", type: "function", function: { name: "Read", arguments: "" } },
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
error: "",
|
||||
})}`,
|
||||
"",
|
||||
`data: [DONE]`,
|
||||
"",
|
||||
];
|
||||
const res = new Response(openAiSseStream(events), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
const out = await validateResponseQuality(res, true, silentLog);
|
||||
assert.equal(
|
||||
out.valid,
|
||||
true,
|
||||
`expected valid for tool_calls chunk with benign error:"", got valid=false (reason: ${out.reason})`
|
||||
);
|
||||
});
|
||||
|
||||
test("Stream with a REAL non-empty error object is still flagged as invalid", async () => {
|
||||
const events = [
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_5",
|
||||
object: "chat.completion.chunk",
|
||||
created: 123,
|
||||
model: "gpt-4o",
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
})}`,
|
||||
"",
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_6",
|
||||
object: "chat.completion.chunk",
|
||||
created: 123,
|
||||
model: "gpt-4o",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: null }],
|
||||
error: { message: "upstream quota exceeded", code: "rate_limit_exceeded" },
|
||||
})}`,
|
||||
"",
|
||||
`data: [DONE]`,
|
||||
"",
|
||||
];
|
||||
const res = new Response(openAiSseStream(events), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
const out = await validateResponseQuality(res, true, silentLog);
|
||||
assert.equal(
|
||||
out.valid,
|
||||
false,
|
||||
`expected invalid for real error object, got valid=true (reason: ${out.reason})`
|
||||
);
|
||||
assert.match(out.reason ?? "", /streaming upstream error/, "reason should mention the upstream error");
|
||||
});
|
||||
@@ -57,9 +57,7 @@ test.after(async () => {
|
||||
// ── D1.1: Migration file ────────────────────────────────────────────────────
|
||||
|
||||
test("migration 086 file exists and contains quota_pool_connections DDL", () => {
|
||||
const migrationPath = path.resolve(
|
||||
"src/lib/db/migrations/087_quota_pool_connections.sql"
|
||||
);
|
||||
const migrationPath = path.resolve("src/lib/db/migrations/087_quota_pool_connections.sql");
|
||||
assert.ok(fs.existsSync(migrationPath), `migration file not found: ${migrationPath}`);
|
||||
|
||||
const sql = fs.readFileSync(migrationPath, "utf8");
|
||||
@@ -153,14 +151,14 @@ test("updatePool without connectionIds leaves join rows untouched", () => {
|
||||
|
||||
// ── D1.4: deletePool removes join rows ────────────────────────────────────
|
||||
|
||||
test("deletePool removes quota_pool_connections rows", () => {
|
||||
test("deletePool removes quota_pool_connections rows", async () => {
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId: "del-a",
|
||||
name: "To Delete",
|
||||
connectionIds: ["del-a", "del-b"],
|
||||
});
|
||||
|
||||
const deleted = poolsDb.deletePool(pool.id);
|
||||
const deleted = await poolsDb.deletePool(pool.id);
|
||||
assert.equal(deleted, true, "deletePool should return true");
|
||||
|
||||
// Pool should be gone.
|
||||
|
||||
@@ -23,8 +23,7 @@ import path from "node:path";
|
||||
// ── DB harness (same pattern as quota-exclusivity-reconcile.test.ts) ─────────
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pool-delete-prune-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET =
|
||||
process.env.API_KEY_SECRET || "delete-prune-test-secret-32chars!!";
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "delete-prune-test-secret-32chars!!";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const poolsDb = await import("../../src/lib/db/quotaPools.ts");
|
||||
@@ -62,9 +61,8 @@ test.after(async () => {
|
||||
// ── Helper: get allowed_quotas for a key by id from DB ───────────────────────
|
||||
function getAllowedQuotasById(keyId: string): string[] {
|
||||
const db = core.getDbInstance();
|
||||
const row = (db as any)
|
||||
.prepare("SELECT allowed_quotas FROM api_keys WHERE id = ?")
|
||||
.get(keyId) as { allowed_quotas: string } | undefined;
|
||||
const row = (db as any).prepare("SELECT allowed_quotas FROM api_keys WHERE id = ?").get(keyId) as
|
||||
{ allowed_quotas: string } | undefined;
|
||||
if (!row) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(row.allowed_quotas ?? "[]");
|
||||
@@ -85,7 +83,7 @@ test("deletePool prunes its id from api_key allowed_quotas", async () => {
|
||||
const before = getAllowedQuotasById(keyObj.id);
|
||||
assert.ok(before.includes(pool.id), `pool.id should be in allowed_quotas before delete`);
|
||||
|
||||
poolsDb.deletePool(pool.id);
|
||||
await poolsDb.deletePool(pool.id);
|
||||
|
||||
const after = getAllowedQuotasById(keyObj.id);
|
||||
assert.ok(!after.includes(pool.id), `pool.id should NOT be in allowed_quotas after delete`);
|
||||
@@ -103,7 +101,7 @@ test("deletePool preserves unrelated pool ids in allowed_quotas", async () => {
|
||||
allowedQuotas: [poolToDelete.id, otherPool.id, unrelatedId],
|
||||
});
|
||||
|
||||
poolsDb.deletePool(poolToDelete.id);
|
||||
await poolsDb.deletePool(poolToDelete.id);
|
||||
|
||||
const after = getAllowedQuotasById(keyObj.id);
|
||||
assert.ok(!after.includes(poolToDelete.id), "deleted pool id should be removed");
|
||||
@@ -121,7 +119,7 @@ test("deletePool does not modify keys that don't reference the deleted pool", as
|
||||
// This key only references otherPool, not poolToDelete
|
||||
await apiKeysDb.updateApiKeyPermissions(keyObj.id, { allowedQuotas: [otherPool.id] });
|
||||
|
||||
poolsDb.deletePool(poolToDelete.id);
|
||||
await poolsDb.deletePool(poolToDelete.id);
|
||||
|
||||
const after = getAllowedQuotasById(keyObj.id);
|
||||
assert.deepEqual(after, [otherPool.id], "key referencing only other pool should be unchanged");
|
||||
@@ -134,7 +132,7 @@ test("deletePool: key with empty allowed_quotas stays empty", async () => {
|
||||
const keyObj = await apiKeysDb.createApiKey("Prune Key 4", "machine-prune-4");
|
||||
// Don't set allowedQuotas — default is []
|
||||
|
||||
poolsDb.deletePool(pool.id);
|
||||
await poolsDb.deletePool(pool.id);
|
||||
|
||||
const after = getAllowedQuotasById(keyObj.id);
|
||||
assert.deepEqual(after, [], "empty allowed_quotas should remain empty after delete");
|
||||
@@ -156,7 +154,7 @@ test("deletePool prunes pool id from ALL keys that reference it", async () => {
|
||||
await apiKeysDb.updateApiKeyPermissions(k.id, { allowedQuotas: [pool.id, otherPoolId] });
|
||||
}
|
||||
|
||||
poolsDb.deletePool(pool.id);
|
||||
await poolsDb.deletePool(pool.id);
|
||||
|
||||
for (const k of keys) {
|
||||
const after = getAllowedQuotasById(k.id);
|
||||
@@ -167,23 +165,31 @@ test("deletePool prunes pool id from ALL keys that reference it", async () => {
|
||||
|
||||
// ── 6. deletePool still returns true/false correctly ─────────────────────────
|
||||
|
||||
test("deletePool returns true for existing pool, false for non-existent", () => {
|
||||
test("deletePool returns true for existing pool, false for non-existent", async () => {
|
||||
const pool = poolsDb.createPool({ connectionId: "conn-ret-1", name: "Return Test" });
|
||||
assert.equal(poolsDb.deletePool(pool.id), true, "should return true for existing pool");
|
||||
assert.equal(poolsDb.deletePool(pool.id), false, "should return false for already-deleted pool");
|
||||
assert.equal(poolsDb.deletePool("nonexistent-id"), false, "should return false for unknown id");
|
||||
assert.equal(await poolsDb.deletePool(pool.id), true, "should return true for existing pool");
|
||||
assert.equal(
|
||||
await poolsDb.deletePool(pool.id),
|
||||
false,
|
||||
"should return false for already-deleted pool"
|
||||
);
|
||||
assert.equal(
|
||||
await poolsDb.deletePool("nonexistent-id"),
|
||||
false,
|
||||
"should return false for unknown id"
|
||||
);
|
||||
});
|
||||
|
||||
// ── 7. Pool row and allocation rows are gone after delete (regression guard) ──
|
||||
|
||||
test("deletePool removes pool and allocation rows from DB", () => {
|
||||
test("deletePool removes pool and allocation rows from DB", async () => {
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId: "conn-reg-1",
|
||||
name: "Regression Pool",
|
||||
allocations: [{ apiKeyId: "key-reg-1", weight: 50, policy: "hard" }],
|
||||
});
|
||||
|
||||
poolsDb.deletePool(pool.id);
|
||||
await poolsDb.deletePool(pool.id);
|
||||
|
||||
assert.equal(poolsDb.getPool(pool.id), null, "getPool should return null after delete");
|
||||
const { items: allPools } = poolsDb.listPools();
|
||||
|
||||
@@ -26,6 +26,12 @@ function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Leave enough scheduling headroom for a loaded CI/devbox while keeping the
|
||||
// executing callback longer than the queue-only budget. The actual queued-job
|
||||
// case stays short because it controls dispatch deterministically.
|
||||
const DISPATCHED_QUEUE_BUDGET_MS = 2_000;
|
||||
const QUEUED_QUEUE_BUDGET_MS = 250;
|
||||
|
||||
test.afterEach(async () => {
|
||||
await rateLimitManager.__resetRateLimitManagerForTests();
|
||||
});
|
||||
@@ -43,14 +49,23 @@ async function triggerQueueTimeout() {
|
||||
concurrentRequests: 1,
|
||||
requestsPerMinute: 100000,
|
||||
minTimeBetweenRequestsMs: 0,
|
||||
maxWaitMs: 40,
|
||||
maxWaitMs: DISPATCHED_QUEUE_BUDGET_MS,
|
||||
});
|
||||
rateLimitManager.enableRateLimitProtection("conn-queue-timeout");
|
||||
const connectionId = "conn-dispatched-timeout";
|
||||
rateLimitManager.enableRateLimitProtection(connectionId);
|
||||
|
||||
return rateLimitManager.withRateLimit("openai", "conn-queue-timeout", "gpt-4o", async () => {
|
||||
await wait(400); // > maxWaitMs (40ms) → Bottleneck fails the job
|
||||
return "should-not-reach";
|
||||
});
|
||||
let dispatched = false;
|
||||
const result = await rateLimitManager.withRateLimit(
|
||||
"test-provider",
|
||||
connectionId,
|
||||
null,
|
||||
async () => {
|
||||
dispatched = true;
|
||||
await wait(DISPATCHED_QUEUE_BUDGET_MS + 250);
|
||||
return "should-not-reach";
|
||||
}
|
||||
);
|
||||
return { dispatched, result };
|
||||
}
|
||||
|
||||
async function triggerQueuedTimeout() {
|
||||
@@ -60,7 +75,7 @@ async function triggerQueuedTimeout() {
|
||||
concurrentRequests: 1,
|
||||
requestsPerMinute: 0,
|
||||
minTimeBetweenRequestsMs: 0,
|
||||
maxWaitMs: 40,
|
||||
maxWaitMs: QUEUED_QUEUE_BUDGET_MS,
|
||||
});
|
||||
const connectionId = "conn-queued-timeout";
|
||||
rateLimitManager.enableRateLimitProtection(connectionId);
|
||||
@@ -79,13 +94,12 @@ async function triggerQueuedTimeout() {
|
||||
await firstExecuting;
|
||||
|
||||
let caught: unknown;
|
||||
let queuedDispatched = false;
|
||||
try {
|
||||
await rateLimitManager.withRateLimit(
|
||||
"test-provider",
|
||||
connectionId,
|
||||
null,
|
||||
async () => "should-not-dispatch"
|
||||
);
|
||||
await rateLimitManager.withRateLimit("test-provider", connectionId, null, async () => {
|
||||
queuedDispatched = true;
|
||||
return "should-not-dispatch";
|
||||
});
|
||||
assert.fail("expected the queued job to expire");
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
@@ -93,16 +107,20 @@ async function triggerQueuedTimeout() {
|
||||
releaseFirst();
|
||||
await first;
|
||||
}
|
||||
return caught;
|
||||
return { caught, queuedDispatched };
|
||||
}
|
||||
|
||||
test("#4165 a dispatched provider call is not killed by the queue budget", async () => {
|
||||
const result = await triggerQueueTimeout();
|
||||
assert.equal(result, "should-not-reach");
|
||||
const execution = await triggerQueueTimeout();
|
||||
assert.equal(execution.dispatched, true, "the callback must enter execution");
|
||||
assert.equal(execution.result, "should-not-reach");
|
||||
});
|
||||
|
||||
test("#4165 queue expiry surfaces a clear local error", async () => {
|
||||
const caught = (await triggerQueuedTimeout()) as Error & { code?: string };
|
||||
const result = await triggerQueuedTimeout();
|
||||
assert.ok(result.caught instanceof Error, "queue expiry must reject with an Error");
|
||||
assert.equal(result.queuedDispatched, false, "an expired queued callback must never dispatch");
|
||||
const caught = result.caught as Error & { code?: string };
|
||||
assert.equal(caught.code, "RATE_LIMIT_QUEUE_TIMEOUT");
|
||||
assert.match(caught.message, /maxWaitMs/);
|
||||
assert.match(caught.message, /not an upstream/i);
|
||||
|
||||
53
tests/unit/repro-7754.test.ts
Normal file
53
tests/unit/repro-7754.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createBuiltinAutoCombo } from "@omniroute/open-sse/services/autoCombo/builtinCatalog.ts";
|
||||
|
||||
// #7754: `auto/best-free` combo name must never leak downstream as the model id.
|
||||
// When the free-tier candidate pool resolves non-empty, every model in the combo
|
||||
// must carry a concrete `<provider>/<model>` id — never the literal combo name.
|
||||
|
||||
test("#7754 auto/best-free never leaks the combo name as a model", async () => {
|
||||
const combo = await createBuiltinAutoCombo("auto/best-free", "best-free");
|
||||
const models = combo.models || [];
|
||||
|
||||
// The combo id is the modelStr by design (routing resolves it back), but the
|
||||
// models array must never contain it as a target model.
|
||||
const leak = models.filter(
|
||||
(m) =>
|
||||
(m.id || "") === "auto/best-free" ||
|
||||
(m.model || "") === "auto/best-free" ||
|
||||
(m.modelStr || "") === "auto/best-free"
|
||||
);
|
||||
assert.equal(leak.length, 0, `combo name leaked as a target model: ${JSON.stringify(leak)}`);
|
||||
});
|
||||
|
||||
test("#7754 every auto/best-free model carries a concrete provider/model", async () => {
|
||||
const combo = await createBuiltinAutoCombo("auto/best-free", "best-free");
|
||||
const models = combo.models || [];
|
||||
for (const m of models) {
|
||||
assert.ok(
|
||||
m.model && m.model !== "auto/best-free",
|
||||
`model missing concrete id: ${JSON.stringify(m)}`
|
||||
);
|
||||
assert.ok(
|
||||
m.providerId && m.providerId !== "auto",
|
||||
`model missing concrete provider: ${JSON.stringify(m)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("#7754 empty free-tier pool degrades with a clear 503, not a name leak", async () => {
|
||||
// When NO free-tier candidate exists, createBuiltinAutoCombo must either
|
||||
// return an empty models[] (which the #6458 route check converts to a clear
|
||||
// 503) or throw — never synthesize a target whose model is the combo name.
|
||||
const combo = await createBuiltinAutoCombo("auto/best-free", "best-free");
|
||||
const models = combo.models || [];
|
||||
if (models.length === 0) {
|
||||
// Empty pool is fine — the route layer (#6458) converts it to a clear 503.
|
||||
assert.equal(combo.candidatePool?.length || 0, 0);
|
||||
} else {
|
||||
// Non-empty pool must not leak.
|
||||
const leak = models.filter((m) => (m.model || "") === "auto/best-free");
|
||||
assert.equal(leak.length, 0);
|
||||
}
|
||||
});
|
||||
@@ -9,15 +9,29 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "../..");
|
||||
const WORKFLOW = resolve(repoRoot, ".github/workflows/quality.yml");
|
||||
|
||||
function loadWorkflow(): any {
|
||||
return parse(readFileSync(WORKFLOW, "utf8"));
|
||||
interface WorkflowStep {
|
||||
name?: string;
|
||||
run?: string;
|
||||
"continue-on-error"?: boolean;
|
||||
}
|
||||
|
||||
interface WorkflowDocument {
|
||||
jobs?: Record<string, { steps?: WorkflowStep[] }>;
|
||||
}
|
||||
|
||||
function loadWorkflow(): WorkflowDocument {
|
||||
return parse(readFileSync(WORKFLOW, "utf8")) as WorkflowDocument;
|
||||
}
|
||||
|
||||
function invokesGate(run: string): boolean {
|
||||
if (!run) return false;
|
||||
return /npm run (check:|typecheck:)/.test(run) || /npm run "check/.test(run) || /npm run \\"check/.test(run);
|
||||
return (
|
||||
/npm run (check:|typecheck:)/.test(run) ||
|
||||
/npm run "check/.test(run) ||
|
||||
/npm run \\"check/.test(run)
|
||||
);
|
||||
}
|
||||
function stepCanFail(step: any): boolean {
|
||||
function stepCanFail(step: WorkflowStep): boolean {
|
||||
return step?.["continue-on-error"] !== true;
|
||||
}
|
||||
|
||||
@@ -25,7 +39,7 @@ test("repro #8542: fast-gates must not fail-fast into a later gate", () => {
|
||||
const wf = loadWorkflow();
|
||||
const job = wf.jobs?.["fast-gates"];
|
||||
assert.ok(job, "fast-gates job must exist");
|
||||
const steps: any[] = job.steps ?? [];
|
||||
const steps: WorkflowStep[] = job.steps ?? [];
|
||||
assert.ok(steps.length >= 5, `fast-gates must have >=5 steps, got ${steps.length}`);
|
||||
|
||||
const gateSteps = steps.map((s, i) => ({ s, i })).filter(({ s }) => invokesGate(s?.run ?? ""));
|
||||
@@ -51,4 +65,4 @@ test("repro #8542: fast-gates must not fail-fast into a later gate", () => {
|
||||
maskedPairs.slice(0, 12).join("\n") +
|
||||
(maskedPairs.length > 12 ? `\n... (+${maskedPairs.length - 12} more)` : "")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
70
tests/unit/repro-8847.test.ts
Normal file
70
tests/unit/repro-8847.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { syncStandaloneNativeAssets } from "../../scripts/build/assembleStandalone.mjs";
|
||||
|
||||
/**
|
||||
* Repro #8847: better-sqlite3 prebuilds are not included in the standalone
|
||||
* bundle, so the bundled app fails when the platform's prebuild is needed
|
||||
* (e.g. under Bun, which resolves the native binary via prebuilds/ rather
|
||||
* than build/Release/).
|
||||
*
|
||||
* The test creates a synthetic node_modules/better-sqlite3/ tree with both
|
||||
* the compiled build/Release/ binary AND the prebuilds/ directory, then
|
||||
* confirms that syncStandaloneNativeAssets copies both into the standalone
|
||||
* output. On the unfixed code this fails because NATIVE_ASSET_ENTRIES only
|
||||
* lists better-sqlite3/build/.
|
||||
*/
|
||||
test("repro-8847: better-sqlite3 prebuilds are bundled alongside the compiled binary", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "repro-8847-"));
|
||||
const projectRoot = path.join(tmp, "src-root");
|
||||
|
||||
// Seed better-sqlite3 with both build/Release/ and prebuilds/.
|
||||
const bsqlDir = path.join(projectRoot, "node_modules", "better-sqlite3");
|
||||
fs.mkdirSync(path.join(bsqlDir, "build", "Release"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(bsqlDir, "build", "Release", "better_sqlite3.node"),
|
||||
"// native binary placeholder"
|
||||
);
|
||||
fs.mkdirSync(path.join(bsqlDir, "prebuilds"), { recursive: true });
|
||||
for (const target of [
|
||||
"darwin-arm64.node",
|
||||
"darwin-x64.node",
|
||||
"linux-arm64.node",
|
||||
"linux-x64.node",
|
||||
"linuxmusl-arm64.node",
|
||||
"linuxmusl-x64.node",
|
||||
"win32-arm64.node",
|
||||
"win32-x64.node",
|
||||
]) {
|
||||
fs.writeFileSync(path.join(bsqlDir, "prebuilds", target), `// ${target}`);
|
||||
}
|
||||
|
||||
const outDir = path.join(tmp, "standalone");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// Act: copy native assets into the standalone output.
|
||||
await syncStandaloneNativeAssets(projectRoot, fs.promises, { log() {} }, outDir);
|
||||
|
||||
// Assert: the compiled build/Release/ binary was copied.
|
||||
assert.ok(
|
||||
fs.existsSync(
|
||||
path.join(outDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node")
|
||||
),
|
||||
"compiled native binary (build/Release/) must be in the standalone bundle"
|
||||
);
|
||||
|
||||
// Assert: the prebuilds/ directory was also copied.
|
||||
const prebuildsDir = path.join(outDir, "node_modules", "better-sqlite3", "prebuilds");
|
||||
assert.ok(fs.existsSync(prebuildsDir), "prebuilds/ directory must be in the standalone bundle");
|
||||
|
||||
// Assert: at least one prebuild file was copied.
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(prebuildsDir, "linux-x64.node")),
|
||||
"linux-x64 prebuild must be in the standalone bundle"
|
||||
);
|
||||
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
111
tests/unit/repro-9156.test.ts
Normal file
111
tests/unit/repro-9156.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// #9156: macOS launchd autostart fails because the supervisor spawns the child
|
||||
// with bare "node", but launchd's PATH cannot resolve it. process.execPath is
|
||||
// always the absolute path to the running Node.js binary and is always resolvable.
|
||||
//
|
||||
// We verify the fix via:
|
||||
// 1. Static source analysis — the spawn() call must use process.execPath
|
||||
// unconditionally (no fallback to bare "node"). This runs without any
|
||||
// experimental flags so it serves as the permanent regression guard.
|
||||
// 2. Runtime test via mock.module (requires --experimental-test-module-mocks)
|
||||
// that captures the actual spawn arguments.
|
||||
|
||||
const __filename = new URL(import.meta.url).pathname;
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const SUPERVISOR_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../../bin/cli/runtime/processSupervisor.mjs"
|
||||
);
|
||||
const supervisorSrc = fs.readFileSync(SUPERVISOR_PATH, "utf8");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Source-level verification (no experimental flag required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("spawn() uses process.execPath unconditionally, no bare 'node' fallback (#9156)", () => {
|
||||
// Must NOT contain the old conditional that falls back to bare "node"
|
||||
assert.ok(
|
||||
!supervisorSrc.includes('process.versions.bun ? process.execPath : "node"'),
|
||||
"must NOT have a conditional fallback to bare 'node'"
|
||||
);
|
||||
|
||||
// Must use process.execPath as the first argument to spawn()
|
||||
const execPathPattern = /spawn\(\s*process\.execPath\s*,/;
|
||||
assert.ok(
|
||||
execPathPattern.test(supervisorSrc),
|
||||
"spawn() must receive process.execPath as first argument"
|
||||
);
|
||||
});
|
||||
|
||||
test("process.execPath is an absolute path to the running Node.js binary", () => {
|
||||
assert.ok(
|
||||
path.isAbsolute(process.execPath),
|
||||
`process.execPath must be absolute, got: ${process.execPath}`
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(process.execPath),
|
||||
`process.execPath must exist: ${process.execPath}`
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Runtime test via mock.module (requires --experimental-test-module-mocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Run manually: node --experimental-test-module-mocks --import tsx/esm --test tests/unit/repro-9156.test.ts
|
||||
|
||||
import { mock } from "node:test";
|
||||
|
||||
if (typeof mock.module === "function") {
|
||||
test("(runtime) ServerSupervisor.start() spawns with process.execPath (#9156)", async () => {
|
||||
let spawnExecutable: string | undefined;
|
||||
const { EventEmitter } = await import("node:events");
|
||||
|
||||
const mockChild = Object.assign(new EventEmitter(), {
|
||||
pid: 12345,
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
kill: () => {},
|
||||
});
|
||||
|
||||
mock.module("node:child_process", {
|
||||
exports: {
|
||||
spawn: (...args: unknown[]) => {
|
||||
spawnExecutable = args[0] as string;
|
||||
return mockChild;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
process.env.PORT = "0";
|
||||
|
||||
const { ServerSupervisor } = await import(
|
||||
"../../bin/cli/runtime/processSupervisor.mjs"
|
||||
);
|
||||
|
||||
const supervisor = new ServerSupervisor({
|
||||
serverPath: "/fake/server.js",
|
||||
env: {},
|
||||
maxRestarts: 0,
|
||||
});
|
||||
|
||||
spawnExecutable = undefined;
|
||||
supervisor.start();
|
||||
|
||||
assert.ok(spawnExecutable, "spawn() must have been called");
|
||||
assert.equal(
|
||||
spawnExecutable,
|
||||
process.execPath,
|
||||
`expected process.execPath, got: ${spawnExecutable}`
|
||||
);
|
||||
assert.notEqual(spawnExecutable, "node", "must not be bare 'node'");
|
||||
|
||||
mockChild.removeAllListeners();
|
||||
delete process.env.PORT;
|
||||
});
|
||||
}
|
||||
71
tests/unit/repro-9486.test.ts
Normal file
71
tests/unit/repro-9486.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Issue #9486 — Anthropic OAuth returns HTTP 400 with "out of extra usage" in
|
||||
* the error body when a tool-carrying request exceeds the account's usage quota.
|
||||
* This should be classified as quota_exhausted (not generic bad_request), so the
|
||||
* account fallback mechanism applies a proper cooldown and combo routing can
|
||||
* skip to another target.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { matchErrorRuleByText, findMatchingErrorRule, ERROR_RULES } =
|
||||
await import("../../open-sse/config/errorConfig.ts");
|
||||
const { checkFallbackError, classifyErrorText } =
|
||||
await import("../../open-sse/services/accountFallback.ts");
|
||||
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
|
||||
|
||||
test("#9486 ERROR_RULES has a text rule for 'out of extra usage' → quota_exhausted", () => {
|
||||
const rule = ERROR_RULES.find((r) => r.text === "out of extra usage");
|
||||
assert.ok(rule, "expected a rule for 'out of extra usage'");
|
||||
assert.equal(rule!.reason, "quota_exhausted");
|
||||
// Should use backoff so the fallback path applies exponential scaling
|
||||
assert.equal(rule!.backoff, true);
|
||||
});
|
||||
|
||||
test("#9486 matchErrorRuleByText finds 'out of extra usage' rule", () => {
|
||||
const rule = matchErrorRuleByText("out of extra usage");
|
||||
assert.ok(rule, "expected a matching rule");
|
||||
assert.equal(rule!.reason, "quota_exhausted");
|
||||
});
|
||||
|
||||
test("#9486 matchErrorRuleByText finds rule in a longer error message", () => {
|
||||
const rule = matchErrorRuleByText(
|
||||
"Error: 400 - out of extra usage. You have exceeded your usage quota for this billing period."
|
||||
);
|
||||
assert.ok(rule, "expected a matching rule from longer message");
|
||||
assert.equal(rule!.reason, "quota_exhausted");
|
||||
});
|
||||
|
||||
test("#9486 findMatchingErrorRule with 400 + 'out of extra usage' returns quota_exhausted", () => {
|
||||
const rule = findMatchingErrorRule(400, "out of extra usage");
|
||||
assert.ok(rule, "expected a matching rule");
|
||||
assert.equal(rule!.reason, "quota_exhausted");
|
||||
});
|
||||
|
||||
test("#9486 checkFallbackError returns quota_exhausted for 400 + 'out of extra usage'", () => {
|
||||
const out = checkFallbackError(400, "out of extra usage", 0, null, "claude");
|
||||
assert.equal(out.shouldFallback, true);
|
||||
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
// Should get a non-zero cooldown (quota exhaustion is not transient)
|
||||
assert.ok(out.cooldownMs > 0, `expected positive cooldown, got ${out.cooldownMs}ms`);
|
||||
});
|
||||
|
||||
test("#9486 checkFallbackError handles 'Extra usage required' (same class)", () => {
|
||||
// Anthropic sometimes returns "Extra usage required" instead of "out of extra usage"
|
||||
const out = checkFallbackError(400, "Extra usage required", 0, null, "claude");
|
||||
assert.equal(out.shouldFallback, true);
|
||||
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
test("#9486 classifyErrorText flags 'out of extra usage' as QUOTA_EXHAUSTED", () => {
|
||||
const out = classifyErrorText("out of extra usage");
|
||||
assert.equal(out, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
test("#9486 generic 400 without quota text still gets no fallback (regression guard)", () => {
|
||||
// Regression guard: a plain 400 with no quota-related text must NOT trigger
|
||||
// fallback, preserving the existing behavior for non-quota 400 errors.
|
||||
const out = checkFallbackError(400, "Bad request: invalid JSON", 0, null, "claude");
|
||||
assert.equal(out.shouldFallback, false);
|
||||
assert.equal(out.reason, RateLimitReason.UNKNOWN);
|
||||
});
|
||||
52
tests/unit/repro-9623.test.ts
Normal file
52
tests/unit/repro-9623.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #9623: Failed connection test leaves testStatus=error with no recovery path.
|
||||
// Previously the route wrote testStatus:"error" + rateLimitedUntil:null, which the
|
||||
// lazy-recovery cooldown filter never matches (it only skips FUTURE rateLimitedUntil),
|
||||
// leaving the connection permanently unavailable after a transient outage.
|
||||
// Fix: non-terminal test failures now get a short future cooldown (30s) so they recover.
|
||||
|
||||
test("#9623 fix: non-terminal test failure sets a future rateLimitedUntil", () => {
|
||||
// Simulate the fixed updateData logic
|
||||
const now = Date.now();
|
||||
const terminalTestStatuses = new Set(["banned", "expired", "credits_exhausted"]);
|
||||
const valid = false;
|
||||
const diagnosis = { code: "network_error", type: "upstream" }; // non-terminal
|
||||
const isTerminalFailure = terminalTestStatuses.has(String(diagnosis.code).toLowerCase());
|
||||
const testFailureCooldownMs = 30_000;
|
||||
|
||||
const rateLimitedUntil =
|
||||
valid || isTerminalFailure
|
||||
? valid
|
||||
? null
|
||||
: null
|
||||
: new Date(now + testFailureCooldownMs).toISOString();
|
||||
|
||||
assert.ok(
|
||||
rateLimitedUntil !== null,
|
||||
"non-terminal failure should set a future rateLimitedUntil"
|
||||
);
|
||||
const cooldownTime = new Date(rateLimitedUntil as string).getTime();
|
||||
assert.ok(
|
||||
cooldownTime > now,
|
||||
"rateLimitedUntil must be in the future so the lazy-recovery path retries"
|
||||
);
|
||||
assert.ok(
|
||||
cooldownTime <= now + 30_000,
|
||||
"cooldown should be bounded (30s)"
|
||||
);
|
||||
});
|
||||
|
||||
test("#9623 guard: terminal failures stay terminal (no fake recovery)", () => {
|
||||
const terminalTestStatuses = new Set(["banned", "expired", "credits_exhausted"]);
|
||||
const diagnosis = { code: "banned", type: "terminal" };
|
||||
const isTerminalFailure = terminalTestStatuses.has(String(diagnosis.code).toLowerCase());
|
||||
assert.equal(isTerminalFailure, true, "banned must be terminal");
|
||||
});
|
||||
|
||||
test("#9623: success resets cooldown to null", () => {
|
||||
const valid = true;
|
||||
const rateLimitedUntil = valid ? null : new Date(Date.now() + 30_000).toISOString();
|
||||
assert.equal(rateLimitedUntil, null, "successful test clears cooldown");
|
||||
});
|
||||
52
tests/unit/repro-9624.test.ts
Normal file
52
tests/unit/repro-9624.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const INSTRUMENTATION_NODE_PATH = resolve(
|
||||
__dirname,
|
||||
"../../src/instrumentation-node.ts"
|
||||
);
|
||||
|
||||
describe("repro-9624: startCleanupScheduler wired in Next.js startup path", () => {
|
||||
it("should import startCleanupScheduler from cleanup", () => {
|
||||
const source = readFileSync(INSTRUMENTATION_NODE_PATH, "utf-8");
|
||||
|
||||
// instrumentation-node.ts loads all startup modules via dynamic imports in a
|
||||
// Promise.all destructure, e.g.:
|
||||
// const [{ startCleanupScheduler }, ...] = await Promise.all([
|
||||
// import("@/lib/db/cleanup"), ...
|
||||
// ]);
|
||||
// So the binding and the module import appear separately in the file.
|
||||
const cleanupModuleImported = /import\(\s*["']@\/lib\/db\/cleanup["']\s*\)/.test(
|
||||
source
|
||||
);
|
||||
const schedulerBound = /\bstartCleanupScheduler\b/.test(source);
|
||||
|
||||
assert.ok(
|
||||
cleanupModuleImported,
|
||||
"@/lib/db/cleanup should be imported (dynamic import) in instrumentation-node.ts"
|
||||
);
|
||||
assert.ok(
|
||||
schedulerBound,
|
||||
"startCleanupScheduler should be bound in instrumentation-node.ts"
|
||||
);
|
||||
});
|
||||
|
||||
it("should call startCleanupScheduler() during startup", () => {
|
||||
const source = readFileSync(INSTRUMENTATION_NODE_PATH, "utf-8");
|
||||
|
||||
// Check that startCleanupScheduler is called (as a function call).
|
||||
// It can be called directly or as part of a conditional.
|
||||
const hasCall = /\bstartCleanupScheduler\s*\(/.test(source);
|
||||
|
||||
assert.ok(
|
||||
hasCall,
|
||||
"startCleanupScheduler() should be called in instrumentation-node.ts"
|
||||
);
|
||||
});
|
||||
});
|
||||
92
tests/unit/repro-9625.test.ts
Normal file
92
tests/unit/repro-9625.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Issue #9625 — domain_cost_history cleanup cutoff unit mismatch.
|
||||
*
|
||||
* cleanupDomainCostHistory() computes the cutoff in epoch seconds
|
||||
* (Math.floor(Date.now() / 1000)) but the timestamp column stores
|
||||
* epoch milliseconds (Date.now()), as inserted by saveCostEntry().
|
||||
*
|
||||
* This test seeds data using the same format as the production code
|
||||
* (milliseconds), then asserts that cleanupDomainCostHistory() correctly
|
||||
* deletes rows older than the retention window.
|
||||
*
|
||||
* Before the fix, the cutoff in seconds was ~1000× smaller than the
|
||||
* stored timestamps, so the DELETE WHERE timestamp < cutoff would
|
||||
* never match old rows — the cleanup was effectively a no-op.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9625-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { cleanupDomainCostHistory } = await import("../../src/lib/db/cleanup.ts");
|
||||
const { getDbInstance, resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
|
||||
test.after(() => {
|
||||
resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const DAY_MS = 86_400_000; // milliseconds
|
||||
|
||||
test("#9625 cleanupDomainCostHistory: cutoff in ms matches production timestamps", async () => {
|
||||
const db = getDbInstance()!;
|
||||
const now = Date.now(); // milliseconds — same as saveCostEntry() default
|
||||
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
// Seed data using millisecond timestamps (production format).
|
||||
// 3 old rows: 40 days ago (should be deleted)
|
||||
// 2 recent rows: 5 days ago (should be kept)
|
||||
insert.run("key1", 1.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 2.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 3.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 4.0, now - 5 * DAY_MS);
|
||||
insert.run("key1", 5.0, now - 5 * DAY_MS);
|
||||
|
||||
const result = await cleanupDomainCostHistory();
|
||||
|
||||
// Before the fix, cutoff was in seconds (~1.7e9) while timestamps
|
||||
// are in milliseconds (~1.7e12). The comparison `WHERE ts < 1.7e9`
|
||||
// would never match rows with ts ~1.7e12, so nothing was deleted.
|
||||
assert.strictEqual(result.deleted, 3, "Should delete 3 old rows (40 days old)");
|
||||
assert.strictEqual(result.errors, 0);
|
||||
|
||||
const remaining = db.prepare("SELECT COUNT(*) as cnt FROM domain_cost_history").get() as {
|
||||
cnt: number;
|
||||
};
|
||||
assert.strictEqual(remaining.cnt, 2, "Should keep 2 recent rows (5 days old)");
|
||||
});
|
||||
|
||||
test("#9625 unit mismatch: seconds cutoff would NOT match ms timestamps", () => {
|
||||
// Demonstrate the arithmetic bug: a cutoff in seconds is ~1000×
|
||||
// smaller than a millisecond timestamp, so the WHERE clause never
|
||||
// matches production data.
|
||||
const nowMs = Date.now();
|
||||
const nowSec = Math.floor(nowMs / 1000);
|
||||
const retentionDays = 30;
|
||||
const cutoffSec = nowSec - retentionDays * 86_400; // seconds
|
||||
const cutoffMs = nowMs - retentionDays * 86_400_000; // milliseconds
|
||||
|
||||
// A row inserted 40 days ago with a millisecond timestamp:
|
||||
const oldRowMs = nowMs - 40 * 86_400_000; // ~1.7e12
|
||||
|
||||
// With seconds cutoff: oldRowMs (1.7e12) < cutoffSec (1.7e9) is FALSE
|
||||
// because 1.7e12 > 1.7e9 — the row is never matched.
|
||||
assert.ok(
|
||||
oldRowMs > cutoffSec,
|
||||
"Bug: ms timestamp is NOT less than seconds cutoff, so row is never deleted"
|
||||
);
|
||||
|
||||
// With milliseconds cutoff: oldRowMs (1.7e12) < cutoffMs (1.7e12) is TRUE
|
||||
assert.ok(
|
||||
oldRowMs < cutoffMs,
|
||||
"Fix: ms timestamp IS less than ms cutoff, so row is correctly deleted"
|
||||
);
|
||||
});
|
||||
62
tests/unit/repro-9626.test.ts
Normal file
62
tests/unit/repro-9626.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const root = join(import.meta.dirname, "../..");
|
||||
const llmChatCardPath =
|
||||
"src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx";
|
||||
const src = readFileSync(join(root, llmChatCardPath), "utf8");
|
||||
|
||||
const DISABLED_ON_LOADING = /disabled\s*=\s*\{\s*loading\s*\}/;
|
||||
const MODELS_LOADING_MARKER = /modelsLoading|Loading…|Loading\.\.\./;
|
||||
const ERROR_BRANCH = /error\s*&&/;
|
||||
const RETRY_ACTION = /onClick\s*=\s*\{[^}]*retry|retry[A-Za-z]*\s*\(\)|const\s+\[reload/i;
|
||||
const NO_MODELS_AFTER_EMPTY = /modelOptions\.length\s*===?\s*0|models\.length\s*===?\s*0/;
|
||||
|
||||
test("LlmChatCard destructures loading and error from useProviderModels (#9626)", () => {
|
||||
const match = src.match(/const\s*\{\s*([^}]+)\s*\}\s*=\s*useProviderModels\(/);
|
||||
assert.ok(match, "Expected to find a destructuring of useProviderModels");
|
||||
|
||||
const destructured = match[1];
|
||||
assert.ok(
|
||||
destructured.includes("loading"),
|
||||
"loading state must be destructured from useProviderModels"
|
||||
);
|
||||
assert.ok(destructured.includes("error"), "error state must be destructured from useProviderModels");
|
||||
});
|
||||
|
||||
test("LlmChatCard disables the model selector while models are loading (#9626)", () => {
|
||||
assert.ok(
|
||||
DISABLED_ON_LOADING.test(src),
|
||||
"The model <select> must be disabled while the models request is pending"
|
||||
);
|
||||
});
|
||||
|
||||
test("LlmChatCard shows a visible loading label while models are pending (#9626)", () => {
|
||||
assert.ok(
|
||||
MODELS_LOADING_MARKER.test(src),
|
||||
"A visible loading text (e.g. 'Loading…') must appear while the models request is pending"
|
||||
);
|
||||
});
|
||||
|
||||
test("LlmChatCard surfaces the provider model error in the UI (#9626)", () => {
|
||||
assert.ok(
|
||||
ERROR_BRANCH.test(src),
|
||||
"An error branch that renders the captured error message must exist"
|
||||
);
|
||||
});
|
||||
|
||||
test("LlmChatCard offers a retry action when the model request fails (#9626)", () => {
|
||||
assert.ok(
|
||||
RETRY_ACTION.test(src),
|
||||
"A retry action must be offered next to the model error"
|
||||
);
|
||||
});
|
||||
|
||||
test("LlmChatCard keeps the empty-state message distinct from an error (#9626)", () => {
|
||||
assert.ok(
|
||||
NO_MODELS_AFTER_EMPTY.test(src),
|
||||
"The empty-state (no models) message must only be shown for a successful empty response"
|
||||
);
|
||||
});
|
||||
27
tests/unit/repro-9633.test.ts
Normal file
27
tests/unit/repro-9633.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
|
||||
const files = pkg.files || [];
|
||||
|
||||
// #9633: `build-next-isolated.mjs` is published (fixed in #1126), but three of
|
||||
// its sibling modules it imports were missing from the `files` whitelist, so
|
||||
// `npm run build` on a globally-installed package crashed with ERR_MODULE_NOT_FOUND.
|
||||
// The dynamic import of `build-tproxy-native.mjs` (~line 308) and the static
|
||||
// imports of `assembleStandalone.mjs` / `backendOnlyPages.mjs` must ship too.
|
||||
const NEEDED = [
|
||||
"scripts/build/assembleStandalone.mjs",
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
"scripts/build/colocateOptionals.mjs",
|
||||
];
|
||||
|
||||
test("#9633: build-next-isolated.mjs sibling imports present in package.json files[]", () => {
|
||||
for (const needed of NEEDED) {
|
||||
assert.ok(
|
||||
files.some((f) => typeof f === "string" && f === needed),
|
||||
`${needed} is not in package.json files[]`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -40,13 +40,21 @@ test("Responses->Chat: first tool_call chunk announces role=assistant", () => {
|
||||
);
|
||||
assert.equal(first.choices[0].delta.tool_calls[0].function.name, "get_weather");
|
||||
|
||||
// Subsequent argument deltas must NOT repeat the role announcement.
|
||||
// #9168: arguments deltas are buffered until output_item.done for schema normalization.
|
||||
const next = openaiResponsesToOpenAIResponse(
|
||||
{ type: "response.function_call_arguments.delta", delta: '{"x":1}' },
|
||||
state
|
||||
);
|
||||
assert.ok(next, "should emit a chunk for arguments.delta");
|
||||
assert.equal(next.choices[0].delta.role, undefined, "only the first delta announces the role");
|
||||
assert.equal(next, null, "arguments delta should buffer until output_item.done");
|
||||
|
||||
// The args are emitted at output_item.done, and the role is not re-announced.
|
||||
const done = openaiResponsesToOpenAIResponse(
|
||||
{ type: "response.output_item.done", item: { type: "function_call", call_id: "call_abc", name: "get_weather" } },
|
||||
state
|
||||
);
|
||||
assert.ok(done, "should emit a chunk for output_item.done");
|
||||
assert.equal(done.choices[0].delta.role, undefined, "role announcement already happened on first chunk");
|
||||
assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, '{"x":1}');
|
||||
});
|
||||
|
||||
test("Responses->Chat: first text chunk announces role=assistant", () => {
|
||||
|
||||
@@ -452,7 +452,13 @@ test("v1 search POST returns 400 when auto-select finds no configured provider (
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(capturedUrl, "", "fallback-only SearXNG must not receive an upstream request");
|
||||
assert.ok(body.error?.message || body.error);
|
||||
assert.match(
|
||||
String(body.error?.message ?? body.error),
|
||||
/provider|configured/i,
|
||||
"the response must explain that no provider was selected"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -23,11 +23,17 @@ test("sseCommentsEnabled defaults to true when the env var is unset", () => {
|
||||
withEnv(undefined, () => assert.equal(sseCommentsEnabled(), true));
|
||||
});
|
||||
|
||||
test("sseCommentsEnabled is false only when set to 'off' (case-insensitive)", () => {
|
||||
test("sseCommentsEnabled is false for 'off', 'false', '0', 'no' (case-insensitive)", () => {
|
||||
withEnv("off", () => assert.equal(sseCommentsEnabled(), false));
|
||||
withEnv("OFF", () => assert.equal(sseCommentsEnabled(), false));
|
||||
withEnv("false", () => assert.equal(sseCommentsEnabled(), false));
|
||||
withEnv("FALSE", () => assert.equal(sseCommentsEnabled(), false));
|
||||
withEnv("0", () => assert.equal(sseCommentsEnabled(), false));
|
||||
withEnv("no", () => assert.equal(sseCommentsEnabled(), false));
|
||||
withEnv("NO", () => assert.equal(sseCommentsEnabled(), false));
|
||||
withEnv("on", () => assert.equal(sseCommentsEnabled(), true));
|
||||
withEnv("false", () => assert.equal(sseCommentsEnabled(), true));
|
||||
withEnv("yes", () => assert.equal(sseCommentsEnabled(), true));
|
||||
withEnv("1", () => assert.equal(sseCommentsEnabled(), true));
|
||||
});
|
||||
|
||||
test("shapeForClientFormat maps known client formats", () => {
|
||||
|
||||
@@ -47,7 +47,8 @@ test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const DAY = 86_400; // seconds
|
||||
const DAY_SECONDS = 86_400;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** Ensure compression_run_telemetry table exists (created lazily in production). */
|
||||
function ensureTelemetryTable(): void {
|
||||
@@ -71,21 +72,34 @@ function ensureTelemetryTable(): void {
|
||||
`);
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
ensureTelemetryTable();
|
||||
const db = getDbInstance()!;
|
||||
for (const table of [
|
||||
"domain_cost_history",
|
||||
"compression_cache_stats",
|
||||
"xp_audit_log",
|
||||
"compression_run_telemetry",
|
||||
]) {
|
||||
db.exec(`DELETE FROM ${table}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
test("#6848 cleanupDomainCostHistory: deletes rows older than retention window", async () => {
|
||||
const db = getDbInstance()!;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const now = Date.now();
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
// 3 old (40 days ago), 2 recent (5 days ago)
|
||||
insert.run("key1", 1.0, now - 40 * DAY);
|
||||
insert.run("key1", 2.0, now - 40 * DAY);
|
||||
insert.run("key1", 3.0, now - 40 * DAY);
|
||||
insert.run("key1", 4.0, now - 5 * DAY);
|
||||
insert.run("key1", 5.0, now - 5 * DAY);
|
||||
insert.run("key1", 1.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 2.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 3.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 4.0, now - 5 * DAY_MS);
|
||||
insert.run("key1", 5.0, now - 5 * DAY_MS);
|
||||
|
||||
const result = await cleanupDomainCostHistory();
|
||||
|
||||
@@ -100,8 +114,8 @@ test("#6848 cleanupDomainCostHistory: deletes rows older than retention window",
|
||||
|
||||
test("#6848 cleanupCompressionCacheStats: deletes rows older than retention window", async () => {
|
||||
const db = getDbInstance()!;
|
||||
const oldDate = new Date(Date.now() - 40 * DAY * 1000).toISOString();
|
||||
const recentDate = new Date(Date.now() - 5 * DAY * 1000).toISOString();
|
||||
const oldDate = new Date(Date.now() - 40 * DAY_MS).toISOString();
|
||||
const recentDate = new Date(Date.now() - 5 * DAY_MS).toISOString();
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO compression_cache_stats (provider, compression_mode, created_at) VALUES (?, ?, ?)"
|
||||
);
|
||||
@@ -123,8 +137,8 @@ test("#6848 cleanupCompressionCacheStats: deletes rows older than retention wind
|
||||
|
||||
test("#6848 cleanupXpAuditLog: deletes rows older than retention window", async () => {
|
||||
const db = getDbInstance()!;
|
||||
const oldDate = new Date(Date.now() - 40 * DAY * 1000).toISOString();
|
||||
const recentDate = new Date(Date.now() - 5 * DAY * 1000).toISOString();
|
||||
const oldDate = new Date(Date.now() - 40 * DAY_MS).toISOString();
|
||||
const recentDate = new Date(Date.now() - 5 * DAY_MS).toISOString();
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO xp_audit_log (api_key_id, action, xp_earned, created_at) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
@@ -146,14 +160,15 @@ test("#6848 cleanupXpAuditLog: deletes rows older than retention window", async
|
||||
test("#6848 cleanupCompressionRunTelemetry: deletes rows older than retention window", async () => {
|
||||
ensureTelemetryTable();
|
||||
const db = getDbInstance()!;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const now = Date.now();
|
||||
const nowSeconds = Math.floor(now / 1000);
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO compression_run_telemetry (timestamp, tokens_before, tokens_after) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
insert.run(now - 40 * DAY, 1000, 500);
|
||||
insert.run(now - 40 * DAY, 2000, 800);
|
||||
insert.run(now - 5 * DAY, 1500, 600);
|
||||
insert.run(nowSeconds - 40 * DAY_SECONDS, 1000, 500);
|
||||
insert.run(nowSeconds - 40 * DAY_SECONDS, 2000, 800);
|
||||
insert.run(nowSeconds - 5 * DAY_SECONDS, 1500, 600);
|
||||
|
||||
const result = await cleanupCompressionRunTelemetry();
|
||||
|
||||
@@ -169,13 +184,14 @@ test("#6848 cleanupCompressionRunTelemetry: deletes rows older than retention wi
|
||||
test("#6848 no rows deleted when all data is within retention window (calls all 4 real functions)", async () => {
|
||||
ensureTelemetryTable();
|
||||
const db = getDbInstance()!;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const nowMilliseconds = Date.now();
|
||||
const recentISO = new Date().toISOString();
|
||||
|
||||
db.prepare("INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)").run(
|
||||
"k",
|
||||
1,
|
||||
now - DAY
|
||||
nowMilliseconds - DAY_MS
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO compression_cache_stats (provider, compression_mode, created_at) VALUES (?, ?, ?)"
|
||||
@@ -185,7 +201,7 @@ test("#6848 no rows deleted when all data is within retention window (calls all
|
||||
).run("k", "a", 5, recentISO);
|
||||
db.prepare(
|
||||
"INSERT INTO compression_run_telemetry (timestamp, tokens_before, tokens_after) VALUES (?, ?, ?)"
|
||||
).run(now - DAY, 100, 50);
|
||||
).run(nowSeconds - DAY_SECONDS, 100, 50);
|
||||
|
||||
const r1 = await cleanupDomainCostHistory();
|
||||
const r2 = await cleanupCompressionCacheStats();
|
||||
|
||||
@@ -200,6 +200,16 @@ test("Claude -> Gemini omits unsigned functionCall instead of injecting a fake t
|
||||
false,
|
||||
"signature-less tool_use must not become a native functionCall"
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify(result).includes('"thoughtSignature"'),
|
||||
false,
|
||||
"the translator must not synthesize a fake thought signature"
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify(result).includes("read_file"),
|
||||
false,
|
||||
"the omitted unsigned call must not leak its tool payload elsewhere"
|
||||
);
|
||||
});
|
||||
|
||||
test("Claude -> Gemini sanitizes long tool names and exposes a restore map", () => {
|
||||
|
||||
@@ -203,7 +203,8 @@ test("Responses -> OpenAI: incremental tool call events + response.completed sna
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.ok(args, "should emit args delta chunk");
|
||||
// #9168: arguments deltas are buffered until output_item.done for schema normalization
|
||||
assert.equal(args, null, "args delta should buffer until output_item.done");
|
||||
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
|
||||
@@ -474,7 +474,7 @@ test("Responses -> OpenAI: tool-call delta, reasoning delta and completed usage
|
||||
},
|
||||
state
|
||||
);
|
||||
openaiResponsesToOpenAIResponse(
|
||||
const done = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", call_id: "call_2", name: "weather" },
|
||||
@@ -497,7 +497,10 @@ test("Responses -> OpenAI: tool-call delta, reasoning delta and completed usage
|
||||
);
|
||||
|
||||
assert.equal(added.choices[0].delta.tool_calls[0].function.name, "weather");
|
||||
assert.equal(args.choices[0].delta.tool_calls[0].function.arguments, '{"city":"SP"}');
|
||||
// #9168: function_call_arguments.delta is buffered and returns null;
|
||||
// arguments are emitted by output_item.done instead.
|
||||
assert.equal(args, null);
|
||||
assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, '{"city":"SP"}');
|
||||
assert.equal(reasoning.choices[0].delta.reasoning_content, "Need weather info.");
|
||||
assert.equal(completed.choices[0].finish_reason, "tool_calls");
|
||||
const comp = completed as {
|
||||
|
||||
@@ -1,127 +1,7 @@
|
||||
/**
|
||||
* #8853 — authenticated HTTP proxy health checks drop credentials
|
||||
*
|
||||
* Root cause: both the auto-test route and the scheduler build proxy URLs
|
||||
* manually as `${proxy.type}://${proxy.host}:${proxy.port}`, dropping
|
||||
* username/password. The `proxyConfigToUrl()` function in proxyDispatcher.ts
|
||||
* already handles URL-encoded credentials correctly.
|
||||
*
|
||||
* We prove the bug by showing that the proxy URL produced by the current
|
||||
* manual construction lacks credentials, and that `proxyConfigToUrl()` with
|
||||
* the same config object includes them — therefore the fix is to reuse it.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// The function that fixes the bug — we import it here to verify it works
|
||||
import { proxyConfigToUrl } from "@omniroute/open-sse/utils/proxyDispatcher";
|
||||
|
||||
// ── proxyConfigToUrl credential tests ──────────────────────────────────────
|
||||
|
||||
test("#8853 proxyConfigToUrl encodes username and password into proxy URL", () => {
|
||||
const url = proxyConfigToUrl({
|
||||
type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 3128,
|
||||
username: "alice",
|
||||
password: "s3cret",
|
||||
});
|
||||
assert.ok(url, "proxyConfigToUrl must return a URL");
|
||||
assert.match(url!, /:\/\/alice:s3cret@/, "URL must contain credentials");
|
||||
});
|
||||
|
||||
test("#8853 proxyConfigToUrl encodes special characters in credentials", () => {
|
||||
const url = proxyConfigToUrl({
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 8080,
|
||||
username: "user@domain",
|
||||
password: "p@ss:w0rd",
|
||||
});
|
||||
assert.ok(url, "proxyConfigToUrl must return a URL");
|
||||
assert.match(url!, /:\/\/user%40domain:p%40ss%3Aw0rd@/, "URL must URL-encode special chars");
|
||||
});
|
||||
|
||||
test("#8853 proxyConfigToUrl omits auth when no username", () => {
|
||||
const url = proxyConfigToUrl({
|
||||
type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 3128,
|
||||
});
|
||||
assert.ok(url, "proxyConfigToUrl must return a URL");
|
||||
assert.doesNotMatch(url!, /@/, "URL must not contain @ (no auth)");
|
||||
});
|
||||
|
||||
test("#8853 proxyConfigToUrl handles IPv6 host with family", () => {
|
||||
const url = proxyConfigToUrl({
|
||||
type: "http",
|
||||
host: "[::1]",
|
||||
port: 3128,
|
||||
family: "ipv6",
|
||||
});
|
||||
assert.ok(url, "proxyConfigToUrl must return a URL");
|
||||
assert.match(url!, /\[::1\]/, "IPv6 host must be bracketed");
|
||||
});
|
||||
|
||||
// ── Simulate the buggy construction ─────────────────────────────────────────
|
||||
|
||||
function buggyManualUrl(proxy: { type: string; host: string; port: number }) {
|
||||
return `${proxy.type}://${proxy.host}:${proxy.port}`;
|
||||
}
|
||||
|
||||
test("#8853 manual URL construction (current bug) drops credentials", () => {
|
||||
const proxy = {
|
||||
type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 3128,
|
||||
username: "alice",
|
||||
password: "s3cret",
|
||||
};
|
||||
const manualUrl = buggyManualUrl(proxy);
|
||||
assert.doesNotMatch(manualUrl, /alice/, "Buggy URL must NOT contain username");
|
||||
assert.doesNotMatch(manualUrl, /s3cret/, "Buggy URL must NOT contain password");
|
||||
|
||||
// Compare with proxyConfigToUrl which includes credentials
|
||||
const fixedUrl = proxyConfigToUrl(proxy);
|
||||
assert.ok(fixedUrl);
|
||||
assert.match(fixedUrl!, /alice/, "Fixed URL must contain username");
|
||||
assert.match(fixedUrl!, /s3cret/, "Fixed URL must contain password");
|
||||
});
|
||||
|
||||
// ── Verify the scheduler and auto-test would use proxyConfigToUrl ──────────
|
||||
|
||||
test("#8853 proxyConfigToUrl accepts ProxyRegistryRecord-shaped object", () => {
|
||||
// Simulating the shape of a proxy record returned by listProxies({ includeSecrets: true })
|
||||
const proxyRecord = {
|
||||
id: "p1",
|
||||
name: "test",
|
||||
type: "http",
|
||||
host: "10.0.0.1",
|
||||
port: 8888,
|
||||
username: "bob",
|
||||
password: "p4ss",
|
||||
family: "auto",
|
||||
region: null,
|
||||
notes: null,
|
||||
status: "active",
|
||||
source: "manual",
|
||||
subscriptionId: null,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
};
|
||||
const url = proxyConfigToUrl(proxyRecord);
|
||||
assert.ok(url, "proxyConfigToUrl must accept ProxyRegistryRecord-shaped objects");
|
||||
assert.match(url!, /bob:p4ss/, "URL must include credentials from the record");
|
||||
});
|
||||
|
||||
test("#8853 proxyConfigToUrl returns null for partial config (no host)", () => {
|
||||
const url = proxyConfigToUrl({ type: "http", port: 8080 } as Record<string, unknown>);
|
||||
assert.equal(url, null, "proxyConfigToUrl must return null for partial config without host");
|
||||
});
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { openaiResponsesToOpenAIRequest } from "../../open-sse/translator/request/openai-responses.ts";
|
||||
import { detectSupportedThinkingEfforts } from "../../src/lib/providerModels/modelDiscovery.ts";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value as Record<string, unknown>;
|
||||
@@ -163,4 +43,56 @@ test("non-GPT-5.6 models still get max downgraded to xhigh", () => {
|
||||
)
|
||||
);
|
||||
assert.equal(translated.reasoning_effort, "xhigh");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// PR #9142 — Anthropic top-level `system` prompts must trigger background detection
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
const { getBackgroundTaskReason, setBackgroundDegradationConfig } =
|
||||
await import("../../open-sse/services/backgroundTaskDetector.ts");
|
||||
|
||||
test("#9142 Anthropic top-level system prompts must trigger background detection", () => {
|
||||
setBackgroundDegradationConfig({ enabled: true });
|
||||
assert.equal(
|
||||
getBackgroundTaskReason({
|
||||
system: "Generate a title for this conversation",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
}),
|
||||
"system_prompt_pattern"
|
||||
);
|
||||
});
|
||||
// #9140 — VS Code routes filter out built-in auto models
|
||||
const { isUsableChatModel } =
|
||||
await import("../../src/app/api/v1/vscode/[token]/usableChatModel.ts");
|
||||
|
||||
test("#9140 VS Code listing must accept built-in auto routing entries", () => {
|
||||
assert.equal(
|
||||
isUsableChatModel({ id: "auto/best-coding", owned_by: "combo" }),
|
||||
true,
|
||||
"built-in auto/* model should be accepted"
|
||||
);
|
||||
assert.equal(
|
||||
isUsableChatModel({ id: "operator-combo", owned_by: "combo" }),
|
||||
false,
|
||||
"operator-created combo should still be rejected"
|
||||
);
|
||||
});
|
||||
// ── #9160 model discovery: capabilities.effort_tiers ────────────────────────
|
||||
|
||||
test("#9160 model discovery must ingest capabilities.effort_tiers", () => {
|
||||
assert.deepEqual(
|
||||
detectSupportedThinkingEfforts({
|
||||
capabilities: { effort_tiers: ["low", "medium", "high", "xhigh"] },
|
||||
}),
|
||||
["low", "medium", "high", "xhigh"]
|
||||
);
|
||||
});
|
||||
|
||||
test("#9160 capabilities.effort_tiers with duplicate and synonym", () => {
|
||||
assert.deepEqual(
|
||||
detectSupportedThinkingEfforts({
|
||||
capabilities: { effort_tiers: ["low", "low", "max"] },
|
||||
}),
|
||||
["low", "xhigh"]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,16 +22,12 @@ const extract = extractCiGates as (
|
||||
) => { id: string; job: string; args: string[]; env?: Record<string, string> }[];
|
||||
|
||||
test("eslintCounts sums errors + warnings across files", () => {
|
||||
const parsed = [
|
||||
{ errorCount: 2, warningCount: 5 },
|
||||
{ errorCount: 0, warningCount: 3 },
|
||||
{},
|
||||
];
|
||||
const parsed = [{ errorCount: 2, warningCount: 5 }, { errorCount: 0, warningCount: 3 }, {}];
|
||||
assert.deepEqual(eslintCounts(parsed), { errors: 2, warnings: 8 });
|
||||
});
|
||||
|
||||
test("parseEslintJson tolerates a leading non-JSON banner", () => {
|
||||
const out = "npm warn something\n[{\"errorCount\":0,\"warningCount\":1}]";
|
||||
const out = 'npm warn something\n[{"errorCount":0,"warningCount":1}]';
|
||||
assert.deepEqual(parseEslintJson(out), [{ errorCount: 0, warningCount: 1 }]);
|
||||
assert.equal(parseEslintJson("no json here"), null);
|
||||
});
|
||||
@@ -52,8 +48,14 @@ test("parseEslintJson tolerates ESLint's trailing unpruned-suppressions stderr s
|
||||
});
|
||||
|
||||
test("parseCognitiveCount reads the gate's count (en + pt)", () => {
|
||||
assert.equal(parseCognitiveCount("[cognitive-complexity] 797 function(s) exceed the threshold (15)."), 797);
|
||||
assert.equal(parseCognitiveCount("[cognitive-complexity] REGRESSÃO — 801 violações > baseline 797"), 801);
|
||||
assert.equal(
|
||||
parseCognitiveCount("[cognitive-complexity] 797 function(s) exceed the threshold (15)."),
|
||||
797
|
||||
);
|
||||
assert.equal(
|
||||
parseCognitiveCount("[cognitive-complexity] REGRESSÃO — 801 violações > baseline 797"),
|
||||
801
|
||||
);
|
||||
assert.equal(parseCognitiveCount("no number"), null);
|
||||
});
|
||||
|
||||
@@ -175,8 +177,16 @@ test("pre-flight wires the test-masking PR-context gate against origin/main (v3.
|
||||
);
|
||||
// run() must honor a per-gate env override so GITHUB_BASE_REF actually reaches the child
|
||||
// (routed through buildGateEnv since the --hermetic scrub was added).
|
||||
assert.match(src, /env:\s*buildGateEnv\(opts\.env\)/, "run() must merge opts.env into the child env");
|
||||
assert.match(src, /\.\.\.\(extra \|\| \{\}\)/, "buildGateEnv must spread the per-gate env override");
|
||||
assert.match(
|
||||
src,
|
||||
/env:\s*buildGateEnv\(opts\.env\)/,
|
||||
"run() must merge opts.env into the child env"
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/\.\.\.\(extra \|\| \{\}\)/,
|
||||
"buildGateEnv must spread the per-gate env override"
|
||||
);
|
||||
});
|
||||
|
||||
test("pre-flight --hermetic scrubs the live-test trigger vars (2026-07-05 false-positive fix)", async () => {
|
||||
@@ -214,6 +224,27 @@ test("pre-flight runs the slow suites CONCURRENTLY (v3.8.45 perf — was ~1h ser
|
||||
assert.match(src, /slow\.forEach\([\s\S]*?saveGateLog\(g\.id/, "each slow gate persists its log");
|
||||
});
|
||||
|
||||
test("pre-flight runs tarball boot only after the package artifact builder completes", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const src = fs.readFileSync(
|
||||
new URL("../../scripts/quality/validate-release-green.mjs", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const parallelWave = src.indexOf("const slowResults = await Promise.all");
|
||||
const packBoot = src.indexOf('id: "pack-boot"');
|
||||
|
||||
assert.ok(parallelWave >= 0, "the parallel slow-gate wave must exist");
|
||||
assert.ok(
|
||||
packBoot > parallelWave,
|
||||
"pack-boot must be declared after the parallel artifact build"
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/packArtifactResult[\s\S]*?check:pack-boot/,
|
||||
"pack-boot must be explicitly sequenced from the package-artifact result"
|
||||
);
|
||||
});
|
||||
|
||||
// ─── --full-ci gate extraction (P0, v3.8.46 post-mortem) ─────────────────────
|
||||
|
||||
const CI_FIXTURE = `
|
||||
@@ -259,7 +290,11 @@ test("extractCiGates: pulls npm-run gate steps from the ci.yml gate jobs only",
|
||||
assert.ok(ids.includes("check:docs-all") && ids.includes("check:docs-symbols"), "multi-line run");
|
||||
// …and NON-gate steps + jobs outside the gate set are ignored.
|
||||
assert.ok(!ids.includes("build") && !ids.some((i) => i.startsWith("test:")), "no build/test-run");
|
||||
assert.equal(gates.find((g) => g.job === "test-unit"), undefined, "test-unit job is not scanned");
|
||||
assert.equal(
|
||||
gates.find((g) => g.job === "test-unit"),
|
||||
undefined,
|
||||
"test-unit job is not scanned"
|
||||
);
|
||||
});
|
||||
|
||||
test("extractCiGates: preserves `-- <args>` so ratchet flags reach the script", () => {
|
||||
@@ -272,7 +307,10 @@ test("extractCiGates: preserves `-- <args>` so ratchet flags reach the script",
|
||||
test("extractCiGates: skips the non-local gates (pr-evidence, codeql-ratchet)", () => {
|
||||
const ids = extract(CI_FIXTURE).map((g) => g.id);
|
||||
assert.ok(!ids.includes("check:pr-evidence"), "pr-evidence needs a PR body — skipped");
|
||||
assert.ok(!ids.includes("check:codeql-ratchet"), "codeql-ratchet is a remote-main check — skipped");
|
||||
assert.ok(
|
||||
!ids.includes("check:codeql-ratchet"),
|
||||
"codeql-ratchet is a remote-main check — skipped"
|
||||
);
|
||||
assert.ok(FULL_CI_SKIP.has("check:pr-evidence") && FULL_CI_SKIP.has("check:codeql-ratchet"));
|
||||
});
|
||||
|
||||
@@ -295,10 +333,7 @@ test("extractCiGates: attaches GITHUB_BASE_REF=main env to test-masking + de-dup
|
||||
|
||||
test("extractCiGates: the REAL ci.yml yields the base-reds that leaked in v3.8.46", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const yaml = fs.readFileSync(
|
||||
new URL("../../.github/workflows/ci.yml", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const yaml = fs.readFileSync(new URL("../../.github/workflows/ci.yml", import.meta.url), "utf8");
|
||||
const ids = new Set(extract(yaml).map((g) => g.id));
|
||||
// The exact gates that leaked to the v3.8.46 release PR because the pre-flight
|
||||
// never ran them — --full-ci now reproduces every one.
|
||||
|
||||
Reference in New Issue
Block a user