Compare commits

...

22 Commits

Author SHA1 Message Date
diegosouzapw
9ee4154829 Merge release/v3.8.51 into fix/release-v3.8.51-basereds-orphans 2026-09-14 19:01:16 -03:00
diegosouzapw
539d2236f5 fix(sse): keep upstream error code/type in the pipeline outcome; trim to what the tip still lacks
Reconciled against release/v3.8.51. The non-streaming credential refresh and
body-derived rate-limit lock this PR restored landed first via #13069, and the
codex image-generation stringify via #12945, so those halves are dropped in
favour of the tip's implementation. What remains is what the tip did not have:
the upstream error code/type (lost in the #12867 extraction, which let a
config-class Antigravity 422 degrade into an account cooldown), the legacy
call_logs boot abort, the malformed custom-models row guard, and the realigned
test guards.
2026-09-14 19:00:54 -03:00
diegosouzapw
6ad5e4ce3f Merge release/v3.8.51 into fix/release-v3.8.51-basereds-orphans 2026-09-14 18:24:38 -03:00
diegosouzapw
3047359386 Merge release/v3.8.51 into fix/release-v3.8.51-basereds-orphans
# Conflicts:
#	config/quality/file-size-baseline.json
#	open-sse/handlers/chatCore.ts
#	open-sse/handlers/chatCore/providerExecutionPipeline.ts
#	src/lib/db/models/activeSyncedCatalog.ts
#	tests/unit/hard-session-lease-bypass-inventory.test.ts
#	tests/unit/kiro-tool-call-validation.test.ts
2026-09-14 18:23:58 -03:00
diegosouzapw
84f018cfd5 test(lease): inventory the credential resolve that combo edit fallthrough added
#12653 made /v1/images/edits fall through to the next combo target, and each
per-target attempt resolves credentials of its own — one more classified site
in that route (5 -> 6). Drift from the base tip, not from this branch.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-11 19:48:52 -03:00
diegosouzapw
889a2bb388 Merge remote-tracking branch 'origin/release/v3.8.51' into fix/release-v3.8.51-basereds-orphans
# Conflicts:
#	config/quality/file-size-baseline.json
2026-09-11 18:53:44 -03:00
diegosouzapw
b1d58c516e chore(quality): clear the file-size and stryker gates for the base-red sweep
- stryker.conf.json: register three covering tests that drifted out of
  tap.testFiles on the base tip (combo-predicates-epoch-cooldown,
  opencode-400-model-unavailable, chat-correlation-id-exhaustion). Reproduced
  on the clean tip, so this is inherited drift, not this branch's.
- file-size: models-catalog-route.test.ts was already at its frozen ceiling, so
  the one-line CATALOG_BUILD_TIMEOUT_MS pin cannot be absorbed. Comment trimmed
  to a single trailing note and testFrozen bumped 1652 -> 1653 with the
  rationale recorded in the baseline.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-11 18:32:45 -03:00
diegosouzapw
555d97f12c test: realign four guards with the merges that changed their contracts
Four base-reds that appeared on the tip today, none owned by any open PR:

