diff --git a/.env.example b/.env.example index c741ba39c6..734402a092 100644 --- a/.env.example +++ b/.env.example @@ -174,6 +174,12 @@ OMNIROUTE_USE_TURBOPACK=1 # hints in production logs. # OMNIROUTE_PROXY_FETCH_DEBUG=true +# Set to any non-empty value to emit `[omniroute completion]` diagnostics from +# the CLI shell-completion cache paths (read/refresh/write) in +# bin/cli/commands/completion.mjs. Off by default — these caches fail silently +# so a missing/corrupt cache never breaks tab-completion. +# OMNIROUTE_DEBUG_COMPLETION=1 + # Docker production port mappings (docker-compose.prod.yml only). # These set the HOST-side published ports. Container ports use PORT/API_PORT. # PROD_DASHBOARD_PORT=20130 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index f5c0aa9967..d1e765cb79 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -23,7 +23,11 @@ env: jobs: fast-gates: name: Fast Quality Gates - runs-on: ubuntu-latest + # Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the + # release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin + # branches only — a fork PR must never execute on the LAN runner). Var unset/false + # or a fork PR falls back to ubuntu-latest, so this is inert until the flag flips. + runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} # tsx gates (known-symbols, route-guard-membership) import modules that open # SQLite on load; provide DB env so a fresh CI DB initializes cleanly. env: @@ -108,7 +112,8 @@ jobs: fast-vitest: name: Vitest (fast-path) - runs-on: ubuntu-latest + # Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest). + runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} env: JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-lint-api-key-secret-long @@ -126,7 +131,10 @@ jobs: fast-unit: name: Unit Tests fast-path (${{ matrix.shard }}/2) - runs-on: ubuntu-latest + # Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest). + # This is the heaviest fast-path job (~9min on ubuntu-latest); the 32-core VPS + # cuts it to ~2-3min when the flag is on. + runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} strategy: fail-fast: false matrix: diff --git a/.gitignore b/.gitignore index b2261dfff7..a91758f691 100644 --- a/.gitignore +++ b/.gitignore @@ -233,3 +233,4 @@ omniroute.md # mise configuration mise.toml _artifacts/ +.claude-flow/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 261dc258aa..2f303c5a82 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -48,11 +48,19 @@ "**/.build", "**/dist", "**/coverage", - "**/.worktrees" + "**/.worktrees", + "**/.claude/worktrees", + "**/electron", + "**/_references", + "**/_mono_repo", + "**/_tasks" ] }, // Para esconder os diretórios gerados da árvore do Explorer, descomente: + // (MANTIDO comentado — o dono precisa ver _references/_mono_repo/_tasks na árvore. + // A performance é resolvida por watcherExclude + search.exclude + tsserver, sem + // precisar escondê-los do Explorer.) // "files.exclude": { // "**/.worktrees": true, // "**/coverage": true, diff --git a/CLAUDE.md b/CLAUDE.md index 324729d317..43f746ca4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -540,7 +540,7 @@ the stale-enforcement added in Fase 6A.3. 18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree. 19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation". 20. PII redaction/sanitization is **opt-in — never on by default**. OmniRoute proxies for self-hosted/local LLMs where the operator owns the data, so mutating request/response payloads by default would silently corrupt legitimate traffic. The two data-mutating PII feature flags **MUST** keep `defaultValue: "false"` in `src/shared/constants/featureFlagDefinitions.ts`: `PII_REDACTION_ENABLED` (request-side) and `PII_RESPONSE_SANITIZATION` (response + streaming). All three application points — `src/lib/guardrails/piiMasker.ts` (request guardrail), `src/lib/piiSanitizer.ts` (response), `src/lib/streamingPiiTransform.ts` (SSE) — are gated on these flags; with both off the `pii-masker` guardrail still runs but never mutates payloads (data passes through untouched). Flipping either default to `"true"` requires explicit operator approval. The regression guard is `tests/unit/pii-opt-in-default.test.ts` (asserts both definition defaults + behavioral pass-through). Opt-in is per-operator via env or the settings/DB override (`src/lib/db/featureFlags.ts`), never a silent default. See `docs/security/GUARDRAILS.md`. -21. **Release-freeze — the FROZEN release branch belongs to the release captain; development does NOT stop (parallel-cycle model, 2026-07-04).** `/generate-release` opens a marker issue labeled `release-freeze` at the start of reconciliation (Phase 0a), **immediately cuts the next cycle's branch `release/vX+1` from the frozen tip (Phase 0a.0b — bump + living release PR + re-home of open PRs)**, and closes the freeze once the release PR squash-merges to `main`. Before merging **any** PR, every campaign workflow (`/review-issues`, `/review-prs`, `/implement-features`, `/green-prs`, `/port-upstream-*`) **MUST** check `gh issue list --repo diegosouzapw/OmniRoute --label release-freeze --state open` — if a freeze is active: **NEVER merge into the frozen `release/vX.Y.Z` named in the freeze title**; instead resolve the ACTIVE development branch (the **highest** `release/v*` by semver — normally `release/vX+1`, announced in a freeze-issue comment) and **retarget the PR there** (`gh pr edit --base release/vX+1`, then VERIFY with `gh pr view --json baseRefName` — the edit fails silently) and merge normally. **HOLD only when the highest release/v\* branch IS the frozen one** (the short window before 0a.0b completes, or a pre-parallel-cycle release) — in that case leave the PR ready and open, tell the operator, and resume when the next branch appears or the freeze lifts. The just-shipped fixes reach `release/vX+1` via the Phase 5 sync-back (`scripts/release/sync-next-cycle.mjs`); do not try to sync mid-release. This is a **coordination signal, not a permission lock**: the release captain and the campaign sessions share the `diegosouzapw` identity, so a GitHub branch-protection lock cannot distinguish them — only this honored marker prevents the mid-release commit races that forced full CHANGELOG re-reconciliation in v3.8.40/v3.8.41 (a parallel campaign advanced `release/vX.Y.Z` by 34 commits mid-run). The release captain's own reconciliation/cycle-open pushes are exempt — they _are_ the release. Fixes that must land during a freeze (a homologation finding) follow the post-merge read-only rule: land on `main` first via `fix/release-vX.Y.Z-*`. **⛔ ONLY `/generate-release` may raise a release-freeze, and ONLY at its Phase 0a (start of generating a new version) — lifted at Phase 12c after the squash-merge to `main`.** No campaign, session, or agent may open a `release-freeze` marker at any other time — a freeze is **never** a mid-development coordination tool. If a session ever believes a freeze is genuinely, unavoidably necessary outside the `/generate-release` flow, it **MUST first ask the operator (`diegosouzapw`) in chat, explicitly alert "estou criando um freeze" and get an explicit yes** — never open, extend, or re-open a `release-freeze` autonomously. Conversely, do **not** close/lift an active `/generate-release` freeze to unblock campaign merges: it protects the captain's single clean CI run and auto-lifts at Phase 12c — closing it early re-triggers the exact commit race it prevents. Verify a freeze is legitimate before acting on it: an open `release-freeze` whose title/body references an **OPEN** release PR (`gh pr view --json state`) is the authorized captain freeze — hold, don't touch. +21. **Release-freeze — the FROZEN release branch belongs to the release captain; development does NOT stop (parallel-cycle model, 2026-07-04).** `/generate-release` opens a marker issue labeled `release-freeze` at the start of reconciliation (Phase 0a), **immediately cuts the next cycle's branch `release/vX+1` from the frozen tip (Phase 0a.0b — bump + living release PR + re-home of open PRs)**, and closes the freeze once the release PR squash-merges to `main`. Before merging **any** PR, every campaign workflow (`/review-prs`, `/review-group-prs`, `/implement-prs`, `/triage-fix-bugs`, `/implement-fix-bugs`, `/triage-features`, `/implement-features`, `/green-prs`, `/port-upstream-*`) **MUST** check `gh issue list --repo diegosouzapw/OmniRoute --label release-freeze --state open` — if a freeze is active: **NEVER merge into the frozen `release/vX.Y.Z` named in the freeze title**; instead resolve the ACTIVE development branch (the **highest** `release/v*` by semver — normally `release/vX+1`, announced in a freeze-issue comment) and **retarget the PR there** (`gh pr edit --base release/vX+1`, then VERIFY with `gh pr view --json baseRefName` — the edit fails silently) and merge normally. **HOLD only when the highest release/v\* branch IS the frozen one** (the short window before 0a.0b completes, or a pre-parallel-cycle release) — in that case leave the PR ready and open, tell the operator, and resume when the next branch appears or the freeze lifts. The just-shipped fixes reach `release/vX+1` via the Phase 5 sync-back (`scripts/release/sync-next-cycle.mjs`); do not try to sync mid-release. This is a **coordination signal, not a permission lock**: the release captain and the campaign sessions share the `diegosouzapw` identity, so a GitHub branch-protection lock cannot distinguish them — only this honored marker prevents the mid-release commit races that forced full CHANGELOG re-reconciliation in v3.8.40/v3.8.41 (a parallel campaign advanced `release/vX.Y.Z` by 34 commits mid-run). The release captain's own reconciliation/cycle-open pushes are exempt — they _are_ the release. Fixes that must land during a freeze (a homologation finding) follow the post-merge read-only rule: land on `main` first via `fix/release-vX.Y.Z-*`. **⛔ ONLY `/generate-release` may raise a release-freeze, and ONLY at its Phase 0a (start of generating a new version) — lifted at Phase 12c after the squash-merge to `main`.** No campaign, session, or agent may open a `release-freeze` marker at any other time — a freeze is **never** a mid-development coordination tool. If a session ever believes a freeze is genuinely, unavoidably necessary outside the `/generate-release` flow, it **MUST first ask the operator (`diegosouzapw`) in chat, explicitly alert "estou criando um freeze" and get an explicit yes** — never open, extend, or re-open a `release-freeze` autonomously. Conversely, do **not** close/lift an active `/generate-release` freeze to unblock campaign merges: it protects the captain's single clean CI run and auto-lifts at Phase 12c — closing it early re-triggers the exact commit race it prevents. Verify a freeze is legitimate before acting on it: an open `release-freeze` whose title/body references an **OPEN** release PR (`gh pr view --json state`) is the authorized captain freeze — hold, don't touch. 22. **Cross-session safety — this repo is worked by MANY parallel sessions/agents at once; never step on another's in-flight work.** Two absolute bans, both recurring incidents (this rule exists because they keep happening): - **(a) Never `git stash` / `git stash pop` — ANYWHERE in this repo, including inside an isolated worktree, and including inside any subagent you dispatch.** `git stash` operates on the **shared repository object store**, not the per-worktree working tree — so a stash pushed or popped in one session can silently clobber or resurrect another parallel session's uncommitted changes. This is not hypothetical: 2026-07-02 a `#5923` quotaCache change leaked into the unrelated `#2296` worktree via a global `stash pop`, and the same class reincided through a **subagent**. To compare working changes against a base ref **without** stashing, use `git show :` or `git diff -- `; to confirm a typecheck/lint error is pre-existing on the base, inspect the base ref directly (`git show origin/release/vX.Y.Z:`) — never stash your tree away to "get it clean". **Put this ban verbatim in the prompt of every subagent that touches git** (agents don't inherit this file's context — the recurrence was a subagent). - **(b) Never merge, push, rebase, or force-push a PR / branch / worktree that another session is actively working.** An open PR whose head is a live fix worktree in `.claude/worktrees/` you did **not** create (e.g. `fix-5852`/`fix-5923` carrying fresh commits, even when they share your `diegosouzapw` identity), or any branch another session owns, is **off-limits — HOLD**, and let the owning session merge it. **Before** merging or pushing to any PR you did not create _this_ session, run `git worktree list` to check for a matching in-flight worktree and re-check `gh pr view --json state,headRefOid`. Only the owning session merges its own in-flight PR; mid-flight merges race the owner and re-trigger the exact commit/CHANGELOG races Rule #19 and Rule #21 guard against. (Reinforces Rule #19.) diff --git a/README.md b/README.md index 8bce5bdf96..7ca2c34529 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,15 @@

