From bc6129bcb2a4745880883e48baf0693786802aed Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 20 Aug 2026 06:37:02 -0300 Subject: [PATCH] fix(relay): normalize bifrost errors, remap credential 404, fix analytics (#10797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — the 5 pre-existing tests that broke from this PR's intentional 404→401 remap (single-model no-credentials) are now realigned to the new contract. Thanks! --- .../api/v1/relay/chat/completions/route.ts | 36 ++- src/sse/handlers/chat.ts | 3 +- src/sse/handlers/chatHelpers.ts | 25 +- tests/integration/chat-pipeline.test.ts | 6 +- tests/integration/combo-routing-e2e.test.ts | 6 +- tests/integration/llama-cpp-provider.test.ts | 5 +- .../api/v1/relay-completions-errors.test.ts | 253 ++++++++++++++++++ tests/unit/chat-helpers.test.ts | 74 ++++- tests/unit/chat-route-coverage.test.ts | 12 +- .../unit/fix-error-message-candidates.test.ts | 17 +- tests/unit/vscode-token-routes.test.ts | 16 +- 11 files changed, 422 insertions(+), 31 deletions(-) create mode 100644 tests/unit/api/v1/relay-completions-errors.test.ts diff --git a/src/app/api/v1/relay/chat/completions/route.ts b/src/app/api/v1/relay/chat/completions/route.ts index b92b9cbfe0..12ff30bea4 100644 --- a/src/app/api/v1/relay/chat/completions/route.ts +++ b/src/app/api/v1/relay/chat/completions/route.ts @@ -10,7 +10,11 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { handleChat } from "@/sse/handlers/chat"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; import { getRelayTokenByHash, checkRateLimit, recordRelayUsage } from "@/lib/db/relayProxies"; -import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { + buildErrorBody, + parseUpstreamError, + sanitizeErrorMessage, +} from "@omniroute/open-sse/utils/error"; import { checkIpRateLimit, extractToken, @@ -29,6 +33,7 @@ import { import { getProviderPluginManifestEntryForModel } from "@omniroute/open-sse/config/providerPluginManifestRegistry.ts"; import { getProviderPluginManifestHeader } from "@omniroute/open-sse/config/providerPluginManifestUrl.ts"; import { finalizeReadableStream } from "./streamFinalizer"; +import { stripStaleEncodingHeaders } from "@omniroute/open-sse/utils/upstreamResponseHeaders.ts"; import { clearBifrostFailure, getActiveBifrostCooldown, @@ -108,6 +113,32 @@ async function forwardToBifrost( headers.set("Content-Type", upstream.headers.get("Content-Type") ?? "application/json"); } + // Issue #1: Bifrost (or the upstream behind it) may return plain text or HTML + // on a non-OK status (e.g. 502 from a sidecar, "invalid character 'd'" style + // proxy errors). Forwarding `upstream.body` raw leaks non-JSON into a client + // that expects OpenAI-shaped JSON, producing client-side parse failures. + // Normalize any non-OK response through parseUpstreamError + buildErrorBody so + // the client always receives a valid JSON error. (Hard rule #12.) + if (!upstream.ok) { + const parsed = await parseUpstreamError(upstream, null); + const errorBody = buildErrorBody( + parsed.statusCode, + sanitizeErrorMessage(parsed.message), + parsed.responseBody + ); + const errorHeaders = stripStaleEncodingHeaders(headers); + errorHeaders.set("Content-Type", "application/json"); + if (parsed.retryAfterMs && parsed.retryAfterMs > 0) { + errorHeaders.set("Retry-After", String(Math.ceil(parsed.retryAfterMs / 1000))); + } + clearTimeout(tid); + recordUsage(token.id, request, startTime, clientIp, userAgent, "error", parsed.statusCode); + return new Response(JSON.stringify(errorBody), { + status: parsed.statusCode, + headers: errorHeaders, + }); + } + if (wantsStream && upstream.body) { const stream = finalizeReadableStream(upstream.body, (error) => { clearTimeout(tid); @@ -144,7 +175,8 @@ async function forwardToBifrost( startTime, clientIp, userAgent, - upstream.status < 500 ? "success" : "error", + // upstream.ok is guaranteed true here (the !upstream.ok branch above returns early). + "success", upstream.status ); diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 25e8caccc4..46435551c8 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -1682,7 +1682,8 @@ async function handleSingleModelChat( model, lastError, lastStatus, - candidateAliases + candidateAliases, + isCombo ); const lastFailedConnectionId = excludedConnectionIds.size > 0 diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index c82333a78e..866ca28b66 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -630,7 +630,8 @@ export function handleNoCredentials( model: string, lastError: string | null, lastStatus: number | null, - candidateAliases?: readonly string[] + candidateAliases?: readonly string[], + isCombo: boolean = false ) { if (credentials?.allRateLimited) { const errorMsg = lastError || credentials.lastError || "Unavailable"; @@ -705,7 +706,7 @@ export function handleNoCredentials( log.warn("AUTH", `No active credentials for provider: ${provider}`); // #FIX: surface the candidate aliases (from resolveModelOrError) so the // operator can pick a working provider/model prefix instead of guessing. - // Without this, "No active credentials for provider: kiro" leaves the + // Without this, "No active credentials for provider: byNara" leaves the // user staring at a wall — most bugs in this area are actually "wrong // provider was picked", not "the provider is broken". const hint = @@ -715,6 +716,26 @@ export function handleNoCredentials( .map((a) => `${a}/${model}`) .join(", ")}.` : ""; + + // Issue #2: for single-model (non-combo) requests, a 404 leaks a misleading + // "No active credentials" status to a direct API client (e.g. OpenCode) that + // then mis-files it as "resource not found" instead of an auth/credential + // failure. The 404 is only meaningful as a combo fall-through signal, so + // remap it to an explicit error status for single-model traffic: a 401 when + // the provider exists but has no usable credentials, else 503 when the + // provider itself is unknown/unreachable. Combo routing keeps the 404 so it + // can still skip past a disabled-credentials leg. + if (!isCombo) { + const singleModelStatus = + provider && String(provider).trim().length > 0 + ? HTTP_STATUS.UNAUTHORIZED + : HTTP_STATUS.SERVICE_UNAVAILABLE; + return errorResponse( + singleModelStatus, + `No active credentials for provider: ${provider}.${hint}` + ); + } + return errorResponse( HTTP_STATUS.NOT_FOUND, `No active credentials for provider: ${provider}.${hint}` diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index d37fa02a51..382dd00b6b 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -1112,7 +1112,8 @@ test("chat pipeline allows unauthenticated requests through to provider resoluti // handleChat does not enforce REQUIRE_API_KEY — that's the authz pipeline's job. // Without provider credentials seeded, the request falls through to the "no credentials" path. // Upstream port decolua/9router#336: 400 → 404 so combo routing can fall through. - assert.equal(response.status, 404); + // #10797: single-model (non-combo) no-credentials now remaps 404 → 401. + assert.equal(response.status, 401); assert.match(json.error.message, /No active credentials for provider/i); }); @@ -1231,7 +1232,8 @@ test("chat pipeline returns current no-credentials contract when no provider con const json = (await response.json()) as any; // Upstream port decolua/9router#336: 400 → 404 so combo routing can fall through. - assert.equal(response.status, 404); + // #10797: single-model (non-combo) no-credentials now remaps 404 → 401. + assert.equal(response.status, 401); assert.match(json.error.message, /No active credentials for provider: openai/); }); diff --git a/tests/integration/combo-routing-e2e.test.ts b/tests/integration/combo-routing-e2e.test.ts index fea0c16b8a..cd8e046ee0 100644 --- a/tests/integration/combo-routing-e2e.test.ts +++ b/tests/integration/combo-routing-e2e.test.ts @@ -365,8 +365,10 @@ test("unmapped custom model requests fail after combo resolution falls through", const json = (await response.json()) as any; // Upstream port decolua/9router#336: 400 → 404 so combo routing can fall through - // to the next target when a provider has zero usable credentials. - assert.equal(response.status, 404); + // to the next target when a provider has zero usable credentials. This request + // never resolves to a combo target (unmapped model), so it takes the + // single-model path — #10797 remaps that 404 → 401. + assert.equal(response.status, 401); assert.match(json.error.message, /No active credentials for provider: tenant/); }); diff --git a/tests/integration/llama-cpp-provider.test.ts b/tests/integration/llama-cpp-provider.test.ts index 3c87261d32..7c1cf517d1 100644 --- a/tests/integration/llama-cpp-provider.test.ts +++ b/tests/integration/llama-cpp-provider.test.ts @@ -171,8 +171,9 @@ test("llama-cpp provider: alias matching works via model catalog prefix", async assert.equal(json.choices[0].message.content, "42"); }); -test("llama-cpp provider: returns 404 when no connection exists", async () => { +test("llama-cpp provider: returns 401 when no connection exists", async () => { // Upstream port decolua/9router#336: 400 → 404 so combo routing can fall through. + // #10797: single-model (non-combo) no-credentials now remaps 404 → 401. const response = await handleChat( buildRequest({ body: { @@ -183,7 +184,7 @@ test("llama-cpp provider: returns 404 when no connection exists", async () => { }) ); - assert.equal(response.status, 404); + assert.equal(response.status, 401); const json = (await response.json()) as any; assert.match(json.error.message, /No active credentials for provider/); }); diff --git a/tests/unit/api/v1/relay-completions-errors.test.ts b/tests/unit/api/v1/relay-completions-errors.test.ts new file mode 100644 index 0000000000..96b19d5d6f --- /dev/null +++ b/tests/unit/api/v1/relay-completions-errors.test.ts @@ -0,0 +1,253 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + checkIpRateLimit, + getClientIp, + sanitizeForensicHeader, +} from "../../../../src/app/api/v1/relay/chat/completions/relaySecurity.ts"; +import { getDbInstance } from "../../../../src/lib/db/core.ts"; +import { getRelayLogs } from "../../../../src/lib/db/relayProxies.ts"; + +// ─── Relay completions route: Bifrost upstream error normalization ────────── +// +// T-issues: (1) a plain-text/HTML non-OK Bifrost response must be normalized +// into a valid OpenAI JSON error instead of leaking raw text (which produces +// client-side "invalid character 'd'" parse failures); (3) upstream 4xx must be +// recorded as analytics "error", never "success". + +const ORIGINAL_BIFROST_BASE_URL = process.env.BIFROST_BASE_URL; +const ORIGINAL_BIFROST_API_KEY = process.env.BIFROST_API_KEY; +const ORIGINAL_BIFROST_OMNI_KEY = process.env.OMNIROUTE_BIFROST_KEY; +const ORIGINAL_BIFROST_TIMEOUT = process.env.BIFROST_TIMEOUT_MS; +const ORIGINAL_BIFROST_STREAMING = process.env.BIFROST_STREAMING_ENABLED; +const ORIGINAL_RELAY_BACKEND = process.env.OMNIROUTE_RELAY_BACKEND; +const ORIGINAL_FETCH = globalThis.fetch; + +function seedRelayToken(rawToken: string) { + const id = `rl_test_${Date.now()}_${Math.random().toString(16).slice(2)}`; + const now = Math.floor(Date.now() / 1000); + getDbInstance() + .prepare( + ` + INSERT INTO relay_tokens (id, name, token_hash, token_prefix, description, combo_id, + allowed_models, max_tokens_per_request, max_requests_per_minute, max_requests_per_day, + max_cost_per_day, enabled, created_at, updated_at, expires_at, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + ` + ) + .run( + id, + "relay-completions-err", + createHash("sha256").update(rawToken).digest("hex"), + "rl_test", + "", + null, + JSON.stringify(["*"]), + 128000, + 60, + 10000, + 0, + now, + now, + null, + "{}" + ); + return { id, rawToken }; +} + +function restoreEnv() { + if (ORIGINAL_BIFROST_BASE_URL === undefined) delete process.env.BIFROST_BASE_URL; + else process.env.BIFROST_BASE_URL = ORIGINAL_BIFROST_BASE_URL; + if (ORIGINAL_BIFROST_API_KEY === undefined) delete process.env.BIFROST_API_KEY; + else process.env.BIFROST_API_KEY = ORIGINAL_BIFROST_API_KEY; + if (ORIGINAL_BIFROST_OMNI_KEY === undefined) delete process.env.OMNIROUTE_BIFROST_KEY; + else process.env.OMNIROUTE_BIFROST_KEY = ORIGINAL_BIFROST_OMNI_KEY; + if (ORIGINAL_BIFROST_TIMEOUT === undefined) delete process.env.BIFROST_TIMEOUT_MS; + else process.env.BIFROST_TIMEOUT_MS = ORIGINAL_BIFROST_TIMEOUT; + if (ORIGINAL_BIFROST_STREAMING === undefined) delete process.env.BIFROST_STREAMING_ENABLED; + else process.env.BIFROST_STREAMING_ENABLED = ORIGINAL_BIFROST_STREAMING; + if (ORIGINAL_RELAY_BACKEND === undefined) delete process.env.OMNIROUTE_RELAY_BACKEND; + else process.env.OMNIROUTE_RELAY_BACKEND = ORIGINAL_RELAY_BACKEND; + globalThis.fetch = ORIGINAL_FETCH; +} + +function setupBifrostEnv() { + process.env.OMNIROUTE_RELAY_BACKEND = "bifrost"; + process.env.BIFROST_BASE_URL = "http://bifrost.test.local:8080"; + process.env.BIFROST_TIMEOUT_MS = "5000"; + delete process.env.BIFROST_API_KEY; + delete process.env.OMNIROUTE_BIFROST_KEY; + delete process.env.BIFROST_STREAMING_ENABLED; +} + +test("relay route: normalizes plain-text Bifrost 404 into JSON error (Issue #1)", async () => { + setupBifrostEnv(); + const relayToken = seedRelayToken(`relay_err_${Date.now()}`); + + // Bifrost sidecar returns a raw HTML/plain-text non-OK response — the exact + // "invalid character 'd'" scenario behind client JSON parse failures. + globalThis.fetch = async () => { + return new Response("404 page not found", { + status: 404, + headers: { "content-type": "text/html" }, + }); + }; + + const { POST } = await import( + `../../../../src/app/api/v1/relay/chat/completions/route.ts?case=${Date.now()}-${Math.random()}` + ); + + const req = new Request("http://localhost/api/v1/relay/chat/completions", { + method: "POST", + headers: { + authorization: `Bearer ${relayToken.rawToken}`, + "content-type": "application/json", + "x-request-id": "relay-err-404", + }, + body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }), + }); + + const res = await POST(req); + // Status preserved from upstream (404), but body is valid JSON, not HTML. + assert.equal(res.status, 404); + assert.equal(res.headers.get("content-type"), "application/json"); + // The critical fix: the client receives parseable JSON, NOT a raw HTML body + // (which previously caused "invalid character 'd'" JSON.parse failures). + const raw = await res.text(); + assert.doesNotMatch(String(raw), /^ { + setupBifrostEnv(); + const relayToken = seedRelayToken(`relay_err_${Date.now()}`); + + globalThis.fetch = async () => { + return new Response( + "502 Bad Gateway
invalid character 'd'
", + { status: 502, headers: { "content-type": "text/html" } } + ); + }; + + const { POST } = await import( + `../../../../src/app/api/v1/relay/chat/completions/route.ts?case=${Date.now()}-${Math.random()}` + ); + + const req = new Request("http://localhost/api/v1/relay/chat/completions", { + method: "POST", + headers: { + authorization: `Bearer ${relayToken.rawToken}`, + "content-type": "application/json", + "x-request-id": "relay-err-502", + }, + body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }), + }); + + const res = await POST(req); + assert.equal(res.status, 502); + assert.equal(res.headers.get("content-type"), "application/json"); + const body = await res.json(); + assert.ok(body?.error?.message); + + // Upstream 4xx/5xx must be recorded as analytics "error" (Issue #3). + const logs = getRelayLogs(relayToken.id, 10); + assert.equal(logs.length, 1); + assert.equal(logs[0].status, "error"); + assert.equal(logs[0].status_code, 502); + + restoreEnv(); +}); + +test("relay route: strips stale upstream content-length before serializing JSON error body", async () => { + setupBifrostEnv(); + const relayToken = seedRelayToken(`relay_err_${Date.now()}`); + + // The upstream Response carries an EXPLICIT content-length for its own (HTML) + // body. Once the route replaces that body with a freshly-serialized JSON error, + // a stale content-length copied verbatim onto the outgoing Response would + // mismatch the real byte length of the new body. + globalThis.fetch = async () => { + const html = "404 page not found, upstream sidecar unreachable"; + return new Response(html, { + status: 404, + headers: { + "content-type": "text/html", + "content-length": String(Buffer.byteLength(html)), + "content-encoding": "gzip", + "transfer-encoding": "chunked", + }, + }); + }; + + const { POST } = await import( + `../../../../src/app/api/v1/relay/chat/completions/route.ts?case=${Date.now()}-${Math.random()}` + ); + + const req = new Request("http://localhost/api/v1/relay/chat/completions", { + method: "POST", + headers: { + authorization: `Bearer ${relayToken.rawToken}`, + "content-type": "application/json", + "x-request-id": "relay-err-stale-length", + }, + body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }), + }); + + const res = await POST(req); + assert.equal(res.status, 404); + assert.equal(res.headers.get("content-encoding"), null, "stale content-encoding must be stripped"); + assert.equal(res.headers.get("transfer-encoding"), null, "stale transfer-encoding must be stripped"); + + const raw = await res.text(); + const declaredLength = res.headers.get("content-length"); + if (declaredLength !== null) { + assert.equal( + Number(declaredLength), + Buffer.byteLength(raw), + "content-length, if present, must match the actual serialized JSON error body" + ); + } + + restoreEnv(); +}); + +test("relay route: upstream 401 recorded as analytics error not success (Issue #3)", async () => { + setupBifrostEnv(); + const relayToken = seedRelayToken(`relay_err_${Date.now()}`); + + globalThis.fetch = async () => { + return new Response(JSON.stringify({ error: { message: "unauthorized" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + }; + + const { POST } = await import( + `../../../../src/app/api/v1/relay/chat/completions/route.ts?case=${Date.now()}-${Math.random()}` + ); + + const req = new Request("http://localhost/api/v1/relay/chat/completions", { + method: "POST", + headers: { + authorization: `Bearer ${relayToken.rawToken}`, + "content-type": "application/json", + "x-request-id": "relay-err-401", + }, + body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }), + }); + + const res = await POST(req); + assert.equal(res.status, 401); + + const logs = getRelayLogs(relayToken.id, 10); + assert.equal(logs.length, 1); + assert.equal(logs[0].status, "error"); + assert.equal(logs[0].status_code, 401); + + restoreEnv(); +}); diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts index 6b85e58291..dc4990d7f3 100644 --- a/tests/unit/chat-helpers.test.ts +++ b/tests/unit/chat-helpers.test.ts @@ -308,7 +308,18 @@ test("handleNoCredentials reports missing provider credentials and exhausted acc // open-sse/services/accountFallback.ts:1593-1599) so the next combo target is // tried. We surface "no active credentials" as 404 so combo can skip past a // disabled-credentials provider instead of failing the whole request. - const missing = handleNoCredentials(null, null, "openai", "gpt-4o-mini", null, null); + // In combo routing the no-credentials branch must stay 404 NOT_FOUND so the + // combo target loop can fall through to the next target. Pass isCombo=true. + const missing = handleNoCredentials( + null, + null, + "openai", + "gpt-4o-mini", + null, + null, + undefined, + true + ); const exhausted = handleNoCredentials( null, "conn_123", @@ -327,6 +338,65 @@ test("handleNoCredentials reports missing provider credentials and exhausted acc assert.match(exhaustedJson.error.message, /Primary account failed/); }); +test("handleNoCredentials remaps leaked 404 to 401/503 for single-model requests", async () => { + // Issue #2: a direct (non-combo) API client must not receive a misleading 404 + // "No active credentials" error — remap to an explicit auth/credential status. + const forKnownProvider = handleNoCredentials( + null, + null, + "byNara", + "claude-sonnet-4.6", + null, + null, + undefined, + /* isCombo */ false + ); + assert.equal(forKnownProvider.status, 401); + const knownJson = (await forKnownProvider.json()) as { error?: { message?: string } }; + assert.match(knownJson.error?.message ?? "", /No active credentials for provider: byNara/); + + const forUnknownProvider = handleNoCredentials( + null, + null, + "", + "gpt-4o-mini", + null, + null, + undefined, + /* isCombo */ false + ); + assert.equal(forUnknownProvider.status, 503); +}); + +test("handleNoCredentials still leaks 404 (combo fall-through) only when combo", async () => { + // Regression guard: the 404 is intentionally preserved for combo routing so it + // can skip a disabled-credentials leg. Explicitly assert isCombo=true keeps 404 + // and isCombo=false does not. (Issue #2) + const combo = handleNoCredentials( + null, + null, + "kiro", + "claude-opus-5", + null, + null, + undefined, + true + ); + assert.equal(combo.status, 404); + + const single = handleNoCredentials( + null, + null, + "byNara", + "claude-opus-5", + null, + null, + undefined, + false + ); + assert.notEqual(single.status, 404); +}); + test("handleNoCredentials returns Retry-After when every account is rate limited", async () => { const retryAfter = new Date(Date.now() + 45_000).toISOString(); const response = handleNoCredentials( @@ -506,7 +576,7 @@ test("executeChatWithBreaker preserves account TLS scope when a proxy bypasses t ], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), - { headers: { "content-type": "application/json" } }, + { headers: { "content-type": "application/json" } } ); }, }); diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts index e0b71badc9..0b8d4c8806 100644 --- a/tests/unit/chat-route-coverage.test.ts +++ b/tests/unit/chat-route-coverage.test.ts @@ -357,11 +357,13 @@ test("handleChat keeps the combo error when the global fallback throws", async ( assert.match(json.error.message, /primary combo failed/i); }); -test("handleChat returns 404 when no provider credentials exist", async () => { +test("handleChat returns 401 when no provider credentials exist (single-model)", async () => { // Upstream port decolua/9router#336 (Ibrahim Ryan): the no-credentials branch - // of handleNoCredentials now surfaces 404 NOT_FOUND so combo routing can fall - // through to the next target instead of being killed by the combo 400-hard-stop - // guard (open-sse/services/combo.ts, PR #4316 / issue #4279). + // of handleNoCredentials originally surfaced 404 NOT_FOUND unconditionally so + // combo routing could fall through to the next target (open-sse/services/combo.ts, + // PR #4316 / issue #4279). #10797 remaps that 404 to 401 for single-model + // (non-combo) requests — a direct client should see an auth/credential failure, + // not "not found"; combo routing still gets the 404 (see combo-routing-e2e.test.ts). const response = await handleChat( buildRequest({ body: { @@ -373,7 +375,7 @@ test("handleChat returns 404 when no provider credentials exist", async () => { ); const json = (await response.json()) as any; - assert.equal(response.status, 404); + assert.equal(response.status, 401); assert.match(json.error.message, /No active credentials for provider: openai/); }); diff --git a/tests/unit/fix-error-message-candidates.test.ts b/tests/unit/fix-error-message-candidates.test.ts index d141da40b0..3bdfa50d51 100644 --- a/tests/unit/fix-error-message-candidates.test.ts +++ b/tests/unit/fix-error-message-candidates.test.ts @@ -17,7 +17,8 @@ test("handleNoCredentials includes candidate aliases hint when supplied", async /* model */ "claude-opus-5", /* lastError */ null, /* lastStatus */ null, - /* candidateAliases */ ["anthropic", "claude", "agentrouter"] + /* candidateAliases */ ["anthropic", "claude", "agentrouter"], + /* isCombo */ true ); assert.equal(res.status, 404); @@ -42,8 +43,10 @@ test("handleNoCredentials omits hint when no candidates supplied", async () => { "kiro", "claude-opus-5", null, - null + null, /* no candidateAliases */ + undefined, + /* isCombo */ true ); assert.equal(res.status, 404); @@ -65,14 +68,18 @@ test("handleNoCredentials trims candidate list to top 3", async () => { "claude-opus-5", null, null, - ["anthropic", "claude", "agentrouter", "github", "vertex-partner"] + ["anthropic", "claude", "agentrouter", "github", "vertex-partner"], + /* isCombo */ true ); const body = (await res.json()) as { error?: { message?: string } }; const message = body?.error?.message ?? ""; // Top-3 (anthropic, claude, agentrouter) — github and vertex-partner are // dropped to keep the hint actionable. - assert.match(message, /Try one of: anthropic\/claude-opus-5, claude\/claude-opus-5, agentrouter\/claude-opus-5/); + assert.match( + message, + /Try one of: anthropic\/claude-opus-5, claude\/claude-opus-5, agentrouter\/claude-opus-5/ + ); assert.doesNotMatch(message, /github\/claude-opus-5/); assert.doesNotMatch(message, /vertex-partner\/claude-opus-5/); -}); \ No newline at end of file +}); diff --git a/tests/unit/vscode-token-routes.test.ts b/tests/unit/vscode-token-routes.test.ts index fe7fbf64d7..5a292b4600 100644 --- a/tests/unit/vscode-token-routes.test.ts +++ b/tests/unit/vscode-token-routes.test.ts @@ -1154,11 +1154,11 @@ test("vscode tokenized /chat/completions route applies the path token and codex ); const body = (await response.json()) as any; - // Upstream port decolua/9router#336: zero-active-credentials now surfaces as - // 404 (combo-fallbackable) instead of 400 (combo hard-stop). The 404 OpenAI - // error code mapping is "model_not_found" (open-sse/config/errorConfig.ts:29). - assert.equal(response.status, 404); - assert.equal(body.error?.code, "model_not_found"); + // #10797: zero-active-credentials for a single-model (non-combo) request now + // remaps to 401 instead of leaking the combo-fallback 404 to a direct client. + // The 401 OpenAI error code mapping is "invalid_api_key" (errorConfig.ts:26). + assert.equal(response.status, 401); + assert.equal(body.error?.code, "invalid_api_key"); assert.equal(body.error?.message, "No active credentials for provider: codex."); }); @@ -1187,9 +1187,9 @@ test("vscode tokenized /responses route applies the path token and codex tier re ); const body = (await response.json()) as any; - // Upstream port decolua/9router#336: see chat/completions sibling test above. - assert.equal(response.status, 404); - assert.equal(body.error?.code, "model_not_found"); + // #10797: see chat/completions sibling test above. + assert.equal(response.status, 401); + assert.equal(body.error?.code, "invalid_api_key"); assert.equal(body.error?.message, "No active credentials for provider: codex."); });