mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 13:52:28 +03:00
Merged after a real reconciliation — this PR and #13069 fixed the same #12867 regression (the non-streaming leg losing failure classification and credential refresh) with different strategies, and #13069 landed first. Rather than stacking two implementations, the tip's was kept and this PR was trimmed to what the tip still lacked. **Dropped, already on the tip:** - non-streaming credential refresh and failure-state persistence → #13069 (`applyProviderFailureClassification`); the `persistProviderFailureState` hook this PR added would have been dead code - body-derived rate-limit lock and non-JSON body message → already in the tip's pipeline (`chat-rate-limit-body-lock` passes there) - codex image-generation stringify of a sanitized error body → #12945 (`stringifyImageErrorForLog`, which would otherwise be declared twice — TS2393) - the two test realignments (`hard-session-lease` inventory wording, kiro stream reader) → #12945 **Kept, missing on the tip:** - **upstream error `code`/`type` in the pipeline error outcome** — ported onto the tip's implementation. Running this PR's own test against the tip returned `errorCode: undefined` instead of `missing_project_id`, so a config-class Antigravity 422 still degraded into an account cooldown. - legacy `call_logs` boot abort (index created after column healing) - malformed operator custom-models row no longer kills every `auto/*` pool (`virtualFactory.ts` 1219 → 1230, annotated) - realigned guards **Evidence on the reconciled tree** - 104 of 106 focused assertions. The 2 red (`models-catalog-route`, `provider-node-reserved-prefix`) fail identically on the pure tip. Against the tip, this PR turns 7 previously red cases green. - ESLint, `typecheck:core`, `check:open-sse-typecheck` (0 errors), `check:changelog-integrity`: all clean - changelog fragment rewritten to claim only what this PR still delivers Unblocking this also surfaced a repo-wide red: the eight llm.txt mirrors added by #13660 kept the pre-#13216/#13248 counts, which failed the pre-commit `docs-sync` gate for everyone. Fixed separately in #13674. `skills/cli-tunnel/SKILL.md` needed no change — #13216 already landed the identical edit. ⚠️ base-red inherited: #12732
681 lines
23 KiB
TypeScript
681 lines
23 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import type {
|
|
ChatCoreExecutorResult,
|
|
PipelineConnectionContext,
|
|
PipelineStateHooks,
|
|
PipelineTargetContext,
|
|
PipelineWireState,
|
|
ProviderExecutionPipelineInput,
|
|
ProviderExecutionPolicy,
|
|
} from "../../open-sse/handlers/chatCore/providerExecutionPipeline.ts";
|
|
|
|
test("runProviderExecutionPipeline is importable", async () => {
|
|
const mod = await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
|
|
assert.equal(typeof mod.runProviderExecutionPipeline, "function");
|
|
});
|
|
|
|
function jsonResponse(body: unknown, status: number, extraHeaders: Record<string, string> = {}) {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json", ...extraHeaders },
|
|
});
|
|
}
|
|
|
|
function makeAttempt(
|
|
body: unknown,
|
|
status: number,
|
|
extra: Partial<ChatCoreExecutorResult> = {}
|
|
): ChatCoreExecutorResult {
|
|
const response = jsonResponse(body, status, extra.headers as Record<string, string> | undefined);
|
|
return {
|
|
response,
|
|
url: extra.url ?? "https://upstream.test/v1/chat/completions",
|
|
headers: extra.headers ?? { "content-type": "application/json" },
|
|
transformedBody: extra.transformedBody ?? { model: "gpt-5" },
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function noopState(): PipelineStateHooks {
|
|
return {
|
|
updatePendingStage: () => {},
|
|
recordRateLimitHeaders: () => {},
|
|
recordRateLimitBody: () => {},
|
|
writeTerminalStatus: async () => {},
|
|
persistConnectionPatch: () => {},
|
|
setConnectionRateLimitedUntil: () => {},
|
|
lockModel: () => {},
|
|
recordAntigravityQuotaState: async () => {},
|
|
markAccountSemaphoreBlocked: () => {},
|
|
isolateProbeFailures: () => false,
|
|
};
|
|
}
|
|
|
|
function makeInput(opts: {
|
|
policy: ProviderExecutionPolicy;
|
|
provider: string;
|
|
model?: string;
|
|
stream?: boolean;
|
|
connectionId?: string;
|
|
send: (model: string, allowDedup: boolean) => Promise<ChatCoreExecutorResult>;
|
|
getProviderCredentials?: PipelineConnectionContext["getProviderCredentials"];
|
|
replaceCredentials?: PipelineConnectionContext["replaceCredentials"];
|
|
getCurrentConnectionId?: () => string | undefined;
|
|
refreshCredentials?: PipelineConnectionContext["refreshCredentials"];
|
|
onCredentialsRefreshed?: PipelineConnectionContext["onCredentialsRefreshed"];
|
|
getNextFamilyFallback?: ProviderExecutionPipelineInput["getNextFamilyFallback"];
|
|
state?: Partial<PipelineStateHooks>;
|
|
}): ProviderExecutionPipelineInput {
|
|
const model = opts.model ?? "gpt-5";
|
|
const connectionId = opts.connectionId ?? "conn-a";
|
|
let currentId: string | undefined = connectionId;
|
|
let credentials: Record<string, unknown> = { connectionId };
|
|
const target: PipelineTargetContext = {
|
|
provider: opts.provider,
|
|
requestedModel: model,
|
|
sourceFormat: "openai",
|
|
targetFormat: "openai",
|
|
stream: opts.stream ?? false,
|
|
};
|
|
const wire: PipelineWireState = {
|
|
body: { model, messages: [{ role: "user", content: "hi" }] },
|
|
currentModel: model,
|
|
triedModels: new Set([model]),
|
|
setBodyAndModel: (body, nextModel) => {
|
|
wire.body = body;
|
|
wire.currentModel = nextModel;
|
|
wire.triedModels.add(nextModel);
|
|
},
|
|
};
|
|
const connection: PipelineConnectionContext = {
|
|
initialConnectionId: connectionId,
|
|
getCurrentConnectionId: opts.getCurrentConnectionId ?? (() => currentId),
|
|
getCredentials: () => credentials,
|
|
replaceCredentials:
|
|
opts.replaceCredentials ??
|
|
((next) => {
|
|
credentials = next;
|
|
currentId = typeof next.connectionId === "string" ? next.connectionId : currentId;
|
|
}),
|
|
onCredentialsRefreshed: opts.onCredentialsRefreshed ?? (() => {}),
|
|
assertManagedLeaseFence: () => {},
|
|
refreshCredentials: opts.refreshCredentials,
|
|
getProviderCredentials:
|
|
opts.getProviderCredentials ??
|
|
(async () => {
|
|
throw new Error("getProviderCredentials must not be called in this fixture");
|
|
}),
|
|
};
|
|
return {
|
|
policy: opts.policy,
|
|
target,
|
|
connection,
|
|
wire,
|
|
state: { ...noopState(), ...(opts.state || {}) },
|
|
sendProviderAttempt: opts.send,
|
|
getNextFamilyFallback: opts.getNextFamilyFallback,
|
|
};
|
|
}
|
|
|
|
test("initial Codex 429: rotation resolver>=1 and successful retry", async () => {
|
|
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,
|
|
},
|
|
provider: "codex",
|
|
connectionId: "conn-a",
|
|
send: async () => {
|
|
sendCount += 1;
|
|
if (sendCount === 1) {
|
|
return makeAttempt({ error: { message: "rate limited", type: "rate_limit_error" } }, 429, {
|
|
headers: { "retry-after": "1" },
|
|
});
|
|
}
|
|
return makeAttempt(
|
|
{
|
|
id: "chatcmpl-ok",
|
|
choices: [{ message: { role: "assistant", content: "rotated" }, finish_reason: "stop" }],
|
|
},
|
|
200
|
|
);
|
|
},
|
|
getProviderCredentials: (async () => {
|
|
resolverCallCount += 1;
|
|
return { connectionId: "conn-b", allRateLimited: false };
|
|
}) as PipelineConnectionContext["getProviderCredentials"],
|
|
});
|
|
|
|
const outcome = await runProviderExecutionPipeline(input);
|
|
assert.equal(resolverCallCount >= 1, true, "resolver must run on initial Codex 429");
|
|
assert.equal(sendCount, 2, "second send after rotation");
|
|
assert.equal(outcome.kind, "response");
|
|
if (outcome.kind === "response") {
|
|
assert.equal(outcome.connectionId, "conn-b");
|
|
assert.equal(outcome.response.status, 200);
|
|
}
|
|
});
|
|
|
|
test("initial Antigravity 422 gcp_project_required: rotation resolver>=1 and successful retry", async () => {
|
|
const { runProviderExecutionPipeline } =
|
|
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
|
|
let sendCount = 0;
|
|
let resolverCallCount = 0;
|
|
const input = makeInput({
|
|
policy: { allowAccountRotation: true, allowModelFallback: true },
|
|
provider: "antigravity",
|
|
connectionId: "agy-a",
|
|
send: async () => {
|
|
sendCount += 1;
|
|
if (sendCount === 1) {
|
|
return makeAttempt(
|
|
{ error: { message: "gcp_project_required", type: "invalid_request" } },
|
|
422
|
|
);
|
|
}
|
|
return makeAttempt(
|
|
{
|
|
id: "chatcmpl-ok",
|
|
choices: [{ message: { role: "assistant", content: "rotated" }, finish_reason: "stop" }],
|
|
},
|
|
200
|
|
);
|
|
},
|
|
getProviderCredentials: (async () => {
|
|
resolverCallCount += 1;
|
|
return { connectionId: "agy-b", allRateLimited: false };
|
|
}) as PipelineConnectionContext["getProviderCredentials"],
|
|
});
|
|
|
|
const outcome = await runProviderExecutionPipeline(input);
|
|
assert.equal(resolverCallCount >= 1, true, "resolver must run on initial Antigravity BYOP 422");
|
|
assert.equal(sendCount, 2, "second send after BYOP rotation");
|
|
assert.equal(outcome.kind, "response");
|
|
if (outcome.kind === "response") {
|
|
assert.equal(outcome.connectionId, "agy-b");
|
|
assert.equal(outcome.response.status, 200);
|
|
}
|
|
});
|
|
|
|
test("follow-up rotation blocks resolver on Antigravity 422", async () => {
|
|
const { runProviderExecutionPipeline } =
|
|
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
|
|
let sendCount = 0;
|
|
let resolverCallCount = 0;
|
|
const input = makeInput({
|
|
policy: {
|
|
allowAccountRotation: false,
|
|
allowModelFallback: false,
|
|
expectedConnectionId: "agy-a",
|
|
},
|
|
provider: "antigravity",
|
|
connectionId: "agy-a",
|
|
send: async () => {
|
|
sendCount += 1;
|
|
return makeAttempt(
|
|
{ error: { message: "gcp_project_required", type: "invalid_request" } },
|
|
422
|
|
);
|
|
},
|
|
getProviderCredentials: (async () => {
|
|
resolverCallCount += 1;
|
|
return { connectionId: "agy-b", allRateLimited: false };
|
|
}) as PipelineConnectionContext["getProviderCredentials"],
|
|
});
|
|
|
|
const outcome = await runProviderExecutionPipeline(input);
|
|
assert.equal(resolverCallCount, 0, "follow-up must not call credentials resolver");
|
|
assert.equal(sendCount, 1, "follow-up sends once");
|
|
assert.equal(outcome.kind, "error");
|
|
if (outcome.kind === "error") {
|
|
assert.equal(outcome.result.status, 422);
|
|
assert.equal(outcome.connectionId, "agy-a");
|
|
}
|
|
});
|
|
|
|
test("follow-up rotation blocks resolver on Codex 429", async () => {
|
|
const { runProviderExecutionPipeline } =
|
|
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
|
|
let sendCount = 0;
|
|
let resolverCallCount = 0;
|
|
const input = makeInput({
|
|
policy: {
|
|
allowAccountRotation: false,
|
|
allowModelFallback: false,
|
|
expectedConnectionId: "conn-a",
|
|
},
|
|
provider: "codex",
|
|
connectionId: "conn-a",
|
|
send: async () => {
|
|
sendCount += 1;
|
|
return makeAttempt({ error: { message: "rate limited", type: "rate_limit_error" } }, 429);
|
|
},
|
|
getProviderCredentials: (async () => {
|
|
resolverCallCount += 1;
|
|
return { connectionId: "conn-b", allRateLimited: false };
|
|
}) as PipelineConnectionContext["getProviderCredentials"],
|
|
});
|
|
|
|
const outcome = await runProviderExecutionPipeline(input);
|
|
assert.equal(resolverCallCount, 0, "follow-up must not call credentials resolver");
|
|
assert.equal(sendCount, 1, "follow-up sends once");
|
|
assert.equal(outcome.kind, "error");
|
|
if (outcome.kind === "error") {
|
|
assert.equal(outcome.result.status, 429);
|
|
assert.equal(outcome.connectionId, "conn-a");
|
|
}
|
|
});
|
|
|
|
test("401 refresh succeeds then retries once on same connection", async () => {
|
|
const { runProviderExecutionPipeline } =
|
|
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
|
|
let sendCount = 0;
|
|
let refreshCount = 0;
|
|
let persistCount = 0;
|
|
let resolverCallCount = 0;
|
|
const input = makeInput({
|
|
policy: { allowAccountRotation: true, allowModelFallback: true },
|
|
provider: "openai",
|
|
connectionId: "conn-a",
|
|
send: async () => {
|
|
sendCount += 1;
|
|
if (sendCount === 1) {
|
|
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" },
|
|
],
|
|
},
|
|
200
|
|
);
|
|
},
|
|
refreshCredentials: async (creds) => {
|
|
refreshCount += 1;
|
|
return { ...creds, accessToken: "new-token" };
|
|
},
|
|
onCredentialsRefreshed: async () => {
|
|
persistCount += 1;
|
|
},
|
|
getProviderCredentials: (async () => {
|
|
resolverCallCount += 1;
|
|
return { connectionId: "conn-b", allRateLimited: false };
|
|
}) as PipelineConnectionContext["getProviderCredentials"],
|
|
});
|
|
|
|
const outcome = await runProviderExecutionPipeline(input);
|
|
assert.equal(refreshCount, 1, "refresh once");
|
|
assert.equal(persistCount, 1, "onCredentialsRefreshed once");
|
|
assert.equal(resolverCallCount, 0, "401 refresh must not rotate accounts");
|
|
assert.equal(sendCount, 2, "retry once after refresh");
|
|
assert.equal(outcome.kind, "response");
|
|
if (outcome.kind === "response") {
|
|
assert.equal(outcome.connectionId, "conn-a");
|
|
assert.equal(outcome.response.status, 200);
|
|
}
|
|
});
|
|
|
|
test("status restatement rewrites agentrouter 403 quota exhaustion to 429 before classification", async () => {
|
|
const { runProviderExecutionPipeline } =
|
|
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
|
|
let sendCount = 0;
|
|
const input = makeInput({
|
|
policy: { allowAccountRotation: true, allowModelFallback: true },
|
|
provider: "agentrouter",
|
|
connectionId: "ar-a",
|
|
send: async () => {
|
|
sendCount += 1;
|
|
return makeAttempt({ error: { message: "用户额度不足", type: "forbidden" } }, 403);
|
|
},
|
|
});
|
|
|
|
const outcome = await runProviderExecutionPipeline(input);
|
|
assert.equal(sendCount, 1);
|
|
assert.equal(outcome.kind, "error");
|
|
if (outcome.kind === "error") {
|
|
assert.equal(outcome.result.status, 429, "restated before classification");
|
|
assert.equal(outcome.connectionId, "ar-a");
|
|
}
|
|
});
|
|
|
|
test("thinking-signature recovery returns winning response", async () => {
|
|
const { runProviderExecutionPipeline } =
|
|
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
|
|
let sendCount = 0;
|
|
const input = makeInput({
|
|
policy: { allowAccountRotation: true, allowModelFallback: true },
|
|
provider: "claude",
|
|
connectionId: "cl-a",
|
|
send: async () => {
|
|
sendCount += 1;
|
|
if (sendCount === 1) {
|
|
return makeAttempt(
|
|
{
|
|
error: {
|
|
message: "invalid signature in thinking block",
|
|
type: "invalid_request_error",
|
|
},
|
|
},
|
|
400
|
|
);
|
|
}
|
|
return makeAttempt(
|
|
{
|
|
id: "msg-ok",
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "recovered" }],
|
|
},
|
|
200
|
|
);
|
|
},
|
|
});
|
|
input.wire.body = {
|
|
model: "gpt-5",
|
|
messages: [
|
|
{ role: "user", content: "q1" },
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "thinking", thinking: "old" },
|
|
{ type: "text", text: "a1" },
|
|
],
|
|
},
|
|
{ role: "user", content: "q2" },
|
|
],
|
|
};
|
|
|
|
const outcome = await runProviderExecutionPipeline(input);
|
|
assert.equal(sendCount, 2, "one recovery send after signature error");
|
|
assert.equal(outcome.kind, "response");
|
|
if (outcome.kind === "response") {
|
|
assert.equal(outcome.response.status, 200);
|
|
assert.equal(outcome.connectionId, "cl-a");
|
|
}
|
|
});
|
|
|
|
test("initial model-unavailable falls back to sibling model", async () => {
|
|
const { runProviderExecutionPipeline } =
|
|
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
|
|
let sendCount = 0;
|
|
let fallbackLookupCount = 0;
|
|
const sentModels: string[] = [];
|
|
const input = makeInput({
|
|
policy: { allowAccountRotation: true, allowModelFallback: true },
|
|
provider: "openai",
|
|
model: "gpt-5",
|
|
connectionId: "conn-a",
|
|
send: async (model) => {
|
|
sendCount += 1;
|
|
sentModels.push(model);
|
|
if (model === "gpt-5") {
|
|
return makeAttempt(
|
|
{ error: { message: "model is not available", type: "invalid_request_error" } },
|
|
404
|
|
);
|
|
}
|
|
return makeAttempt(
|
|
{
|
|
id: "chatcmpl-ok",
|
|
choices: [{ message: { role: "assistant", content: "fallback" }, finish_reason: "stop" }],
|
|
},
|
|
200
|
|
);
|
|
},
|
|
getNextFamilyFallback: (current) => {
|
|
fallbackLookupCount += 1;
|
|
return current === "gpt-5" ? "gpt-5-mini" : null;
|
|
},
|
|
});
|
|
|
|
const outcome = await runProviderExecutionPipeline(input);
|
|
assert.equal(fallbackLookupCount >= 1, true, "family fallback consulted");
|
|
assert.deepEqual(sentModels, ["gpt-5", "gpt-5-mini"]);
|
|
assert.equal(sendCount, 2);
|
|
assert.equal(outcome.kind, "response");
|
|
if (outcome.kind === "response") {
|
|
assert.equal(outcome.model, "gpt-5-mini");
|
|
assert.equal(outcome.response.status, 200);
|
|
}
|
|
});
|
|
|
|
test("follow-up allowModelFallback=false blocks model-unavailable fallback", async () => {
|
|
const { runProviderExecutionPipeline } =
|
|
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
|
|
let sendCount = 0;
|
|
let fallbackLookupCount = 0;
|
|
const input = makeInput({
|
|
policy: {
|
|
allowAccountRotation: false,
|
|
allowModelFallback: false,
|
|
expectedConnectionId: "conn-a",
|
|
},
|
|
provider: "openai",
|
|
model: "gpt-5",
|
|
connectionId: "conn-a",
|
|
send: async () => {
|
|
sendCount += 1;
|
|
return makeAttempt(
|
|
{ error: { message: "model is not available", type: "invalid_request_error" } },
|
|
404
|
|
);
|
|
},
|
|
getNextFamilyFallback: () => {
|
|
fallbackLookupCount += 1;
|
|
return "gpt-5-mini";
|
|
},
|
|
});
|
|
|
|
const outcome = await runProviderExecutionPipeline(input);
|
|
assert.equal(fallbackLookupCount, 0, "follow-up must not consult family fallback");
|
|
assert.equal(sendCount, 1);
|
|
assert.equal(outcome.kind, "error");
|
|
if (outcome.kind === "error") {
|
|
assert.equal(outcome.result.status, 404);
|
|
assert.equal(outcome.model, "gpt-5");
|
|
}
|
|
});
|
|
|
|
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 rateLimited: Array<Record<string, unknown>> = [];
|
|
const affinityCleared: string[] = [];
|
|
const audits: Array<Record<string, unknown>> = [];
|
|
let sendCount = 0;
|
|
const input = makeInput({
|
|
policy: { allowAccountRotation: true, allowModelFallback: true },
|
|
provider: "codex",
|
|
connectionId: "conn-a",
|
|
send: async () => {
|
|
sendCount += 1;
|
|
if (sendCount === 1) {
|
|
return makeAttempt({ error: { message: "rate limited", type: "rate_limit_error" } }, 429, {
|
|
headers: { "retry-after": "2" },
|
|
});
|
|
}
|
|
return makeAttempt(
|
|
{
|
|
id: "chatcmpl-ok",
|
|
choices: [{ message: { role: "assistant", content: "rotated" }, finish_reason: "stop" }],
|
|
},
|
|
200
|
|
);
|
|
},
|
|
getProviderCredentials: (async () => ({
|
|
connectionId: "conn-b",
|
|
allRateLimited: false,
|
|
})) as PipelineConnectionContext["getProviderCredentials"],
|
|
state: {
|
|
onCodexScopeRateLimited: (params) => {
|
|
rateLimited.push(params as unknown as Record<string, unknown>);
|
|
},
|
|
onClearSessionAffinity: (params) => {
|
|
affinityCleared.push(params.failedConnectionId);
|
|
},
|
|
onAuditAccountRotation: (params) => {
|
|
audits.push(params as unknown as Record<string, unknown>);
|
|
},
|
|
},
|
|
});
|
|
|
|
const outcome = await runProviderExecutionPipeline(input);
|
|
assert.equal(outcome.kind, "response");
|
|
assert.equal(rateLimited.length, 1, "must persist Codex model-scope cooldown");
|
|
assert.equal(rateLimited[0]?.failedConnectionId, "conn-a");
|
|
assert.deepEqual(affinityCleared, ["conn-a"]);
|
|
assert.equal(audits.length, 1);
|
|
assert.equal(audits[0]?.action, "codex.account_rotation");
|
|
assert.equal(audits[0]?.failedConnectionId, "conn-a");
|
|
assert.equal(audits[0]?.newConnectionId, "conn-b");
|
|
});
|
|
|
|
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 rateLimited: Array<Record<string, unknown>> = [];
|
|
let sendCount = 0;
|
|
const input = makeInput({
|
|
policy: { allowAccountRotation: true, allowModelFallback: true },
|
|
provider: "codex",
|
|
connectionId: "conn-a",
|
|
send: async () => {
|
|
sendCount += 1;
|
|
if (sendCount === 1) {
|
|
// BaseExecutor puts REQUEST headers on attempt.headers (Authorization).
|
|
// Upstream Retry-After lives on the Response. Mixing the two bags is the
|
|
// extract regression: cooldown silently falls back to 60s.
|
|
return {
|
|
response: jsonResponse(
|
|
{ error: { message: "rate limited", type: "rate_limit_error" } },
|
|
429,
|
|
{ "Retry-After": "5" }
|
|
),
|
|
url: "https://upstream.test/v1/chat/completions",
|
|
headers: { Authorization: "Bearer request-token", "content-type": "application/json" },
|
|
transformedBody: { model: "gpt-5" },
|
|
};
|
|
}
|
|
return makeAttempt(
|
|
{
|
|
id: "chatcmpl-ok",
|
|
choices: [{ message: { role: "assistant", content: "rotated" }, finish_reason: "stop" }],
|
|
},
|
|
200
|
|
);
|
|
},
|
|
getProviderCredentials: (async () => ({
|
|
connectionId: "conn-b",
|
|
allRateLimited: false,
|
|
})) as PipelineConnectionContext["getProviderCredentials"],
|
|
state: {
|
|
onCodexScopeRateLimited: (params) => {
|
|
rateLimited.push(params as unknown as Record<string, unknown>);
|
|
},
|
|
},
|
|
});
|
|
|
|
const outcome = await runProviderExecutionPipeline(input);
|
|
assert.equal(outcome.kind, "response");
|
|
assert.equal(rateLimited.length, 1, "must persist Codex model-scope cooldown");
|
|
const until = new Date(String(rateLimited[0]?.rateLimitedUntil)).getTime();
|
|
const delta = until - Date.now();
|
|
assert.ok(
|
|
delta > 4_000 && delta < 8_000,
|
|
`Retry-After: 5 must yield ~5s cooldown, got ${delta}ms (60s = still reading request headers)`
|
|
);
|
|
});
|
|
|
|
test("Antigravity BYOP 422 rotation persists cooldown via setConnectionRateLimitedUntil", async () => {
|
|
const { runProviderExecutionPipeline } =
|
|
await import("../../open-sse/handlers/chatCore/providerExecutionPipeline.ts");
|
|
const cooldowns: Array<{ id: string; untilMs: number | null }> = [];
|
|
let sendCount = 0;
|
|
const input = makeInput({
|
|
policy: { allowAccountRotation: true, allowModelFallback: true },
|
|
provider: "antigravity",
|
|
connectionId: "agy-a",
|
|
send: async () => {
|
|
sendCount += 1;
|
|
if (sendCount === 1) {
|
|
return makeAttempt(
|
|
{ error: { message: "gcp_project_required", type: "invalid_request" } },
|
|
422
|
|
);
|
|
}
|
|
return makeAttempt(
|
|
{
|
|
id: "chatcmpl-ok",
|
|
choices: [{ message: { role: "assistant", content: "rotated" }, finish_reason: "stop" }],
|
|
},
|
|
200
|
|
);
|
|
},
|
|
getProviderCredentials: (async () => ({
|
|
connectionId: "agy-b",
|
|
allRateLimited: false,
|
|
})) as PipelineConnectionContext["getProviderCredentials"],
|
|
state: {
|
|
setConnectionRateLimitedUntil: (id, untilMs) => {
|
|
cooldowns.push({ id, untilMs });
|
|
},
|
|
},
|
|
});
|
|
|
|
const outcome = await runProviderExecutionPipeline(input);
|
|
assert.equal(outcome.kind, "response");
|
|
assert.equal(sendCount, 2);
|
|
assert.equal(cooldowns.length, 1, "BYOP rotate must persist cooldown before picking sibling");
|
|
assert.equal(cooldowns[0]?.id, "agy-a");
|
|
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);
|
|
}
|
|
});
|