-⭐ Star the repo if OMNIROUTE helped you save money and make your work easier. [![Stars](https://img.shields.io/github/stars/diegosouzapw/OmniRoute?style=social)](https://github.com/diegosouzapw/OmniRoute) +⭐ Star the repo if OMNIROUTE helped you save money and make your work easier. +

+[![Stars](https://img.shields.io/github/stars/diegosouzapw/OmniRoute?style=social)](https://github.com/diegosouzapw/OmniRoute) diegosouzapw%2FOmniRoute | Trendshift +[![Star History Rank](https://api.star-history.com/badge?repo=diegosouzapw/OmniRoute&theme=dark)](https://www.star-history.com/diegosouzapw/omniroute) + +
[![237 AI Providers](https://img.shields.io/badge/237-AI_Providers-6C5CE7?style=for-the-badge)](#-237-ai-providers--90-free) [![90+ Free](https://img.shields.io/badge/90%2B-Free_Tiers-00B894?style=for-the-badge)](#-237-ai-providers--90-free) @@ -1150,14 +1155,13 @@ gh release create v3.8.2 --title "v3.8.2" --generate-notes ## 📊 Stars - + - - - Star History Chart + + + Star History Chart -
diff --git a/bin/cli/commands/completion.mjs b/bin/cli/commands/completion.mjs index d1d02d944c..3fbbfe9258 100644 --- a/bin/cli/commands/completion.mjs +++ b/bin/cli/commands/completion.mjs @@ -15,7 +15,11 @@ function readCache() { try { const raw = JSON.parse(readFileSync(cachePath(), "utf8")); if (raw && typeof raw.ts === "number" && Date.now() - raw.ts < CACHE_TTL_MS) return raw; - } catch {} + } catch (err) { + if (process.env.OMNIROUTE_DEBUG_COMPLETION) { + console.error("[omniroute completion] readCache failed:", err?.message ?? err); + } + } return null; } @@ -41,12 +45,20 @@ async function refreshCache(opts = {}) { const j = await mr.value.json(); models = (Array.isArray(j) ? j : j.data || []).map((m) => m.id).filter(Boolean); } - } catch {} + } catch (err) { + if (process.env.OMNIROUTE_DEBUG_COMPLETION) { + console.error("[omniroute completion] refreshCache failed:", err?.message ?? err); + } + } const data = { combos, providers, models, ts: Date.now() }; try { mkdirSync(dirname(cachePath()), { recursive: true }); writeFileSync(cachePath(), JSON.stringify(data)); - } catch {} + } catch (err) { + if (process.env.OMNIROUTE_DEBUG_COMPLETION) { + console.error("[omniroute completion] writeCache failed:", err?.message ?? err); + } + } return data; } diff --git a/bin/cli/commands/compression.mjs b/bin/cli/commands/compression.mjs index 95bb6f6c18..6992497f69 100644 --- a/bin/cli/commands/compression.mjs +++ b/bin/cli/commands/compression.mjs @@ -24,7 +24,7 @@ async function restCompressionStatus() { const combosBody = combosRes.ok ? await combosRes.json() : { combos: [] }; const analytics = analyticsRes && analyticsRes.ok ? await analyticsRes.json() : null; return { - engine: settings.engine ?? null, + strategy: settings.defaultMode || "standard", settings, combos: combosBody.combos ?? combosBody, analytics, @@ -33,7 +33,10 @@ async function restCompressionStatus() { async function restCompressionConfigure(config) { const body = { ...config }; - if (body.engine) body.engine = normalizeEngine(body.engine); + if (body.strategy) { + body.defaultMode = body.strategy === "caveman" ? "standard" : normalizeEngine(body.strategy); + delete body.strategy; + } const res = await apiFetch("/api/settings/compression", { method: "PUT", body }); if (!res.ok) { process.stderr.write(`Error: ${res.status}\n`); @@ -43,9 +46,10 @@ async function restCompressionConfigure(config) { } async function restSetEngine(name) { + const normalized = normalizeEngine(name); const res = await apiFetch("/api/settings/compression", { method: "PUT", - body: { engine: normalizeEngine(name) }, + body: { defaultMode: normalized === "caveman" ? "standard" : normalized }, }); if (!res.ok) { process.stderr.write(`Error: ${res.status}\n`); @@ -103,7 +107,11 @@ export async function runCompressionStatus(opts, cmd) { export async function runCompressionConfigure(opts, cmd) { const config = {}; - if (opts.engine) config.engine = opts.engine; + // #6571 — both the MCP tool schema (compressionConfigureInput) and + // handleCompressionConfigure expect `strategy`, not `engine`; a non-strict + // MCP schema silently strips an unrecognized `engine` key on the primary + // (MCP-mounted) path, so this must be `strategy` on both paths. + if (opts.engine) config.strategy = normalizeEngine(opts.engine); if (opts.cavemanAggressiveness !== undefined) config.caveman = { aggressiveness: opts.cavemanAggressiveness }; if (opts.rtkBudget !== undefined) config.rtk = { tokenBudget: opts.rtkBudget }; @@ -163,7 +171,7 @@ export function registerCompression(program) { engine.command("set ").action(runCompressionEngineSet); engine.command("get").action(async (opts, cmd) => { const data = await mcpCall("omniroute_compression_status", {}, restCompressionStatus); - process.stdout.write(`${data.engine ?? "(default)"}\n`); + process.stdout.write(`${data.strategy ?? "(default)"}\n`); }); const combos = cmp.command("combos").description(t("compression.combos.description")); diff --git a/bin/cli/output.mjs b/bin/cli/output.mjs index 786c3da340..69f2518b74 100644 --- a/bin/cli/output.mjs +++ b/bin/cli/output.mjs @@ -37,6 +37,7 @@ function inferSchema(sample) { function formatCell(v, col) { if (v == null) return ""; if (col.formatter) return col.formatter(v); + if (typeof v === "object") return JSON.stringify(v); return String(v); } diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index 3d9b6e2e63..ed24860b31 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,7 @@ { "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", - "count": 2052, + "count": 2053, + "_rebaseline_2026_07_08_6556_inherited_drift": "2052->2053 (+1). PR #6556 (omniglyph engine): drift herdado do merge burst da base (a catraca nao roda no fast-path PR->release, mesmo padrao dos rebaselines v3.8.44/46). Trust-but-verify: o proprio codigo do PR e complexity-net-zero — as 2 violacoes que ele introduzia (runCompressionAsync complexity 17 apos o branch do modo omniglyph; OmniglyphContextPageClient 161 linhas) foram CORRIGIDAS por extracao real (engines/omniglyphSingleMode.ts + split do componente em section components), medido: 2055->2053 local; base pura origin/release/v3.8.47 mede 2053 identico. Tighten via --update next cycle.", "_rebaseline_2026_07_07_v3846_release_close": "2035->2050 (+15). v3.8.46 release close (generate-release Phase 0 pre-flight): drift herdado do merge burst do ciclo (39 commits do dia + campanha /review-*). Trust-but-verify: os fixes de base-red do captain (agentSkills path.resolve #6366, catalogo cache #6408, tipagem de teste no-explicit-any, MitmProxyTab suppression) sao complexity-net-zero — check:complexity mede 2050 identico com e sem os fixes (a catraca NAO roda no fast-path PR->release, entao o ramo acumulou sem rebaselinar). Tighten via --update next cycle.", "_rebaseline_2026_07_04_v3844_release_close": "2026->2028 (+2). v3.8.44 release close (generate-release Phase 0/1): drift residual do fim do ciclo medido no tip pos-#6155 (merge burst final: #6155 cooling-panel + #6104 Kenari + #6139/#6128 provider-limits). Trust-but-verify: os 2 fixes de codigo do release-captain (model.ts alias boundary, auggie.ts stdin error handlers) adicionam 0 violacoes NOVAS — eslint.complexity direto nos 2 arquivos flagra apenas funcoes que ja estouravam o limite antes (runStreaming/start ja >80 linhas; resolveModelByProviderInference/getModelInfoCore pre-existentes de #5918), e resolveProviderAlias segue abaixo de 15. Logo o +2 e drift herdado do burst. Tighten via --update next cycle.", "_rebaseline_2026_07_03_v3844_ipfilter_release_green": "2015->2026 (+11). v3.8.44 cycle drift measured on release tip 32e4c906e during the #6131/#5975 release-green rebaseline. Inherited from the merge burst (Quality Ratchet does not run on PR->release fast-gates). route-edge-coverage +7 is my #5975 test comment; the rest is parallel-session drift. Tighten via --update next cycle.", @@ -37,4 +38,4 @@ "_rebaseline_2026_06_26_v3837_release": "1950->1963 (+13). v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.", "_rebaseline_2026_07_06_v3845_release_close": "2028->2035 (+7). v3.8.45 release close (generate-release Phase 0): drift herdado do merge burst final do ciclo (#6216 streaming fixes, #6251/#6253 dashboard UX, #6292 zero-width, fixes do pre-flight ce897453 — todos test/config/workflow-neutros em complexidade nova, verificado pelo validador no tip 5ecca12aa5). Tighten via --update next cycle.", "_rebaseline_2026_07_07_6552_chirag_api_models_filter": "2050->2052 (+2). PR #6552 (@chirag127, #6328): hidePaidModels filter across the 4 dashboard /api/models endpoints adds 2 functions over the complexity threshold. Owner-approved rebaseline (contributor own-growth). Tighten via --update next cycle." -} \ No newline at end of file +} diff --git a/config/quality/dependency-allowlist.json b/config/quality/dependency-allowlist.json index c5c9518414..6dae8b6274 100644 --- a/config/quality/dependency-allowlist.json +++ b/config/quality/dependency-allowlist.json @@ -91,6 +91,7 @@ "next-themes", "node-loader", "node-machine-id", + "omniglyph", "open", "ora", "parse5", diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index f182d0abc5..81739a3e88 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -178,7 +178,7 @@ "_rebaseline_2026_06_26_fidelity_gate_extraction": "Milestone-B fidelity-gate wiring residual: bodyToText+gateAdvance extracted to fidelityGateStep.ts (889->854, -35), but the StackOptions.fidelityGate field, the `const fidelityGate` reads at the two stacked-loop dispatch chokepoints, and the import of FidelityGateConfig are irreducible wiring that cannot leave strategySelector without an architectural refactor of the pre-existing stacked pipeline. Net: 889->854 (+6 vs the pre-Milestone-B frozen 848). Covered by tests/unit/compression/*.test.ts (940 pass).", "_rebaseline_2026_06_28_5243_risk_gate_prepass": "PR #5243 (compression risk-gate pre-pass) own growth: open-sse/services/compression/strategySelector.ts 854->899 (+45). The three exported entry points (applyCompression/applyStackedCompression/applyStackedCompressionAsync) become thin wrappers over pure-extracted private bodies (runCompression/runStackedCompression/runStackedCompressionAsync) so the risk-gate mask->run->restore wrapper sits strictly OUTSIDE the per-step loop — a single universal integration point. The wrapper logic itself (resolveRiskGate/withRiskGate) lives in the new riskGate/strategyWrap.ts (960 (+61 = the opt-in result-memoization branches in applyCompression/applyCompressionAsync — principal+determinism gate, makeMemoKey lookup/store with model+supportsVision folded into the key, recompute-with-memo-off). Default off (memoizeCompressionResults), so zero behavior change. The memo helpers live in the leaf resultMemo.ts (2181 (+78 = the compare-and-swap guard on the refresh persist — runWithCasGuard/getActiveCasGuard AsyncLocalStorage pair mirroring runWithOnPersist, casGuardShouldSkipPersist that rereads the row right before persisting and skips the write when a concurrent writer already rotated the refresh_token past the one presented, plus getCasGuardStats counters). Fixes the sibling-rotation-revert → token-family-revocation storm. Gated behind an active guard (opt-in; no guard => byte-identical). Wiring lives at the two persist chokepoints inside getAccessToken; the comparison reuses wasRefreshTokenRotated from refreshSerializer. Not extractable without splitting the refresh hot path.", "open-sse/services/tokenRefresh.ts": 2181, @@ -386,6 +386,7 @@ "_rebaseline_2026_07_07_6526_chirag_modal_1080p": "PR #6526 (@chirag127, #6265): AddApiKeyModal.tsx ->961 (1080p sizing). Owner-approved. Frozen.", "_rebaseline_2026_07_07_6515_chirag": "PR #6515 (@chirag127) own growth: src/sse/handlers/chat.ts ->1763. Owner-approved rebaseline. Frozen.", "_rebaseline_2026_07_07_6534_chirag": "PR #6534 (@chirag127) own growth: open-sse/services/compression/strategySelector.ts ->1025. Owner-approved rebaseline. Frozen.", + "_rebaseline_2026_07_08_6556_omniglyph_mode": "PR #6556 (omniglyph engine) own growth: open-sse/services/compression/strategySelector.ts 1025->1043 (+18 at the existing mode-dispatch chokepoints). Two single-mode branches (sync no-op + async resolve via the engine registry, mirroring the rtk single-mode pattern, B-MODE-ENGINE-DECOUPLE) plus the optional providerTransport field threaded through the three options types (gates transport-sensitive engines). The engine itself lives in engines/omniglyphAdapter.ts (876. Owner-approved rebaseline. Frozen.", "_rebaseline_2026_07_07_6525_chirag_image_guard": "PR #6525 (@chirag127, #6457) own growth: chat.ts ->1778 (reject image-only models on /v1/chat/completions; stacks on #6515). Owner-approved. Frozen." -} \ No newline at end of file +} diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index ddec82469c..377b90bfab 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -120,7 +120,7 @@ "_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle." }, "cognitiveComplexity": { - "value": 883, + "value": 884, "_rebaseline_2026_07_07_v3846_release_close": "877->882 (+5). v3.8.46 release close (generate-release Phase 0 pre-flight): drift herdado do merge burst do ciclo. Trust-but-verify: os fixes de base-red do captain (agentSkills path.resolve #6366, catalogo cache #6408, tipagem de teste, MitmProxyTab suppression) sao cognitive-net-zero — check:cognitive-complexity mede 882 identico com e sem os fixes (a catraca NAO roda no fast-path PR->release). Tighten via --update next cycle.", "_rebaseline_2026_07_03_v3844_ipfilter_release_green": "861->867 (+6). v3.8.44 cycle drift measured on release tip 32e4c906e during the #6131/#5975 release-green rebaseline. Inherited from the merge burst (Quality Ratchet does not run on PR->release fast-gates). route-edge-coverage +7 is my #5975 test comment; the rest is parallel-session drift. Tighten via --update next cycle.", "_rebaseline_2026_07_03_v3844_review_prs_fix_batch": "860->861 (+1). Inherited v3.8.44 cycle drift surfaced by the release-green pre-flight during the /review-prs fix-batch round; check:cognitive-complexity measures 861 on the release tip 72ee80649. Negligible +1 from the round's / parallel-session merge burst (cognitive-complexity does NOT run on PR->release fast-gates). Structural shrink tracked in #3501. Tighten via --update next cycle.", @@ -138,7 +138,8 @@ "_rebaseline_2026_06_26_v3837_release": "816->826. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.", "_rebaseline_2026_06_26_v3838_release": "826->833. v3.8.38 release base measures 833 locally on origin/release/v3.8.38 (800b04ad6) while the committed baseline still says 826. This PR measures the same 833 after refactoring jsonToSse helpers back under the sonarjs/cognitive-complexity threshold, so it does not add a net cognitive-complexity violation. The baseline bump records inherited release-base drift only; structural shrink remains tracked by the existing chatCore decomposition work.", "_rebaseline_2026_07_06_v3845_release_close": "867->877 (+10). v3.8.45 cycle drift measured by check:release-green (hermetic) on release tip 5ecca12aa5 during the /generate-release Phase 0 pre-flight. Inherited from the cycle's merge burst (cognitive-complexity does not run on PR->release fast-gates); the captain's pre-flight fixes are gate/test/workflow changes (complexity-neutral). Tighten via --update next cycle.", - "_rebaseline_2026_07_07_6519_chirag_fallback_reasons": "882->883 (+1). PR #6519 (@chirag127, #6461): the preview route's fallbackReasons dedup loop adds one function over the cognitive threshold. Owner-approved rebaseline (contributor own-growth). Tighten via --update next cycle." + "_rebaseline_2026_07_07_6519_chirag_fallback_reasons": "882->883 (+1). PR #6519 (@chirag127, #6461): the preview route's fallbackReasons dedup loop adds one function over the cognitive threshold. Owner-approved rebaseline (contributor own-growth). Tighten via --update next cycle.", + "_rebaseline_2026_07_08_6556_inherited_drift": "883->884 (+1). PR #6556 (omniglyph engine): drift herdado do merge burst da base (cognitive-complexity nao roda no fast-path PR->release). Trust-but-verify: check:cognitive-complexity mede 884 IDENTICO na base pura origin/release/v3.8.47 e neste HEAD — o PR e cognitive-net-zero (runCompressionAsync extraido para engines/omniglyphSingleMode.ts e o page client dividido em section components na mesma rodada). Tighten via --update next cycle." }, "typeCoveragePct": { "value": 92.17, @@ -377,4 +378,4 @@ "_zizmor_rebaseline_2026_06_19_r1_redundancy": "zizmorFindings 139 -> 145. Quebra: +3 drift PRE-EXISTENTE da base release/v3.8.30 a23d0d678 (medido com minhas mudancas stashed = 142 > 139; o fast-path do release nao roda check:workflows --ratchet) + 3 do novo workflow mutation-redundancy.yml (R1 disableBail): exatamente 3 unpinned-uses de actions/checkout@v7, actions/setup-node@v6, actions/upload-artifact@v7 — a MESMA convencao @vN deliberada e INTOCADA de todos os workflows (ver _scanner_harden_workflows_2026_06_16), identica ao nightly-mutation.yml. SHA-pinar so este workflow violaria a convencao. NOTA DE COLISAO CROSS-PR: o PR #4321 (a11y) tambem rebaselina este metric 139->145 (+3 do job a11y) off a MESMA base — se ambos mergearem, o total real vira 148 (142 base + 3 a11y + 3 r1) e o segundo a mergear precisa reconciliar zizmorFindings -> 148 (mesmo padrao release-volatil dos baselines de complexity/eslint).", "_zizmor_rebaseline_2026_06_19_a11y_148_reconcile": "RECONCILIACAO CROSS-PR (release-volatil) ao mergear #4321 (a11y) APOS #4322 (R1): zizmorFindings 145 -> 148. O #4322 ja rebaselinou 139->145 (drift base 142 + 3 unpinned-uses do mutation-redundancy.yml). Este PR adiciona +3 unpinned-uses @vN do novo job 'a11y' (nightly-resilience.yml): actions/checkout@v7, actions/setup-node@v6, actions/cache@v5.0.5 — MESMA convencao @vN deliberada e INTOCADA de todos os workflows (ver _scanner_harden_workflows_2026_06_16). Total = 142 base + 3 r1 + 3 a11y = 148, MEDIDO com `node scripts/check/check-workflows.mjs --ratchet` na arvore release(com #4322)+#4321 = 148 exato. Nenhum template-injection/artipacked/cache-poisoning novo.", "_zizmor_rebaseline_2026_06_20_ci_build_artifact_reuse": "zizmorFindings 148 -> 152. Drift legitimo deste PR ao reutilizar o artefato next-build do job Build em package-artifact/electron-package-smoke e ao separar o build de compatibilidade Node 26: +4 unpinned-uses novos (2x actions/download-artifact@v8, actions/checkout@v7, actions/setup-node@v6). Mantida a convencao deliberada @vN dos workflows (sem SHA-pinning/manual update burden), conforme precedentes _scanner_harden_workflows_2026_06_16 e _zizmor_rebaseline_2026_06_19_*. Sem novos findings de template-injection/artipacked/cache-poisoning; medido localmente com zizmor 1.25.2 via `npm run check:workflows -- --ratchet` = 152." -} \ No newline at end of file +} diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 1ca37d603f..1a987faff9 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -96,6 +96,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Flush interval (ms) for the batched spend/cost writer. Lower values reduce write coalescing; higher values reduce DB contention. | | `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Max buffered spend entries before a forced flush. Raise on high-QPS deployments; lower when bounded memory matters more. | | `OMNIROUTE_PROXY_FETCH_DEBUG` | _(unset)_ | `open-sse/utils/proxyFetch.ts` | Set to `"true"` to emit `[ProxyFetch]` debug logs on the Vercel relay path. Off by default to avoid leaking routing hints. | +| `OMNIROUTE_DEBUG_COMPLETION` | _(unset)_ | `bin/cli/commands/completion.mjs` | Set to any non-empty value to emit `[omniroute completion]` diagnostics from the CLI shell-completion cache paths (read/refresh/write). Off by default — those caches fail silently so a missing/corrupt cache never breaks tab-completion. | | `BATCH_RETRY_DURATION_MS` | `86400000` (24h) | `open-sse/services/batchProcessor.ts` | Maximum retry window for individual batch items (ms). Items exceeding this duration are marked failed. | | `BATCH_BACKOFF_BASE_MS` | `5000` | `open-sse/services/batchProcessor.ts` | Base delay (ms) for exponential backoff on batch item retries. | | `BATCH_BACKOFF_MAX_MS` | `3600000` (1h) | `open-sse/services/batchProcessor.ts` | Cap (ms) for exponential backoff between batch item retries. | diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 330a37c730..97f1dc60ce 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -723,6 +723,19 @@ export function getImageModelAliases() { return IMAGE_MODEL_ALIASES; } +/** + * #6457 — precise provider+modelId membership check against the image registry. + * Unlike getImageModelEntry() (which also resolves bare aliases and unprefixed + * ids by scanning every provider), this only answers "is `modelId` registered + * as an image model under this exact `providerId`?" — used by the chat catalog + * builder to keep upstream-discovered models (e.g. HuggingFace's live + * `/v1/models`, which returns image/diffusion models with no modality field) + * out of the chat listing when they are already known image-only models. + */ +export function isRegisteredImageModel(providerId, modelId) { + return Boolean(findImageModelConfig(providerId, modelId)); +} + export function getImageModelEntry(modelStr) { if (!modelStr) return null; diff --git a/open-sse/config/providerFieldStrips.ts b/open-sse/config/providerFieldStrips.ts index 5a342cc9c1..b02bb37851 100644 --- a/open-sse/config/providerFieldStrips.ts +++ b/open-sse/config/providerFieldStrips.ts @@ -22,6 +22,26 @@ export function findOffendingField(bodyText: string): string | null { return null; } +/** + * Regex to extract an unsupported parameter name from upstream 400 error text. + * Matches: + * - "Unsupported parameter(s): thinking" + * - "Unsupported parameter: max_tokens" + * - "Unsupported parameter 'reasoning_budget'" + */ +export const UNSUPPORTED_PARAM_RE = + /unsupported\s+parameter\w*(?:\s*\(s\))?[:\s]+["'`]?(\w+)["'`]?/i; + +/** + * Extract a single unsupported parameter name from a 400 error body, + * or null if the error does not match the known pattern. + */ +export function detectUnsupportedParam(bodyText: string): string | null { + if (typeof bodyText !== "string" || !bodyText) return null; + const match = UNSUPPORTED_PARAM_RE.exec(bodyText); + return match?.[1] ?? null; +} + /** Immutably drop request fields Groq rejects with a 400. */ export function stripGroqUnsupportedFields>(body: T): T { if (!body || typeof body !== "object") return body; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 59e64bde69..0058affe32 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -4,7 +4,16 @@ import { normalizeAnthropicHeaderVariants, } from "../config/anthropicHeaders.ts"; import { applyContextEditingToBody } from "../config/contextEditing.ts"; -import { findOffendingField, stripGroqUnsupportedFields } from "../config/providerFieldStrips.ts"; +import { + findOffendingField, + detectUnsupportedParam, + stripGroqUnsupportedFields, +} from "../config/providerFieldStrips.ts"; +import { + getParamFilterConfig, + addParamToBlocklist, + isAutoLearnGloballyEnabled, +} from "@/lib/db/paramFilters"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; @@ -1261,6 +1270,39 @@ export class BaseExecutor { `Upstream 400 rejected ${offending} on ${url} — retrying without it` ); response = await fetchWithStartTimeout(url, { ...fetchOptions, body: retryBody }); + } else { + // Auto-learn: detect "Unsupported parameter" errors and persist to DB + // when the provider config has autoLearn enabled (#6625). + const autoLearned = detectUnsupportedParam(errText); + if ( + autoLearned && + !strippedFields.has(autoLearned) && + (transformedBody as Record)[autoLearned] !== undefined + ) { + try { + const config = getParamFilterConfig(this.provider); + const shouldAutoLearn = isAutoLearnGloballyEnabled() || config?.autoLearn === true; + if (shouldAutoLearn) { + strippedFields.add(autoLearned); + addParamToBlocklist(this.provider, autoLearned, model); + delete (transformedBody as Record)[autoLearned]; + let retryBody = JSON.stringify(transformedBody); + if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") { + retryBody = await signRequestBody(retryBody); + } + log?.info?.( + "AUTO_LEARN", + `Auto-learned "${autoLearned}" for provider ${this.provider} (model: ${model}) from 400 on ${url} — retrying` + ); + response = await fetchWithStartTimeout(url, { ...fetchOptions, body: retryBody }); + } + } catch (learnError) { + log?.warn?.( + "AUTO_LEARN", + `Failed to persist auto-learned param "${autoLearned}" for ${this.provider}: ${String(learnError)}` + ); + } + } } } diff --git a/open-sse/executors/mimocode.ts b/open-sse/executors/mimocode.ts index 45a061bae3..1313a1d2ab 100644 --- a/open-sse/executors/mimocode.ts +++ b/open-sse/executors/mimocode.ts @@ -12,13 +12,20 @@ * * Only the "mimo-auto" model is supported (1M context, 128K output). * Supports multiple accounts: N fingerprints → N JWTs → round-robin with cooldown. - * On 429, account enters cooldown (exponential backoff). On 401/403, JWT is re-bootstrapped. + * On 429 — or a 400 carrying MiMoCode's rate-limit text — account enters cooldown + * (exponential backoff) and the next account is tried. On 401/403, JWT is + * re-bootstrapped. Any other 400 is a genuinely malformed request (#2101): it fails + * fast on the current account instead of being retried identically on every + * account, which would waste N round-trips, cooldown every account, and hide the + * real upstream diagnostic behind a generic "all accounts exhausted" error (#4976). */ import * as crypto from "node:crypto"; import * as os from "node:os"; import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts"; import { createProxyDispatcher } from "../utils/proxyDispatcher.ts"; +import { RATE_LIMIT_TEXT_PATTERNS } from "../services/accountFallback.ts"; +import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts"; import { fetch as undiciFetch, type Dispatcher } from "undici"; const BOOTSTRAP_PATH = "/api/free-ai/bootstrap"; @@ -350,6 +357,127 @@ export class MimocodeExecutor extends BaseExecutor { account.consecutiveFails = 0; } + /** + * POST the request with the account's JWT; on auth failure (401/403), re-bootstrap + * the account's JWT and retry once. Mutates `headers`' Authorization in place. + */ + private async fetchWithAuthRetry( + url: string, + headers: Record, + reqBody: unknown, + signal: AbortSignal | null | undefined, + account: AccountState, + log: ExecuteInput["log"] + ): Promise { + const jwt = await this.getJwtForAccount(account, signal); + headers["Authorization"] = `Bearer ${jwt}`; + + const resp = await this.fetchWithProxy( + url, + { + method: "POST", + headers, + body: JSON.stringify(reqBody), + signal: signal ?? undefined, + }, + account.fingerprint + ); + if (resp.status !== 401 && resp.status !== 403) return resp; + + // On auth failure, re-bootstrap this account and retry once + log?.warn?.( + "MIMOCODE", + `Auth failed (${resp.status}) on account ${account.fingerprint.slice(0, 8)}…` + ); + account.jwt = ""; + account.expiresAt = 0; + account.consecutiveFails = 0; + const freshJwt = await this.getJwtForAccount(account, signal); + headers["Authorization"] = `Bearer ${freshJwt}`; + return this.fetchWithProxy( + url, + { + method: "POST", + headers, + body: JSON.stringify(reqBody), + signal: signal ?? undefined, + }, + account.fingerprint + ); + } + + /** + * Gate 429/400 statuses before the success path: a 429 — or a 400 carrying + * MiMoCode's rate-limit text — puts the account on cooldown and rotates; any other + * 400 fails fast with the sanitized upstream error (#2101/#4976, see + * handleBadRequest). Returns "rotate", a fail-fast Response, or null to proceed. + */ + private async gateRetryableStatus( + resp: Response, + account: AccountState, + log: ExecuteInput["log"] + ): Promise<"rotate" | Response | null> { + if (resp.status === 429) { + this.markCooldown(account); + log?.warn?.( + "MIMOCODE", + `Rate limited on account ${account.fingerprint.slice(0, 8)}, trying next…` + ); + return "rotate"; + } + if (resp.status !== 400) return null; + return (await this.handleBadRequest(resp, account, log)) ?? "rotate"; + } + + /** + * Classify a 400 response body (#2101/#4976). + * + * #4976: MiMoCode signals throttling via a non-standard 400 whose body carries + * rate-limit semantics (e.g. "Detected high-frequency non-compliant requests from + * you.") instead of a 429 — same RATE_LIMIT_TEXT_PATTERNS as accountFallback.ts's + * checkFallbackError(), so the two call sites never disagree on what counts as + * throttling. That case puts the account on cooldown and returns `null` (rotate). + * + * #2101: any other 400 is a genuinely malformed request that fails identically on + * every account — rotating would waste N round-trips, cooldown every account (a + * provider-wide outage for parallel requests), and hide the real diagnostic behind + * a generic exhaustion error. That case returns a fail-fast 400 Response carrying + * the sanitized upstream message, without touching cooldown/success state. + */ + private async handleBadRequest( + resp: Response, + account: AccountState, + log: ExecuteInput["log"] + ): Promise { + const bodyText = await resp.text().catch(() => ""); + + if (RATE_LIMIT_TEXT_PATTERNS.some((p) => p.test(bodyText))) { + this.markCooldown(account); + log?.warn?.( + "MIMOCODE", + `Rate-limit-style 400 on account ${account.fingerprint.slice(0, 8)}, trying next…` + ); + return null; + } + + log?.warn?.( + "MIMOCODE", + `Malformed request (400) on account ${account.fingerprint.slice(0, 8)}, not retrying` + ); + let upstreamMessage = bodyText; + try { + const parsed = JSON.parse(bodyText) as { error?: { message?: string } }; + if (parsed?.error?.message) upstreamMessage = parsed.error.message; + } catch { + /* body wasn't JSON — use raw text */ + } + const errorBody = buildErrorBody(400, sanitizeErrorMessage(upstreamMessage || "Bad request")); + return new Response(MimocodeExecutor.encoder.encode(JSON.stringify(errorBody)), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + buildUrl( _model: string, _stream: boolean, @@ -457,51 +585,19 @@ export class MimocodeExecutor extends BaseExecutor { for (let attempt = 0; attempt < this.accounts.length; attempt++) { const account = this.pickAccount(); try { - const jwt = await this.getJwtForAccount(account, signal); const headers = this.buildHeaders(input.credentials, stream); - headers["Authorization"] = `Bearer ${jwt}`; + const resp = await this.fetchWithAuthRetry(url, headers, reqBody, signal, account, log); - let resp = await this.fetchWithProxy( - url, - { - method: "POST", - headers, - body: JSON.stringify(reqBody), - signal: signal ?? undefined, - }, - account.fingerprint - ); - - // On auth failure, re-bootstrap this account and retry once - if (resp.status === 401 || resp.status === 403) { - log?.warn?.( - "MIMOCODE", - `Auth failed (${resp.status}) on account ${account.fingerprint.slice(0, 8)}…` - ); - account.jwt = ""; - account.expiresAt = 0; - account.consecutiveFails = 0; - const freshJwt = await this.getJwtForAccount(account, signal); - headers["Authorization"] = `Bearer ${freshJwt}`; - resp = await this.fetchWithProxy( + // 429/400 gating (#2101/#4976): cooldown+rotate, fail fast, or proceed. + const gate = await this.gateRetryableStatus(resp, account, log); + if (gate === "rotate") continue; + if (gate) { + return { + response: gate, url, - { - method: "POST", - headers, - body: JSON.stringify(reqBody), - signal: signal ?? undefined, - }, - account.fingerprint - ); - } - - if (resp.status === 429) { - this.markCooldown(account); - log?.warn?.( - "MIMOCODE", - `Rate limited on account ${account.fingerprint.slice(0, 8)}, trying next…` - ); - continue; + headers: this.buildHeaders(input.credentials, stream), + transformedBody: reqBody, + }; } this.markSuccess(account); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 7495e6d531..676292a4c5 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -113,6 +113,7 @@ import { stripGpt5SamplingWhenReasoning } from "../services/gpt5SamplingGuard.ts import { getUnsupportedParams, REGISTRY } from "../config/providerRegistry.ts"; import { supportsMaxTokens } from "@/lib/modelCapabilities.ts"; import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts"; +import { isVisionModelId } from "@/shared/constants/visionModels.ts"; import { buildErrorBody, createErrorResult, @@ -562,6 +563,10 @@ export async function handleChatCore({ clientRawRequest, provider, model, + // NEXA fusion-idempotency fix: body.messages feeds the key digest so combo-internal + // sub-requests (fusion panel + judge re-enter chatCore sharing the client's headers) + // can never collide on the raw Idempotency-Key/x-request-id header key. + body, effectiveServiceTier, startTime, log, @@ -1305,6 +1310,10 @@ export async function handleChatCore({ const compressionConfig = resolveCacheAwareConfig(config, compressionInputBody, cacheCtx); const result = await applyCompressionAsync(compressionInputBody, mode, { model: effectiveModel, + supportsVision: isVisionModelId(effectiveModel), + // Rota direta oficial ('anthropic') vs agregadores: o engine omniglyph + // exige 'direct' — agregadores redimensionam imagens (medido 2026-07-06). + providerTransport: provider === "anthropic" ? "direct" : "aggregator", config: compressionConfig, cachingContext: cacheCtx, principalId: apiKeyInfo?.id ? String(apiKeyInfo.id) : undefined, diff --git a/open-sse/handlers/chatCore/idempotency.ts b/open-sse/handlers/chatCore/idempotency.ts index 7fdc45d7e0..205856d41a 100644 --- a/open-sse/handlers/chatCore/idempotency.ts +++ b/open-sse/handlers/chatCore/idempotency.ts @@ -1,7 +1,45 @@ +import { createHash } from "node:crypto"; import { getIdempotencyKey, checkIdempotency } from "@/lib/idempotencyLayer"; import { calculateCost } from "@/lib/usage/costCalculator"; import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; +/** + * NEXA fusion-idempotency fix: compose the effective idempotency key from the raw + * header key + target provider/model + a digest of the request messages. + * + * Why: combo-internal sub-requests (fusion panel members AND the judge) re-enter + * chatCore SHARING the client's headers, so the raw `Idempotency-Key`/`x-request-id` + * key was identical for all of them. A panel answer saved under the key and the + * judge's check (~1ms later, well inside the 5s window) replayed it — the client + * received a panel member's answer instead of the judge synthesis. Namespacing by + * model separates panel members; the messages digest separates the judge even when + * it reuses a panel member's model (the judge body appends the judge directive + * turn). A genuine client retry (same key, same model, same body) still replays. + */ +export function composeIdempotencyKey({ + rawKey, + provider, + model, + messages, +}: { + rawKey: string | null | undefined; + provider: string; + model: string; + messages: unknown; +}): string | null { + if (!rawKey) return null; + let digest = ""; + try { + digest = createHash("sha256") + .update(JSON.stringify(messages ?? "")) + .digest("hex") + .slice(0, 16); + } catch { + digest = "nodigest"; + } + return `${rawKey}|${provider}|${model}|${digest}`; +} + /** * Resolve the request's idempotency key once and check the idempotency store. Returns the * resolved `idempotencyKey` alongside the cache `hit` so the caller can reuse the SAME key @@ -12,6 +50,7 @@ export async function checkIdempotencyCache({ clientRawRequest, provider, model, + body, effectiveServiceTier, startTime, log, @@ -19,19 +58,26 @@ export async function checkIdempotencyCache({ clientRawRequest: unknown; provider: string; model: string; + body?: unknown; effectiveServiceTier: unknown; startTime: number; log: unknown; -}): Promise<{ hit: { success: true; response: Response } | null; idempotencyKey: string }> { - const idempotencyKey = getIdempotencyKey(clientRawRequest?.headers); +}): Promise<{ hit: { success: true; response: Response } | null; idempotencyKey: string | null }> { + // NEXA fusion-idempotency fix: namespace the raw header key (see composeIdempotencyKey). + const rawIdempotencyKey = getIdempotencyKey(clientRawRequest?.headers); + const idempotencyKey = composeIdempotencyKey({ + rawKey: rawIdempotencyKey, + provider, + model, + messages: (body as { messages?: unknown } | undefined)?.messages, + }); const cachedIdemp = checkIdempotency(idempotencyKey); if (cachedIdemp) { log?.debug?.("IDEMPOTENCY", `Hit for key=${idempotencyKey?.slice(0, 12)}...`); const idempotentUsage = cachedIdemp.response && typeof cachedIdemp.response === "object" ? ((cachedIdemp.response as Record).usage as - | Record - | undefined) + Record | undefined) : undefined; const idempotentCost = idempotentUsage ? await calculateCost(provider, model, idempotentUsage as Record, { diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index a2a3e76964..c6fc990822 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -1093,11 +1093,11 @@ export const compressionStatusTool: McpToolDefinition< export const compressionConfigureInput = z.object({ enabled: z.boolean().optional(), strategy: z - .enum(["off", "lite", "standard", "aggressive", "ultra", "rtk", "stacked"]) + .enum(["off", "lite", "standard", "aggressive", "ultra", "rtk", "stacked", "omniglyph"]) .optional() .describe("Compression mode"), autoTriggerMode: z - .enum(["off", "lite", "standard", "aggressive", "ultra", "rtk", "stacked"]) + .enum(["off", "lite", "standard", "aggressive", "ultra", "rtk", "stacked", "omniglyph"]) .optional(), maxTokens: z .number() diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 0b2f72e0c3..d37acc6752 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -265,8 +265,8 @@ const MALFORMED_REQUEST_PATTERNS = [ // non-standard 400 status whose body carries rate-limit semantics instead of a 429 // (#4976). When detected, the request is fallback-worthy at connection-cooldown scope // (NOT a whole-provider breaker) so combo routing can fail over to another free target. -// Bounded, non-overlapping patterns only (ReDoS-safe — no nested quantifiers). -const RATE_LIMIT_TEXT_PATTERNS = [ +// Exported: mimocode.ts's executor reuses this list directly (single source of truth). +export const RATE_LIMIT_TEXT_PATTERNS = [ /high.?frequency/i, /non-compliant/i, /too many requests/i, diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index d4eaf3ed70..94a090c87c 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -18,7 +18,12 @@ import { recordProviderFailure, selectLockoutCooldownMs, } from "./accountFallback.ts"; -import { errorResponse, unavailableResponse } from "../utils/error.ts"; +import { + errorResponse, + unavailableResponse, + errorResponseWithComboDiagnostics, +} from "../utils/error.ts"; +import type { ComboDiagnostics } from "../utils/error.ts"; import { buildTargetTimeoutRunner } from "./combo/targetTimeoutRunner.ts"; import { recordComboRequest, recordComboShadowRequest, getComboMetrics } from "./comboMetrics.ts"; import { @@ -1222,8 +1227,24 @@ export async function handleComboChat({ // 16 strategies (priority, weighted, etc.) that funnel through executeTarget. const quotaCutoffResetWindowConfig = resolveResetWindowConfig(config as Record); + // QA P0 diagnostics: record the order in which targets were actually attempted + // (provider/model ids only) so a terminal combo failure can report the attempt + // sequence alongside pool size + exhaustion reasons. Accumulates across set retries. + const comboAttemptOrder: Array<{ provider: string; model: string }> = []; + if (orderedTargets.length === 0) { - return comboModelNotFoundResponse("Combo has no executable targets"); + return errorResponseWithComboDiagnostics( + 404, + "Combo has no executable targets", + { + poolSize: 0, + attempted: 0, + excluded: [], + attemptOrder: [], + terminalReason: "no_executable_targets", + }, + { code: "model_not_found", type: "invalid_request_error" } + ); } scheduleShadowRouting( @@ -1306,6 +1327,23 @@ export async function handleComboChat({ let fallbackCount = 0; let recordedAttempts = 0; + // QA P0: assemble a sanitized diagnostic trace from the state already in scope + // (pool size + this set-try's exhausted providers/connections + attempt order + + // a terminal-reason code). Never touches keys/tokens — provider/model ids only. + const buildComboDiag = (terminalReason: string): ComboDiagnostics => ({ + poolSize: orderedTargets.length, + attempted: recordedAttempts, + excluded: [ + ...[...exhaustedProviders].map((p) => ({ provider: p, reason: "exhausted" })), + ...[...exhaustedConnections].map((c) => ({ + provider: "unknown", + reason: `exhausted_connection:${String(c).slice(0, 8)}`, + })), + ], + attemptOrder: comboAttemptOrder, + terminalReason, + }); + let globalResolve: ((res: Response) => void) | null = null; const globalPromise = new Promise((res) => { globalResolve = res; @@ -1442,7 +1480,23 @@ export async function handleComboChat({ "COMBO", `Maximum combo attempts (${MAX_GLOBAL_ATTEMPTS}) exceeded across all targets and fallbacks. Terminating loop to prevent runaway background requests.` ); - return { ok: false, response: errorResponse(503, "Maximum combo retry limit reached") }; + // Actionable failure instead of an opaque 503 when every candidate + // failed the same recoverable way. If the dominant cause was reasoning + // models exhausting a too-small max_tokens budget (no content output), + // retrying other models can't help — tell the caller to raise max_tokens. + const reasoningExhausted = /reasoning consumed \d+\/\d+ tokens/.test(lastError || ""); + return { + ok: false, + response: errorResponseWithComboDiagnostics( + 503, + reasoningExhausted + ? "All combo candidates exhausted their token budget on reasoning without producing content. Increase max_tokens — reasoning models need a larger budget to emit content." + : "Maximum combo retry limit reached", + buildComboDiag( + reasoningExhausted ? "reasoning_budget_exhausted" : "max_attempts_exceeded" + ) + ), + }; } // Predictive TTFT Circuit Breaker (skip slow models) @@ -1500,6 +1554,8 @@ export async function handleComboChat({ timestamp: Date.now(), strategy, }); + // QA P0 diagnostics: capture the attempt order (provider/model ids only). + comboAttemptOrder.push({ provider: provider ?? "unknown", model: modelStr }); // Deep clone the body to ensure context preservation and prevent mutations // from affecting other targets in the combo. structuredClone avoids the @@ -2244,15 +2300,11 @@ export async function handleComboChat({ latencyMs, fallbackCount, }); - return new Response( - JSON.stringify({ - error: { - message: "Service temporarily unavailable: all upstream accounts are inactive", - type: "service_unavailable", - code: "ALL_ACCOUNTS_INACTIVE", - }, - }), - { status: 503, headers: { "Content-Type": "application/json" } } + return errorResponseWithComboDiagnostics( + 503, + "Service temporarily unavailable: all upstream accounts are inactive", + buildComboDiag("all_accounts_inactive"), + { code: "ALL_ACCOUNTS_INACTIVE", type: "service_unavailable" } ); } @@ -2303,10 +2355,11 @@ export async function handleComboChat({ } log.warn("COMBO", `All models failed | ${msg}`); - return new Response(JSON.stringify({ error: { message: msg } }), { + return errorResponseWithComboDiagnostics( status, - headers: { "Content-Type": "application/json" }, - }); + msg, + buildComboDiag(lastError ?? "all_models_failed") + ); } return errorResponse(503, "Combo routing completed without an upstream response"); diff --git a/open-sse/services/combo/validateQuality.ts b/open-sse/services/combo/validateQuality.ts index 2bab070a21..2b56146479 100644 --- a/open-sse/services/combo/validateQuality.ts +++ b/open-sse/services/combo/validateQuality.ts @@ -22,6 +22,38 @@ export function toRetryAfterDisplayValue(value: ComboRetryAfter): string | Date return new Date(value); } +// Issue #6427: some providers mask credit/quota exhaustion behind an HTTP 200 — +// either an OpenAI-shape top-level `error` object, or a known exhaustion phrase +// living in the error envelope itself (never in assistant prose — see +// `extractEnvelopeErrorText`). Single-quantifier-per-token-class alternation, +// no nested/overlapping quantifiers — cannot backtrack catastrophically. +const EXHAUSTION_MARKER_PATTERN = + /\b(insufficient\s+credit|insufficient\s+balance|quota\s+exceeded|out\s+of\s+credits?|credit\s+exhausted)\b/i; + +/** + * Collect the small set of top-level "error envelope" strings a 200 response may + * carry alongside (or instead of) a normal completion: the OpenAI-shape `error` + * object's `message`/`code`/`type`, a bare string `error`, or sibling top-level + * `message`/`detail` fields some providers use for the same purpose. Deliberately + * does NOT look inside `choices[].message.content` — assistant prose that merely + * mentions "quota" or "credits" must never be misclassified as an upstream failure. + */ +function extractEnvelopeErrorText(json: Record): string | null { + const parts: string[] = []; + const err = json.error; + if (err && typeof err === "object") { + const e = err as Record; + if (typeof e.message === "string") parts.push(e.message); + if (typeof e.code === "string") parts.push(e.code); + if (typeof e.type === "string") parts.push(e.type); + } else if (typeof err === "string" && err.length > 0) { + parts.push(err); + } + if (typeof json.message === "string") parts.push(json.message); + if (typeof json.detail === "string") parts.push(json.detail); + return parts.length > 0 ? parts.join(" ") : null; +} + function responsesApiOutputHasContent(output: unknown): boolean { return ( Array.isArray(output) && @@ -335,6 +367,32 @@ export async function validateResponseQuality( } } + // Issue #6427: a masked 200 — an OpenAI-shape top-level `error` object, or a + // known exhaustion phrase in the error envelope — is a failure regardless of + // whether `choices`/`output` also look structurally present (some providers + // echo a stub completion alongside the error). Checked unconditionally, before + // any shape-specific branch, so it can't be shadowed by an otherwise-valid body. + const rawError = json?.error; + const errorIsMeaningful = + (typeof rawError === "string" && rawError.length > 0) || + (!!rawError && typeof rawError === "object" && Object.keys(rawError).length > 0); + if (errorIsMeaningful) { + const envelopeText = extractEnvelopeErrorText(json); + const errMsg = + rawError && typeof rawError === "object" && typeof (rawError as Record).message === "string" + ? ((rawError as Record).message as string) + : envelopeText || JSON.stringify(rawError).substring(0, 200); + return { valid: false, reason: `upstream error in 200 body: ${errMsg}` }; + } + { + const envelopeText = extractEnvelopeErrorText(json); + if (envelopeText && EXHAUSTION_MARKER_PATTERN.test(envelopeText)) { + const snippet = + envelopeText.length > 80 ? `${envelopeText.slice(0, 80)}…` : envelopeText; + return { valid: false, reason: `upstream exhaustion marker in 200 body: ${snippet}` }; + } + } + const choices = json?.choices; if (json?.object === "response") { if (!responsesApiOutputHasContent(json.output)) @@ -354,14 +412,9 @@ export async function validateResponseQuality( } if (!Array.isArray(choices) || choices.length === 0) { + // `json?.error` is already handled unconditionally above (#6427); reaching + // here means no error envelope was present. if (json?.output || json?.result || json?.data || json?.response) return { valid: true }; - if (json?.error) { - const err = json.error as Record; - return { - valid: false, - reason: `upstream error in 200 body: ${err?.message || JSON.stringify(json.error).substring(0, 200)}`, - }; - } return { valid: true }; } diff --git a/open-sse/services/compression/adaptiveCompression/ladder.ts b/open-sse/services/compression/adaptiveCompression/ladder.ts index 3f7021b5f7..2302b2c64b 100644 --- a/open-sse/services/compression/adaptiveCompression/ladder.ts +++ b/open-sse/services/compression/adaptiveCompression/ladder.ts @@ -7,31 +7,48 @@ import type { LadderStage } from "./types.ts"; * SLM tier wired through `ultra`); an operator can still add them via ladderOverride. */ export const DEFAULT_LADDER: LadderStage[] = [ - { engine: "session-dedup" }, // lossless cross-turn dedup (catalog pri 3) + { engine: "session-dedup" }, // lossless cross-turn dedup (catalog pri 3) { engine: "rtk", intensity: "standard" }, // command-output filtering (pri 10) - { engine: "headroom" }, // tabular JSON compaction (pri 15) - { engine: "lite" }, // whitespace/format cleanup (pri 5, but cheap prose pass) + { engine: "headroom" }, // tabular JSON compaction (pri 15) + { engine: "lite" }, // whitespace/format cleanup (pri 5, but cheap prose pass) { engine: "caveman", intensity: "full" }, // rule-based prose (pri 20) - { engine: "aggressive" }, // summarize + age old turns (pri 30) - { engine: "ultra" }, // heuristic token pruning + optional SLM (pri 40) + { engine: "aggressive" }, // summarize + age old turns (pri 30) + { engine: "ultra" }, // heuristic token pruning + optional SLM (pri 40) ]; /** * Aggressiveness rank used to know where a base plan sits so `floor` mode escalates * BEYOND it (design §4.2). Keyed by engine id AND by the equivalent CompressionMode name * ("standard" === caveman) so a base plan's `mode` string maps cleanly. + * + * Rescaled ×10 vs the original 7-entry scale (#6533) to make room for the novel catalog + * engines that ship in `open-sse/services/compression/engines/index.ts` but are not part + * of DEFAULT_LADDER: `ccr` and `llmlingua` are intentionally excluded from the AUTOMATIC + * ladder (see DEFAULT_LADDER doc comment) yet must still rank correctly when an operator + * adds them via `ladderOverride` — same for `ionizer`, `relevance`, `llm`, and + * `read-lifecycle`. Placement follows each engine's documented `stackPriority` in + * `engineCatalog.ts` / its own module header, interpolated onto the existing 7-tier scale + * (the `lite` exception — ranked after `headroom` despite a lower stackPriority — is a + * pre-existing, deliberate design call and is left untouched). */ const AGGRESSIVENESS: Record = { off: 0, - "session-dedup": 1, - rtk: 2, - headroom: 3, - lite: 4, - caveman: 5, - standard: 5, // mode-name alias for caveman - stacked: 5, // a derived/stacked base plan sits at the prose tier; floor escalates past it - aggressive: 6, - ultra: 7, + "session-dedup": 10, // stackPriority 3 — lossless cross-turn dedup + ccr: 15, // stackPriority 4 — reversible retrieval marker, only if it shrinks + rtk: 20, // stackPriority 10 — command-output filtering + ionizer: 25, // stackPriority 13 — tabular row sampling (lighter than headroom) + headroom: 30, // stackPriority 15 — tabular JSON compaction + lite: 40, // pri 5, but cheap prose pass (pre-existing reorder, kept as-is) + "read-lifecycle": 42, // stackPriority 5 (ties lite) — narrow-scope, opt-in, fully lossy + relevance: 45, // stackPriority 18 — extractive sentence scoring, opt-in + caveman: 50, + standard: 50, // mode-name alias for caveman + stacked: 50, // a derived/stacked base plan sits at the prose tier; floor escalates past it + aggressive: 60, + llmlingua: 65, // stackPriority 35 — semantic pruning (ONNX), after aggressive, before ultra/llm + llm: 68, // stackPriority 38 — full LLM-tier compressor, opt-in default-off + ultra: 70, + omniglyph: 80, // stackPriority 90 — context-as-image (lossy render), runs after every text engine }; export function aggressivenessOf(engineOrMode: string): number { @@ -45,13 +62,20 @@ export function aggressivenessOf(engineOrMode: string): number { */ const REDUCTION_FACTOR: Record = { "session-dedup": 0.95, + ccr: 0.9, // conservative: only replaces a block when the marker is shorter than it rtk: 0.85, + ionizer: 0.83, // row sampling, lighter than headroom's full tabular compaction headroom: 0.8, lite: 0.92, + "read-lifecycle": 0.88, // scope-limited to stale/superseded Read tool-results + relevance: 0.75, // extractive sentence dropping caveman: 0.7, standard: 0.7, aggressive: 0.55, + llmlingua: 0.5, // semantic pruning (ONNX) + llm: 0.45, // full LLM-tier compressor, stronger than llmlingua ultra: 0.4, + omniglyph: 0.35, // measured 0.23-0.33 on converted blocks (254->84 tokens); 0.35 stays conservative }; export function expectedReductionFactor(engine: string): number { diff --git a/open-sse/services/compression/deriveDefaultPlan.ts b/open-sse/services/compression/deriveDefaultPlan.ts index 15ccdbeebc..9fa3d2c55f 100644 --- a/open-sse/services/compression/deriveDefaultPlan.ts +++ b/open-sse/services/compression/deriveDefaultPlan.ts @@ -8,6 +8,7 @@ const SINGLE_MODE_OF: Record = { aggressive: "aggressive", ultra: "ultra", rtk: "rtk", + omniglyph: "omniglyph", }; export type CompressionSource = diff --git a/open-sse/services/compression/engineCatalog.ts b/open-sse/services/compression/engineCatalog.ts index 4fc10bf5d3..09ab676720 100644 --- a/open-sse/services/compression/engineCatalog.ts +++ b/open-sse/services/compression/engineCatalog.ts @@ -80,6 +80,13 @@ export const ENGINE_CATALOG: Record = { isSingleMode: true, description: "Heuristic token pruning (+ optional SLM).", }, + omniglyph: { + id: "omniglyph", + label: "OmniGlyph", + stackPriority: 90, + isSingleMode: true, + description: "Contexto-como-imagem (Claude Fable 5, rota direta).", + }, }; export const ENGINE_IDS: string[] = Object.values(ENGINE_CATALOG) diff --git a/open-sse/services/compression/engines/index.ts b/open-sse/services/compression/engines/index.ts index e58cc3d893..02ba4729a4 100644 --- a/open-sse/services/compression/engines/index.ts +++ b/open-sse/services/compression/engines/index.ts @@ -9,6 +9,7 @@ import { ionizerEngine } from "./ionizer/index.ts"; import { relevanceEngine } from "./relevance/index.ts"; import { llmCompressorEngine } from "./llm/index.ts"; import { readLifecycleEngine } from "./readLifecycle/index.ts"; +import { omniglyphEngine } from "./omniglyphAdapter.ts"; let registered = false; @@ -34,6 +35,7 @@ export function registerBuiltinCompressionEngines(): void { { id: "relevance", engine: relevanceEngine }, { id: "llm", engine: llmCompressorEngine }, { id: "read-lifecycle", engine: readLifecycleEngine }, + { id: "omniglyph", engine: omniglyphEngine }, ]; for (const { id, engine } of engines) { diff --git a/open-sse/services/compression/engines/omniglyphAdapter.ts b/open-sse/services/compression/engines/omniglyphAdapter.ts new file mode 100644 index 0000000000..453fb85bd1 --- /dev/null +++ b/open-sse/services/compression/engines/omniglyphAdapter.ts @@ -0,0 +1,116 @@ +/** + * OmniGlyph — compressão contexto-como-imagem (Anthropic/Fable 5 apenas). + * Renderiza system prompt, tool docs, histórico antigo e tool_results grandes + * como páginas PNG densas; o modelo lê as páginas no lugar do texto por ~10× + * menos tokens no bloco convertido (59-70% ponta a ponta, medido). + * + * GATES (todos fail-closed; cada skip vira técnica `skip:` nos stats): + * - supportsVision !== true → skip:no_vision + * - modelo fora da allowlist medida → skip:model_not_approved + * - providerTransport !== 'direct' → skip:transport_not_direct + * (agregadores redimensionam imagens e destroem a legibilidade — medido) + * - corpo não é formato Claude nativo → skip:source_format_not_claude + * - gate de rentabilidade interno do omniglyph decide o resto (patches 28px + * exatos; texto esparso/pequeno passa direto) → skip:not_profitable + * + * `sampling: true`: perda é INTENCIONAL (byte-exatos viajam no factsheet em + * texto) — o fidelity gate pula esta engine por design, não por acidente. + */ +import type { CompressionEngine, CompressionEngineApplyOptions } from "./types.ts"; +import type { CompressionResult } from "../types.ts"; +import { createCompressionStats } from "../stats.ts"; +import { transformAnthropicMessages, isOmniGlyphSupportedModel } from "omniglyph"; + +function skip(body: Record, reason: string): CompressionResult { + try { + return { + body, + compressed: false, + stats: createCompressionStats(body, body, "stacked", [`skip:${reason}`]), + }; + } catch { + // Fail-open guard: a non-serializable body (e.g. circular reference) makes + // createCompressionStats' internal JSON.stringify throw too — stats become + // best-effort telemetry, never a reason to propagate the error. + return { body, compressed: false, stats: null }; + } +} + +/** Formato Claude nativo: system no topo, nunca role:"system" dentro de messages. */ +function isClaudeFormat(body: Record): boolean { + const messages = body.messages; + if (!Array.isArray(messages)) return false; + return !messages.some((m) => (m as { role?: string } | null)?.role === "system"); +} + +async function applyOmniglyph( + body: Record, + options?: CompressionEngineApplyOptions +): Promise { + const model = options?.model ?? (body as { model?: string }).model ?? ""; + if (options?.supportsVision !== true) return skip(body, "no_vision"); + if (!isOmniGlyphSupportedModel(model)) return skip(body, "model_not_approved"); + if (options?.providerTransport !== "direct") return skip(body, "transport_not_direct"); + if (!isClaudeFormat(body)) return skip(body, "source_format_not_claude"); + + const started = Date.now(); + let outBody: Record; + try { + const encoded = new TextEncoder().encode(JSON.stringify(body)); + const result = await transformAnthropicMessages({ body: encoded, model }); + if (!result.applied) return skip(body, result.reason ?? "not_profitable"); + outBody = JSON.parse(new TextDecoder().decode(result.body)) as Record; + } catch { + // Fail-open: qualquer erro no encode/transform/decode (ex.: corpo não serializável, + // render PNG estourando, JSON decodificado malformado) vira skip, nunca propaga. + return skip(body, "transform_error"); + } + + return { + body: outBody, + compressed: true, + stats: createCompressionStats( + body, + outBody, + "stacked", + ["omniglyph:context-as-image"], + undefined, + Date.now() - started + ), + }; +} + +export const omniglyphEngine: CompressionEngine = { + id: "omniglyph", + name: "OmniGlyph", + description: + "Contexto-como-imagem (Anthropic Fable 5, rota direta): system prompt, tool docs e histórico viram páginas PNG densas — ~10× menos tokens no bloco convertido.", + icon: "image", + targets: ["messages", "tool_results"], + stackable: true, + stackPriority: 90, // por último: RTK/Caveman limpam texto antes; omniglyph imageia o residual + sampling: true, // perda intencional + factsheet → fidelity gate pula por design + metadata: { + id: "omniglyph", + name: "OmniGlyph", + description: "Contexto-como-imagem para Claude Fable 5 via rota direta Anthropic.", + inputScope: "mixed", + targetLatencyMs: 250, // render+encode PNG de páginas grandes + supportsPreview: true, + stable: false, // P1: preview — promover após o e2e P3 (30/30 via OmniRoute) + }, + // Contrato da interface: engines async-only mantêm apply síncrono como pass-through seguro. + apply(body) { + return { body, compressed: false, stats: null }; + }, + applyAsync: applyOmniglyph, + compress(body, config) { + return this.apply(body, { stepConfig: config }); + }, + getConfigSchema() { + return []; + }, + validateConfig() { + return { valid: true, errors: [] }; + }, +}; diff --git a/open-sse/services/compression/engines/omniglyphSingleMode.ts b/open-sse/services/compression/engines/omniglyphSingleMode.ts new file mode 100644 index 0000000000..90c25450d4 --- /dev/null +++ b/open-sse/services/compression/engines/omniglyphSingleMode.ts @@ -0,0 +1,23 @@ +import { registerBuiltinCompressionEngines } from "./index.ts"; +import { getCompressionEngine } from "./registry.ts"; +import type { CompressionEngineApplyOptions } from "./types.ts"; +import type { CompressionResult } from "../types.ts"; + +/** + * Single-mode resolution for the async-only "omniglyph" engine. Selecting the + * "omniglyph" mode IS the enable signal — run it alone, same pattern as the + * "rtk" single mode. (B-MODE-ENGINE-DECOUPLE) + * + * Kept out of strategySelector's runCompressionAsync so the dispatcher stays + * under the complexity gate; this helper owns the registry lookup + the + * fail-safe pass-through when the engine (or its async entry) is unavailable. + */ +export async function applyOmniglyphSingleMode( + body: Record, + options?: CompressionEngineApplyOptions +): Promise { + registerBuiltinCompressionEngines(); + const engine = getCompressionEngine("omniglyph"); + if (!engine?.applyAsync) return { body, compressed: false, stats: null }; + return engine.applyAsync(body, options); +} diff --git a/open-sse/services/compression/engines/types.ts b/open-sse/services/compression/engines/types.ts index dcbca19b96..65454eda0c 100644 --- a/open-sse/services/compression/engines/types.ts +++ b/open-sse/services/compression/engines/types.ts @@ -32,6 +32,11 @@ export interface CompressionEngineMetadata { export interface CompressionEngineApplyOptions { model?: string; supportsVision?: boolean | null; + /** Como o request chega ao provider: rota direta oficial ('direct') vs + * agregador que pode reprocessar imagens ('aggregator'). O engine omniglyph + * exige 'direct' — medição 2026-07-06: agregadores redimensionam as páginas + * e destroem a legibilidade. undefined = desconhecido = skip (fail-closed). */ + providerTransport?: "direct" | "aggregator"; config?: CompressionConfig; compressionComboId?: string | null; stepConfig?: Record; diff --git a/open-sse/services/compression/resultMemo.ts b/open-sse/services/compression/resultMemo.ts index 2d6ef21062..283733a31f 100644 --- a/open-sse/services/compression/resultMemo.ts +++ b/open-sse/services/compression/resultMemo.ts @@ -10,9 +10,13 @@ const memoMap = new Map(); // CCR store (`ccr/index.ts` ccrStore; session-dedup imports storeBlock), so their output // depends on prior state → not safe to memoize; `ultra`/`aggressive`/`llmlingua` are // model-backed/non-deterministic. Any NEW engine is excluded until explicitly vetted. +// "omniglyph" is intentionally excluded too (P2 registry-consistency pass): it renders +// context as an image via a model-backed pipeline, so it is not yet proven deterministic +// across requests — conservative default (never-wrong) until explicitly vetted. const DETERMINISTIC_ENGINES = new Set(["lite", "caveman", "rtk"]); -/** Top-level modes safe to cache (whitelist — any unknown/new mode defaults to false). */ +/** Top-level modes safe to cache (whitelist — any unknown/new mode defaults to false). + * "omniglyph" intentionally omitted — see comment on DETERMINISTIC_ENGINES above. */ const DETERMINISTIC_MODES = new Set(["lite", "standard", "rtk"]); export function isDeterministicMode(mode: CompressionMode, config?: CompressionConfig): boolean { diff --git a/open-sse/services/compression/stats.ts b/open-sse/services/compression/stats.ts index 8d86f982c7..f2298c7f8a 100644 --- a/open-sse/services/compression/stats.ts +++ b/open-sse/services/compression/stats.ts @@ -7,13 +7,134 @@ import { DEFAULT_RTK_CONFIG, DEFAULT_COMPRESSION_LANGUAGE_CONFIG, } from "./types.ts"; +import { anthropicImageTokens, ANTHROPIC_IMAGE_BLOCK_OVERHEAD_TOKENS } from "omniglyph"; const CHARS_PER_TOKEN = 4; +/** + * Anthropic image block shape this estimator recognizes: + * `{ type: "image", source: { type: "base64", media_type: "image/png", data: "" } }`. + * Only PNG is decoded (the only format omniglyph emits); anything else falls back to + * char-counting that block, same as before. + */ +interface AnthropicImageBlock { + type: "image"; + source: { type: "base64"; media_type: string; data: string }; +} + +function isAnthropicPngImageBlock(value: unknown): value is AnthropicImageBlock { + if (!value || typeof value !== "object") return false; + const block = value as Record; + if (block.type !== "image") return false; + const source = block.source as Record | undefined; + if (!source || typeof source !== "object") return false; + return ( + source.type === "base64" && + source.media_type === "image/png" && + typeof source.data === "string" + ); +} + +/** + * Decode PNG width/height from the IHDR chunk without decoding the whole image. + * PNG layout: 8-byte signature, then IHDR chunk `length(4) + "IHDR"(4) + width(4) + + * height(4) + ...`. Width/height live at bytes 16..19 / 20..23 (big-endian uint32), + * so we need through byte 23 (24 raw bytes). We slice the first 64 base64 chars + * → 48 raw bytes, a comfortable margin over the 24 required. + * Returns null (never throws) on malformed/non-PNG/truncated input. + */ +function decodePngDimensions(base64: string): { width: number; height: number } | null { + try { + const prefix = base64.slice(0, 64); + const bytes = Buffer.from(prefix, "base64"); + if (bytes.length < 24) return null; + // PNG signature check (bytes 0..7): 89 50 4E 47 0D 0A 1A 0A + const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + for (let i = 0; i < PNG_SIGNATURE.length; i++) { + if (bytes[i] !== PNG_SIGNATURE[i]) return null; + } + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; + } + return { width, height }; + } catch { + return null; + } +} + +/** Char-count fallback for one value (same accounting as the legacy estimator). */ +function charTokensOf(value: unknown): number { + if (value === null || value === undefined) return 0; + const str = typeof value === "string" ? value : JSON.stringify(value); + return Math.ceil(str.length / CHARS_PER_TOKEN); +} + +/** + * Walk `messages[].content[]` (and `system` when it is an array) looking for Anthropic + * base64 PNG image blocks. For each recognized block: blank its `data` (shallow clone, + * so the char-count pass below doesn't double-count the base64) and add its real + * image-token cost (`anthropicImageTokens` + per-block overhead). Malformed/undecodable + * blocks are left as-is and fall back to char-counting like any other value — never throw. + * Tier is fixed to "standard": production resolves every tier to standard today (measured + * in the omniglyph billing sweep — see anthropic-vision.ts), so there is no model-specific + * signal available here that would change the result. + */ +function blankImageBlocksAndSumImageTokens(body: Record): { + clone: Record; + imageTokens: number; +} { + let imageTokens = 0; + const clone: Record = { ...body }; + + const processContentArray = (content: unknown): unknown => { + if (!Array.isArray(content)) return content; + return content.map((block) => { + if (!isAnthropicPngImageBlock(block)) return block; + const dims = decodePngDimensions(block.source.data); + if (!dims) return block; // fall back to char-counting this block as-is + imageTokens += anthropicImageTokens(dims.width, dims.height, "standard"); + imageTokens += ANTHROPIC_IMAGE_BLOCK_OVERHEAD_TOKENS; + return { ...block, source: { ...block.source, data: "" } }; + }); + }; + + if (Array.isArray(clone.messages)) { + clone.messages = clone.messages.map((message) => { + if (!message || typeof message !== "object") return message; + const m = message as Record; + if (!Array.isArray(m.content)) return message; + return { ...m, content: processContentArray(m.content) }; + }); + } + + if (Array.isArray(clone.system)) { + clone.system = processContentArray(clone.system); + } + + return { clone, imageTokens }; +} + export function estimateCompressionTokens(text: string | object | null | undefined): number { if (!text) return 0; - const str = typeof text === "string" ? text : JSON.stringify(text); - return Math.ceil(str.length / CHARS_PER_TOKEN); + if (typeof text === "string") { + return Math.ceil(text.length / CHARS_PER_TOKEN); + } + try { + const { clone, imageTokens } = blankImageBlocksAndSumImageTokens( + text as Record + ); + if (imageTokens === 0) { + // No recognized image blocks — byte-identical to the legacy behavior. + return Math.ceil(JSON.stringify(text).length / CHARS_PER_TOKEN); + } + return Math.ceil(JSON.stringify(clone).length / CHARS_PER_TOKEN) + imageTokens; + } catch { + // Non-serializable/unexpected shape → fall back to the legacy char-count, + // never throw out of an estimator. + return charTokensOf(text); + } } export function createCompressionStats( diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index f4208b6d2a..f0eafd8fcf 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -30,6 +30,7 @@ import { } from "./stackedStepCore.ts"; import { registerBuiltinCompressionEngines } from "./engines/index.ts"; import { getCompressionEngine, getEngineEntry } from "./engines/registry.ts"; +import { applyOmniglyphSingleMode } from "./engines/omniglyphSingleMode.ts"; import { applyRtkCompression } from "./engines/rtk/index.ts"; import { adaptBodyForCompression } from "./bodyAdapter.ts"; import { @@ -321,6 +322,10 @@ function runCompression( config: { ...(options?.config?.rtkConfig ?? {}), enabled: true }, }); } + if (mode === "omniglyph") { + // omniglyph is async-only — use applyCompressionAsync. Safe no-op here. + return { body, compressed: false, stats: null }; + } const adapter = adaptBodyForCompression(body); const compressionBody = adapter.body; if (mode === "lite") { @@ -440,6 +445,8 @@ export async function applyCompressionAsync( options?: { model?: string; supportsVision?: boolean | null; + /** Direct-to-provider vs. aggregator transport (gates transport-sensitive engines like omniglyph). */ + providerTransport?: "direct" | "aggregator"; config?: CompressionConfig; principalId?: string; onEngineStep?: (step: StackedCompressionStep) => void; @@ -457,6 +464,8 @@ async function runCompressionAsync( options?: { model?: string; supportsVision?: boolean | null; + /** Direct-to-provider vs. aggregator transport (gates transport-sensitive engines like omniglyph). */ + providerTransport?: "direct" | "aggregator"; config?: CompressionConfig; principalId?: string; onEngineStep?: (step: StackedCompressionStep) => void; @@ -489,6 +498,8 @@ async function runCompressionAsync( memoStore(key, result); return memoLookup(key)!; } + // Single-mode omniglyph (async-only) — resolution lives in engines/omniglyphSingleMode.ts. + if (mode === "omniglyph") return applyOmniglyphSingleMode(body, options); if (mode === "stacked") { const adapter = adaptBodyForCompression(body); const result = await applyStackedCompressionAsync( @@ -631,6 +642,8 @@ export interface StackedCompressionStep { interface StackOptions { model?: string; supportsVision?: boolean | null; + /** Direct-to-provider vs. aggregator transport (gates transport-sensitive engines like omniglyph). */ + providerTransport?: "direct" | "aggregator"; config?: CompressionConfig; compressionComboId?: string | null; /** TV1 bail-out discipline (opt-in, default disabled). */ diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index 5df5f266c4..fa973fd44e 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -25,7 +25,7 @@ import type { QuantumLockConfig, QuantumLockStats } from "./quantumLock/quantumP export { ENGINE_IDS }; export type CompressionMode = - "off" | "lite" | "standard" | "aggressive" | "ultra" | "rtk" | "stacked"; + "off" | "lite" | "standard" | "aggressive" | "ultra" | "rtk" | "omniglyph" | "stacked"; export type CavemanIntensity = "lite" | "full" | "ultra"; export type RtkIntensity = "minimal" | "standard" | "aggressive"; export type RtkRawOutputRetention = "never" | "failures" | "always"; @@ -38,7 +38,8 @@ export type CompressionEngineId = | "session-dedup" | "headroom" | "ccr" - | "llmlingua"; + | "llmlingua" + | "omniglyph"; export interface CavemanRule { name: string; diff --git a/open-sse/services/fusion.ts b/open-sse/services/fusion.ts index 15772a28a2..55ec0ce7d8 100644 --- a/open-sse/services/fusion.ts +++ b/open-sse/services/fusion.ts @@ -250,7 +250,8 @@ export async function handleFusionChat({ // Honor user-supplied minPanel down to 1: with 1 survivor we still degrade // gracefully via the answers.length===1 branch below (issue #6454). const minPanel = Math.min(Math.max(1, cfg.minPanel), panel.length); - const judge = judgeModel && judgeModel.trim() ? judgeModel.trim() : panel[0]; + const hasExplicitJudge = Boolean(judgeModel && judgeModel.trim()); + const judge = hasExplicitJudge ? (judgeModel as string).trim() : panel[0]; log.info( "FUSION", `Combo "${comboName ?? ""}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}` @@ -335,11 +336,24 @@ export async function handleFusionChat({ ); } if (answers.length === 1) { + // No explicit judgeModel configured: the "judge" is just panel[0], so + // synthesizing from a single source through itself would be redundant — + // answer directly with the lone survivor (issue #6454). + if (!hasExplicitJudge) { + log.info( + "FUSION", + `Only ${answers[0].model} succeeded — answering directly (no fusion)` + ); + return handleSingleModel(body, answers[0].model); + } + // An explicit judgeModel IS configured: honor it even with a single + // surviving panel answer, rather than silently substituting the panel + // member for the configured judge (issue #6455). The judge still adds + // value reviewing/polishing a lone source per its documented contract. log.info( "FUSION", - `Only ${answers[0].model} succeeded — answering directly (no fusion)` + `Only ${answers[0].model} succeeded — judging single answer with ${judge}` ); - return handleSingleModel(body, answers[0].model); } // 4. Judge analyzes + writes one final answer (streams to client if requested). diff --git a/open-sse/translator/paramSupport.ts b/open-sse/translator/paramSupport.ts index 30a32e2b5c..3b42fc4f2e 100644 --- a/open-sse/translator/paramSupport.ts +++ b/open-sse/translator/paramSupport.ts @@ -11,6 +11,8 @@ // introduces new keys and never throws on null/undefined bodies — call sites // can chain it without extra guards. +import { getParamFilterConfig, ModelParamFilter, ProviderParamFilter } from "@/lib/db/paramFilters"; + type StripRule = { provider?: string; match: RegExp | ((model: string) => boolean); @@ -25,8 +27,7 @@ const STRIP_RULES: StripRule[] = [ // GitHub Copilot Claude (except opus/sonnet 4.6): thinking + reasoning_effort rejected. #713 { provider: "github", - match: (m: string) => - /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), + match: (m: string) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"], }, // NVIDIA NIM z-ai/glm-5.2: OpenAI-compatible wrapper rejects BOTH the `reasoning` @@ -56,6 +57,11 @@ export function stripUnsupportedParams( ): T { if (!model || !body || typeof body !== "object") return body; const rec = body as unknown as Record; + // Snapshot the original body before any mutations so the allowlist can + // restore params that were stripped by hardcoded or config-driven denylist. + const snapshot = { ...rec }; + + // Phase 1: Hardcoded rules (unchanged) for (const rule of STRIP_RULES) { if (rule.provider && rule.provider !== provider) continue; if (!matches(rule, model)) continue; @@ -63,8 +69,94 @@ export function stripUnsupportedParams( if (rec[key] !== undefined) delete rec[key]; } } + + // Phase 2: Config-driven rules from DB + applyConfigFilters(provider, model, rec, snapshot); + return body; } +/** + * Restore keys from `snapshot` into `body` for every key listed in `allow`, + * but only when the key was present in the original request. Shared by the + * provider-level and model-level allowlist passes below. + */ +function restoreAllowedKeys( + body: Record, + snapshot: Record, + allow: readonly string[] +): void { + for (const key of allow) { + if (key in snapshot) { + body[key] = snapshot[key]; + } + } +} + +/** + * Apply the provider-level denylist, then restore the provider-level + * allowlist from `snapshot`. Runs BEFORE model-level operations so + * model-level settings can override provider-level ones. + */ +function applyProviderLevelFilters( + body: Record, + snapshot: Record, + config: ProviderParamFilter +): void { + for (const key of config.block) { + delete body[key]; + } + if (config.allow.length > 0) { + restoreAllowedKeys(body, snapshot, config.allow); + } +} + +/** + * Apply the model-level denylist (overrides the provider-level allowlist), + * then restore the model-level allowlist from `snapshot` (final pass, most + * specific wins). + */ +function applyModelLevelFilters( + body: Record, + snapshot: Record, + modelCfg: ModelParamFilter | undefined +): void { + if (modelCfg?.block) { + for (const key of modelCfg.block) { + delete body[key]; + } + } + if (modelCfg?.allow) { + restoreAllowedKeys(body, snapshot, modelCfg.allow); + } +} + +/** + * Apply config-driven denylist + allowlist rules from the DB-backed + * ProviderParamFilter store. Order of operations: + * 1. Provider-level denylist + * 2. Model-level denylist + * 3. Provider-level allowlist (restores from snapshot) + * 4. Model-level allowlist (restores from snapshot) + * + * The allowlist only restores keys that were present in the original request + * (the snapshot). It never introduces new params the client didn't send. + */ +export function applyConfigFilters( + provider: string | null | undefined, + model: string | null | undefined, + body: Record, + snapshot: Record +): void { + if (!provider || !body) return; + const config = getParamFilterConfig(provider); + if (!config) return; + + applyProviderLevelFilters(body, snapshot, config); + + const modelCfg = config.models?.[model ?? ""]; + applyModelLevelFilters(body, snapshot, modelCfg); +} + // Exported for unit tests only — do not import from production code. export const __STRIP_RULES_FOR_TEST: ReadonlyArray = STRIP_RULES; diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 24354451f7..88efa6c67d 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -120,6 +120,88 @@ export function buildErrorBody( return body; } +/** + * Sanitized auto-combo diagnostic trace surfaced on a combo terminal failure. + * Contains ONLY provider/model ids, enumerated reason codes, and counts — never + * keys, tokens, cookies, credentials, or upstream bodies. Fields are length- and + * count-capped so the projection is safe to place in HTTP headers too. (QA P0: + * "Add a sanitized combo diagnostic trace … candidate pool count, excluded + * provider/model reasons, selected attempt order, terminal failure summary.") + */ +export interface ComboExclusion { + provider: string; + model?: string; + reason: string; +} +export interface ComboDiagnostics { + poolSize: number; + attempted: number; + excluded: ComboExclusion[]; + attemptOrder: Array<{ provider: string; model: string }>; + terminalReason: string; +} + +function clampDiagStr(v: unknown, max = 128): string { + return typeof v === "string" ? v.slice(0, max).replace(/[\r\n]+/g, " ") : ""; +} + +/** + * Whitelist projection — guarantees only id/reason string primitives + integer + * counts can escape, regardless of what the caller assembled. This is the secret + * containment boundary for the diagnostic trace. + */ +export function sanitizeComboDiagnostics(d: ComboDiagnostics): ComboDiagnostics { + return { + poolSize: Number.isFinite(d?.poolSize) ? d.poolSize : 0, + attempted: Number.isFinite(d?.attempted) ? d.attempted : 0, + excluded: (d?.excluded ?? []).slice(0, 64).map((e) => ({ + provider: clampDiagStr(e?.provider, 64), + ...(e?.model ? { model: clampDiagStr(e.model, 96) } : {}), + reason: clampDiagStr(e?.reason, 64), + })), + attemptOrder: (d?.attemptOrder ?? []) + .slice(0, 64) + .map((a) => ({ provider: clampDiagStr(a?.provider, 64), model: clampDiagStr(a?.model, 96) })), + terminalReason: clampDiagStr(d?.terminalReason, 200), + }; +} + +/** + * errorResponse variant that attaches a sanitized combo diagnostic trace as BOTH + * `x-omniroute-combo-*` headers and a `diagnostics` field in the OpenAI-shaped + * error body (extra field — backward-compatible with standard error parsers). + * `opts.code`/`opts.type` override the status-derived defaults (e.g. to preserve + * the `ALL_ACCOUNTS_INACTIVE` code on the 503 terminal path). + */ +export function errorResponseWithComboDiagnostics( + statusCode: number, + message: string, + diagnostics: ComboDiagnostics, + opts: { code?: string; type?: string } = {} +): Response { + const safe = sanitizeComboDiagnostics(diagnostics); + const body = buildErrorBody(statusCode, message) as ErrorResponseBody & { + diagnostics?: ComboDiagnostics; + }; + if (opts.code) body.error.code = opts.code; + if (opts.type) body.error.type = opts.type; + body.diagnostics = safe; + const excludedHeader = safe.excluded + .map((e) => `${e.provider}${e.model ? `/${e.model}` : ""}:${e.reason}`) + .join(",") + .slice(0, 900); + return new Response(JSON.stringify(body), { + status: statusCode, + headers: { + "Content-Type": "application/json", + "x-omniroute-combo-pool-size": String(safe.poolSize), + "x-omniroute-combo-attempted": String(safe.attempted), + "x-omniroute-combo-excluded": excludedHeader, + "x-omniroute-combo-terminal-reason": safe.terminalReason.slice(0, 200), + }, + }); +} + /** * Create error Response object (for non-streaming) * @param {number} statusCode - HTTP status code diff --git a/open-sse/utils/toolCallArguments.ts b/open-sse/utils/toolCallArguments.ts index b5554c681a..9d7c797994 100644 --- a/open-sse/utils/toolCallArguments.ts +++ b/open-sse/utils/toolCallArguments.ts @@ -15,10 +15,32 @@ * A fuzzy suffix/prefix-overlap heuristic must NOT be used here: it silently * drops bytes from legitimate incremental deltas (turning `ll` into `l`, `xx` * into `x`), which trades a visible duplication bug for a silent truncation bug. + * + * A third, non-conformant shape some upstreams emit (#6459): the FULL + * `arguments` value delivered as an already-parsed JSON object/array instead + * of a JSON-encoded string (violates the OpenAI streaming contract, but seen + * from some Anthropic-shape-passthrough backends). Treating that as "not a + * string" and silently discarding it left `tool_use.input` empty upstream — + * or, when a caller re-serialized the buffer with plain string coercion + * instead of JSON, rendered literally as `[object Object]` in the client + * transcript. JSON.stringify it into a proper fragment instead of dropping it. */ +function normalizeIncomingFragment(incoming: unknown): string { + if (typeof incoming === "string") return incoming; + if (incoming == null) return ""; + if (typeof incoming === "object") { + try { + return JSON.stringify(incoming); + } catch { + return ""; + } + } + return ""; +} + export function appendToolCallArgumentDelta(current: unknown, incoming: unknown): string { const existing = typeof current === "string" ? current : ""; - const next = typeof incoming === "string" ? incoming : ""; + const next = normalizeIncomingFragment(incoming); if (!existing) return next; if (!next) return existing; diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index 8fc24f511a..8b67cc793a 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Token Usage Tracking - Extract, normalize, estimate and log token usage */ diff --git a/package-lock.json b/package-lock.json index 9b2840e903..ef4f311e89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "next-intl": "^4.12.0", "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", + "omniglyph": "^1.0.2", "open": "^11.0.0", "ora": "^9.4.1", "parse5": "^8.0.1", @@ -16262,6 +16263,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gpt-tokenizer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-3.4.0.tgz", + "integrity": "sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==", + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -22290,6 +22297,21 @@ ], "license": "MIT" }, + "node_modules/omniglyph": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/omniglyph/-/omniglyph-1.0.2.tgz", + "integrity": "sha512-GGLet99n3HVxOx3WuNPda4B0ETptX9SA8h1fnm/AYSXmvsKXE3mN11Ae2jfX8ldAOA/rVJRXHzPhNkXsRHzMsg==", + "license": "MIT", + "dependencies": { + "gpt-tokenizer": "^3.4.0" + }, + "bin": { + "omniglyph": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", diff --git a/package.json b/package.json index eb9e674a6b..c898b34204 100644 --- a/package.json +++ b/package.json @@ -261,6 +261,7 @@ "next-intl": "^4.12.0", "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", + "omniglyph": "^1.0.2", "open": "^11.0.0", "ora": "^9.4.1", "parse5": "^8.0.1", diff --git a/scripts/check/check-test-masking.mjs b/scripts/check/check-test-masking.mjs index 1a2dddf95f..3b5bf0e350 100644 --- a/scripts/check/check-test-masking.mjs +++ b/scripts/check/check-test-masking.mjs @@ -59,6 +59,29 @@ export function countExtendedTautologies(src) { return count; } +/** + * (#6404) Narrower sibling of countExtendedTautologies(), deliberately EXCLUDING + * `assert.ok(true)`: that pattern is intentionally left to the lenient, diff-only, + * new-occurrences-only subcheck 3 above, because ~15 pre-existing, verified-legitimate + * uses already exist repo-wide (documented fallbacks like "expected to throw" / + * "DB not available, expected" in try/catch branches) — an absolute, always-on scan + * against all of them would be a mass false-positive, not a real signal. + * + * `expect(true).toBe(true)` / `assert.equal(1, 1)` / `assert.strictEqual(1, 1)` have + * no such legitimate use anywhere in this codebase (verified zero pre-existing hits + * after fixing #6404's playground-api-tab.test.tsx) — a genuinely bare, no-argument + * tautology is never a deliberate pattern here, so it is safe to fail on ANY hit, + * with or without a PR diff to compare against. See scanBareTautologies() below. + */ +export function countBareTautologies(src) { + let count = 0; + // expect(true).toBe(true) + count += (src.match(/\bexpect\s*\(\s*true\s*\)\s*\.\s*toBe\s*\(\s*true\s*\)/g) || []).length; + // assert.equal(1, 1) / assert.strictEqual(1, 1) — literal numeric identity + count += (src.match(/\bassert\s*\.\s*(?:strict)?[Ee]qual\s*\(\s*1\s*,\s*1\s*\)/g) || []).length; + return count; +} + // ─── (6348) Subcheck 4: inline-reimplemented prod conditions (REPORT-ONLY) ─── // A test that copies a conditional expression out of production code (instead of // importing and exercising the symbol that owns it) is the wrong-shape-contract-test @@ -325,6 +348,50 @@ export function evaluateMasking(perFile, assertReductionAllowlist = new Set()) { return flags; } +/** + * (#6404) Absolute floor scan for bare tautologies (`expect(true).toBe(true)`, + * `assert.equal(1, 1)` / `assert.strictEqual(1, 1)`), independent of PR diffing. + * + * The subcheck-3 diff logic above (`evaluateMasking`'s `headExtTaut > baseExtTaut`) + * only fires for a tautology INTRODUCED within the current PR's own diff, and + * `resolveBase()` returns `null` outside CI (no `GITHUB_BASE_SHA`/`GITHUB_BASE_REF`), + * so a local `npm run check:test-masking` run silently no-ops — "sem base ref — + * pulando" — regardless of what the tests actually contain. That is exactly how + * #6404's `expect(true).toBe(true)` in `playground-api-tab.test.tsx` slipped through + * for a full release cycle after merging once (the diff-only gate has nothing to + * compare a pre-existing, already-merged tautology against, and local runs never + * scan repo content at all). This scans every tracked test file's current content, + * in or out of PR context, so a stray tautology can never hide once merged. + * + * Uses `countBareTautologies()` (not `countExtendedTautologies()`) — deliberately + * excludes `assert.ok(true)`, which has ~15 verified-legitimate pre-existing uses + * repo-wide and stays governed by the lenient, new-occurrence-only diff subcheck. + * + * `check-test-masking.test.ts` is excluded — its fixtures legitimately embed the + * literal pattern as string literals to exercise the count* helpers themselves. + */ +export function scanBareTautologies(testFiles, readFile) { + const read = readFile || ((f) => fs.readFileSync(f, "utf8")); + const flags = []; + for (const file of testFiles || []) { + if (file.endsWith("check-test-masking.test.ts")) continue; + let src; + try { + src = read(file); + } catch { + continue; + } + const count = countBareTautologies(src); + if (count > 0) { + flags.push( + `${file}: ${count} tautologia(s) pura(s) (expect(true).toBe(true) / assert.equal(1,1)) — ` + + "substitua por um assert real do comportamento observável" + ); + } + } + return flags; +} + function git(args) { try { return execFileSync("git", args, { encoding: "utf8" }); @@ -333,6 +400,15 @@ function git(args) { } } +/** All git-tracked test files (`.test.ts(x)`/`.spec.ts(x)`), repo-wide — used by the + * absolute floor scan so it also covers files untouched by the current diff/PR. */ +function listTrackedTestFiles() { + return git(["ls-files"]) + .split("\n") + .map((s) => s.trim()) + .filter((f) => TEST_RE.test(f)); +} + function resolveBase() { if (process.env.GITHUB_BASE_SHA) return process.env.GITHUB_BASE_SHA; if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`; @@ -340,9 +416,34 @@ function resolveBase() { } function main() { + // (#6404) Absolute floor scan — runs unconditionally, PR or not, so a tautology + // that is already merged into the base (and thus invisible to the diff-only + // subchecks below) or a local pre-push run (which has no PR base to diff + // against) still gets caught. See scanBareTautologies() doc comment. + let bareTautAllowlist = new Set(); + try { + const raw = JSON.parse(fs.readFileSync("config/quality/test-masking-allowlist.json", "utf8")); + bareTautAllowlist = new Set(raw._bareTautologyAllowlist || []); + } catch { + // no allowlist file — treat as empty + } + const trackedTestFiles = listTrackedTestFiles().filter((f) => !bareTautAllowlist.has(f)); + const absoluteTautFlags = scanBareTautologies(trackedTestFiles); + if (absoluteTautFlags.length) { + console.error( + `[test-masking] ${absoluteTautFlags.length} tautologia(s) pura(s) encontradas ` + + `(scan absoluto — roda com ou sem contexto de PR):\n` + + absoluteTautFlags.map((f) => " ✗ " + f).join("\n") + + `\n → substitua por um assert real do comportamento observável.` + ); + process.exit(1); + } + const base = resolveBase(); if (!base) { - console.log("[test-masking] sem base ref (não é PR) — pulando."); + console.log( + "[test-masking] sem base ref (não é PR) — pulando checks de diff (scan absoluto de tautologias OK)." + ); return; } diff --git a/scripts/vps/release-runner-down.sh b/scripts/vps/release-runner-down.sh index dd27087cad..f65e0ace8e 100755 --- a/scripts/vps/release-runner-down.sh +++ b/scripts/vps/release-runner-down.sh @@ -10,6 +10,17 @@ VM_ID="${VM_ID:-113}" REPO="${REPO:-diegosouzapw/OmniRoute}" SSH="ssh -o BatchMode=yes -o ConnectTimeout=8" +# 0) Always-on mode. When the repo var VPS_ALWAYS_ON=true, the VM 113 is a DEDICATED, +# 24/7 CI host (32c/24GB, exclusive to this project) — the day-to-day quality.yml PRs +# (PR→release/**) route to it too, not just release CI. In that mode the release MUST NOT +# tear the VM down or flip USE_VPS_RUNNER off, or every subsequent PR would fall back to +# ubuntu-latest until the next release. Teardown/flag-off is the LEGACY on-demand-per-release +# model only. (Set/unset with: gh variable set VPS_ALWAYS_ON --body true|false.) +if [ "$(gh variable get VPS_ALWAYS_ON --repo "$REPO" 2>/dev/null)" = "true" ]; then + echo "[release-runner] VPS_ALWAYS_ON=true — dedicated 24/7 host; leaving VM $VM_ID up and USE_VPS_RUNNER=true." + exit 0 +fi + # 1) Volta o CI para ubuntu-latest ANTES de derrubar a VM (evita jobs presos). echo "[release-runner] USE_VPS_RUNNER=false (CI volta ao GitHub-hosted)." gh variable set USE_VPS_RUNNER --repo "$REPO" --body "false" >/dev/null 2>&1 || true diff --git a/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx b/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx new file mode 100644 index 0000000000..cd7613ea66 --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/omniglyph/OmniglyphContextPageClient.tsx @@ -0,0 +1,264 @@ +"use client"; + +// OmniglyphContextPageClient — the dedicated detail screen for the "omniglyph" +// context-as-image compression engine. Four sections: economics (measured savings), +// a real before→after (dense text vs the rendered PNG page), the fail-closed gate +// flow, and the enable control (wired to /api/settings/compression, preview engine). +// +// Engine-facing copy is hardcoded English (matches the catalog convention — engine +// text stays deterministic, not i18n). The sample in ./sampleData.ts is a REAL render +// from the omniglyph package, not a mockup. +// +// Card/Toggle are imported from their direct module paths (not the @/shared/components +// barrel) — the barrel pulls a Node-only module that hangs vitest/jsdom. +import { useEffect, useState } from "react"; +import Card from "@/shared/components/Card"; +import Toggle from "@/shared/components/Toggle"; +import { SAMPLE_BEFORE_TEXT, SAMPLE_PAGE_PNG_DATA_URI, SAMPLE_METRICS } from "./sampleData"; + +interface CompressionConfigLite { + engines?: Record; +} + +type EngineMap = Record; + +/** The measured fail-closed gate chain, in evaluation order. Every no-op is telemetered + * as `skip:`; the engine only fires when all pass. */ +const GATES: ReadonlyArray<{ label: string; pass: string; why: string }> = [ + { + label: "Model", + pass: "claude-fable-5", + why: "Only Fable 5 reads dense pages at 100% (measured, n=30). GPT-5.5 and Gemini 2.5-flash are blocked.", + }, + { + label: "Transport", + pass: "direct Anthropic", + why: "Aggregators resample images and destroy legibility — only the direct route is authoritative.", + }, + { + label: "Format", + pass: "native Claude", + why: "The body must be Claude-format (never a system role inside messages).", + }, + { + label: "Profitable", + pass: "dense enough", + why: "The exact 28px-patch cost gate decides per request; small or sparse text passes through untouched.", + }, +]; + +const ECONOMICS: ReadonlyArray<{ value: string; label: string }> = [ + { value: "~10×", label: "fewer tokens on the converted block" }, + { value: "59–70%", label: "end-to-end savings (measured)" }, + { value: "1456", label: "image tokens for a 1568×728 page (~28k chars)" }, + { value: "100%", label: "reading accuracy on Fable 5 (n=30)" }, +]; + +// Section components are split out of the page component so each function stays +// under the complexity gate's 80-line cap; the page composes them. + +function PageHeader() { + return ( +
+
+

OmniGlyph

+ + Preview + +
+

+ Context-as-image compression. Renders the system prompt, tool docs and dense history as + compact PNG pages that Claude Fable 5 reads instead of the text — image tokens are billed by + dimensions, not characters, so the converted block costs ~10× less. Direct Anthropic route + only. +

+
+ ); +} + +function EconomicsCard() { + return ( + +

The economics

+
+ {ECONOMICS.map((e) => ( +
+ {e.value} + {e.label} +
+ ))} +
+
+ ); +} + +function BeforeAfterCard() { + return ( + +
+

Before → after

+ + −{SAMPLE_METRICS.savingsPct}% tokens on this block + +
+

+ A real render of {SAMPLE_METRICS.beforeChars} characters of dense tool docs — not a mockup. +

+
+
+ + Text · ≈ {SAMPLE_METRICS.textTokens} tokens + +
+            {SAMPLE_BEFORE_TEXT}
+          
+
+
+ + Rendered page · ≈ {SAMPLE_METRICS.imageTokens} tokens + +
+ {/* eslint-disable-next-line @next/next/no-img-element -- data URI, no loader needed */} + {`Rendered +
+
+
+
+ ); +} + +function GatesCard() { + return ( + +

When it fires

+

+ Fail-closed: every gate must pass, or the request passes through untouched (each skip is + telemetered as skip:<reason>). +

+
    + {GATES.map((g, i) => ( +
  1. + + {i + 1} + +
    + + {g.label} — {g.pass} + + {g.why} +
    +
  2. + ))} +
+
+ ); +} + +function EnableCard(props: { + enabled: boolean; + disabled: boolean; + status: "" | "saved" | "error"; + onToggle: (next: boolean) => void; +}) { + return ( + +
+
+

Enable the engine

+

+ Runs last in the stack (after RTK/Caveman clean the text, OmniGlyph images the residual) + and also standalone via the omniglyph mode. Preview — off by default until + the end-to-end validation lands. +

+ + {props.status === "saved" + ? "Saved." + : props.status === "error" + ? "Could not save." + : ""} + +
+ + + +
+
+ ); +} + +export default function OmniglyphContextPageClient() { + const [engines, setEngines] = useState({}); + const [enabled, setEnabled] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState<"" | "saved" | "error">(""); + + useEffect(() => { + fetch("/api/settings/compression") + .then((r) => (r.ok ? r.json() : null)) + .then((data: CompressionConfigLite | null) => { + const e = data?.engines ?? {}; + setEngines(e); + setEnabled(e.omniglyph?.enabled === true); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + // Persist the FULL engines map (the store keeps it as one JSON row — a partial patch + // of a single engine would drop the others). Mirrors CompressionPanel.setEngine. + const toggle = async (next: boolean) => { + setEnabled(next); + const nextEngines: EngineMap = { + ...engines, + omniglyph: { ...(engines.omniglyph ?? { enabled: false }), enabled: next }, + }; + setEngines(nextEngines); + setSaving(true); + setStatus(""); + try { + const res = await fetch("/api/settings/compression", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ engines: nextEngines }), + }); + if (res.ok) { + setStatus("saved"); + setTimeout(() => setStatus(""), 2000); + } else { + setStatus("error"); + } + } catch { + setStatus("error"); + } finally { + setSaving(false); + } + }; + + return ( +
+ + + + + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/context/omniglyph/page.tsx b/src/app/(dashboard)/dashboard/context/omniglyph/page.tsx new file mode 100644 index 0000000000..9f5dfc6e89 --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/omniglyph/page.tsx @@ -0,0 +1,5 @@ +import OmniglyphContextPageClient from "./OmniglyphContextPageClient"; + +export default function OmniglyphContextPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/context/omniglyph/sampleData.ts b/src/app/(dashboard)/dashboard/context/omniglyph/sampleData.ts new file mode 100644 index 0000000000..60d8d58c82 --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/omniglyph/sampleData.ts @@ -0,0 +1,31 @@ +// Amostra REAL para o painel "antes → depois": um bloco denso de tool-defs/config +// (1015 chars) renderizado pelo pacote omniglyph em uma página PNG 438×120. +// Números medidos: 254 tokens de texto → 84 tokens de imagem (billing 28px + 4/bloco) +// = 66,9% de economia, dentro da faixa medida de 59–70% ponta a ponta. +// Gerado offline via renderTextToImages — não é mockup, é a saída real da engine. + +export const SAMPLE_BEFORE_TEXT = `## Tool: search_records(query, filters?, limit=50) +Searches the ledger. filters: {status:"ok"|"pending"|"failed", since:ISO8601, region}. +Returns [{id, status, attempt, region, ts, checksum}]. Rate: 20 rps. Auth: bearer. +## Tool: apply_patch(recordId, patch, dryRun=false) +Mutates one record. patch is RFC-6902 JSON Patch. dryRun returns the diff only. +Idempotency-Key header required. 409 on version conflict; retry with fresh read. +## Config +retry: {base_ms:300, factor:2, max:5}. circuit_breaker:{threshold:5, reset_s:30}. +regions: [us-east, us-west, eu-central, sa-east]. default_region: sa-east. +REGISTRO-A: {"id":"a3f8c2d19b4e","status":"ok","attempt":1,"region":"sa-east"} +REGISTRO-B: {"id":"7b1e4409ccde","status":"pending","attempt":3,"region":"eu-central"} +REGISTRO-C: {"id":"2d909e99f1b3","status":"failed","attempt":5,"region":"us-west"} +Notes: never expose checksum in logs; redact bearer; prefer read-modify-write with +Idempotency-Key; on 429 honor Retry-After; treat 408/500/502/503/504 as retryable.`; + +export const SAMPLE_PAGE_PNG_DATA_URI = "data:image/png;base64,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"; + +export const SAMPLE_METRICS = { + beforeChars: 1015, + pageWidth: 438, + pageHeight: 120, + textTokens: 254, + imageTokens: 84, + savingsPct: 66.9, +} as const; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index e460af15d7..5a618fb134 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -39,7 +39,7 @@ import { useModelVisibilityHandlers } from "./hooks/useModelVisibilityHandlers"; import { useModelCompatState } from "./hooks/useModelCompatState"; import { useConnectionGate } from "./hooks/useConnectionGate"; import { useProviderNodeActions } from "./hooks/useProviderNodeActions"; -import ProviderPlaygroundPanel from "./components/ProviderPlaygroundPanel"; +import ProviderExtraPanels from "./components/ProviderExtraPanels"; import ProviderModelsSection from "./components/ProviderModelsSection"; import CustomModelsSection from "./components/CustomModelsSection"; import ConnectionsListPanel from "./components/ConnectionsListPanel"; @@ -696,8 +696,8 @@ export default function ProviderDetailPageClient() { {/* Search provider info */} {isSearchProvider && } - {/* Playground panel — rendered for providers that declare serviceKinds */} - + {/* Playground + param filters — extracted to components/ProviderExtraPanels.tsx (#6649) */} + {/* Modals — Phase 1t.5: extracted to components/ProviderModalsPanel.tsx */} , genId: () => string): H return entries.map(([name, value]) => ({ id: genId(), name, value })); } +function parseCommaList(text: string): string[] { + return text + ? text + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; +} + +interface ParamFilterConfigLike { + block?: string[]; + allow?: string[]; + models?: Record; + autoLearn?: boolean; +} + +// Builds the PUT body for the model-level block/allow save. Extracted so the +// caller's async handler stays simple — this is pure payload-shaping logic. +function buildModelParamFilterPayload( + current: ParamFilterConfigLike | null | undefined, + modelId: string, + blockText: string, + allowText: string +) { + const updatedModels: Record = { ...(current?.models ?? {}) }; + const block = parseCommaList(blockText); + const allow = parseCommaList(allowText); + if (block.length > 0 || allow.length > 0) { + updatedModels[modelId] = { block, allow }; + } else { + delete updatedModels[modelId]; + } + return { + block: current?.block ?? [], + allow: current?.allow ?? [], + models: Object.keys(updatedModels).length > 0 ? updatedModels : undefined, + autoLearn: current?.autoLearn ?? false, + }; +} + // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- export interface ModelCompatPopoverProps { t: (key: string) => string; + providerId: string; + modelId: string; effectiveModelNormalize: (protocol: string) => boolean; effectiveModelPreserveDeveloper: (protocol: string) => boolean; getUpstreamHeadersRecord: (protocol: string) => Record; @@ -65,6 +107,10 @@ export default function ModelCompatPopover({ const [open, setOpen] = useState(false); const [protocol, setProtocol] = useState(MODEL_COMPAT_PROTOCOL_KEYS[0]); const [headerRows, setHeaderRows] = useState([]); + const [blockText, setBlockText] = useState(""); + const [allowText, setAllowText] = useState(""); + const [paramDirty, setParamDirty] = useState(false); + const [paramSaving, setParamSaving] = useState(false); const [valuePeekRowId, setValuePeekRowId] = useState(null); const [valueFocusRowId, setValueFocusRowId] = useState(null); const ref = useRef(null); @@ -118,6 +164,44 @@ export default function ModelCompatPopover({ // eslint-disable-next-line react-hooks/exhaustive-deps -- see above }, [open, protocol]); + // Load model-level block/allow from param-filters API + useEffect(() => { + if (!open) return; + (async () => { + try { + const res = await fetch(`/api/providers/${providerId}/param-filters`); + const data = await res.json(); + const modelCfg = data?.models?.[modelId]; + setBlockText(modelCfg ? (modelCfg.block ?? []).join(", ") : ""); + setAllowText(modelCfg ? (modelCfg.allow ?? []).join(", ") : ""); + } catch { + setBlockText(""); + setAllowText(""); + } + setParamDirty(false); + })(); + }, [open]); + + const saveModelParamFilters = useCallback(async () => { + if (!paramDirty) return; + setParamSaving(true); + try { + const res = await fetch(`/api/providers/${providerId}/param-filters`); + const current = await res.json(); + const payload = buildModelParamFilterPayload(current, modelId, blockText, allowText); + await fetch(`/api/providers/${providerId}/param-filters`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + setParamDirty(false); + } catch { + // Silently ignore save error + } finally { + setParamSaving(false); + } + }, [paramDirty, blockText, allowText]); + useEffect(() => { setValuePeekRowId(null); setValueFocusRowId(null); @@ -266,6 +350,48 @@ export default function ModelCompatPopover({ )} + {/* Param filters — model-level block/allow (#6625) */} +
+ +
+ { + setBlockText(e.target.value); + setParamDirty(true); + }} + onBlur={() => saveModelParamFilters()} + placeholder="thinking, … (comma-separated)" + disabled={disabled} + className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900" + /> +

+ {t("compatBlockedParamsHint") ?? "Blocked params (stripped from requests)"} + {paramSaving && " ● saving…"} +

+
+
+ { + setAllowText(e.target.value); + setParamDirty(true); + }} + onBlur={() => saveModelParamFilters()} + placeholder="reasoning, … (comma-separated)" + disabled={disabled} + className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900" + /> +

+ {t("compatAllowedParamsHint") ?? "Allowed params (re-added after deny)"} +

+
+
+