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"})`
|
||||
);
|
||||
@@ -2215,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,
|
||||
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user