mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 06:42:19 +03:00
Compare commits
22 Commits
fix/13452-
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ee4154829 | ||
|
|
539d2236f5 | ||
|
|
6ad5e4ce3f | ||
|
|
3047359386 | ||
|
|
84f018cfd5 | ||
|
|
889a2bb388 | ||
|
|
b1d58c516e | ||
|
|
555d97f12c | ||
|
|
11e82e022e | ||
|
|
480f4a2bbb | ||
|
|
39300717ab | ||
|
|
ea7ba79a73 | ||
|
|
3603a7b246 | ||
|
|
be40cadd01 | ||
|
|
9e4955b069 | ||
|
|
47b6fb837a | ||
|
|
e25b706c56 | ||
|
|
7edda9c30a | ||
|
|
b4616e4316 | ||
|
|
b3d3d9524c | ||
|
|
ed44f4ae12 | ||
|
|
2724ad07e1 |
4
changelog.d/fixes/12732-basereds-orphans-v3851.md
Normal file
4
changelog.d/fixes/12732-basereds-orphans-v3851.md
Normal 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))
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user