mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 06:32:16 +03:00
fix(relay): normalize bifrost errors, remap credential 404, fix analytics (#10797)
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!
This commit is contained in:
committed by
GitHub
parent
3810a6c52c
commit
bc6129bcb2
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -1682,7 +1682,8 @@ async function handleSingleModelChat(
|
||||
model,
|
||||
lastError,
|
||||
lastStatus,
|
||||
candidateAliases
|
||||
candidateAliases,
|
||||
isCombo
|
||||
);
|
||||
const lastFailedConnectionId =
|
||||
excludedConnectionIds.size > 0
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
253
tests/unit/api/v1/relay-completions-errors.test.ts
Normal file
253
tests/unit/api/v1/relay-completions-errors.test.ts
Normal file
@@ -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("<html><body>404 page not found</body></html>", {
|
||||
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), /^</, "response body must be JSON, not raw HTML");
|
||||
const body = JSON.parse(raw);
|
||||
assert.ok(body?.error?.message, "must contain an error message");
|
||||
assert.match(String(body?.error?.message), /page not found/);
|
||||
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
test("relay route: normalizes HTML 502 from Bifrost into JSON error (Issue #1)", async () => {
|
||||
setupBifrostEnv();
|
||||
const relayToken = seedRelayToken(`relay_err_${Date.now()}`);
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
return new Response(
|
||||
"<!doctype html><title>502 Bad Gateway</title><pre>invalid character 'd'</pre>",
|
||||
{ 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 = "<html><body>404 page not found, upstream sidecar unreachable</body></html>";
|
||||
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();
|
||||
});
|
||||
@@ -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" } }
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user