mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Compare commits
10 Commits
4053e2314a
...
647afe327b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
647afe327b | ||
|
|
d628105503 | ||
|
|
2cf462c1a1 | ||
|
|
0312fe2a40 | ||
|
|
fd739ab008 | ||
|
|
1bd1af2f48 | ||
|
|
06326a3c80 | ||
|
|
a51cd06322 | ||
|
|
f60c9fe542 | ||
|
|
1d7878d857 |
@@ -0,0 +1 @@
|
||||
- **chore(quality):** rebaseline complexity (2130→2183) and cognitive-complexity (951→968) across the v3.8.49 `/merge-prs` queue-drain. The first step (→2169/→956) cleared inherited base drift measured on the pristine release tip (the PR→release fast-path never ratchets these). The second step (→2183/→968) absorbs the aggregate own-growth of the 41-PR merge-train (each PR under-ceiling individually; the combined batch adds +14/+12). Owner-approved (2026-07-25); structural shrink tracked in [#3501](https://github.com/diegosouzapw/OmniRoute/issues/3501).
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.",
|
||||
"_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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -8,6 +8,26 @@ import {
|
||||
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
|
||||
import { stripUnsupportedParams } from "../translator/paramSupport.ts";
|
||||
|
||||
/**
|
||||
* What a Copilot credential refresh resolves to.
|
||||
*
|
||||
* `refreshCredentials()` returns one of three shapes — the raw GitHub token pair, that pair
|
||||
* plus the minted Copilot token, or the Copilot token folded onto the existing credentials —
|
||||
* and `null` when nothing could be refreshed. Left to inference, the union of those literals
|
||||
* is narrower than the contract subclasses actually honor: `GheCopilotExecutor` carries a
|
||||
* wider `providerSpecificData` (it also records the enterprise proxy URL) and omits
|
||||
* `expiresIn`, which made a valid override fail with TS2416. Every field is therefore
|
||||
* optional here — callers already treat them as such.
|
||||
*/
|
||||
export interface RefreshedCopilotCredentials {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
copilotToken?: string;
|
||||
copilotTokenExpiresAt?: string | number;
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class GithubExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("github", PROVIDERS.github);
|
||||
@@ -367,7 +387,7 @@ export class GithubExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshCredentials(credentials, log) {
|
||||
async refreshCredentials(credentials, log): Promise<RefreshedCopilotCredentials | null> {
|
||||
let copilotResult = await this.refreshCopilotToken(credentials.accessToken, log);
|
||||
|
||||
if (!copilotResult && credentials.refreshToken) {
|
||||
|
||||
@@ -253,7 +253,7 @@ export class HailuoWebExecutor extends BaseExecutor {
|
||||
super("hailuo-web", { id: "hailuo-web", baseUrl: BASE_URL });
|
||||
}
|
||||
|
||||
private buildHeaders(token: string, yy: string): Record<string, string> {
|
||||
private buildStreamHeaders(token: string, yy: string): Record<string, string> {
|
||||
return {
|
||||
Accept: "text/event-stream",
|
||||
"User-Agent": USER_AGENT,
|
||||
@@ -357,7 +357,7 @@ export class HailuoWebExecutor extends BaseExecutor {
|
||||
form.set("chatID", chatID);
|
||||
form.set("searchMode", "0");
|
||||
|
||||
return { url: `${BASE_URL}${pathAndQuery}`, headers: this.buildHeaders(token, yy), form };
|
||||
return { url: `${BASE_URL}${pathAndQuery}`, headers: this.buildStreamHeaders(token, yy), form };
|
||||
}
|
||||
|
||||
/** POST the signed multipart request and normalize both network + upstream-status errors. */
|
||||
|
||||
@@ -73,11 +73,13 @@ export class LMArenaExecutor extends BaseExecutor {
|
||||
super("lmarena", { format: "openai", ...providerConfig });
|
||||
}
|
||||
|
||||
protected buildUrl(_model: string, _credentials: unknown): string {
|
||||
// Public to match BaseExecutor.buildUrl — a subclass may widen visibility but not
|
||||
// narrow it. This was masked behind the buildHeaders TS2416 until that one cleared.
|
||||
buildUrl(_model: string, _credentials: unknown): string {
|
||||
return LMARENA_STREAM_URL;
|
||||
}
|
||||
|
||||
protected buildHeaders(
|
||||
protected buildRequestHeaders(
|
||||
_model: string,
|
||||
credentials: unknown,
|
||||
_body: unknown
|
||||
@@ -91,7 +93,7 @@ export class LMArenaExecutor extends BaseExecutor {
|
||||
return headers;
|
||||
}
|
||||
|
||||
protected transformRequest(body: unknown, model: string, credentials?: unknown): unknown {
|
||||
transformRequest(body: unknown, model: string, credentials?: unknown): unknown {
|
||||
const openaiBody = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||
const messages = Array.isArray(openaiBody.messages)
|
||||
? (openaiBody.messages as OpenAIMessage[])
|
||||
@@ -115,7 +117,7 @@ export class LMArenaExecutor extends BaseExecutor {
|
||||
async execute(input: ExecuteInput) {
|
||||
const { model, body, stream, credentials, signal, log } = input;
|
||||
const url = this.buildUrl(model, credentials);
|
||||
const headers = this.buildHeaders(model, credentials, body);
|
||||
const headers = this.buildRequestHeaders(model, credentials, body);
|
||||
const cookie = readLMArenaCookie(credentials);
|
||||
|
||||
if (!cookie) {
|
||||
|
||||
@@ -100,6 +100,12 @@ interface AccountState {
|
||||
expiresAt: number;
|
||||
cooldownUntil: number;
|
||||
consecutiveFails: number;
|
||||
/**
|
||||
* #3837/#5521: the account's resolved proxy, or `null` when none is configured.
|
||||
* Always present (never `undefined`) so callers can read `acct.proxy` directly —
|
||||
* syncAccountsFromCredentials() writes it on every account on every sync.
|
||||
*/
|
||||
proxy: AccountProxyConfig["proxy"];
|
||||
}
|
||||
|
||||
function parseJwtExp(jwt: string): number {
|
||||
|
||||
@@ -906,6 +906,15 @@ function buildWsUrl(authorization: string, requestId: string): string {
|
||||
|
||||
type GraphqlResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Narrows the failure arm. Under this workspace's `strictNullChecks: false`, a
|
||||
* boolean-literal discriminant narrows the positive branch but not the negative one, so
|
||||
* `!result.ok` leaves the full union and `.error` is unreachable to the checker.
|
||||
*/
|
||||
function isGraphqlFailure(result: GraphqlResult): result is Extract<GraphqlResult, { ok: false }> {
|
||||
return !result.ok;
|
||||
}
|
||||
|
||||
async function graphqlPost(
|
||||
docId: string,
|
||||
variables: Record<string, unknown>,
|
||||
@@ -1315,7 +1324,7 @@ export class MuseSparkWebExecutor extends BaseExecutor {
|
||||
"Warmup",
|
||||
signal
|
||||
);
|
||||
if (!warmupResult.ok) {
|
||||
if (isGraphqlFailure(warmupResult)) {
|
||||
evictContinuationIfNeeded(cached, continuationCacheKey);
|
||||
log?.error?.("MUSE-SPARK-WEB", `Warmup failed: ${warmupResult.error}`);
|
||||
return errorResult(502, warmupResult.error, "meta_ai_warmup_failed", {}, body);
|
||||
@@ -1329,7 +1338,7 @@ export class MuseSparkWebExecutor extends BaseExecutor {
|
||||
"Mode switch",
|
||||
signal
|
||||
);
|
||||
if (!modeResult.ok) {
|
||||
if (isGraphqlFailure(modeResult)) {
|
||||
evictContinuationIfNeeded(cached, continuationCacheKey);
|
||||
log?.error?.("MUSE-SPARK-WEB", `Mode switch failed: ${modeResult.error}`);
|
||||
return errorResult(502, modeResult.error, "meta_ai_mode_switch_failed", {}, body);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -94,7 +94,7 @@ export class QwenWebExecutor extends BaseExecutor {
|
||||
super("qwen-web", { id: "qwen-web", baseUrl: BASE_URL });
|
||||
}
|
||||
|
||||
private buildHeaders(
|
||||
private buildApiHeaders(
|
||||
token: string,
|
||||
cookieHeader: string,
|
||||
chatId?: string
|
||||
@@ -139,7 +139,7 @@ export class QwenWebExecutor extends BaseExecutor {
|
||||
try {
|
||||
const newChatRes = await fetch(CHATS_NEW_URL, {
|
||||
method: "POST",
|
||||
headers: this.buildHeaders(token, cookieHeader),
|
||||
headers: this.buildApiHeaders(token, cookieHeader),
|
||||
body: JSON.stringify({
|
||||
title: "New Chat",
|
||||
models: [modelId],
|
||||
@@ -186,7 +186,7 @@ export class QwenWebExecutor extends BaseExecutor {
|
||||
try {
|
||||
upstream = await fetch(completionUrl, {
|
||||
method: "POST",
|
||||
headers: this.buildHeaders(token, cookieHeader, chatId),
|
||||
headers: this.buildApiHeaders(token, cookieHeader, chatId),
|
||||
body: JSON.stringify(msgPayload),
|
||||
signal,
|
||||
});
|
||||
@@ -251,7 +251,7 @@ export class QwenWebExecutor extends BaseExecutor {
|
||||
},
|
||||
}),
|
||||
url: completionUrl,
|
||||
headers: this.buildHeaders(token, cookieHeader, chatId),
|
||||
headers: this.buildApiHeaders(token, cookieHeader, chatId),
|
||||
transformedBody: msgPayload,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -248,8 +248,16 @@ function buildGetChatMessageRequest(
|
||||
|
||||
// ─── gRPC-web framing ────────────────────────────────────────────────────────
|
||||
|
||||
/** Wrap a protobuf message in a 5-byte gRPC-web data frame. */
|
||||
function grpcWebFrame(payload: Uint8Array): Uint8Array {
|
||||
/**
|
||||
* Wrap a protobuf message in a 5-byte gRPC-web data frame.
|
||||
*
|
||||
* Returns `Uint8Array<ArrayBuffer>`, not bare `Uint8Array`: the frame is
|
||||
* allocated with `new Uint8Array(length)`, which is always ArrayBuffer-backed,
|
||||
* and only that narrower form satisfies `BodyInit` at the `fetch` call below.
|
||||
* Bare `Uint8Array` widens to `Uint8Array<ArrayBufferLike>`, which admits
|
||||
* `SharedArrayBuffer` and is therefore rejected as a request body.
|
||||
*/
|
||||
function grpcWebFrame(payload: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
const frame = new Uint8Array(5 + payload.length);
|
||||
frame[0] = 0x00; // compression flag: no compression
|
||||
const view = new DataView(frame.buffer);
|
||||
|
||||
@@ -117,8 +117,18 @@ function createErrorChunk(model: string, message: string): Record<string, unknow
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The single controller capability these SSE helpers use. They only ever enqueue —
|
||||
* never `close()`, never read `desiredSize` — so typing them by that one method lets
|
||||
* the same code serve both stream kinds. The wider
|
||||
* `ReadableStreamDefaultController` annotation rejected every call site, because the
|
||||
* helpers are driven from a TransformStream and `TransformStreamDefaultController`
|
||||
* has no `close()`.
|
||||
*/
|
||||
type SseEnqueueTarget = Pick<ReadableStreamDefaultController<Uint8Array>, "enqueue">;
|
||||
|
||||
function enqueueSseObject(
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
controller: SseEnqueueTarget,
|
||||
encoder: TextEncoder,
|
||||
chunk: unknown
|
||||
): void {
|
||||
@@ -202,7 +212,7 @@ function wrapZedCompletionStream(
|
||||
let buffer = "";
|
||||
let done = false;
|
||||
|
||||
const finish = (controller: ReadableStreamDefaultController<Uint8Array>) => {
|
||||
const finish = (controller: SseEnqueueTarget) => {
|
||||
if (done) return;
|
||||
const finalChunk = convertProviderEvent(provider, null, state);
|
||||
enqueueSseObject(controller, encoder, finalChunk);
|
||||
@@ -210,7 +220,7 @@ function wrapZedCompletionStream(
|
||||
done = true;
|
||||
};
|
||||
|
||||
const processLine = (line: string, controller: ReadableStreamDefaultController<Uint8Array>) => {
|
||||
const processLine = (line: string, controller: SseEnqueueTarget) => {
|
||||
if (done) return;
|
||||
const payload = unwrapZedLine(line);
|
||||
if (!payload) return;
|
||||
|
||||
@@ -72,11 +72,16 @@ function getUploadedFileName(file: Blob & { name?: unknown }): string {
|
||||
return typeof file.name === "string" && file.name.length > 0 ? file.name : "audio.wav";
|
||||
}
|
||||
|
||||
/**
|
||||
* `body` is `Uint8Array<ArrayBuffer>`, not bare `Uint8Array`: `new Uint8Array(n)`
|
||||
* is always ArrayBuffer-backed, and only that narrower form satisfies `BodyInit`
|
||||
* (the bare type widens to `ArrayBufferLike`, which admits `SharedArrayBuffer`).
|
||||
*/
|
||||
export async function buildMultipartBody(
|
||||
file: Blob & { name?: unknown },
|
||||
fields: Record<string, string>,
|
||||
fileFieldName = "file"
|
||||
): Promise<{ body: Uint8Array; contentType: string }> {
|
||||
): Promise<{ body: Uint8Array<ArrayBuffer>; contentType: string }> {
|
||||
const boundary = "----OmniRouteAudioBoundary" + Date.now().toString(36);
|
||||
const parts: Uint8Array[] = [];
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
@@ -104,15 +104,26 @@ interface DesignerWebRequestConfig {
|
||||
pollIntervalMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of request validation. String-discriminated rather than `ok: boolean`
|
||||
* because `open-sse` compiles with `strictNullChecks: false`, where a
|
||||
* boolean-literal discriminant narrows the positive branch but leaves the
|
||||
* negative one as the full union — so `if (!resolved.ok)` would not expose
|
||||
* `status`/`error`. All three unions in this file shared that root cause.
|
||||
*/
|
||||
type DesignerWebRequestResolution =
|
||||
| { state: "resolved"; config: DesignerWebRequestConfig }
|
||||
| { state: "invalid"; status: number; error: string };
|
||||
|
||||
/** Validates the request and resolves auth + poll timing. Returns an error status/message on failure. */
|
||||
function resolveDesignerWebRequest(
|
||||
body: { prompt?: unknown; size?: unknown; timeout_ms?: unknown; poll_interval_ms?: unknown },
|
||||
credentials: { apiKey?: string; accessToken?: string }
|
||||
): { ok: true; config: DesignerWebRequestConfig } | { ok: false; status: number; error: string } {
|
||||
): DesignerWebRequestResolution {
|
||||
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
||||
if (!prompt) {
|
||||
return {
|
||||
ok: false,
|
||||
state: "invalid",
|
||||
status: 400,
|
||||
error: "Prompt is required for Microsoft Designer image generation",
|
||||
};
|
||||
@@ -120,7 +131,11 @@ function resolveDesignerWebRequest(
|
||||
|
||||
const accessToken = credentials?.apiKey || credentials?.accessToken;
|
||||
if (!accessToken) {
|
||||
return { ok: false, status: 401, error: "Microsoft Designer credentials missing access_token" };
|
||||
return {
|
||||
state: "invalid",
|
||||
status: 401,
|
||||
error: "Microsoft Designer credentials missing access_token",
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutMs = normalizePositiveNumber(
|
||||
@@ -139,7 +154,7 @@ function resolveDesignerWebRequest(
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
state: "resolved",
|
||||
config: {
|
||||
prompt,
|
||||
accessToken,
|
||||
@@ -151,10 +166,20 @@ function resolveDesignerWebRequest(
|
||||
};
|
||||
}
|
||||
|
||||
type DesignerWebStepResult =
|
||||
| { done: false; waitMs: number }
|
||||
| { done: true; success: true; imageUrls: string[] }
|
||||
| { done: true; success: false; status: number; error: string };
|
||||
type DesignerWebPending = { state: "pending"; waitMs: number };
|
||||
type DesignerWebReady = { state: "ready"; imageUrls: string[] };
|
||||
type DesignerWebFailed = { state: "failed"; status: number; error: string };
|
||||
|
||||
/** One poll cycle: still working, finished with images, or finished with an error. */
|
||||
type DesignerWebStepResult = DesignerWebPending | DesignerWebReady | DesignerWebFailed;
|
||||
|
||||
/**
|
||||
* What the poll loop hands back. Deliberately excludes the pending arm — the
|
||||
* loop either returns a terminal step or synthesizes a 504, and never surfaces
|
||||
* `pending` to its caller. The previous signature admitted it, which is why
|
||||
* `outcome.success` did not exist on every member of that union.
|
||||
*/
|
||||
type DesignerWebOutcome = DesignerWebReady | DesignerWebFailed;
|
||||
|
||||
/** Runs one submit/poll fetch cycle and classifies the outcome. */
|
||||
async function stepDesignerWebPoll(
|
||||
@@ -167,23 +192,29 @@ async function stepDesignerWebPoll(
|
||||
const resp = await fetchImpl(baseUrl, { method: "POST", headers, body: formBody });
|
||||
|
||||
if (!resp.ok) {
|
||||
return { done: true, success: false, status: resp.status, error: sanitizeErrorMessage(await resp.text()) };
|
||||
return {
|
||||
state: "failed",
|
||||
status: resp.status,
|
||||
error: sanitizeErrorMessage(await resp.text()),
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseDesignerWebResponse(await resp.json());
|
||||
|
||||
if (parsed.status === "ready") {
|
||||
return { done: true, success: true, imageUrls: parsed.imageUrls };
|
||||
return { state: "ready", imageUrls: parsed.imageUrls };
|
||||
}
|
||||
if (parsed.status === "empty") {
|
||||
return {
|
||||
done: true,
|
||||
success: false,
|
||||
state: "failed",
|
||||
status: 502,
|
||||
error: "Microsoft Designer response did not contain image data or polling metadata",
|
||||
};
|
||||
}
|
||||
return { done: false, waitMs: Math.min(parsed.pollIntervalMs ?? pollIntervalMs, pollIntervalMs) };
|
||||
return {
|
||||
state: "pending",
|
||||
waitMs: Math.min(parsed.pollIntervalMs ?? pollIntervalMs, pollIntervalMs),
|
||||
};
|
||||
}
|
||||
|
||||
/** Drives the submit-then-poll loop to completion, timeout, or a terminal error. */
|
||||
@@ -192,7 +223,7 @@ async function runDesignerWebPollLoop(
|
||||
config: DesignerWebRequestConfig,
|
||||
fetchImpl: typeof fetch,
|
||||
log?: { info?: (...args: unknown[]) => void }
|
||||
): Promise<DesignerWebStepResult | { done: true; success: false; status: 504; error: string }> {
|
||||
): Promise<DesignerWebOutcome> {
|
||||
const deadline = Date.now() + config.timeoutMs;
|
||||
let attempt = 0;
|
||||
|
||||
@@ -205,14 +236,13 @@ async function runDesignerWebPollLoop(
|
||||
config.pollIntervalMs,
|
||||
fetchImpl
|
||||
);
|
||||
if (step.done) return step;
|
||||
if (step.state !== "pending") return step;
|
||||
log?.info?.("IMAGE", `designer-web pending, poll #${attempt} in ${step.waitMs}ms`);
|
||||
await new Promise((resolve) => setTimeout(resolve, step.waitMs));
|
||||
}
|
||||
|
||||
return {
|
||||
done: true,
|
||||
success: false,
|
||||
state: "failed",
|
||||
status: 504,
|
||||
error: "Microsoft Designer image generation timed out waiting for a result",
|
||||
};
|
||||
@@ -237,13 +267,19 @@ export async function handleDesignerWebImageGeneration({
|
||||
}) {
|
||||
const startTime = Date.now();
|
||||
const resolved = resolveDesignerWebRequest(body, credentials);
|
||||
if (!resolved.ok) {
|
||||
return saveImageErrorResult({ provider, model, status: resolved.status, startTime, error: resolved.error });
|
||||
if (resolved.state === "invalid") {
|
||||
return saveImageErrorResult({
|
||||
provider,
|
||||
model,
|
||||
status: resolved.status,
|
||||
startTime,
|
||||
error: resolved.error,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const outcome = await runDesignerWebPollLoop(providerConfig.baseUrl, resolved.config, fetchImpl, log);
|
||||
if (outcome.success) {
|
||||
if (outcome.state === "ready") {
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model,
|
||||
|
||||
@@ -16,6 +16,25 @@ import { resolveProxyForConnection } from "@/lib/db/settings";
|
||||
import { runWithProxyContext } from "../utils/proxyFetch.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
|
||||
/** A document as the Cohere-compatible rerank API accepts it: a bare string or `{ text }`. */
|
||||
type RerankDocument = string | { text?: string };
|
||||
|
||||
/**
|
||||
* The caller-side request fields the response adapters need to rebuild Cohere's
|
||||
* `results[]`. Upstreams either omit the documents entirely (DeepInfra returns
|
||||
* bare scores) or echo them in their own shape (Voyage returns plain strings),
|
||||
* so document text is always synthesized from the caller's originals — and
|
||||
* `top_n` / `return_documents` are honored here rather than upstream.
|
||||
*
|
||||
* Every field is optional: the parameter defaults to `{}` and the unit suites
|
||||
* call it with subsets (e.g. `{ documents: ["a", "b"] }`).
|
||||
*/
|
||||
interface RerankResponseOptions {
|
||||
documents?: RerankDocument[];
|
||||
return_documents?: boolean;
|
||||
top_n?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authorization header for a rerank provider
|
||||
*/
|
||||
@@ -76,7 +95,11 @@ function buildAuthHeader(providerConfig, token) {
|
||||
/**
|
||||
* Transform response from provider-specific formats back to Cohere format
|
||||
*/
|
||||
/* @testonly */ export function transformResponseFromProvider(providerConfig, data, options = {}) {
|
||||
/* @testonly */ export function transformResponseFromProvider(
|
||||
providerConfig,
|
||||
data,
|
||||
options: RerankResponseOptions = {}
|
||||
) {
|
||||
if (providerConfig.format === "nvidia") {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : `rerank-${Date.now()}`,
|
||||
|
||||
@@ -2709,63 +2709,63 @@ export async function handleComboChat({
|
||||
: "";
|
||||
const msg = (lastError || "All combo models unavailable") + comboErrorSummary;
|
||||
|
||||
if (earliestRetryAfter && isRetryAfterEligibleStatus(status)) {
|
||||
// Cooldown-aware retry: instead of crystallizing the 429/503, wait out
|
||||
// a SHORT transient cooldown and re-run the whole set loop. Guarded by
|
||||
// the helper (quota_exhausted/auth/not-found excluded, ceiling,
|
||||
// attempts, budget). MAX_GLOBAL_ATTEMPTS still bounds total dispatches.
|
||||
// Available to ALL combo strategies (not just quota-share).
|
||||
if (comboCooldownWaitEnabled && status === 429) {
|
||||
// ONE decision path for EVERY strategy. The reason that drives the
|
||||
// wait is always the target's REAL model-lockout reason, resolved
|
||||
// through the helper's allow-list — never a hardcoded literal.
|
||||
//
|
||||
// SECURITY (see comboCooldownRetry.ts header): the allow-list is the
|
||||
// PRIMARY barrier and `maxWaitMs` only the SECOND one. Hardcoding
|
||||
// reason:"rate_limit" for non-quota-share strategies would drop the
|
||||
// primary barrier and leave only the ceiling — which does NOT cover a
|
||||
// quota_exhausted lock carrying a SHORT upstream retry-after (e.g.
|
||||
// 3s < maxWaitMs): the combo would wait, redispatch against a model
|
||||
// locked until midnight, and burn the attempt. Model lockouts are
|
||||
// recorded for all strategies (recordModelLockoutFailure above is not
|
||||
// gated on quota-share), so the real reason is always available.
|
||||
const decision: ResolveComboCooldownDecisionResult = resolveComboCooldownWaitDecision({
|
||||
targets: orderedTargets,
|
||||
earliestRetryAfter,
|
||||
attempt: comboCooldownAttempt,
|
||||
budgetLeftMs: comboCooldownBudgetLeftMs,
|
||||
settings: resilienceSettings.comboCooldownWait,
|
||||
// Key each lookup on the TARGET's own model: quota-share combos are
|
||||
// single-model/multi-account (so this is identical to the previous
|
||||
// orderedTargets[0] behavior), but heterogeneous combos carry a
|
||||
// different model per target.
|
||||
lookupLock: (provider, connectionId, target) => {
|
||||
const rawModel = parseModel(target?.modelStr ?? "").model || "";
|
||||
if (!rawModel) return null;
|
||||
return getModelLockoutInfo(provider, connectionId, rawModel);
|
||||
},
|
||||
computeWaitMs: (retryAfter) => computeClosestRetryAfter(retryAfter).waitMs,
|
||||
});
|
||||
// Cooldown-aware retry: instead of crystallizing a transient failure, wait
|
||||
// out a SHORT cooldown and re-run the whole set loop. Guarded by the helper
|
||||
// (quota_exhausted/auth/not-found excluded, ceiling, attempts, budget).
|
||||
// MAX_GLOBAL_ATTEMPTS still bounds total dispatches. Available to ALL combo
|
||||
// strategies when enabled — entry is driven by earliestRetryAfter + the
|
||||
// real model-lockout reason, NOT by whichever target last overwrote
|
||||
// `status` (a later 403 must not skip the allow-list check for an earlier
|
||||
// 429's retry-after hint). SECURITY (see comboCooldownRetry.ts header): the
|
||||
// allow-list is the PRIMARY barrier and `maxWaitMs` only the SECOND one.
|
||||
// Hardcoding reason:"rate_limit" would drop the primary barrier and leave
|
||||
// only the ceiling — which does NOT cover a quota_exhausted lock carrying a
|
||||
// SHORT upstream retry-after. Model lockouts are recorded for all strategies,
|
||||
// so the real reason is always available.
|
||||
if (comboCooldownWaitEnabled && earliestRetryAfter) {
|
||||
const decision: ResolveComboCooldownDecisionResult = resolveComboCooldownWaitDecision({
|
||||
targets: orderedTargets,
|
||||
earliestRetryAfter,
|
||||
attempt: comboCooldownAttempt,
|
||||
budgetLeftMs: comboCooldownBudgetLeftMs,
|
||||
settings: resilienceSettings.comboCooldownWait,
|
||||
// Key each lookup on the TARGET's own model: quota-share combos are
|
||||
// single-model/multi-account (so this is identical to the previous
|
||||
// orderedTargets[0] behavior), but heterogeneous combos carry a
|
||||
// different model per target.
|
||||
lookupLock: (provider, connectionId, target) => {
|
||||
const rawModel = parseModel(target?.modelStr ?? "").model || "";
|
||||
if (!rawModel) return null;
|
||||
return getModelLockoutInfo(provider, connectionId, rawModel);
|
||||
},
|
||||
computeWaitMs: (retryAfter) => computeClosestRetryAfter(retryAfter).waitMs,
|
||||
});
|
||||
|
||||
if (decision.wait) {
|
||||
log.info(
|
||||
"COMBO",
|
||||
`${strategy} cooldown wait: ${msg} — waiting ${Math.ceil(
|
||||
decision.waitMs / 1000
|
||||
)}s (reason=${decision.reason ?? "?"}) then retrying (attempt ${
|
||||
comboCooldownAttempt + 1
|
||||
}/${resilienceSettings.comboCooldownWait.maxAttempts})`
|
||||
);
|
||||
const completed = await waitForCooldownAwareRetry(decision.waitMs, signal);
|
||||
if (!completed) {
|
||||
log.info("COMBO", `${strategy} cooldown wait aborted by client disconnect`);
|
||||
return errorResponse(499, "Request aborted");
|
||||
}
|
||||
comboCooldownAttempt += 1;
|
||||
comboCooldownBudgetLeftMs = Math.max(0, comboCooldownBudgetLeftMs - decision.waitMs);
|
||||
return dispatchWithCooldownRetry();
|
||||
if (decision.wait) {
|
||||
log.info(
|
||||
"COMBO",
|
||||
`${strategy} cooldown wait: ${msg} — waiting ${Math.ceil(
|
||||
decision.waitMs / 1000
|
||||
)}s (reason=${decision.reason ?? "?"}) then retrying (attempt ${
|
||||
comboCooldownAttempt + 1
|
||||
}/${resilienceSettings.comboCooldownWait.maxAttempts})`
|
||||
);
|
||||
const completed = await waitForCooldownAwareRetry(decision.waitMs, signal);
|
||||
if (!completed) {
|
||||
log.info("COMBO", `${strategy} cooldown wait aborted by client disconnect`);
|
||||
return errorResponse(499, "Request aborted");
|
||||
}
|
||||
comboCooldownAttempt += 1;
|
||||
comboCooldownBudgetLeftMs = Math.max(0, comboCooldownBudgetLeftMs - decision.waitMs);
|
||||
return dispatchWithCooldownRetry();
|
||||
}
|
||||
}
|
||||
|
||||
// Retry-after decoration is separate from the wait decision above: only
|
||||
// rate-limit-class final statuses may carry a `(reset after ...)` suffix
|
||||
// (see unavailableRetryGate.ts — do not stitch a peer target's window onto
|
||||
// a config-class status like 403/422).
|
||||
if (earliestRetryAfter && isRetryAfterEligibleStatus(status)) {
|
||||
const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter));
|
||||
log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`);
|
||||
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }))}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -14,6 +14,9 @@ const {
|
||||
const { createComboSchema, updateComboDefaultsSchema } =
|
||||
await import("../../src/shared/validation/schemas.ts");
|
||||
const { MAX_TIMER_TIMEOUT_MS } = await import("../../src/shared/utils/runtimeTimeouts.ts");
|
||||
const { ROUTING_STRATEGY_VALUES, INTERNAL_ROUTING_STRATEGY_VALUES } =
|
||||
await import("../../src/shared/constants/routingStrategies.ts");
|
||||
const ALL_COMBO_STRATEGIES = [...ROUTING_STRATEGY_VALUES, ...INTERNAL_ROUTING_STRATEGY_VALUES];
|
||||
|
||||
test("getDefaultComboConfig returns a fresh copy of the defaults", () => {
|
||||
const first = getDefaultComboConfig();
|
||||
@@ -321,40 +324,32 @@ test("resolveComboTargetTimeoutMs falls back to the saner combo default when uns
|
||||
assert.equal(resolveComboTargetTimeoutMs({}, 0, 120000), 0);
|
||||
});
|
||||
|
||||
// #7360 follow-up: a "default" auto-strategy combo hitting Gemini TPM/RPM on both
|
||||
// targets waits out cooldowns for up to comboCooldownWait.budgetMs (default 130s), but
|
||||
// DEFAULT_COMBO_TARGET_TIMEOUT_MS (120s) is shorter — the per-target timeout was cutting
|
||||
// the wait off early and returning a synthetic 524 instead of letting the wait finish.
|
||||
test("isComboCooldownWaitEligible only engages for quota-share/auto with the feature enabled", () => {
|
||||
assert.equal(isComboCooldownWaitEligible("auto", { enabled: true }), true);
|
||||
assert.equal(isComboCooldownWaitEligible("quota-share", { enabled: true }), true);
|
||||
assert.equal(isComboCooldownWaitEligible("auto", { enabled: false }), false);
|
||||
assert.equal(isComboCooldownWaitEligible("fill-first", { enabled: true }), false);
|
||||
assert.equal(isComboCooldownWaitEligible("priority", { enabled: true }), false);
|
||||
// #7360 / #7301: any strategy with comboCooldownWait enabled waits out cooldowns for up
|
||||
// to comboCooldownWait.budgetMs, so the per-target timeout floor must cover that budget
|
||||
// (DEFAULT_COMBO_TARGET_TIMEOUT_MS alone would cut a long wait short into a 524).
|
||||
test("isComboCooldownWaitEligible engages for every strategy when the feature is enabled", () => {
|
||||
for (const strategy of ALL_COMBO_STRATEGIES) {
|
||||
assert.equal(isComboCooldownWaitEligible(strategy, { enabled: true }), true);
|
||||
assert.equal(isComboCooldownWaitEligible(strategy, { enabled: false }), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveComboTargetTimeoutMsForCombo raises the floor to cover the cooldown-wait budget for eligible strategies", () => {
|
||||
test("resolveComboTargetTimeoutMsForCombo raises the floor to cover the cooldown-wait budget when enabled", () => {
|
||||
const comboCooldownWait = { enabled: true, budgetMs: 130000 };
|
||||
const floor = 130000 + COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS;
|
||||
const disabled = { enabled: false, budgetMs: 130000 };
|
||||
|
||||
// Wait-eligible strategy: floor is budget + buffer (150s), not the 120s default.
|
||||
assert.equal(
|
||||
resolveComboTargetTimeoutMsForCombo({}, 600000, "auto", comboCooldownWait),
|
||||
130000 + COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS
|
||||
);
|
||||
assert.equal(
|
||||
resolveComboTargetTimeoutMsForCombo({}, 600000, "quota-share", comboCooldownWait),
|
||||
130000 + COMBO_TARGET_TIMEOUT_WAIT_BUFFER_MS
|
||||
);
|
||||
|
||||
// Not wait-eligible (wrong strategy, or feature disabled): unchanged 120s default.
|
||||
assert.equal(
|
||||
resolveComboTargetTimeoutMsForCombo({}, 600000, "fill-first", comboCooldownWait),
|
||||
DEFAULT_COMBO_TARGET_TIMEOUT_MS
|
||||
);
|
||||
assert.equal(
|
||||
resolveComboTargetTimeoutMsForCombo({}, 600000, "auto", { enabled: false, budgetMs: 130000 }),
|
||||
DEFAULT_COMBO_TARGET_TIMEOUT_MS
|
||||
);
|
||||
// Feature on: floor is budget + buffer for every strategy; off: 120s default.
|
||||
for (const strategy of ALL_COMBO_STRATEGIES) {
|
||||
assert.equal(
|
||||
resolveComboTargetTimeoutMsForCombo({}, 600000, strategy, comboCooldownWait),
|
||||
floor
|
||||
);
|
||||
assert.equal(
|
||||
resolveComboTargetTimeoutMsForCombo({}, 600000, strategy, disabled),
|
||||
DEFAULT_COMBO_TARGET_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
// A small budget below the 120s default never lowers the floor.
|
||||
assert.equal(
|
||||
@@ -364,12 +359,7 @@ test("resolveComboTargetTimeoutMsForCombo raises the floor to cover the cooldown
|
||||
|
||||
// Explicit per-combo targetTimeoutMs still wins over the derived floor.
|
||||
assert.equal(
|
||||
resolveComboTargetTimeoutMsForCombo(
|
||||
{ targetTimeoutMs: 45000 },
|
||||
600000,
|
||||
"auto",
|
||||
comboCooldownWait
|
||||
),
|
||||
resolveComboTargetTimeoutMsForCombo({ targetTimeoutMs: 45000 }, 600000, "auto", comboCooldownWait),
|
||||
45000
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
67
tests/unit/designer-web-empty-response-502.test.ts
Normal file
67
tests/unit/designer-web-empty-response-502.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { handleDesignerWebImageGeneration } = await import(
|
||||
"../../open-sse/handlers/imageGeneration/providers/designerWeb.ts"
|
||||
);
|
||||
|
||||
/**
|
||||
* `stepDesignerWebPoll` classifies an unrecognized upstream body as a terminal
|
||||
* 502 — the "empty" arm of the step union, alongside pending / ready / upstream
|
||||
* failure.
|
||||
*
|
||||
* `microsoft-designer-web-6672.test.ts` covers every other arm end-to-end
|
||||
* (400 no prompt, 401 no token, immediate ready, poll-then-ready, non-OK
|
||||
* upstream, 504 timeout) but tests "empty" only at the parser level
|
||||
* (`parseDesignerWebResponse: unrecognized shape is 'empty'`) — it never drives
|
||||
* the handler with one, so the 502 the handler synthesizes from it was
|
||||
* unasserted.
|
||||
*/
|
||||
|
||||
function jsonResponse(status: number, body: unknown) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
model: "dall-e-3",
|
||||
provider: "microsoft-designer-web",
|
||||
providerConfig: { baseUrl: "https://designerapp.officeapps.live.com/designerapp/DallE.ashx" },
|
||||
credentials: { apiKey: "tok-abc" },
|
||||
};
|
||||
|
||||
test("a 200 with an unrecognized body is a terminal 502, not a retry", async () => {
|
||||
let calls = 0;
|
||||
const result = await handleDesignerWebImageGeneration({
|
||||
...BASE,
|
||||
body: { prompt: "a cat astronaut", timeout_ms: 5_000, poll_interval_ms: 1 },
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return jsonResponse(200, { unexpected: true });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 502, "an unparseable success body is a bad-gateway, not a timeout");
|
||||
assert.match(String(result.error), /did not contain image data or polling metadata/);
|
||||
assert.equal(calls, 1, "the empty classification is terminal — it must not keep polling");
|
||||
});
|
||||
|
||||
test("a 200 with neither images nor polling metadata does not fall through to 504", async () => {
|
||||
// The distinction matters: 502 says "the upstream answered with something we
|
||||
// cannot use", 504 says "the upstream never finished". A timeout_ms generous
|
||||
// enough to allow several polls proves the 502 came from classification, not
|
||||
// from the deadline.
|
||||
const result = await handleDesignerWebImageGeneration({
|
||||
...BASE,
|
||||
body: { prompt: "a cat astronaut", timeout_ms: 10_000, poll_interval_ms: 1 },
|
||||
fetchImpl: async () => jsonResponse(200, { polling_response: {} }),
|
||||
});
|
||||
|
||||
assert.equal(result.status, 502);
|
||||
assert.notEqual(result.status, 504);
|
||||
});
|
||||
121
tests/unit/guardrails-void-no-change-contract.test.ts
Normal file
121
tests/unit/guardrails-void-no-change-contract.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { BaseGuardrail, GuardrailRegistry } from "../../src/lib/guardrails/index.ts";
|
||||
|
||||
/**
|
||||
* `docs/security/GUARDRAILS.md`: "A guardrail signals 'no change' by returning
|
||||
* either `void`, `{}`, or ...".
|
||||
*
|
||||
* The `void` arm of that contract had no test — `guardrails-registry.test.ts`
|
||||
* covers guardrails that return a result and one that throws, but never one
|
||||
* that simply returns nothing. These tests pin it, because the registry's
|
||||
* dispatch reads the return value's properties and must keep treating "nothing"
|
||||
* as a clean pass rather than a block or a modification.
|
||||
*/
|
||||
|
||||
class SilentGuardrail extends BaseGuardrail {
|
||||
constructor(name = "silent") {
|
||||
super(name, { priority: 10 });
|
||||
}
|
||||
|
||||
override async preCall() {
|
||||
// no return — the documented "no change" signal
|
||||
}
|
||||
|
||||
override async postCall() {
|
||||
// no return — the documented "no change" signal
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyResultGuardrail extends BaseGuardrail {
|
||||
constructor(name = "empty") {
|
||||
super(name, { priority: 10 });
|
||||
}
|
||||
|
||||
override async preCall() {
|
||||
return {};
|
||||
}
|
||||
|
||||
override async postCall() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
test("a preCall returning nothing passes the payload through untouched", async () => {
|
||||
const registry = new GuardrailRegistry();
|
||||
registry.register(new SilentGuardrail());
|
||||
|
||||
const payload = { messages: [{ role: "user", content: "hello" }] };
|
||||
const result = await registry.runPreCallHooks(payload, {});
|
||||
|
||||
assert.equal(result.blocked, false, "returning nothing must not block");
|
||||
assert.deepEqual(result.payload, payload, "payload must be forwarded unchanged");
|
||||
|
||||
const execution = result.results[0];
|
||||
assert.equal(execution?.guardrail, "silent");
|
||||
assert.equal(execution?.blocked, false);
|
||||
assert.equal(execution?.modified, false, "nothing returned means nothing modified");
|
||||
assert.equal(execution?.skipped, false, "the guardrail ran — it is not 'skipped'");
|
||||
assert.equal(execution?.error, undefined, "a silent pass is not an error");
|
||||
assert.equal(execution?.stage, "pre");
|
||||
});
|
||||
|
||||
test("a postCall returning nothing passes the response through untouched", async () => {
|
||||
const registry = new GuardrailRegistry();
|
||||
registry.register(new SilentGuardrail());
|
||||
|
||||
const response = { choices: [{ message: { content: "hi" } }] };
|
||||
const result = await registry.runPostCallHooks(response, {});
|
||||
|
||||
assert.equal(result.blocked, false);
|
||||
assert.deepEqual(result.response, response);
|
||||
|
||||
const execution = result.results[0];
|
||||
assert.equal(execution?.modified, false);
|
||||
assert.equal(execution?.skipped, false);
|
||||
assert.equal(execution?.error, undefined);
|
||||
assert.equal(execution?.stage, "post");
|
||||
});
|
||||
|
||||
test("returning an empty object behaves identically to returning nothing", async () => {
|
||||
const payload = { messages: [{ role: "user", content: "hello" }] };
|
||||
|
||||
const silent = new GuardrailRegistry();
|
||||
silent.register(new SilentGuardrail("g"));
|
||||
const silentResult = await silent.runPreCallHooks(payload, {});
|
||||
|
||||
const empty = new GuardrailRegistry();
|
||||
empty.register(new EmptyResultGuardrail("g"));
|
||||
const emptyResult = await empty.runPreCallHooks(payload, {});
|
||||
|
||||
assert.deepEqual(
|
||||
silentResult.results,
|
||||
emptyResult.results,
|
||||
"the two documented no-change signals must produce the same execution record"
|
||||
);
|
||||
assert.deepEqual(silentResult.payload, emptyResult.payload);
|
||||
});
|
||||
|
||||
test("a silent guardrail does not stop later guardrails from modifying", async () => {
|
||||
class AppendGuardrail extends BaseGuardrail {
|
||||
constructor() {
|
||||
super("append", { priority: 20 });
|
||||
}
|
||||
|
||||
override async preCall(payload: unknown) {
|
||||
return { modifiedPayload: { ...(payload as Record<string, unknown>), seen: true } };
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new GuardrailRegistry();
|
||||
registry.register(new SilentGuardrail());
|
||||
registry.register(new AppendGuardrail());
|
||||
|
||||
const result = await registry.runPreCallHooks({ messages: [] }, {});
|
||||
|
||||
assert.equal((result.payload as Record<string, unknown>).seen, true);
|
||||
assert.equal(result.results.length, 2);
|
||||
assert.equal(result.results[0]?.modified, false, "silent guardrail ran first, modified nothing");
|
||||
assert.equal(result.results[1]?.modified, true, "the later guardrail still applied");
|
||||
});
|
||||
@@ -30,7 +30,7 @@ const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9
|
||||
type LMArenaExecutorTestAccess = {
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
62
tests/unit/multipart-body-arraybuffer-backing.test.ts
Normal file
62
tests/unit/multipart-body-arraybuffer-backing.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { buildMultipartBody } = await import("../../open-sse/handlers/audioTranscription.ts");
|
||||
|
||||
/**
|
||||
* `buildMultipartBody` is handed straight to `fetch` as the request body by six
|
||||
* call sites across `audioTranscription.ts` and `audioTranslation.ts`, so its
|
||||
* result must satisfy `BodyInit` — which requires an `ArrayBuffer`-backed view,
|
||||
* not merely "some Uint8Array". A `SharedArrayBuffer`-backed view, or a slice
|
||||
* into a shared pool, is not a valid request body.
|
||||
*
|
||||
* The four existing `buildMultipartBody` tests in
|
||||
* `audio-transcription-handler.test.ts` decode the payload and assert its
|
||||
* *contents* (boundary, filename sanitization, MIME fallback). None of them
|
||||
* assert the backing, which is the property the declared
|
||||
* `Uint8Array<ArrayBuffer>` return type exists to guarantee — and the one a
|
||||
* plausible "optimization" to a pooled `Buffer.concat` would break.
|
||||
*/
|
||||
|
||||
function audioFile(name = "clip.wav", type = "audio/wav") {
|
||||
return Object.assign(new Blob([new Uint8Array([1, 2, 3, 4])], { type }), { name });
|
||||
}
|
||||
|
||||
test("the assembled body is backed by a plain ArrayBuffer, never a shared one", async () => {
|
||||
const { body } = await buildMultipartBody(audioFile(), { model: "whisper-1" });
|
||||
|
||||
assert.ok(body instanceof Uint8Array);
|
||||
assert.ok(
|
||||
body.buffer instanceof ArrayBuffer,
|
||||
"a SharedArrayBuffer-backed view is not accepted as a fetch BodyInit"
|
||||
);
|
||||
});
|
||||
|
||||
test("the assembled body owns its whole buffer — not a view into a shared pool", async () => {
|
||||
const { body } = await buildMultipartBody(audioFile(), { model: "whisper-1" });
|
||||
|
||||
// `new Uint8Array(totalLength)` allocates exclusively. A pooled allocation
|
||||
// (e.g. switching to Buffer.concat) would leave a non-zero byteOffset and a
|
||||
// buffer larger than the payload, so the bytes handed to fetch would no
|
||||
// longer be the whole buffer.
|
||||
assert.equal(body.byteOffset, 0, "body must start at offset 0 of its own buffer");
|
||||
assert.equal(
|
||||
body.byteLength,
|
||||
body.buffer.byteLength,
|
||||
"body must span its entire buffer, with no pooled remainder"
|
||||
);
|
||||
});
|
||||
|
||||
test("the backing holds for a larger payload than a single pool slab", async () => {
|
||||
// 64 KiB — comfortably past Node's 8 KiB Buffer pool threshold, so a pooled
|
||||
// implementation would behave differently here than for the small case above.
|
||||
const big = Object.assign(new Blob([new Uint8Array(64 * 1024)], { type: "audio/wav" }), {
|
||||
name: "big.wav",
|
||||
});
|
||||
const { body } = await buildMultipartBody(big, { model: "whisper-1" });
|
||||
|
||||
assert.ok(body.buffer instanceof ArrayBuffer);
|
||||
assert.equal(body.byteOffset, 0);
|
||||
assert.equal(body.byteLength, body.buffer.byteLength);
|
||||
assert.ok(body.byteLength > 64 * 1024, "payload carries the file plus the multipart envelope");
|
||||
});
|
||||
84
tests/unit/rerank-object-documents-response-path.test.ts
Normal file
84
tests/unit/rerank-object-documents-response-path.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { getRerankProvider } = await import("../../open-sse/config/rerankRegistry.ts");
|
||||
const { transformResponseFromProvider } = await import("../../open-sse/handlers/rerank.ts");
|
||||
|
||||
/**
|
||||
* The Cohere-compatible rerank API accepts each document as either a bare string
|
||||
* or `{ text }`, and the DeepInfra/Voyage **response** adapters synthesize
|
||||
* `document.text` from the caller's originals (upstreams either omit documents
|
||||
* or echo them in their own shape — #7809/#7811).
|
||||
*
|
||||
* The `{ text }` form was only ever exercised through the *request* adapter
|
||||
* (`#5332 deepinfra request adapter`, `#7809 voyage request adapter handles
|
||||
* {text} object documents`). Every response-adapter test passed plain strings,
|
||||
* so the `typeof doc === "string" ? doc : doc?.text` branch on the response side
|
||||
* was uncovered — which is also the branch that gives
|
||||
* `RerankResponseOptions.documents` its union type.
|
||||
*/
|
||||
|
||||
test("deepinfra response adapter resolves document text from {text} objects", () => {
|
||||
const cfg = getRerankProvider("deepinfra");
|
||||
const out = transformResponseFromProvider(
|
||||
cfg,
|
||||
{ scores: [0.1, 0.9] },
|
||||
{ documents: [{ text: "Washington DC" }, { text: "Paris" }], return_documents: true }
|
||||
);
|
||||
|
||||
assert.equal(out.results[0].index, 1, "0.9 ranks first");
|
||||
assert.equal(out.results[0].document.text, "Paris");
|
||||
assert.equal(out.results[1].document.text, "Washington DC");
|
||||
});
|
||||
|
||||
test("deepinfra response adapter handles a mixed string/{text} document array", () => {
|
||||
const cfg = getRerankProvider("deepinfra");
|
||||
const out = transformResponseFromProvider(
|
||||
cfg,
|
||||
{ scores: [0.2, 0.8] },
|
||||
{ documents: ["plain string", { text: "object form" }], return_documents: true }
|
||||
);
|
||||
|
||||
assert.equal(out.results[0].document.text, "object form");
|
||||
assert.equal(out.results[1].document.text, "plain string");
|
||||
});
|
||||
|
||||
test("deepinfra response adapter falls back to empty text for a {text}-less object", () => {
|
||||
const cfg = getRerankProvider("deepinfra");
|
||||
const out = transformResponseFromProvider(
|
||||
cfg,
|
||||
{ scores: [0.5] },
|
||||
{ documents: [{} as { text?: string }], return_documents: true }
|
||||
);
|
||||
|
||||
assert.equal(out.results[0].document.text, "", "a document with no text must not yield undefined");
|
||||
});
|
||||
|
||||
test("voyage response adapter resolves document text from {text} objects", () => {
|
||||
const cfg = getRerankProvider("voyage-ai");
|
||||
const out = transformResponseFromProvider(
|
||||
cfg,
|
||||
{ data: [{ index: 0, relevance_score: 0.4 }, { index: 1, relevance_score: 0.7 }] },
|
||||
{ documents: [{ text: "alpha" }, { text: "beta" }], return_documents: true }
|
||||
);
|
||||
|
||||
assert.equal(out.results[0].index, 1, "0.7 ranks first");
|
||||
assert.equal(out.results[0].document.text, "beta");
|
||||
assert.equal(out.results[1].document.text, "alpha");
|
||||
});
|
||||
|
||||
test("voyage response adapter remaps indices past an empty {text} document", () => {
|
||||
// The request adapter drops exact-empty documents before sending, so the
|
||||
// upstream indices refer to the filtered array. The response adapter rebuilds
|
||||
// that filter to map back — this must work for the object form too, not just
|
||||
// strings.
|
||||
const cfg = getRerankProvider("voyage-ai");
|
||||
const out = transformResponseFromProvider(
|
||||
cfg,
|
||||
{ data: [{ index: 1, relevance_score: 0.9 }] },
|
||||
{ documents: [{ text: "kept" }, { text: "" }, { text: "also kept" }], return_documents: true }
|
||||
);
|
||||
|
||||
assert.equal(out.results[0].index, 2, "filtered index 1 maps back to original index 2");
|
||||
assert.equal(out.results[0].document.text, "also kept");
|
||||
});
|
||||
@@ -20,9 +20,8 @@
|
||||
* The non-quota-share (priority) scenario was UPDATED for the "universal
|
||||
* 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
90
tests/unit/ts7-executor-override-signatures.test.ts
Normal file
90
tests/unit/ts7-executor-override-signatures.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Guards the executor override signatures fixed for TS 7 readiness.
|
||||
*
|
||||
* Three executors declared a *private/protected* `buildHeaders()` helper whose signature
|
||||
* has nothing to do with `BaseExecutor.buildHeaders(credentials, stream?, clientHeaders?,
|
||||
* model?, health?)`:
|
||||
*
|
||||
* hailuo-web (token: string, yy: string)
|
||||
* lmarena (_model: string, credentials: unknown, _body: unknown)
|
||||
* qwen-web (token: string, cookieHeader: string, chatId?: string)
|
||||
*
|
||||
* They were name collisions, not overrides — each shadowed the inherited member with an
|
||||
* incompatible signature (TS2416). `BaseExecutor` calls `this.buildHeaders(credentials,
|
||||
* false)` from `countTokens()`, so the shadow sat on a live dispatch path; it was never
|
||||
* reached only because `buildCountTokensUrl()` returns null unless `config.format` is
|
||||
* `"claude"`, and none of these three is. Latent rather than live — but one `format`
|
||||
* change away from passing a credentials object where a token string was expected.
|
||||
*
|
||||
* The helpers are renamed, so these assertions pin both halves: the inherited method is
|
||||
* no longer shadowed, and the early return that kept it harmless still holds.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { BaseExecutor } from "../../open-sse/executors/base.ts";
|
||||
import { HailuoWebExecutor } from "../../open-sse/executors/hailuo-web.ts";
|
||||
import { LMArenaExecutor } from "../../open-sse/executors/lmarena.ts";
|
||||
import { QwenWebExecutor } from "../../open-sse/executors/qwen-web.ts";
|
||||
|
||||
const CASES = [
|
||||
{ name: "hailuo-web", make: () => new HailuoWebExecutor(), helper: "buildStreamHeaders" },
|
||||
{ name: "lmarena", make: () => new LMArenaExecutor(), helper: "buildRequestHeaders" },
|
||||
{ name: "qwen-web", make: () => new QwenWebExecutor(), helper: "buildApiHeaders" },
|
||||
];
|
||||
|
||||
for (const { name, make, helper } of CASES) {
|
||||
test(`${name}: buildHeaders resolves to BaseExecutor, not a local helper`, () => {
|
||||
const executor = make() as unknown as Record<string, unknown>;
|
||||
|
||||
assert.equal(
|
||||
executor.buildHeaders,
|
||||
BaseExecutor.prototype.buildHeaders,
|
||||
`${name} must not shadow BaseExecutor.buildHeaders — countTokens() dispatches through it`
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: its own header helper is still present under the renamed key`, () => {
|
||||
const executor = make() as unknown as Record<string, unknown>;
|
||||
|
||||
assert.equal(
|
||||
typeof executor[helper],
|
||||
"function",
|
||||
`${name} should keep its provider-specific header builder as ${helper}()`
|
||||
);
|
||||
assert.notEqual(
|
||||
executor[helper],
|
||||
BaseExecutor.prototype.buildHeaders,
|
||||
"the renamed helper must be the provider's own function, not the inherited one"
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: countTokens() short-circuits before reaching buildHeaders`, async () => {
|
||||
const executor = make();
|
||||
|
||||
// buildCountTokensUrl() returns null unless config.format === "claude" and the URL
|
||||
// carries /messages. That early return is what kept the old shadow unreachable; if it
|
||||
// ever changes, the inherited buildHeaders must be the one that runs.
|
||||
const result = await executor.countTokens({
|
||||
model: "whatever",
|
||||
body: { messages: [] },
|
||||
credentials: {},
|
||||
signal: new AbortController().signal,
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result, null, `${name} does not support the Anthropic count_tokens endpoint`);
|
||||
});
|
||||
}
|
||||
|
||||
test("LMArenaExecutor does not narrow visibility of inherited members", () => {
|
||||
// TS2415: a subclass may widen a member's visibility but never narrow it. `buildUrl` and
|
||||
// `transformRequest` were `protected` here while public on BaseExecutor — masked behind
|
||||
// the buildHeaders TS2416 until that cleared. Runtime has no visibility, so this asserts
|
||||
// the members are reachable, which is what the type change encodes.
|
||||
const executor = new LMArenaExecutor() as unknown as Record<string, unknown>;
|
||||
|
||||
for (const member of ["buildUrl", "transformRequest"]) {
|
||||
assert.equal(typeof executor[member], "function", `${member} must stay callable`);
|
||||
}
|
||||
});
|
||||
145
tests/unit/ts7-executor-shared-shapes.test.ts
Normal file
145
tests/unit/ts7-executor-shared-shapes.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { OpencodeExecutor } = await import("../../open-sse/executors/opencode.ts");
|
||||
const { MimocodeExecutor } = await import("../../open-sse/executors/mimocode.ts");
|
||||
|
||||
/**
|
||||
* Behavioral guards for the three type-only fixes in the TS 7 executor slice
|
||||
* (see #8484). Each fix restored a type the code already depended on at runtime;
|
||||
* these tests pin the runtime contracts so a future "simplification" of the
|
||||
* annotations cannot silently change behavior.
|
||||
*
|
||||
* The zed-hosted `SseEnqueueTarget` fix is already covered end-to-end by
|
||||
* `zed-hosted-think-close-marker.test.ts`, which drives the same TransformStream
|
||||
* that failed to type-check — no duplicate added here.
|
||||
*/
|
||||
|
||||
describe("OpencodeExecutor — tools truncation survives the narrowing fix", () => {
|
||||
const executor = new OpencodeExecutor("opencode-go");
|
||||
const CREDENTIALS = { apiKey: "k" } as Record<string, unknown>;
|
||||
|
||||
const tools = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
type: "function",
|
||||
function: { name: `tool_${i}`, parameters: {} },
|
||||
}));
|
||||
|
||||
function bodyWith(toolCount: number) {
|
||||
return {
|
||||
model: "oc/kimi-k2.6",
|
||||
stream: true,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: tools(toolCount),
|
||||
};
|
||||
}
|
||||
|
||||
it("truncates an over-long tools array to 128 entries", () => {
|
||||
const out = executor.transformRequest("oc/kimi-k2.6", bodyWith(200), true, CREDENTIALS) as {
|
||||
tools: unknown[];
|
||||
};
|
||||
assert.equal(out.tools.length, 128, "upstream rejects more than 128 tools");
|
||||
assert.deepEqual(
|
||||
(out.tools[127] as { function: { name: string } }).function.name,
|
||||
"tool_127",
|
||||
"truncation keeps the first 128 in order, not an arbitrary slice"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a within-limit tools array untouched", () => {
|
||||
const out = executor.transformRequest("oc/kimi-k2.6", bodyWith(10), true, CREDENTIALS) as {
|
||||
tools: unknown[];
|
||||
};
|
||||
assert.equal(out.tools.length, 10);
|
||||
});
|
||||
|
||||
it("is a no-op when the body carries no tools", () => {
|
||||
const body = {
|
||||
model: "oc/kimi-k2.6",
|
||||
stream: true,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
};
|
||||
const out = executor.transformRequest("oc/kimi-k2.6", body, true, CREDENTIALS) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal("tools" in out, false);
|
||||
assert.ok(Array.isArray(out.messages), "messages preserved");
|
||||
});
|
||||
|
||||
it("leaves an array-shaped body alone (pins the !Array.isArray guard)", () => {
|
||||
// The pre-fix condition reached `.tools` on any object, arrays included, and
|
||||
// relied on `Array.isArray(undefined)` short-circuiting. The explicit
|
||||
// !Array.isArray() guard must keep that outcome identical.
|
||||
const arrayBody = [{ role: "user", content: "hi" }] as unknown as Record<string, unknown>;
|
||||
const out = executor.transformRequest("oc/kimi-k2.6", arrayBody, true, CREDENTIALS);
|
||||
assert.ok(Array.isArray(out), "array body must pass through as an array");
|
||||
assert.equal((out as unknown[]).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MimocodeExecutor — AccountState.proxy is always present (#3837/#5521)", () => {
|
||||
const FP = "fingerprint-1";
|
||||
|
||||
function accountsOf(exec: unknown): Array<Record<string, unknown>> {
|
||||
return (exec as { accounts: Array<Record<string, unknown>> }).accounts;
|
||||
}
|
||||
|
||||
function sync(exec: unknown, credentials: unknown): void {
|
||||
(exec as { syncAccountsFromCredentials(c: unknown): void }).syncAccountsFromCredentials(
|
||||
credentials
|
||||
);
|
||||
}
|
||||
|
||||
it("defaults proxy to null — not undefined — when no accountProxies are configured", () => {
|
||||
const exec = new MimocodeExecutor();
|
||||
accountsOf(exec).length = 0;
|
||||
accountsOf(exec).push({
|
||||
fingerprint: FP,
|
||||
jwt: "",
|
||||
expiresAt: 0,
|
||||
cooldownUntil: 0,
|
||||
consecutiveFails: 0,
|
||||
});
|
||||
|
||||
sync(exec, { providerSpecificData: {} });
|
||||
|
||||
const account = accountsOf(exec)[0];
|
||||
assert.ok("proxy" in account, "every account must expose a proxy key");
|
||||
assert.equal(account.proxy, null, "unconfigured proxy is null, never undefined");
|
||||
});
|
||||
|
||||
it("resolves a configured proxy onto the matching account", () => {
|
||||
const exec = new MimocodeExecutor();
|
||||
accountsOf(exec).length = 0;
|
||||
accountsOf(exec).push({
|
||||
fingerprint: FP,
|
||||
jwt: "",
|
||||
expiresAt: 0,
|
||||
cooldownUntil: 0,
|
||||
consecutiveFails: 0,
|
||||
});
|
||||
|
||||
const proxy = { type: "http", host: "p1.example.com", port: 1080 };
|
||||
sync(exec, { providerSpecificData: { accountProxies: [{ fingerprint: FP, proxy }] } });
|
||||
|
||||
assert.deepEqual(accountsOf(exec)[0].proxy, proxy);
|
||||
});
|
||||
|
||||
it("clears a previously-resolved proxy back to null when config drops it", () => {
|
||||
const exec = new MimocodeExecutor();
|
||||
accountsOf(exec).length = 0;
|
||||
accountsOf(exec).push({
|
||||
fingerprint: FP,
|
||||
jwt: "",
|
||||
expiresAt: 0,
|
||||
cooldownUntil: 0,
|
||||
consecutiveFails: 0,
|
||||
proxy: { type: "http", host: "stale.example.com", port: 8080 },
|
||||
});
|
||||
|
||||
sync(exec, { providerSpecificData: { accountProxies: [] } });
|
||||
|
||||
assert.equal(accountsOf(exec)[0].proxy, null, "a removed proxy must not linger on the account");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user