mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Compare commits
10 Commits
4053e2314a
...
647afe327b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
647afe327b | ||
|
|
d628105503 | ||
|
|
2cf462c1a1 | ||
|
|
0312fe2a40 | ||
|
|
fd739ab008 | ||
|
|
1bd1af2f48 | ||
|
|
06326a3c80 | ||
|
|
a51cd06322 | ||
|
|
f60c9fe542 | ||
|
|
1d7878d857 |
@@ -0,0 +1 @@
|
|||||||
|
- **chore(quality):** rebaseline complexity (2130→2183) and cognitive-complexity (951→968) across the v3.8.49 `/merge-prs` queue-drain. The first step (→2169/→956) cleared inherited base drift measured on the pristine release tip (the PR→release fast-path never ratchets these). The second step (→2183/→968) absorbs the aggregate own-growth of the 41-PR merge-train (each PR under-ceiling individually; the combined batch adds +14/+12). Owner-approved (2026-07-25); structural shrink tracked in [#3501](https://github.com/diegosouzapw/OmniRoute/issues/3501).
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.",
|
"_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.",
|
||||||
"_rebaseline_2026_07_20_owner_night_drain": "Owner-approved (chat, 2026-07-20 ~00:50): 2072->2130. The day's 17 merged PRs consumed the entire slack (tip at 2069/2072); queue PRs #6973(+4)/#7662(+2)/#7719(+1) plus the #7744/#7779 reworks were collectively blocked. Owner chose a wide margin for the remainder of the v3.8.49 cycle instead of per-PR extraction.",
|
"_rebaseline_2026_07_25b_v3849_mergetrain_owngrowth": "Owner-approved (chat, 2026-07-25): 2169->2183 (+14). v3.8.49 /merge-prs 41-PR merge-train aggregate own-growth: measured 2183 on the combined boarded tree (tip ac15014ca7) vs 2169 on the pristine release tip. Each boarded PR sits under the ceiling individually, but the combined batch adds +14 (new branches in #8378 chatCore contextLimit / #8432 cursor native_todo / #8476 combo input-bound / #8526 combo select-all modals / etc — the pre-screen-flagged complexity-growth set). Same merge-burst-inherited-drift class as the notes below; owner chose absorbing the ceiling over per-PR helper-extraction churn. Structural shrink stays debt (#3501); tighten via --update next cycle.",
|
||||||
"count": 2130,
|
"_rebaseline_2026_07_25_v3849_mergequeue_drain": "Owner-approved (chat, 2026-07-25): 2130->2169 (+39). v3.8.49 /merge-prs queue-drain: the cycle's merge burst (the 8 base-red slices + owner PRs + parallel-session merges #8500-8508) accrued inherited cyclomatic drift the fast-path PR->release never ratchets (check:complexity does not run on PR->release). Measured 2169 on the pristine release tip 4053e2314a alone (BEFORE any queue PR boards) — so the entire +39 is base drift already on the tip, not any queued PR's own growth. Every merge-ready PR in the queue was tripping Fast Quality Gates on this shared base-red. Owner approved raising the ceiling to the measured tip value so the ~34-PR merge-train lands without per-PR helper-extraction churn. Structural shrink stays debt (#3501); tighten via --update next cycle.",
|
||||||
|
"count": 2183,
|
||||||
"_rebaseline_2026_07_19_v3849_fix_sweep_cluster": "2059->2072 (owner-approved, 2026-07-19). /fix-prs validation-train sweep: a cluster of otherwise-clean contributor PRs (#6973/#7683/#7662/#7672/#7633/#7767, each +1/+2 cyclomatic own-growth from new provider/auth/combo branches) collectively pushed the count from tip 2056 to 2068. Individually all but #6973 sit under the old 2059 baseline; combined they exceed it. The tip had only 3 units of slack (2056 vs 2059), so every new-feature PR was tripping the ratchet (this was the 4th such block of the day after #7695/#7747/#7768). Owner approved raising the ceiling to 2072 = combined-cluster 2068 + 4 units headroom, so the cluster lands without per-PR helper-extraction churn and near-term feature PRs have breathing room. Measured 2068 on the 9-PR combined probe tree. Structural shrink stays debt (#3501); tighten via --update next cycle.",
|
"_rebaseline_2026_07_19_v3849_fix_sweep_cluster": "2059->2072 (owner-approved, 2026-07-19). /fix-prs validation-train sweep: a cluster of otherwise-clean contributor PRs (#6973/#7683/#7662/#7672/#7633/#7767, each +1/+2 cyclomatic own-growth from new provider/auth/combo branches) collectively pushed the count from tip 2056 to 2068. Individually all but #6973 sit under the old 2059 baseline; combined they exceed it. The tip had only 3 units of slack (2056 vs 2059), so every new-feature PR was tripping the ratchet (this was the 4th such block of the day after #7695/#7747/#7768). Owner approved raising the ceiling to 2072 = combined-cluster 2068 + 4 units headroom, so the cluster lands without per-PR helper-extraction churn and near-term feature PRs have breathing room. Measured 2068 on the 9-PR combined probe tree. Structural shrink stays debt (#3501); tighten via --update next cycle.",
|
||||||
"_rebaseline_2026_07_18_pr7360_quota_visibility_resync": "2058->2059 (+1 vs recorded ceiling; measured 2056 fresh on release tip cab9e5f0c alone, so this ceiling still carries 2 units of un-banked slack from prior shrinkage — real regression from this merge is 2056->2059, +3). PR #7360 (JxnLexn) release-resync: merging origin/release/v3.8.49 to resolve the 3-file conflict (ConnectionRow.tsx/ConnectionsListPanel.tsx/useProviderConnections.ts) unions two already-compliant features in the same already-oversized god-component: release's confirm-delete-account wiring (#7361) and this PR's per-connection quota-visibility wiring. Diffed release-tip-only vs merged violation lists (scripts dumped via getComplexityEslintReport): most entries are the SAME pre-existing violations shifted a few lines (ConnectionRow/getStatusPresentation/inferErrorType — no count change) or marginally bigger (ConnectionRow function complexity 85->86, ConnectionsListPanel function 498->510 lines) from the two ConnectionRow call sites each gaining both PRs' multi-line JSX props. The 2 genuinely NEW crossings are the 'no tag' and 'tagged groups' .map() render callbacks in ConnectionsListPanel.tsx (83 and 85 lines, was <=80 on both parents individually) tipping over 80 lines specifically because both PRs' props land on the same call sites. No new logic was written during the resync itself (only import-statement unions); the growth is inherent to combining the two already-reviewed feature branches. Structural shrink tracked in #3501. Tighten via --update next cycle (true floor is 2056, not 2058).",
|
"_rebaseline_2026_07_18_pr7360_quota_visibility_resync": "2058->2059 (+1 vs recorded ceiling; measured 2056 fresh on release tip cab9e5f0c alone, so this ceiling still carries 2 units of un-banked slack from prior shrinkage — real regression from this merge is 2056->2059, +3). PR #7360 (JxnLexn) release-resync: merging origin/release/v3.8.49 to resolve the 3-file conflict (ConnectionRow.tsx/ConnectionsListPanel.tsx/useProviderConnections.ts) unions two already-compliant features in the same already-oversized god-component: release's confirm-delete-account wiring (#7361) and this PR's per-connection quota-visibility wiring. Diffed release-tip-only vs merged violation lists (scripts dumped via getComplexityEslintReport): most entries are the SAME pre-existing violations shifted a few lines (ConnectionRow/getStatusPresentation/inferErrorType — no count change) or marginally bigger (ConnectionRow function complexity 85->86, ConnectionsListPanel function 498->510 lines) from the two ConnectionRow call sites each gaining both PRs' multi-line JSX props. The 2 genuinely NEW crossings are the 'no tag' and 'tagged groups' .map() render callbacks in ConnectionsListPanel.tsx (83 and 85 lines, was <=80 on both parents individually) tipping over 80 lines specifically because both PRs' props land on the same call sites. No new logic was written during the resync itself (only import-statement unions); the growth is inherent to combining the two already-reviewed feature branches. Structural shrink tracked in #3501. Tighten via --update next cycle (true floor is 2056, not 2058).",
|
||||||
"_rebaseline_2026_07_17_v3849_ownerprs_providers": "2056->2058 (+2). v3.8.49 owner-PR merge campaign own-growth: the new provider handlers/dispatch branches merged this cycle (freetheai/felo/notion/segmind/deepinfra/novita/msdesigner image+video handlers, each adding a format-dispatch guard) pushed cyclomatic violations 2056->2058. Fast-gates PR->release do not run the complexity ratchet, so this surfaced only on re-sync. Spread across the new leaf handlers (not a single extractable function); measured on the release tip. Structural shrink tracked in #3501.",
|
"_rebaseline_2026_07_17_v3849_ownerprs_providers": "2056->2058 (+2). v3.8.49 owner-PR merge campaign own-growth: the new provider handlers/dispatch branches merged this cycle (freetheai/felo/notion/segmind/deepinfra/novita/msdesigner image+video handlers, each adding a format-dispatch guard) pushed cyclomatic violations 2056->2058. Fast-gates PR->release do not run the complexity ratchet, so this surfaced only on re-sync. Spread across the new leaf handlers (not a single extractable function); measured on the release tip. Structural shrink tracked in #3501.",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).",
|
||||||
"_rebaseline_2026_07_22_8131_windowshide_cloudflared_spawn": "PR #8167 (Dingding-leo, fix/windows-hide-child-process, #8131) own growth: src/lib/cloudflaredTunnel.ts 934->935 (+1, irreducible call-site wiring — the single `windowsHide: true` option added to the existing cloudflared spawn() options object so no transient conhost.exe/cmd console window flashes open on Windows). Covered by the pre-merge-fix regression test tests/unit/windows-hide-child-process-spawns-8131.test.ts (added for the two additional spawn() sites the PR missed: ServiceSupervisor.ts, versionManager/processManager.ts) plus the windowsHide assertion added to tests/unit/services/installers/runNpm-shell-5379.test.ts (installers/utils.ts buildNpmExecOptions).",
|
"_rebaseline_2026_07_22_8131_windowshide_cloudflared_spawn": "PR #8167 (Dingding-leo, fix/windows-hide-child-process, #8131) own growth: src/lib/cloudflaredTunnel.ts 934->935 (+1, irreducible call-site wiring — the single `windowsHide: true` option added to the existing cloudflared spawn() options object so no transient conhost.exe/cmd console window flashes open on Windows). Covered by the pre-merge-fix regression test tests/unit/windows-hide-child-process-spawns-8131.test.ts (added for the two additional spawn() sites the PR missed: ServiceSupervisor.ts, versionManager/processManager.ts) plus the windowsHide assertion added to tests/unit/services/installers/runNpm-shell-5379.test.ts (installers/utils.ts buildNpmExecOptions).",
|
||||||
"_rebaseline_2026_07_22_8006_adobe_firefly_media_provider": "PR #8006 (artickc, feat/adobe-firefly-media) own growth: adds Adobe Firefly as a media-only (image + video) provider — unofficial IMS/cookie-session bridge for firefly.adobe.com covering IMS cookie->access_token exchange, discovery-catalog fallback, credits/balance usage, and submit+poll dispatch for both image (nano-banana/gpt-image families) and video (Sora 2/Veo 3.1/Kling 3.0) generation, with 408-under-load retry handling. New leaf open-sse/services/adobeFireflyClient.ts frozen at 1958 (>>cap 800) — a single self-contained upstream client (mirrors the qoderCli.ts precedent for a new provider client that is legitimately large on day one: IMS auth, cookie/JWT normalization, payload builders for 2 media types x multiple model families, SSE-less submit/poll state machine, error sanitization); not extractable without scattering a single upstream integration across artificial module boundaries mid-PR. open-sse/config/imageRegistry.ts (existing, previously under cap) grows 800->821 (+21, the new adobe-firefly IMAGE_PROVIDERS entry + models list, additive registry data at the existing registry chokepoint). src/lib/usage/providerLimits.ts 1000->1003 (+3, adobe-firefly/firefly added to the existing apikey-usage-fetcher allowlist, irreducible call-site wiring mirroring the sibling #7994 PromptQL/HyperAgent entries in the same PR group). Covered by tests/unit/adobe-firefly.test.ts (35/35). Structural shrink tracked in #3501.",
|
"_rebaseline_2026_07_22_8006_adobe_firefly_media_provider": "PR #8006 (artickc, feat/adobe-firefly-media) own growth: adds Adobe Firefly as a media-only (image + video) provider — unofficial IMS/cookie-session bridge for firefly.adobe.com covering IMS cookie->access_token exchange, discovery-catalog fallback, credits/balance usage, and submit+poll dispatch for both image (nano-banana/gpt-image families) and video (Sora 2/Veo 3.1/Kling 3.0) generation, with 408-under-load retry handling. New leaf open-sse/services/adobeFireflyClient.ts frozen at 1958 (>>cap 800) — a single self-contained upstream client (mirrors the qoderCli.ts precedent for a new provider client that is legitimately large on day one: IMS auth, cookie/JWT normalization, payload builders for 2 media types x multiple model families, SSE-less submit/poll state machine, error sanitization); not extractable without scattering a single upstream integration across artificial module boundaries mid-PR. open-sse/config/imageRegistry.ts (existing, previously under cap) grows 800->821 (+21, the new adobe-firefly IMAGE_PROVIDERS entry + models list, additive registry data at the existing registry chokepoint). src/lib/usage/providerLimits.ts 1000->1003 (+3, adobe-firefly/firefly added to the existing apikey-usage-fetcher allowlist, irreducible call-site wiring mirroring the sibling #7994 PromptQL/HyperAgent entries in the same PR group). Covered by tests/unit/adobe-firefly.test.ts (35/35). Structural shrink tracked in #3501.",
|
||||||
"_rebaseline_2026_07_22_7994_hyperagent_web_provider": "PR #7994 (artickc, feat/hyperagent-web) own growth: adds HyperAgent (hyperagent.com) as a new unofficial web-cookie chat provider, reverse-engineered from live SPA captures (thread/session SSE flow, credits/usage endpoint). New leaf open-sse/executors/hyperagent.ts frozen at 937 (>cap 800) — single self-contained executor covering cookie auth, SSE parsing (text/session_start/session_end/done events), and a sticky thread/session cache for multi-turn continuity; not extractable without splitting the executor mid-request-flow (mirrors the sseParser.ts/muse-spark-web.ts precedent for new provider executors that exceed cap on day one). src/lib/usage/providerLimits.ts 1000->1003 (+3, irreducible call-site wiring adding hyperagent/ha to the existing USAGE_FETCHER_PROVIDERS-style allowlist at the chokepoint other web-cookie providers already extend). Covered by tests/unit/executor-hyperagent.test.ts (16/16). Structural shrink tracked in #3501.",
|
"_rebaseline_2026_07_22_7994_hyperagent_web_provider": "PR #7994 (artickc, feat/hyperagent-web) own growth: adds HyperAgent (hyperagent.com) as a new unofficial web-cookie chat provider, reverse-engineered from live SPA captures (thread/session SSE flow, credits/usage endpoint). New leaf open-sse/executors/hyperagent.ts frozen at 937 (>cap 800) — single self-contained executor covering cookie auth, SSE parsing (text/session_start/session_end/done events), and a sticky thread/session cache for multi-turn continuity; not extractable without splitting the executor mid-request-flow (mirrors the sseParser.ts/muse-spark-web.ts precedent for new provider executors that exceed cap on day one). src/lib/usage/providerLimits.ts 1000->1003 (+3, irreducible call-site wiring adding hyperagent/ha to the existing USAGE_FETCHER_PROVIDERS-style allowlist at the chokepoint other web-cookie providers already extend). Covered by tests/unit/executor-hyperagent.test.ts (16/16). Structural shrink tracked in #3501.",
|
||||||
@@ -175,7 +176,7 @@
|
|||||||
"open-sse/executors/duckduckgo-web.ts": 925,
|
"open-sse/executors/duckduckgo-web.ts": 925,
|
||||||
"open-sse/executors/grok-web.ts": 1873,
|
"open-sse/executors/grok-web.ts": 1873,
|
||||||
"open-sse/executors/hyperagent.ts": 937,
|
"open-sse/executors/hyperagent.ts": 937,
|
||||||
"open-sse/executors/muse-spark-web.ts": 1396,
|
"open-sse/executors/muse-spark-web.ts": 1405,
|
||||||
"open-sse/executors/perplexity-web.ts": 1032,
|
"open-sse/executors/perplexity-web.ts": 1032,
|
||||||
"open-sse/handlers/audioSpeech.ts": 1061,
|
"open-sse/handlers/audioSpeech.ts": 1061,
|
||||||
"open-sse/handlers/chatCore.ts": 5125,
|
"open-sse/handlers/chatCore.ts": 5125,
|
||||||
|
|||||||
@@ -123,7 +123,9 @@
|
|||||||
"_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle."
|
"_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle."
|
||||||
},
|
},
|
||||||
"cognitiveComplexity": {
|
"cognitiveComplexity": {
|
||||||
"value": 951,
|
"value": 968,
|
||||||
|
"_rebaseline_2026_07_25b_v3849_mergetrain_owngrowth": "Owner-approved (chat, 2026-07-25): 956->968 (+12). v3.8.49 /merge-prs 41-PR merge-train aggregate own-growth: measured 968 on the combined boarded tree (tip ac15014ca7) vs 956 on the pristine release tip. The batch's new over-threshold functions come from the pre-screen-flagged complexity-growth set (#8378/#8432/#8476/#8526 etc); each PR is under-ceiling alone, the combined batch adds +12. Same merge-burst class as the notes below; owner chose ceiling-absorb over per-PR extraction. Structural shrink tracked in #3501; tighten via --update next cycle.",
|
||||||
|
"_rebaseline_2026_07_25_v3849_mergequeue_drain": "Owner-approved (chat, 2026-07-25): 951->956 (+5). v3.8.49 /merge-prs queue-drain: inherited cognitive-complexity drift from the cycle's merge burst (base-red slices + owner PRs + parallel-session merges #8500-8508); check:cognitive-complexity does not run on PR->release fast-gates, so it accrued unmeasured. Measured 956 on the pristine release tip 4053e2314a alone (BEFORE any queue PR boards) — the entire +5 is base drift already on the tip, reddening Fast Quality Gates for every merge-ready PR. Owner approved raising the ceiling to the measured tip value so the ~34-PR merge-train lands without per-PR extraction churn. Structural shrink tracked in #3501; tighten via --update next cycle.",
|
||||||
"_rebaseline_2026_07_10_gcf_v3_2": "885->888 (+3). PR feat/headroom-gcf-v3.2-nested-flattening: own growth from re-vendoring the GCF (Headroom) codec to spec v3.2 (nested flattening). The new over-threshold functions are the vendored v3.2 flatten/unflatten walk in open-sse/services/compression/engines/headroom/gcf/{generic,decode_generic}.ts. Imported third-party code kept byte-faithful to upstream gcf-typescript; measured 888 with the update vs 885 on the pristine origin/release/v3.8.47 tip. Guarded by tests/unit/compression/headroom-smartcrusher.test.ts (deep-nested case). Structural shrink belongs upstream in gcf.",
|
"_rebaseline_2026_07_10_gcf_v3_2": "885->888 (+3). PR feat/headroom-gcf-v3.2-nested-flattening: own growth from re-vendoring the GCF (Headroom) codec to spec v3.2 (nested flattening). The new over-threshold functions are the vendored v3.2 flatten/unflatten walk in open-sse/services/compression/engines/headroom/gcf/{generic,decode_generic}.ts. Imported third-party code kept byte-faithful to upstream gcf-typescript; measured 888 with the update vs 885 on the pristine origin/release/v3.8.47 tip. Guarded by tests/unit/compression/headroom-smartcrusher.test.ts (deep-nested case). Structural shrink belongs upstream in gcf.",
|
||||||
"_rebaseline_2026_07_12_v3847_mergetrain_burst": "885->890 (+5). v3.8.47 /merge-prs merge-train batch (23 merge-ready PRs) inherited drift: cognitive-complexity does NOT run on PR->release fast-gates, so incidental growth accrued unmeasured across the batch. Measured 890 on the combined merge-train tip (5d980352d) vs 885 on the pristine release tip 1b7a9150e. #6838 (headroom gcf codec re-vendor) accounts for +3 (its own baseline bump to 888, superseded here by this later 890 rebaseline which already covers it); the remaining +2 is parallel-batch drift across the other 22 PRs. Owner-approved rebaseline (merge-burst reconciliation, same class as the v3.8.46/v3.8.44 notes below). Tighten via --update next cycle.",
|
"_rebaseline_2026_07_12_v3847_mergetrain_burst": "885->890 (+5). v3.8.47 /merge-prs merge-train batch (23 merge-ready PRs) inherited drift: cognitive-complexity does NOT run on PR->release fast-gates, so incidental growth accrued unmeasured across the batch. Measured 890 on the combined merge-train tip (5d980352d) vs 885 on the pristine release tip 1b7a9150e. #6838 (headroom gcf codec re-vendor) accounts for +3 (its own baseline bump to 888, superseded here by this later 890 rebaseline which already covers it); the remaining +2 is parallel-batch drift across the other 22 PRs. Owner-approved rebaseline (merge-burst reconciliation, same class as the v3.8.46/v3.8.44 notes below). Tighten via --update next cycle.",
|
||||||
"_rebaseline_2026_07_09_6587_kiro_api_key_auth": "884->885 (+1). PR #6587 (@strangersp) own growth: open-sse/services/usage/kiro.ts gains ONE new over-threshold function — getKiroUsage grew from a single fetch to a 3-endpoint fallback chain (codewhisperer-get / codewhisperer-post / q-get) with per-attempt auth-header selection (tokentype: API_KEY vs Bearer-only), needed so usage/quota lookups work for the new long-lived-API-key auth path in addition to the existing OAuth path (measured: 0 violations on release tip -> 1 violation, complexity 33, at open-sse/services/usage/kiro.ts). Covered by tests/unit/kiro-iam-profilearn-usage.test.ts (tokentype header selection, friendly auth-expired/rejected-token messages). Cohesive multi-endpoint-fallback logic at an existing usage chokepoint; not extractable without splitting the fallback loop mid-merge. Structural shrink tracked in #3501.",
|
"_rebaseline_2026_07_09_6587_kiro_api_key_auth": "884->885 (+1). PR #6587 (@strangersp) own growth: open-sse/services/usage/kiro.ts gains ONE new over-threshold function — getKiroUsage grew from a single fetch to a 3-endpoint fallback chain (codewhisperer-get / codewhisperer-post / q-get) with per-attempt auth-header selection (tokentype: API_KEY vs Bearer-only), needed so usage/quota lookups work for the new long-lived-API-key auth path in addition to the existing OAuth path (measured: 0 violations on release tip -> 1 violation, complexity 33, at open-sse/services/usage/kiro.ts). Covered by tests/unit/kiro-iam-profilearn-usage.test.ts (tokentype header selection, friendly auth-expired/rejected-token messages). Cohesive multi-endpoint-fallback logic at an existing usage chokepoint; not extractable without splitting the fallback loop mid-merge. Structural shrink tracked in #3501.",
|
||||||
|
|||||||
@@ -209,14 +209,13 @@ on). Without a `max_concurrent` cap the behavior is unchanged.
|
|||||||
|
|
||||||
### Combo cooldown-aware retry
|
### Combo cooldown-aware retry
|
||||||
|
|
||||||
For quota-share and `auto` combos, a request that would crystallize a 429 for a
|
For every combo strategy (when enabled), a request that would crystallize a 429
|
||||||
SHORT transient cooldown waits it out and re-dispatches instead of returning
|
for a SHORT transient cooldown waits it out and re-dispatches instead of
|
||||||
the 429 — this covers Gemini-class TPM/RPM windows (~60s retry-after) on a
|
returning the 429 — this covers Gemini-class TPM/RPM windows (~60s retry-after)
|
||||||
multi-model `auto` combo, e.g. both targets of a 2-model combo hitting a
|
on multi-model combos, e.g. both targets of a 2-model combo hitting a per-model
|
||||||
per-model rate limit. Bounded by `comboCooldownWait` (`enabled`, `maxWaitMs`
|
rate limit. Bounded by `comboCooldownWait` (`enabled`, `maxWaitMs`, `maxAttempts`,
|
||||||
65s, `maxAttempts` 2, `budgetMs` 130s, hard ceiling 90s) in **Settings →
|
`budgetMs`) in **Settings → Resilience**. It never waits on `quota_exhausted`
|
||||||
Resilience**. It never waits on `quota_exhausted` (locked until midnight) or
|
(locked until midnight) or auth/not-found reasons.
|
||||||
auth/not-found reasons.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,19 @@ export async function initAuggieModels(
|
|||||||
|
|
||||||
type AuggieModelResolution = { ok: true; model: string } | { ok: false; error: string };
|
type AuggieModelResolution = { ok: true; model: string } | { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This workspace compiles with `strictNullChecks: false`, where a boolean-literal
|
||||||
|
* discriminant narrows the positive branch but not the negative one — so `!r.ok` alone
|
||||||
|
* leaves `r` as the full union and reading `.error` fails. An explicit type predicate
|
||||||
|
* narrows under those settings without retagging the union (which is public API here:
|
||||||
|
* `resolveAuggieModel` is exported and its tests deep-equal the `{ ok: true, ... }` shape).
|
||||||
|
*/
|
||||||
|
function isAuggieModelFailure(
|
||||||
|
resolution: AuggieModelResolution
|
||||||
|
): resolution is Extract<AuggieModelResolution, { ok: false }> {
|
||||||
|
return !resolution.ok;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate + resolve the requested model against the registry allowlist.
|
* Validate + resolve the requested model against the registry allowlist.
|
||||||
* Rejects flag-smuggling (leading "-") and any id not declared in the registry.
|
* Rejects flag-smuggling (leading "-") and any id not declared in the registry.
|
||||||
@@ -384,7 +397,7 @@ export class AuggieExecutor extends BaseExecutor {
|
|||||||
await initAuggieModels(signal);
|
await initAuggieModels(signal);
|
||||||
// Argument-injection defense: never forward an unvalidated model into the argv.
|
// Argument-injection defense: never forward an unvalidated model into the argv.
|
||||||
const modelResolution = resolveAuggieModel(model);
|
const modelResolution = resolveAuggieModel(model);
|
||||||
if (!modelResolution.ok) {
|
if (isAuggieModelFailure(modelResolution)) {
|
||||||
const response = wantsStream
|
const response = wantsStream
|
||||||
? buildAuggieSseError(modelResolution.error)
|
? buildAuggieSseError(modelResolution.error)
|
||||||
: errorResponse(400, modelResolution.error);
|
: errorResponse(400, modelResolution.error);
|
||||||
|
|||||||
@@ -8,6 +8,26 @@ import {
|
|||||||
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
|
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
|
||||||
import { stripUnsupportedParams } from "../translator/paramSupport.ts";
|
import { stripUnsupportedParams } from "../translator/paramSupport.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a Copilot credential refresh resolves to.
|
||||||
|
*
|
||||||
|
* `refreshCredentials()` returns one of three shapes — the raw GitHub token pair, that pair
|
||||||
|
* plus the minted Copilot token, or the Copilot token folded onto the existing credentials —
|
||||||
|
* and `null` when nothing could be refreshed. Left to inference, the union of those literals
|
||||||
|
* is narrower than the contract subclasses actually honor: `GheCopilotExecutor` carries a
|
||||||
|
* wider `providerSpecificData` (it also records the enterprise proxy URL) and omits
|
||||||
|
* `expiresIn`, which made a valid override fail with TS2416. Every field is therefore
|
||||||
|
* optional here — callers already treat them as such.
|
||||||
|
*/
|
||||||
|
export interface RefreshedCopilotCredentials {
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
copilotToken?: string;
|
||||||
|
copilotTokenExpiresAt?: string | number;
|
||||||
|
providerSpecificData?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export class GithubExecutor extends BaseExecutor {
|
export class GithubExecutor extends BaseExecutor {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("github", PROVIDERS.github);
|
super("github", PROVIDERS.github);
|
||||||
@@ -367,7 +387,7 @@ export class GithubExecutor extends BaseExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshCredentials(credentials, log) {
|
async refreshCredentials(credentials, log): Promise<RefreshedCopilotCredentials | null> {
|
||||||
let copilotResult = await this.refreshCopilotToken(credentials.accessToken, log);
|
let copilotResult = await this.refreshCopilotToken(credentials.accessToken, log);
|
||||||
|
|
||||||
if (!copilotResult && credentials.refreshToken) {
|
if (!copilotResult && credentials.refreshToken) {
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ export class HailuoWebExecutor extends BaseExecutor {
|
|||||||
super("hailuo-web", { id: "hailuo-web", baseUrl: BASE_URL });
|
super("hailuo-web", { id: "hailuo-web", baseUrl: BASE_URL });
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildHeaders(token: string, yy: string): Record<string, string> {
|
private buildStreamHeaders(token: string, yy: string): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
Accept: "text/event-stream",
|
Accept: "text/event-stream",
|
||||||
"User-Agent": USER_AGENT,
|
"User-Agent": USER_AGENT,
|
||||||
@@ -357,7 +357,7 @@ export class HailuoWebExecutor extends BaseExecutor {
|
|||||||
form.set("chatID", chatID);
|
form.set("chatID", chatID);
|
||||||
form.set("searchMode", "0");
|
form.set("searchMode", "0");
|
||||||
|
|
||||||
return { url: `${BASE_URL}${pathAndQuery}`, headers: this.buildHeaders(token, yy), form };
|
return { url: `${BASE_URL}${pathAndQuery}`, headers: this.buildStreamHeaders(token, yy), form };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST the signed multipart request and normalize both network + upstream-status errors. */
|
/** POST the signed multipart request and normalize both network + upstream-status errors. */
|
||||||
|
|||||||
@@ -73,11 +73,13 @@ export class LMArenaExecutor extends BaseExecutor {
|
|||||||
super("lmarena", { format: "openai", ...providerConfig });
|
super("lmarena", { format: "openai", ...providerConfig });
|
||||||
}
|
}
|
||||||
|
|
||||||
protected buildUrl(_model: string, _credentials: unknown): string {
|
// Public to match BaseExecutor.buildUrl — a subclass may widen visibility but not
|
||||||
|
// narrow it. This was masked behind the buildHeaders TS2416 until that one cleared.
|
||||||
|
buildUrl(_model: string, _credentials: unknown): string {
|
||||||
return LMARENA_STREAM_URL;
|
return LMARENA_STREAM_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected buildHeaders(
|
protected buildRequestHeaders(
|
||||||
_model: string,
|
_model: string,
|
||||||
credentials: unknown,
|
credentials: unknown,
|
||||||
_body: unknown
|
_body: unknown
|
||||||
@@ -91,7 +93,7 @@ export class LMArenaExecutor extends BaseExecutor {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected transformRequest(body: unknown, model: string, credentials?: unknown): unknown {
|
transformRequest(body: unknown, model: string, credentials?: unknown): unknown {
|
||||||
const openaiBody = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
const openaiBody = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||||
const messages = Array.isArray(openaiBody.messages)
|
const messages = Array.isArray(openaiBody.messages)
|
||||||
? (openaiBody.messages as OpenAIMessage[])
|
? (openaiBody.messages as OpenAIMessage[])
|
||||||
@@ -115,7 +117,7 @@ export class LMArenaExecutor extends BaseExecutor {
|
|||||||
async execute(input: ExecuteInput) {
|
async execute(input: ExecuteInput) {
|
||||||
const { model, body, stream, credentials, signal, log } = input;
|
const { model, body, stream, credentials, signal, log } = input;
|
||||||
const url = this.buildUrl(model, credentials);
|
const url = this.buildUrl(model, credentials);
|
||||||
const headers = this.buildHeaders(model, credentials, body);
|
const headers = this.buildRequestHeaders(model, credentials, body);
|
||||||
const cookie = readLMArenaCookie(credentials);
|
const cookie = readLMArenaCookie(credentials);
|
||||||
|
|
||||||
if (!cookie) {
|
if (!cookie) {
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ interface AccountState {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
cooldownUntil: number;
|
cooldownUntil: number;
|
||||||
consecutiveFails: number;
|
consecutiveFails: number;
|
||||||
|
/**
|
||||||
|
* #3837/#5521: the account's resolved proxy, or `null` when none is configured.
|
||||||
|
* Always present (never `undefined`) so callers can read `acct.proxy` directly —
|
||||||
|
* syncAccountsFromCredentials() writes it on every account on every sync.
|
||||||
|
*/
|
||||||
|
proxy: AccountProxyConfig["proxy"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJwtExp(jwt: string): number {
|
function parseJwtExp(jwt: string): number {
|
||||||
|
|||||||
@@ -906,6 +906,15 @@ function buildWsUrl(authorization: string, requestId: string): string {
|
|||||||
|
|
||||||
type GraphqlResult = { ok: true } | { ok: false; error: string };
|
type GraphqlResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows the failure arm. Under this workspace's `strictNullChecks: false`, a
|
||||||
|
* boolean-literal discriminant narrows the positive branch but not the negative one, so
|
||||||
|
* `!result.ok` leaves the full union and `.error` is unreachable to the checker.
|
||||||
|
*/
|
||||||
|
function isGraphqlFailure(result: GraphqlResult): result is Extract<GraphqlResult, { ok: false }> {
|
||||||
|
return !result.ok;
|
||||||
|
}
|
||||||
|
|
||||||
async function graphqlPost(
|
async function graphqlPost(
|
||||||
docId: string,
|
docId: string,
|
||||||
variables: Record<string, unknown>,
|
variables: Record<string, unknown>,
|
||||||
@@ -1315,7 +1324,7 @@ export class MuseSparkWebExecutor extends BaseExecutor {
|
|||||||
"Warmup",
|
"Warmup",
|
||||||
signal
|
signal
|
||||||
);
|
);
|
||||||
if (!warmupResult.ok) {
|
if (isGraphqlFailure(warmupResult)) {
|
||||||
evictContinuationIfNeeded(cached, continuationCacheKey);
|
evictContinuationIfNeeded(cached, continuationCacheKey);
|
||||||
log?.error?.("MUSE-SPARK-WEB", `Warmup failed: ${warmupResult.error}`);
|
log?.error?.("MUSE-SPARK-WEB", `Warmup failed: ${warmupResult.error}`);
|
||||||
return errorResult(502, warmupResult.error, "meta_ai_warmup_failed", {}, body);
|
return errorResult(502, warmupResult.error, "meta_ai_warmup_failed", {}, body);
|
||||||
@@ -1329,7 +1338,7 @@ export class MuseSparkWebExecutor extends BaseExecutor {
|
|||||||
"Mode switch",
|
"Mode switch",
|
||||||
signal
|
signal
|
||||||
);
|
);
|
||||||
if (!modeResult.ok) {
|
if (isGraphqlFailure(modeResult)) {
|
||||||
evictContinuationIfNeeded(cached, continuationCacheKey);
|
evictContinuationIfNeeded(cached, continuationCacheKey);
|
||||||
log?.error?.("MUSE-SPARK-WEB", `Mode switch failed: ${modeResult.error}`);
|
log?.error?.("MUSE-SPARK-WEB", `Mode switch failed: ${modeResult.error}`);
|
||||||
return errorResult(502, modeResult.error, "meta_ai_mode_switch_failed", {}, body);
|
return errorResult(502, modeResult.error, "meta_ai_mode_switch_failed", {}, body);
|
||||||
|
|||||||
@@ -606,13 +606,26 @@ export class NotionWebExecutor extends BaseExecutor {
|
|||||||
|
|
||||||
const reqHeaders = buildNotionExecuteHeaders({ cookie, spaceId, userId, agent });
|
const reqHeaders = buildNotionExecuteHeaders({ cookie, spaceId, userId, agent });
|
||||||
|
|
||||||
|
type NotionAttempt =
|
||||||
|
| { ok: true; finalText: string; reqBody: Record<string, unknown> }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
errorResult: ReturnType<typeof makeErrorResult>;
|
||||||
|
retryable: boolean;
|
||||||
|
reqBody: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// `strictNullChecks: false` narrows a boolean-literal discriminant on the positive
|
||||||
|
// branch only, so `!attempt.ok` leaves the full union and the failure-only fields are
|
||||||
|
// unreachable to the checker. An explicit predicate narrows under those settings.
|
||||||
|
const isFailedAttempt = (
|
||||||
|
attempt: NotionAttempt
|
||||||
|
): attempt is Extract<NotionAttempt, { ok: false }> => !attempt.ok;
|
||||||
|
|
||||||
const runOnce = async (opts: {
|
const runOnce = async (opts: {
|
||||||
createThread: boolean;
|
createThread: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
}): Promise<
|
}): Promise<NotionAttempt> => {
|
||||||
| { ok: true; finalText: string; reqBody: Record<string, unknown> }
|
|
||||||
| { ok: false; errorResult: ReturnType<typeof makeErrorResult>; retryable: boolean; reqBody: Record<string, unknown> }
|
|
||||||
> => {
|
|
||||||
const transcript = buildNotionTranscript(messages, {
|
const transcript = buildNotionTranscript(messages, {
|
||||||
notionModel: notionCodename || undefined,
|
notionModel: notionCodename || undefined,
|
||||||
spaceId,
|
spaceId,
|
||||||
@@ -681,13 +694,13 @@ export class NotionWebExecutor extends BaseExecutor {
|
|||||||
let attempt = await runOnce({ createThread, threadId });
|
let attempt = await runOnce({ createThread, threadId });
|
||||||
|
|
||||||
// One automatic retry for transient Notion faults — same threadId, never create again
|
// One automatic retry for transient Notion faults — same threadId, never create again
|
||||||
if (!attempt.ok && attempt.retryable) {
|
if (isFailedAttempt(attempt) && attempt.retryable) {
|
||||||
const delayMs = process.env.NODE_ENV === "test" || process.env.VITEST ? 20 : 700 + Math.floor(Math.random() * 400);
|
const delayMs = process.env.NODE_ENV === "test" || process.env.VITEST ? 20 : 700 + Math.floor(Math.random() * 400);
|
||||||
await new Promise((r) => setTimeout(r, delayMs));
|
await new Promise((r) => setTimeout(r, delayMs));
|
||||||
attempt = await runOnce({ createThread: false, threadId });
|
attempt = await runOnce({ createThread: false, threadId });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!attempt.ok) {
|
if (isFailedAttempt(attempt)) {
|
||||||
return attempt.errorResult;
|
return attempt.errorResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -338,13 +338,11 @@ export class OpencodeExecutor extends BaseExecutor {
|
|||||||
) {
|
) {
|
||||||
delete (modifiedBody as Record<string, unknown>).client_metadata;
|
delete (modifiedBody as Record<string, unknown>).client_metadata;
|
||||||
}
|
}
|
||||||
if (
|
if (modifiedBody && typeof modifiedBody === "object" && !Array.isArray(modifiedBody)) {
|
||||||
modifiedBody &&
|
const mb = modifiedBody as Record<string, unknown>;
|
||||||
typeof modifiedBody === "object" &&
|
if (Array.isArray(mb.tools) && mb.tools.length > 128) {
|
||||||
Array.isArray(modifiedBody.tools) &&
|
mb.tools = mb.tools.slice(0, 128);
|
||||||
modifiedBody.tools.length > 128
|
}
|
||||||
) {
|
|
||||||
modifiedBody.tools = modifiedBody.tools.slice(0, 128);
|
|
||||||
}
|
}
|
||||||
if (modifiedBody && typeof modifiedBody === "object" && !Array.isArray(modifiedBody)) {
|
if (modifiedBody && typeof modifiedBody === "object" && !Array.isArray(modifiedBody)) {
|
||||||
const mb = modifiedBody as Record<string, unknown>;
|
const mb = modifiedBody as Record<string, unknown>;
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export class QwenWebExecutor extends BaseExecutor {
|
|||||||
super("qwen-web", { id: "qwen-web", baseUrl: BASE_URL });
|
super("qwen-web", { id: "qwen-web", baseUrl: BASE_URL });
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildHeaders(
|
private buildApiHeaders(
|
||||||
token: string,
|
token: string,
|
||||||
cookieHeader: string,
|
cookieHeader: string,
|
||||||
chatId?: string
|
chatId?: string
|
||||||
@@ -139,7 +139,7 @@ export class QwenWebExecutor extends BaseExecutor {
|
|||||||
try {
|
try {
|
||||||
const newChatRes = await fetch(CHATS_NEW_URL, {
|
const newChatRes = await fetch(CHATS_NEW_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: this.buildHeaders(token, cookieHeader),
|
headers: this.buildApiHeaders(token, cookieHeader),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: "New Chat",
|
title: "New Chat",
|
||||||
models: [modelId],
|
models: [modelId],
|
||||||
@@ -186,7 +186,7 @@ export class QwenWebExecutor extends BaseExecutor {
|
|||||||
try {
|
try {
|
||||||
upstream = await fetch(completionUrl, {
|
upstream = await fetch(completionUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: this.buildHeaders(token, cookieHeader, chatId),
|
headers: this.buildApiHeaders(token, cookieHeader, chatId),
|
||||||
body: JSON.stringify(msgPayload),
|
body: JSON.stringify(msgPayload),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@@ -251,7 +251,7 @@ export class QwenWebExecutor extends BaseExecutor {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
url: completionUrl,
|
url: completionUrl,
|
||||||
headers: this.buildHeaders(token, cookieHeader, chatId),
|
headers: this.buildApiHeaders(token, cookieHeader, chatId),
|
||||||
transformedBody: msgPayload,
|
transformedBody: msgPayload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,8 +248,16 @@ function buildGetChatMessageRequest(
|
|||||||
|
|
||||||
// ─── gRPC-web framing ────────────────────────────────────────────────────────
|
// ─── gRPC-web framing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Wrap a protobuf message in a 5-byte gRPC-web data frame. */
|
/**
|
||||||
function grpcWebFrame(payload: Uint8Array): Uint8Array {
|
* Wrap a protobuf message in a 5-byte gRPC-web data frame.
|
||||||
|
*
|
||||||
|
* Returns `Uint8Array<ArrayBuffer>`, not bare `Uint8Array`: the frame is
|
||||||
|
* allocated with `new Uint8Array(length)`, which is always ArrayBuffer-backed,
|
||||||
|
* and only that narrower form satisfies `BodyInit` at the `fetch` call below.
|
||||||
|
* Bare `Uint8Array` widens to `Uint8Array<ArrayBufferLike>`, which admits
|
||||||
|
* `SharedArrayBuffer` and is therefore rejected as a request body.
|
||||||
|
*/
|
||||||
|
function grpcWebFrame(payload: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
const frame = new Uint8Array(5 + payload.length);
|
const frame = new Uint8Array(5 + payload.length);
|
||||||
frame[0] = 0x00; // compression flag: no compression
|
frame[0] = 0x00; // compression flag: no compression
|
||||||
const view = new DataView(frame.buffer);
|
const view = new DataView(frame.buffer);
|
||||||
|
|||||||
@@ -117,8 +117,18 @@ function createErrorChunk(model: string, message: string): Record<string, unknow
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single controller capability these SSE helpers use. They only ever enqueue —
|
||||||
|
* never `close()`, never read `desiredSize` — so typing them by that one method lets
|
||||||
|
* the same code serve both stream kinds. The wider
|
||||||
|
* `ReadableStreamDefaultController` annotation rejected every call site, because the
|
||||||
|
* helpers are driven from a TransformStream and `TransformStreamDefaultController`
|
||||||
|
* has no `close()`.
|
||||||
|
*/
|
||||||
|
type SseEnqueueTarget = Pick<ReadableStreamDefaultController<Uint8Array>, "enqueue">;
|
||||||
|
|
||||||
function enqueueSseObject(
|
function enqueueSseObject(
|
||||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
controller: SseEnqueueTarget,
|
||||||
encoder: TextEncoder,
|
encoder: TextEncoder,
|
||||||
chunk: unknown
|
chunk: unknown
|
||||||
): void {
|
): void {
|
||||||
@@ -202,7 +212,7 @@ function wrapZedCompletionStream(
|
|||||||
let buffer = "";
|
let buffer = "";
|
||||||
let done = false;
|
let done = false;
|
||||||
|
|
||||||
const finish = (controller: ReadableStreamDefaultController<Uint8Array>) => {
|
const finish = (controller: SseEnqueueTarget) => {
|
||||||
if (done) return;
|
if (done) return;
|
||||||
const finalChunk = convertProviderEvent(provider, null, state);
|
const finalChunk = convertProviderEvent(provider, null, state);
|
||||||
enqueueSseObject(controller, encoder, finalChunk);
|
enqueueSseObject(controller, encoder, finalChunk);
|
||||||
@@ -210,7 +220,7 @@ function wrapZedCompletionStream(
|
|||||||
done = true;
|
done = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const processLine = (line: string, controller: ReadableStreamDefaultController<Uint8Array>) => {
|
const processLine = (line: string, controller: SseEnqueueTarget) => {
|
||||||
if (done) return;
|
if (done) return;
|
||||||
const payload = unwrapZedLine(line);
|
const payload = unwrapZedLine(line);
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
|
|||||||
@@ -72,11 +72,16 @@ function getUploadedFileName(file: Blob & { name?: unknown }): string {
|
|||||||
return typeof file.name === "string" && file.name.length > 0 ? file.name : "audio.wav";
|
return typeof file.name === "string" && file.name.length > 0 ? file.name : "audio.wav";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `body` is `Uint8Array<ArrayBuffer>`, not bare `Uint8Array`: `new Uint8Array(n)`
|
||||||
|
* is always ArrayBuffer-backed, and only that narrower form satisfies `BodyInit`
|
||||||
|
* (the bare type widens to `ArrayBufferLike`, which admits `SharedArrayBuffer`).
|
||||||
|
*/
|
||||||
export async function buildMultipartBody(
|
export async function buildMultipartBody(
|
||||||
file: Blob & { name?: unknown },
|
file: Blob & { name?: unknown },
|
||||||
fields: Record<string, string>,
|
fields: Record<string, string>,
|
||||||
fileFieldName = "file"
|
fileFieldName = "file"
|
||||||
): Promise<{ body: Uint8Array; contentType: string }> {
|
): Promise<{ body: Uint8Array<ArrayBuffer>; contentType: string }> {
|
||||||
const boundary = "----OmniRouteAudioBoundary" + Date.now().toString(36);
|
const boundary = "----OmniRouteAudioBoundary" + Date.now().toString(36);
|
||||||
const parts: Uint8Array[] = [];
|
const parts: Uint8Array[] = [];
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|||||||
@@ -104,15 +104,26 @@ interface DesignerWebRequestConfig {
|
|||||||
pollIntervalMs: number;
|
pollIntervalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of request validation. String-discriminated rather than `ok: boolean`
|
||||||
|
* because `open-sse` compiles with `strictNullChecks: false`, where a
|
||||||
|
* boolean-literal discriminant narrows the positive branch but leaves the
|
||||||
|
* negative one as the full union — so `if (!resolved.ok)` would not expose
|
||||||
|
* `status`/`error`. All three unions in this file shared that root cause.
|
||||||
|
*/
|
||||||
|
type DesignerWebRequestResolution =
|
||||||
|
| { state: "resolved"; config: DesignerWebRequestConfig }
|
||||||
|
| { state: "invalid"; status: number; error: string };
|
||||||
|
|
||||||
/** Validates the request and resolves auth + poll timing. Returns an error status/message on failure. */
|
/** Validates the request and resolves auth + poll timing. Returns an error status/message on failure. */
|
||||||
function resolveDesignerWebRequest(
|
function resolveDesignerWebRequest(
|
||||||
body: { prompt?: unknown; size?: unknown; timeout_ms?: unknown; poll_interval_ms?: unknown },
|
body: { prompt?: unknown; size?: unknown; timeout_ms?: unknown; poll_interval_ms?: unknown },
|
||||||
credentials: { apiKey?: string; accessToken?: string }
|
credentials: { apiKey?: string; accessToken?: string }
|
||||||
): { ok: true; config: DesignerWebRequestConfig } | { ok: false; status: number; error: string } {
|
): DesignerWebRequestResolution {
|
||||||
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
state: "invalid",
|
||||||
status: 400,
|
status: 400,
|
||||||
error: "Prompt is required for Microsoft Designer image generation",
|
error: "Prompt is required for Microsoft Designer image generation",
|
||||||
};
|
};
|
||||||
@@ -120,7 +131,11 @@ function resolveDesignerWebRequest(
|
|||||||
|
|
||||||
const accessToken = credentials?.apiKey || credentials?.accessToken;
|
const accessToken = credentials?.apiKey || credentials?.accessToken;
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return { ok: false, status: 401, error: "Microsoft Designer credentials missing access_token" };
|
return {
|
||||||
|
state: "invalid",
|
||||||
|
status: 401,
|
||||||
|
error: "Microsoft Designer credentials missing access_token",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutMs = normalizePositiveNumber(
|
const timeoutMs = normalizePositiveNumber(
|
||||||
@@ -139,7 +154,7 @@ function resolveDesignerWebRequest(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
state: "resolved",
|
||||||
config: {
|
config: {
|
||||||
prompt,
|
prompt,
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -151,10 +166,20 @@ function resolveDesignerWebRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type DesignerWebStepResult =
|
type DesignerWebPending = { state: "pending"; waitMs: number };
|
||||||
| { done: false; waitMs: number }
|
type DesignerWebReady = { state: "ready"; imageUrls: string[] };
|
||||||
| { done: true; success: true; imageUrls: string[] }
|
type DesignerWebFailed = { state: "failed"; status: number; error: string };
|
||||||
| { done: true; success: false; status: number; error: string };
|
|
||||||
|
/** One poll cycle: still working, finished with images, or finished with an error. */
|
||||||
|
type DesignerWebStepResult = DesignerWebPending | DesignerWebReady | DesignerWebFailed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the poll loop hands back. Deliberately excludes the pending arm — the
|
||||||
|
* loop either returns a terminal step or synthesizes a 504, and never surfaces
|
||||||
|
* `pending` to its caller. The previous signature admitted it, which is why
|
||||||
|
* `outcome.success` did not exist on every member of that union.
|
||||||
|
*/
|
||||||
|
type DesignerWebOutcome = DesignerWebReady | DesignerWebFailed;
|
||||||
|
|
||||||
/** Runs one submit/poll fetch cycle and classifies the outcome. */
|
/** Runs one submit/poll fetch cycle and classifies the outcome. */
|
||||||
async function stepDesignerWebPoll(
|
async function stepDesignerWebPoll(
|
||||||
@@ -167,23 +192,29 @@ async function stepDesignerWebPoll(
|
|||||||
const resp = await fetchImpl(baseUrl, { method: "POST", headers, body: formBody });
|
const resp = await fetchImpl(baseUrl, { method: "POST", headers, body: formBody });
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
return { done: true, success: false, status: resp.status, error: sanitizeErrorMessage(await resp.text()) };
|
return {
|
||||||
|
state: "failed",
|
||||||
|
status: resp.status,
|
||||||
|
error: sanitizeErrorMessage(await resp.text()),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseDesignerWebResponse(await resp.json());
|
const parsed = parseDesignerWebResponse(await resp.json());
|
||||||
|
|
||||||
if (parsed.status === "ready") {
|
if (parsed.status === "ready") {
|
||||||
return { done: true, success: true, imageUrls: parsed.imageUrls };
|
return { state: "ready", imageUrls: parsed.imageUrls };
|
||||||
}
|
}
|
||||||
if (parsed.status === "empty") {
|
if (parsed.status === "empty") {
|
||||||
return {
|
return {
|
||||||
done: true,
|
state: "failed",
|
||||||
success: false,
|
|
||||||
status: 502,
|
status: 502,
|
||||||
error: "Microsoft Designer response did not contain image data or polling metadata",
|
error: "Microsoft Designer response did not contain image data or polling metadata",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { done: false, waitMs: Math.min(parsed.pollIntervalMs ?? pollIntervalMs, pollIntervalMs) };
|
return {
|
||||||
|
state: "pending",
|
||||||
|
waitMs: Math.min(parsed.pollIntervalMs ?? pollIntervalMs, pollIntervalMs),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drives the submit-then-poll loop to completion, timeout, or a terminal error. */
|
/** Drives the submit-then-poll loop to completion, timeout, or a terminal error. */
|
||||||
@@ -192,7 +223,7 @@ async function runDesignerWebPollLoop(
|
|||||||
config: DesignerWebRequestConfig,
|
config: DesignerWebRequestConfig,
|
||||||
fetchImpl: typeof fetch,
|
fetchImpl: typeof fetch,
|
||||||
log?: { info?: (...args: unknown[]) => void }
|
log?: { info?: (...args: unknown[]) => void }
|
||||||
): Promise<DesignerWebStepResult | { done: true; success: false; status: 504; error: string }> {
|
): Promise<DesignerWebOutcome> {
|
||||||
const deadline = Date.now() + config.timeoutMs;
|
const deadline = Date.now() + config.timeoutMs;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
|
|
||||||
@@ -205,14 +236,13 @@ async function runDesignerWebPollLoop(
|
|||||||
config.pollIntervalMs,
|
config.pollIntervalMs,
|
||||||
fetchImpl
|
fetchImpl
|
||||||
);
|
);
|
||||||
if (step.done) return step;
|
if (step.state !== "pending") return step;
|
||||||
log?.info?.("IMAGE", `designer-web pending, poll #${attempt} in ${step.waitMs}ms`);
|
log?.info?.("IMAGE", `designer-web pending, poll #${attempt} in ${step.waitMs}ms`);
|
||||||
await new Promise((resolve) => setTimeout(resolve, step.waitMs));
|
await new Promise((resolve) => setTimeout(resolve, step.waitMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
done: true,
|
state: "failed",
|
||||||
success: false,
|
|
||||||
status: 504,
|
status: 504,
|
||||||
error: "Microsoft Designer image generation timed out waiting for a result",
|
error: "Microsoft Designer image generation timed out waiting for a result",
|
||||||
};
|
};
|
||||||
@@ -237,13 +267,19 @@ export async function handleDesignerWebImageGeneration({
|
|||||||
}) {
|
}) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const resolved = resolveDesignerWebRequest(body, credentials);
|
const resolved = resolveDesignerWebRequest(body, credentials);
|
||||||
if (!resolved.ok) {
|
if (resolved.state === "invalid") {
|
||||||
return saveImageErrorResult({ provider, model, status: resolved.status, startTime, error: resolved.error });
|
return saveImageErrorResult({
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
status: resolved.status,
|
||||||
|
startTime,
|
||||||
|
error: resolved.error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const outcome = await runDesignerWebPollLoop(providerConfig.baseUrl, resolved.config, fetchImpl, log);
|
const outcome = await runDesignerWebPollLoop(providerConfig.baseUrl, resolved.config, fetchImpl, log);
|
||||||
if (outcome.success) {
|
if (outcome.state === "ready") {
|
||||||
return saveImageSuccessResult({
|
return saveImageSuccessResult({
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
|
|||||||
@@ -16,6 +16,25 @@ import { resolveProxyForConnection } from "@/lib/db/settings";
|
|||||||
import { runWithProxyContext } from "../utils/proxyFetch.ts";
|
import { runWithProxyContext } from "../utils/proxyFetch.ts";
|
||||||
import * as log from "@/sse/utils/logger";
|
import * as log from "@/sse/utils/logger";
|
||||||
|
|
||||||
|
/** A document as the Cohere-compatible rerank API accepts it: a bare string or `{ text }`. */
|
||||||
|
type RerankDocument = string | { text?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller-side request fields the response adapters need to rebuild Cohere's
|
||||||
|
* `results[]`. Upstreams either omit the documents entirely (DeepInfra returns
|
||||||
|
* bare scores) or echo them in their own shape (Voyage returns plain strings),
|
||||||
|
* so document text is always synthesized from the caller's originals — and
|
||||||
|
* `top_n` / `return_documents` are honored here rather than upstream.
|
||||||
|
*
|
||||||
|
* Every field is optional: the parameter defaults to `{}` and the unit suites
|
||||||
|
* call it with subsets (e.g. `{ documents: ["a", "b"] }`).
|
||||||
|
*/
|
||||||
|
interface RerankResponseOptions {
|
||||||
|
documents?: RerankDocument[];
|
||||||
|
return_documents?: boolean;
|
||||||
|
top_n?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build authorization header for a rerank provider
|
* Build authorization header for a rerank provider
|
||||||
*/
|
*/
|
||||||
@@ -76,7 +95,11 @@ function buildAuthHeader(providerConfig, token) {
|
|||||||
/**
|
/**
|
||||||
* Transform response from provider-specific formats back to Cohere format
|
* Transform response from provider-specific formats back to Cohere format
|
||||||
*/
|
*/
|
||||||
/* @testonly */ export function transformResponseFromProvider(providerConfig, data, options = {}) {
|
/* @testonly */ export function transformResponseFromProvider(
|
||||||
|
providerConfig,
|
||||||
|
data,
|
||||||
|
options: RerankResponseOptions = {}
|
||||||
|
) {
|
||||||
if (providerConfig.format === "nvidia") {
|
if (providerConfig.format === "nvidia") {
|
||||||
return {
|
return {
|
||||||
id: data.id != null ? String(data.id) : `rerank-${Date.now()}`,
|
id: data.id != null ? String(data.id) : `rerank-${Date.now()}`,
|
||||||
|
|||||||
@@ -2709,63 +2709,63 @@ export async function handleComboChat({
|
|||||||
: "";
|
: "";
|
||||||
const msg = (lastError || "All combo models unavailable") + comboErrorSummary;
|
const msg = (lastError || "All combo models unavailable") + comboErrorSummary;
|
||||||
|
|
||||||
if (earliestRetryAfter && isRetryAfterEligibleStatus(status)) {
|
// Cooldown-aware retry: instead of crystallizing a transient failure, wait
|
||||||
// Cooldown-aware retry: instead of crystallizing the 429/503, wait out
|
// out a SHORT cooldown and re-run the whole set loop. Guarded by the helper
|
||||||
// a SHORT transient cooldown and re-run the whole set loop. Guarded by
|
// (quota_exhausted/auth/not-found excluded, ceiling, attempts, budget).
|
||||||
// the helper (quota_exhausted/auth/not-found excluded, ceiling,
|
// MAX_GLOBAL_ATTEMPTS still bounds total dispatches. Available to ALL combo
|
||||||
// attempts, budget). MAX_GLOBAL_ATTEMPTS still bounds total dispatches.
|
// strategies when enabled — entry is driven by earliestRetryAfter + the
|
||||||
// Available to ALL combo strategies (not just quota-share).
|
// real model-lockout reason, NOT by whichever target last overwrote
|
||||||
if (comboCooldownWaitEnabled && status === 429) {
|
// `status` (a later 403 must not skip the allow-list check for an earlier
|
||||||
// ONE decision path for EVERY strategy. The reason that drives the
|
// 429's retry-after hint). SECURITY (see comboCooldownRetry.ts header): the
|
||||||
// wait is always the target's REAL model-lockout reason, resolved
|
// allow-list is the PRIMARY barrier and `maxWaitMs` only the SECOND one.
|
||||||
// through the helper's allow-list — never a hardcoded literal.
|
// Hardcoding reason:"rate_limit" would drop the primary barrier and leave
|
||||||
//
|
// only the ceiling — which does NOT cover a quota_exhausted lock carrying a
|
||||||
// SECURITY (see comboCooldownRetry.ts header): the allow-list is the
|
// SHORT upstream retry-after. Model lockouts are recorded for all strategies,
|
||||||
// PRIMARY barrier and `maxWaitMs` only the SECOND one. Hardcoding
|
// so the real reason is always available.
|
||||||
// reason:"rate_limit" for non-quota-share strategies would drop the
|
if (comboCooldownWaitEnabled && earliestRetryAfter) {
|
||||||
// primary barrier and leave only the ceiling — which does NOT cover a
|
const decision: ResolveComboCooldownDecisionResult = resolveComboCooldownWaitDecision({
|
||||||
// quota_exhausted lock carrying a SHORT upstream retry-after (e.g.
|
targets: orderedTargets,
|
||||||
// 3s < maxWaitMs): the combo would wait, redispatch against a model
|
earliestRetryAfter,
|
||||||
// locked until midnight, and burn the attempt. Model lockouts are
|
attempt: comboCooldownAttempt,
|
||||||
// recorded for all strategies (recordModelLockoutFailure above is not
|
budgetLeftMs: comboCooldownBudgetLeftMs,
|
||||||
// gated on quota-share), so the real reason is always available.
|
settings: resilienceSettings.comboCooldownWait,
|
||||||
const decision: ResolveComboCooldownDecisionResult = resolveComboCooldownWaitDecision({
|
// Key each lookup on the TARGET's own model: quota-share combos are
|
||||||
targets: orderedTargets,
|
// single-model/multi-account (so this is identical to the previous
|
||||||
earliestRetryAfter,
|
// orderedTargets[0] behavior), but heterogeneous combos carry a
|
||||||
attempt: comboCooldownAttempt,
|
// different model per target.
|
||||||
budgetLeftMs: comboCooldownBudgetLeftMs,
|
lookupLock: (provider, connectionId, target) => {
|
||||||
settings: resilienceSettings.comboCooldownWait,
|
const rawModel = parseModel(target?.modelStr ?? "").model || "";
|
||||||
// Key each lookup on the TARGET's own model: quota-share combos are
|
if (!rawModel) return null;
|
||||||
// single-model/multi-account (so this is identical to the previous
|
return getModelLockoutInfo(provider, connectionId, rawModel);
|
||||||
// orderedTargets[0] behavior), but heterogeneous combos carry a
|
},
|
||||||
// different model per target.
|
computeWaitMs: (retryAfter) => computeClosestRetryAfter(retryAfter).waitMs,
|
||||||
lookupLock: (provider, connectionId, target) => {
|
});
|
||||||
const rawModel = parseModel(target?.modelStr ?? "").model || "";
|
|
||||||
if (!rawModel) return null;
|
|
||||||
return getModelLockoutInfo(provider, connectionId, rawModel);
|
|
||||||
},
|
|
||||||
computeWaitMs: (retryAfter) => computeClosestRetryAfter(retryAfter).waitMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (decision.wait) {
|
if (decision.wait) {
|
||||||
log.info(
|
log.info(
|
||||||
"COMBO",
|
"COMBO",
|
||||||
`${strategy} cooldown wait: ${msg} — waiting ${Math.ceil(
|
`${strategy} cooldown wait: ${msg} — waiting ${Math.ceil(
|
||||||
decision.waitMs / 1000
|
decision.waitMs / 1000
|
||||||
)}s (reason=${decision.reason ?? "?"}) then retrying (attempt ${
|
)}s (reason=${decision.reason ?? "?"}) then retrying (attempt ${
|
||||||
comboCooldownAttempt + 1
|
comboCooldownAttempt + 1
|
||||||
}/${resilienceSettings.comboCooldownWait.maxAttempts})`
|
}/${resilienceSettings.comboCooldownWait.maxAttempts})`
|
||||||
);
|
);
|
||||||
const completed = await waitForCooldownAwareRetry(decision.waitMs, signal);
|
const completed = await waitForCooldownAwareRetry(decision.waitMs, signal);
|
||||||
if (!completed) {
|
if (!completed) {
|
||||||
log.info("COMBO", `${strategy} cooldown wait aborted by client disconnect`);
|
log.info("COMBO", `${strategy} cooldown wait aborted by client disconnect`);
|
||||||
return errorResponse(499, "Request aborted");
|
return errorResponse(499, "Request aborted");
|
||||||
}
|
|
||||||
comboCooldownAttempt += 1;
|
|
||||||
comboCooldownBudgetLeftMs = Math.max(0, comboCooldownBudgetLeftMs - decision.waitMs);
|
|
||||||
return dispatchWithCooldownRetry();
|
|
||||||
}
|
}
|
||||||
|
comboCooldownAttempt += 1;
|
||||||
|
comboCooldownBudgetLeftMs = Math.max(0, comboCooldownBudgetLeftMs - decision.waitMs);
|
||||||
|
return dispatchWithCooldownRetry();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry-after decoration is separate from the wait decision above: only
|
||||||
|
// rate-limit-class final statuses may carry a `(reset after ...)` suffix
|
||||||
|
// (see unavailableRetryGate.ts — do not stitch a peer target's window onto
|
||||||
|
// a config-class status like 403/422).
|
||||||
|
if (earliestRetryAfter && isRetryAfterEligibleStatus(status)) {
|
||||||
const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter));
|
const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter));
|
||||||
log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`);
|
log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`);
|
||||||
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);
|
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);
|
||||||
|
|||||||
@@ -38,18 +38,22 @@ export const DEFAULT_COMBO_TARGET_TIMEOUT_MS = 120_000;
|
|||||||
export const COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS = 10_000;
|
export const COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS = 10_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a combo's cooldown-aware wait+retry (#7360) engages for this request: only
|
* Whether a combo's cooldown-aware wait+retry (#7360 / #7301) engages for this request.
|
||||||
* "quota-share" and "auto" strategies wait out a short transient cooldown instead of
|
* When the operator has the feature enabled, EVERY combo strategy waits out a short
|
||||||
* crystallizing a 429 into a combo-level failure, and only when the operator has the
|
* transient cooldown instead of crystallizing a 429 into a combo-level failure.
|
||||||
* feature enabled. Shared by combo.ts (to decide whether to wait) and comboSetup.ts (to
|
* Shared by combo.ts (to decide whether to wait) and comboSetup.ts (to size the
|
||||||
* size the per-target timeout floor so it doesn't cut the wait off early — see
|
* per-target timeout floor so it doesn't cut the wait off early — see
|
||||||
* resolveComboTargetTimeoutMsForCombo below).
|
* resolveComboTargetTimeoutMsForCombo below).
|
||||||
|
*
|
||||||
|
* `strategy` is retained in the signature for call-site clarity; eligibility is
|
||||||
|
* strategy-agnostic (the wait path in combo.ts already uses the real model-lockout
|
||||||
|
* reason for every strategy).
|
||||||
*/
|
*/
|
||||||
export function isComboCooldownWaitEligible(
|
export function isComboCooldownWaitEligible(
|
||||||
strategy: string,
|
_strategy: string,
|
||||||
comboCooldownWait: Pick<ComboCooldownWaitSettings, "enabled">
|
comboCooldownWait: Pick<ComboCooldownWaitSettings, "enabled">
|
||||||
): boolean {
|
): boolean {
|
||||||
return (strategy === "quota-share" || strategy === "auto") && comboCooldownWait.enabled;
|
return comboCooldownWait.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -702,10 +702,10 @@ function ComboCooldownWaitCard({
|
|||||||
setDraft(value);
|
setDraft(value);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const title = t("resilienceComboCooldownWaitTitle") || "Quota-share combo cooldown wait";
|
const title = t("resilienceComboCooldownWaitTitle") || "Combo cooldown wait";
|
||||||
const desc =
|
const desc =
|
||||||
t("resilienceComboCooldownWaitDesc") ||
|
t("resilienceComboCooldownWaitDesc") ||
|
||||||
"For quota-share combos only: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.";
|
"For all combo strategies: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
@@ -738,7 +738,7 @@ function ComboCooldownWaitCard({
|
|||||||
label={t("resilienceEnableServerWait") || "Enabled"}
|
label={t("resilienceEnableServerWait") || "Enabled"}
|
||||||
description={
|
description={
|
||||||
t("resilienceComboCooldownWaitToggleDesc") ||
|
t("resilienceComboCooldownWaitToggleDesc") ||
|
||||||
"Quota-share combos only; never waits on quota_exhausted."
|
"All combo strategies; never waits on quota_exhausted."
|
||||||
}
|
}
|
||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
|
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
|
||||||
|
|||||||
@@ -7458,9 +7458,9 @@
|
|||||||
"resilienceEnableServerWaitDesc": "When enabled, OmniRoute waits for the first cooldown to expire and retries automatically.",
|
"resilienceEnableServerWaitDesc": "When enabled, OmniRoute waits for the first cooldown to expire and retries automatically.",
|
||||||
"resilienceMaxAttempts": "Maximum attempts",
|
"resilienceMaxAttempts": "Maximum attempts",
|
||||||
"resilienceMaxWaitPerAttempt": "Maximum wait per attempt",
|
"resilienceMaxWaitPerAttempt": "Maximum wait per attempt",
|
||||||
"resilienceComboCooldownWaitTitle": "Quota-share combo cooldown wait",
|
"resilienceComboCooldownWaitTitle": "Combo cooldown wait",
|
||||||
"resilienceComboCooldownWaitDesc": "For quota-share combos only: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.",
|
"resilienceComboCooldownWaitDesc": "For all combo strategies: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.",
|
||||||
"resilienceComboCooldownWaitToggleDesc": "Quota-share combos only; never waits on quota_exhausted.",
|
"resilienceComboCooldownWaitToggleDesc": "All combo strategies; never waits on quota_exhausted.",
|
||||||
"resilienceComboCooldownMaxWaitMs": "Maximum wait per attempt",
|
"resilienceComboCooldownMaxWaitMs": "Maximum wait per attempt",
|
||||||
"resilienceComboCooldownBudgetMs": "Total wait budget",
|
"resilienceComboCooldownBudgetMs": "Total wait budget",
|
||||||
"resilienceQuotaShareConcurrencyTitle": "Quota-share per-connection concurrency",
|
"resilienceQuotaShareConcurrencyTitle": "Quota-share per-connection concurrency",
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
import { BaseGuardrail, type GuardrailContext, type GuardrailExecutionResult } from "./base";
|
import {
|
||||||
|
BaseGuardrail,
|
||||||
|
type GuardrailContext,
|
||||||
|
type GuardrailExecutionResult,
|
||||||
|
type GuardrailResult,
|
||||||
|
} from "./base";
|
||||||
import { PIIMaskerGuardrail } from "./piiMasker";
|
import { PIIMaskerGuardrail } from "./piiMasker";
|
||||||
import { PromptInjectionGuardrail } from "./promptInjection";
|
import { PromptInjectionGuardrail } from "./promptInjection";
|
||||||
import { VisionBridgeGuardrail } from "./visionBridge";
|
import { VisionBridgeGuardrail } from "./visionBridge";
|
||||||
import { CredentialMaskerGuardrail } from "./credentialMasker";
|
import { CredentialMaskerGuardrail } from "./credentialMasker";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `preCall`/`postCall` may legitimately return nothing — that is the documented
|
||||||
|
* "no change" signal, alongside `{}` and `{ block: false }`
|
||||||
|
* (`docs/security/GUARDRAILS.md`), and `CredentialMaskerGuardrail` still declares
|
||||||
|
* the `| void` arm.
|
||||||
|
*
|
||||||
|
* `void` is not a value the checker lets us inspect, so neither `result?.block`
|
||||||
|
* nor a truthiness test compiles against `GuardrailResult | void`. Funnel the
|
||||||
|
* return through `unknown` once, here, and hand the dispatch loops a plain
|
||||||
|
* optional. Runtime behavior is unchanged: a guardrail that returns nothing
|
||||||
|
* still yields `undefined` and is still treated as "passed".
|
||||||
|
*/
|
||||||
|
function asGuardrailResult(raw: unknown): GuardrailResult<unknown> | undefined {
|
||||||
|
return raw && typeof raw === "object" ? (raw as GuardrailResult<unknown>) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
type HeadersLike = Headers | Record<string, unknown> | null | undefined;
|
type HeadersLike = Headers | Record<string, unknown> | null | undefined;
|
||||||
|
|
||||||
function isHeaderStore(headers: HeadersLike): headers is Headers {
|
function isHeaderStore(headers: HeadersLike): headers is Headers {
|
||||||
@@ -128,7 +149,7 @@ export class GuardrailRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await guardrail.preCall(currentPayload, context);
|
const result = asGuardrailResult(await guardrail.preCall(currentPayload, context));
|
||||||
const modified = result?.modifiedPayload !== undefined;
|
const modified = result?.modifiedPayload !== undefined;
|
||||||
const meta = result?.meta || null;
|
const meta = result?.meta || null;
|
||||||
|
|
||||||
@@ -201,7 +222,7 @@ export class GuardrailRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await guardrail.postCall(currentResponse, context);
|
const result = asGuardrailResult(await guardrail.postCall(currentResponse, context));
|
||||||
const modified = result?.modifiedResponse !== undefined;
|
const modified = result?.modifiedResponse !== undefined;
|
||||||
const meta = result?.meta || null;
|
const meta = result?.meta || null;
|
||||||
|
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ export const DEFAULT_RESILIENCE_SETTINGS: ResilienceSettings = {
|
|||||||
},
|
},
|
||||||
// Wait at most 90s for a single short transient cooldown (covers Gemini-class
|
// Wait at most 90s for a single short transient cooldown (covers Gemini-class
|
||||||
// TPM/RPM windows, which report ~60s retry-after live — #7360), at most 5
|
// TPM/RPM windows, which report ~60s retry-after live — #7360), at most 5
|
||||||
// redispatch cycles, never more than 300s (5 min) total. Active for
|
// redispatch cycles, never more than 300s (5 min) total. Active for every
|
||||||
// quota-share and auto combos, and only for transient (non quota_exhausted)
|
// combo strategy when enabled, and only for transient (non quota_exhausted)
|
||||||
// reasons.
|
// reasons.
|
||||||
comboCooldownWait: {
|
comboCooldownWait: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -65,12 +65,12 @@ export interface WaitForCooldownSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quota-share combo cooldown-aware retry (Variante A). A quota-share (`qtSd/…`)
|
* Combo cooldown-aware retry. When enabled, any combo strategy that would
|
||||||
* combo that would crystallize a 429 `model_cooldown` for a SHORT transient
|
* crystallize a 429 `model_cooldown` for a SHORT transient cooldown waits it
|
||||||
* cooldown waits it out and re-dispatches instead. Guards (gating + the
|
* out and re-dispatches instead. Guards (gating + the `quota_exhausted`/auth/
|
||||||
* `quota_exhausted`/auth/not-found exclusions) live in
|
* not-found exclusions) live in open-sse/services/combo/comboCooldownRetry.ts;
|
||||||
* open-sse/services/combo/comboCooldownRetry.ts; `maxWaitMs`/`maxAttempts`/
|
* `maxWaitMs`/`maxAttempts`/`budgetMs` bound a single wait, the retry cycles,
|
||||||
* `budgetMs` bound a single wait, the retry cycles, and the total wait time.
|
* and the total wait time.
|
||||||
*/
|
*/
|
||||||
export interface ComboCooldownWaitSettings {
|
export interface ComboCooldownWaitSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -293,6 +293,34 @@ test("resolveAuggieModel resolves every pre-v0.32.0 alias to an allowlisted v0.3
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The failure arm is read through an `isAuggieModelFailure()` type predicate — this
|
||||||
|
// workspace compiles with `strictNullChecks: false`, where `!result.ok` narrows the
|
||||||
|
// success branch but not the failure one, so `.error` was unreachable to the checker.
|
||||||
|
// The predicate is a one-liner whose control flow inverts on a stray `!`, so pin both
|
||||||
|
// arms: the failure arm must carry a usable message, the success arm must not claim one.
|
||||||
|
test("resolveAuggieModel's failure arm carries a readable error message", () => {
|
||||||
|
const result = resolveAuggieModel("totally-not-a-real-model");
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (!result.ok) {
|
||||||
|
assert.equal(typeof result.error, "string");
|
||||||
|
assert.ok(result.error.length > 0, "the rejection must explain itself");
|
||||||
|
assert.match(result.error, /Unknown Auggie model/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveAuggieModel's success arm resolves a model and reports no error", () => {
|
||||||
|
const result = resolveAuggieModel("haiku4.5");
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
if (result.ok) {
|
||||||
|
assert.equal(result.model, "haiku4.5");
|
||||||
|
assert.equal(
|
||||||
|
(result as unknown as { error?: unknown }).error,
|
||||||
|
undefined,
|
||||||
|
"the success arm must not carry a failure message"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── execute(): model allowlist (argument-injection defense) ──────────────
|
// ─── execute(): model allowlist (argument-injection defense) ──────────────
|
||||||
|
|
||||||
test("execute() rejects a model not in the registry allowlist and never spawns", async () => {
|
test("execute() rejects a model not in the registry allowlist and never spawns", async () => {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const {
|
|||||||
const { createComboSchema, updateComboDefaultsSchema } =
|
const { createComboSchema, updateComboDefaultsSchema } =
|
||||||
await import("../../src/shared/validation/schemas.ts");
|
await import("../../src/shared/validation/schemas.ts");
|
||||||
const { MAX_TIMER_TIMEOUT_MS } = await import("../../src/shared/utils/runtimeTimeouts.ts");
|
const { MAX_TIMER_TIMEOUT_MS } = await import("../../src/shared/utils/runtimeTimeouts.ts");
|
||||||
|
const { ROUTING_STRATEGY_VALUES, INTERNAL_ROUTING_STRATEGY_VALUES } =
|
||||||
|
await import("../../src/shared/constants/routingStrategies.ts");
|
||||||
|
const ALL_COMBO_STRATEGIES = [...ROUTING_STRATEGY_VALUES, ...INTERNAL_ROUTING_STRATEGY_VALUES];
|
||||||
|
|
||||||
test("getDefaultComboConfig returns a fresh copy of the defaults", () => {
|
test("getDefaultComboConfig returns a fresh copy of the defaults", () => {
|
||||||
const first = getDefaultComboConfig();
|
const first = getDefaultComboConfig();
|
||||||
@@ -321,40 +324,32 @@ test("resolveComboTargetTimeoutMs falls back to the saner combo default when uns
|
|||||||
assert.equal(resolveComboTargetTimeoutMs({}, 0, 120000), 0);
|
assert.equal(resolveComboTargetTimeoutMs({}, 0, 120000), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #7360 follow-up: a "default" auto-strategy combo hitting Gemini TPM/RPM on both
|
// #7360 / #7301: any strategy with comboCooldownWait enabled waits out cooldowns for up
|
||||||
// targets waits out cooldowns for up to comboCooldownWait.budgetMs (default 130s), but
|
// to comboCooldownWait.budgetMs, so the per-target timeout floor must cover that budget
|
||||||
// DEFAULT_COMBO_TARGET_TIMEOUT_MS (120s) is shorter — the per-target timeout was cutting
|
// (DEFAULT_COMBO_TARGET_TIMEOUT_MS alone would cut a long wait short into a 524).
|
||||||
// the wait off early and returning a synthetic 524 instead of letting the wait finish.
|
test("isComboCooldownWaitEligible engages for every strategy when the feature is enabled", () => {
|
||||||
test("isComboCooldownWaitEligible only engages for quota-share/auto with the feature enabled", () => {
|
for (const strategy of ALL_COMBO_STRATEGIES) {
|
||||||
assert.equal(isComboCooldownWaitEligible("auto", { enabled: true }), true);
|
assert.equal(isComboCooldownWaitEligible(strategy, { enabled: true }), true);
|
||||||
assert.equal(isComboCooldownWaitEligible("quota-share", { enabled: true }), true);
|
assert.equal(isComboCooldownWaitEligible(strategy, { enabled: false }), false);
|
||||||
assert.equal(isComboCooldownWaitEligible("auto", { enabled: false }), false);
|
}
|
||||||
assert.equal(isComboCooldownWaitEligible("fill-first", { enabled: true }), false);
|
|
||||||
assert.equal(isComboCooldownWaitEligible("priority", { enabled: true }), false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolveComboTargetTimeoutMsForCombo raises the floor to cover the cooldown-wait budget for eligible strategies", () => {
|
test("resolveComboTargetTimeoutMsForCombo raises the floor to cover the cooldown-wait budget when enabled", () => {
|
||||||
const comboCooldownWait = { enabled: true, budgetMs: 130000 };
|
const comboCooldownWait = { enabled: true, budgetMs: 130000 };
|
||||||
|
const floor = 130000 + COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS;
|
||||||
|
const disabled = { enabled: false, budgetMs: 130000 };
|
||||||
|
|
||||||
// Wait-eligible strategy: floor is budget + buffer (150s), not the 120s default.
|
// Feature on: floor is budget + buffer for every strategy; off: 120s default.
|
||||||
assert.equal(
|
for (const strategy of ALL_COMBO_STRATEGIES) {
|
||||||
resolveComboTargetTimeoutMsForCombo({}, 600000, "auto", comboCooldownWait),
|
assert.equal(
|
||||||
130000 + COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS
|
resolveComboTargetTimeoutMsForCombo({}, 600000, strategy, comboCooldownWait),
|
||||||
);
|
floor
|
||||||
assert.equal(
|
);
|
||||||
resolveComboTargetTimeoutMsForCombo({}, 600000, "quota-share", comboCooldownWait),
|
assert.equal(
|
||||||
130000 + COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS
|
resolveComboTargetTimeoutMsForCombo({}, 600000, strategy, disabled),
|
||||||
);
|
DEFAULT_COMBO_TARGET_TIMEOUT_MS
|
||||||
|
);
|
||||||
// Not wait-eligible (wrong strategy, or feature disabled): unchanged 120s default.
|
}
|
||||||
assert.equal(
|
|
||||||
resolveComboTargetTimeoutMsForCombo({}, 600000, "fill-first", comboCooldownWait),
|
|
||||||
DEFAULT_COMBO_TARGET_TIMEOUT_MS
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
resolveComboTargetTimeoutMsForCombo({}, 600000, "auto", { enabled: false, budgetMs: 130000 }),
|
|
||||||
DEFAULT_COMBO_TARGET_TIMEOUT_MS
|
|
||||||
);
|
|
||||||
|
|
||||||
// A small budget below the 120s default never lowers the floor.
|
// A small budget below the 120s default never lowers the floor.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -364,12 +359,7 @@ test("resolveComboTargetTimeoutMsForCombo raises the floor to cover the cooldown
|
|||||||
|
|
||||||
// Explicit per-combo targetTimeoutMs still wins over the derived floor.
|
// Explicit per-combo targetTimeoutMs still wins over the derived floor.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveComboTargetTimeoutMsForCombo(
|
resolveComboTargetTimeoutMsForCombo({ targetTimeoutMs: 45000 }, 600000, "auto", comboCooldownWait),
|
||||||
{ targetTimeoutMs: 45000 },
|
|
||||||
600000,
|
|
||||||
"auto",
|
|
||||||
comboCooldownWait
|
|
||||||
),
|
|
||||||
45000
|
45000
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
* 2. A 403 (quota_exhausted, locked until midnight) → NO wait, the 403/429 is
|
* 2. A 403 (quota_exhausted, locked until midnight) → NO wait, the 403/429 is
|
||||||
* propagated immediately (the helper's critical exclusion).
|
* propagated immediately (the helper's critical exclusion).
|
||||||
* 3. Client abort DURING the wait → 499 "Request aborted".
|
* 3. Client abort DURING the wait → 499 "Request aborted".
|
||||||
* 4. strategy="priority" (non quota-share) → unchanged: the 429 is propagated
|
* 4. strategy="priority" (and every other strategy) also waits out a SHORT
|
||||||
* immediately with NO wait.
|
* transient 429 when comboCooldownWait is enabled — same decision helper.
|
||||||
* 5. comboCooldownWait disabled in settings → unchanged: 429 propagated, no wait.
|
* 5. comboCooldownWait disabled in settings → unchanged: 429 propagated, no wait.
|
||||||
*
|
*
|
||||||
* The waits use a real (short) cooldown so the real setTimeout in
|
* The waits use a real (short) cooldown so the real setTimeout in
|
||||||
|
|||||||
67
tests/unit/designer-web-empty-response-502.test.ts
Normal file
67
tests/unit/designer-web-empty-response-502.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
const { handleDesignerWebImageGeneration } = await import(
|
||||||
|
"../../open-sse/handlers/imageGeneration/providers/designerWeb.ts"
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `stepDesignerWebPoll` classifies an unrecognized upstream body as a terminal
|
||||||
|
* 502 — the "empty" arm of the step union, alongside pending / ready / upstream
|
||||||
|
* failure.
|
||||||
|
*
|
||||||
|
* `microsoft-designer-web-6672.test.ts` covers every other arm end-to-end
|
||||||
|
* (400 no prompt, 401 no token, immediate ready, poll-then-ready, non-OK
|
||||||
|
* upstream, 504 timeout) but tests "empty" only at the parser level
|
||||||
|
* (`parseDesignerWebResponse: unrecognized shape is 'empty'`) — it never drives
|
||||||
|
* the handler with one, so the 502 the handler synthesizes from it was
|
||||||
|
* unasserted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function jsonResponse(status: number, body: unknown) {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
text: async () => JSON.stringify(body),
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = {
|
||||||
|
model: "dall-e-3",
|
||||||
|
provider: "microsoft-designer-web",
|
||||||
|
providerConfig: { baseUrl: "https://designerapp.officeapps.live.com/designerapp/DallE.ashx" },
|
||||||
|
credentials: { apiKey: "tok-abc" },
|
||||||
|
};
|
||||||
|
|
||||||
|
test("a 200 with an unrecognized body is a terminal 502, not a retry", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const result = await handleDesignerWebImageGeneration({
|
||||||
|
...BASE,
|
||||||
|
body: { prompt: "a cat astronaut", timeout_ms: 5_000, poll_interval_ms: 1 },
|
||||||
|
fetchImpl: async () => {
|
||||||
|
calls += 1;
|
||||||
|
return jsonResponse(200, { unexpected: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.success, false);
|
||||||
|
assert.equal(result.status, 502, "an unparseable success body is a bad-gateway, not a timeout");
|
||||||
|
assert.match(String(result.error), /did not contain image data or polling metadata/);
|
||||||
|
assert.equal(calls, 1, "the empty classification is terminal — it must not keep polling");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 200 with neither images nor polling metadata does not fall through to 504", async () => {
|
||||||
|
// The distinction matters: 502 says "the upstream answered with something we
|
||||||
|
// cannot use", 504 says "the upstream never finished". A timeout_ms generous
|
||||||
|
// enough to allow several polls proves the 502 came from classification, not
|
||||||
|
// from the deadline.
|
||||||
|
const result = await handleDesignerWebImageGeneration({
|
||||||
|
...BASE,
|
||||||
|
body: { prompt: "a cat astronaut", timeout_ms: 10_000, poll_interval_ms: 1 },
|
||||||
|
fetchImpl: async () => jsonResponse(200, { polling_response: {} }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 502);
|
||||||
|
assert.notEqual(result.status, 504);
|
||||||
|
});
|
||||||
121
tests/unit/guardrails-void-no-change-contract.test.ts
Normal file
121
tests/unit/guardrails-void-no-change-contract.test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { BaseGuardrail, GuardrailRegistry } from "../../src/lib/guardrails/index.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `docs/security/GUARDRAILS.md`: "A guardrail signals 'no change' by returning
|
||||||
|
* either `void`, `{}`, or ...".
|
||||||
|
*
|
||||||
|
* The `void` arm of that contract had no test — `guardrails-registry.test.ts`
|
||||||
|
* covers guardrails that return a result and one that throws, but never one
|
||||||
|
* that simply returns nothing. These tests pin it, because the registry's
|
||||||
|
* dispatch reads the return value's properties and must keep treating "nothing"
|
||||||
|
* as a clean pass rather than a block or a modification.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class SilentGuardrail extends BaseGuardrail {
|
||||||
|
constructor(name = "silent") {
|
||||||
|
super(name, { priority: 10 });
|
||||||
|
}
|
||||||
|
|
||||||
|
override async preCall() {
|
||||||
|
// no return — the documented "no change" signal
|
||||||
|
}
|
||||||
|
|
||||||
|
override async postCall() {
|
||||||
|
// no return — the documented "no change" signal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmptyResultGuardrail extends BaseGuardrail {
|
||||||
|
constructor(name = "empty") {
|
||||||
|
super(name, { priority: 10 });
|
||||||
|
}
|
||||||
|
|
||||||
|
override async preCall() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
override async postCall() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a preCall returning nothing passes the payload through untouched", async () => {
|
||||||
|
const registry = new GuardrailRegistry();
|
||||||
|
registry.register(new SilentGuardrail());
|
||||||
|
|
||||||
|
const payload = { messages: [{ role: "user", content: "hello" }] };
|
||||||
|
const result = await registry.runPreCallHooks(payload, {});
|
||||||
|
|
||||||
|
assert.equal(result.blocked, false, "returning nothing must not block");
|
||||||
|
assert.deepEqual(result.payload, payload, "payload must be forwarded unchanged");
|
||||||
|
|
||||||
|
const execution = result.results[0];
|
||||||
|
assert.equal(execution?.guardrail, "silent");
|
||||||
|
assert.equal(execution?.blocked, false);
|
||||||
|
assert.equal(execution?.modified, false, "nothing returned means nothing modified");
|
||||||
|
assert.equal(execution?.skipped, false, "the guardrail ran — it is not 'skipped'");
|
||||||
|
assert.equal(execution?.error, undefined, "a silent pass is not an error");
|
||||||
|
assert.equal(execution?.stage, "pre");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a postCall returning nothing passes the response through untouched", async () => {
|
||||||
|
const registry = new GuardrailRegistry();
|
||||||
|
registry.register(new SilentGuardrail());
|
||||||
|
|
||||||
|
const response = { choices: [{ message: { content: "hi" } }] };
|
||||||
|
const result = await registry.runPostCallHooks(response, {});
|
||||||
|
|
||||||
|
assert.equal(result.blocked, false);
|
||||||
|
assert.deepEqual(result.response, response);
|
||||||
|
|
||||||
|
const execution = result.results[0];
|
||||||
|
assert.equal(execution?.modified, false);
|
||||||
|
assert.equal(execution?.skipped, false);
|
||||||
|
assert.equal(execution?.error, undefined);
|
||||||
|
assert.equal(execution?.stage, "post");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returning an empty object behaves identically to returning nothing", async () => {
|
||||||
|
const payload = { messages: [{ role: "user", content: "hello" }] };
|
||||||
|
|
||||||
|
const silent = new GuardrailRegistry();
|
||||||
|
silent.register(new SilentGuardrail("g"));
|
||||||
|
const silentResult = await silent.runPreCallHooks(payload, {});
|
||||||
|
|
||||||
|
const empty = new GuardrailRegistry();
|
||||||
|
empty.register(new EmptyResultGuardrail("g"));
|
||||||
|
const emptyResult = await empty.runPreCallHooks(payload, {});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
silentResult.results,
|
||||||
|
emptyResult.results,
|
||||||
|
"the two documented no-change signals must produce the same execution record"
|
||||||
|
);
|
||||||
|
assert.deepEqual(silentResult.payload, emptyResult.payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a silent guardrail does not stop later guardrails from modifying", async () => {
|
||||||
|
class AppendGuardrail extends BaseGuardrail {
|
||||||
|
constructor() {
|
||||||
|
super("append", { priority: 20 });
|
||||||
|
}
|
||||||
|
|
||||||
|
override async preCall(payload: unknown) {
|
||||||
|
return { modifiedPayload: { ...(payload as Record<string, unknown>), seen: true } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = new GuardrailRegistry();
|
||||||
|
registry.register(new SilentGuardrail());
|
||||||
|
registry.register(new AppendGuardrail());
|
||||||
|
|
||||||
|
const result = await registry.runPreCallHooks({ messages: [] }, {});
|
||||||
|
|
||||||
|
assert.equal((result.payload as Record<string, unknown>).seen, true);
|
||||||
|
assert.equal(result.results.length, 2);
|
||||||
|
assert.equal(result.results[0]?.modified, false, "silent guardrail ran first, modified nothing");
|
||||||
|
assert.equal(result.results[1]?.modified, true, "the later guardrail still applied");
|
||||||
|
});
|
||||||
@@ -30,7 +30,7 @@ const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9
|
|||||||
type LMArenaExecutorTestAccess = {
|
type LMArenaExecutorTestAccess = {
|
||||||
provider: string;
|
provider: string;
|
||||||
buildUrl: (model: string, credentials: unknown) => string;
|
buildUrl: (model: string, credentials: unknown) => string;
|
||||||
buildHeaders: (model: string, credentials: unknown, body: unknown) => Record<string, string>;
|
buildRequestHeaders: (model: string, credentials: unknown, body: unknown) => Record<string, string>;
|
||||||
transformRequest: (
|
transformRequest: (
|
||||||
body: unknown,
|
body: unknown,
|
||||||
model: string,
|
model: string,
|
||||||
@@ -133,7 +133,7 @@ describe("LMArena Executor", () => {
|
|||||||
|
|
||||||
it("builds headers with cookie", () => {
|
it("builds headers with cookie", () => {
|
||||||
const executor = new LMArenaExecutor();
|
const executor = new LMArenaExecutor();
|
||||||
const headers = access(executor).buildHeaders("gpt-4", { cookie: "session=abc123" }, {});
|
const headers = access(executor).buildRequestHeaders("gpt-4", { cookie: "session=abc123" }, {});
|
||||||
assert.ok(headers.Cookie, "Should have Cookie header");
|
assert.ok(headers.Cookie, "Should have Cookie header");
|
||||||
assert.equal(headers.Cookie, "session=abc123");
|
assert.equal(headers.Cookie, "session=abc123");
|
||||||
assert.equal(headers["Content-Type"], "application/json");
|
assert.equal(headers["Content-Type"], "application/json");
|
||||||
@@ -142,7 +142,7 @@ describe("LMArena Executor", () => {
|
|||||||
|
|
||||||
it("builds headers without cookie when not provided", () => {
|
it("builds headers without cookie when not provided", () => {
|
||||||
const executor = new LMArenaExecutor();
|
const executor = new LMArenaExecutor();
|
||||||
const headers = access(executor).buildHeaders("gpt-4", {}, {});
|
const headers = access(executor).buildRequestHeaders("gpt-4", {}, {});
|
||||||
assert.ok(!headers.Cookie, "Should not have Cookie header when no cookie provided");
|
assert.ok(!headers.Cookie, "Should not have Cookie header when no cookie provided");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,19 +151,19 @@ describe("LMArena Executor", () => {
|
|||||||
const ex = access(executor);
|
const ex = access(executor);
|
||||||
|
|
||||||
// Direct cookie field
|
// Direct cookie field
|
||||||
let headers = ex.buildHeaders("gpt-4", { cookie: "session=abc" }, {});
|
let headers = ex.buildRequestHeaders("gpt-4", { cookie: "session=abc" }, {});
|
||||||
assert.equal(headers.Cookie, "session=abc");
|
assert.equal(headers.Cookie, "session=abc");
|
||||||
|
|
||||||
// apiKey field (dashboard form)
|
// apiKey field (dashboard form)
|
||||||
headers = ex.buildHeaders("gpt-4", { apiKey: "session=def" }, {});
|
headers = ex.buildRequestHeaders("gpt-4", { apiKey: "session=def" }, {});
|
||||||
assert.equal(headers.Cookie, "session=def");
|
assert.equal(headers.Cookie, "session=def");
|
||||||
|
|
||||||
// providerSpecificData.cookie
|
// providerSpecificData.cookie
|
||||||
headers = ex.buildHeaders("gpt-4", { providerSpecificData: { cookie: "session=ghi" } }, {});
|
headers = ex.buildRequestHeaders("gpt-4", { providerSpecificData: { cookie: "session=ghi" } }, {});
|
||||||
assert.equal(headers.Cookie, "session=ghi");
|
assert.equal(headers.Cookie, "session=ghi");
|
||||||
|
|
||||||
// Priority: direct > apiKey > providerSpecificData
|
// Priority: direct > apiKey > providerSpecificData
|
||||||
headers = ex.buildHeaders("gpt-4", { cookie: "session=abc", apiKey: "session=def" }, {});
|
headers = ex.buildRequestHeaders("gpt-4", { cookie: "session=abc", apiKey: "session=def" }, {});
|
||||||
assert.equal(headers.Cookie, "session=abc");
|
assert.equal(headers.Cookie, "session=abc");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { getWebSessionCredentialRequirement } from "../../src/shared/providers/w
|
|||||||
|
|
||||||
function cookieHeaderFor(credentials: unknown): string | undefined {
|
function cookieHeaderFor(credentials: unknown): string | undefined {
|
||||||
const executor = new LMArenaExecutor();
|
const executor = new LMArenaExecutor();
|
||||||
const headers = (executor as any).buildHeaders("gpt-4", credentials, {});
|
const headers = (executor as any).buildRequestHeaders("gpt-4", credentials, {});
|
||||||
return headers.Cookie;
|
return headers.Cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
62
tests/unit/multipart-body-arraybuffer-backing.test.ts
Normal file
62
tests/unit/multipart-body-arraybuffer-backing.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
const { buildMultipartBody } = await import("../../open-sse/handlers/audioTranscription.ts");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `buildMultipartBody` is handed straight to `fetch` as the request body by six
|
||||||
|
* call sites across `audioTranscription.ts` and `audioTranslation.ts`, so its
|
||||||
|
* result must satisfy `BodyInit` — which requires an `ArrayBuffer`-backed view,
|
||||||
|
* not merely "some Uint8Array". A `SharedArrayBuffer`-backed view, or a slice
|
||||||
|
* into a shared pool, is not a valid request body.
|
||||||
|
*
|
||||||
|
* The four existing `buildMultipartBody` tests in
|
||||||
|
* `audio-transcription-handler.test.ts` decode the payload and assert its
|
||||||
|
* *contents* (boundary, filename sanitization, MIME fallback). None of them
|
||||||
|
* assert the backing, which is the property the declared
|
||||||
|
* `Uint8Array<ArrayBuffer>` return type exists to guarantee — and the one a
|
||||||
|
* plausible "optimization" to a pooled `Buffer.concat` would break.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function audioFile(name = "clip.wav", type = "audio/wav") {
|
||||||
|
return Object.assign(new Blob([new Uint8Array([1, 2, 3, 4])], { type }), { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the assembled body is backed by a plain ArrayBuffer, never a shared one", async () => {
|
||||||
|
const { body } = await buildMultipartBody(audioFile(), { model: "whisper-1" });
|
||||||
|
|
||||||
|
assert.ok(body instanceof Uint8Array);
|
||||||
|
assert.ok(
|
||||||
|
body.buffer instanceof ArrayBuffer,
|
||||||
|
"a SharedArrayBuffer-backed view is not accepted as a fetch BodyInit"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the assembled body owns its whole buffer — not a view into a shared pool", async () => {
|
||||||
|
const { body } = await buildMultipartBody(audioFile(), { model: "whisper-1" });
|
||||||
|
|
||||||
|
// `new Uint8Array(totalLength)` allocates exclusively. A pooled allocation
|
||||||
|
// (e.g. switching to Buffer.concat) would leave a non-zero byteOffset and a
|
||||||
|
// buffer larger than the payload, so the bytes handed to fetch would no
|
||||||
|
// longer be the whole buffer.
|
||||||
|
assert.equal(body.byteOffset, 0, "body must start at offset 0 of its own buffer");
|
||||||
|
assert.equal(
|
||||||
|
body.byteLength,
|
||||||
|
body.buffer.byteLength,
|
||||||
|
"body must span its entire buffer, with no pooled remainder"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the backing holds for a larger payload than a single pool slab", async () => {
|
||||||
|
// 64 KiB — comfortably past Node's 8 KiB Buffer pool threshold, so a pooled
|
||||||
|
// implementation would behave differently here than for the small case above.
|
||||||
|
const big = Object.assign(new Blob([new Uint8Array(64 * 1024)], { type: "audio/wav" }), {
|
||||||
|
name: "big.wav",
|
||||||
|
});
|
||||||
|
const { body } = await buildMultipartBody(big, { model: "whisper-1" });
|
||||||
|
|
||||||
|
assert.ok(body.buffer instanceof ArrayBuffer);
|
||||||
|
assert.equal(body.byteOffset, 0);
|
||||||
|
assert.equal(body.byteLength, body.buffer.byteLength);
|
||||||
|
assert.ok(body.byteLength > 64 * 1024, "payload carries the file plus the multipart envelope");
|
||||||
|
});
|
||||||
84
tests/unit/rerank-object-documents-response-path.test.ts
Normal file
84
tests/unit/rerank-object-documents-response-path.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
const { getRerankProvider } = await import("../../open-sse/config/rerankRegistry.ts");
|
||||||
|
const { transformResponseFromProvider } = await import("../../open-sse/handlers/rerank.ts");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Cohere-compatible rerank API accepts each document as either a bare string
|
||||||
|
* or `{ text }`, and the DeepInfra/Voyage **response** adapters synthesize
|
||||||
|
* `document.text` from the caller's originals (upstreams either omit documents
|
||||||
|
* or echo them in their own shape — #7809/#7811).
|
||||||
|
*
|
||||||
|
* The `{ text }` form was only ever exercised through the *request* adapter
|
||||||
|
* (`#5332 deepinfra request adapter`, `#7809 voyage request adapter handles
|
||||||
|
* {text} object documents`). Every response-adapter test passed plain strings,
|
||||||
|
* so the `typeof doc === "string" ? doc : doc?.text` branch on the response side
|
||||||
|
* was uncovered — which is also the branch that gives
|
||||||
|
* `RerankResponseOptions.documents` its union type.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test("deepinfra response adapter resolves document text from {text} objects", () => {
|
||||||
|
const cfg = getRerankProvider("deepinfra");
|
||||||
|
const out = transformResponseFromProvider(
|
||||||
|
cfg,
|
||||||
|
{ scores: [0.1, 0.9] },
|
||||||
|
{ documents: [{ text: "Washington DC" }, { text: "Paris" }], return_documents: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(out.results[0].index, 1, "0.9 ranks first");
|
||||||
|
assert.equal(out.results[0].document.text, "Paris");
|
||||||
|
assert.equal(out.results[1].document.text, "Washington DC");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deepinfra response adapter handles a mixed string/{text} document array", () => {
|
||||||
|
const cfg = getRerankProvider("deepinfra");
|
||||||
|
const out = transformResponseFromProvider(
|
||||||
|
cfg,
|
||||||
|
{ scores: [0.2, 0.8] },
|
||||||
|
{ documents: ["plain string", { text: "object form" }], return_documents: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(out.results[0].document.text, "object form");
|
||||||
|
assert.equal(out.results[1].document.text, "plain string");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deepinfra response adapter falls back to empty text for a {text}-less object", () => {
|
||||||
|
const cfg = getRerankProvider("deepinfra");
|
||||||
|
const out = transformResponseFromProvider(
|
||||||
|
cfg,
|
||||||
|
{ scores: [0.5] },
|
||||||
|
{ documents: [{} as { text?: string }], return_documents: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(out.results[0].document.text, "", "a document with no text must not yield undefined");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("voyage response adapter resolves document text from {text} objects", () => {
|
||||||
|
const cfg = getRerankProvider("voyage-ai");
|
||||||
|
const out = transformResponseFromProvider(
|
||||||
|
cfg,
|
||||||
|
{ data: [{ index: 0, relevance_score: 0.4 }, { index: 1, relevance_score: 0.7 }] },
|
||||||
|
{ documents: [{ text: "alpha" }, { text: "beta" }], return_documents: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(out.results[0].index, 1, "0.7 ranks first");
|
||||||
|
assert.equal(out.results[0].document.text, "beta");
|
||||||
|
assert.equal(out.results[1].document.text, "alpha");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("voyage response adapter remaps indices past an empty {text} document", () => {
|
||||||
|
// The request adapter drops exact-empty documents before sending, so the
|
||||||
|
// upstream indices refer to the filtered array. The response adapter rebuilds
|
||||||
|
// that filter to map back — this must work for the object form too, not just
|
||||||
|
// strings.
|
||||||
|
const cfg = getRerankProvider("voyage-ai");
|
||||||
|
const out = transformResponseFromProvider(
|
||||||
|
cfg,
|
||||||
|
{ data: [{ index: 1, relevance_score: 0.9 }] },
|
||||||
|
{ documents: [{ text: "kept" }, { text: "" }, { text: "also kept" }], return_documents: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(out.results[0].index, 2, "filtered index 1 maps back to original index 2");
|
||||||
|
assert.equal(out.results[0].document.text, "also kept");
|
||||||
|
});
|
||||||
@@ -20,9 +20,8 @@
|
|||||||
* The non-quota-share (priority) scenario was UPDATED for the "universal
|
* The non-quota-share (priority) scenario was UPDATED for the "universal
|
||||||
* cooldown-aware retry" change: comboCooldownWait is no longer gated on
|
* cooldown-aware retry" change: comboCooldownWait is no longer gated on
|
||||||
* `strategy === "quota-share"` — every combo strategy now waits out a SHORT
|
* `strategy === "quota-share"` — every combo strategy now waits out a SHORT
|
||||||
* transient 429 and re-dispatches (using a "rate_limit" reason and the
|
* transient 429 and re-dispatches via the same resolveComboCooldownWaitDecision
|
||||||
* earliest retry-after hint directly, since non quota-share strategies have no
|
* path (real model-lockout reason + allow-list). It used to assert the
|
||||||
* per-connection model-lockout tracking to consult). It used to assert the
|
|
||||||
* OPPOSITE (immediate propagation, no wait) — that assertion is now testing
|
* OPPOSITE (immediate propagation, no wait) — that assertion is now testing
|
||||||
* dead behavior, so it was rewritten to assert the new intended behavior
|
* dead behavior, so it was rewritten to assert the new intended behavior
|
||||||
* instead of being deleted or weakened.
|
* instead of being deleted or weakened.
|
||||||
@@ -147,11 +146,8 @@ test("non quota-share (priority): short 429 cooldown → waits and re-dispatches
|
|||||||
const handleSingleModel = async () => {
|
const handleSingleModel = async () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
// 1st dispatch: transient 429 with a short retry-after hint. 2nd dispatch
|
// 1st dispatch: transient 429 with a short retry-after hint. 2nd dispatch
|
||||||
// (after the universal cooldown wait): success. Priority combos have no
|
// (after the universal cooldown wait): success. Exercises the shared
|
||||||
// per-connection model-lockout tracking, so this exercises the
|
// resolveComboCooldownWaitDecision path (real model-lockout reason).
|
||||||
// `shouldWaitForComboCooldown({ reason: "rate_limit", ... })` path fed
|
|
||||||
// directly by the earliest retry-after hint (not resolveComboCooldownWaitDecision's
|
|
||||||
// per-target lock lookup, which stays quota-share-only).
|
|
||||||
return calls === 1 ? rateLimitResponse(429) : okResponse();
|
return calls === 1 ? rateLimitResponse(429) : okResponse();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,18 +178,18 @@ test("non quota-share (priority): a quota_exhausted lock drives the decision wit
|
|||||||
//
|
//
|
||||||
// Barrier 1 = the reason allow-list. Barrier 2 = the maxWaitMs ceiling.
|
// Barrier 1 = the reason allow-list. Barrier 2 = the maxWaitMs ceiling.
|
||||||
// This scenario is engineered so ONLY barrier 1 can stop the wait:
|
// This scenario is engineered so ONLY barrier 1 can stop the wait:
|
||||||
// - modelLockout.errorCodes is [403] ONLY, so model-a's 429 crystallizes
|
// - modelLockout.errorCodes is [403] ONLY, so model-a's 429 contributes a
|
||||||
// status 429 (the sole status that opens the cooldown-wait branch) WITHOUT
|
// retry-after hint (opens the cooldown-wait decision) WITHOUT recording a
|
||||||
// recording a competing `rate_limit` lock.
|
// competing `rate_limit` lock.
|
||||||
// - model-b's 403 records the only lock in play: `quota_exhausted`. It is
|
// - model-b's 403 records the only lock in play: `quota_exhausted`. It is
|
||||||
// therefore the lock resolveComboCooldownWaitDecision picks, so its reason
|
// therefore the lock resolveComboCooldownWaitDecision picks, so its reason
|
||||||
// is what drives the decision.
|
// is what drives the decision.
|
||||||
// - The resulting wait is SHORT (well under maxWaitMs=5000), so barrier 2
|
// - The resulting wait is SHORT (well under maxWaitMs=5000), so barrier 2
|
||||||
// lets it through. Only the allow-list can reject it.
|
// lets it through. Only the allow-list can reject it.
|
||||||
//
|
//
|
||||||
// With the reason hardcoded to "rate_limit" (as the non-quota-share path did
|
// With the reason hardcoded to "rate_limit", barrier 1 is gone and this exact
|
||||||
// before), barrier 1 is gone and this exact input waits + redispatches against
|
// input waits + redispatches against a quota-exhausted model — verified: the
|
||||||
// a quota-exhausted model — verified: the same test yields 6 dispatches.
|
// same test yields 6 dispatches.
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const handleSingleModel = async (_body: unknown, modelStr: string) => {
|
const handleSingleModel = async (_body: unknown, modelStr: string) => {
|
||||||
calls.push(modelStr);
|
calls.push(modelStr);
|
||||||
@@ -222,7 +218,10 @@ test("non quota-share (priority): a quota_exhausted lock drives the decision wit
|
|||||||
allCombos: null,
|
allCombos: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(res.status, 429, "the crystallized 429 must be propagated, not retried");
|
// Final status is the last target's 403 (aggregation last-wins). The security
|
||||||
|
// invariant is that the allow-list rejected the wait — not that a peer 429 is
|
||||||
|
// re-surfaced as the HTTP status.
|
||||||
|
assert.equal(res.status, 403, "quota_exhausted target status crystallizes; wait must not redispatch");
|
||||||
// Deterministic proof (no wall-clock dependency, so it cannot flake under
|
// Deterministic proof (no wall-clock dependency, so it cannot flake under
|
||||||
// CI-runner contention): each target is dispatched EXACTLY ONCE. Had the wait
|
// CI-runner contention): each target is dispatched EXACTLY ONCE. Had the wait
|
||||||
// fired, the whole set loop would re-run — maxAttempts=2 within the 8s budget
|
// fired, the whole set loop would re-run — maxAttempts=2 within the 8s budget
|
||||||
|
|||||||
@@ -95,10 +95,10 @@ const resilienceTabSettingsKeys = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const quotaShareResilienceSettingsMessages = {
|
const quotaShareResilienceSettingsMessages = {
|
||||||
resilienceComboCooldownWaitTitle: "Quota-share combo cooldown wait",
|
resilienceComboCooldownWaitTitle: "Combo cooldown wait",
|
||||||
resilienceComboCooldownWaitDesc:
|
resilienceComboCooldownWaitDesc:
|
||||||
"For quota-share combos only: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.",
|
"For all combo strategies: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.",
|
||||||
resilienceComboCooldownWaitToggleDesc: "Quota-share combos only; never waits on quota_exhausted.",
|
resilienceComboCooldownWaitToggleDesc: "All combo strategies; never waits on quota_exhausted.",
|
||||||
resilienceComboCooldownMaxWaitMs: "Maximum wait per attempt",
|
resilienceComboCooldownMaxWaitMs: "Maximum wait per attempt",
|
||||||
resilienceComboCooldownBudgetMs: "Total wait budget",
|
resilienceComboCooldownBudgetMs: "Total wait budget",
|
||||||
resilienceQuotaShareConcurrencyTitle: "Quota-share per-connection concurrency",
|
resilienceQuotaShareConcurrencyTitle: "Quota-share per-connection concurrency",
|
||||||
|
|||||||
90
tests/unit/ts7-executor-override-signatures.test.ts
Normal file
90
tests/unit/ts7-executor-override-signatures.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Guards the executor override signatures fixed for TS 7 readiness.
|
||||||
|
*
|
||||||
|
* Three executors declared a *private/protected* `buildHeaders()` helper whose signature
|
||||||
|
* has nothing to do with `BaseExecutor.buildHeaders(credentials, stream?, clientHeaders?,
|
||||||
|
* model?, health?)`:
|
||||||
|
*
|
||||||
|
* hailuo-web (token: string, yy: string)
|
||||||
|
* lmarena (_model: string, credentials: unknown, _body: unknown)
|
||||||
|
* qwen-web (token: string, cookieHeader: string, chatId?: string)
|
||||||
|
*
|
||||||
|
* They were name collisions, not overrides — each shadowed the inherited member with an
|
||||||
|
* incompatible signature (TS2416). `BaseExecutor` calls `this.buildHeaders(credentials,
|
||||||
|
* false)` from `countTokens()`, so the shadow sat on a live dispatch path; it was never
|
||||||
|
* reached only because `buildCountTokensUrl()` returns null unless `config.format` is
|
||||||
|
* `"claude"`, and none of these three is. Latent rather than live — but one `format`
|
||||||
|
* change away from passing a credentials object where a token string was expected.
|
||||||
|
*
|
||||||
|
* The helpers are renamed, so these assertions pin both halves: the inherited method is
|
||||||
|
* no longer shadowed, and the early return that kept it harmless still holds.
|
||||||
|
*/
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { BaseExecutor } from "../../open-sse/executors/base.ts";
|
||||||
|
import { HailuoWebExecutor } from "../../open-sse/executors/hailuo-web.ts";
|
||||||
|
import { LMArenaExecutor } from "../../open-sse/executors/lmarena.ts";
|
||||||
|
import { QwenWebExecutor } from "../../open-sse/executors/qwen-web.ts";
|
||||||
|
|
||||||
|
const CASES = [
|
||||||
|
{ name: "hailuo-web", make: () => new HailuoWebExecutor(), helper: "buildStreamHeaders" },
|
||||||
|
{ name: "lmarena", make: () => new LMArenaExecutor(), helper: "buildRequestHeaders" },
|
||||||
|
{ name: "qwen-web", make: () => new QwenWebExecutor(), helper: "buildApiHeaders" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, make, helper } of CASES) {
|
||||||
|
test(`${name}: buildHeaders resolves to BaseExecutor, not a local helper`, () => {
|
||||||
|
const executor = make() as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
executor.buildHeaders,
|
||||||
|
BaseExecutor.prototype.buildHeaders,
|
||||||
|
`${name} must not shadow BaseExecutor.buildHeaders — countTokens() dispatches through it`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${name}: its own header helper is still present under the renamed key`, () => {
|
||||||
|
const executor = make() as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
typeof executor[helper],
|
||||||
|
"function",
|
||||||
|
`${name} should keep its provider-specific header builder as ${helper}()`
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
executor[helper],
|
||||||
|
BaseExecutor.prototype.buildHeaders,
|
||||||
|
"the renamed helper must be the provider's own function, not the inherited one"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${name}: countTokens() short-circuits before reaching buildHeaders`, async () => {
|
||||||
|
const executor = make();
|
||||||
|
|
||||||
|
// buildCountTokensUrl() returns null unless config.format === "claude" and the URL
|
||||||
|
// carries /messages. That early return is what kept the old shadow unreachable; if it
|
||||||
|
// ever changes, the inherited buildHeaders must be the one that runs.
|
||||||
|
const result = await executor.countTokens({
|
||||||
|
model: "whatever",
|
||||||
|
body: { messages: [] },
|
||||||
|
credentials: {},
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
log: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, null, `${name} does not support the Anthropic count_tokens endpoint`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("LMArenaExecutor does not narrow visibility of inherited members", () => {
|
||||||
|
// TS2415: a subclass may widen a member's visibility but never narrow it. `buildUrl` and
|
||||||
|
// `transformRequest` were `protected` here while public on BaseExecutor — masked behind
|
||||||
|
// the buildHeaders TS2416 until that cleared. Runtime has no visibility, so this asserts
|
||||||
|
// the members are reachable, which is what the type change encodes.
|
||||||
|
const executor = new LMArenaExecutor() as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
for (const member of ["buildUrl", "transformRequest"]) {
|
||||||
|
assert.equal(typeof executor[member], "function", `${member} must stay callable`);
|
||||||
|
}
|
||||||
|
});
|
||||||
145
tests/unit/ts7-executor-shared-shapes.test.ts
Normal file
145
tests/unit/ts7-executor-shared-shapes.test.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
const { OpencodeExecutor } = await import("../../open-sse/executors/opencode.ts");
|
||||||
|
const { MimocodeExecutor } = await import("../../open-sse/executors/mimocode.ts");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavioral guards for the three type-only fixes in the TS 7 executor slice
|
||||||
|
* (see #8484). Each fix restored a type the code already depended on at runtime;
|
||||||
|
* these tests pin the runtime contracts so a future "simplification" of the
|
||||||
|
* annotations cannot silently change behavior.
|
||||||
|
*
|
||||||
|
* The zed-hosted `SseEnqueueTarget` fix is already covered end-to-end by
|
||||||
|
* `zed-hosted-think-close-marker.test.ts`, which drives the same TransformStream
|
||||||
|
* that failed to type-check — no duplicate added here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("OpencodeExecutor — tools truncation survives the narrowing fix", () => {
|
||||||
|
const executor = new OpencodeExecutor("opencode-go");
|
||||||
|
const CREDENTIALS = { apiKey: "k" } as Record<string, unknown>;
|
||||||
|
|
||||||
|
const tools = (n: number) =>
|
||||||
|
Array.from({ length: n }, (_, i) => ({
|
||||||
|
type: "function",
|
||||||
|
function: { name: `tool_${i}`, parameters: {} },
|
||||||
|
}));
|
||||||
|
|
||||||
|
function bodyWith(toolCount: number) {
|
||||||
|
return {
|
||||||
|
model: "oc/kimi-k2.6",
|
||||||
|
stream: true,
|
||||||
|
messages: [{ role: "user", content: "hi" }],
|
||||||
|
tools: tools(toolCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("truncates an over-long tools array to 128 entries", () => {
|
||||||
|
const out = executor.transformRequest("oc/kimi-k2.6", bodyWith(200), true, CREDENTIALS) as {
|
||||||
|
tools: unknown[];
|
||||||
|
};
|
||||||
|
assert.equal(out.tools.length, 128, "upstream rejects more than 128 tools");
|
||||||
|
assert.deepEqual(
|
||||||
|
(out.tools[127] as { function: { name: string } }).function.name,
|
||||||
|
"tool_127",
|
||||||
|
"truncation keeps the first 128 in order, not an arbitrary slice"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a within-limit tools array untouched", () => {
|
||||||
|
const out = executor.transformRequest("oc/kimi-k2.6", bodyWith(10), true, CREDENTIALS) as {
|
||||||
|
tools: unknown[];
|
||||||
|
};
|
||||||
|
assert.equal(out.tools.length, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when the body carries no tools", () => {
|
||||||
|
const body = {
|
||||||
|
model: "oc/kimi-k2.6",
|
||||||
|
stream: true,
|
||||||
|
messages: [{ role: "user", content: "hi" }],
|
||||||
|
};
|
||||||
|
const out = executor.transformRequest("oc/kimi-k2.6", body, true, CREDENTIALS) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
assert.equal("tools" in out, false);
|
||||||
|
assert.ok(Array.isArray(out.messages), "messages preserved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an array-shaped body alone (pins the !Array.isArray guard)", () => {
|
||||||
|
// The pre-fix condition reached `.tools` on any object, arrays included, and
|
||||||
|
// relied on `Array.isArray(undefined)` short-circuiting. The explicit
|
||||||
|
// !Array.isArray() guard must keep that outcome identical.
|
||||||
|
const arrayBody = [{ role: "user", content: "hi" }] as unknown as Record<string, unknown>;
|
||||||
|
const out = executor.transformRequest("oc/kimi-k2.6", arrayBody, true, CREDENTIALS);
|
||||||
|
assert.ok(Array.isArray(out), "array body must pass through as an array");
|
||||||
|
assert.equal((out as unknown[]).length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MimocodeExecutor — AccountState.proxy is always present (#3837/#5521)", () => {
|
||||||
|
const FP = "fingerprint-1";
|
||||||
|
|
||||||
|
function accountsOf(exec: unknown): Array<Record<string, unknown>> {
|
||||||
|
return (exec as { accounts: Array<Record<string, unknown>> }).accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync(exec: unknown, credentials: unknown): void {
|
||||||
|
(exec as { syncAccountsFromCredentials(c: unknown): void }).syncAccountsFromCredentials(
|
||||||
|
credentials
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("defaults proxy to null — not undefined — when no accountProxies are configured", () => {
|
||||||
|
const exec = new MimocodeExecutor();
|
||||||
|
accountsOf(exec).length = 0;
|
||||||
|
accountsOf(exec).push({
|
||||||
|
fingerprint: FP,
|
||||||
|
jwt: "",
|
||||||
|
expiresAt: 0,
|
||||||
|
cooldownUntil: 0,
|
||||||
|
consecutiveFails: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
sync(exec, { providerSpecificData: {} });
|
||||||
|
|
||||||
|
const account = accountsOf(exec)[0];
|
||||||
|
assert.ok("proxy" in account, "every account must expose a proxy key");
|
||||||
|
assert.equal(account.proxy, null, "unconfigured proxy is null, never undefined");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a configured proxy onto the matching account", () => {
|
||||||
|
const exec = new MimocodeExecutor();
|
||||||
|
accountsOf(exec).length = 0;
|
||||||
|
accountsOf(exec).push({
|
||||||
|
fingerprint: FP,
|
||||||
|
jwt: "",
|
||||||
|
expiresAt: 0,
|
||||||
|
cooldownUntil: 0,
|
||||||
|
consecutiveFails: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxy = { type: "http", host: "p1.example.com", port: 1080 };
|
||||||
|
sync(exec, { providerSpecificData: { accountProxies: [{ fingerprint: FP, proxy }] } });
|
||||||
|
|
||||||
|
assert.deepEqual(accountsOf(exec)[0].proxy, proxy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a previously-resolved proxy back to null when config drops it", () => {
|
||||||
|
const exec = new MimocodeExecutor();
|
||||||
|
accountsOf(exec).length = 0;
|
||||||
|
accountsOf(exec).push({
|
||||||
|
fingerprint: FP,
|
||||||
|
jwt: "",
|
||||||
|
expiresAt: 0,
|
||||||
|
cooldownUntil: 0,
|
||||||
|
consecutiveFails: 0,
|
||||||
|
proxy: { type: "http", host: "stale.example.com", port: 8080 },
|
||||||
|
});
|
||||||
|
|
||||||
|
sync(exec, { providerSpecificData: { accountProxies: [] } });
|
||||||
|
|
||||||
|
assert.equal(accountsOf(exec)[0].proxy, null, "a removed proxy must not linger on the account");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user