- providers-constants-split / provider-node-reserved-prefix: GreenPT (#13024)
  and EURouter (#13025) move the totals 238 -> 240 and 408 -> 410.
- provider-translate-path-golden: same two providers, regenerated with
  UPDATE_GOLDEN=1; the snapshot delta is purely additive.
- glm-sse-transform-arity: #12925 declared the buffer-size slot GLM had been
  filling since #12179. The invariant is unchanged — the value must land in a
  real parameter, never a dropped positional — so the guard now checks it from
  the helper side and requires the named constant at the call site instead of
  a magic literal.
- injection-guard-scan-bound-3932: #13104 split the 16 KB budget between head
  and tail, so 'outside the window' is now the middle of an oversized body,
  not its end. The 16 KB ceiling these cases protect is untouched.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-11 14:58:18 -03:00
diegosouzapw
11e82e022e Merge remote-tracking branch 'origin/release/v3.8.51' into fix/release-v3.8.51-basereds-orphans 2026-09-11 14:55:08 -03:00
diegosouzapw
480f4a2bbb chore(skills): regenerate the cli-tunnel SKILL.md after the positional-arg fix
#13009 taught the CLI registry parser to read positionals declared with
.addArgument() but did not re-run the generator, so check:agent-skills-sync
has been red on the base tip ever since. Regenerated with the official
generator (--apply --only=cli-tunnel); the only delta is 'tunnel create'
gaining its [type] positional.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-11 13:50:33 -03:00
diegosouzapw
39300717ab docs(changelog): record the base-red orphan fixes for v3.8.51 2026-09-11 13:25:20 -03:00
diegosouzapw
ea7ba79a73 fix(sse): restore credential refresh, failure persistence and codex image errors
Three more regressions from the #12867 pipeline extraction, all red on the
base tip:

- the non-streaming leg stopped refreshing credentials after a 401 and
  stopped persisting failure state, so a Copilot token was never retried
  and per-model quota locks lost their helper references;
- handleImageGeneration (codex) crashed reading fields off a body that
  #12506 now sanitizes before it reaches the classifier;
- the pipeline discarded the raw upstream body, which provider-error
  classification needs (quota vs rate-limit vs ban) — surfaced as
  rawMessage/upstreamBody, both internal to the classifier.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-11 12:56:54 -03:00
diegosouzapw
3603a7b246 test(sse): pin the pipeline error-classification and Kiro stream contracts
Regression guards for the two defects above: the pipeline must surface the
upstream error code/type, and a Kiro error frame now terminates the stream
after forwarding response.failed (the #12506 contract the sibling
stream-passthrough suite already encodes).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-11 12:56:15 -03:00
diegosouzapw
be40cadd01 fix(sse): carry the upstream error code/type through the provider pipeline
parseUpstreamError() already returns errorCode/errorType; the pipeline was
dropping both on the floor. Gates that key on the pair — notably
isAntigravityMissingProjectError — never fired, so a config-class 422 turned
into a generic account cooldown instead of a fail-closed answer.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-11 12:55:42 -03:00
diegosouzapw
9e4955b069 Merge branch 'fix/v3851-basereds-orphans-g1' into fix/release-v3.8.51-basereds-orphans 2026-09-11 12:55:06 -03:00
diegosouzapw
47b6fb837a Merge branch 'fix/v3851-basereds-orphans-g4' into fix/release-v3.8.51-basereds-orphans 2026-09-11 12:55:06 -03:00
diegosouzapw
e25b706c56 test(lease): re-inventory hard-lease call sites after the pipeline extraction
Four drifts, all from PRs merged into the tip on 2026-09-07:

- #12867 extracted chatCore.ts's streaming execution loop into
  chatCore/providerExecutionPipeline.ts. Its two getProviderCredentials() sites
  now go through the injected `connection.getProviderCredentials` handle, which
  the bare-identifier AST scan never saw — the sites would have left the
  inventory unnoticed. Count property-access calls too and inventory the new file.
- #12867 also re-expressed the codex 429 managed-lease fence: the inline
  `provider === "codex" && !managedLease` became `allowAccountRotation:
  !managedLease && …` in chatCore.ts, gated in the pipeline as `canRotateAccount`.
  Assert both halves of that seam instead of the vanished inline form.
- #12746 moved combo.ts's getProviderConnectionById into
  combo/executeTargetGates.ts (class B, unchanged).
- #12805 added the Grok Build reset-credit path: src/lib/usage/grokResetCredits.ts
  (class B — same isConnectionUnavailableToAuxiliaryActivity fence as its codex
  sibling, so it also joins auxiliaryIsolationSources) and
  src/app/api/usage/codex-reset-credit/route.ts (class C — resolves the
  connection's provider to pick a library, never to serve a request).

Also pin CATALOG_BUILD_TIMEOUT_MS in models-catalog-route.test.ts: #12627's 8s
cold-build bound is sized for a warm production process, and a tsx test runner
building the full catalog from a fresh SQLite file crosses it (10-13s observed),
returning a `catalog_build_timeout` error body with no `data` array. The bound's
own behavior stays covered by 12627-catalog-inflight-timeout.test.ts.
2026-09-10 19:13:05 -03:00
diegosouzapw
7edda9c30a test(combo): follow the provider-cooldown source guard into the split modules
#12746 (executeTarget → gates/attempt/loop) and #12811 (round-robin) moved the
combo dispatcher out of open-sse/services/combo.ts. The #5976 invariant moved
with it intact — executeTargetAttempt.ts:1169 and roundRobinCombo.ts:1049 both
still exclude a 500/429 on a per-model-quota provider before recording a provider
cooldown — but the guard only read combo.ts and went red on an empty file.

Scan the whole combo surface instead of one filename and require the skip in
EVERY module that calls recordProviderCooldown(), with the pattern matched on
whitespace-normalized source so a prettier rewrap cannot silently disarm it. The
invariant asserted is unchanged; the guard is now stronger than the single
substring check it replaces.
2026-09-10 19:12:46 -03:00
diegosouzapw
b4616e4316 fix(catalog): keep operator custom models out of the live-sync reader
#12934 unioned customModels into getAllActiveSyncedModels() alongside the
dispatch-time readers it was actually fixing (#12597: getActiveSyncedCatalog,
reconcileProvidersWithActiveSyncedCatalog, getActiveProvidersWithSyncedModel).

getAllActiveSyncedModels() is not a dispatch reader. Its three consumers read it
as "what the provider's live sync reported": /v1/models feeds its synced-emission
loop (and has a separate custom-model pass right after, which owns the
specialty-registry dedupe, hidePaid and the vision overrides), /api/models uses it
to decide whether an exclusive-listing provider suppresses a static row, and
getSyncedAutoAliases derives tier aliases from it. Blurring custom rows into
"synced" made a custom embedding/rerank model on jina-ai come out as the alias
row `jina/<id>` (parentless primary) with the registry's canonical
`jina-ai/<id>` demoted to its child — the inverse of the identity every other
specialty model of that provider carries, and it also dropped the registry's
`dimensions`.

Drop the union there only; the dispatch trio keeps it, so #12597's tests and the
400-on-picker-model fix are untouched. Locked by a new case in
custom-models-live-catalog-12597.test.ts and by both jina cases in
models-catalog-route.test.ts ("does not duplicate imported/custom Jina specialty
models"), which encode the two identities side by side.
2026-09-10 19:12:06 -03:00
diegosouzapw
b3d3d9524c fix(combo): survive a malformed customModels row when building auto/* pools
prepareVirtualAutoComboInputs() reads getCustomModels(providerId) straight into
`for (const m of customModels) if (m.id ...)`. That blob is operator-writable and
is returned as raw parsed JSON, so a null / non-object row threw "Cannot read
properties of null (reading 'id')" and EVERY auto/* combo failed to materialize
("[catalog] Could not materialize built-in auto model auto/<id>"), silently
degrading the whole zero-setup routing surface to its minimal catalog entries.

Filter the rows to objects first, the same way the /v1/models custom-model pass
already does. Regression test in combo-auto-pool-visible-only.test.ts fails with
the original TypeError before the guard.
2026-09-10 19:11:36 -03:00
diegosouzapw
ed44f4ae12 fix(db): create the call_logs provider-stats index after legacy healing
#12832 declared `idx_cl_request_provider ON call_logs(request_type, provider)`
inside SCHEMA_SQL. That block runs before ensureCallLogsColumns() heals a legacy
call_logs table, so on any lineage predating the request_type column the CREATE
INDEX aborted the whole schema exec with "no such column: request_type" and the
server never finished opening the database.

Move the index next to the other request_type/combo indexes in
ensureCallLogsColumns(), which runs after the ALTER TABLE healing (and is also
called on the in-memory path), so both fresh and upgraded databases get it.

Proven by tests/unit/db-core-init.test.ts, "legacy call_logs schemas are upgraded
before combo target indexes are created" — failing on the release tip, green now.
2026-09-10 19:11:08 -03:00
diegosouzapw
2724ad07e1 fix(sse): restore upstream error parsing + rate-limit lock in the provider pipeline
#12867 lifted the non-2xx branch out of chatCore.ts into
providerExecutionPipeline.ts::toOutcome, but reimplemented it instead of
delegating, dropping three behaviors the non-streaming failure path relies on:

1. A non-JSON upstream body fell into the inline JSON.parse catch and surfaced
   as the (empty) statusText — "upstream error" — discarding the text the
   client needs. Now parseUpstreamError() supplies the message, as chatCore did.
2. The body-derived retry-after ("Please retry after 20s") was never parsed:
   retryAfterMs was hard-coded null into applyStatusRestatement/createErrorResult.
3. recordRateLimitBody was plumbed through PipelineStateHooks and wired at both
   chatCore call sites, but never called — so updateFromResponseBody(), which
   drains the runtime reservoir on a body-derived 429, silently stopped running
   for every request routed through this pipeline.

restatement rules also now match against the real upstream payload rather than
the request body that transformedBody carried whenever the parse failed.

Regression guard: tests/unit/chat-rate-limit-body-lock.test.ts (2 base-red
failures on the release/v3.8.51 tip) — both green; provider-execution-pipeline
13/13; typecheck:core clean.
2026-09-10 18:39:42 -03:00
18 changed files with 360 additions and 99 deletions

View File

@@ -0,0 +1,4 @@
- **fix(sse):** the provider execution pipeline keeps the upstream error `code`/`type` again — both were lost when the non-streaming leg was extracted, so a config-class `422` (Antigravity missing project) degraded into a generic account cooldown instead of reaching the gate that recognises it ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732))
- **fix(db):** a legacy `call_logs` table no longer aborts startup — the provider-stats index is created after the column healing runs, not before it, so an install predating `request_type` opens instead of failing with `no such column` ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732))
- **fix(catalog):** a malformed row in the operator-writable custom-models blob no longer kills every `auto/*` pool ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732))
- **fix(tests):** realign the guards that the same day's merges left asserting the old behavior — the provider-count total after GreenPT and EURouter, the GLM stream buffer slot that `#12925` turned into a declared parameter, and the injection scan bound that `#13104` reshaped from a head-only window into head-plus-tail ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732))

View File

@@ -1,4 +1,6 @@
{
"_rebaseline_2026_09_14_13349_virtualfactory_custom_models_guard": "PR #13349 own growth: open-sse/services/autoCombo/virtualFactory.ts 1219->1230 (+11). The customModels key_value blob is operator-writable raw JSON, so a null or non-object row null-derefed every read and no auto/* pool could materialize; the builder now filters rows the same way catalog.ts already does. Irreducible at the read site. Covered by tests/unit/combo-auto-pool-visible-only.test.ts.",
"_rebaseline_2026_09_11_12732_catalog_timeout_pin": "+1 in tests/unit/models-catalog-route.test.ts (1652->1653) for a single line: process.env.CATALOG_BUILD_TIMEOUT_MS. #12627 bounds a cold catalog build at 8s; beforeEach resets the catalog cache so every case in this file pays a cold build, and a tsx runner needs 10-13s under load — the file returned catalog_build_timeout instead of rows and oscillated between 1 and 10 failures per run, reddening the whole PR queue (base-red #12732). The bound itself stays covered by tests/unit/12627-catalog-inflight-timeout.test.ts. The file is already at its frozen ceiling, so the pin cannot be absorbed; structural shrink tracked in #3501.",
"_rebaseline_2026_09_11_12945_image_only_model_guard": "PR #12945 own growth: open-sse/handlers/imageGeneration.ts 3259->3293 (+35/-1). The image-only-model guard the PR adds to clear its base-red: the handler now recognises a model that only serves image generation and answers before the chat path can mis-route it. Irreducible at this call site; the predicate itself lives outside the file. Landed as its own PR rather than on #12945 because that branch has a live worktree in another session and pushing to it would pull the branch out from under whoever is working it. Covered by the batch run: 203/208 with the 5 remaining failures reproducing on the pure tip.",
"_rebaseline_2026_09_11_mergebatch_v3851_diego": "/merge-batch 2026-09-11 (v3.8.51), owner batch. open-sse/handlers/chatCore.ts 6144->6146 (+2): #13278 requires a Responses-shaped body before the native OpenAI-compatible passthrough (+1) and #13276 stops the reactive-compaction log from claiming a compaction when compression is disabled (+2/-1). Both are guard conditions at existing call sites, no new branching structure. open-sse/utils/stream.ts is deliberately NOT rebaselined: already 3115 > 3098 on the pure tip with zero contribution from this batch (base-red #12732, owned by /sweep-reds). Covered by 256 assertions across the batch's test files (246 node:test + 10 vitest).",
"_rebaseline_2026_09_11_mergebatch_v3851_houminxi": "/merge-batch 2026-09-11 (v3.8.51), batch by HouMinXi. Final combined values, set on the first PR merged so every intermediate state is covered. open-sse/handlers/chatCore.ts 6036->6144: #13069 routes the non-streaming leg through the same provider-failure classification, model lockout and credential-refresh path the streaming leg already used (+443/-340 = +103 net; it extracts applyProviderFailureClassification and wires both legs to it, which is what #13043 reported missing), plus #13050 stamping that the client asked for SSE before the web_search fallback flips stream off (+6) and #13038 threading the dispatched target index (+3). src/sse/services/auth.ts 3488->3542: #13017 adds the explicit-pin one-shot probe for a recoverable inactive row with its 60s storm gate (+42 net) and #13061 makes a grok-cli 402 a connection-wide shared-wallet signal instead of a per-model billing miss (+12 net). src/sse/handlers/chat.ts 2458->2462: #13038 (+5). open-sse/services/combo/executeTargetAttempt.ts 1205->1212: #13006 feeds the 402 it already classified into the quota cache instead of dropping it (+7). open-sse/services/accountFallback.ts 2468->2469: #13060 adds the Cline re-auth phrase to OAUTH_INVALID_TOKEN_SIGNALS (+1). open-sse/utils/stream.ts is deliberately NOT rebaselined: already 3115 > 3098 on the pure tip with zero contribution from this batch (base-red #12732, owned by /sweep-reds). The file also carried \"open-sse/handlers/chatCore.ts\" twice (6026 and 6036); JSON keeps the last, so the first was dead weight any writer could have picked instead. Collapsed to one entry at the live value. Covered by 531 focused assertions across the batch's 46 test files.",
@@ -236,7 +238,7 @@
"tests/unit/executor-default-base.test.ts": 1632,
"tests/unit/grok-web.test.ts": 2985,
"tests/unit/image-generation-handler.test.ts": 2133,
"tests/unit/models-catalog-route.test.ts": 1652,
"tests/unit/models-catalog-route.test.ts": 1653,
"tests/unit/perplexity-web.test.ts": 1384,
"tests/unit/provider-models-route.test.ts": 1783,
"tests/unit/provider-validation-specialty.test.ts": 2912,
@@ -477,7 +479,7 @@
"src/sse/services/auth.ts": 3542,
"tests/unit/account-fallback-service.test.ts": 2453,
"tests/unit/provider-validation-specialty.test.ts": 4656,
"open-sse/services/autoCombo/virtualFactory.ts": 1219,
"open-sse/services/autoCombo/virtualFactory.ts": 1230,
"open-sse/services/combo/roundRobinCombo.ts": 1205
},
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",

View File

@@ -224,14 +224,22 @@ async function toOutcome(
}
let message = attempt.response.statusText || "upstream error";
let body: unknown = attempt.transformedBody;
// #12867 dropped the upstream error code/type when this leg moved into the
// pipeline. Gates that key on both (isAntigravityMissingProjectError) then
// stopped firing, and a config-class 422 degraded into an account cooldown.
let upstreamCode: string | undefined;
let upstreamType: string | undefined;
try {
// clone() is the drain. sendProviderAttempt must not cancel() a streaming
// non-2xx body before we get here (BYOP 422 / Codex 429 Retry-After).
const text = await attempt.response.clone().text();
try {
body = JSON.parse(text);
const err = (body as { error?: { message?: unknown } } | null)?.error;
const err = (body as { error?: { message?: unknown; code?: unknown; type?: unknown } } | null)
?.error;
if (err && typeof err.message === "string" && err.message) message = err.message;
if (err && typeof err.code === "string" && err.code) upstreamCode = err.code;
if (err && typeof err.type === "string" && err.type) upstreamType = err.type;
} catch {
// Non-JSON upstream body (plain-text 429, HTML error page). parseUpstreamError
// — the pre-pipeline path this replaced — surfaces the raw text as the message;
@@ -250,7 +258,13 @@ async function toOutcome(
body,
retryAfterMs: null,
});
const result = createErrorResult(restatement.status, message, restatement.retryAfterMs);
const result = createErrorResult(
restatement.status,
message,
restatement.retryAfterMs,
upstreamCode,
upstreamType
);
return {
kind: "error",
result: {

View File

@@ -685,10 +685,21 @@ export async function prepareVirtualAutoComboInputs(
// back to the static catalog only when the user has none. This keeps catalog-only
// models (e.g. openrouter/auto) out of every auto/* pool when the operator only
// synced a subset (e.g. OpenRouter with importFreeModelsOnly).
const [syncedByConnection, customModels] = await Promise.all([
const [syncedByConnection, rawCustomModels] = await Promise.all([
getSyncedAvailableModelsByConnection(providerId),
getCustomModels(providerId),
]);
// The `customModels` key_value blob is operator-writable and is stored as raw
// parsed JSON, so a row can be `null` or a non-object. The catalog builder
// already filters those out (catalog.ts, "Add custom models"); without the same
// filter here every read below null-derefs and the whole auto/* pool fails to
// materialize ("Could not materialize built-in auto model auto/<id>").
const customModels: Array<{ id?: string }> = (
Array.isArray(rawCustomModels) ? rawCustomModels : []
).filter(
(model: unknown): model is { id?: string } =>
!!model && typeof model === "object" && !Array.isArray(model)
);
const userVisibleIds = new Set<string>();
for (const models of Object.values(syncedByConnection)) {
for (const m of models) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);

View File

@@ -408,7 +408,12 @@ const SCHEMA_SQL = `
CREATE INDEX IF NOT EXISTS idx_cl_timestamp ON call_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_cl_status ON call_logs(status);
CREATE INDEX IF NOT EXISTS idx_cl_provider_timestamp ON call_logs(provider, timestamp);
CREATE INDEX IF NOT EXISTS idx_cl_request_provider ON call_logs(request_type, provider);
-- idx_cl_request_provider is NOT declared here: SCHEMA_SQL runs before
-- ensureCallLogsColumns() heals a legacy call_logs table, and a lineage that
-- predates the request_type column has none yet — the CREATE INDEX would abort
-- the whole schema exec with "no such column: request_type" and the server would
-- never boot. It is created next to the other request_type/combo indexes in
-- ensureCallLogsColumns() (db/schemaColumns.ts), after the columns exist.
CREATE TABLE IF NOT EXISTS proxy_logs (
id TEXT PRIMARY KEY,

View File

@@ -265,6 +265,12 @@ export function ensureCallLogsColumns(db: SqliteDatabase) {
"CREATE INDEX IF NOT EXISTS idx_call_logs_requested_model ON call_logs(requested_model)"
);
db.exec("CREATE INDEX IF NOT EXISTS idx_call_logs_request_type ON call_logs(request_type)");
// #12832's provider-stats index. It lives here rather than in SCHEMA_SQL because
// SCHEMA_SQL runs before this healing pass: on a legacy call_logs table that
// predates `request_type` the CREATE INDEX aborts the whole schema exec.
db.exec(
"CREATE INDEX IF NOT EXISTS idx_cl_request_provider ON call_logs(request_type, provider)"
);
db.exec(
"CREATE INDEX IF NOT EXISTS idx_cl_combo_target ON call_logs(combo_name, combo_execution_key, timestamp)"
);

View File

@@ -58,7 +58,9 @@
"tests/unit/account-fallback-retry-after-json.test.ts",
"tests/unit/account-fallback-route-restriction-403.test.ts",
"tests/unit/account-fallback-service.test.ts",
"tests/unit/chat-correlation-id-exhaustion.test.ts",
"tests/unit/combo-pin-implicit-allowlist.test.ts",
"tests/unit/combo-predicates-epoch-cooldown.test.ts",
"tests/unit/combo/execute-target-gates.test.ts",
"tests/unit/moonshot-quota-writeback.test.ts",
"tests/unit/accountfallback-ratelimit-400-4976.test.ts",
@@ -79,6 +81,7 @@
"tests/unit/api-key-rotator-health.test.ts",
"tests/unit/chat-routing-synced-inventory-11089.test.ts",
"tests/unit/openai-compatible-per-upstream-402-health.test.ts",
"tests/unit/opencode-400-model-unavailable.test.ts",
"tests/unit/quota-window-label.test.ts",
"tests/unit/repro-combo-persisted-cooldown-preskip.test.ts",
"tests/unit/repro-glm-iso-reset-24h-cap.test.ts",

View File

@@ -2000,6 +2000,29 @@
"stream": "https://api.electronhub.ai/v1/chat/completions"
}
},
"eurouter": {
"format": "openai",
"headers": {
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
}
},
"url": {
"nonStream": "https://api.eurouter.ai/v1/chat/completions",
"stream": "https://api.eurouter.ai/v1/chat/completions"
}
},
"factory": {
"format": "openai",
"headers": {
@@ -2744,6 +2767,29 @@
"stream": "https://api.z.ai/api/coding/paas/v4/chat/completions"
}
},
"greenpt": {
"format": "openai",
"headers": {
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
}
},
"url": {
"nonStream": "https://api.greenpt.ai/v1/chat/completions",
"stream": "https://api.greenpt.ai/v1/chat/completions"
}
},
"grok-cli": {
"format": "openai",
"headers": {

View File

@@ -7,6 +7,14 @@ const source = readFileSync(
"utf8"
);
// #12867 split the send: chatCore keeps the per-attempt admission loop and the
// account/model recovery loop moved to providerExecutionPipeline.ts, which
// re-enters chatCore through sendProviderAttempt on every rotation.
const pipeline = readFileSync(
new URL("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts", import.meta.url),
"utf8"
);
test("chatCore acquires cumulative gates immediately before withRateLimit", () => {
const acquire = source.indexOf("await acquireConcurrencyGates(");
const rateLimit = source.indexOf("await withRateLimit(", acquire);
@@ -23,15 +31,49 @@ test("chatCore acquires cumulative gates immediately before withRateLimit", () =
assert.match(admission, /maxQueueDepth/);
});
// Invariant: a rotated account NEVER reuses the failed account's composite slot.
// Every attempt acquires its own global+provider+account slot and gives it back
// before the next attempt starts, so one wedged account cannot pin the gates of
// the sibling it rotated to. Before #12867 both loops lived in chatCore.ts and a
// single index check covered it; the loop is now split across two files, so the
// guard checks both halves of the same invariant.
test("each rotated account attempt acquires and releases a fresh composite slot", () => {
const attemptLoop = source.indexOf(
"while (attempts < maxAttempts || antigravityByopRotationPending)"
);
// ── chatCore half: one acquisition per attempt, released on every exit ──
const attemptLoop = source.indexOf("while (attempts < maxAttempts)");
const acquire = source.indexOf("await acquireConcurrencyGates(", attemptLoop);
const finallyRelease = source.indexOf("releaseAccountSemaphore();", acquire);
const release = source.indexOf("releaseAccountSemaphore();", acquire);
const retryContinue = source.indexOf("continue;", acquire);
assert.ok(attemptLoop >= 0 && acquire > attemptLoop);
assert.ok(finallyRelease > acquire, "each attempt must release the composite slot");
assert.ok(retryContinue > acquire, "rotation remains inside the per-attempt acquisition loop");
assert.ok(attemptLoop >= 0, "chatCore must keep the per-attempt admission loop");
assert.ok(acquire > attemptLoop, "the composite slot is acquired inside the attempt loop");
assert.ok(release > acquire, "each attempt must release the composite slot");
assert.ok(retryContinue > release, "an in-loop retry releases the slot before continuing");
assert.match(
source.slice(acquire),
/catch \(error\) \{\s*releaseAccountSemaphore\(\);\s*throw error;/,
"a throwing attempt must release the composite slot"
);
// ── pipeline half: rotation re-enters the acquisition, never sends in place ──
const rotationLoop = pipeline.search(/while \(\s*attempts < maxAttempts\b/);
assert.ok(rotationLoop >= 0, "the account/model recovery loop must exist");
assert.ok(
pipeline.indexOf("await sendProviderAttempt(") > rotationLoop,
"the wire send lives inside the recovery loop, so every attempt re-acquires"
);
assert.equal(
pipeline.includes("acquireConcurrencyGates"),
false,
"the recovery loop must not hold a composite slot across rotations"
);
assert.match(
pipeline.slice(rotationLoop),
/(?:antigravityByopRotationPending|authRefreshPending|modelFallbackPending)\s*=\s*true;\s*continue;/,
"a rotation hands control back to the loop head instead of re-sending in place"
);
assert.match(
pipeline.slice(rotationLoop),
/attempts \+= 1;\s*continue;/,
"account rotation hands control back to the loop head instead of re-sending in place"
);
});

View File

@@ -173,3 +173,40 @@ test("virtual auto-combo pool filters EVERY provider with partial sync, not just
"kilocode pool must contain exactly the two synced models"
);
});
test("virtual auto-combo pool survives a malformed customModels row", async () => {
// The `customModels` key_value blob is operator-writable and is read back as raw
// parsed JSON, so a row can be null / a non-object / carry no id. Before the guard
// in prepareVirtualAutoComboInputs those rows threw
// "Cannot read properties of null (reading 'id')" and EVERY auto/* combo failed to
// materialize ("[catalog] Could not materialize built-in auto model auto/<id>").
const conn = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "OpenAI",
apiKey: "sk-test-openai",
});
const connectionId = (conn as { id?: string }).id;
await modelsDb.replaceSyncedAvailableModelsForConnection("openai", connectionId, [
{ id: "gpt-4o-mini", name: "GPT-4o mini", source: "imported" as const },
]);
core
.getDbInstance()
.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)")
.run(
"customModels",
"openai",
JSON.stringify([null, "not-an-object", { name: "Missing Id" }, { id: "operator-custom" }])
);
const prepared = await virtualFactory.prepareVirtualAutoComboInputs();
const openaiCandidates = prepared.regularCandidates.filter((c) => c.provider === "openai");
assert.ok(
openaiCandidates.some((c) => c.model === "gpt-4o-mini"),
"the synced model must still reach the pool despite the malformed custom rows"
);
assert.ok(
openaiCandidates.some((c) => c.model === "operator-custom"),
"the one well-formed custom row must still reach the pool"
);
});

View File

@@ -129,12 +129,33 @@ test("source guard: auth.ts skips model lockout for per-model-quota providers on
});
test("source guard: combo.ts skips provider cooldown for per-model-quota on 500", () => {
const src = fs.readFileSync(
// The combo dispatcher was split out of combo.ts into open-sse/services/combo/*
// (#12746 executeTarget → gates/attempt/loop, #12811 round-robin). The invariant
// did not move: EVERY call site that records a provider cooldown after a failed
// combo target must first exclude a 500 on a per-model-quota provider, or one
// model's outage cools down its siblings. Scan the whole combo surface so the
// guard follows the code instead of one file name.
const comboDir = path.join(process.cwd(), "open-sse", "services", "combo");
const comboFiles = [
path.join(process.cwd(), "open-sse", "services", "combo.ts"),
"utf-8"
...fs
.readdirSync(comboDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
.map((entry) => path.join(comboDir, entry.name)),
].filter((file) => fs.existsSync(file));
const recordingFiles = comboFiles.filter((file) =>
fs.readFileSync(file, "utf-8").includes("recordProviderCooldown(")
);
assert.ok(
src.includes("hasPerModelQuota(provider, rawModel)") && src.includes("recordProviderCooldown"),
"combo.ts must skip provider cooldown recording for per-model-quota providers on 500"
recordingFiles.length > 0,
"no combo module records a provider cooldown — the guarded call site vanished"
);
for (const file of recordingFiles) {
const normalized = fs.readFileSync(file, "utf-8").replace(/\s+/g, " ");
assert.ok(
/result\.status === 500[^;]{0,160}?hasPerModelQuota\(provider,/.test(normalized),
`${path.basename(file)} must skip provider cooldown recording for per-model-quota providers on 500`
);
}
});

View File

@@ -14,13 +14,13 @@ process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "custom-live-12597-test-secret";
const core = await import("../../src/lib/db/core.ts");
const { addCustomModel, replaceSyncedAvailableModelsForConnection, getActiveProvidersWithSyncedModel } =
await import("../../src/lib/db/models.ts");
const {
getActiveSyncedCatalog,
catalogContainsModel,
reconcileProvidersWithActiveSyncedCatalog,
} = await import("../../src/lib/db/models/activeSyncedCatalog.ts");
addCustomModel,
replaceSyncedAvailableModelsForConnection,
getActiveProvidersWithSyncedModel,
} = await import("../../src/lib/db/models.ts");
const { getActiveSyncedCatalog, catalogContainsModel, reconcileProvidersWithActiveSyncedCatalog } =
await import("../../src/lib/db/models/activeSyncedCatalog.ts");
const PROVIDER = "github";
const CONNECTION_ID = "github-live-catalog-12597";

View File

@@ -5,12 +5,16 @@ import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
/**
* GLM's translateSseResponse used to pass a 16th positional (65536) to
* createSSETransformStreamWithLogger. The helper only has 15 parameters
* (last is requestToolIdentityMap) — tsc reports TS2554 and the number
* never reached TransformStream.
* GLM's translateSseResponse passed a 16th positional to
* createSSETransformStreamWithLogger from #12179 until #12925, while the helper
* still declared 15 parameters — tsc reported TS2554 and the number never
* reached TransformStream. #12925 closed the seam by declaring the slot as
* `streamBufferBytes`.
*
* Guard the call site in source: no 65536, last arg is suppressThinkClose.
* The invariant this guards has not changed: the buffer size GLM passes must
* land in a real parameter, never in a dropped extra positional. It is now
* checked from the other side — the helper must declare the slot last, and the
* call site must fill it with the named constant rather than a magic literal.
*/
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -26,18 +30,21 @@ function extractParens(src: string, openAt: number): string {
return src.slice(openAt, i);
}
test("createSSETransformStreamWithLogger has no highWaterMark slot", () => {
test("createSSETransformStreamWithLogger declares the buffer-size slot last", () => {
const src = readFileSync(join(root, "open-sse", "utils", "stream.ts"), "utf8");
const needle = "export function createSSETransformStreamWithLogger(";
const start = src.indexOf(needle);
assert.ok(start >= 0);
const header = extractParens(src, start + needle.length - 1);
assert.equal(/highWaterMark/.test(header), false, header);
assert.match(header, /requestToolIdentityMap/);
assert.match(header, /suppressThinkClose/);
// #12925: the slot GLM had been filling since #12179 is now declared, so the
// value reaches TransformStream instead of being dropped as an extra arg.
assert.match(header, /streamBufferBytes\s*:\s*number/);
assert.match(header, /streamBufferBytes[^,)]*\)\s*$/, `buffer size must stay last:\n${header}`);
});
test("GLM translateSseResponse does not pass a 16th positional to the stream helper", () => {
test("GLM translateSseResponse fills the buffer-size slot with the named constant", () => {
const src = readFileSync(join(root, "open-sse", "executors", "glm.ts"), "utf8");
const fnStart = src.indexOf("export function translateSseResponse(");
assert.ok(fnStart >= 0);
@@ -46,6 +53,13 @@ test("GLM translateSseResponse does not pass a 16th positional to the stream hel
const callAt = body.indexOf("createSSETransformStreamWithLogger(");
assert.ok(callAt >= 0);
const call = extractParens(body, callAt + "createSSETransformStreamWithLogger".length);
assert.equal(/65536/.test(call), false, `dead 16th arg still present:\n${call}`);
assert.match(call, /suppressThinkClose\s*\)\s*$/);
// A magic literal here is what made the original mismatch invisible; the call
// must reference the shared constant so a change to it cannot drift silently.
assert.equal(/65536/.test(call), false, `magic buffer literal is back:\n${call}`);
assert.match(
call,
/GLM_STREAM_BUFFER_BYTES\s*\)\s*$/,
`buffer size must be the last arg:\n${call}`
);
assert.match(call, /suppressThinkClose/, `suppressThinkClose must still be passed:\n${call}`);
});

View File

@@ -9,13 +9,18 @@ import assert from "node:assert/strict";
// Two properties are asserted at BOTH detection call sites:
// 1. A directive at the TOP of a large (>16 KB) body is STILL detected
// (real detection is not weakened — injection sits near the top).
// 2. The SAME unique injection marker placed BEYOND the 16 KB cap is
// 2. The SAME unique injection marker placed OUTSIDE the scan window is
// NOT scanned (proves the bound is active and CPU is saved).
//
// #13104 changed the SHAPE of that window without changing the bound: the
// budget is now split between the head and the TAIL, because the tail is
// where never-before-scanned content lands. So "outside the window" is the
// MIDDLE of an oversized body, not its end. The 16 KB ceiling — the property
// these cases exist to protect — is unchanged.
// ─────────────────────────────────────────────────────────────────────
const { detectInjection, MAX_INJECTION_SCAN_BYTES } = await import(
"../../src/shared/utils/inputSanitizer.ts"
);
const { detectInjection, MAX_INJECTION_SCAN_BYTES } =
await import("../../src/shared/utils/inputSanitizer.ts");
const { evaluatePromptInjection } = await import("../../src/lib/guardrails/promptInjection.ts");
// A real high-severity pattern from INJECTION_PATTERNS (system_override).
@@ -37,15 +42,15 @@ test("inputSanitizer.detectInjection: directive at the TOP of a >16 KB body is s
);
});
test("inputSanitizer.detectInjection: a directive BEYOND the 16 KB cap is NOT scanned", () => {
// Place the ONLY injection marker well past the cap. With the bound active
// the scan never reaches it, so nothing is flagged.
const body = `${padTo(MAX_INJECTION_SCAN_BYTES + 4096)}\n${INJECTION_DIRECTIVE}`;
test("inputSanitizer.detectInjection: a directive OUTSIDE the scan window is NOT scanned", () => {
// Place the ONLY injection marker in the middle — past the head slice and
// before the tail slice. With the bound active the scan never reaches it.
const body = `${padTo(MAX_INJECTION_SCAN_BYTES)}\n${INJECTION_DIRECTIVE}\n${padTo(MAX_INJECTION_SCAN_BYTES)}`;
const detections = detectInjection(body);
assert.equal(
detections.length,
0,
"an injection marker placed beyond the 16 KB cap must not be detected"
"an injection marker outside the 16 KB scan window must not be detected"
);
});
@@ -65,14 +70,14 @@ test("promptInjection guard: directive at the TOP of a >16 KB message is still f
);
});
test("promptInjection guard: a directive BEYOND the 16 KB cap is NOT scanned", () => {
// Single message whose only injection marker sits past the cap. The joined
// scan text is sliced to 16 KB before the regex loop, so it is not flagged.
test("promptInjection guard: a directive OUTSIDE the scan window is NOT scanned", () => {
// Single message whose only injection marker sits between the head and tail
// slices. The joined scan text stays bounded at 16 KB, so it is not flagged.
const body = {
messages: [
{
role: "user",
content: `${padTo(MAX_INJECTION_SCAN_BYTES + 4096)}\n${INJECTION_DIRECTIVE}`,
content: `${padTo(MAX_INJECTION_SCAN_BYTES)}\n${INJECTION_DIRECTIVE}\n${padTo(MAX_INJECTION_SCAN_BYTES)}`,
},
],
};
@@ -80,7 +85,7 @@ test("promptInjection guard: a directive BEYOND the 16 KB cap is NOT scanned", (
assert.equal(
decision.result.flagged,
false,
"an injection marker beyond the 16 KB cap must not be flagged"
"an injection marker outside the 16 KB scan window must not be flagged"
);
assert.equal(decision.blocked, false);
});

View File

@@ -7,6 +7,7 @@ import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-model-catalog-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "catalog-test-secret";
process.env.CATALOG_BUILD_TIMEOUT_MS = process.env.CATALOG_BUILD_TIMEOUT_MS || "120000"; // #12627 bound flakes a cold tsx build; 12627-catalog-inflight-timeout owns it
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");

View File

@@ -119,13 +119,16 @@ function makeInput(opts: {
}
test("initial Codex 429: rotation resolver>=1 and successful retry", async () => {
const { runProviderExecutionPipeline } = await import(
"../../open-sse/handlers/chatCore/providerExecutionPipeline.ts"
);
const { runProviderExecutionPipeline } =
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
let sendCount = 0;
let resolverCallCount = 0;
const input = makeInput({
policy: { allowAccountRotation: true, allowModelFallback: true, expectedConnectionId: undefined },
policy: {
allowAccountRotation: true,
allowModelFallback: true,
expectedConnectionId: undefined,
},
provider: "codex",
connectionId: "conn-a",
send: async () => {
@@ -135,10 +138,13 @@ test("initial Codex 429: rotation resolver>=1 and successful retry", async () =>
headers: { "retry-after": "1" },
});
}
return makeAttempt({
id: "chatcmpl-ok",
choices: [{ message: { role: "assistant", content: "rotated" }, finish_reason: "stop" }],
}, 200);
return makeAttempt(
{
id: "chatcmpl-ok",
choices: [{ message: { role: "assistant", content: "rotated" }, finish_reason: "stop" }],
},
200
);
},
getProviderCredentials: (async () => {
resolverCallCount += 1;
@@ -157,9 +163,8 @@ test("initial Codex 429: rotation resolver>=1 and successful retry", async () =>
});
test("initial Antigravity 422 gcp_project_required: rotation resolver>=1 and successful retry", async () => {
const { runProviderExecutionPipeline } = await import(
"../../open-sse/handlers/chatCore/providerExecutionPipeline.ts"
);
const { runProviderExecutionPipeline } =
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
let sendCount = 0;
let resolverCallCount = 0;
const input = makeInput({
@@ -169,7 +174,10 @@ test("initial Antigravity 422 gcp_project_required: rotation resolver>=1 and suc
send: async () => {
sendCount += 1;
if (sendCount === 1) {
return makeAttempt({ error: { message: "gcp_project_required", type: "invalid_request" } }, 422);
return makeAttempt(
{ error: { message: "gcp_project_required", type: "invalid_request" } },
422
);
}
return makeAttempt(
{
@@ -196,9 +204,8 @@ test("initial Antigravity 422 gcp_project_required: rotation resolver>=1 and suc
});
test("follow-up rotation blocks resolver on Antigravity 422", async () => {
const { runProviderExecutionPipeline } = await import(
"../../open-sse/handlers/chatCore/providerExecutionPipeline.ts"
);
const { runProviderExecutionPipeline } =
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
let sendCount = 0;
let resolverCallCount = 0;
const input = makeInput({
@@ -211,7 +218,10 @@ test("follow-up rotation blocks resolver on Antigravity 422", async () => {
connectionId: "agy-a",
send: async () => {
sendCount += 1;
return makeAttempt({ error: { message: "gcp_project_required", type: "invalid_request" } }, 422);
return makeAttempt(
{ error: { message: "gcp_project_required", type: "invalid_request" } },
422
);
},
getProviderCredentials: (async () => {
resolverCallCount += 1;
@@ -230,9 +240,8 @@ test("follow-up rotation blocks resolver on Antigravity 422", async () => {
});
test("follow-up rotation blocks resolver on Codex 429", async () => {
const { runProviderExecutionPipeline } = await import(
"../../open-sse/handlers/chatCore/providerExecutionPipeline.ts"
);
const { runProviderExecutionPipeline } =
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
let sendCount = 0;
let resolverCallCount = 0;
const input = makeInput({
@@ -264,9 +273,8 @@ test("follow-up rotation blocks resolver on Codex 429", async () => {
});
test("401 refresh succeeds then retries once on same connection", async () => {
const { runProviderExecutionPipeline } = await import(
"../../open-sse/handlers/chatCore/providerExecutionPipeline.ts"
);
const { runProviderExecutionPipeline } =
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
let sendCount = 0;
let refreshCount = 0;
let persistCount = 0;
@@ -278,12 +286,17 @@ test("401 refresh succeeds then retries once on same connection", async () => {
send: async () => {
sendCount += 1;
if (sendCount === 1) {
return makeAttempt({ error: { message: "invalid_api_key", type: "authentication_error" } }, 401);
return makeAttempt(
{ error: { message: "invalid_api_key", type: "authentication_error" } },
401
);
}
return makeAttempt(
{
id: "chatcmpl-ok",
choices: [{ message: { role: "assistant", content: "refreshed" }, finish_reason: "stop" }],
choices: [
{ message: { role: "assistant", content: "refreshed" }, finish_reason: "stop" },
],
},
200
);
@@ -314,9 +327,8 @@ test("401 refresh succeeds then retries once on same connection", async () => {
});
test("status restatement rewrites agentrouter 403 quota exhaustion to 429 before classification", async () => {
const { runProviderExecutionPipeline } = await import(
"../../open-sse/handlers/chatCore/providerExecutionPipeline.ts"
);
const { runProviderExecutionPipeline } =
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
let sendCount = 0;
const input = makeInput({
policy: { allowAccountRotation: true, allowModelFallback: true },
@@ -338,9 +350,8 @@ test("status restatement rewrites agentrouter 403 quota exhaustion to 429 before
});
test("thinking-signature recovery returns winning response", async () => {
const { runProviderExecutionPipeline } = await import(
"../../open-sse/handlers/chatCore/providerExecutionPipeline.ts"
);
const { runProviderExecutionPipeline } =
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
let sendCount = 0;
const input = makeInput({
policy: { allowAccountRotation: true, allowModelFallback: true },
@@ -350,7 +361,12 @@ test("thinking-signature recovery returns winning response", async () => {
sendCount += 1;
if (sendCount === 1) {
return makeAttempt(
{ error: { message: "invalid signature in thinking block", type: "invalid_request_error" } },
{
error: {
message: "invalid signature in thinking block",
type: "invalid_request_error",
},
},
400
);
}
@@ -390,9 +406,8 @@ test("thinking-signature recovery returns winning response", async () => {
});
test("initial model-unavailable falls back to sibling model", async () => {
const { runProviderExecutionPipeline } = await import(
"../../open-sse/handlers/chatCore/providerExecutionPipeline.ts"
);
const { runProviderExecutionPipeline } =
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
let sendCount = 0;
let fallbackLookupCount = 0;
const sentModels: string[] = [];
@@ -436,9 +451,8 @@ test("initial model-unavailable falls back to sibling model", async () => {
});
test("follow-up allowModelFallback=false blocks model-unavailable fallback", async () => {
const { runProviderExecutionPipeline } = await import(
"../../open-sse/handlers/chatCore/providerExecutionPipeline.ts"
);
const { runProviderExecutionPipeline } =
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
let sendCount = 0;
let fallbackLookupCount = 0;
const input = makeInput({
@@ -474,9 +488,8 @@ test("follow-up allowModelFallback=false blocks model-unavailable fallback", asy
});
test("Codex 429 rotation calls scope-rate-limit, affinity-clear, and audit hooks", async () => {
const { runProviderExecutionPipeline } = await import(
"../../open-sse/handlers/chatCore/providerExecutionPipeline.ts"
);
const { runProviderExecutionPipeline } =
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
const rateLimited: Array<Record<string, unknown>> = [];
const affinityCleared: string[] = [];
const audits: Array<Record<string, unknown>> = [];
@@ -529,9 +542,8 @@ test("Codex 429 rotation calls scope-rate-limit, affinity-clear, and audit hooks
});
test("Codex 429 cooldown reads Retry-After from the response, not request headers", async () => {
const { runProviderExecutionPipeline } = await import(
"../../open-sse/handlers/chatCore/providerExecutionPipeline.ts"
);
const { runProviderExecutionPipeline } =
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
const rateLimited: Array<Record<string, unknown>> = [];
let sendCount = 0;
const input = makeInput({
@@ -586,9 +598,8 @@ test("Codex 429 cooldown reads Retry-After from the response, not request header
});
test("Antigravity BYOP 422 rotation persists cooldown via setConnectionRateLimitedUntil", async () => {
const { runProviderExecutionPipeline } = await import(
"../../open-sse/handlers/chatCore/providerExecutionPipeline.ts"
);
const { runProviderExecutionPipeline } =
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
const cooldowns: Array<{ id: string; untilMs: number | null }> = [];
let sendCount = 0;
const input = makeInput({
@@ -630,3 +641,40 @@ test("Antigravity BYOP 422 rotation persists cooldown via setConnectionRateLimit
assert.equal(typeof cooldowns[0]?.untilMs, "number");
assert.equal((cooldowns[0]?.untilMs ?? 0) > Date.now(), true);
});
test("upstream error code/type survive into the error outcome", async () => {
const { runProviderExecutionPipeline } =
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
const input = makeInput({
policy: {
allowAccountRotation: false,
allowModelFallback: false,
expectedConnectionId: "agy-a",
},
provider: "antigravity",
connectionId: "agy-a",
send: async () =>
makeAttempt(
{
error: {
message: "Missing Google projectId for Antigravity account.",
type: "oauth_missing_project_id",
code: "missing_project_id",
},
},
422
),
});
const outcome = await runProviderExecutionPipeline(input);
assert.equal(outcome.kind, "error");
if (outcome.kind === "error") {
// #12867 dropped this pair when the leg moved into the pipeline, so
// downstream gates that key on BOTH fields (e.g.
// isAntigravityMissingProjectError) silently stopped firing and a
// config-class 422 degraded into a generic account cooldown.
assert.equal(outcome.result.errorCode, "missing_project_id");
assert.equal(outcome.result.errorType, "oauth_missing_project_id");
assert.equal(outcome.result.status, 422);
}
});

View File

@@ -180,7 +180,8 @@ test("shared set size includes live REGISTRY and retired Designer + Felo + Qwen
// 2026-09-02: a keyless provider was removed at its operator's request, taking its id and
// alias out of the REGISTRY walk (408 → 406).
// #11786: SeekAi adds id "seekai" + alias "ska" (406 → 408).
assert.equal(RESERVED_PREFIX_COUNT, 408);
// #13024/#13025: GreenPT and EURouter each add one REGISTRY id (408 → 410).
assert.equal(RESERVED_PREFIX_COUNT, 410);
});
test("isReservedProviderPrefix rejects non-string input", () => {

View File

@@ -34,6 +34,7 @@
// Perplexity Agent API (#12103) makes it 236;
// UC Direct (#11513, uncensored.com metered Developer API) adds one frontier-labs entry — 237;
// SeekAi (#11786, QuantumNous New-API gateway) adds one gateways entry — 238.
// GreenPT (#13024) and EURouter (#13025) each add one OpenAI-compatible gateway entry — 240.
import { test } from "node:test";
import assert from "node:assert/strict";
@@ -62,12 +63,12 @@ test("barrel still exports every catalog + key helpers", () => {
}
});
test("APIKEY_PROVIDERS merges the 6 family files into 238 entries (no loss / no dup)", async () => {
test("APIKEY_PROVIDERS merges the 6 family files into 240 entries (no loss / no dup)", async () => {
const keys = Object.keys((P as Record<string, object>).APIKEY_PROVIDERS);
assert.equal(keys.length, 238);
assert.equal(new Set(keys).size, 238, "duplicate keys after spread-merge");
assert.equal(keys.length, 240);
assert.equal(new Set(keys).size, 240, "duplicate keys after spread-merge");
// the merged object's entry-count equals the sum of the 6 semantic family files; families are a
// strict partition (every provider in exactly one), so the sum must be exactly 238.
// strict partition (every provider in exactly one), so the sum must be exactly 240.
const families: [string, string][] = [
["gateways", "APIKEY_PROVIDERS_GATEWAYS"],
["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"],
@@ -87,7 +88,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 238 entries (no loss / no
seen.add(k);
}
}
assert.equal(famTotal, 238, "families must partition all 238 providers");
assert.equal(famTotal, 240, "families must partition all 240 providers");
});
test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {