mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 22:22:57 +03:00
Compare commits
3 Commits
fix/10785-
...
radar-expo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f13fe4221 | ||
|
|
5089c17b44 | ||
|
|
bc6129bcb2 |
64
.github/workflows/radar-export.yml
vendored
Normal file
64
.github/workflows/radar-export.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
# Publica o export estável do catálogo consumido pelo OmniRoute Radar numa URL
|
||||
# fixa (asset de release `radar-export-latest`), para o servidor privado do Radar
|
||||
# (1 GB RAM, nunca clona/builda o OmniRoute) baixá-lo via `RADAR_EXPORT_URL` em
|
||||
# vez de depender do snapshot gravado no deploy. Fonte: scripts/release/radar-export.mjs.
|
||||
#
|
||||
# A URL estável resultante (definir em RADAR_EXPORT_URL no .env do radar-server):
|
||||
# https://github.com/diegosouzapw/OmniRoute/releases/download/radar-export-latest/export-omniroute.json
|
||||
name: Radar Export
|
||||
|
||||
on:
|
||||
workflow_dispatch: # o operador pode publicar sob demanda (de qualquer ref)
|
||||
push:
|
||||
branches: [main] # produção: só o catálogo do main clobra o asset estável
|
||||
paths:
|
||||
- open-sse/config/freeModelCatalog.data.ts
|
||||
- open-sse/config/freeModelCatalog.ts
|
||||
- open-sse/config/providerRegistry.ts
|
||||
- open-sse/config/providers/**
|
||||
- scripts/release/radar-export.mjs
|
||||
- .github/workflows/radar-export.yml
|
||||
schedule:
|
||||
- cron: "17 6 * * 1" # semanal (segunda 06:17 UTC): mantém geradoEm/proveniência frescos
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: radar-export-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CI_NODE_VERSION: "24"
|
||||
|
||||
jobs:
|
||||
publish-export:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # gh release upload — clobra o asset estável do export
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false # publish usa GH_TOKEN via gh release, não a credencial do checkout
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Generate catalog export with provenance
|
||||
run: node --import tsx/esm scripts/release/radar-export.mjs "$RUNNER_TEMP/export-omniroute.json"
|
||||
- name: Publish to the stable release asset
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="radar-export-latest"
|
||||
# Cria o release estável na primeira vez; nas seguintes só re-anexa o asset.
|
||||
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "Radar catalog export (rolling)" \
|
||||
--notes "Export estável do catálogo OmniRoute para o Radar. Atualizado automaticamente; NÃO é um release de versão do produto." \
|
||||
--latest=false
|
||||
fi
|
||||
gh release upload "$TAG" "$RUNNER_TEMP/export-omniroute.json" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
1
changelog.d/fixes/10792-double-transport-retry-scope.md
Normal file
1
changelog.d/fixes/10792-double-transport-retry-scope.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(resilience):** scope the same-account transport retry (#9708) out of emergency-fallback and combo hops — it was retrying the free fallback model and combo targets too, doubling upstream calls and corrupting the terminal error status on those paths.
|
||||
@@ -166,7 +166,8 @@
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"zizmorFindings": {
|
||||
"value": 190,
|
||||
"value": 192,
|
||||
"_rebaseline_2026_08_20_radar_export_workflow": "190 -> 192 (+2). Workflow novo `.github/workflows/radar-export.yml` (passo 10 do go-live do Radar: publica o export estável do catálogo como asset de release para o servidor privado baixar via RADAR_EXPORT_URL). Os +2 são unpinned-uses @vN: actions/checkout@v7 + actions/setup-node@v7 — a MESMA convenção deliberada de todos os workflows (ver _scanner_harden_workflows_2026_06_16); fixar por SHA só este violaria a convenção. O findings artipacked do checkout foi CORRIGIDO com `persist-credentials: false` (o job publica via GH_TOKEN em `gh release`, não usa a credencial do checkout). Nenhuma classe nova de template-injection / cache-poisoning / dangerous-triggers. Medido local com zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 191; +1 do delta conhecido do runner (ver _rebaseline_2026_07_28_ci_runner_delta: o runner enxerga 1 unpinned-uses @vN a mais que o devbox no mesmo commit; a baseline segue o runner) => 192.",
|
||||
"_rebaseline_2026_07_20_aliasresolver_hook_split_7808": "175 -> 176 (+1). Companion to PR #7808 (CodeQL js/incomplete-url-substring-sanitization fix in bin/aliasResolver.mjs). The +1 is NOT caused by this PR's code changes (bin/* is not a workflow file) — it is a pre-existing drift that surfaced because the ratchet gate runs on this PR's CI: the zizmor scanner version on the GitHub runner gained a new rule (or extended an existing one) since the v3.8.49 baseline was seeded on 2026-07-17. Breakdown: the new finding is an unpinned-uses @vN class item on one of the existing workflows (same deliberate convention as _scanner_harden_workflows_2026_06_16 — @vN is intentional, SHA-pinning only this one would violate the convention). No new template-injection/artipacked/cache-poisoning/dangerous-triggers classes introduced. Measured by the Quality Gates (Extended) job on run 29713001401 = 176, baseline was 175. Note: by the time this landed on release/v3.8.49, the baseline was already at 176 via _rebaseline_2026_07_17_combo_recovery_hints — this entry is kept as historical record; no further bump applied.",
|
||||
"_rebaseline_2026_07_17_v3849_release": "169 -> 175 (+6). Cycle workflow drift (v3.8.48/v3.8.49): npm-publish.yml (new, WS1.3 #7092), electron-release.yml, nightly-compat.yml, nightly-release-green.yml, CI restructures (#7501 full-history base fetch, #7355 main-green, #7202 merge-queue gates, Trunk/Codecov). Breakdown vs v3.8.47: +3 unpinned-uses (@vN convention, deliberate per _scanner_harden_workflows_2026_06_16), +2 cache-poisoning (artifact upload/cache in the OWN electron-release/npm-publish RELEASE workflows -- operator-controlled, not fork-PR exploitable), +1 excessive-permissions (nightly-compat.yml permissions:issues). No new template-injection/artipacked/dangerous-triggers. Measured with zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 175 on da3a0be69.",
|
||||
"direction": "down",
|
||||
|
||||
93
scripts/release/radar-export.mjs
Normal file
93
scripts/release/radar-export.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
// Gera o export estável do catálogo OmniRoute consumido pelo OmniRoute Radar
|
||||
// (`RADAR_EXPORT_URL` → `${DATA_DIR}/export-omniroute.json` no servidor privado).
|
||||
//
|
||||
// Por que existe: o servidor Radar (1 GB RAM na Akamai) NUNCA clona nem instala
|
||||
// o OmniRoute; ele só baixa este JSON de uma URL estável. Antes o export vinha
|
||||
// do snapshot gravado no deploy, preso à máquina do operador. Este script roda
|
||||
// no CI do OmniRoute (que tem os módulos de catálogo + tsx), emite o export com
|
||||
// PROVENIÊNCIA e o workflow o publica como asset de release de URL fixa.
|
||||
//
|
||||
// Contrato do consumidor (`src/feed/exportSource.ts` no radar-server): exige
|
||||
// `budgets[]` não-vazio e lê `geradoEm`; chaves extras são ignoradas, então
|
||||
// `totais`, `registry` e `provenance` viajam junto sem quebrar retrocompat.
|
||||
//
|
||||
// Uso (precisa de tsx, pois lê .ts):
|
||||
// node --import tsx/esm scripts/release/radar-export.mjs [saída.json]
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = path.resolve(DIR, "../.."); // …/OmniRoute
|
||||
const SAIDA = process.argv[2] || path.join(REPO, "export-omniroute.json");
|
||||
|
||||
const { FREE_MODEL_BUDGETS } = await import(
|
||||
path.join(REPO, "open-sse/config/freeModelCatalog.data.ts")
|
||||
);
|
||||
const { computeFreeModelTotals } = await import(
|
||||
path.join(REPO, "open-sse/config/freeModelCatalog.ts")
|
||||
);
|
||||
const { REGISTRY } = await import(path.join(REPO, "open-sse/config/providerRegistry.ts"));
|
||||
|
||||
/**
|
||||
* Proveniência: quem/quando/de-qual-commit gerou o export. Cada campo é `null`
|
||||
* quando a origem é desconhecida — NUNCA inventamos um valor (D16: desconhecido
|
||||
* permanece `null`). No CI o GitHub popula as variáveis; localmente caímos no
|
||||
* `git` e, sem repositório, em `null`.
|
||||
*/
|
||||
function firstEnv(...names) {
|
||||
for (const name of names) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function gitHead() {
|
||||
try {
|
||||
return execFileSync("git", ["-C", REPO, "rev-parse", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildProvenance(geradoEm) {
|
||||
const sourceCommit = firstEnv("GITHUB_SHA") ?? gitHead();
|
||||
const sourceRef = firstEnv("GITHUB_REF_NAME", "GITHUB_REF");
|
||||
const server = firstEnv("GITHUB_SERVER_URL");
|
||||
const repository = firstEnv("GITHUB_REPOSITORY");
|
||||
const runId = firstEnv("GITHUB_RUN_ID");
|
||||
const runUrl = server && repository && runId ? `${server}/${repository}/actions/runs/${runId}` : null;
|
||||
return {
|
||||
generatedAt: geradoEm,
|
||||
generator: "scripts/release/radar-export.mjs",
|
||||
generatedBy: firstEnv("GITHUB_ACTIONS") ? "github-actions" : "manual",
|
||||
sourceCommit,
|
||||
sourceRef,
|
||||
runUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const geradoEm = new Date().toISOString();
|
||||
const dados = {
|
||||
geradoEm,
|
||||
budgets: FREE_MODEL_BUDGETS,
|
||||
totais: computeFreeModelTotals(),
|
||||
// Só as chaves: o consumidor apenas pergunta "sabemos rotear este provider?".
|
||||
registry: Object.keys(REGISTRY).sort(),
|
||||
provenance: buildProvenance(geradoEm),
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(path.resolve(SAIDA)), { recursive: true });
|
||||
fs.writeFileSync(SAIDA, JSON.stringify(dados));
|
||||
console.log(
|
||||
`catálogo exportado → ${path.basename(SAIDA)}: ` +
|
||||
`${dados.budgets.length} modelos, ${dados.registry.length} providers no registry` +
|
||||
` (commit ${dados.provenance.sourceCommit ?? "desconhecido"})`
|
||||
);
|
||||
@@ -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
|
||||
@@ -2214,8 +2215,17 @@ async function handleSingleModelChat(
|
||||
// #9708: retry a retryable pre-output transport failure once on the same
|
||||
// account (jittered 2-3s) before cooling the connection. A first 503/507
|
||||
// must not rotate away from a still-healthy Codex prompt-cache partition.
|
||||
// Skipped inside an emergency-fallback hop: that path guarantees exactly one
|
||||
// upstream call against the free fallback model (#1731) — an extra retry there
|
||||
// burns a second call against a provider we're already treating as a last resort.
|
||||
// Skipped for combo targets too: combo routing owns its own target-level
|
||||
// fallback/retry policy (per-target error handling in handleSingleModel,
|
||||
// then the next combo target) — a same-account retry here just delays that
|
||||
// policy and can surface the wrong terminal status when a later hop throws.
|
||||
const transportAttempts = sameAccountTransportRetries.get(credentials.connectionId) || 0;
|
||||
if (
|
||||
!runtimeOptions.emergencyFallbackTried &&
|
||||
!comboName &&
|
||||
shouldRetrySameAccountTransport({
|
||||
status: result.status,
|
||||
errorText: errorStr,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
85
tests/unit/radar-export.test.mjs
Normal file
85
tests/unit/radar-export.test.mjs
Normal file
@@ -0,0 +1,85 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Gera o export estável do catálogo (scripts/release/radar-export.mjs) e valida
|
||||
// o contrato consumido pelo OmniRoute Radar + a proveniência (D16: desconhecido
|
||||
// permanece `null`, nunca inventado).
|
||||
|
||||
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = path.resolve(DIR, "../.."); // …/OmniRoute
|
||||
const SCRIPT = path.join(REPO, "scripts/release/radar-export.mjs");
|
||||
|
||||
function runExport(extraEnv = {}) {
|
||||
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "radar-export-"));
|
||||
const outPath = path.join(outDir, "export-omniroute.json");
|
||||
execFileSync("node", ["--import", "tsx/esm", SCRIPT, outPath], {
|
||||
cwd: REPO,
|
||||
stdio: ["ignore", "ignore", "inherit"],
|
||||
// Base limpa: sem herdar GITHUB_* do ambiente do CI que roda os testes.
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
GITHUB_SHA: undefined,
|
||||
GITHUB_REF_NAME: undefined,
|
||||
GITHUB_REF: undefined,
|
||||
GITHUB_ACTIONS: undefined,
|
||||
GITHUB_SERVER_URL: undefined,
|
||||
GITHUB_REPOSITORY: undefined,
|
||||
GITHUB_RUN_ID: undefined,
|
||||
...extraEnv,
|
||||
},
|
||||
});
|
||||
const parsed = JSON.parse(fs.readFileSync(outPath, "utf8"));
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
return parsed;
|
||||
}
|
||||
|
||||
test("radar export satisfies the Radar consumer contract with a fresh catalog", () => {
|
||||
const data = runExport();
|
||||
// Contrato mínimo de src/feed/exportSource.ts: budgets[] não-vazio + geradoEm.
|
||||
assert.ok(Array.isArray(data.budgets) && data.budgets.length > 0, "budgets não-vazio");
|
||||
assert.ok(Array.isArray(data.registry) && data.registry.length > 0, "registry não-vazio");
|
||||
assert.ok(
|
||||
typeof data.geradoEm === "string" && !Number.isNaN(Date.parse(data.geradoEm)),
|
||||
"geradoEm ISO válido"
|
||||
);
|
||||
assert.ok(data.totais && typeof data.totais === "object", "totais presente");
|
||||
// registry ordenado e sem duplicatas (chaves de provider).
|
||||
assert.deepEqual(data.registry, [...data.registry].sort());
|
||||
});
|
||||
|
||||
test("radar export provenance never fabricates unknown fields", () => {
|
||||
const data = runExport();
|
||||
const p = data.provenance;
|
||||
assert.ok(p && typeof p === "object", "provenance presente");
|
||||
assert.equal(p.generatedAt, data.geradoEm);
|
||||
assert.equal(p.generator, "scripts/release/radar-export.mjs");
|
||||
// Fora de um runner do GitHub Actions: manual, e ref/runUrl desconhecidos = null.
|
||||
assert.equal(p.generatedBy, "manual");
|
||||
assert.equal(p.sourceRef, null);
|
||||
assert.equal(p.runUrl, null);
|
||||
// sourceCommit: SHA de 40 hex (via git no checkout) ou null se indisponível.
|
||||
assert.ok(p.sourceCommit === null || /^[0-9a-f]{40}$/.test(p.sourceCommit), "sourceCommit sha|null");
|
||||
});
|
||||
|
||||
test("radar export provenance reflects the GitHub Actions environment when present", () => {
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const data = runExport({
|
||||
GITHUB_ACTIONS: "true",
|
||||
GITHUB_SHA: sha,
|
||||
GITHUB_REF_NAME: "release/v9.9.9",
|
||||
GITHUB_SERVER_URL: "https://github.com",
|
||||
GITHUB_REPOSITORY: "diegosouzapw/OmniRoute",
|
||||
GITHUB_RUN_ID: "42",
|
||||
});
|
||||
const p = data.provenance;
|
||||
assert.equal(p.generatedBy, "github-actions");
|
||||
assert.equal(p.sourceCommit, sha);
|
||||
assert.equal(p.sourceRef, "release/v9.9.9");
|
||||
assert.equal(p.runUrl, "https://github.com/diegosouzapw/OmniRoute/actions/runs/42");
|
||||
});
|
||||
@@ -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