Compare commits

...

10 Commits

Author SHA1 Message Date
backryun
647afe327b refactor(sse): declare the multipart/gRPC frame bodies as ArrayBuffer-backed (#8533)
* refactor(sse): declare the multipart/gRPC frame bodies as ArrayBuffer-backed

Seven TS2769 across three files, one root cause: a bare `Uint8Array` widens to
`Uint8Array<ArrayBufferLike>`, which admits `SharedArrayBuffer` and so is not
assignable to `BodyInit`. Every one of the seven is a `fetch({ body })` call
rejecting an otherwise-valid payload.

They flow from exactly two producers:

  buildMultipartBody()  audioTranscription.ts  -> 5 sites here + 1 in audioTranslation.ts
  grpcWebFrame()        windsurf.ts            -> 1 site

Both allocate with `new Uint8Array(length)`, which is always ArrayBuffer-backed,
so declaring `Uint8Array<ArrayBuffer>` states what the code already guarantees.
This is a narrowing of the declaration to match reality, not a cast at the call
sites — no `as BodyInit` anywhere, and the seven call sites are untouched.

280 -> 273, zero new, on a line-number-agnostic diff of the full tsc error set.
The runtime diff is two return-type annotations; nothing executable changed.

Tests: buildMultipartBody already has four direct tests, but they assert the
payload's *contents* (boundary, filename sanitization, MIME fallback) — none
assert the backing, which is precisely what the new type guarantees and what a
plausible switch to a pooled `Buffer.concat` would break. Added
multipart-body-arraybuffer-backing.test.ts (3 tests): the buffer is a plain
ArrayBuffer, the view spans it entirely at offset 0 (no pool remainder), and
both hold for a 64 KiB payload past Node's Buffer-pool threshold. 258/258 across
the 17 affected audio/windsurf suites.

Not included: the 3 remaining TS2339 in audioTranscription.ts (`data?.data?.…`
on an untyped poll result). Different root cause — grouped by kind, not by file,
per #8484.

* chore(quality): trim the buildMultipartBody doc so audioTranscription.ts stays under the 800 cap

My doc comment on buildMultipartBody added 9 lines to a file with 8 to spare
(792 -> 801 as check:file-size counts, cap 800), so this PR was failing the gate
on growth I introduced. Condensed the comment to 4 lines; the file lands at 796.

Rebuilt on top of the /green-prs sync merge (9973bf3bf) rather than force-pushing
my own rebase over it — the release-tip sync there is @ikelvingo's work and had
already been validated against this branch.

Delta unchanged: 280 -> 273, 7 fixed, zero new. 68/68 on the audio/windsurf
suites; typecheck:core and eslint clean; check:file-size now OK.

---------

Co-authored-by: backryun <busan011@ormbiz.co.kr>
Co-authored-by: ikelvingo <im.kelvinwong@gmail.com>
2026-07-26 03:52:04 -03:00
backryun
d628105503 refactor(sse): retag the designer-web result unions with string discriminants (#8531)
All 10 diagnostics in this file are the `strictNullChecks: false` narrowing
limitation: a boolean-literal discriminant narrows the positive branch but
leaves the negative one as the full union, so `if (!resolved.ok)` and the code
after `if (outcome.success)` could not see `status`/`error`.

Three unions, all module-private and untouched by any test, so per the rule
recorded on #8499 these are retagged rather than fixed with predicates —
predicates are for exported unions whose shape callers depend on:

  resolveDesignerWebRequest  ok: true|false      -> state: "resolved"|"invalid"
  DesignerWebStepResult      done + success      -> state: "pending"|"ready"|"failed"

The step union collapsed two booleans into one discriminant; `done`/`success`
encoded three states across two flags, which is also why the pending arm had no
`success` property for `outcome.success` to read.

Also narrowed runDesignerWebPollLoop's declared return from
`DesignerWebStepResult | {…504…}` to a new DesignerWebOutcome (ready | failed).
The loop returns a step only after confirming it is terminal, and otherwise
synthesizes a 504 — it can never return a pending step, and the old signature
claiming it could is what made `.success` unreadable on the union at all.

280 -> 270, zero new, on a line-number-agnostic diff of the full tsc error set.

Tests: unlike the previous slices this rewrote real control flow (three
conditionals), so the existing suite is doing actual work here —
microsoft-designer-web-6672.test.ts drives the handler end-to-end through 400,
401, immediate-ready, poll-then-ready, non-OK upstream and 504-timeout, i.e.
every arm but one. The "empty" arm (unrecognized 200 body -> terminal 502) was
tested only at the parser level, never through the handler, so the 502 itself
was unasserted. Added designer-web-empty-response-502.test.ts (2 tests) pinning
that it is terminal (exactly one fetch, no polling) and distinct from the 504
deadline path. Both suites pass against the parent commit too — the tests are
black-box through the exported handler, so they are agnostic to the discriminant
rename and prove the retag is behavior-preserving. 61/61 across the 3 suites.

Co-authored-by: backryun <busan011@ormbiz.co.kr>
2026-07-26 03:52:00 -03:00
backryun
2cf462c1a1 refactor(sse): type the rerank response adapter's options parameter (#8528)
`transformResponseFromProvider(providerConfig, data, options = {})` annotated
nothing, so TS inferred `options` as `{}` from its default value and rejected
every read of it: `top_n` x6, `documents` x4, `return_documents` x2 across the
DeepInfra and Voyage adapters. All 12 of the file's diagnostics, one cause.

Declared `RerankResponseOptions` from the JSDoc that already documents these
fields on handleRerank, with `documents` as `Array<string | { text?: string }>`
— the two forms the Cohere-compatible API accepts and the adapters already
branch on. `providerConfig` and `data` stay unannotated; only `options` was
producing errors and only `options` is touched.

280 -> 268, zero new, on a line-number-agnostic diff of the full tsc error set.

Tests: `{ text }` documents were only ever exercised through the *request*
adapter (#5332, #7809) — every response-adapter test passed plain strings, so
the `typeof doc === "string" ? doc : doc?.text` branch on the response side was
uncovered, and that branch is exactly what gives the declared type its union.
Added rerank-object-documents-response-path.test.ts (5 tests): object-form
documents resolved through both adapters, a mixed string/object array, the
`{}`-without-text fallback, and Voyage's index remap across an empty `{ text: "" }`
document. They pass against the parent commit's rerank.ts too — no behavior
change. 43/43 across the 6 rerank/media-cost suites.

Co-authored-by: backryun <busan011@ormbiz.co.kr>
2026-07-26 03:51:56 -03:00
backryun
0312fe2a40 refactor(guardrails): narrow the documented void return at the dispatch site (#8525)
`BaseGuardrail.preCall`/`postCall` return `GuardrailResult<unknown> | void`,
and that `| void` is deliberate: docs/security/GUARDRAILS.md documents returning
nothing as one of the three "no change" signals, and CredentialMaskerGuardrail
still declares it. So the union stays.

The problem is the consumption site. `registry.ts` reads `result?.block`,
`result?.message`, `result?.meta`, `result?.modifiedPayload` — but optional
chaining does not make `void` inspectable, and neither does a truthiness test.
That is all 12 TS2339s in the file: one union, six property reads, two dispatch
loops.

Fixed by funnelling the return through `unknown` once, in a local
`asGuardrailResult()` helper, so the loops work against a plain
`GuardrailResult | undefined`. The public contract is untouched — no signature
narrowed, no guardrail changed, callers outside this file unaffected.

280 -> 268, zero new, on a line-number-agnostic diff of the full tsc error set.

Tests: the `void` arm of the documented contract had NO coverage —
guardrails-registry.test.ts exercises guardrails that return a result and one
that throws, never one that returns nothing. Added
guardrails-void-no-change-contract.test.ts (4 tests): silent preCall/postCall
pass through untouched and are recorded as ran-not-skipped-not-errored;
returning `{}` produces an identical execution record; a silent guardrail does
not stop a later one from modifying. They pass against the parent commit's
registry.ts too, which is the evidence for no behavior change.

75/75 across the 13 guardrail suites.

Co-authored-by: backryun <busan011@ormbiz.co.kr>
2026-07-26 03:51:53 -03:00
backryun
fd739ab008 refactor(sse): restore three executor types the runtime already relied on (#8520)
Three independent root causes in the TS 7 executor slice (#8484), each a
declaration that had drifted behind the code using it. All type-only —
no behavior change, verified by running the new tests against the parent
commit's sources (7/7 pass there too).

mimocode — AccountState was missing `proxy`, but syncAccountsFromCredentials()
writes it on every account and getProxyDispatcher() reads it. The #3837/#5521
contract ("always present, null when unconfigured") lived only in a comment.
Declared as AccountProxyConfig["proxy"] so the two stay in lockstep. (4)

opencode — the tools-truncation block narrowed `modifiedBody` with
`typeof === "object"` but, unlike the client_metadata block directly above it,
omitted `!Array.isArray()` and the Record cast, so `.tools` was unreachable on
`object`. Adopted the neighbouring block's idiom. Behavior is identical: the
old code reached `.tools` on arrays too and relied on Array.isArray(undefined)
short-circuiting. (4)

zed-hosted — enqueueSseObject/finish/processLine were annotated
ReadableStreamDefaultController but are driven from a TransformStream, whose
controller has no close(). All three only ever enqueue, so they are now typed
by that single capability (SseEnqueueTarget). (3)

310 -> 299 diagnostics, zero new, on a line-number-agnostic diff of the full
tsc error set.

Tests: new tests/unit/ts7-executor-shared-shapes.test.ts pins the tools
truncation (previously uncovered) and the proxy-always-present contract. The
zed-hosted transform is already covered end-to-end by
zed-hosted-think-close-marker.test.ts. 300/300 across the 26 affected files.

Co-authored-by: backryun <busan011@ormbiz.co.kr>
2026-07-26 03:51:49 -03:00
backryun
1bd1af2f48 refactor(sse): narrow three result unions via type predicates (#8499)
* refactor(sse): narrow three result unions via type predicates

Eight diagnostics across three executors, all the same root cause already recorded in
#8483: this workspace compiles with `strictNullChecks: false`, where a boolean-literal
discriminant narrows the positive branch but not the negative one. Reading a
failure-only field after `!result.ok` therefore leaves the full union.

  auggie.ts        2  .error       on AuggieModelResolution
  muse-spark-web   4  .error       on GraphqlResult
  notion-web       2  .retryable / .errorResult on the runOnce union

#8483 retagged its union with a string discriminant. That is the better shape when the
union is module-private and small, but it does not fit here: `resolveAuggieModel` is
exported and its tests deep-equal the literal `{ ok: true, model }` object, so retagging
would churn public API and assertions to fix a checker limitation. Each union instead
gets an explicit type predicate, which narrows correctly under these compiler settings
while leaving the shape, every call site, and the tests untouched. notion-web's inline
union is named `NotionAttempt` first so it has something to `Extract` from.

Validation: full tsc error-set diff against the base config — 335 -> 327, zero new
errors (line-number-agnostic). `typecheck:core` clean; the 6 existing test files
importing a touched executor pass.

Coverage: each predicate is a one-liner whose control flow inverts on a stray `!`, and
all three failure branches already have behavioral guards —
`auggie-executor.test.ts` (400 + /Unknown Auggie model/),
`muse-spark-web-continuation.test.ts` ("Warmup failed: …"), and
`executor-notion-web.test.ts` (nested temporarily-unavailable → retried). The two
assertions added here pin the arm that the predicate unlocks on the one union that is
exported and directly reachable.

* chore(quality): rebaseline muse-spark-web.ts for #8499 own growth

The new isGraphqlFailure() type-predicate helper (TS7 strictNullChecks:false
narrowing fix) grows the frozen file 1396->1405 (+9), irreducible per the
justification recorded in config/quality/file-size-baseline.json.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

---------

Co-authored-by: ikelvingo <im.kelvinwong@gmail.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-07-26 03:51:45 -03:00
backryun
06326a3c80 refactor(sse): stop three executors shadowing BaseExecutor.buildHeaders (#8498)
`BaseExecutor.buildHeaders(credentials, stream?, clientHeaders?, model?, health?)` was
shadowed in three executors by same-named helpers with unrelated signatures:

  hailuo-web  private   buildHeaders(token: string, yy: string)
  lmarena     protected buildHeaders(_model: string, credentials: unknown, _body: unknown)
  qwen-web    private   buildHeaders(token: string, cookieHeader: string, chatId?: string)

Name collisions, not overrides — each reported TS2416. They are renamed to
`buildStreamHeaders` / `buildRequestHeaders` / `buildApiHeaders`; the two lmarena test
files that called the helper directly are updated with them.

Worth stating precisely, because the shadow sat on a live dispatch path without being a
live bug: `BaseExecutor.countTokens()` calls `this.buildHeaders(credentials, false)`, and
all three inherit `countTokens()`. It is unreachable today only because
`buildCountTokensUrl()` returns null unless `config.format === "claude"` and the URL
carries `/messages` — hailuo-web and qwen-web set no format, lmarena sets `"openai"` — so
`countTokens()` returns at the guard above. Latent, not live; one `format` change away
from passing a credentials object where a token string is expected.

Two more, surfaced by clearing the above:

* `lmarena` declared `buildUrl` and `transformRequest` `protected` while both are public
  on BaseExecutor (TS2415 — a subclass may widen visibility, never narrow it). Both were
  masked behind the buildHeaders TS2416 and appeared one at a time as it cleared. Runtime
  is unaffected; JavaScript has no member visibility.

* `GithubExecutor.refreshCredentials` had no declared return type, so TypeScript inferred
  the union of its four literal returns. `GheCopilotExecutor` legitimately overrides it
  with a wider `providerSpecificData` (it also records the enterprise proxy URL) and no
  `expiresIn`, which is not assignable to that inferred union. Declared as
  `RefreshedCopilotCredentials | null` — same shape of fix as #8489, on a different method.

Validation: full tsc error-set diff against the base config — 335 -> 331, zero new errors
(line-number-agnostic). `typecheck:core` clean; the 15 existing test files importing a
touched executor pass, including lmarena's 44 across the two updated files.
`plan3-p0.test.ts` fails identically with and without this change (it reads the
developer's real ~/.omniroute DB rather than a test-scoped DATA_DIR).

The new test pins that the inherited method is no longer shadowed — verified to fail on
the base, where all three prototypes still carry their own `buildHeaders` — and that the
`countTokens()` early return which kept it harmless still holds.
2026-07-26 03:51:42 -03:00
AmirHossein Rezaei
a51cd06322 fix(resilience): honor comboCooldownWait for every combo strategy (#8541) (#8559)
* fix(resilience): honor comboCooldownWait for every combo strategy

The cooldown-wait path claimed all strategies, but isComboCooldownWaitEligible still gated on quota-share/auto. Widen the gate, raise the per-target timeout floor with it, and consult the allow-list from earliestRetryAfter so a later 403 cannot skip the decision. Fixes #8541.

* test(combo): fold cooldown-wait strategy assertions into a loop

Keep combo-config.test.ts under the file-size ceiling while covering every
routing strategy (including internal quota-share) for the #8541 gate fix.

* chore(combo): trim cooldown-wait comment under file-size ceiling

After rebase onto tip the net +2 in combo.ts sat one line over the frozen
check:file-size count (split-based); fold the SECURITY note into the block.
2026-07-26 03:51:38 -03:00
ikelvingo
f60c9fe542 chore(quality): rebaseline complexity/cognitive for v3.8.49 merge-train own-growth 2026-07-25 21:46:36 -03:00
ikelvingo
1d7878d857 chore(quality): rebaseline complexity/cognitive to v3.8.49 tip drift 2026-07-25 21:03:23 -03:00
39 changed files with 966 additions and 208 deletions

View File

@@ -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).

View File

@@ -1,7 +1,8 @@
{
"_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.",
"_rebaseline_2026_07_20_owner_night_drain": "Owner-approved (chat, 2026-07-20 ~00:50): 2072->2130. The day's 17 merged PRs consumed the entire slack (tip at 2069/2072); queue PRs #6973(+4)/#7662(+2)/#7719(+1) plus the #7744/#7779 reworks were collectively blocked. Owner chose a wide margin for the remainder of the v3.8.49 cycle instead of per-PR extraction.",
"count": 2130,
"_rebaseline_2026_07_25b_v3849_mergetrain_owngrowth": "Owner-approved (chat, 2026-07-25): 2169->2183 (+14). v3.8.49 /merge-prs 41-PR merge-train aggregate own-growth: measured 2183 on the combined boarded tree (tip ac15014ca7) vs 2169 on the pristine release tip. Each boarded PR sits under the ceiling individually, but the combined batch adds +14 (new branches in #8378 chatCore contextLimit / #8432 cursor native_todo / #8476 combo input-bound / #8526 combo select-all modals / etc — the pre-screen-flagged complexity-growth set). Same merge-burst-inherited-drift class as the notes below; owner chose absorbing the ceiling over per-PR helper-extraction churn. Structural shrink stays debt (#3501); tighten via --update next cycle.",
"_rebaseline_2026_07_25_v3849_mergequeue_drain": "Owner-approved (chat, 2026-07-25): 2130->2169 (+39). v3.8.49 /merge-prs queue-drain: the cycle's merge burst (the 8 base-red slices + owner PRs + parallel-session merges #8500-8508) accrued inherited cyclomatic drift the fast-path PR->release never ratchets (check:complexity does not run on PR->release). Measured 2169 on the pristine release tip 4053e2314a alone (BEFORE any queue PR boards) — so the entire +39 is base drift already on the tip, not any queued PR's own growth. Every merge-ready PR in the queue was tripping Fast Quality Gates on this shared base-red. Owner approved raising the ceiling to the measured tip value so the ~34-PR merge-train lands without per-PR helper-extraction churn. Structural shrink stays debt (#3501); tighten via --update next cycle.",
"count": 2183,
"_rebaseline_2026_07_19_v3849_fix_sweep_cluster": "2059->2072 (owner-approved, 2026-07-19). /fix-prs validation-train sweep: a cluster of otherwise-clean contributor PRs (#6973/#7683/#7662/#7672/#7633/#7767, each +1/+2 cyclomatic own-growth from new provider/auth/combo branches) collectively pushed the count from tip 2056 to 2068. Individually all but #6973 sit under the old 2059 baseline; combined they exceed it. The tip had only 3 units of slack (2056 vs 2059), so every new-feature PR was tripping the ratchet (this was the 4th such block of the day after #7695/#7747/#7768). Owner approved raising the ceiling to 2072 = combined-cluster 2068 + 4 units headroom, so the cluster lands without per-PR helper-extraction churn and near-term feature PRs have breathing room. Measured 2068 on the 9-PR combined probe tree. Structural shrink stays debt (#3501); tighten via --update next cycle.",
"_rebaseline_2026_07_18_pr7360_quota_visibility_resync": "2058->2059 (+1 vs recorded ceiling; measured 2056 fresh on release tip cab9e5f0c alone, so this ceiling still carries 2 units of un-banked slack from prior shrinkage — real regression from this merge is 2056->2059, +3). PR #7360 (JxnLexn) release-resync: merging origin/release/v3.8.49 to resolve the 3-file conflict (ConnectionRow.tsx/ConnectionsListPanel.tsx/useProviderConnections.ts) unions two already-compliant features in the same already-oversized god-component: release's confirm-delete-account wiring (#7361) and this PR's per-connection quota-visibility wiring. Diffed release-tip-only vs merged violation lists (scripts dumped via getComplexityEslintReport): most entries are the SAME pre-existing violations shifted a few lines (ConnectionRow/getStatusPresentation/inferErrorType — no count change) or marginally bigger (ConnectionRow function complexity 85->86, ConnectionsListPanel function 498->510 lines) from the two ConnectionRow call sites each gaining both PRs' multi-line JSX props. The 2 genuinely NEW crossings are the 'no tag' and 'tagged groups' .map() render callbacks in ConnectionsListPanel.tsx (83 and 85 lines, was <=80 on both parents individually) tipping over 80 lines specifically because both PRs' props land on the same call sites. No new logic was written during the resync itself (only import-statement unions); the growth is inherent to combining the two already-reviewed feature branches. Structural shrink tracked in #3501. Tighten via --update next cycle (true floor is 2056, not 2058).",
"_rebaseline_2026_07_17_v3849_ownerprs_providers": "2056->2058 (+2). v3.8.49 owner-PR merge campaign own-growth: the new provider handlers/dispatch branches merged this cycle (freetheai/felo/notion/segmind/deepinfra/novita/msdesigner image+video handlers, each adding a format-dispatch guard) pushed cyclomatic violations 2056->2058. Fast-gates PR->release do not run the complexity ratchet, so this surfaced only on re-sync. Spread across the new leaf handlers (not a single extractable function); measured on the release tip. Structural shrink tracked in #3501.",

View File

@@ -1,4 +1,5 @@
{
"_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).",
"_rebaseline_2026_07_22_8131_windowshide_cloudflared_spawn": "PR #8167 (Dingding-leo, fix/windows-hide-child-process, #8131) own growth: src/lib/cloudflaredTunnel.ts 934->935 (+1, irreducible call-site wiring — the single `windowsHide: true` option added to the existing cloudflared spawn() options object so no transient conhost.exe/cmd console window flashes open on Windows). Covered by the pre-merge-fix regression test tests/unit/windows-hide-child-process-spawns-8131.test.ts (added for the two additional spawn() sites the PR missed: ServiceSupervisor.ts, versionManager/processManager.ts) plus the windowsHide assertion added to tests/unit/services/installers/runNpm-shell-5379.test.ts (installers/utils.ts buildNpmExecOptions).",
"_rebaseline_2026_07_22_8006_adobe_firefly_media_provider": "PR #8006 (artickc, feat/adobe-firefly-media) own growth: adds Adobe Firefly as a media-only (image + video) provider — unofficial IMS/cookie-session bridge for firefly.adobe.com covering IMS cookie->access_token exchange, discovery-catalog fallback, credits/balance usage, and submit+poll dispatch for both image (nano-banana/gpt-image families) and video (Sora 2/Veo 3.1/Kling 3.0) generation, with 408-under-load retry handling. New leaf open-sse/services/adobeFireflyClient.ts frozen at 1958 (>>cap 800) — a single self-contained upstream client (mirrors the qoderCli.ts precedent for a new provider client that is legitimately large on day one: IMS auth, cookie/JWT normalization, payload builders for 2 media types x multiple model families, SSE-less submit/poll state machine, error sanitization); not extractable without scattering a single upstream integration across artificial module boundaries mid-PR. open-sse/config/imageRegistry.ts (existing, previously under cap) grows 800->821 (+21, the new adobe-firefly IMAGE_PROVIDERS entry + models list, additive registry data at the existing registry chokepoint). src/lib/usage/providerLimits.ts 1000->1003 (+3, adobe-firefly/firefly added to the existing apikey-usage-fetcher allowlist, irreducible call-site wiring mirroring the sibling #7994 PromptQL/HyperAgent entries in the same PR group). Covered by tests/unit/adobe-firefly.test.ts (35/35). Structural shrink tracked in #3501.",
"_rebaseline_2026_07_22_7994_hyperagent_web_provider": "PR #7994 (artickc, feat/hyperagent-web) own growth: adds HyperAgent (hyperagent.com) as a new unofficial web-cookie chat provider, reverse-engineered from live SPA captures (thread/session SSE flow, credits/usage endpoint). New leaf open-sse/executors/hyperagent.ts frozen at 937 (>cap 800) — single self-contained executor covering cookie auth, SSE parsing (text/session_start/session_end/done events), and a sticky thread/session cache for multi-turn continuity; not extractable without splitting the executor mid-request-flow (mirrors the sseParser.ts/muse-spark-web.ts precedent for new provider executors that exceed cap on day one). src/lib/usage/providerLimits.ts 1000->1003 (+3, irreducible call-site wiring adding hyperagent/ha to the existing USAGE_FETCHER_PROVIDERS-style allowlist at the chokepoint other web-cookie providers already extend). Covered by tests/unit/executor-hyperagent.test.ts (16/16). Structural shrink tracked in #3501.",
@@ -175,7 +176,7 @@
"open-sse/executors/duckduckgo-web.ts": 925,
"open-sse/executors/grok-web.ts": 1873,
"open-sse/executors/hyperagent.ts": 937,
"open-sse/executors/muse-spark-web.ts": 1396,
"open-sse/executors/muse-spark-web.ts": 1405,
"open-sse/executors/perplexity-web.ts": 1032,
"open-sse/handlers/audioSpeech.ts": 1061,
"open-sse/handlers/chatCore.ts": 5125,

View File

@@ -123,7 +123,9 @@
"_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle."
},
"cognitiveComplexity": {
"value": 951,
"value": 968,
"_rebaseline_2026_07_25b_v3849_mergetrain_owngrowth": "Owner-approved (chat, 2026-07-25): 956->968 (+12). v3.8.49 /merge-prs 41-PR merge-train aggregate own-growth: measured 968 on the combined boarded tree (tip ac15014ca7) vs 956 on the pristine release tip. The batch's new over-threshold functions come from the pre-screen-flagged complexity-growth set (#8378/#8432/#8476/#8526 etc); each PR is under-ceiling alone, the combined batch adds +12. Same merge-burst class as the notes below; owner chose ceiling-absorb over per-PR extraction. Structural shrink tracked in #3501; tighten via --update next cycle.",
"_rebaseline_2026_07_25_v3849_mergequeue_drain": "Owner-approved (chat, 2026-07-25): 951->956 (+5). v3.8.49 /merge-prs queue-drain: inherited cognitive-complexity drift from the cycle's merge burst (base-red slices + owner PRs + parallel-session merges #8500-8508); check:cognitive-complexity does not run on PR->release fast-gates, so it accrued unmeasured. Measured 956 on the pristine release tip 4053e2314a alone (BEFORE any queue PR boards) — the entire +5 is base drift already on the tip, reddening Fast Quality Gates for every merge-ready PR. Owner approved raising the ceiling to the measured tip value so the ~34-PR merge-train lands without per-PR extraction churn. Structural shrink tracked in #3501; tighten via --update next cycle.",
"_rebaseline_2026_07_10_gcf_v3_2": "885->888 (+3). PR feat/headroom-gcf-v3.2-nested-flattening: own growth from re-vendoring the GCF (Headroom) codec to spec v3.2 (nested flattening). The new over-threshold functions are the vendored v3.2 flatten/unflatten walk in open-sse/services/compression/engines/headroom/gcf/{generic,decode_generic}.ts. Imported third-party code kept byte-faithful to upstream gcf-typescript; measured 888 with the update vs 885 on the pristine origin/release/v3.8.47 tip. Guarded by tests/unit/compression/headroom-smartcrusher.test.ts (deep-nested case). Structural shrink belongs upstream in gcf.",
"_rebaseline_2026_07_12_v3847_mergetrain_burst": "885->890 (+5). v3.8.47 /merge-prs merge-train batch (23 merge-ready PRs) inherited drift: cognitive-complexity does NOT run on PR->release fast-gates, so incidental growth accrued unmeasured across the batch. Measured 890 on the combined merge-train tip (5d980352d) vs 885 on the pristine release tip 1b7a9150e. #6838 (headroom gcf codec re-vendor) accounts for +3 (its own baseline bump to 888, superseded here by this later 890 rebaseline which already covers it); the remaining +2 is parallel-batch drift across the other 22 PRs. Owner-approved rebaseline (merge-burst reconciliation, same class as the v3.8.46/v3.8.44 notes below). Tighten via --update next cycle.",
"_rebaseline_2026_07_09_6587_kiro_api_key_auth": "884->885 (+1). PR #6587 (@strangersp) own growth: open-sse/services/usage/kiro.ts gains ONE new over-threshold function — getKiroUsage grew from a single fetch to a 3-endpoint fallback chain (codewhisperer-get / codewhisperer-post / q-get) with per-attempt auth-header selection (tokentype: API_KEY vs Bearer-only), needed so usage/quota lookups work for the new long-lived-API-key auth path in addition to the existing OAuth path (measured: 0 violations on release tip -> 1 violation, complexity 33, at open-sse/services/usage/kiro.ts). Covered by tests/unit/kiro-iam-profilearn-usage.test.ts (tokentype header selection, friendly auth-expired/rejected-token messages). Cohesive multi-endpoint-fallback logic at an existing usage chokepoint; not extractable without splitting the fallback loop mid-merge. Structural shrink tracked in #3501.",

View File

@@ -209,14 +209,13 @@ on). Without a `max_concurrent` cap the behavior is unchanged.
### Combo cooldown-aware retry
For quota-share and `auto` combos, a request that would crystallize a 429 for a
SHORT transient cooldown waits it out and re-dispatches instead of returning
the 429 — this covers Gemini-class TPM/RPM windows (~60s retry-after) on a
multi-model `auto` combo, e.g. both targets of a 2-model combo hitting a
per-model rate limit. Bounded by `comboCooldownWait` (`enabled`, `maxWaitMs`
65s, `maxAttempts` 2, `budgetMs` 130s, hard ceiling 90s) in **Settings →
Resilience**. It never waits on `quota_exhausted` (locked until midnight) or
auth/not-found reasons.
For every combo strategy (when enabled), a request that would crystallize a 429
for a SHORT transient cooldown waits it out and re-dispatches instead of
returning the 429 — this covers Gemini-class TPM/RPM windows (~60s retry-after)
on multi-model combos, e.g. both targets of a 2-model combo hitting a per-model
rate limit. Bounded by `comboCooldownWait` (`enabled`, `maxWaitMs`, `maxAttempts`,
`budgetMs`) in **Settings → Resilience**. It never waits on `quota_exhausted`
(locked until midnight) or auth/not-found reasons.
---

View File

@@ -149,6 +149,19 @@ export async function initAuggieModels(
type AuggieModelResolution = { ok: true; model: string } | { ok: false; error: string };
/**
* This workspace compiles with `strictNullChecks: false`, where a boolean-literal
* discriminant narrows the positive branch but not the negative one — so `!r.ok` alone
* leaves `r` as the full union and reading `.error` fails. An explicit type predicate
* narrows under those settings without retagging the union (which is public API here:
* `resolveAuggieModel` is exported and its tests deep-equal the `{ ok: true, ... }` shape).
*/
function isAuggieModelFailure(
resolution: AuggieModelResolution
): resolution is Extract<AuggieModelResolution, { ok: false }> {
return !resolution.ok;
}
/**
* Validate + resolve the requested model against the registry allowlist.
* Rejects flag-smuggling (leading "-") and any id not declared in the registry.
@@ -384,7 +397,7 @@ export class AuggieExecutor extends BaseExecutor {
await initAuggieModels(signal);
// Argument-injection defense: never forward an unvalidated model into the argv.
const modelResolution = resolveAuggieModel(model);
if (!modelResolution.ok) {
if (isAuggieModelFailure(modelResolution)) {
const response = wantsStream
? buildAuggieSseError(modelResolution.error)
: errorResponse(400, modelResolution.error);

View File

@@ -8,6 +8,26 @@ import {
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
import { stripUnsupportedParams } from "../translator/paramSupport.ts";
/**
* What a Copilot credential refresh resolves to.
*
* `refreshCredentials()` returns one of three shapes — the raw GitHub token pair, that pair
* plus the minted Copilot token, or the Copilot token folded onto the existing credentials —
* and `null` when nothing could be refreshed. Left to inference, the union of those literals
* is narrower than the contract subclasses actually honor: `GheCopilotExecutor` carries a
* wider `providerSpecificData` (it also records the enterprise proxy URL) and omits
* `expiresIn`, which made a valid override fail with TS2416. Every field is therefore
* optional here — callers already treat them as such.
*/
export interface RefreshedCopilotCredentials {
accessToken?: string;
refreshToken?: string;
expiresIn?: number;
copilotToken?: string;
copilotTokenExpiresAt?: string | number;
providerSpecificData?: Record<string, unknown>;
}
export class GithubExecutor extends BaseExecutor {
constructor() {
super("github", PROVIDERS.github);
@@ -367,7 +387,7 @@ export class GithubExecutor extends BaseExecutor {
}
}
async refreshCredentials(credentials, log) {
async refreshCredentials(credentials, log): Promise<RefreshedCopilotCredentials | null> {
let copilotResult = await this.refreshCopilotToken(credentials.accessToken, log);
if (!copilotResult && credentials.refreshToken) {

View File

@@ -253,7 +253,7 @@ export class HailuoWebExecutor extends BaseExecutor {
super("hailuo-web", { id: "hailuo-web", baseUrl: BASE_URL });
}
private buildHeaders(token: string, yy: string): Record<string, string> {
private buildStreamHeaders(token: string, yy: string): Record<string, string> {
return {
Accept: "text/event-stream",
"User-Agent": USER_AGENT,
@@ -357,7 +357,7 @@ export class HailuoWebExecutor extends BaseExecutor {
form.set("chatID", chatID);
form.set("searchMode", "0");
return { url: `${BASE_URL}${pathAndQuery}`, headers: this.buildHeaders(token, yy), form };
return { url: `${BASE_URL}${pathAndQuery}`, headers: this.buildStreamHeaders(token, yy), form };
}
/** POST the signed multipart request and normalize both network + upstream-status errors. */

View File

@@ -73,11 +73,13 @@ export class LMArenaExecutor extends BaseExecutor {
super("lmarena", { format: "openai", ...providerConfig });
}
protected buildUrl(_model: string, _credentials: unknown): string {
// Public to match BaseExecutor.buildUrl — a subclass may widen visibility but not
// narrow it. This was masked behind the buildHeaders TS2416 until that one cleared.
buildUrl(_model: string, _credentials: unknown): string {
return LMARENA_STREAM_URL;
}
protected buildHeaders(
protected buildRequestHeaders(
_model: string,
credentials: unknown,
_body: unknown
@@ -91,7 +93,7 @@ export class LMArenaExecutor extends BaseExecutor {
return headers;
}
protected transformRequest(body: unknown, model: string, credentials?: unknown): unknown {
transformRequest(body: unknown, model: string, credentials?: unknown): unknown {
const openaiBody = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const messages = Array.isArray(openaiBody.messages)
? (openaiBody.messages as OpenAIMessage[])
@@ -115,7 +117,7 @@ export class LMArenaExecutor extends BaseExecutor {
async execute(input: ExecuteInput) {
const { model, body, stream, credentials, signal, log } = input;
const url = this.buildUrl(model, credentials);
const headers = this.buildHeaders(model, credentials, body);
const headers = this.buildRequestHeaders(model, credentials, body);
const cookie = readLMArenaCookie(credentials);
if (!cookie) {

View File

@@ -100,6 +100,12 @@ interface AccountState {
expiresAt: number;
cooldownUntil: number;
consecutiveFails: number;
/**
* #3837/#5521: the account's resolved proxy, or `null` when none is configured.
* Always present (never `undefined`) so callers can read `acct.proxy` directly —
* syncAccountsFromCredentials() writes it on every account on every sync.
*/
proxy: AccountProxyConfig["proxy"];
}
function parseJwtExp(jwt: string): number {

View File

@@ -906,6 +906,15 @@ function buildWsUrl(authorization: string, requestId: string): string {
type GraphqlResult = { ok: true } | { ok: false; error: string };
/**
* Narrows the failure arm. Under this workspace's `strictNullChecks: false`, a
* boolean-literal discriminant narrows the positive branch but not the negative one, so
* `!result.ok` leaves the full union and `.error` is unreachable to the checker.
*/
function isGraphqlFailure(result: GraphqlResult): result is Extract<GraphqlResult, { ok: false }> {
return !result.ok;
}
async function graphqlPost(
docId: string,
variables: Record<string, unknown>,
@@ -1315,7 +1324,7 @@ export class MuseSparkWebExecutor extends BaseExecutor {
"Warmup",
signal
);
if (!warmupResult.ok) {
if (isGraphqlFailure(warmupResult)) {
evictContinuationIfNeeded(cached, continuationCacheKey);
log?.error?.("MUSE-SPARK-WEB", `Warmup failed: ${warmupResult.error}`);
return errorResult(502, warmupResult.error, "meta_ai_warmup_failed", {}, body);
@@ -1329,7 +1338,7 @@ export class MuseSparkWebExecutor extends BaseExecutor {
"Mode switch",
signal
);
if (!modeResult.ok) {
if (isGraphqlFailure(modeResult)) {
evictContinuationIfNeeded(cached, continuationCacheKey);
log?.error?.("MUSE-SPARK-WEB", `Mode switch failed: ${modeResult.error}`);
return errorResult(502, modeResult.error, "meta_ai_mode_switch_failed", {}, body);

View File

@@ -606,13 +606,26 @@ export class NotionWebExecutor extends BaseExecutor {
const reqHeaders = buildNotionExecuteHeaders({ cookie, spaceId, userId, agent });
type NotionAttempt =
| { ok: true; finalText: string; reqBody: Record<string, unknown> }
| {
ok: false;
errorResult: ReturnType<typeof makeErrorResult>;
retryable: boolean;
reqBody: Record<string, unknown>;
};
// `strictNullChecks: false` narrows a boolean-literal discriminant on the positive
// branch only, so `!attempt.ok` leaves the full union and the failure-only fields are
// unreachable to the checker. An explicit predicate narrows under those settings.
const isFailedAttempt = (
attempt: NotionAttempt
): attempt is Extract<NotionAttempt, { ok: false }> => !attempt.ok;
const runOnce = async (opts: {
createThread: boolean;
threadId: string;
}): Promise<
| { ok: true; finalText: string; reqBody: Record<string, unknown> }
| { ok: false; errorResult: ReturnType<typeof makeErrorResult>; retryable: boolean; reqBody: Record<string, unknown> }
> => {
}): Promise<NotionAttempt> => {
const transcript = buildNotionTranscript(messages, {
notionModel: notionCodename || undefined,
spaceId,
@@ -681,13 +694,13 @@ export class NotionWebExecutor extends BaseExecutor {
let attempt = await runOnce({ createThread, threadId });
// One automatic retry for transient Notion faults — same threadId, never create again
if (!attempt.ok && attempt.retryable) {
if (isFailedAttempt(attempt) && attempt.retryable) {
const delayMs = process.env.NODE_ENV === "test" || process.env.VITEST ? 20 : 700 + Math.floor(Math.random() * 400);
await new Promise((r) => setTimeout(r, delayMs));
attempt = await runOnce({ createThread: false, threadId });
}
if (!attempt.ok) {
if (isFailedAttempt(attempt)) {
return attempt.errorResult;
}

View File

@@ -338,13 +338,11 @@ export class OpencodeExecutor extends BaseExecutor {
) {
delete (modifiedBody as Record<string, unknown>).client_metadata;
}
if (
modifiedBody &&
typeof modifiedBody === "object" &&
Array.isArray(modifiedBody.tools) &&
modifiedBody.tools.length > 128
) {
modifiedBody.tools = modifiedBody.tools.slice(0, 128);
if (modifiedBody && typeof modifiedBody === "object" && !Array.isArray(modifiedBody)) {
const mb = modifiedBody as Record<string, unknown>;
if (Array.isArray(mb.tools) && mb.tools.length > 128) {
mb.tools = mb.tools.slice(0, 128);
}
}
if (modifiedBody && typeof modifiedBody === "object" && !Array.isArray(modifiedBody)) {
const mb = modifiedBody as Record<string, unknown>;

View File

@@ -94,7 +94,7 @@ export class QwenWebExecutor extends BaseExecutor {
super("qwen-web", { id: "qwen-web", baseUrl: BASE_URL });
}
private buildHeaders(
private buildApiHeaders(
token: string,
cookieHeader: string,
chatId?: string
@@ -139,7 +139,7 @@ export class QwenWebExecutor extends BaseExecutor {
try {
const newChatRes = await fetch(CHATS_NEW_URL, {
method: "POST",
headers: this.buildHeaders(token, cookieHeader),
headers: this.buildApiHeaders(token, cookieHeader),
body: JSON.stringify({
title: "New Chat",
models: [modelId],
@@ -186,7 +186,7 @@ export class QwenWebExecutor extends BaseExecutor {
try {
upstream = await fetch(completionUrl, {
method: "POST",
headers: this.buildHeaders(token, cookieHeader, chatId),
headers: this.buildApiHeaders(token, cookieHeader, chatId),
body: JSON.stringify(msgPayload),
signal,
});
@@ -251,7 +251,7 @@ export class QwenWebExecutor extends BaseExecutor {
},
}),
url: completionUrl,
headers: this.buildHeaders(token, cookieHeader, chatId),
headers: this.buildApiHeaders(token, cookieHeader, chatId),
transformedBody: msgPayload,
};
}

View File

@@ -248,8 +248,16 @@ function buildGetChatMessageRequest(
// ─── gRPC-web framing ────────────────────────────────────────────────────────
/** Wrap a protobuf message in a 5-byte gRPC-web data frame. */
function grpcWebFrame(payload: Uint8Array): Uint8Array {
/**
* Wrap a protobuf message in a 5-byte gRPC-web data frame.
*
* Returns `Uint8Array<ArrayBuffer>`, not bare `Uint8Array`: the frame is
* allocated with `new Uint8Array(length)`, which is always ArrayBuffer-backed,
* and only that narrower form satisfies `BodyInit` at the `fetch` call below.
* Bare `Uint8Array` widens to `Uint8Array<ArrayBufferLike>`, which admits
* `SharedArrayBuffer` and is therefore rejected as a request body.
*/
function grpcWebFrame(payload: Uint8Array): Uint8Array<ArrayBuffer> {
const frame = new Uint8Array(5 + payload.length);
frame[0] = 0x00; // compression flag: no compression
const view = new DataView(frame.buffer);

View File

@@ -117,8 +117,18 @@ function createErrorChunk(model: string, message: string): Record<string, unknow
};
}
/**
* The single controller capability these SSE helpers use. They only ever enqueue —
* never `close()`, never read `desiredSize` — so typing them by that one method lets
* the same code serve both stream kinds. The wider
* `ReadableStreamDefaultController` annotation rejected every call site, because the
* helpers are driven from a TransformStream and `TransformStreamDefaultController`
* has no `close()`.
*/
type SseEnqueueTarget = Pick<ReadableStreamDefaultController<Uint8Array>, "enqueue">;
function enqueueSseObject(
controller: ReadableStreamDefaultController<Uint8Array>,
controller: SseEnqueueTarget,
encoder: TextEncoder,
chunk: unknown
): void {
@@ -202,7 +212,7 @@ function wrapZedCompletionStream(
let buffer = "";
let done = false;
const finish = (controller: ReadableStreamDefaultController<Uint8Array>) => {
const finish = (controller: SseEnqueueTarget) => {
if (done) return;
const finalChunk = convertProviderEvent(provider, null, state);
enqueueSseObject(controller, encoder, finalChunk);
@@ -210,7 +220,7 @@ function wrapZedCompletionStream(
done = true;
};
const processLine = (line: string, controller: ReadableStreamDefaultController<Uint8Array>) => {
const processLine = (line: string, controller: SseEnqueueTarget) => {
if (done) return;
const payload = unwrapZedLine(line);
if (!payload) return;

View File

@@ -72,11 +72,16 @@ function getUploadedFileName(file: Blob & { name?: unknown }): string {
return typeof file.name === "string" && file.name.length > 0 ? file.name : "audio.wav";
}
/**
* `body` is `Uint8Array<ArrayBuffer>`, not bare `Uint8Array`: `new Uint8Array(n)`
* is always ArrayBuffer-backed, and only that narrower form satisfies `BodyInit`
* (the bare type widens to `ArrayBufferLike`, which admits `SharedArrayBuffer`).
*/
export async function buildMultipartBody(
file: Blob & { name?: unknown },
fields: Record<string, string>,
fileFieldName = "file"
): Promise<{ body: Uint8Array; contentType: string }> {
): Promise<{ body: Uint8Array<ArrayBuffer>; contentType: string }> {
const boundary = "----OmniRouteAudioBoundary" + Date.now().toString(36);
const parts: Uint8Array[] = [];
const encoder = new TextEncoder();

View File

@@ -104,15 +104,26 @@ interface DesignerWebRequestConfig {
pollIntervalMs: number;
}
/**
* Outcome of request validation. String-discriminated rather than `ok: boolean`
* because `open-sse` compiles with `strictNullChecks: false`, where a
* boolean-literal discriminant narrows the positive branch but leaves the
* negative one as the full union — so `if (!resolved.ok)` would not expose
* `status`/`error`. All three unions in this file shared that root cause.
*/
type DesignerWebRequestResolution =
| { state: "resolved"; config: DesignerWebRequestConfig }
| { state: "invalid"; status: number; error: string };
/** Validates the request and resolves auth + poll timing. Returns an error status/message on failure. */
function resolveDesignerWebRequest(
body: { prompt?: unknown; size?: unknown; timeout_ms?: unknown; poll_interval_ms?: unknown },
credentials: { apiKey?: string; accessToken?: string }
): { ok: true; config: DesignerWebRequestConfig } | { ok: false; status: number; error: string } {
): DesignerWebRequestResolution {
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
if (!prompt) {
return {
ok: false,
state: "invalid",
status: 400,
error: "Prompt is required for Microsoft Designer image generation",
};
@@ -120,7 +131,11 @@ function resolveDesignerWebRequest(
const accessToken = credentials?.apiKey || credentials?.accessToken;
if (!accessToken) {
return { ok: false, status: 401, error: "Microsoft Designer credentials missing access_token" };
return {
state: "invalid",
status: 401,
error: "Microsoft Designer credentials missing access_token",
};
}
const timeoutMs = normalizePositiveNumber(
@@ -139,7 +154,7 @@ function resolveDesignerWebRequest(
);
return {
ok: true,
state: "resolved",
config: {
prompt,
accessToken,
@@ -151,10 +166,20 @@ function resolveDesignerWebRequest(
};
}
type DesignerWebStepResult =
| { done: false; waitMs: number }
| { done: true; success: true; imageUrls: string[] }
| { done: true; success: false; status: number; error: string };
type DesignerWebPending = { state: "pending"; waitMs: number };
type DesignerWebReady = { state: "ready"; imageUrls: string[] };
type DesignerWebFailed = { state: "failed"; status: number; error: string };
/** One poll cycle: still working, finished with images, or finished with an error. */
type DesignerWebStepResult = DesignerWebPending | DesignerWebReady | DesignerWebFailed;
/**
* What the poll loop hands back. Deliberately excludes the pending arm — the
* loop either returns a terminal step or synthesizes a 504, and never surfaces
* `pending` to its caller. The previous signature admitted it, which is why
* `outcome.success` did not exist on every member of that union.
*/
type DesignerWebOutcome = DesignerWebReady | DesignerWebFailed;
/** Runs one submit/poll fetch cycle and classifies the outcome. */
async function stepDesignerWebPoll(
@@ -167,23 +192,29 @@ async function stepDesignerWebPoll(
const resp = await fetchImpl(baseUrl, { method: "POST", headers, body: formBody });
if (!resp.ok) {
return { done: true, success: false, status: resp.status, error: sanitizeErrorMessage(await resp.text()) };
return {
state: "failed",
status: resp.status,
error: sanitizeErrorMessage(await resp.text()),
};
}
const parsed = parseDesignerWebResponse(await resp.json());
if (parsed.status === "ready") {
return { done: true, success: true, imageUrls: parsed.imageUrls };
return { state: "ready", imageUrls: parsed.imageUrls };
}
if (parsed.status === "empty") {
return {
done: true,
success: false,
state: "failed",
status: 502,
error: "Microsoft Designer response did not contain image data or polling metadata",
};
}
return { done: false, waitMs: Math.min(parsed.pollIntervalMs ?? pollIntervalMs, pollIntervalMs) };
return {
state: "pending",
waitMs: Math.min(parsed.pollIntervalMs ?? pollIntervalMs, pollIntervalMs),
};
}
/** Drives the submit-then-poll loop to completion, timeout, or a terminal error. */
@@ -192,7 +223,7 @@ async function runDesignerWebPollLoop(
config: DesignerWebRequestConfig,
fetchImpl: typeof fetch,
log?: { info?: (...args: unknown[]) => void }
): Promise<DesignerWebStepResult | { done: true; success: false; status: 504; error: string }> {
): Promise<DesignerWebOutcome> {
const deadline = Date.now() + config.timeoutMs;
let attempt = 0;
@@ -205,14 +236,13 @@ async function runDesignerWebPollLoop(
config.pollIntervalMs,
fetchImpl
);
if (step.done) return step;
if (step.state !== "pending") return step;
log?.info?.("IMAGE", `designer-web pending, poll #${attempt} in ${step.waitMs}ms`);
await new Promise((resolve) => setTimeout(resolve, step.waitMs));
}
return {
done: true,
success: false,
state: "failed",
status: 504,
error: "Microsoft Designer image generation timed out waiting for a result",
};
@@ -237,13 +267,19 @@ export async function handleDesignerWebImageGeneration({
}) {
const startTime = Date.now();
const resolved = resolveDesignerWebRequest(body, credentials);
if (!resolved.ok) {
return saveImageErrorResult({ provider, model, status: resolved.status, startTime, error: resolved.error });
if (resolved.state === "invalid") {
return saveImageErrorResult({
provider,
model,
status: resolved.status,
startTime,
error: resolved.error,
});
}
try {
const outcome = await runDesignerWebPollLoop(providerConfig.baseUrl, resolved.config, fetchImpl, log);
if (outcome.success) {
if (outcome.state === "ready") {
return saveImageSuccessResult({
provider,
model,

View File

@@ -16,6 +16,25 @@ import { resolveProxyForConnection } from "@/lib/db/settings";
import { runWithProxyContext } from "../utils/proxyFetch.ts";
import * as log from "@/sse/utils/logger";
/** A document as the Cohere-compatible rerank API accepts it: a bare string or `{ text }`. */
type RerankDocument = string | { text?: string };
/**
* The caller-side request fields the response adapters need to rebuild Cohere's
* `results[]`. Upstreams either omit the documents entirely (DeepInfra returns
* bare scores) or echo them in their own shape (Voyage returns plain strings),
* so document text is always synthesized from the caller's originals — and
* `top_n` / `return_documents` are honored here rather than upstream.
*
* Every field is optional: the parameter defaults to `{}` and the unit suites
* call it with subsets (e.g. `{ documents: ["a", "b"] }`).
*/
interface RerankResponseOptions {
documents?: RerankDocument[];
return_documents?: boolean;
top_n?: number;
}
/**
* Build authorization header for a rerank provider
*/
@@ -76,7 +95,11 @@ function buildAuthHeader(providerConfig, token) {
/**
* Transform response from provider-specific formats back to Cohere format
*/
/* @testonly */ export function transformResponseFromProvider(providerConfig, data, options = {}) {
/* @testonly */ export function transformResponseFromProvider(
providerConfig,
data,
options: RerankResponseOptions = {}
) {
if (providerConfig.format === "nvidia") {
return {
id: data.id != null ? String(data.id) : `rerank-${Date.now()}`,

View File

@@ -2709,26 +2709,20 @@ export async function handleComboChat({
: "";
const msg = (lastError || "All combo models unavailable") + comboErrorSummary;
if (earliestRetryAfter && isRetryAfterEligibleStatus(status)) {
// Cooldown-aware retry: instead of crystallizing the 429/503, wait out
// a SHORT transient cooldown and re-run the whole set loop. Guarded by
// the helper (quota_exhausted/auth/not-found excluded, ceiling,
// attempts, budget). MAX_GLOBAL_ATTEMPTS still bounds total dispatches.
// Available to ALL combo strategies (not just quota-share).
if (comboCooldownWaitEnabled && status === 429) {
// ONE decision path for EVERY strategy. The reason that drives the
// wait is always the target's REAL model-lockout reason, resolved
// through the helper's allow-list — never a hardcoded literal.
//
// SECURITY (see comboCooldownRetry.ts header): the allow-list is the
// PRIMARY barrier and `maxWaitMs` only the SECOND one. Hardcoding
// reason:"rate_limit" for non-quota-share strategies would drop the
// primary barrier and leave only the ceiling — which does NOT cover a
// quota_exhausted lock carrying a SHORT upstream retry-after (e.g.
// 3s < maxWaitMs): the combo would wait, redispatch against a model
// locked until midnight, and burn the attempt. Model lockouts are
// recorded for all strategies (recordModelLockoutFailure above is not
// gated on quota-share), so the real reason is always available.
// Cooldown-aware retry: instead of crystallizing a transient failure, wait
// out a SHORT cooldown and re-run the whole set loop. Guarded by the helper
// (quota_exhausted/auth/not-found excluded, ceiling, attempts, budget).
// MAX_GLOBAL_ATTEMPTS still bounds total dispatches. Available to ALL combo
// strategies when enabled — entry is driven by earliestRetryAfter + the
// real model-lockout reason, NOT by whichever target last overwrote
// `status` (a later 403 must not skip the allow-list check for an earlier
// 429's retry-after hint). SECURITY (see comboCooldownRetry.ts header): the
// allow-list is the PRIMARY barrier and `maxWaitMs` only the SECOND one.
// Hardcoding reason:"rate_limit" would drop the primary barrier and leave
// only the ceiling — which does NOT cover a quota_exhausted lock carrying a
// SHORT upstream retry-after. Model lockouts are recorded for all strategies,
// so the real reason is always available.
if (comboCooldownWaitEnabled && earliestRetryAfter) {
const decision: ResolveComboCooldownDecisionResult = resolveComboCooldownWaitDecision({
targets: orderedTargets,
earliestRetryAfter,
@@ -2766,6 +2760,12 @@ export async function handleComboChat({
return dispatchWithCooldownRetry();
}
}
// Retry-after decoration is separate from the wait decision above: only
// rate-limit-class final statuses may carry a `(reset after ...)` suffix
// (see unavailableRetryGate.ts — do not stitch a peer target's window onto
// a config-class status like 403/422).
if (earliestRetryAfter && isRetryAfterEligibleStatus(status)) {
const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter));
log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`);
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);

View File

@@ -38,18 +38,22 @@ export const DEFAULT_COMBO_TARGET_TIMEOUT_MS = 120_000;
export const COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS = 10_000;
/**
* Whether a combo's cooldown-aware wait+retry (#7360) engages for this request: only
* "quota-share" and "auto" strategies wait out a short transient cooldown instead of
* crystallizing a 429 into a combo-level failure, and only when the operator has the
* feature enabled. Shared by combo.ts (to decide whether to wait) and comboSetup.ts (to
* size the per-target timeout floor so it doesn't cut the wait off early — see
* Whether a combo's cooldown-aware wait+retry (#7360 / #7301) engages for this request.
* When the operator has the feature enabled, EVERY combo strategy waits out a short
* transient cooldown instead of crystallizing a 429 into a combo-level failure.
* Shared by combo.ts (to decide whether to wait) and comboSetup.ts (to size the
* per-target timeout floor so it doesn't cut the wait off early — see
* resolveComboTargetTimeoutMsForCombo below).
*
* `strategy` is retained in the signature for call-site clarity; eligibility is
* strategy-agnostic (the wait path in combo.ts already uses the real model-lockout
* reason for every strategy).
*/
export function isComboCooldownWaitEligible(
strategy: string,
_strategy: string,
comboCooldownWait: Pick<ComboCooldownWaitSettings, "enabled">
): boolean {
return (strategy === "quota-share" || strategy === "auto") && comboCooldownWait.enabled;
return comboCooldownWait.enabled;
}
/**

View File

@@ -702,10 +702,10 @@ function ComboCooldownWaitCard({
setDraft(value);
}, [value]);
const title = t("resilienceComboCooldownWaitTitle") || "Quota-share combo cooldown wait";
const title = t("resilienceComboCooldownWaitTitle") || "Combo cooldown wait";
const desc =
t("resilienceComboCooldownWaitDesc") ||
"For quota-share combos only: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.";
"For all combo strategies: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.";
return (
<Card className="p-6">
@@ -738,7 +738,7 @@ function ComboCooldownWaitCard({
label={t("resilienceEnableServerWait") || "Enabled"}
description={
t("resilienceComboCooldownWaitToggleDesc") ||
"Quota-share combos only; never waits on quota_exhausted."
"All combo strategies; never waits on quota_exhausted."
}
checked={draft.enabled}
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}

View File

@@ -7458,9 +7458,9 @@
"resilienceEnableServerWaitDesc": "When enabled, OmniRoute waits for the first cooldown to expire and retries automatically.",
"resilienceMaxAttempts": "Maximum attempts",
"resilienceMaxWaitPerAttempt": "Maximum wait per attempt",
"resilienceComboCooldownWaitTitle": "Quota-share combo cooldown wait",
"resilienceComboCooldownWaitDesc": "For quota-share combos only: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.",
"resilienceComboCooldownWaitToggleDesc": "Quota-share combos only; never waits on quota_exhausted.",
"resilienceComboCooldownWaitTitle": "Combo cooldown wait",
"resilienceComboCooldownWaitDesc": "For all combo strategies: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.",
"resilienceComboCooldownWaitToggleDesc": "All combo strategies; never waits on quota_exhausted.",
"resilienceComboCooldownMaxWaitMs": "Maximum wait per attempt",
"resilienceComboCooldownBudgetMs": "Total wait budget",
"resilienceQuotaShareConcurrencyTitle": "Quota-share per-connection concurrency",

View File

@@ -1,9 +1,30 @@
import { BaseGuardrail, type GuardrailContext, type GuardrailExecutionResult } from "./base";
import {
BaseGuardrail,
type GuardrailContext,
type GuardrailExecutionResult,
type GuardrailResult,
} from "./base";
import { PIIMaskerGuardrail } from "./piiMasker";
import { PromptInjectionGuardrail } from "./promptInjection";
import { VisionBridgeGuardrail } from "./visionBridge";
import { CredentialMaskerGuardrail } from "./credentialMasker";
/**
* `preCall`/`postCall` may legitimately return nothing — that is the documented
* "no change" signal, alongside `{}` and `{ block: false }`
* (`docs/security/GUARDRAILS.md`), and `CredentialMaskerGuardrail` still declares
* the `| void` arm.
*
* `void` is not a value the checker lets us inspect, so neither `result?.block`
* nor a truthiness test compiles against `GuardrailResult | void`. Funnel the
* return through `unknown` once, here, and hand the dispatch loops a plain
* optional. Runtime behavior is unchanged: a guardrail that returns nothing
* still yields `undefined` and is still treated as "passed".
*/
function asGuardrailResult(raw: unknown): GuardrailResult<unknown> | undefined {
return raw && typeof raw === "object" ? (raw as GuardrailResult<unknown>) : undefined;
}
type HeadersLike = Headers | Record<string, unknown> | null | undefined;
function isHeaderStore(headers: HeadersLike): headers is Headers {
@@ -128,7 +149,7 @@ export class GuardrailRegistry {
}
try {
const result = await guardrail.preCall(currentPayload, context);
const result = asGuardrailResult(await guardrail.preCall(currentPayload, context));
const modified = result?.modifiedPayload !== undefined;
const meta = result?.meta || null;
@@ -201,7 +222,7 @@ export class GuardrailRegistry {
}
try {
const result = await guardrail.postCall(currentResponse, context);
const result = asGuardrailResult(await guardrail.postCall(currentResponse, context));
const modified = result?.modifiedResponse !== undefined;
const meta = result?.meta || null;

View File

@@ -95,8 +95,8 @@ export const DEFAULT_RESILIENCE_SETTINGS: ResilienceSettings = {
},
// Wait at most 90s for a single short transient cooldown (covers Gemini-class
// TPM/RPM windows, which report ~60s retry-after live — #7360), at most 5
// redispatch cycles, never more than 300s (5 min) total. Active for
// quota-share and auto combos, and only for transient (non quota_exhausted)
// redispatch cycles, never more than 300s (5 min) total. Active for every
// combo strategy when enabled, and only for transient (non quota_exhausted)
// reasons.
comboCooldownWait: {
enabled: true,

View File

@@ -65,12 +65,12 @@ export interface WaitForCooldownSettings {
}
/**
* Quota-share combo cooldown-aware retry (Variante A). A quota-share (`qtSd/…`)
* combo that would crystallize a 429 `model_cooldown` for a SHORT transient
* cooldown waits it out and re-dispatches instead. Guards (gating + the
* `quota_exhausted`/auth/not-found exclusions) live in
* open-sse/services/combo/comboCooldownRetry.ts; `maxWaitMs`/`maxAttempts`/
* `budgetMs` bound a single wait, the retry cycles, and the total wait time.
* Combo cooldown-aware retry. When enabled, any combo strategy that would
* crystallize a 429 `model_cooldown` for a SHORT transient cooldown waits it
* out and re-dispatches instead. Guards (gating + the `quota_exhausted`/auth/
* not-found exclusions) live in open-sse/services/combo/comboCooldownRetry.ts;
* `maxWaitMs`/`maxAttempts`/`budgetMs` bound a single wait, the retry cycles,
* and the total wait time.
*/
export interface ComboCooldownWaitSettings {
enabled: boolean;

View File

@@ -293,6 +293,34 @@ test("resolveAuggieModel resolves every pre-v0.32.0 alias to an allowlisted v0.3
}
});
// The failure arm is read through an `isAuggieModelFailure()` type predicate — this
// workspace compiles with `strictNullChecks: false`, where `!result.ok` narrows the
// success branch but not the failure one, so `.error` was unreachable to the checker.
// The predicate is a one-liner whose control flow inverts on a stray `!`, so pin both
// arms: the failure arm must carry a usable message, the success arm must not claim one.
test("resolveAuggieModel's failure arm carries a readable error message", () => {
const result = resolveAuggieModel("totally-not-a-real-model");
assert.equal(result.ok, false);
if (!result.ok) {
assert.equal(typeof result.error, "string");
assert.ok(result.error.length > 0, "the rejection must explain itself");
assert.match(result.error, /Unknown Auggie model/);
}
});
test("resolveAuggieModel's success arm resolves a model and reports no error", () => {
const result = resolveAuggieModel("haiku4.5");
assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.model, "haiku4.5");
assert.equal(
(result as unknown as { error?: unknown }).error,
undefined,
"the success arm must not carry a failure message"
);
}
});
// ─── execute(): model allowlist (argument-injection defense) ──────────────
test("execute() rejects a model not in the registry allowlist and never spawns", async () => {

View File

@@ -14,6 +14,9 @@ const {
const { createComboSchema, updateComboDefaultsSchema } =
await import("../../src/shared/validation/schemas.ts");
const { MAX_TIMER_TIMEOUT_MS } = await import("../../src/shared/utils/runtimeTimeouts.ts");
const { ROUTING_STRATEGY_VALUES, INTERNAL_ROUTING_STRATEGY_VALUES } =
await import("../../src/shared/constants/routingStrategies.ts");
const ALL_COMBO_STRATEGIES = [...ROUTING_STRATEGY_VALUES, ...INTERNAL_ROUTING_STRATEGY_VALUES];
test("getDefaultComboConfig returns a fresh copy of the defaults", () => {
const first = getDefaultComboConfig();
@@ -321,40 +324,32 @@ test("resolveComboTargetTimeoutMs falls back to the saner combo default when uns
assert.equal(resolveComboTargetTimeoutMs({}, 0, 120000), 0);
});
// #7360 follow-up: a "default" auto-strategy combo hitting Gemini TPM/RPM on both
// targets waits out cooldowns for up to comboCooldownWait.budgetMs (default 130s), but
// DEFAULT_COMBO_TARGET_TIMEOUT_MS (120s) is shorter — the per-target timeout was cutting
// the wait off early and returning a synthetic 524 instead of letting the wait finish.
test("isComboCooldownWaitEligible only engages for quota-share/auto with the feature enabled", () => {
assert.equal(isComboCooldownWaitEligible("auto", { enabled: true }), true);
assert.equal(isComboCooldownWaitEligible("quota-share", { enabled: true }), true);
assert.equal(isComboCooldownWaitEligible("auto", { enabled: false }), false);
assert.equal(isComboCooldownWaitEligible("fill-first", { enabled: true }), false);
assert.equal(isComboCooldownWaitEligible("priority", { enabled: true }), false);
// #7360 / #7301: any strategy with comboCooldownWait enabled waits out cooldowns for up
// to comboCooldownWait.budgetMs, so the per-target timeout floor must cover that budget
// (DEFAULT_COMBO_TARGET_TIMEOUT_MS alone would cut a long wait short into a 524).
test("isComboCooldownWaitEligible engages for every strategy when the feature is enabled", () => {
for (const strategy of ALL_COMBO_STRATEGIES) {
assert.equal(isComboCooldownWaitEligible(strategy, { enabled: true }), true);
assert.equal(isComboCooldownWaitEligible(strategy, { enabled: false }), false);
}
});
test("resolveComboTargetTimeoutMsForCombo raises the floor to cover the cooldown-wait budget for eligible strategies", () => {
test("resolveComboTargetTimeoutMsForCombo raises the floor to cover the cooldown-wait budget when enabled", () => {
const comboCooldownWait = { enabled: true, budgetMs: 130000 };
const floor = 130000 + COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS;
const disabled = { enabled: false, budgetMs: 130000 };
// Wait-eligible strategy: floor is budget + buffer (150s), not the 120s default.
// Feature on: floor is budget + buffer for every strategy; off: 120s default.
for (const strategy of ALL_COMBO_STRATEGIES) {
assert.equal(
resolveComboTargetTimeoutMsForCombo({}, 600000, "auto", comboCooldownWait),
130000 + COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS
resolveComboTargetTimeoutMsForCombo({}, 600000, strategy, comboCooldownWait),
floor
);
assert.equal(
resolveComboTargetTimeoutMsForCombo({}, 600000, "quota-share", comboCooldownWait),
130000 + COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS
);
// Not wait-eligible (wrong strategy, or feature disabled): unchanged 120s default.
assert.equal(
resolveComboTargetTimeoutMsForCombo({}, 600000, "fill-first", comboCooldownWait),
DEFAULT_COMBO_TARGET_TIMEOUT_MS
);
assert.equal(
resolveComboTargetTimeoutMsForCombo({}, 600000, "auto", { enabled: false, budgetMs: 130000 }),
resolveComboTargetTimeoutMsForCombo({}, 600000, strategy, disabled),
DEFAULT_COMBO_TARGET_TIMEOUT_MS
);
}
// A small budget below the 120s default never lowers the floor.
assert.equal(
@@ -364,12 +359,7 @@ test("resolveComboTargetTimeoutMsForCombo raises the floor to cover the cooldown
// Explicit per-combo targetTimeoutMs still wins over the derived floor.
assert.equal(
resolveComboTargetTimeoutMsForCombo(
{ targetTimeoutMs: 45000 },
600000,
"auto",
comboCooldownWait
),
resolveComboTargetTimeoutMsForCombo({ targetTimeoutMs: 45000 }, 600000, "auto", comboCooldownWait),
45000
);

View File

@@ -9,8 +9,8 @@
* 2. A 403 (quota_exhausted, locked until midnight) → NO wait, the 403/429 is
* propagated immediately (the helper's critical exclusion).
* 3. Client abort DURING the wait → 499 "Request aborted".
* 4. strategy="priority" (non quota-share) → unchanged: the 429 is propagated
* immediately with NO wait.
* 4. strategy="priority" (and every other strategy) also waits out a SHORT
* transient 429 when comboCooldownWait is enabled — same decision helper.
* 5. comboCooldownWait disabled in settings → unchanged: 429 propagated, no wait.
*
* The waits use a real (short) cooldown so the real setTimeout in

View 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);
});

View 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");
});

View File

@@ -30,7 +30,7 @@ const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9
type LMArenaExecutorTestAccess = {
provider: string;
buildUrl: (model: string, credentials: unknown) => string;
buildHeaders: (model: string, credentials: unknown, body: unknown) => Record<string, string>;
buildRequestHeaders: (model: string, credentials: unknown, body: unknown) => Record<string, string>;
transformRequest: (
body: unknown,
model: string,
@@ -133,7 +133,7 @@ describe("LMArena Executor", () => {
it("builds headers with cookie", () => {
const executor = new LMArenaExecutor();
const headers = access(executor).buildHeaders("gpt-4", { cookie: "session=abc123" }, {});
const headers = access(executor).buildRequestHeaders("gpt-4", { cookie: "session=abc123" }, {});
assert.ok(headers.Cookie, "Should have Cookie header");
assert.equal(headers.Cookie, "session=abc123");
assert.equal(headers["Content-Type"], "application/json");
@@ -142,7 +142,7 @@ describe("LMArena Executor", () => {
it("builds headers without cookie when not provided", () => {
const executor = new LMArenaExecutor();
const headers = access(executor).buildHeaders("gpt-4", {}, {});
const headers = access(executor).buildRequestHeaders("gpt-4", {}, {});
assert.ok(!headers.Cookie, "Should not have Cookie header when no cookie provided");
});
@@ -151,19 +151,19 @@ describe("LMArena Executor", () => {
const ex = access(executor);
// Direct cookie field
let headers = ex.buildHeaders("gpt-4", { cookie: "session=abc" }, {});
let headers = ex.buildRequestHeaders("gpt-4", { cookie: "session=abc" }, {});
assert.equal(headers.Cookie, "session=abc");
// apiKey field (dashboard form)
headers = ex.buildHeaders("gpt-4", { apiKey: "session=def" }, {});
headers = ex.buildRequestHeaders("gpt-4", { apiKey: "session=def" }, {});
assert.equal(headers.Cookie, "session=def");
// providerSpecificData.cookie
headers = ex.buildHeaders("gpt-4", { providerSpecificData: { cookie: "session=ghi" } }, {});
headers = ex.buildRequestHeaders("gpt-4", { providerSpecificData: { cookie: "session=ghi" } }, {});
assert.equal(headers.Cookie, "session=ghi");
// Priority: direct > apiKey > providerSpecificData
headers = ex.buildHeaders("gpt-4", { cookie: "session=abc", apiKey: "session=def" }, {});
headers = ex.buildRequestHeaders("gpt-4", { cookie: "session=abc", apiKey: "session=def" }, {});
assert.equal(headers.Cookie, "session=abc");
});

View File

@@ -22,7 +22,7 @@ import { getWebSessionCredentialRequirement } from "../../src/shared/providers/w
function cookieHeaderFor(credentials: unknown): string | undefined {
const executor = new LMArenaExecutor();
const headers = (executor as any).buildHeaders("gpt-4", credentials, {});
const headers = (executor as any).buildRequestHeaders("gpt-4", credentials, {});
return headers.Cookie;
}

View 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");
});

View 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");
});

View File

@@ -20,9 +20,8 @@
* The non-quota-share (priority) scenario was UPDATED for the "universal
* cooldown-aware retry" change: comboCooldownWait is no longer gated on
* `strategy === "quota-share"` — every combo strategy now waits out a SHORT
* transient 429 and re-dispatches (using a "rate_limit" reason and the
* earliest retry-after hint directly, since non quota-share strategies have no
* per-connection model-lockout tracking to consult). It used to assert the
* transient 429 and re-dispatches via the same resolveComboCooldownWaitDecision
* path (real model-lockout reason + allow-list). It used to assert the
* OPPOSITE (immediate propagation, no wait) — that assertion is now testing
* dead behavior, so it was rewritten to assert the new intended behavior
* instead of being deleted or weakened.
@@ -147,11 +146,8 @@ test("non quota-share (priority): short 429 cooldown → waits and re-dispatches
const handleSingleModel = async () => {
calls += 1;
// 1st dispatch: transient 429 with a short retry-after hint. 2nd dispatch
// (after the universal cooldown wait): success. Priority combos have no
// per-connection model-lockout tracking, so this exercises the
// `shouldWaitForComboCooldown({ reason: "rate_limit", ... })` path fed
// directly by the earliest retry-after hint (not resolveComboCooldownWaitDecision's
// per-target lock lookup, which stays quota-share-only).
// (after the universal cooldown wait): success. Exercises the shared
// resolveComboCooldownWaitDecision path (real model-lockout reason).
return calls === 1 ? rateLimitResponse(429) : okResponse();
};
@@ -182,18 +178,18 @@ test("non quota-share (priority): a quota_exhausted lock drives the decision wit
//
// Barrier 1 = the reason allow-list. Barrier 2 = the maxWaitMs ceiling.
// This scenario is engineered so ONLY barrier 1 can stop the wait:
// - modelLockout.errorCodes is [403] ONLY, so model-a's 429 crystallizes
// status 429 (the sole status that opens the cooldown-wait branch) WITHOUT
// recording a competing `rate_limit` lock.
// - modelLockout.errorCodes is [403] ONLY, so model-a's 429 contributes a
// retry-after hint (opens the cooldown-wait decision) WITHOUT recording a
// competing `rate_limit` lock.
// - model-b's 403 records the only lock in play: `quota_exhausted`. It is
// therefore the lock resolveComboCooldownWaitDecision picks, so its reason
// is what drives the decision.
// - The resulting wait is SHORT (well under maxWaitMs=5000), so barrier 2
// lets it through. Only the allow-list can reject it.
//
// With the reason hardcoded to "rate_limit" (as the non-quota-share path did
// before), barrier 1 is gone and this exact input waits + redispatches against
// a quota-exhausted model — verified: the same test yields 6 dispatches.
// With the reason hardcoded to "rate_limit", barrier 1 is gone and this exact
// input waits + redispatches against a quota-exhausted model — verified: the
// same test yields 6 dispatches.
const calls: string[] = [];
const handleSingleModel = async (_body: unknown, modelStr: string) => {
calls.push(modelStr);
@@ -222,7 +218,10 @@ test("non quota-share (priority): a quota_exhausted lock drives the decision wit
allCombos: null,
});
assert.equal(res.status, 429, "the crystallized 429 must be propagated, not retried");
// Final status is the last target's 403 (aggregation last-wins). The security
// invariant is that the allow-list rejected the wait — not that a peer 429 is
// re-surfaced as the HTTP status.
assert.equal(res.status, 403, "quota_exhausted target status crystallizes; wait must not redispatch");
// Deterministic proof (no wall-clock dependency, so it cannot flake under
// CI-runner contention): each target is dispatched EXACTLY ONCE. Had the wait
// fired, the whole set loop would re-run — maxAttempts=2 within the 8s budget

View File

@@ -95,10 +95,10 @@ const resilienceTabSettingsKeys = [
];
const quotaShareResilienceSettingsMessages = {
resilienceComboCooldownWaitTitle: "Quota-share combo cooldown wait",
resilienceComboCooldownWaitTitle: "Combo cooldown wait",
resilienceComboCooldownWaitDesc:
"For quota-share combos only: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.",
resilienceComboCooldownWaitToggleDesc: "Quota-share combos only; never waits on quota_exhausted.",
"For all combo strategies: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.",
resilienceComboCooldownWaitToggleDesc: "All combo strategies; never waits on quota_exhausted.",
resilienceComboCooldownMaxWaitMs: "Maximum wait per attempt",
resilienceComboCooldownBudgetMs: "Total wait budget",
resilienceQuotaShareConcurrencyTitle: "Quota-share per-connection concurrency",

View 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`);
}
});

View 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");
});
});