Compare commits

...

3 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
0f13fe4221 feat(radar): stable catalog export workflow with provenance (#10826)
Passo 10 of the Radar go-live: publish the OmniRoute catalog export the Radar
server consumes to a stable URL, so the 1 GB-RAM private server pulls it via
RADAR_EXPORT_URL instead of depending on the deploy-time snapshot.

- scripts/release/radar-export.mjs: emits {geradoEm, budgets, totais, registry,
  provenance} from the catalog config modules. Provenance (sourceCommit,
  sourceRef, runUrl, generatedBy) is never fabricated — unknown fields stay null.
- .github/workflows/radar-export.yml: on main catalog changes / manual dispatch /
  weekly, generates the export and clobbers the stable 'radar-export-latest'
  release asset (gh release, GH_TOKEN — checkout persist-credentials:false).
- tests/unit/radar-export.test.mjs: consumer contract (budgets[] non-empty) +
  provenance null-when-unknown + GitHub-env reflection.

Stable URL for RADAR_EXPORT_URL:
https://github.com/diegosouzapw/OmniRoute/releases/download/radar-export-latest/export-omniroute.json

Re-baselines zizmorFindings 190->192 (+2 unpinned-uses @vN, the repo-wide
deliberate convention; artipacked auto-fixed).

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-20 08:09:42 -03:00
Markus Hartung
5089c17b44 fix(resilience): scope same-account transport retry out of emergency-fallback and combo hops
#10792 (#9708) added a same-account retry for retryable 502/503/504/507 transport
failures, applied uniformly inside handleSingleModelChat. Two other paths call
into the same function recursively/iteratively and each carries its own
documented single-call guarantee that the retry silently broke:

- Emergency fallback (#1731): exactly one hop to the free fallback model, no
  extra calls against an already-exhausted provider. The retry was doubling
  that call whenever the fallback model itself returned a transient-looking
  status.
- Combo routing: target-level fallback is the combo's own policy (next target,
  not same-account retry). The retry delayed that policy and could surface the
  wrong terminal status when a later combo/global-fallback hop threw.

Both regressions were already covered by existing tests in
chat-route-coverage.test.ts (asserting exact call counts / preserved status) —
confirmed red on the release tip before this fix, green after.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-20 08:08:09 -03:00
Diego Rodrigues de Sa e Souza
bc6129bcb2 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!
2026-08-20 06:37:02 -03:00
16 changed files with 676 additions and 32 deletions

64
.github/workflows/radar-export.yml vendored Normal file
View 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

View 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.

View File

@@ -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",

View 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"})`
);

View File

@@ -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
);

View File

@@ -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,

View File

@@ -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}`

View File

@@ -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/);
});

View File

@@ -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/);
});

View File

@@ -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/);
});

View 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();
});

View File

@@ -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" } }
);
},
});

View File

@@ -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/);
});

View File

@@ -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/);
});
});

View 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");
});

View File

@@ -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.");
});