Compare commits

...

55 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
a3c19dd27c fix(ci): accept CVE-2025-68121 in the prebuilt tls-client .so, auto-close base-red issues, guard Scorecard on the default branch (#12085)
Validated: actionlint clean on all three touched workflows, check-api-typecheck.mjs OK (289 pre-existing, all frozen) after boarding on top of #12094. Confirmed the .trivyignore justification against the documented CVE Variance process (docs/security/SUPPLY_CHAIN.md) — has tracking issue #12084, expiry before the v3.8.51 tag, and a real technical reason the .so can't be rebuilt in this repo. Scorecard branch guard correctly targets the actual default branch (release/vX.Y.Z), not a hardcoded main.
2026-08-30 04:51:48 -03:00
Diego Rodrigues de Sa e Souza
e620c50f3c fix(api): clear the six API-route TypeScript regressions the new gate landed red on (#12094)
Validated: check-api-typecheck.mjs OK (289 pre-existing, all frozen), typecheck:core clean, 8/8 check-api-typecheck.test.ts pass. Spot-checked two of the six fixes directly — the webhooks/[id]/test/route.ts duplicate import is confirmed removed (real ESM defect), and the volcengine-plan strict-boolean-narrowing fix (`validation.success === false` vs `!validation.success`) is behaviorally identical since `.success` is a strict boolean. This unblocks every other open PR into release/v3.8.51 that was landing red on the new API Route Typecheck gate — including #12085.
2026-08-30 04:48:54 -03:00
brick30llc-ctrl
2e3cd599b6 feat(routing): add LiquidAI LFM2.5-2.6B free tier via OpenRouter (#11752)
Resynced onto the release tip — the FREE_CATALOG_CURATED_AT bump conflicted with a later bump already on the tip; resolved to today's date since real content is landing. typecheck:core clean, 23/23 focused tests pass (free-model-catalog, free-models). Verified live against OpenRouter's own /api/v1/models pricing as claimed. Thanks for the new free-tier entry.
2026-08-30 04:31:31 -03:00
Markus Hartung
5684589ce7 feat(dashboard): collapsible JSON tree viewer for request/response payloads (#11703)
Resynced onto the release tip. Two fixes applied during boarding: (1) the branch forked before the recent optionalDependencies placement of @huggingface/transformers and onnxruntime-node — its own diff re-added both into "dependencies" as duplicates alongside the real new dependency (react18-json-view); removed the duplicates, ran npm install to sync the lockfile. (2) config/quality/dependency-allowlist.json referenced the wrong package name (react-json-view-lite, an earlier iteration per the PR body) — the code actually imports react18-json-view; fixed the allowlist entry to match. RequestLoggerDetail.tsx crossed its frozen file-size cap (1018->1111); rebaselined with a note — the PR does split out the new logic (RequestLoggerDetail.sections.tsx, JsonTreeExpandControls.tsx, useTimestampTitles.ts, jsonTreeExpandStore.ts, all well under cap), the growth here is irreducible wiring. typecheck:core, check:dashboard-typecheck, check:file-size, check-deps all green after resync; 8/8 vitest + 11/11 native tests pass. Nice, well-structured 6-commit feature with full i18n and good test coverage. Thanks!
2026-08-30 04:26:15 -03:00
Diego Rodrigues de Sa e Souza
ccee48d34a fix(db): drop three consumer-less 1proxy exports — dead-code base-red on release/v3.8.51 after the barrel deletion (#12055) (#12087)
* test(cli): align the nodes --base-url contract test with #12033

#11860 asserted that `nodes add/update/validate` must NOT register `--base-url`
(reserved for the global server target); #12033 (issue #11999) then registered
it on purpose so `omniroute nodes add --provider p --base-url <url>` stops being
rejected by Commander's global option. Both PRs landed and the older test turned
the base red on unit shard 2/4 (`Unit Tests fast-path (2/4)`, run 33293442568).

The test now asserts the current contract: both flags are registered and each
parses into its own option; the server-target/payload separation keeps its own
test right below.

* test(mutation): register lkgp-stale-pin-exhaustion-11911 in tap.testFiles

38e2baa879 (#11911) added a unit test covering src/shared/utils/circuitBreaker.ts
without listing it in stryker.conf.json tap.testFiles, so check:mutation-test-coverage
--strict (Fast Quality Gates) is red on the release tip.

* fix(db): drop three consumer-less 1proxy exports the deleted localDb barrel was masking

50bc8ab8aa (#12055) removed the @/lib/localDb barrel; its re-exports were the only
thing keeping getOneproxyStats / deleteOneproxyProxy / clearAllOneproxyProxies (and
the private mapStatsRow + OneproxyStats type) 'used' for knip. The 1proxy routes
are 308 compat redirects to /api/settings/free-proxies since v3.8.4, so nothing
calls them: check:dead-code went 413 -> 419 on the release tip (baseline 416).
Back to 416 with typecheck:core, eslint and check:db-rules green.

* chore(mutation): drop the duplicate tap.testFiles entry — #12082 already registered it
2026-08-30 04:23:09 -03:00
Sabee Ur Rehman Khan
1c37fff056 fix(memory): honor category filter in GET /api/memory (#11699)
Boarded with #11756 (a duplicate fix for the same underlying issue #11650). Compared both implementations directly: this one is technically superior — guards the json_extract() call with json_valid(metadata) so malformed/legacy metadata returns no match instead of throwing a 500, and covers genericBackend.ts/obsidianBackend.ts in addition to sqliteBackend.ts. #11756 only touched SQLite and had no malformed-JSON guard. Closing #11756 with credit. Resynced onto the updated release tip: the test file's `await import("../../src/lib/localDb.ts")` broke after #12055 deleted the barrel earlier this session (your branch forked before that migration) — fixed to import updateSettings directly from @/lib/db/settings, matching the pattern already used by other integration tests. typecheck:core, check:dashboard-typecheck, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles, check-deps all green; 4/4 integration + 35/35 vitest pass after resync. Thanks for the thorough, well-tested fix.
2026-08-30 04:12:08 -03:00
Sabee Ur Rehman Khan
c2c97aff82 ci: add API route TypeScript regression gate (#11705)
Boarded in a combined worktree: typecheck:core, check:dashboard-typecheck, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles, check-deps all green. Clean, self-contained addition (5 new files, 0 modifications to existing code) that mirrors the existing dashboard-typecheck baseline-ratchet pattern. Thanks for closing a real coverage gap — API routes had no dedicated typecheck gate.
2026-08-30 04:07:28 -03:00
santosraju99-hub
faebf6de5f fix(shared): block cloud-metadata hosts under default remote-image guard (#11755)
Boarded in a combined worktree: typecheck:core, check:dashboard-typecheck, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles, check-deps all green; 10/10 focused tests pass. Real SSRF gap confirmed — the default "block-metadata" guard mode fell through to the unchecked parseOutboundUrl() while 3 other call sites of the same guard mode already routed through parseAndValidateNonMetadataUrl(). Good catch that the existing test suite only ever exercised "public-only" explicitly. Retargeted from the stale release/v3.8.50 base to release/v3.8.51. Thanks for closing a real cloud-metadata SSRF exposure.
2026-08-30 04:07:18 -03:00
Nguyễn Viết Tuấn
55691e0416 fix(usage): allow quota refresh for FREE lease-reserved connections (#11758)
Boarded in a combined worktree with 6 other PRs: typecheck:core, check:dashboard-typecheck, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles, check-deps all green. Verified the root-cause diagnosis directly against the code: isConnectionUnavailableToAuxiliaryActivity() does return true for any connection reachable by an active exclusive lease regardless of whether the lease is actively serving a request, confirming the fix's scoping is correct. The change is surgically limited to providerLimits.ts's live-usage-fetch path — the shared isolation function and its other call sites (warmupScheduler, quotaAutoPing, modelTestRunner, etc.) are untouched. Well tested (214 lines across 3 test files). Thanks for tracking this down.
2026-08-30 04:07:06 -03:00
ANIRUDDHA ADAK
a1d6ff5fbf fix(api): preserve caller-provided X-Correlation-Id on chat completions (#11760)
Boarded with #11741 (a duplicate fix for the same underlying issue #11739). Compared both implementations directly: this one is technically superior — a dedicated resolveIncomingCorrelationId() helper that strips CRLF (header-injection prevention) and bounds length to 1-256 chars, with 4 unit tests covering those edge cases. #11741's simpler `header || generateRequestId()` has no sanitization. Closing #11741 with credit. Validated in a combined worktree: typecheck:core, check:dashboard-typecheck, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles, check-deps all green; 84/84 + 43/43 focused tests pass across this batch. Thanks for the careful sanitization work.
2026-08-30 04:06:52 -03:00
backryun
49827c1db1 fix(dev): bound webpack and Tailwind scans (#12075)
Boarded with #12082 in one combined worktree: typecheck:core, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles all green; 77/77 focused tests pass. Genuinely conservative as described — dev-only Tailwind/webpack scanning bounds, production chunking untouched. The later phases of #12074 (2/3/4/4b) are being held for a dedicated review given their combined architectural weight (DB init graph, credential refresh, process lifecycle, network dispatch boundary) — flagged separately on those PRs. Thanks for the clean Phase 1 baseline.
2026-08-30 03:31:14 -03:00
backryun
47ea113b99 fix(ci): reconcile release test contract drift (#12082)
Boarded with #12075 in one combined worktree: typecheck:core, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles all green; 77/77 focused tests pass. CI-contract-only reconciliation as described — no production behavior change, and the referenced files (lkgp-stale-pin-exhaustion-11911.test.ts, cli-nodes-commands.test.ts) confirmed already present and correctly aligned. Thanks for keeping this separate from the dev-bundler phase PRs.
2026-08-30 03:30:54 -03:00
Syed Raheemuddin
d26fe03801 feat(routing): add relayMode for schema-locked context handoffs (#11839)
Boarded with #12003/#11841/#11840 in one combined worktree: typecheck:core, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles all green; 24/24 focused tests pass. relayMode is opt-in and defaults to standard, so this is backward-compatible as claimed — verified the plumbing through resolveUniversalHandoffConfig/resolveContextRelayConfig/selectMessagesForSummary. Thanks for the clean, well-tested addition.
2026-08-30 02:52:10 -03:00
Syed Raheemuddin
92574de164 fix(chat): preserve unstripped model string for passthrough provider routing (#11840)
Boarded with #12003/#11841/#11839 in one combined worktree: typecheck:core, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles all green; 24/24 focused tests pass. Contained fix — preserves the unstripped model string for passthrough providers (cline/kilocode) only when the combo actually redirected to a passthrough provider. Thanks for the regression coverage.
2026-08-30 02:51:53 -03:00
Syed Raheemuddin
da678bd3ff feat(config): add support for runtime system prompt configuration and hot-reloading (#11841)
Boarded with #12003/#11840/#11839 in one combined worktree: typecheck:core, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles all green; 24/24 focused tests pass. Clean, well-contained addition mirroring the existing systemTransforms hot-reload pattern, tested for both set and cleared states. Thanks for the tidy runtime-config feature.
2026-08-30 02:51:34 -03:00
Syed Raheemuddin
2ec24e7c0b fix(core): resolve DB init race condition and reasoning translation (#12003)
Boarded with #11841/#11840/#11839 in one combined worktree: typecheck:core, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles all green; 24/24 focused tests pass. Both fixes are surgical and well-reasoned: explicit ensureDbInitialized() call for MCP stdio (verified the function exists at src/lib/db/core.ts:1496) avoids a startup race, and the reasoningContent fallback prevents empty message.content when only reasoning was returned. Thanks for tracking down both root causes.
2026-08-30 02:51:18 -03:00
Webman
50bc8ab8aa fix(barrel): delete the @/lib/localDb barrel — every consumer migrated (#11795 Phase 5) (#12055)
Resynced onto the release tip after #12051/#12052/#12053 landed. Same LKGP-clear conflict as #12053 (kept the current clearStaleLKGP() helper at both call sites). One additional issue this final phase's combined-worktree validation surfaced: clearStaleLKGP() itself (added by #12013, which none of the 4 phase PRs could have seen since it landed after they were authored) still had a dynamic `await import("@/lib/localDb")` — a real break once this PR deletes the barrel. Fixed to `await import("@/lib/db/settings")`, matching the direct-import pattern used at every other call site. typecheck:core, check-db-rules, check:cycles, and the eslint-import-boundaries regression test (3/3, including "G14 rejects localDb barrel imports") all green after resync — zero barrel-importing production files remain. Nice clean 5-phase migration, and thanks for taking on the full #11795 cleanup.
2026-08-30 02:36:26 -03:00
Webman
4e11887085 fix(barrel): migrate open-sse, src/shared, src/sse, src/models, src/domain off the @/lib/localDb barrel import (#11795 Phase 4) (#12053)
Resynced onto the release tip after #12051/#12052 landed. One real conflict in open-sse/services/combo.ts at both LKGP-clear call sites (handleComboChat + round-robin path): the release tip already has #12013's clearStaleLKGP() helper, which this PR's branch predates — kept the current helper call at both sites, discarding the pre-refactor inline pattern. typecheck:core and the open-sse test suite (vitest, 9/9 on volumeDetector) both green after resync. Thanks for the well-scoped Phase 4 migration.
2026-08-30 02:31:09 -03:00
Webman
38a29661d3 URGENT fix(build): route ChatGPT Web MCP bundle through runBuildTool (Windows/Node 24 build crash) v.50/.51 (#11706)
Confirmed the bug is real and unfixed on the current tip before merging: `scripts/build/prepublish.ts` line 332 was still calling `execFileSync(NPX_BIN, ...)` directly (raw win32 npx.cmd spawn), the exact CVE-2024-27980 shim pattern the file's own header warns about. Root cause, fix, and evidence match — routing through the existing `runBuildTool()` helper. Thanks for catching the one call site the earlier refactor missed.
2026-08-30 02:27:54 -03:00
Webman
2463781e00 fix(barrel): migrate src/lib/ off the localDb barrel to direct db imports (#59) (#12052)
Boarded together with Phases 2, 4, 5 (#12051, #12053, #12055) and validated in one combined worktree: typecheck:core, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles, check-db-rules all green. Mechanical import-path migration only, no behavior change. Thanks for the phased, well-tested cleanup.
2026-08-30 02:27:25 -03:00
Webman
aa861a80d2 fix(barrel): migrate src/app/ off the @/lib/localDb barrel import (#11795 Phase 2) (#12051)
Boarded together with Phases 3-5 (#12052, #12053, #12055) and validated in one combined worktree: typecheck:core, check:file-size, check:changelog-integrity, check:complexity, check:cognitive-complexity, check:cycles, check-db-rules all green. Mechanical import-path migration only, no behavior change. Thanks for the phased, well-tested cleanup.
2026-08-30 02:26:55 -03:00
Bob.Hou
38e2baa879 fix(resilience): clear persisted LKGP pin on target exhaustion and skip (#11911) (#12013)
When an auto/*/lkgp combo target failed into exhaustion (e.g. an unauthenticated free-tier 401) or was skipped pre-dispatch (cooldown, model lockout, unavailability), the Last Known Good Provider pin was never cleared — so subsequent requests kept re-selecting the same dead provider, causing repeated failures and mass-skipping instead of falling through to a healthy target. Centralizes invalidation into clearStaleLKGP(), invoked from both handleComboChat and handleRoundRobinCombo on exhaustion, pre-dispatch skip, and body-specific 400 termination.
2026-08-29 19:52:06 -03:00
Bob.Hou
d3420d29f1 fix(admission): exclude reclaimable page cache from the cgroup pressure ratio (#12017)
Real production incident (2026-08-29): the resource-pressure guard ratioed raw cgroup v2 memory.current (which counts reclaimable page cache) against memory.max, so a busy host with ~3GiB of page cache latched a global 503 across every model for 26 minutes even though PSI/OOM/memory.events all showed zero real pressure — the kernel would have reclaimed those pages instantly. Fix: ratio the working set (current - file) for the trip/recovery check, falling back to the raw ratio when memory.stat is missing/stale/zero (never clamping to a false zero-pressure reading).

12 new tests including direct incident reproduction (raw 95%/workingset 32% stays normal) + bug-injection round trips. Full resource-pressure + admission suites green (48/48, re-verified in this batch together with the other 3 PRs: 57/57).
2026-08-29 19:52:02 -03:00
Bob.Hou
5698769aba fix(cli): support --base-url alongside --endpoint in nodes subcommands (#11999) (#12033)
Commander's top-level global --base-url option was shadowing the flag on `omniroute nodes add/update/validate`, rejecting the command with "required option '--endpoint <url>' not specified" even when --base-url was correctly supplied. Now both flags are accepted on all three subcommands, falling back to whichever the user passes.
2026-08-29 19:51:59 -03:00
Bob.Hou
55e33f3dc8 fix(sse): default crash-guard logger to console.warn, not console (#12042)
Real production incident (2026-08-29): the crash guard #11556 introduced defaulted its logger to `log ?? console` — console is an object, not a function, so a burst of client aborts (ECONNRESET) reaching the process-level guard threw TypeError inside the uncaughtException handler itself and killed the server, twice in three minutes. Fix: default to console.warn.bind(console).

Bug-injection round trip confirms the new test fails on the old default and passes on the fix. Existing guard suite stays green: 9/9 (verified together with the new test).
2026-08-29 19:51:56 -03:00
Diego Rodrigues de Sa e Souza
36b7920db1 refactor(video): extract a Video Bridge pipeline with explicit ports (#12016)
Extracts videoBridge.ts's per-part loop body, whole-result cache identity/key helpers, and describeWithVisionModel into a new videoBridgePipeline.ts with explicit port boundaries (VideoMediaBrokerPort, VideoAudioTranscriptionPort, VideoDrilldownPort). videoBridge.ts shrinks 820→255 lines, now only handling request traversal, policy resolution, aggregation, and response payload. Moved as whole blocks, parameterized rather than rewritten — byte-for-byte traceable to the pre-extraction code.

Rebased onto the tip after sibling #12009 (FU-05 core) landed first and bumped the result-cache version v4→v5 in videoBridge.ts — that same bump (plus its explanatory comment) is now carried into the extracted videoBridgePipeline.ts instead. Re-validated: 20/20 focused tests, typecheck clean.
2026-08-29 19:39:37 -03:00
Diego Rodrigues de Sa e Souza
5fcd39bd6f feat(video): orchestrate Audio Bridge STT with one-download budgets (FU-06, #11654) (#12012)
FU-06 (Audio Bridge STT orchestration): one download, two extractions — takes already-downloaded video bytes and extracts bounded mono 16kHz PCM WAV via the loopback broker's new mode=audio operation, sharing the exact same queue/deadline/byte budgets as the frame path. Dual opt-in (operator setting default false + per-request), only reaches the STT call when both are on.

Rebased onto the tip after sibling #12011 (subtitle mode) landed first, both touching the same broker route/client — combined additively so frames/audio/subtitles all share the one extractionQueue singleton. Re-validated: 59/59 focused tests pass.
2026-08-29 19:34:57 -03:00
Diego Rodrigues de Sa e Souza
e8b2cd208d docs(video): clarify Video Bridge transcript provenance is caller-declared (#11661) (#12001)
Reconciles the Video Bridge FU-01..09 backlog docs against verified code and GitHub state (ground truth established first, per this repo's Documentation accuracy rule), correcting a real gap in GUARDRAILS.md: the transcript source field was documented as validated without noting OmniRoute didn't yet verify server-side extraction — exactly the gap #11652 (now merged as #12009) closes. Refs #11661, not Closes — truthfully closing it needs the sibling PRs' actual landed state folded back in, left as an explicit follow-up.
2026-08-29 19:33:39 -03:00
Diego Rodrigues de Sa e Souza
f30e5b2675 feat(video): connect tenant-bound drill-down lifecycle and multiresolution variants (FU-08) (#12006)
FU-08 (Refs #11655): drill-down producer/consumer lifecycle on top of the existing cache substrate, without modifying it — new VideoDrilldownLifecycle (opaque sha256 handles, principal-bound resolve/delete with no existence oracle, preview/standard/detail multiresolution variants, 8-frame/32MiB page budget) plus a new authenticated remote-consumer route, both opt-in (default false).
2026-08-29 19:33:37 -03:00
Diego Rodrigues de Sa e Souza
ef668967f6 test(video): freeze FU-07/FU-09 promotion-evidence manifest, aggregator, evaluator and allowlist scaffold (#11656) (#12008)
FU-07/FU-09 promotion-evidence harness (Refs #11656): delivers the manifest schema, deterministic fixture recipes, metrics aggregator, and promotion-verdict evaluator #11656 asks for — deliberately does NOT deliver the promotion verdicts themselves (they require real models against real fixtures on a live host, HOLD with explicit reason instead of any fabricated result). New files only, no collision with sibling PRs.
2026-08-29 19:33:33 -03:00
Diego Rodrigues de Sa e Souza
60dc242178 feat(video): derive embedded subtitle provenance in the protected broker (#11659) (#12011)
FU-05 subtitle adapter (Refs #11659 — deliberately not Closes: the adapter is not yet wired into the live describeVideoPart path, that composition point is sibling #12009 which just landed): server-owned, loopback-only ffprobe/ffmpeg subtitle extraction that legitimately earns the "embedded" provenance label, mirroring the existing frame-extraction lifecycle. Broker route now also serves ?subtitles=1, stamped with the shared broker fingerprint so the client-side adapter can verify the payload actually came from the trusted process. Bounded, ReDoS-safe WebVTT parser.
2026-08-29 19:33:27 -03:00
Diego Rodrigues de Sa e Souza
3b00535d04 feat(guardrails): enforce video transcript provenance, budgets and reconciliation (#11652) (#12009)
FU-05 core (closes #11652): caller-supplied Video Bridge transcripts had no bounded, deterministic contract — a client could self-assert source: "embedded"/"audio-bridge" and it was accepted verbatim. normalizeVideoTranscript gained a code-only trustedSource seam unreachable from request-body JSON; without it, any cue declaring embedded/audio-bridge is reclassified to client. Added budgets (256 cues, 4096 code units/cue, 4KiB/cue, 64KiB total), malformed-surrogate rejection, focus-window scoping, deterministic cross-source reconciliation, and bumped the result-cache version v4→v5 so old-contract cache entries can never serve new-contract requests.

All 187 videoBridge* tests pass (185 pass, 2 unrelated pre-existing skips).
2026-08-29 19:32:35 -03:00
Diego Rodrigues de Sa e Souza
2b8d3a8291 fix(radar): restore D12 public boundary (#12057) 2026-08-29 17:37:26 -03:00
Diego Rodrigues de Sa e Souza
34e2f84c04 feat(api): explicit model exposure allow/deny list for /v1/models (#11481) (#11997)
Adds opt-in modelVisibilityAllowlist/modelVisibilityDenylist settings so an operator can curate exactly which models GET /v1/models advertises, mirrored into auto/* combo candidate pools (the same trap #6512 fixed for hidePaidModels). Default off, no behavior change for anyone who doesn't opt in.

TDD: 4 new test files, 22/22 passing (16 node:test + 6 vitest) + regression sweep across virtual-auto-combo/hide-paid/hide-auto-no-think suites (21/21).

Rebased onto the updated tip (a sibling #9133 landed first, same file) — kept both rebaseline annotations in file-size-baseline.json and set the value to the real measured line count after both merged.
2026-08-29 15:40:32 -03:00
Diego Rodrigues de Sa e Souza
065d998407 fix(cli): update flow now says whether the running process needs a restart (#11885) (#12005)
Fixes three defects in the "update doesn't restart the running process" bug class: CLI update guidance now detects a live server and tells the operator to restart instead of implying the update is already live; the dashboard's Update button tries OmniRoute's own PID-file supervisor before falling back to pm2 instead of hardcoding pm2 and silently skipping; getLatestVersionFromNpmCli now uses --prefer-online (same fix pattern as #4376). TDD throughout, 63/63 targeted regression tests pass.
2026-08-29 15:27:53 -03:00
Diego Rodrigues de Sa e Souza
d32c76f85a fix(config): correct Hermes-4-405B display label from 7B to 405B (#11861) (#11993)
Fixes a copy-paste label typo (Hermes-4-405B mislabeled "7B") in both the registry and the free-model catalog data, spotted in the #11861 comment thread. TDD: 3/3 tests, generic parameter-size consistency check + exact regression guard.
2026-08-29 15:27:49 -03:00
Diego Rodrigues de Sa e Souza
c9b1c12cfd fix(db): include local no-API-key providers in Qdrant embedding-model list (#11949) (#11995)
Local no-API-key providers (ollama-local, lm-studio, vllm, etc.) were invisible in the Qdrant embedding-model dropdown because configuredProviders required a real apiKey or OAuth. Extended the filter to also include providerAllowsOptionalApiKey(connection.provider) — the same canonical helper already used for the identical check elsewhere. TDD: 21/21 integration tests pass (was 20/21 before the fix).
2026-08-29 15:27:44 -03:00
Diego Rodrigues de Sa e Souza
c8dc982eaa fix(ci): drop the stale ESLint cache restore-keys fallback from ci.yml (#11600) (#11996)
Fixes the blocking Lint job's own ci.yml cache: PR #11963 removed the stale restore-keys fallback from quality.yml but left ci.yml's two "Restore ESLint file cache" steps carrying the same prefix-match fallback that lets a cache from a different lint config report stale per-file verdicts. Byte-level parity with #11963's already-merged fix.

Deliberately half of #11600 — the other half (run-eslint-json.mjs) is covered by PR #11983 from a parallel session, so the two don't collide on the same file.
2026-08-29 15:27:39 -03:00
Diego Rodrigues de Sa e Souza
9ec4d39a74 fix(ci): webpack for docker-publish even on omni-build (#12050)
Turbopack had 31 GB on omniroute-113-6 and still panicked
(TurbopackInternalError: there must be a path to a root, run
33253576569). The same tree's arm64 webpack build on hosted ARM
succeeded. Dockerfile already documents webpack as the Docker
escape hatch. Keep amd64 on the one omni-build slot (#12048).
2026-08-29 15:18:33 -03:00
Diego Rodrigues de Sa e Souza
a9aee94a00 docs(ops): the .113 heavy-build ceiling is one runner, not two (#12048)
* docs(ops): the .113 heavy-build ceiling is one runner, not two

Two concurrent next-builds (15.4 GB + 17.2 GB RSS) OOM-killed one on 2026-08-29 17:26 UTC;
systemd booked the kill on the other runner's unit and its job died with the same
"shutdown signal" text a hosted-runner OOM shows. omni-build now lives on
omniroute-113-5 only; 113-6 keeps omni-release. The janitor ceiling counts every
listener on the box (4 OmniRoute + OmniHeuris + OmniMind = 6). The second heavy slot
returns when the Proxmox VM gets more RAM; the exact command is in the doc.

* docs(ops): apply the single-heavy-slot text (previous commit only carried formatting)
2026-08-29 14:41:20 -03:00
Diego Rodrigues de Sa e Souza
47f7e5a306 fix(release): the packaged-app smoke verifies the database opened, not a driver line the primary path never prints (twin of #12032) (#12047)
* fix(release): the packaged-app smoke verifies the database opened, not a driver line the primary path never prints (release/v3.8.51 twin of #12032)

Same change as #12032 on main: the packaged app opens SQLite during the smoke but
its primary open path prints no "[DB] Driver: …" line (only the recovery path and
the sql.js fallback do), so the #7592 assertion failed every Linux release leg. The
guard rejects the sql.js fallback line, accepts a native driver line, and otherwise
accepts demonstrable database activity; after readiness the smoke requests
/api/monitoring/health and waits for that activity outside the readiness loop.
electron-smoke-script suite 10/10.

* fix(release): reapply the smoke rework on top of release/v3.8.51's own copy of the script

The previous commit copied main's file wholesale and dropped this branch's
ensureSmokeEnvDirs(currentPlatform) fix and its tests; this reapplies only the
DB-open evidence change as a patch. electron-smoke-script suite green.
2026-08-29 14:07:58 -03:00
Diego Rodrigues de Sa e Souza
38e2616464 fix(ci): stop hosted docker-publish OOM and unpaint Build (advisory) (#12021)
* fix(ci): stop hosted docker-publish OOM and unpaint Build (advisory)

docker-publish was firing 8 concurrent hosted builds on every merge
storm; each died ResourceExhausted in npm run build (#11976). One
publish per ref, webpack instead of Turbopack so native RSS stays
inside the V8 heap we can cap. Build (advisory) is skipped: continue-on-error
still reports FAILURE and was painting every fork PR red.

Closes #11976

* fix(ci): run docker-publish amd64 on omni-build and share the heavy lane

The .113 box is 31 GB / 32 cores — enough for one next-build. Hosted
ubuntu-24.04 is ~7 GB and ResourceExhausted every publish (#11976).
amd64 now targets [self-hosted, omni-build] (Turbopack) when
USE_VPS_RUNNER is on, joins the existing heavy-build-main group so it
queues beside ci.yml Build instead of becoming a third heavy, and
falls back to hosted + webpack if the VPS is off. arm64 stays on
ubuntu-24.04-arm with webpack (no ARM box).

* test(ci): align the advisory-build contract with the hosted-OOM skip

if: ${{ false }} tripped zizmor obfuscation (194→195). Bare if: false
skips the job without a new finding. The #7307 test now pins the skip
and keeps the job body as the restore recipe.
2026-08-29 09:52:00 -03:00
Diego Rodrigues de Sa e Souza
c4bd8b8ec4 fix(release): electron lockfile resync, build_ref, curated notes and SBOM on dispatch (twin of #11982 + #12020) (#12022)
* fix(release): resync the electron lockfile, build a dispatch from a repaired ref, keep curated notes, attach the SBOM on dispatch (release/v3.8.51 twin of #11982 + #12020)

Same four changes as #11982 and #12020 on main, applied to this branch's own copies:

- electron/package-lock.json regenerated (271 -> 284 entries): the optional
  electron-builder-squirrel-windows subtree was missing and `npm ci` refused the lock
  (EUSAGE) on the Linux and macOS legs; a clean `npm ci --ignore-scripts` on the
  result exits 0.
- electron-release.yml: `build_ref` dispatch input (default: the version tag) and
  `generate_release_notes` only on the tag push (a re-attach dispatch appended
  GitHub's auto notes to the curated body on v3.8.50).
- npm-publish.yml: the SBOM attaches to the GitHub Release on workflow_dispatch
  publishes too, whenever a release for the tag exists.

actionlint and prettier clean; electron-release-desktop-channel-8949,
electron-release-efficiency, electron-release-latest-yml.repro, check-workflows
and npm-publish-artifact-provenance suites pass.

* fix(release): validate build_ref in the validate job before any checkout uses it

CodeQL (actions/cache-poisoning/poisonable-step, high) on release/v3.8.51 — the
default branch: a raw dispatch input checked out next to setup-node's npm cache is a
cache-poisoning vector. The input now goes through the validate job's regex
allowlist (main or release/vX.Y.Z, empty = the version tag) and every build job
checks out needs.validate.outputs.build_ref, never the input itself.

* fix(release): drop the build_ref input — a dispatch builds the ref it is dispatched on

CodeQL (actions/cache-poisoning/poisonable-step) tracks the input through the
validate job's output regardless of the regex allowlist: an input-controlled
checkout next to setup-node's npm cache on the default branch is a cache-poisoning
vector. The ref is not an input any more; the checkouts use github.ref, so
`gh workflow run electron-release.yml --ref v3.8.50 -f version=v3.8.50` rebuilds
the tag and `--ref main` builds the repaired line. The tag-push path is unchanged.
2026-08-29 09:28:03 -03:00
Diego Rodrigues de Sa e Souza
e6de61f0c2 fix(sse): stop the auto-combo candidates inspector from dropping blocked rows (#9133) (#11994)
* fix(sse): stop the auto-combo candidates inspector from dropping blocked rows (#9133)

prepareVirtualAutoComboInputs applied filterResilienceBlockedCandidates
before the #7819 read-only candidate inspector ever saw the pool, so a
model-locked or cooled-down candidate silently disappeared from
/auto-combo/*/candidates instead of showing up as reachable:false with a
reason (modelLocked/connectionCooldown/breakerState were dead fields by
construction). Add an opt-in `skip` parameter so the inspector builds its
own unfiltered pool; routing (createVirtualAutoCombo/createBuiltinAutoCombo
called without a prepared override) is unchanged. Also aligns
isModelLocked's model argument to the bare model id, matching every lock
writer and the routing-side filter, instead of the "provider/model" string.

Regression test: tests/unit/auto-combo-candidates-locked-model-visible.test.ts
(red before the fix — locked account's row silently missing; green after).

* chore(quality): register the #9133 regression test in stryker tap.testFiles

tests/unit/auto-combo-candidates-locked-model-visible.test.ts covers
open-sse/services/accountFallback.ts (via isModelLocked) but wasn't listed,
so its mutant kills wouldn't count toward mutation coverage.

---------

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-29 08:10:40 -03:00
Diego Rodrigues de Sa e Souza
02ba573730 fix(providers): scope Antigravity mitmAlias tier ids to the safe static alias (#11824) (#11988)
Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-29 08:10:37 -03:00
Diego Rodrigues de Sa e Souza
bd04bb9cc6 fix(sse): set X-OmniRoute-Selected-Connection-Id on successful combo dispatches (#11810) (#11986)
Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-29 08:10:33 -03:00
Diego Rodrigues de Sa e Souza
322b218f06 fix(cli): drop the never-produced dist/index.cjs requirement from prepublish's opencode-plugin skip check (#11787) (#11990)
* fix(cli): drop the never-produced dist/index.cjs requirement from prepublish's opencode-plugin skip check (#11787)

* test(build): resolve tsup/npm portably in the #11787 regression test instead of a hardcoded .bin path

The old test assumed @omniroute/opencode-plugin/node_modules/.bin/tsup
already existed. A fresh checkout (CI's npm ci never installs this
standalone package's own deps) has no such node_modules at all, so the
test failed with MODULE_NOT_FOUND in CI while passing locally on a devbox
that had installed it before. Mirror scripts/build/prepublish.ts's own
install-then-resolveLocalBinEntry approach.

---------

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-29 08:10:29 -03:00
Diego Rodrigues de Sa e Souza
71093eda77 fix(codex): keep parallel_tool_calls:false on translated Responses Lite path (#11707) (#11984)
enforceCodexResponsesLiteParallelToolCalls() forces parallel_tool_calls:false
at the top of CodexExecutor.execute(), but transformRequest() early-returns
the body before its RESPONSES_API_ALLOWLIST field filter only when
_nativeCodexPassthrough is set. Any request that reaches the codex
executor via the translated (non-native-passthrough) path never gets that
flag, so the allowlist filter silently deleted parallel_tool_calls right
before the fetch body was sent, reproducing the reported upstream
rejection ('X-OpenAI-Internal-Codex-Responses-Lite requires
parallel_tool_calls to be false') for every model.

Add parallel_tool_calls to RESPONSES_API_ALLOWLIST so the value survives
the translated path too. Update the sibling #2608 allowlist test that
previously asserted parallel_tool_calls gets stripped like other Chat
Completions-only fields -- it is a legitimate Responses API field that
must now survive.

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-29 08:10:26 -03:00
Diego Rodrigues de Sa e Souza
fb9cbe9566 fix(ci): pass --pass-on-unpruned-suppressions in run-eslint-json.mjs (#11600) (#11983)
Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-29 08:10:21 -03:00
Diego Rodrigues de Sa e Souza
674cc5feb1 fix(db): rate-limit Arena ELO fetch-failure warnings on repeated timeouts (#11500) (#11989)
* fix(db): rate-limit Arena ELO fetch-failure warnings on repeated timeouts (#11500)

* test(quality): split the #11500 fetch-failure-dedup tests into their own file

tests/unit/arena-elo-sync.test.ts crossed the 1000-line new-test-file cap
(file-size gate, PR mode). The two new tests don't need the file's DB
fixture (fetchArenaLeaderboards() never touches the DB), so they move to a
self-contained sibling file instead of growing the frozen suite.

---------

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-29 08:10:17 -03:00
Diego Rodrigues de Sa e Souza
d2ad71cf56 fix(kie): route flux/kontext to its dedicated endpoint, not the Market createTask flow (#11296) (#11985)
flux/kontext is catalogued with isMarket: true, so handleKieImageGeneration
routed it through KIE's unified Market createTask endpoint with
model: "flux/kontext". KIE does not expose Flux Kontext through the Market
catalog at all -- it lives under a dedicated API tree
(POST /api/v1/flux/kontext/generate, poll GET /api/v1/flux/kontext/record-info,
models flux-kontext-pro/flux-kontext-max) -- so the Market endpoint rejected it
with "model name not supported", matching the reporter's exact error text.

Special-case flux/kontext ahead of the isMarket branch so it hits the
dedicated endpoint/payload shape instead of being treated as a Market entry.
z-image/4.0-*/4.5-* remains intentionally untouched (still blocked on
reporter/live confirmation per the existing in-code comment).

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-29 08:10:13 -03:00
Tobias Andersen
c705147de2 docs(i18n): finish Freepik → Magnific rebrand in locale strings and README (#11772)
Finishes the Freepik → Magnific rebrand from #10594 across 40 locale files and 3 README feature-list bullets (README.md, docs/i18n/it, docs/i18n/tr) — legacy `freepik` alias intentionally left in code/tests/redirects for backward compatibility, and historical CHANGELOG entries left untouched as documented history.

The README bullet had base-drifted since the PR branched (release tip's "What's New" changelog snippet had already dropped two providers mentioned nowhere else in the codebase, unrelated to this PR's scope) — resolved by keeping the tip's current bullet shape and applying only the Freepik→Magnific rename on top, in both the combined-worktree validation and the pushed branch.

Validated: all 40 edited locale JSON files parse; re-verified after resync onto the updated tip (post #11762/#11774/#11781).
2026-08-29 05:30:13 -03:00
Tobias Andersen
d8879371ea fix(combo): lock GitHub models rejected as "not supported" for future requests (#11781)
Follow-up to #11762/#11774, same bug class in combo's own model-lockout wiring: GitHub rejects several models (gpt-5.4, gpt-5.3-codex, etc.) with a 400 that's permanently unavailable for this account's Copilot integration, but nothing recorded a cross-request lockout — combo's #5249 in-request advance guard is correct but doesn't persist, so the same doomed model gets retried from scratch on every new request, indefinitely.

Fix: on a model-scoped 400 (`isModelScoped400`), call `lockModelIfPerModelQuota(provider, connectionId, rawModel, "model_capacity", 1h)`. GitHub already has per-model-quota enabled, so only the rejected model locks — siblings keep working. `isModelLocked()` is already checked pre-dispatch, so no other wiring needed.

Validated: 3/3 new tests + fixed a pre-existing test-isolation gap in combo-model-scoped-400-advance.test.ts (shared model name across sub-tests without clearing lockout state). Thanks!
2026-08-29 05:21:50 -03:00
Tobias Andersen
91f9a01fda fix(resilience): stop hammering permanently-moved endpoints and billing-suspended accounts (#11774)
Follow-up to #11762, same bug class hitting freeaiapikey (410 permanently-moved endpoint) and fireworks (412 billing-suspension) — both fell through checkFallbackError's generic transient-cooldown branch and got retried every ~1 minute for a full day.

Fix: `ENDPOINT_PERMANENTLY_MOVED_PATTERNS`/`isEndpointPermanentlyMoved()` → 24h lockout; `ACCOUNT_SUSPENDED_BILLING_PATTERNS`/`isAccountSuspendedForBilling()` → treated as credits-exhausted (1h cooldown), independent of status code so it also catches Fireworks' 412.

#11762 landed first and touched the same file — rebased/re-merged onto the updated tip (additive, no logic changes) and re-validated: 13/13 tests pass. Thanks for tracing this with real production logs again!
2026-08-29 05:19:15 -03:00
Tobias Andersen
87b3bdf85e fix(resilience): lock permanently retired models instead of short backoff (Gemini ban prevention) (#11762)
Root-caused via a real Gemini-ban incident log: deprecated-model 404/410s (e.g. gemini-2.5-flash "no longer available to new users") fell through checkFallbackError's generic transient-cooldown branch, so combo/auto-routing kept re-selecting a permanently dead model every cooldown window forever — the hammering that got the account flagged as abusive.

Fix: `MODEL_PERMANENTLY_UNAVAILABLE_PATTERNS` + `isModelPermanentlyUnavailable()` classify these as a 24h lockout instead, surfaced via `quotaResetHintMs` so combo's per-request model-lockout honors it in full.

Validated: 6/6 new tests + 133/133 existing accountFallback/error-classification tests, no regressions. Thanks for tracing this end-to-end with real production logs!
2026-08-29 05:09:37 -03:00
542 changed files with 15069 additions and 5372 deletions

View File

@@ -0,0 +1,36 @@
name: API Route Typecheck
on:
pull_request:
branches:
- main
- "release/**"
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
api-typecheck:
name: API Route Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Reject new API-route TypeScript diagnostics
run: node scripts/check/check-api-typecheck.mjs
- name: API typecheck gate unit tests
run: node --import tsx/esm --test tests/unit/build/check-api-typecheck.test.ts

View File

@@ -109,8 +109,11 @@ jobs:
.eslintcache
.eslintcache-complexity
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
restore-keys: |
eslint-${{ runner.os }}-
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
# cache built under a different suppressions file / lint config / lockfile reports
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
# partial cache from another configuration.
# Single ESLint inventory (JSON) — quality-gate reuses the artifact instead of
# a second cold full-tree pass for eslintWarnings ratchet counts.
- name: ESLint (JSON report)
@@ -209,8 +212,11 @@ jobs:
.eslintcache
.eslintcache-complexity
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
restore-keys: |
eslint-${{ runner.os }}-
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
# cache built under a different suppressions file / lint config / lockfile reports
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
# partial cache from another configuration.
# Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura.
# continue-on-error: o artifact pode não existir se a job test-coverage foi
# SKIPPED (shard flaky). Nesse caso collect-metrics pula coverage.* (ausente sem
@@ -621,9 +627,9 @@ jobs:
# 13:50Z the kernel OOM-killed main's build while a PR build ran beside it
# (five Build jobs had been queued by a burst of PRs). Two lanes: main keeps
# its own so a release is never queued behind PR traffic; PR builds serialize
# among themselves. GitHub keeps one running + one pending per group and
# CANCELS older pendings — a cancelled PR build is re-runnable; a dead main
# build costs the publish its artefact and a 40-minute rebuild that OOMs.
# among themselves. docker-publish.yml's amd64 leg joins `heavy-build-main`
# so a :next image build waits beside this artefact instead of becoming the
# third heavy (#11976). GitHub keeps one running + one pending per group.
concurrency:
group: heavy-build-${{ github.ref == 'refs/heads/main' && 'main' || 'pr' }}
cancel-in-progress: false

View File

@@ -26,6 +26,14 @@ on:
type: boolean
default: false
# One publish per ref. A merge storm used to fan out 8 concurrent hosted builds,
# every one OOM-killing `npm run build` inside BuildKit (#11976). The :next
# channel only needs the newest SHA; cancel-in-progress is the same pattern as
# quality.yml / nightly-release-green.
concurrency:
group: docker-publish-${{ github.ref }}
cancel-in-progress: true
# Least-privilege default: read-only at the top level; the build and merge jobs that
# push to GHCR grant packages: write themselves (Scorecard TokenPermissions).
permissions:
@@ -118,7 +126,23 @@ jobs:
name: Build Docker (${{ matrix.platform }})
needs: prepare
if: needs.prepare.outputs.skip != 'true'
runs-on: ${{ matrix.runner }}
# amd64: the .113 omni-build pool (31 GB / 32 cores, ONE listener since
# #12048). Hosted ubuntu-24.04 is ~7 GB and dies ResourceExhausted (#11976).
# Falls back to hosted when USE_VPS_RUNNER is off. arm64: no ARM box — stay
# on GitHub's ubuntu-24.04-arm.
# Webpack on BOTH arches: Turbopack on omniroute-113-6 hit
# TurbopackInternalError "there must be a path to a root" after 26 min
# (run 33253576569). The same tree's arm64 webpack build on hosted ARM
# succeeded (run 33264823398). Dockerfile already documents webpack as the
# Docker escape hatch (OMNIROUTE_USE_TURBOPACK=0).
runs-on: ${{ matrix.arch == 'amd64' && (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-24.04') || 'ubuntu-24.04-arm' }}
# Share the 1-slot omni-build ceiling (#12048) with ci.yml `Build` /
# npm-publish. Same group as main's Build so a :next publish waits beside
# the artefact instead of sitting next to it. arm64 is hosted — its own
# group, cancelled by the workflow-level concurrency.
concurrency:
group: ${{ matrix.arch == 'amd64' && 'heavy-build-main' || format('docker-publish-arm-{0}', github.ref) }}
cancel-in-progress: ${{ matrix.arch != 'amd64' }}
permissions:
contents: read
packages: write
@@ -127,10 +151,8 @@ jobs:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
arch: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
arch: arm64
env:
IMAGE_NAME: diegosouzapw/omniroute
@@ -143,6 +165,9 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
fetch-depth: 0
- name: Assert Docker Engine
run: docker info
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -166,6 +191,8 @@ jobs:
context: .
target: runner-base
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -183,6 +210,8 @@ jobs:
context: .
target: runner-web
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -208,6 +237,8 @@ jobs:
file: Dockerfile.bun
target: runner-base
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -233,6 +264,8 @@ jobs:
file: Dockerfile.bun
target: runner-web
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -495,6 +528,9 @@ jobs:
severity: CRITICAL
ignore-unfixed: true
exit-code: "1"
# Explicit: the advisory scan above already points at it, and the blocking
# gate must honour the same accepted-risk list (#12084).
trivyignores: .trivyignore
- name: Upload Trivy SARIF to Security tab
if: needs.prepare.outputs.version != 'main'

View File

@@ -4,6 +4,10 @@ on:
push:
tags:
- "v*"
# A dispatch builds the ref it is dispatched ON (`gh workflow run … --ref v3.8.50` rebuilds
# that tag; `--ref main` builds the repaired line). The ref is deliberately NOT an input:
# CodeQL flags an input-controlled checkout next to the npm cache on the default branch as
# cache poisoning (actions/cache-poisoning/poisonable-step), and `github.ref` is trusted.
workflow_dispatch:
inputs:
version:
@@ -81,9 +85,6 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
# tag push this resolves to the same commit.
ref: ${{ needs.validate.outputs.version }}
- name: Setup Node
uses: actions/setup-node@v7
with:
@@ -169,9 +170,6 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
# tag push this resolves to the same commit.
ref: ${{ needs.validate.outputs.version }}
- name: Setup Node
uses: actions/setup-node@v7
with:
@@ -358,8 +356,6 @@ jobs:
with:
persist-credentials: false
fetch-depth: 0
# Source archives + SBOM come from the tag being released, not the dispatching branch.
ref: ${{ needs.validate.outputs.version }}
# `merge-multiple` is deliberately OFF. It resolves same-name collisions by ARRIVAL
# ORDER, and the two macOS jobs each emit their own `latest-mac.yml` listing only their
@@ -417,7 +413,10 @@ jobs:
tag_name: ${{ needs.validate.outputs.version }}
draft: false
prerelease: false
generate_release_notes: true
# Only on the tag push. A re-attach dispatch runs against a release whose curated
# notes already exist, and `true` APPENDS GitHub's auto-generated "What's Changed"
# block to them (v3.8.50, run 33238093090: +1,416 chars on a 121 KB body).
generate_release_notes: ${{ github.event_name != 'workflow_dispatch' }}
fail_on_unmatched_files: false
files: |
release-assets/*.dmg

View File

@@ -196,6 +196,26 @@ jobs:
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
fi
- name: Close tracking issue when the branch is green again
if: steps.validate.outputs.exit == '0'
env:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ steps.branch.outputs.target }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# The open/update step above is the UPWARD half of the loop; without this
# step a stale "not green" issue outlives the fix and every base-green check
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
TITLE="🔴 Release branch not green: ${TARGET}"
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
--comment "✅ \`${TARGET}\` is release-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
echo "Closed issue #$EXISTING"
fi
- name: Upload report artifact
if: always()
uses: actions/upload-artifact@v7
@@ -294,6 +314,25 @@ jobs:
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
fi
- name: Close tracking issue when the branch is green again
if: steps.validate.outputs.exit == '0'
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# The open/update step above is the UPWARD half of the loop; without this
# step a stale "not green" issue outlives the fix and every base-green check
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
TITLE="🔴 main branch not green"
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
--comment "✅ \`main\` is main-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
echo "Closed issue #$EXISTING"
fi
- name: Upload report artifact
if: always()
uses: actions/upload-artifact@v7

View File

@@ -273,11 +273,20 @@ jobs:
if-no-files-found: error
- name: Attach SBOM to GitHub Release
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'release'
# Not only on the `release` event: the v3.8.50 package shipped through a
# workflow_dispatch (staged publish, 11 attempts) and this step was skipped, so the
# GitHub Release carried no SBOM until it was attached by hand from the run's
# `sbom-npm` artifact. Attach whenever a release for the published tag exists.
if: steps.resolve.outputs.skip != 'true' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: gh release upload "$TAG" sbom-npm.cdx.json --clobber
TAG: ${{ github.event_name == 'release' && github.ref_name || format('v{0}', inputs.version) }}
run: |
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "::notice::no GitHub Release for $TAG yet — SBOM stays on the sbom-npm workflow artifact"
exit 0
fi
gh release upload "$TAG" sbom-npm.cdx.json --repo "$GITHUB_REPOSITORY" --clobber
# WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must
# BOOT. build:cli already assembled dist/ above; this packs+installs+boots the

View File

@@ -70,7 +70,16 @@ jobs:
# 2026-08-14: 72 of the last 100 PRs into release/** came from forks, so the fork case is
# the majority of the traffic, not the exception — this job earns its place, it just should
# not duplicate build.yml for the own-origin 28%.
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true' && github.event.pull_request.head.repo.full_name != github.repository) }}
# Disabled 2026-08-29 (#11976 follow-up). `continue-on-error: true` still
# reports a GitHub check FAILURE, so every fork PR into release/** was born
# with a red "Build (advisory)" even when every required gate was green
# (sweep-reds, 41 PRs). Hosted ubuntu-latest cannot finish `npm run build`
# on this tree — VM shutdown ~6 min in, same class as build.yml going
# workflow_dispatch-only in #11962. Pre-merge build signal for release/**
# is nightly-release-green (omni-build); for main it is ci.yml `Build`.
# Restore this job when a runner that actually fits the tree is wired here.
# Bare `false` (not `${{ false }}`) — zizmor obfuscation flags the expression form.
if: false
# PINNED to hosted — this was the last job in THIS workflow still on the USE_VPS_RUNNER
# switch (ci.yml's Build, nightly-release-green and npm-publish keep it, so the variable
# stays meaningful), and with USE_VPS_RUNNER=true it produced NO signal at all here.

View File

@@ -4,12 +4,15 @@ on:
schedule:
- cron: "27 7 * * 1"
push:
branches: ["main"]
# Scorecard only accepts the DEFAULT branch — here the active release/vX.Y.Z,
# not `main`. The job below guards on it so a push to any other branch skips.
branches: ["main", "release/**"]
permissions: read-all
jobs:
analysis:
if: ${{ github.event_name != 'push' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:

View File

@@ -19,4 +19,12 @@
# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a
# patched base / bump the dep) over suppressing. Stale entries are debt.
#
# (No accepted-risk suppressions at present — ignore-unfixed covers the noise.)
# CVE-2025-68121 — Go stdlib crypto/tls (session-resumption certificate validation)
# inside the PREBUILT bogdanfinn/tls-client v1.15.1 .so that tls-client-node's
# postinstall downloads (built with go 1.24.1; fixed in 1.24.13). No upstream
# rebuild exists (v1.15.1 is still the latest release) and nothing in this repo
# can bump it. The binary is only loaded by the browser-TLS web-provider
# executors (claude-web / grok-web / lmarena / perplexity-web / notion-web),
# whose handshakes go through utls. Tracking issue: #12084. Revisit at the next
# tls-client release or base-image bump and BEFORE the v3.8.51 tag (2026-09-15).
CVE-2025-68121

View File

@@ -289,8 +289,7 @@ When creating _any_ validation tests or one-off logic scripts, default to `scrip
### Database
- **Always** go through `src/lib/db/` domain modules — **never** write raw SQL in routes or handlers
- **Never** add logic to `src/lib/localDb.ts` (re-export layer only)
- **Never** barrel-import from `localDb.ts` — import specific `db/` modules instead
- **Never** barrel-import from `localDb.ts` — import specific `src/lib/db/*` modules
- DB singleton: `getDbInstance()` from `src/lib/db/core.ts` (WAL journaling)
- Migrations: `src/lib/db/migrations/` — versioned SQL files, idempotent, run in transactions
@@ -355,8 +354,7 @@ Documentation must describe verified behavior, not plausible behavior.
1. Create `src/lib/db/yourModule.ts` — import `getDbInstance` from `./core.ts`
2. Export CRUD functions for your domain table(s)
3. Add migration in `src/lib/db/migrations/` if new tables needed
4. Re-export from `src/lib/localDb.ts` (add to the re-export list only)
5. Write tests
4. Write tests
### Adding a New MCP Tool
@@ -668,7 +666,7 @@ the stale-enforcement added in Fase 6A.3.
## Hard Rules
1. Never commit secrets or credentials
2. Never add logic to `localDb.ts`
2. Never barrel-import from `localDb.ts` — import specific `src/lib/db/*` modules
3. Never use `eval()` / `new Function()` / implied eval
4. Never commit directly to `main`
5. Never write raw SQL in routes — use `src/lib/db/` modules

View File

@@ -557,7 +557,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
- **🧠 Memory you control** — off by default, opt-in int8 vector quantization + typed decay, per-request `x-omniroute-no-memory`. → [Memory](docs/frameworks/MEMORY.md)
- **🛡️ Security** — prompt-injection guard on every LLM route (red-team suite), opt-in credential-masking guardrail (redacts leaked API keys/secrets in both directions), free DuckDuckGo last-resort web search, and an optional OIDC login gate for the dashboard (password login always stays available). → [Guardrails](docs/security/GUARDRAILS.md)
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Segmind, and speech providers such as ElevenLabs. → [API Reference](docs/reference/API_REFERENCE.md)
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Magnific, Adobe Firefly, Segmind, and speech providers such as ElevenLabs. → [API Reference](docs/reference/API_REFERENCE.md)
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **351-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)

View File

@@ -24,6 +24,80 @@ function parseHeader(kv) {
return { name: kv.slice(0, eq), value: kv.slice(eq + 1) };
}
function getRootCommand(cmd) {
let curr = cmd;
while (curr.parent) curr = curr.parent;
return curr;
}
function resolveNodeEndpoint(opts, cmd) {
if (opts.endpoint) {
return { endpoint: opts.endpoint, apiFetchOpts: cmd.optsWithGlobals() };
}
if (opts.nodeUrl) {
return { endpoint: opts.nodeUrl, apiFetchOpts: cmd.optsWithGlobals() };
}
// Check if --base-url, --endpoint, or --node-url was explicitly passed after the subcommand
const root = getRootCommand(cmd);
const rawArgs = root.rawArgs || process.argv;
const cmdName = cmd.name();
let subArgsStart = -1;
for (let i = 0; i < rawArgs.length - 1; i++) {
if (rawArgs[i] === "nodes" || rawArgs[i] === "provider-nodes") {
if (rawArgs[i + 1] === cmdName) {
subArgsStart = i + 2;
break;
}
}
}
let explicitSubcommandBaseUrl = undefined;
let serverBaseUrl = undefined;
if (subArgsStart !== -1) {
const preArgs = rawArgs.slice(0, subArgsStart);
for (let i = 0; i < preArgs.length; i++) {
if (preArgs[i] === "--base-url" && i + 1 < preArgs.length) {
serverBaseUrl = preArgs[i + 1];
} else if (preArgs[i].startsWith("--base-url=")) {
serverBaseUrl = preArgs[i].slice("--base-url=".length);
}
}
const subArgs = rawArgs.slice(subArgsStart);
for (let i = 0; i < subArgs.length; i++) {
const arg = subArgs[i];
if (
(arg === "--base-url" || arg === "--endpoint" || arg === "--node-url") &&
i + 1 < subArgs.length
) {
explicitSubcommandBaseUrl = subArgs[i + 1];
} else if (
arg.startsWith("--base-url=") ||
arg.startsWith("--endpoint=") ||
arg.startsWith("--node-url=")
) {
explicitSubcommandBaseUrl = arg.slice(arg.indexOf("=") + 1);
}
}
}
if (explicitSubcommandBaseUrl !== undefined) {
const globals = cmd.optsWithGlobals?.() ?? {};
const apiFetchOpts = { ...globals };
if (serverBaseUrl) {
apiFetchOpts.baseUrl = serverBaseUrl;
} else {
delete apiFetchOpts.baseUrl;
}
return { endpoint: explicitSubcommandBaseUrl, apiFetchOpts };
}
return { endpoint: undefined, apiFetchOpts: cmd.optsWithGlobals() };
}
const nodeSchema = [
{ key: "id", header: "Node ID", width: 22 },
{ key: "provider", header: "Provider", width: 16 },
@@ -70,7 +144,8 @@ export function registerNodes(program) {
nodes
.command("add")
.requiredOption("--provider <p>", t("nodes.add.provider"))
.requiredOption("--endpoint <url>", t("nodes.add.baseUrl"))
.option("--endpoint <url>", t("nodes.add.baseUrl"))
.option("--base-url <url>", t("nodes.add.baseUrl"))
.option("--name <n>", t("nodes.add.name"))
.option("--weight <w>", t("nodes.add.weight"), parseInt, 100)
.option("--region <r>", t("nodes.add.region"))
@@ -81,9 +156,14 @@ export function registerNodes(program) {
[]
)
.action(async (opts, cmd) => {
const { endpoint, apiFetchOpts } = resolveNodeEndpoint(opts, cmd);
if (!endpoint) {
process.stderr.write(`error: required option '--endpoint <url>' or '--base-url <url>' not specified\n`);
process.exit(1);
}
const body = {
provider: opts.provider,
baseUrl: opts.endpoint,
baseUrl: endpoint,
name: opts.name,
weight: opts.weight,
region: opts.region,
@@ -91,7 +171,7 @@ export function registerNodes(program) {
headers: opts.authHeader?.length ? opts.authHeader : undefined,
};
const res = await apiFetch("/api/provider-nodes", {
...cmd.optsWithGlobals(),
...apiFetchOpts,
method: "POST",
body,
});
@@ -99,24 +179,26 @@ export function registerNodes(program) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
emit(await res.json(), cmd.optsWithGlobals());
emit(await res.json(), apiFetchOpts);
});
nodes
.command("update <nodeId>")
.option("--endpoint <url>", t("nodes.update.baseUrl"))
.option("--base-url <url>", t("nodes.update.baseUrl"))
.option("--name <n>", t("nodes.update.name"))
.option("--weight <w>", t("nodes.update.weight"), parseInt)
.option("--region <r>", t("nodes.update.region"))
.option("--enabled <b>", t("nodes.update.enabled"), (v) => v === "true")
.action(async (id, opts, cmd) => {
const { endpoint, apiFetchOpts } = resolveNodeEndpoint(opts, cmd);
const body = {};
if (opts.endpoint !== undefined) body.baseUrl = opts.endpoint;
if (endpoint !== undefined) body.baseUrl = endpoint;
for (const k of ["name", "weight", "region", "enabled"]) {
if (opts[k] !== undefined) body[k] = opts[k];
}
const res = await apiFetch(`/api/provider-nodes/${id}`, {
...cmd.optsWithGlobals(),
...apiFetchOpts,
method: "PUT",
body,
});
@@ -124,7 +206,7 @@ export function registerNodes(program) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
emit(await res.json(), cmd.optsWithGlobals());
emit(await res.json(), apiFetchOpts);
});
nodes
@@ -145,19 +227,25 @@ export function registerNodes(program) {
nodes
.command("validate")
.requiredOption("--endpoint <url>", t("nodes.validate.baseUrl"))
.option("--endpoint <url>", t("nodes.validate.baseUrl"))
.option("--base-url <url>", t("nodes.validate.baseUrl"))
.requiredOption("--provider <p>", t("nodes.validate.provider"))
.action(async (opts, cmd) => {
const { endpoint, apiFetchOpts } = resolveNodeEndpoint(opts, cmd);
if (!endpoint) {
process.stderr.write(`error: required option '--endpoint <url>' or '--base-url <url>' not specified\n`);
process.exit(1);
}
const res = await apiFetch("/api/provider-nodes/validate", {
...cmd.optsWithGlobals(),
...apiFetchOpts,
method: "POST",
body: { baseUrl: opts.endpoint, provider: opts.provider },
body: { baseUrl: endpoint, provider: opts.provider },
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
emit(await res.json(), cmd.optsWithGlobals());
emit(await res.json(), apiFetchOpts);
});
nodes

View File

@@ -1,4 +1,4 @@
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { printHeading, printInfo, printSuccess, printError, printWarning } from "../io.mjs";
import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -6,6 +6,7 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { t } from "../i18n.mjs";
import { npmBin, npmExecOptions } from "../npm-exec.mjs";
import { readPidFile, isPidRunning } from "../utils/pid.mjs";
const execFileAsync = promisify(execFile);
@@ -79,6 +80,39 @@ export async function createBackup() {
}
}
// #11885: `--apply` installs the new files (npm install -g) and re-reads
// package.json from disk to confirm it, but a long-lived server process keeps
// serving whatever it loaded at its last start — Node caches a `require()`d
// package.json per resolved path for the life of the process. A later
// `omniroute update` then correctly reports "already up to date" (the files
// ARE current) while the running server is still stale, matching the reported
// symptom. `--apply` never restarted anything and its success message ("Run
// `omniroute --version` to verify.") implied the update was already live.
//
// `restart.mjs`'s `runRestartCommand()` stops then re-spawns the server in the
// foreground (via `serve.mjs::runServe`), which can block the calling terminal
// and is a materially bigger behavior change than this fix warrants to invoke
// unconditionally and unattended from `--apply`. Instead, detect whether a
// CLI-managed server is currently running (the same PID file `stop.mjs`/
// `restart.mjs` already trust) and print an explicit, prominent instruction —
// honest about what did and didn't happen — rather than silently assuming.
export async function isServerProcessRunning(deps = { readPidFile, isPidRunning }) {
const pid = deps.readPidFile("server");
return Boolean(pid && deps.isPidRunning(pid));
}
export async function printPostApplyGuidance(latest, deps = { readPidFile, isPidRunning }) {
const running = await isServerProcessRunning(deps);
if (running) {
printWarning(`Files updated to ${latest}, but the running server is still on the old version.`);
printInfo(" Run `omniroute restart` now to apply this update.");
} else {
printInfo(`No running OmniRoute server was detected via the CLI's PID file.`);
printInfo(` Start it with \`omniroute serve\` (or restart your existing process) to run ${latest}.`);
}
printInfo("`omniroute --version` will keep reporting the old version until the process restarts.");
}
export function registerUpdate(program) {
program
.command("update")
@@ -210,8 +244,8 @@ export async function runUpdateCommand(opts = {}) {
console.log(" or reorder PATH so the global bin comes first.");
return 1;
}
printSuccess(`Updated to version ${latest}`);
printInfo("Run `omniroute --version` to verify.");
printSuccess(`Installed omniroute@${latest} to disk.`);
await printPostApplyGuidance(latest);
return 0;
} catch (err) {
printError(`Update failed: ${err.message}`);

View File

@@ -81,3 +81,7 @@ export function printInfo(message) {
export function printError(message) {
console.log(`\x1b[31m✖ ${message}\x1b[0m`);
}
export function printWarning(message) {
console.log(`\x1b[33m⚠ ${message}\x1b[0m`);
}

View File

@@ -0,0 +1 @@
- feat(api): add an opt-in `modelVisibilityAllowlist`/`modelVisibilityDenylist` settings pair to curate exactly which models `/v1/models` advertises, mirrored into every `auto/*` combo candidate pool so a denied model cannot be routed to via combo selection either (#11481)

View File

@@ -0,0 +1 @@
- **feat(guardrails):** enforce a bounded, deterministic contract for Video Bridge transcripts — 256 cues, 4096 input code units and 4 KiB UTF-8 per cue, 64 KiB total text, malformed-Unicode rejection, focus-window scoping, cross-source reconciliation with contributing-source metadata, and a structural provenance trust boundary so caller JSON can never self-assert `embedded`/`audio-bridge` provenance ([#11652](https://github.com/diegosouzapw/OmniRoute/issues/11652))

View File

@@ -0,0 +1 @@
- **feat(video):** orchestrate optional Video Bridge audio extraction and Audio Bridge STT behind a dual opt-in (operator setting AND per-request signal) — a new loopback-only broker `mode=audio` operation shares the frame path's exact process queue, deadline, AbortSignal, and byte budgets to extract a bounded mono 16 kHz PCM WAV from the same already-downloaded video, then reuses the existing Audio Bridge transcription boundary; provider segment timing is preserved when available and marked coarse otherwise, and every failure degrades to a visual-only-safe partial instead of throwing (#11654).

View File

@@ -0,0 +1,6 @@
- Add a tenant-bound Video Bridge drill-down lifecycle on top of the existing secure cache
substrate: opaque hashed handles (never raw session/video identifiers), preview/standard/detail
multiresolution variants resampled on read, response pagination capped at 8 frames and 32 MiB,
and a new authenticated `/api/v1/video-bridge/drilldown` consumer route that stays disabled for
remote access by default and denies cross-key access with the same response as a nonexistent
handle (no existence oracle).

View File

@@ -0,0 +1 @@
- **test(video):** Add the Video Bridge FU-07/FU-09 promotion-evidence harness (#11656) — a frozen Zod manifest schema covering the 8 required scenario kinds (static scenes, rapid cuts, late facts, fades, blur, small text, close events, visual prompt injection) with a minimum of 3 repetitions per case, deterministic declarative fixture recipes (`videoBridgePromotionFixtures.ts`), a pure medians/p95 metrics aggregator, a pure FU-07/FU-09 promotion-verdict evaluator applying the ticket's exact thresholds (missing token usage always holds), a digest-only persistence layer that never retains raw media or raw model responses, and a versioned per-model promotion allowlist shipped empty with every model defaulting to `hold`. The FU-07/FU-09 promotion verdicts themselves remain HOLD — they require a real evidence run against real models on VPS 192.168.0.15.

View File

@@ -0,0 +1 @@
- **feat(video bridge):** "embedded" transcript provenance can now be legitimately earned instead of merely asserted — a bounded, allowlisted (`mov_text`/`subrip`/`webvtt`) subtitle probe runs through the loopback-only Video Bridge broker (at most 2 streams, 10s subdeadline bounded by the request deadline, 256 KiB output, 4096-code-unit lines), normalized through a bounded, ReDoS-safe WebVTT parser and Zod-validated end to end. The adapter always resolves to an explicit `success`/`absent`/`transient_failure` outcome — a subtitle failure never breaks the visual description path, and only a fingerprint-verified broker response (never a caller-declared label) can produce embedded cues (#11659).

View File

@@ -0,0 +1 @@
- **fix(kie):** reroute `flux/kontext` off the KIE Market `createTask` flow — it is catalogued with `isMarket: true` but has no Market catalog page, so KIE rejected it with "model name not supported"; it now hits the dedicated `POST /api/v1/flux/kontext/generate` / `GET /api/v1/flux/kontext/record-info` endpoints instead (#11296).

View File

@@ -0,0 +1 @@
- fix(db): rate-limit repeated Arena ELO leaderboard fetch-failure warnings instead of logging one per sync attempt (#11500)

View File

@@ -0,0 +1,5 @@
- Dropped the stale-`.eslintcache` `restore-keys` fallback from both "Restore ESLint file
cache" steps in `ci.yml`, so the blocking `Lint` job can no longer be served per-file
verdicts computed under a different lint config, suppressions file or lockfile. `quality.yml`
had already dropped it in #11963; `ci.yml` — the workflow that actually gates PRs — had not
(#11600).

View File

@@ -0,0 +1 @@
- fix(ci): pass `--pass-on-unpruned-suppressions` in `run-eslint-json.mjs` so the CI Lint job no longer fails when a suppression is merely orphaned by a genuine fix, mirroring the identical fix already in `validate-release-green.mjs` (#11600)

View File

@@ -0,0 +1 @@
- **fix(build):** `prepublish.ts` bundles the ChatGPT Web (Codex) MCP bridge through `runBuildTool()` instead of spawning `npx.cmd` raw, fixing the build crash on Node ≥ 20/Windows where `.cmd` shims cannot be spawned without a shell (EINVAL) ([#11704](https://github.com/diegosouzapw/OmniRoute/issues/11704))

View File

@@ -0,0 +1 @@
- fix(codex): keep `parallel_tool_calls:false` on the translated Codex Responses Lite path (#11707)

View File

@@ -0,0 +1 @@
- **fix(usage):** quota and usage refresh no longer 409 when an exclusive lease reserves the connection ([#11758](https://github.com/diegosouzapw/OmniRoute/pull/11758)) — thanks @TheDemonTuan

View File

@@ -0,0 +1 @@
- fix(cli): stop prepublish from re-rebuilding the already-built ESM-only opencode-plugin dist (#11787)

View File

@@ -0,0 +1 @@
- fix(sse): set X-OmniRoute-Selected-Connection-Id on successful combo dispatches so downstream consumers stop falling back to an empty connection id (#11810)

View File

@@ -0,0 +1 @@
- **fix(providers):** Antigravity's dynamic mitmAlias table no longer routes `gemini-3.7-flash-{high,medium,low}` to a literal tier-suffixed upstream id just because one connected account's own discovery listed it directly — those display ids always resolve through the safe `gemini-3.7-flash-tiered` static alias, so one account's Google-provisioned access no longer 404s every sibling account of the provider ([#11824](https://github.com/diegosouzapw/OmniRoute/issues/11824), [#11651](https://github.com/diegosouzapw/OmniRoute/issues/11651))

View File

@@ -0,0 +1 @@
- **fix(config):** Nous Research's `Hermes-4-405B` model now displays as "Hermes 4 405B (Nous Research)" in both the provider registry and the free-model catalog, instead of the mislabelled "Hermes 4 7B" ([#11861](https://github.com/diegosouzapw/OmniRoute/issues/11861)) — thanks @Karan825

View File

@@ -0,0 +1 @@
- **fix(cli):** `omniroute update --apply` now tells you explicitly whether a running server was detected and, if so, that you must run `omniroute restart` to apply the update — it never restarted anything and previously implied the update was already live once files were installed. The dashboard's npm-mode Update flow (`/api/system/version`) now tries OmniRoute's own PID-file-managed supervisor before falling back to pm2, and reports an honest "restart required" step instead of a silent pm2-only "skipped" that read like a completed update. The server-side latest-version lookup backing the dashboard's update banner also gained `--prefer-online`, closing the same stale-npm-cache class already fixed in the CLI's own copy for #4376 ([#11885](https://github.com/diegosouzapw/OmniRoute/issues/11885)).

View File

@@ -0,0 +1 @@
- **fix(resilience):** clear persisted LKGP pins when a target suffers connection/provider exhaustion or is skipped before dispatch due to cooldown/exhaustion/unavailability, preventing subsequent requests from repeatedly prioritizing known-dead providers ([#11911](https://github.com/diegosouzapw/OmniRoute/issues/11911)).

View File

@@ -0,0 +1 @@
- **fix(db):** the Qdrant embedding-model dropdown now lists local/self-hosted providers (Ollama, LM Studio, vLLM, etc.) — an active connection is treated as "configured" when the provider allows an optional API key, not only when it has a real key or OAuth, so a running local embedding provider is no longer hidden from the picker ([#11949](https://github.com/diegosouzapw/OmniRoute/issues/11949))

View File

@@ -0,0 +1 @@
- **fix(cli):** support `--base-url` alongside `--endpoint` in `omniroute nodes add`, `update`, and `validate` subcommands to prevent global `--base-url` shadowing issues ([#11999](https://github.com/diegosouzapw/OmniRoute/issues/11999)).

View File

@@ -0,0 +1 @@
- fix(sse): stop the auto-combo candidates inspector from silently dropping model-locked/cooled-down rows (#9133)

View File

@@ -0,0 +1 @@
- Electron release: `electron/package-lock.json` regained the optional `electron-builder-squirrel-windows` subtree (13 entries) that `npm ci` had been refusing as out of sync, `electron-release.yml` gained a `build_ref` dispatch input and stops regenerating release notes on a re-attach dispatch, and the npm publish workflow attaches the SBOM to the GitHub Release on dispatch publishes too — so the v3.8.51 tag ships every desktop asset and the SBOM like v3.8.49 did

View File

@@ -0,0 +1 @@
- **refactor(video bridge):** extract per-video acquisition, whole-result caching, description, and metrics/abort/cleanup out of `VideoBridgeGuardrail.preCall` into a `processVideoPart` seam in a new `videoBridgePipeline.ts`, behind explicit `VideoMediaBrokerPort`, `VideoAudioTranscriptionPort`, and `VideoDrilldownPort` boundaries; `preCall` now only handles request traversal, policy, and response aggregation. The Video tab's FFmpeg/ffprobe runtime status is now an explicit `unknown` / `restricted` / `unavailable` / `available` state instead of a nullable boolean pair, fixing a case where an in-flight or failed probe was mislabeled as "install FFmpeg" ([#11657](https://github.com/diegosouzapw/OmniRoute/issues/11657)).

View File

@@ -0,0 +1,5 @@
- **docs(video):** clarify that the Video Bridge transcript `source` field (`client`,
`embedded`, `audio-bridge`) is presently caller-declared and not yet server-verified —
OmniRoute enforces the enum shape but does not cryptographically confirm that an
`embedded`/`audio-bridge` label came from a server-owned extraction
([#11661](https://github.com/diegosouzapw/OmniRoute/issues/11661)).

View File

@@ -0,0 +1 @@
- Stop painting every fork PR into `release/**` red: `quality.yml` `Build (advisory)` is skipped (GitHub still reports `continue-on-error` failures as check FAILURE). Hosted `ubuntu-latest` cannot finish `npm run build` on this tree — same class as #11962 taking `build.yml` off the PR rail. `docker-publish.yml` amd64 now runs on the `.113` `omni-build` pool (31 GB / 32 cores, two listeners) with Turbopack, shares the `heavy-build-main` lane with `ci.yml` `Build` so it queues instead of becoming a third heavy, and keeps per-ref concurrency (a merge storm was starting 8 concurrent OOM builds). arm64 stays on `ubuntu-24.04-arm` with webpack — there is no ARM box. Fallback when `USE_VPS_RUNNER` is off: hosted amd64 + webpack (#11976).

View File

@@ -0,0 +1 @@
- `docker-publish.yml` builds with webpack (`OMNIROUTE_USE_TURBOPACK=0`) on **both** arches, including amd64 on the `.113` `omni-build` pool. Turbopack had the RAM (31 GB, `omniroute-113-6`) and still panicked (`TurbopackInternalError: there must be a path to a root`, run 33253576569, 26 min in). The same tree's arm64 webpack build on hosted `ubuntu-24.04-arm` succeeded (run 33264823398). Dockerfile already documents webpack as the Docker escape hatch (#11976).

View File

@@ -0,0 +1,400 @@
{
"open-sse/transformer/responsesTransformer.ts": {
"TS2353": 1
},
"open-sse/utils/progressTracker.ts": {
"TS2353": 1
},
"open-sse/utils/sseHeartbeat.ts": {
"TS2353": 1
},
"open-sse/utils/stream.ts": {
"TS2353": 1
},
"src/app/api/assess/route.ts": {
"TS2339": 1
},
"src/app/api/cache/route.ts": {
"TS2339": 1
},
"src/app/api/cli-tools/all-statuses/route.ts": {
"TS2339": 1
},
"src/app/api/cli-tools/claude-settings/route.ts": {
"TS2339": 1
},
"src/app/api/cli-tools/cline-settings/route.ts": {
"TS2339": 5
},
"src/app/api/cli-tools/codex-settings/route.ts": {
"TS2345": 2
},
"src/app/api/cli-tools/grok-build-settings/route.ts": {
"TS2304": 1
},
"src/app/api/cli-tools/hermes-agent-settings/route.ts": {
"TS2345": 1
},
"src/app/api/cli-tools/letta-settings/route.ts": {
"TS2339": 1
},
"src/app/api/cli-tools/omp-settings/route.ts": {
"TS2339": 8
},
"src/app/api/cli-tools/qwen-settings/route.ts": {
"TS2322": 1
},
"src/app/api/combos/auto/route.ts": {
"TS2322": 1
},
"src/app/api/combos/test/route.ts": {
"TS2345": 1,
"TS2339": 1
},
"src/app/api/compression/compare/route.ts": {
"TS2345": 1
},
"src/app/api/compression/preview/route.ts": {
"TS2345": 1
},
"src/app/api/context/combos/[id]/route.ts": {
"TS2345": 1
},
"src/app/api/context/combos/route.ts": {
"TS2345": 1
},
"src/app/api/copilot/chat/route.ts": {
"TS2345": 1
},
"src/app/api/guardrails/test/route.ts": {
"TS2554": 1
},
"src/app/api/internal/codex-responses-ws/route.ts": {
"TS2740": 1,
"TS2339": 7
},
"src/app/api/keys/[id]/route.ts": {
"TS2339": 1
},
"src/app/api/local/redis/start/route.ts": {
"TS2339": 1
},
"src/app/api/local/redis/stop/route.ts": {
"TS2339": 1
},
"src/app/api/logs/[id]/route.ts": {
"TS2322": 1
},
"src/app/api/model-capability-overrides/route.ts": {
"TS2339": 1
},
"src/app/api/model-combo-mappings/route.ts": {
"TS2339": 1
},
"src/app/api/models/alias/route.ts": {
"TS2339": 5
},
"src/app/api/models/route.ts": {
"TS2345": 3,
"TS2538": 1
},
"src/app/api/monitoring/health/route.ts": {
"TS2322": 1
},
"src/app/api/oauth/codex/import-token/route.ts": {
"TS2339": 3
},
"src/app/api/oauth/codex/import/route.ts": {
"TS2554": 1,
"TS2353": 1,
"TS2339": 3
},
"src/app/api/oauth/cursor/login/poll/route.ts": {
"TS2554": 1
},
"src/app/api/oauth/kiro/auto-import/route.ts": {
"TS2345": 1
},
"src/app/api/omniroute/route/preview/route.ts": {
"TS2345": 1
},
"src/app/api/playground/presets/[id]/route.ts": {
"TS2339": 3
},
"src/app/api/provider-nodes/validate/route.ts": {
"TS2339": 2
},
"src/app/api/providers/[id]/login/route.ts": {
"TS2739": 1
},
"src/app/api/providers/[id]/models/route.ts": {
"TS2367": 1,
"TS2339": 2,
"TS2322": 2,
"TS2554": 2,
"TS2345": 3
},
"src/app/api/providers/[id]/refresh-cursor/route.ts": {
"TS2352": 1
},
"src/app/api/providers/[id]/refresh/route.ts": {
"TS2345": 1,
"TS2698": 1,
"TS2339": 6
},
"src/app/api/providers/[id]/sync-models/route.ts": {
"TS2345": 1
},
"src/app/api/providers/[id]/test/route.ts": {
"TS2362": 1,
"TS2698": 1
},
"src/app/api/providers/free-onboarding/route.ts": {
"TS2345": 1
},
"src/app/api/providers/health-autopilot/actions/route.ts": {
"TS2339": 1
},
"src/app/api/providers/route.ts": {
"TS2352": 1,
"TS2322": 2,
"TS2345": 3
},
"src/app/api/providers/test-batch/route.ts": {
"TS2345": 4
},
"src/app/api/providers/validate/route.ts": {
"TS2322": 1
},
"src/app/api/providers/volcengine-plan/connect/[sessionId]/cancel/route.ts": {
"TS2739": 1
},
"src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts": {
"TS2739": 1
},
"src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts": {
"TS2739": 1
},
"src/app/api/providers/volcengine-plan/connect/[sessionId]/resend/route.ts": {
"TS2739": 1
},
"src/app/api/providers/volcengine-plan/connect/[sessionId]/status/route.ts": {
"TS2739": 1
},
"src/app/api/providers/volcengine-plan/connect/route.ts": {
"TS2739": 1
},
"src/app/api/radar/local-model-state/route.ts": {
"TS2339": 4
},
"src/app/api/resilience/model-cooldowns/route.ts": {
"TS2339": 1
},
"src/app/api/services/_shared/installRoute.ts": {
"TS2339": 1
},
"src/app/api/settings/cache-config/route.ts": {
"TS2339": 1,
"TS2322": 1
},
"src/app/api/settings/database/route.ts": {
"TS2345": 1
},
"src/app/api/settings/models-dev/route.ts": {
"TS2339": 1
},
"src/app/api/settings/obsidian/webdav/route.ts": {
"TS2339": 1
},
"src/app/api/settings/proxies/bulk-import/route.ts": {
"TS2345": 1
},
"src/app/api/settings/proxy/cloudflare-deploy/route.ts": {
"TS2769": 1,
"TS2322": 2
},
"src/app/api/settings/proxy/deno-deploy/route.ts": {
"TS2322": 4
},
"src/app/api/settings/proxy/vercel-deploy/route.ts": {
"TS2322": 3
},
"src/app/api/settings/reasoning-routing-rules/[id]/route.ts": {
"TS2339": 1
},
"src/app/api/settings/reasoning-routing-rules/route.ts": {
"TS2339": 1
},
"src/app/api/settings/reasoning-routing-rules/simulate/route.ts": {
"TS2322": 1,
"TS2339": 1
},
"src/app/api/system/env/repair/route.ts": {
"TS2578": 1,
"TS2353": 3
},
"src/app/api/system/version/route.ts": {
"TS2769": 1
},
"src/app/api/tools/agent-bridge/agents/[id]/detected-models/route.ts": {
"TS2769": 1
},
"src/app/api/tools/traffic-inspector/internal/ingest/route.ts": {
"TS1117": 2,
"TS2345": 1
},
"src/app/api/tools/traffic-inspector/ws/route.ts": {
"TS2578": 1
},
"src/app/api/translator/send/route.ts": {
"TS2345": 1,
"TS2322": 1,
"TS2339": 1
},
"src/app/api/translator/translate/route.ts": {
"TS2345": 1,
"TS2322": 1
},
"src/app/api/usage/analytics/route.ts": {
"TS2352": 15
},
"src/app/api/usage/combo-health-autopilot/route.ts": {
"TS2769": 2
},
"src/app/api/v1/batches/route.ts": {
"TS2339": 1
},
"src/app/api/v1/classify/route.ts": {
"TS2322": 1
},
"src/app/api/v1/files/[id]/content/route.ts": {
"TS2345": 1
},
"src/app/api/v1/files/route.ts": {
"TS2339": 1
},
"src/app/api/v1/images/edits/route.ts": {
"TS2339": 18,
"TS2322": 4
},
"src/app/api/v1/messages/count_tokens/route.ts": {
"TS2339": 2,
"TS2322": 1
},
"src/app/api/v1/music/generations/route.ts": {
"TS2322": 1,
"TS2345": 1
},
"src/app/api/v1/ocr/route.ts": {
"TS2345": 1
},
"src/app/api/v1/provider-plugin-manifest/route.ts": {
"TS2345": 1
},
"src/app/api/v1/providers/[provider]/embeddings/route.ts": {
"TS2339": 3,
"TS2322": 1
},
"src/app/api/v1/providers/[provider]/images/generations/route.ts": {
"TS2339": 5
},
"src/app/api/v1/rerank/route.ts": {
"TS2339": 2
},
"src/app/api/v1/segment/route.ts": {
"TS2322": 1
},
"src/app/api/v1/session-leases/route.ts": {
"TS2339": 4,
"TS2345": 1
},
"src/app/api/v1/speech-to-text/route.ts": {
"TS2353": 1
},
"src/app/api/v1/text-to-speech/[voiceId]/route.ts": {
"TS2353": 1
},
"src/app/api/v1/web/fetch/route.ts": {
"TS2339": 1
},
"src/app/api/v1beta/models/route.ts": {
"TS2345": 1,
"TS2538": 1
},
"src/app/api/version-manager/restart/route.ts": {
"TS2339": 1
},
"src/app/api/version-manager/start/route.ts": {
"TS2339": 1
},
"src/app/api/version-manager/stop/route.ts": {
"TS2339": 1
},
"src/app/api/webhooks/[id]/route.ts": {
"TS2554": 1
},
"src/app/api/webhooks/[id]/test/route.ts": {
"TS2352": 2
},
"src/app/api/webhooks/route.ts": {
"TS2554": 1,
"TS2345": 1
},
"src/lib/db/tierConfig.ts": {
"TS2345": 2
},
"src/lib/monitoring/comboHealthAutopilot.ts": {
"TS2305": 1,
"TS2345": 1
},
"src/lib/monitoring/providerHealthAutopilot.ts": {
"TS2352": 4
},
"src/lib/omnirouteStatus.ts": {
"TS2322": 1,
"TS2558": 1
},
"src/lib/providerModels/managedModelImport.ts": {
"TS2352": 4
},
"src/lib/proxySubscription/parse.ts": {
"TS2345": 3
},
"src/lib/quota/quotaAnalytics.ts": {
"TS2769": 1
},
"src/lib/quota/quotaResetTimers.ts": {
"TS2769": 2
},
"src/lib/usage/comboForecast.ts": {
"TS2345": 1
},
"src/lib/usage/comboHealth.ts": {
"TS2345": 1
},
"src/lib/usage/comboScoringInspector.ts": {
"TS2352": 1,
"TS2741": 1
},
"src/lib/usage/providerWindowCosts.ts": {
"TS2322": 2,
"TS2558": 5,
"TS2339": 12,
"TS2345": 1
},
"src/lib/vscode/modelPresentation.ts": {
"TS2554": 1
},
"src/lib/ws/handshake.ts": {
"TS2339": 1
},
"src/mitm/detection/index.ts": {
"TS2741": 1
},
"src/mitm/inspector/httpProxyServer.ts": {
"TS2769": 1
},
"src/shared/schemas/cliCatalog.ts": {
"TS2554": 2
}
}

View File

@@ -119,6 +119,7 @@
"react",
"react-dom",
"react-is",
"react18-json-view",
"react-markdown",
"react-reconciler",
"recharts",

File diff suppressed because it is too large Load Diff

View File

@@ -477,11 +477,14 @@
"_rebaseline_2026_08_23_tip_drift_post_batch0823": "Tip drift after the 2026-08-23 merge wave: chatBodyAdmission.ts 1009->1118 (+109, gate count incl. +1) and auth.ts 3337->3344 (+7), both grown by merges already on origin/release/v3.8.50 (verified identical on the pristine tip) — not by the codex-appserver-hardening PR that carries this bump. Owner pre-authorized baseline bumps 2026-08-22.",
"_rebaseline_2026_08_24_11355_cooldown_recovery_guards": "PR #11355 own growth: test/route.ts 1215->1237, +22 (startup crash-recovery guard: clearStaleCrashCooldowns() now parses the persisted rate_limited_until deadline and skips clearing rows still genuinely in the future, instead of clearing every non-terminal cooldown unconditionally). Cohesive fix at the existing test-route dispatch chokepoint alongside the #11141 probe builder. Covered by tests/unit/startup-stale-cooldown-recovery.test.ts + tests/unit/repro-zai-cooldown-cleared-by-connection-test.test.ts.",
"_rebaseline_2026_08_24_video_bridge_fu02_fu07_sampler": "PRs #11344 (FU-02 one-frame scene-aware determinism) + #11381 (FU-07 opt-in segment_aware structural sampling) own growth: videoBridgeRuntime.ts <1000->1009, +9 (sum of both boarded together in the same merge-batch). #11344 adds the deterministic one-frame midpoint fallback + policyEffective=uniform report at the existing scene_aware seam; #11381 adds the bounded local-only FFmpeg structural pre-analysis pass (scene/freeze/blur/exposure/SI-TI) and its budget-reallocation logic. Covered by tests/unit/guardrails/videoBridgeSampler.test.ts, tests/unit/guardrails/videoBridgeFu07StructuralSampling.test.ts, tests/integration/video-bridge-sampler-ffmpeg.test.ts. Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive).",
"open-sse/services/autoCombo/virtualFactory.ts": 1138,
"open-sse/services/autoCombo/virtualFactory.ts": 1145,
"_rebaseline_2026_08_29_9133_candidates_inspector_skip_flag": "#9133 own growth: open-sse/services/autoCombo/virtualFactory.ts 1138->1139 (+1, net of extraction). Fix: prepareVirtualAutoComboInputs gained an opt-in `skip` parameter so the read-only #7819 candidate inspector (open-sse/handlers/autoComboCandidates.ts) can build the FULL, unfiltered pool and decorate a resilience-blocked candidate as reachable:false instead of filterResilienceBlockedCandidates silently dropping the row before the inspector ever sees it (routing is unaffected — it never passes `skip`). The connectionsById map-building loop was extracted to buildConnectionResilienceMap() in resilienceCandidateFilter.ts (net 0 there since Prettier still breaks the call over multiple lines) and the now-unused ConnectionResilienceView import was dropped; the sole remaining growth is the new `skip` default parameter itself, which Prettier always places on its own line once the preceding options object parameter already breaks across lines — not further reducible without splitting prepareVirtualAutoComboInputs's signature away from its own body. Covered by tests/unit/auto-combo-candidates-locked-model-visible.test.ts (TDD repro: red before the fix, green after) plus the existing tests/unit/noauth-autocombo-lockout-7623.test.ts and tests/unit/auto-combo-credentialed-model-pool.test.ts (unaffected routing-path behavior).",
"_rebaseline_2026_08_29_11481_model_exposure_list": "Feature #11481 (explicit model exposure allow/deny list for /v1/models, mirrored into auto/* combo pools) own growth on top of #9133's +1: open-sse/services/autoCombo/virtualFactory.ts 1139->1145 (measured real line count after both #9133 and #11481 merged together = one import line for filterModelExposureCandidates plus the filter-and-reassign block at the existing buildPreparedPool chokepoint, immediately after the filterPaidOnlyCandidates call it mirrors — the exact pattern #6512 already established for hidePaidModels). The actual predicate (isModelExposureAllowed, glob support via the shared globToRegex matcher) lives in the new src/shared/utils/modelExposureList.ts leaf, and the pool-filter wrapper lives in the new open-sse/services/autoCombo/modelExposureFilter.ts leaf (both well under cap) — this file only carries the minimal call-site wiring plus import, not extractable further without hiding the buildPreparedPool filter chain. Covered by tests/unit/autoCombo/model-exposure-filter-11481.test.ts (pure filter, all branches) and tests/unit/model-exposure-list.test.ts (predicate).",
"_rebaseline_2026_08_28_mergebatch_v3851_qwen_retirement": "/merge-batch 2026-08-28 (v3.8.51): #11713 (Qwen Web retirement) own growth: open-sse/services/autoCombo/virtualFactory.ts 1132->1135 (+3, combining the Designer + Runtime retirement-guard filter into the single runtimeConnections predicate at the existing candidate-pool chokepoint, now excluding Qwen Web alongside Felo Web). Irreducible per-mechanism wiring, additive not a new branch. Covered by tests/unit/virtual-auto-combo.test.ts.",
"_rebaseline_2026_08_28_mergebatch_v3851_chatgptweb_retirement": "/merge-batch 2026-08-28 (v3.8.51): #11754 (common ChatGPT Web retirement) own growth: open-sse/services/autoCombo/virtualFactory.ts 1135->1138 (+3, an early `available` connection filter for the retired chatgpt-web/cgpt-web ids applied to both the active and disabled-noauth connection lists, ahead of the existing Designer+Runtime runtimeConnections filter). Irreducible per-mechanism wiring, additive not a new branch. Covered by tests/unit/virtual-auto-combo.test.ts.",
"src/lib/cloudflaredTunnel.ts": 1078,
"src/shared/components/RequestLoggerDetail.tsx": 1018,
"src/shared/components/RequestLoggerDetail.tsx": 1111,
"_rebaseline_2026_08_30_11703_json_tree_viewer": "/merge-batch 2026-08-30 (v3.8.51): #11703 (hartmark) own growth: src/shared/components/RequestLoggerDetail.tsx 1018->1111 (+93). The 2026-07-22 annotation on this same file said 'no further growth without split rationale' — this PR does split: the collapsible-JSON-tree rendering logic itself lives in the sibling RequestLoggerDetail.sections.tsx (PayloadSection/StreamSection extraction, +82 lines there) plus two new leaves (JsonTreeExpandControls.tsx, useTimestampTitles.ts) and a new store (jsonTreeExpandStore.ts) — all well under cap. The +93 remaining here is the irreducible call-site wiring: import + mount JsonTreeExpandControls, wire the per-section expand-level state and timestamp-tooltip hook into the existing detail panel layout. Covered by the PR's own tests/unit/dashboard/payload-section-collapsible-json.test.tsx, timestamp-titles.test.tsx, tests/unit/shared/json-tree-expand-store.test.ts, short-call-id.test.ts (43/43 vitest + 11/11 native pass).",
"src/app/api/providers/[id]/test/route.ts": 1255,
"src/lib/guardrails/videoBridgeRuntime.ts": 1009,
"_rebaseline_2026_08_28_mergebatch_v3851_ratchet_bank_reconcile": "/merge-batch 2026-08-28 (v3.8.51): boarding #11702 (fix/verify-ratchet-bank object-note comparator) surfaced a large stale `frozen`/`testFrozen` snapshot on PR #11702's own branch (forked before the 08-11 banking outage — see the object-valued `_rebaseline_2026_08_11_v3850_merge_storm_provider_registry` note above, the exact bug #11702 fixes in the verifier) — its conflicting block duplicated ~85 already-tracked files with sizes smaller than the current release tip, and still listed open-sse/executors/chatgpt-web.ts (deleted by the #11754 retirement). Resolved by re-measuring every file in the union of both sides directly on the boarded tree (split(\"\\n\").length, matching check-file-size.mjs) rather than trusting either stale snapshot; dropped the dead chatgpt-web.ts entry; kept the two genuinely-new entries PR #11702's branch had that this tip did not yet track (src/app/api/providers/[id]/test/route.ts, src/lib/guardrails/videoBridgeRuntime.ts, both re-measured). Same reconciliation applied to the testFrozen block above.",

View File

@@ -514,7 +514,7 @@ For the full stealth playbook and operational guidance, see
Primary state DB (SQLite):
- Core infra: `src/lib/db/core.ts` (better-sqlite3, migrations, WAL)
- Re-export facade: `src/lib/localDb.ts` (thin compatibility layer for callers)
- DB access: import specific `src/lib/db/*` modules directly (the old `localDb.ts` barrel was removed)
- file: `${DATA_DIR}/storage.sqlite` (or `$XDG_CONFIG_HOME/omniroute/storage.sqlite` when set, else `~/.omniroute/storage.sqlite`)
- entities (tables + KV namespaces): providerConnections, providerNodes, modelAliases, combos, apiKeys, settings, pricing, **customModels**, **proxyConfig**, **ipFilter**, **thinkingBudget**, **systemPrompt**
@@ -888,7 +888,7 @@ flowchart LR
### Persistence
- `src/lib/db/*`: persistent config/state and domain persistence on SQLite
- `src/lib/localDb.ts`: compatibility re-export for DB modules
- `src/lib/db/*`: import specific modules directly — no barrel (the old `localDb.ts` re-export layer was removed)
- `src/lib/usageDb.ts`: usage history/call logs facade on top of SQLite tables
## Provider Executor Coverage (Strategy Pattern)

View File

@@ -313,7 +313,7 @@ table groups the actual directories and notable top-level files.
Top-level files in `src/lib/`:
- `localDb.ts` — re-export layer only. **Never** add logic here.
- The old `localDb.ts` barrel was removed — consumers import specific `src/lib/db/*` modules directly.
- `proxyHealth.ts`, `proxyLogger.ts`, `tokenHealthCheck.ts`, `localHealthCheck.ts`
- `oneproxyRotator.ts`, `oneproxySync.ts`
- `apiBridgeServer.ts`, `cacheLayer.ts`, `semanticCache.ts`, `settingsCache.ts`
@@ -759,7 +759,7 @@ See [RESILIENCE_GUIDE.md](./RESILIENCE_GUIDE.md) and the dedicated section in
2. Export CRUD functions for your domain.
3. If new tables: add a migration under `src/lib/db/migrations/`, numbered
sequentially, idempotent, transactional.
4. Re-export from `src/lib/localDb.ts` (re-export only — **no logic**).
4. Importers use direct imports from `@/lib/db/yourModule` (no barrel — the old `localDb.ts` re-export layer was removed).
5. Add tests under `tests/unit/`.
### Add a new MCP tool
@@ -790,7 +790,7 @@ See [A2A-SERVER.md § Adding a New Skill](../frameworks/A2A-SERVER.md). Skills l
- **TypeScript**: `strict: false` (legacy posture). Prefer explicit types over
inference for cross-module boundaries.
- **Database**: never write raw SQL in routes or handlers — always go through
`src/lib/db/` modules. Never add logic to `src/lib/localDb.ts`.
`src/lib/db/` modules. Never barrel-import — use specific `src/lib/db/*` modules directly.
- **DB-entity typing (#3512)**: a function that writes or reads a DB table's
row shape should take/return a named TS interface mirroring that table's
columns 1:1, not `any` or an inline anonymous type at the call site. Land
@@ -824,7 +824,7 @@ See [A2A-SERVER.md § Adding a New Skill](../frameworks/A2A-SERVER.md). Skills l
## 12. Hard Rules (from CLAUDE.md)
1. Never commit secrets or credentials.
2. Never add logic to `src/lib/localDb.ts`.
2. Never barrel-import — use specific `src/lib/db/*` modules directly.
3. Never use `eval()` / `new Function()` / implied eval.
4. Never commit directly to `main`.
5. Never write raw SQL in routes — always go through `src/lib/db/` modules.

View File

@@ -494,7 +494,7 @@ Radar è opt-in e usa soltanto richieste GET. Il client OmniRoute non carica pro
- **🧠 Memoria sotto il tuo controllo** — disattivata per impostazione predefinita, quantizzazione vettoriale int8 opt-in + decadimento tipizzato, `x-omniroute-no-memory` per-request. → [Memoria](../../frameworks/MEMORY.md)
- **🛡️ Sicurezza** — guard contro la prompt injection su ogni route LLM (suite red-team), guardrail opzionale per il masking delle credenziali (oscura API key/secret trapelati in entrambe le direzioni), web search DuckDuckGo gratuita come ultima risorsa e gate di login OIDC opzionale per la dashboard (il login con password resta sempre disponibile). → [Guardrail](../../security/GUARDRAILS.md)
- **🖼️ Nuovi endpoint** — `/v1/ocr` (Mistral OCR) e `/v1/audio/translations` (stile Whisper) completano la superficie media. → [Riferimento API](../../reference/API_REFERENCE.md)
- **🎨 Generazione immagini / video / audio** — una sola API per i media: xAI Grok Imagine e Novita AI video, ComfyUI, Freepik, Adobe Firefly, Segmind e provider vocali come ElevenLabs. → [Riferimento API](../../reference/API_REFERENCE.md)
- **🎨 Generazione immagini / video / audio** — una sola API per i media: xAI Grok Imagine e Novita AI video, ComfyUI, Magnific, Adobe Firefly, Segmind e provider vocali come ElevenLabs. → [Riferimento API](../../reference/API_REFERENCE.md)
- **🌍 Deployment e operazioni** — `basePath` del reverse proxy, rilevamento automatico della lingua del browser, tracking dei dispositivi per chiave, trust MITM senza root, localizzazione zh-TW. → [Ambiente](../../reference/ENVIRONMENT.md)
- **🤝 Più provider e agenti** — Cursor Cloud Agent, Grok Build (xAI) con login browser + OAuth, scheda Ollama di prima classe, Claude Opus 5 e Sonnet 5, partnership ufficiale Kimi (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… e un catalogo aggiornato di **350 provider**. → [Provider](../../reference/PROVIDER_REFERENCE.md)
- **📡 Trasparenza del routing** — ogni risposta include un header `X-OmniRoute-Decision` con strategia/provider/latenza che l'ha servita; una nuova strategia combo `cache-optimized` + il fattore `cacheAffinity` di Auto-Combo riportano le richieste ripetute alla connessione che possiede il prefisso in cache; un endpoint read-only `/v1/auto-combo/{channel}/candidates` espone il pool di candidati live di un canale `auto/*`. → [Auto-Combo](../../routing/AUTO-COMBO.md)

View File

@@ -548,7 +548,7 @@ Radar isteğe bağlıdır (opt-in) ve yalnızca GET istekleri yapar. OmniRoute i
- **🧠 Kontrol ettiğiniz bellek** — Varsayılan olarak kapalı, isteğe bağlı int8 vektör niceleme + tipli sönümleme, istek başına `x-omniroute-no-memory`. → [Bellek](docs/frameworks/MEMORY.md)
- **🛡️ Güvenlik** — Her LLM rotasında istem enjeksiyonu koruması (red-team paketi), isteğe bağlı kimlik bilgisi maskeleme koruması (her iki yönde de sızan API anahtarlarını/gizli bilgileri sansürler), ücretsiz DuckDuckGo son çare web araması ve pano için isteğe bağlı OIDC giriş kapısı (şifreyle giriş her zaman kullanılabilir kalır). → [Güvenlik Önlemleri (Guardrails)](docs/security/GUARDRAILS.md)
- **🖼️ Yeni uç noktalar** — `/v1/ocr` (Mistral OCR) ve `/v1/audio/translations` (Whisper tarzı) medya yüzeyini tamamlar. → [API Referansı](docs/reference/API_REFERENCE.md)
- **🎨 Görsel / video / ses üretimi** — Medya için tek bir API: xAI Grok Imagine ve Novita AI video, ComfyUI, Freepik, Adobe Firefly, Segmind ve ElevenLabs gibi konuşma sağlayıcıları. → [API Referansı](docs/reference/API_REFERENCE.md)
- **🎨 Görsel / video / ses üretimi** — Medya için tek bir API: xAI Grok Imagine ve Novita AI video, ComfyUI, Magnific, Adobe Firefly, Segmind ve ElevenLabs gibi konuşma sağlayıcıları. → [API Referansı](docs/reference/API_REFERENCE.md)
- **🌍 Dağıtım ve operasyonlar** — Ters proxy `basePath`, tarayıcı dili otomatik algılama, anahtar başına cihaz takibi, root gerektirmeyen MITM güveni, zh-TW yerelleştirmesi. → [Ortam Değişkenleri](docs/reference/ENVIRONMENT.md)
- **🤝 Daha fazla sağlayıcı ve ajan** — Cursor Cloud Agent, tarayıcı + OAuth girişiyle Grok Build (xAI), Ollama birinci sınıf kartı, Claude Opus 5 ve Sonnet 5, Kimi resmi ortaklığı (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… ve yenilenen **350 sağlayıcılı katalog**. → [Sağlayıcılar](docs/reference/PROVIDER_REFERENCE.md)
- **📡 Yönlendirme şeffaflığı** — Her yanıt, isteğe hizmet veren stratejiyi/sağlayıcıyı/gecikmeyi belirten bir `X-OmniRoute-Decision` başlığı taşır, yeni bir `cache-optimized` kombo stratejisi + Auto-Combo `cacheAffinity` faktörü yinelenen istekleri önbelleğe alınmış öneki tutan bağlantıya geri yönlendirir ve salt okunur bir `/v1/auto-combo/{channel}/candidates` uç noktası bir `auto/*` kanalının canlı aday havuzunu gösterir. → [Auto-Combo](docs/routing/AUTO-COMBO.md)

View File

@@ -158,7 +158,7 @@ status. CI performs the broader package artifact and ecosystem checks.
**Contracts**
- Domain modules under `src/lib/db/`; `src/lib/localDb.ts` remains a re-export layer only.
- Domain modules under `src/lib/db/`; import specific modules directly (the old `localDb.ts` re-export layer was removed).
- Numbered, idempotent SQL migrations under `src/lib/db/migrations/`, transaction safety, upgrade
behavior, indexes, and every caller affected by the schema.
- Routes and handlers never issue raw SQL directly.

View File

@@ -7,13 +7,13 @@ title: Self-Hosted Runner Box Operations
The self-hosted pool (`self-hosted, omni-release` on all eight runners; `omni-build` on two) runs on the **.113** box.
Measured 2026-08-28 (v3.8.50 postmortem, Parte III):
| resource | value | what it means for scheduling |
| --------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| RAM / CPU | **31 GB / 32 cores** (was 16 GB when this doc was first written) | one `next-build` peaks at **~14 GB** → 2 concurrent heavy builds saturate the box, 3 take it down (2026-08-28 06:42Z: load 56, two jobs lost) |
| swap | 15 GB | it swapped its way through the v3.8.50 publish; pressure shows in `/proc/pressure/memory` |
| `/tmp` | **12 GB tmpfs = RAM** | anything parked there is memory; leftovers are swept after 3 h |
| disk | 188 GB | `_work` checkouts of 8 runners reach ~70 GB with no cap |
| runners | **6 listeners**: 4 OmniRoute (2 `omni-build` + 2 `omni-light`) + OmniHeuris + OmniMind | all share the memory above; `omniroute-113-3/-4/-7/-8` are disabled (`systemctl enable --now` brings one back) |
| resource | value | what it means for scheduling |
| --------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| RAM / CPU | **31 GB / 32 cores** (was 16 GB when this doc was first written) | one `next-build` peaks at **~14 GB** → 2 concurrent heavy builds saturate the box, 3 take it down (2026-08-28 06:42Z: load 56, two jobs lost) |
| swap | 15 GB | it swapped its way through the v3.8.50 publish; pressure shows in `/proc/pressure/memory` |
| `/tmp` | **12 GB tmpfs = RAM** | anything parked there is memory; leftovers are swept after 3 h |
| disk | 188 GB | `_work` checkouts of 8 runners reach ~70 GB with no cap |
| runners | **6 listeners**: 4 OmniRoute (1 `omni-build` + 1 `omni-release`-only + 2 `omni-light`) + OmniHeuris + OmniMind | all share the memory above; `omniroute-113-3/-4/-7/-8` are disabled (`systemctl enable --now` brings one back) |
## Install the janitor (one-time, on the box)
@@ -21,7 +21,7 @@ Measured 2026-08-28 (v3.8.50 postmortem, Parte III):
scp scripts/ops/runner-janitor.sh root@192.168.0.113:/opt/omniroute-ops/runner-janitor.sh
ssh root@192.168.0.113 'chmod +x /opt/omniroute-ops/runner-janitor.sh; apt-get install -y lsof'
# cron (root): every 30 min, log to /var/log/runner-janitor.log
*/30 * * * * MAX_ACTIVE_RUNNERS=4 /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1
*/30 * * * * MAX_ACTIVE_RUNNERS=6 /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1
```
`lsof` is required: the janitor proves a path is idle with one snapshot of open
@@ -49,14 +49,28 @@ a time, only when idle**, with the idle check and the restart in the same comman
## Operating rules
- **Heavy-build ceiling: 2 at a time — enforced by label.** Every job that runs a
`next build` (`ci.yml` `build`, `npm-publish.yml` `publish`, both `nightly-release-green`
validations) targets `[self-hosted, omni-build]`, and only **two** runners carry that
label (`omniroute-113-5`, `omniroute-113-6`, added through the runners API — no
re-registration). The other six keep `omni-release` and take nothing heavy; GitHub
queues a third build instead of the kernel killing one. Pair with the `heavy-build-*`
concurrency lanes in `ci.yml`. To add capacity, label another runner — never raise
the count past what 31 GB holds (one next-build ≈ 1416 GB).
- **Heavy-build ceiling: ONE at a time — enforced by label (since 2026-08-29).** Every job
that runs a full `next build` targets `[self-hosted, omni-build]`, and only
**`omniroute-113-5`** carries that label (added through the runners API — no
re-registration): `ci.yml` `Build`, `npm-publish.yml` `publish`, both
`nightly-release-green` validations, and `docker-publish.yml` **amd64** (hosted
7 GB ResourceExhausted this tree — #11976). The arm64 Docker leg stays on
`ubuntu-24.04-arm` (no ARM box) with webpack. Docker amd64 also uses webpack:
Turbopack on this tree panicked inside BuildKit (`TurbopackInternalError:
there must be a path to a root`, run 33253576569) even with 31 GB; the same
tree's arm64 webpack build on hosted ARM succeeded. `docker-publish` amd64
shares the `heavy-build-main` concurrency group with `ci.yml` `Build`
(`cancel-in-progress: false`) so it queues on the one slot. Docker Engine
must be on `omniroute-113-5` (`docker info` is the first step of the publish
job). Two was the previous ceiling and it was wrong for 31 GB: on
2026-08-29 17:26 UTC two concurrent `next-build`s (15.4 GB + 17.2 GB RSS) drove the box
to 5 GB free with 4 GB of swap in use and the kernel OOM-killed one of them — systemd
booked the kill on the _other_ runner's unit, `runsvc.sh` SIGKILLed that listener, and
the job on it died with "The runner has received a shutdown signal" (same text as a
hosted-runner OOM). `omniroute-113-6` keeps `omni-release` only. Heavy builds from
`main` merges, PRs and the nightly now serialize on one slot; the queue is the price.
The second slot comes back the day the Proxmox VM gets more RAM (4864 GB):
`gh api -X POST repos/<repo>/actions/runners/<id of omniroute-113-6>/labels -f 'labels[]=omni-build'`.
- **Light pool: `omni-light` (2026-08-29, #11965).** `omniroute-113` and `omniroute-113-2` carry
`omni-light` for jobs that need a backend-only `next build` (~56 GB) but not a full one: the
nightly Schemathesis, promptfoo, garak and axe-a11y jobs. They ran on the hosted 7 GB runner and
@@ -65,7 +79,8 @@ a time, only when idle**, with the idle check and the restart in the same comman
on the Proxmox VM (`tomni-proxmox-113`), which turns the label ceilings into 3 heavy + 2 light.
- **Fewer listeners on purpose.** Four OmniRoute units were disabled on 2026-08-29 — with only
`ci.yml` `Build` and the nightlies using the box, 8 listeners were idle and each extra one is a
potential 14 GB tenant. The janitor ceiling is 4 (`MAX_ACTIVE_RUNNERS=4` in cron).
potential 14 GB tenant. The janitor ceiling is 6 (`MAX_ACTIVE_RUNNERS=6` in cron): it counts
every `Runner.Listener` on the box, and OmniHeuris + OmniMind add two to our four.
- **Never clean `/tmp` or `_work` by hand while any runner is busy.** A
check-then-delete with a gap between the two is how a live Build job lost its
`_work` on 2026-08-27. The janitor does the check and the removal in one step;

View File

@@ -0,0 +1,66 @@
---
title: "Model Exposure Allow/Deny List"
version: 3.8.51
lastUpdated: 2026-08-29
---
# Model Exposure Allow/Deny List
> Opt-in, off by default (`modelVisibilityAllowlist`/`modelVisibilityDenylist` both empty leaves
> the `/v1/models` catalog AND every `auto/*` candidate pool byte-identical). A sibling of
> `hidePaidModels`/`hideAutoCombos` (`src/lib/db/settings.ts`) for operators who want a curated
> model list for reasons that have nothing to do with cost.
## Why this exists
`hidePaidModels` answers "is this model free?" and `hideAutoCombos` answers "should `auto/*`
virtual ids be advertised at all?" — neither lets an operator curate an arbitrary subset of
models (e.g. presenting exactly the models a given Claude Code / OpenCode client should see,
independent of pricing). #11481 adds that as two independent, opt-in string-array settings.
## Settings
| Key | Type | Default | Meaning |
| ----------------------------- | ---------- | ------- | ----------------------------------------------------------------- |
| `modelVisibilityDenylist` | `string[]` | `[]` | Entries matching a candidate hide it from the catalog/candidate pool. |
| `modelVisibilityAllowlist` | `string[]` | `[]` | When non-empty, ONLY entries matching a candidate stay exposed. |
Both accept up to 500 entries of up to 200 characters each (Zod-validated in
`src/shared/validation/settingsSchemas.ts`). An entry is either:
- an exact catalog id — `"gpt-4o"` (bare model id) or `"openai/gpt-4o"` (provider-prefixed), or
- a glob pattern using `*`/`?` — e.g. `"openai/gpt-4*"` or `"anthropic/*"` — resolved via the
same shared `globToRegex()` matcher (`src/shared/utils/globPattern.ts`) already used by
`ModelRoutingSection`'s per-model combo mappings and `freeModels.ts::matchesOnlyPaidModels`.
Precedence: the denylist is checked first (a denied entry is always hidden, even if it also
matches the allowlist); when the allowlist is non-empty, only entries it matches survive.
## Two chokepoints, not one
The lesson from #6512 (a `hidePaidModels`-only catalog filter still let `auto/*` route to a
paid model, since the combo candidate pool was built independently) applies identically here.
The matching predicate `isModelExposureAllowed()` (`src/shared/utils/modelExposureList.ts`) is
called from BOTH:
- `src/app/api/v1/models/catalog.ts` — the `/v1/models` listing itself, at the same 5 per-source
chokepoints `shouldHidePaid()` already gates (static `PROVIDER_MODELS`, synced provider rows,
custom rows, alias-backed rows, managed-fallback rows).
- `open-sse/services/autoCombo/modelExposureFilter.ts::filterModelExposureCandidates()` — called
from `virtualFactory.ts::buildPreparedPool`, immediately after the equivalent
`filterPaidOnlyCandidates()` call, so a denied model can never be selected into an `auto/*`
candidate pool either.
## What is NOT filtered
Mirrors `hideAutoCombos`'s existing behaviour: a model id sent **explicitly** (not via `auto/*`,
and not discovered through the catalog listing) is never blocked at dispatch — only
advertisement/candidate-pool membership is filtered. This is independent of `hidePaidModels`;
an operator may want a curated set for reasons that have nothing to do with cost, so both
settings compose as independent AND-ed filters, same as the existing multi-flag composition in
`catalog.ts`.
Settings export (`GET /api/settings/export-json`) includes both arrays verbatim, like any other
settings field — unlike `hidePaidModels`'s combo-step export filter, there is no re-hydration
risk here: a denied id embedded in an exported combo step is the operator's own explicit routing
choice, not something the export boundary needs to strip.

View File

@@ -1,13 +1,14 @@
---
title: "Guardrails"
version: 3.8.50
lastUpdated: 2026-08-24
version: 3.8.51
lastUpdated: 2026-08-29
---
# Guardrails
> **Source of truth:** `src/lib/guardrails/`
> **Last updated:** 2026-08-24 — v3.8.50 (Video Bridge visual dedup hardening + focused captions)
> **Last updated:** 2026-08-29 — v3.8.51 (Video Bridge transcript provenance is caller-declared,
> not yet server-verified — clarified per #11661)
Guardrails enforce safety, policy, and content transformations at the boundary
between OmniRoute and upstream providers. Each guardrail can inspect (and
@@ -206,8 +207,12 @@ limits, runtime
counters, and a guarded sample request. The Audio tab is also live: it exposes
enablement, an STT-only model picker with Auto, timeout/max-clip limits, audio
counters, and an `input_audio` sample test. The Video tab is functional: it reports
the FFmpeg/ffprobe runtime state, persists enable/model/frame/video/timeout limits,
filters the model picker to vision-capable models, and exposes video counters.
the FFmpeg/ffprobe runtime state — one of four explicit UI states (`unknown` while
the probe is in flight or could not complete, `restricted` on a non-loopback
dashboard host where the probe is skipped client-side, `unavailable` once probed
and confirmed missing, or `available` with the FFmpeg/ffprobe versions) — persists
enable/model/frame/video/timeout limits, filters the model picker to vision-capable
models, and exposes video counters.
The former Vision Bridge card under AI settings is a compatibility link to the
new page; it no longer owns a second copy of the form. Media Providers also
@@ -287,7 +292,7 @@ Runtime settings are DB-backed and Zod-validated:
The shared cache remains controlled by `modalityBridgeCacheEnabled`,
`modalityBridgeCacheTtlMinutes`, and `modalityBridgeCacheMaxEntries`.
### Video Bridge (`videoBridge.ts`)
### Video Bridge (`videoBridge.ts`, `videoBridgePipeline.ts`)
Intercepts top-level video parts in Chat Completions `messages` and Responses
API `input` before a target without known native video support is called.
@@ -295,6 +300,19 @@ Supported shapes are `input_video`, `video_url`, `video_source`, HTTPS URLs,
and `data:video/*;base64,...` data URIs. Plain filenames in text are not treated
as video.
`VideoBridgeGuardrail.preCall` (`videoBridge.ts`) owns request traversal, the
capability/policy check, per-request aggregation, and the response payload.
Per-video work — acquisition, the whole-result cache, describing a frame
sequence (which fuses any caller-declared audio transcript), and per-attempt
metrics/abort/cleanup — is hidden behind `processVideoPart` in
`videoBridgePipeline.ts`, called once per video part inside `preCall`'s loop.
That module also defines the explicit port boundaries `VideoMediaBrokerPort`
(acquiring bytes and extracting sampled frames), `VideoAudioTranscriptionPort`
(fusing a caller-declared audio transcript with the sampled captions), and
`VideoDrilldownPort` (the frame drill-down persistence boundary; not yet wired
into `processVideoPart` — only the separate `/api/modality-bridge/video/drilldown`
route writes drill-down entries today).
The public `/v1` request path never imports or invokes a subprocess. Remote
videos are downloaded under a 50 MiB bound; inline base64 videos have a
conservative 36 MiB decoded per-video cap so the model/messages/framing envelope
@@ -437,7 +455,12 @@ OmniRoute never starts transcription from this metadata: validated cues are
copied into the described result with source, confidence, and interval, and
are rendered as untrusted observations alongside the frame captions. Invalid,
out-of-range, or provenance-free text is rejected rather than mixed into the
caption stream.
caption stream. The `source` field is presently caller-declared, not
server-verified: OmniRoute enforces that the value is one of the three
allowed strings, but does not yet cryptographically confirm that an
`embedded` or `audio-bridge` label actually came from a server-owned
extraction. Treat `source` as an untrusted hint until that verification
lands; do not build authorization decisions on it.
An advanced caller may provide an already-authorized `audioTranscript` track
for the same video. The fusion seam runs visual and audio observations under

View File

@@ -297,6 +297,45 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@electron/windows-sign": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
"integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
"fs-extra": "^11.1.1",
"minimist": "^1.2.8",
"postject": "^1.0.0-alpha.6"
},
"bin": {
"electron-windows-sign": "bin/electron-windows-sign.js"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@electron/windows-sign/node_modules/fs-extra": {
"version": "11.4.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz",
"integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -1091,6 +1130,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-dirname": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1411,6 +1459,19 @@
"node": ">=14.0.0"
}
},
"node_modules/electron-builder-squirrel-windows": {
"version": "26.15.3",
"resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz",
"integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.15.3",
"builder-util": "26.15.3",
"electron-winstaller": "5.4.0"
}
},
"node_modules/electron-publish": {
"version": "26.15.3",
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz",
@@ -1445,6 +1506,66 @@
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-winstaller": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
"integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
"fs-extra": "^7.0.1",
"lodash": "^4.17.21",
"temp": "^0.9.0"
},
"engines": {
"node": ">=8.0.0"
},
"optionalDependencies": {
"@electron/windows-sign": "^1.1.2"
}
},
"node_modules/electron-winstaller/node_modules/fs-extra": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/electron-winstaller/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"license": "MIT",
"peer": true,
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/electron-winstaller/node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -2359,6 +2480,20 @@
"node": ">= 18"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2622,6 +2757,36 @@
"node": ">=18"
}
},
"node_modules/postject": {
"version": "1.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
"integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
"bin": {
"postject": "dist/cli.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/postject/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/proc-log": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
@@ -2816,6 +2981,21 @@
"node": ">= 4"
}
},
"node_modules/rimraf": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
@@ -3071,6 +3251,21 @@
"node": ">=18"
}
},
"node_modules/temp": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/temp-file": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz",

View File

@@ -329,11 +329,12 @@ const nextConfig = {
// TODO: Re-enable after fixing all sub-component useTranslations scope issues
ignoreBuildErrors: true,
},
webpack(config, { webpack }) {
webpack(config, { dev, webpack }) {
config.ignoreWarnings = [
...(config.ignoreWarnings || []),
isNextIntlExtractorDynamicImportWarning,
];
const nextDefaultSplitChunks = config.optimization?.splitChunks;
config.optimization = config.optimization || {};
config.optimization.splitChunks = {
...config.optimization.splitChunks,
@@ -392,6 +393,9 @@ const nextConfig = {
},
},
};
// Next's development defaults are tuned for incremental route compilation.
// Retain the custom vendor topology for production without imposing it on dev.
if (dev) config.optimization.splitChunks = nextDefaultSplitChunks;
if (isMinimalBuild) {
// Mirror the turbopack.resolveAlias entries for webpack-built artifacts.

View File

@@ -16,7 +16,7 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts";
* rewrites file timestamps on every deploy, which would report a months-old
* catalog as "updated today". Bump this whenever the entries below change.
*/
export const FREE_CATALOG_CURATED_AT = "2026-08-26";
export const FREE_CATALOG_CURATED_AT = "2026-08-30";
export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
@@ -268,7 +268,7 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "muse-spark-web", modelId: "muse-spark-contemplating", displayName: "Muse Spark Contemplating", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "muse-spark-web", tos: "avoid" },
{ provider: "nebius", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "Llama 3.3 70B Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "nebius", tos: "caution" },
{ provider: "nlpcloud", modelId: "llama-3-8b-instruct", displayName: "Llama 3 8B", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-monthly", poolKey: "nlpcloud", tos: "avoid" },
{ provider: "nous-research", modelId: "Hermes-4-405B", displayName: "Hermes 4 7B (Nous Research)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-credit", poolKey: "nous-research", tos: "ambiguous" },
{ provider: "nous-research", modelId: "Hermes-4-405B", displayName: "Hermes 4 405B (Nous Research)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-credit", poolKey: "nous-research", tos: "ambiguous" },
{ provider: "nous-research", modelId: "Hermes-4-70B", displayName: "Hermes 4 70B (Nous Research)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-credit", poolKey: "nous-research", tos: "ambiguous" },
{ provider: "novita", modelId: "ai-ai/llama-3.1-8b-instruct", displayName: "Llama 3.1 8B", monthlyTokens: 0, creditTokens: 500000, freeType: "one-time-initial", poolKey: "novita", tos: "caution" },
{ provider: "nscale", modelId: "moonshotai/Kimi-K2.5", displayName: "moonshotai/Kimi-K2.5", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" },
@@ -312,6 +312,7 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "opencode-zen", modelId: "opencode/nemotron-3-ultra-free", displayName: "Nemotron 3 Ultra (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "opencode-zen-free", tos: "caution" },
{ provider: "openrouter", modelId: "auto", displayName: "Auto (Best Available)", monthlyTokens: 1200000, creditTokens: 0, freeType: "recurring-daily", poolKey: "openrouter-free", tos: "caution" },
{ provider: "openrouter", modelId: "stealth/ox-alpha", displayName: "Stealth Ox Alpha (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "openrouter-free", tos: "caution" },
{ provider: "openrouter", modelId: "liquid/lfm-2.5-2.6b:free", displayName: "LiquidAI: LFM2.5-2.6B (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "openrouter-free", tos: "caution" },
{ provider: "pollinations", modelId: "openai", displayName: "OpenAI (Pollinations)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "pollinations", tos: "caution" },
{ provider: "pollinations", modelId: "openai-fast", displayName: "OpenAI Fast (Pollinations)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "pollinations", tos: "caution" },
{ provider: "pollinations", modelId: "openai-large", displayName: "OpenAI Large (Pollinations)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "pollinations", tos: "caution" },

View File

@@ -9,7 +9,7 @@ export const nous_researchProvider: RegistryEntry = {
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "Hermes-4-405B", name: "Hermes 4 7B (Nous Research)" },
{ id: "Hermes-4-405B", name: "Hermes 4 405B (Nous Research)" },
{ id: "Hermes-4-70B", name: "Hermes 4 70B (Nous Research)" },
],
};

View File

@@ -1478,6 +1478,11 @@ export class CodexExecutor extends BaseExecutor {
"client_metadata",
// GPT-5 output verbosity ({ verbosity } — normalized above by normalizeCodexVerbosity).
"text",
// Responses Lite (#7171/#7821/#11707): enforceCodexResponsesLiteParallelToolCalls()
// forces this field on the translated (non-_nativeCodexPassthrough) path too — it
// must survive this allowlist filter or upstream rejects with "X-OpenAI-Internal-
// Codex-Responses-Lite requires `parallel_tool_calls` to be false."
"parallel_tool_calls",
// Internal markers used by OmniRoute pipeline
"_omnirouteResponsesStore",
]);

View File

@@ -2,11 +2,13 @@
* #7819 (Level 1) — read-only candidate pool + reachability listing for an
* `auto/*` channel.
*
* Builds the SAME candidate pool `virtualFactory.createVirtualAutoCombo` uses
* for routing (via `createBuiltinAutoCombo`, unfiltered by any per-key
* exclusion so the operator can see — and toggle — excluded candidates), then
* decorates each candidate with live reachability derived from the existing
* resilience reads (CLAUDE.md "Resilience Runtime State"):
* Builds its OWN candidate pool via `prepareVirtualAutoComboInputs({}, true)`
* (#9133, the `skip` opt-out) — the full, unfiltered pool that routing would
* otherwise narrow with `filterResilienceBlockedCandidates` before ever
* handing it to this endpoint. Not filtered by any per-key exclusion either,
* so the operator can see — and toggle — excluded candidates. Every
* candidate is then decorated with live reachability derived from the
* existing resilience reads (CLAUDE.md "Resilience Runtime State"):
* - provider circuit breaker: `getCircuitBreaker(provider).getStatus()` /
* `.canExecute()` — NEVER raw `state`, so an expired breaker (lazy
* recovery) doesn't show as permanently open.
@@ -14,13 +16,31 @@
* provider_connections row (no-auth synthetic connections have no row —
* treated as always reachable on this axis).
* - model lockout: `isModelLocked(provider, connectionId, model)`.
*
* A candidate the routing path would currently skip (model-locked, cooled
* down, breaker open) is never dropped from this listing — it is surfaced
* with `reachable:false` and the specific reason field (`modelLocked` /
* `connectionCooldown` / `breakerState`) set, since the panel's whole
* purpose is read-only transparency, not a preview of the dispatch pool.
* The actual routing path (`createVirtualAutoCombo` /
* `createBuiltinAutoCombo` called without a `prepared` override) is
* unchanged and keeps excluding these candidates before dispatch.
*/
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
import { getCircuitBreaker } from "@/shared/utils/circuitBreaker";
import { isModelLocked } from "@omniroute/open-sse/services/accountFallback.ts";
import { parseModel } from "@omniroute/open-sse/services/model.ts";
import { getProviderConnectionById } from "@/lib/db/providers";
import { getExcludedConnectionIds } from "@/lib/db/autoCandidateOverrides";
/**
* One row of the unfiltered, reason-annotated candidate pool (#9133): every
* eligible provider/model/account combination the routing pool would
* otherwise contain, including rows routing itself would currently skip.
* `reachable:false` plus the relevant reason field (`modelLocked` /
* `connectionCooldown` / `breakerState`) is how a blocked candidate is
* represented here — it is never simply absent from `candidates`.
*/
export interface AutoComboCandidateView {
provider: string;
connectionId: string;
@@ -69,7 +89,17 @@ async function decorateCandidate(candidate: {
}
}
const modelLocked = isModelLocked(candidate.provider, candidate.connectionId, candidate.model);
// #9133 (latent alignment): every lock writer (accountFallback.ts) and the
// routing-side filter (resilienceCandidateFilter.ts, combo.ts) key
// `isModelLocked` off the BARE model id, never the "provider/model"
// `modelStr`. `candidate.model` here is the prefixed form (it comes from
// the materialized combo's `models[].model`, not the raw candidate pool),
// so it must be parsed back to bare before the lock lookup — currently
// masked (both spellings map to the same quota-family key for every
// provider exercised so far) but not guaranteed for a provider whose lock
// key isn't family-scoped.
const bareModel = parseModel(candidate.modelStr).model ?? candidate.model;
const modelLocked = isModelLocked(candidate.provider, candidate.connectionId, bareModel);
return {
provider: candidate.provider,
@@ -99,15 +129,26 @@ export async function getAutoComboCandidates(
// virtualFactory — createBuiltinAutoCombo() only recognizes `auto/<suffix>`
// ids (matches classifyAutoModel()'s special-casing of the literal "auto"
// model string in src/sse/handlers/autoRouting.ts).
// #9133 — this endpoint's stated role is read-only transparency, so it must
// build its OWN unfiltered pool (`prepareVirtualAutoComboInputs({}, true)`,
// the `skip` opt-out) rather than reuse the routing path's pool. Routing
// legitimately drops resilience-blocked candidates before dispatch; this
// inspector must not, or a model-locked/cooled-down row silently vanishes
// instead of showing up as `reachable:false` with a reason. The routing
// path itself is unchanged — it keeps calling
// `prepareVirtualAutoComboInputs()`/`createVirtualAutoCombo()` with the
// default (filtered) behavior.
const { prepareVirtualAutoComboInputs, createVirtualAutoComboFromPrepared } =
await import("@omniroute/open-sse/services/autoCombo/virtualFactory.ts");
const prepared = await prepareVirtualAutoComboInputs({}, true);
let virtualCombo;
if (channel === "auto") {
const { createVirtualAutoCombo } =
await import("@omniroute/open-sse/services/autoCombo/virtualFactory.ts");
virtualCombo = await createVirtualAutoCombo(undefined);
virtualCombo = await createVirtualAutoComboFromPrepared(prepared, undefined);
} else {
const { createBuiltinAutoCombo } =
await import("@omniroute/open-sse/services/autoCombo/builtinCatalog.ts");
virtualCombo = await createBuiltinAutoCombo(modelStr, channel);
virtualCombo = await createBuiltinAutoCombo(modelStr, channel, prepared);
}
const excludedConnectionIds = apiKeyId

View File

@@ -1427,7 +1427,7 @@ export async function handleChatCore({
};
if ((isCombo && comboName) || routingComboId) {
try {
const { getComboByName } = await import("../../src/lib/localDb");
const { getComboByName } = await import("@/lib/db/combos");
let comboConfig = await getComboByName(comboName);
if (!comboConfig && comboName?.startsWith("combo/")) {
comboConfig = await getComboByName(comboName.substring(6));
@@ -1918,7 +1918,7 @@ export async function handleChatCore({
if (isCombo && comboName) {
log?.info?.("CONTEXT", `Attempting to resolve combo limits for comboName=${comboName}`);
try {
const { getComboByName } = await import("../../src/lib/localDb");
const { getComboByName } = await import("@/lib/db/combos");
const { resolveComboTargets } = await import("../services/combo.ts");
let comboConfig = await getComboByName(comboName);
if (!comboConfig && comboName.startsWith("combo/")) {
@@ -4292,13 +4292,14 @@ export async function handleChatCore({
let quotaCooldownMs = kimiRateLimitResetAt
? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0)
: retryAfterMs || COOLDOWN_MS.rateLimit;
const deferAntigravityQuotaStateToCaller =
shouldDeferAntigravityQuotaStateToCaller(
provider,
typeof onStreamFailure === "function"
);
const isAntigravityQuotaFamily =
shouldDeferAntigravityQuotaStateToCaller(provider, true);
const deferAntigravityQuotaStateToCaller = shouldDeferAntigravityQuotaStateToCaller(
provider,
typeof onStreamFailure === "function"
);
const isAntigravityQuotaFamily = shouldDeferAntigravityQuotaStateToCaller(
provider,
true
);
let coreOwnedAntigravityLockout: {
cooldownMs: number;
failureCount: number;

View File

@@ -1,5 +1,4 @@
import { getUpstreamProxyConfig } from "@/lib/localDb";
import type { FallbackBackend } from "@/lib/db/upstreamProxy";
import { FallbackBackend, getUpstreamProxyConfig } from "@/lib/db/upstreamProxy";
/**
* Module-level cache for upstream proxy config (shared across all requests).
@@ -29,7 +28,8 @@ const COMBOS_CACHE_TTL = 10_000;
export async function getCombosCached(): Promise<unknown[]> {
const now = Date.now();
const { getCombos, getCombosCacheVersion } = await import("@/lib/localDb");
const { getCombos } = await import("@/lib/db/combos");
const { getCombosCacheVersion } = await import("@/lib/db/readCache");
const version = getCombosCacheVersion();
// A combo write (create/update/delete/reorder) bumps the shared version via
// invalidateDbCache("combos"); when it no longer matches our snapshot we drop

View File

@@ -119,20 +119,21 @@ interface KieImageOptions {
// ideogram/v3-reframe has no dedicated docs.kie.ai page as of this sweep
// (its 3 siblings above are all direct id matches, so it is assumed
// correct by pattern, not independently confirmed).
// Two catalog entries remain UNRESOLVED after this sweep and are
// deliberately left untouched pending a follow-up (see #11296 discussion):
// One catalog entry remains UNRESOLVED after this sweep and is deliberately
// left untouched pending a follow-up (see #11296 discussion):
// - z-image/4.0-text-to-image and z-image/4.5-text-to-image: the only
// documented Z-Image Market page (docs.kie.ai/market/z-image/z-image)
// shows a single fixed `model` enum value `"z-image"` with no
// version-specific id or "version" input field found — unclear whether
// both catalog ids should collapse to the same upstream call.
// - flux/kontext: no `docs.kie.ai/market/flux2/kontext` (or similar)
// Market page exists; Flux Kontext is documented under the separate
// `/flux-kontext-api/*` docs tree with its own endpoint
// (`POST /api/v1/flux/kontext/generate`, models `flux-kontext-pro`/
// `flux-kontext-max`), not the Market `createTask` flow this map feeds.
// This entry may be miscatalogued as `isMarket: true` and need a
// dedicated reroute rather than an id rewrite.
// flux/kontext is RESOLVED (#11296): it is catalogued with `isMarket: true`
// but has no `docs.kie.ai/market/flux2/kontext` (or similar) Market page —
// Flux Kontext is documented under the separate `/flux-kontext-api/*` docs
// tree with its own endpoint (`POST /api/v1/flux/kontext/generate`, poll
// `GET /api/v1/flux/kontext/record-info`, models `flux-kontext-pro`/
// `flux-kontext-max`), not the Market `createTask` flow this map feeds. It is
// NOT in KIE_MARKET_UPSTREAM_MODEL_IDS below on purpose — handleKieImageGeneration
// reroutes it to the dedicated endpoint instead of rewriting its id.
export const KIE_MARKET_UPSTREAM_MODEL_IDS: ReadonlyMap<string, string> = new Map([
["google-imagen/nano-banana", "google/nano-banana"],
["google-imagen/nano-banana-2", "nano-banana-2"],
@@ -815,13 +816,29 @@ async function handleKieImageGeneration({
// Check if model is a Market model (unified API)
const fullRegistry = getImageProvider(provider);
const modelEntry = fullRegistry?.models?.find((m) => m.id === model);
const isMarket = modelEntry?.isMarket || model.includes("/");
// #11296 — flux/kontext is catalogued with `isMarket: true`, but KIE does not
// expose it through the Market catalog at all: it lives under a dedicated API
// tree (POST /api/v1/flux/kontext/generate, poll .../flux/kontext/record-info)
// that rejects the Market createTask flow with "model name not supported". Route
// it there instead of treating it as a Market entry (see KIE_MARKET_UPSTREAM_MODEL_IDS
// comment above for the same finding).
const isFluxKontext = model === "flux/kontext";
const isMarket = !isFluxKontext && (modelEntry?.isMarket || model.includes("/"));
const { imageUrl } = extractImageInputs(body);
let baseUrl = "";
let payload: Record<string, unknown> = {};
if (isMarket) {
if (isFluxKontext) {
// Dedicated Flux Kontext API endpoint (not part of the Market catalog).
baseUrl = `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/flux/kontext/generate`;
payload = {
prompt,
aspectRatio: mapImageSize(size),
model: "flux-kontext-pro",
...(imageUrl ? { inputImage: imageUrl } : {}),
};
} else if (isMarket) {
// Unified Market API endpoint
baseUrl = `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/createTask`;
const input: Record<string, unknown> = {
@@ -853,13 +870,18 @@ async function handleKieImageGeneration({
const promptPreview = String(body.prompt ?? "").slice(0, 60);
log.info(
"IMAGE",
`${provider}/${model} (${isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."`
`${provider}/${model} (${isFluxKontext ? "flux-kontext" : isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."`
);
}
try {
const endpoint = isMarket ? "/api/v1/jobs/createTask" : new URL(baseUrl).pathname;
const createBaseUrl = isMarket ? providerConfig.baseUrl : baseUrl.replace(endpoint, "");
const endpoint = isFluxKontext
? "/api/v1/flux/kontext/generate"
: isMarket
? "/api/v1/jobs/createTask"
: new URL(baseUrl).pathname;
const createBaseUrl =
isFluxKontext || isMarket ? providerConfig.baseUrl : baseUrl.replace(endpoint, "");
const createData = await kieExecutor.createTask({
baseUrl: createBaseUrl,
token,
@@ -888,11 +910,13 @@ async function handleKieImageGeneration({
}
// Use statusUrl from providerConfig if available, fallback to dynamic derivation
const statusUrl = isMarket
? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/recordInfo`
: providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo")
? providerConfig.statusUrl
: baseUrl.replace(/\/generate$/, "/record-info");
const statusUrl = isFluxKontext
? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/flux/kontext/record-info`
: isMarket
? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/recordInfo`
: providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo")
? providerConfig.statusUrl
: baseUrl.replace(/\/generate$/, "/record-info");
const { data: recordData, state } = await kieExecutor.pollTask({
statusUrl,

View File

@@ -265,7 +265,15 @@ export function translateNonStreamingResponse(
if (toolCalls.length > 0) {
message.tool_calls = toolCalls;
}
if (message.content === undefined) {
if (
(!message.content ||
(typeof message.content === "string" && message.content.trim().length === 0)) &&
toolCalls.length === 0 &&
replayableReasoningContent &&
replayableReasoningContent.trim().length > 0
) {
message.content = replayableReasoningContent;
} else if (message.content === undefined) {
message.content = "";
}

View File

@@ -90,7 +90,7 @@ import {
clampMcpAccessibilityConfig,
type McpAccessibilityConfig,
} from "../services/compression/engines/mcpAccessibility/constants.ts";
import { getDbInstance } from "../../src/lib/db/core.ts";
import { getDbInstance, ensureDbInitialized } from "../../src/lib/db/core.ts";
import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
@@ -1527,10 +1527,10 @@ export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
* Called when `omniroute --mcp` is used.
*/
export async function startMcpStdio(): Promise<void> {
await ensureDbInitialized();
// Stdout is reserved for JSON-RPC — bin/mcpStdioConsoleGuard.mjs is preloaded via
// `node --import` (see bin/mcp-server.mjs) so console.log/warn already redirect to
// stderr before this module's own imports evaluate (DB init happens as a side effect of
// createMcpServer()'s tool registration, earlier than any code placed here could catch).
// stderr before this module's own imports evaluate.
const server = createMcpServer();
const transport = new StdioServerTransport();
const version = process.env.npm_package_version || "1.8.1";

View File

@@ -3,7 +3,7 @@ import { describe, it, expect, vi } from "vitest";
// Mock the DB so recommendStrategyOverride sees adaptiveVolumeRouting = true.
// Without this the real getSettings() throws (no SQLite in test env), the
// catch block fires, and the function returns noOverride before any rule runs.
vi.mock("@/lib/localDb", () => ({
vi.mock("@/lib/db/settings", () => ({
getSettings: vi.fn().mockResolvedValue({ adaptiveVolumeRouting: true }),
}));

View File

@@ -249,6 +249,59 @@ export const OAUTH_INVALID_TOKEN_SIGNALS = [
"invalid credentials",
];
// A model that upstream has permanently retired — Gemini's deprecated-model 404
// ("This model models/gemini-2.5-flash is no longer available to new users...")
// and Fireworks/OpenAI-compatible "end of life" 410s ("has reached its end of
// life ... and is no longer available") — will 404/410 on EVERY future request;
// no cooldown short enough to retry soon is ever correct. Without this check
// these fall through to the generic "all other errors" branch at the bottom of
// checkFallbackError, which only applies a short (seconds-to-minutes) transient
// cooldown, so combo/auto-routing keeps re-selecting the dead model roughly
// every cooldown window, forever — wasted upstream calls that, at volume, look
// like abusive traffic to the provider (observed: a Gemini free-tier key
// retried `gemini-2.5-flash`/`gemini-2.5-flash-lite` every ~15-45 minutes for a
// full day). Matched independent of MODEL_ACCESS_DENIED_PATTERNS below because
// those only fire for status 400; this needs to catch the far more common
// 404/410 status a retired model actually returns.
export const MODEL_PERMANENTLY_UNAVAILABLE_PATTERNS = [
/\bno longer available\b/i,
/\bno longer supported\b/i,
/\bhas reached (?:its |the )?end.?of.?life\b/i,
/\bmodel[\s\S]{0,40}?\b(?:deprecated|retired|discontinued|decommissioned)\b/i,
/\b(?:deprecated|retired|discontinued|decommissioned)[\s\S]{0,40}?\bmodel\b/i,
];
// A provider that has permanently retired its API base_url — the old endpoint
// keeps returning 410/404 on EVERY future request until the connection's
// base_url is updated by an operator; no cooldown short enough to retry soon
// is ever correct. Without this check these fall through to the generic
// "all other errors" branch, which only applies a short (seconds-to-minutes)
// transient cooldown, so combo/auto-routing keeps re-selecting the dead
// endpoint roughly every cooldown window, forever — wasted upstream calls
// that, at volume, look like abusive traffic (observed: freeaiapikey's moved
// endpoint retried every ~1 minute for a full day: "This API endpoint has
// moved. Please update your base_url to https://api.freeaiapikey.com/v1 —
// the old endpoint on freeaiapikey.com no longer works.").
export const ENDPOINT_PERMANENTLY_MOVED_PATTERNS = [
/\bendpoint has moved\b/i,
/\bno longer works\b/i,
/\bupdate your base.?url\b/i,
];
// A billing/account suspension that requires manual operator action (unpaid
// invoice, spending limit) — text varies per provider/account name, e.g.
// Fireworks: "Account hummern is suspended, possibly due to reaching the
// monthly spending limit or failure to pay past invoices." This does not
// match ACCOUNT_DEACTIVATED_SIGNALS' fixed "your account has been suspended"
// substring, and several providers surface it on a status (412) that
// checkFallbackError does not otherwise classify — so it fell through to the
// generic transient-error branch and got retried every few minutes, all day,
// against an account that cannot succeed until billing is fixed.
export const ACCOUNT_SUSPENDED_BILLING_PATTERNS = [
/\bsuspended\b[\s\S]{0,120}?\b(?:spending limit|billing|invoice|payment)\b/i,
/\b(?:spending limit|billing|invoice|payment)\b[\s\S]{0,120}?\bsuspended\b/i,
];
// Context overflow patterns — the prompt exceeds the model's maximum context length.
// Different providers phrase this differently. Used to decide whether a 400 error
// should trigger combo fallback (a different model may have a larger context window).
@@ -421,6 +474,34 @@ export function isCreditsExhausted(errorText: string): boolean {
return CREDITS_EXHAUSTED_SIGNALS.some((sig) => lower.includes(sig));
}
/**
* Returns true if the response body indicates the requested model has been
* permanently retired by the provider (see MODEL_PERMANENTLY_UNAVAILABLE_PATTERNS).
*/
export function isModelPermanentlyUnavailable(errorText: string): boolean {
const text = String(errorText || "");
return MODEL_PERMANENTLY_UNAVAILABLE_PATTERNS.some((p) => p.test(text));
}
/**
* Returns true if response body indicates the provider's API endpoint/base_url
* has permanently moved (see ENDPOINT_PERMANENTLY_MOVED_PATTERNS).
*/
export function isEndpointPermanentlyMoved(errorText: string): boolean {
const text = String(errorText || "");
return ENDPOINT_PERMANENTLY_MOVED_PATTERNS.some((p) => p.test(text));
}
/**
* Returns true if response body indicates the account is suspended for a
* billing reason (unpaid invoice, spending limit) — see
* ACCOUNT_SUSPENDED_BILLING_PATTERNS.
*/
export function isAccountSuspendedForBilling(errorText: string): boolean {
const text = String(errorText || "");
return ACCOUNT_SUSPENDED_BILLING_PATTERNS.some((p) => p.test(text));
}
/**
* T11: Returns true if response body indicates OAuth token is invalid/expired.
* This is different from permanent account deactivation - token refresh can recover.
@@ -1755,6 +1836,56 @@ export function checkFallbackError(
};
}
// A retired model (Gemini deprecated-model 404, Fireworks/etc. end-of-life 410)
// will fail identically on every future request — lock it for a long, fixed
// window instead of falling through to the generic transient-error branch's
// short backoff, which would otherwise keep re-selecting a permanently dead
// model roughly every cooldown window, all day, hammering the provider with
// guaranteed-to-fail requests (see MODEL_PERMANENTLY_UNAVAILABLE_PATTERNS).
// `quotaResetHintMs` flows into combo.ts's per-request model-lockout as an
// upstream-verified reset, so it is honored in full and not clamped to the
// normal ~20min model-lockout ceiling.
if (
(status === HTTP_STATUS.NOT_FOUND || status === HTTP_STATUS.GONE) &&
isModelPermanentlyUnavailable(errorStr)
) {
const cooldownMs = 24 * 60 * 60 * 1000; // 24h
return {
shouldFallback: true,
cooldownMs,
reason: "not_found",
quotaResetHintMs: cooldownMs,
};
}
// The provider's API endpoint/base_url has permanently moved — every future
// request against the stale base_url fails identically, so lock it for a
// long, fixed window instead of the generic transient-error branch's short
// backoff (see ENDPOINT_PERMANENTLY_MOVED_PATTERNS).
if (isEndpointPermanentlyMoved(errorStr)) {
const cooldownMs = 24 * 60 * 60 * 1000; // 24h
return {
shouldFallback: true,
cooldownMs,
reason: "not_found",
quotaResetHintMs: cooldownMs,
};
}
// The account is suspended for a billing reason (unpaid invoice, spending
// limit) that varies per provider/account name and can arrive on a status
// checkFallbackError does not otherwise classify (e.g. Fireworks 412) —
// treat it like a credits-exhausted account so it stops being retried
// every few minutes until billing is fixed (see ACCOUNT_SUSPENDED_BILLING_PATTERNS).
if (isAccountSuspendedForBilling(errorStr)) {
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.paymentRequired ?? 3600 * 1000, // 1h cooldown
reason: RateLimitReason.QUOTA_EXHAUSTED,
creditsExhausted: true,
};
}
// Gemini-specific check — MUST run before isCreditsExhausted/
// isDailyQuotaExhausted/the generic text classifier below: Gemini's free-
// tier 429 boilerplate literally says "You exceeded your current quota,

View File

@@ -0,0 +1,51 @@
/**
* #11481 — mandatory mirror of the `/v1/models` catalog's explicit model
* exposure allow/deny list into every `auto/*` combo candidate pool.
*
* `src/app/api/v1/models/catalog.ts` hides denied models (and, when an
* allowlist is set, restricts to it) from the advertised catalog, but
* `auto/*` combos build their candidate pool independently — exactly the
* trap #6512 already fixed once for `hidePaidModels` (PR #6495 hid paid
* models from `GET /v1/models`, but `auto/*` could still route to one,
* a 402/403 at request time). This applies the SAME predicate `catalog.ts`
* uses to every virtual auto-combo candidate pool.
*
* Kept as a pure, dependency-light function so the filter is unit-testable
* in isolation without seeding the DB-backed virtual factory (mirrors
* `paidModelFilter.ts`).
*/
import {
isModelExposureAllowed,
type ModelExposureListSettings,
} from "@/shared/utils/modelExposureList";
interface ExposureFilterCandidate {
provider: string;
model: string;
}
function hasAnyExposureListEntries(settings: ModelExposureListSettings | null | undefined): boolean {
return (
(Array.isArray(settings?.modelVisibilityDenylist) &&
settings.modelVisibilityDenylist.length > 0) ||
(Array.isArray(settings?.modelVisibilityAllowlist) &&
settings.modelVisibilityAllowlist.length > 0)
);
}
/**
* Return the candidate pool filtered by the operator's model exposure
* allow/deny lists; otherwise return the pool unchanged (identity — the
* default, opt-in-off path when both lists are empty). If the allowlist
* empties the pool, the caller's existing graceful empty-pool path handles
* it, consistent with the opt-in intent.
*/
export function filterModelExposureCandidates<T extends ExposureFilterCandidate>(
pool: T[],
settings: ModelExposureListSettings | null | undefined
): T[] {
if (!hasAnyExposureListEntries(settings)) return pool;
return pool.filter((candidate) =>
isModelExposureAllowed(candidate.provider, candidate.model, settings)
);
}

View File

@@ -31,6 +31,15 @@ export interface ConnectionResilienceView {
testStatus?: string | null;
}
/** Index connection resilience views by id, for the O(1) lookups this filter needs. */
export function buildConnectionResilienceMap(
connections: Iterable<ConnectionResilienceView>
): Map<string, ConnectionResilienceView> {
const byId = new Map<string, ConnectionResilienceView>();
for (const conn of connections) byId.set(conn.id, conn);
return byId;
}
function isConnectionResilienceBlocked(connection: ConnectionResilienceView): boolean {
if (isAccountUnavailable(connection.rateLimitedUntil)) return true;
const status = connection.testStatus;
@@ -54,12 +63,19 @@ function isConnectionEligibleForModel(
* Remove auto-combo candidates whose provider/model pair is model-locked, and
* trim credentialed logical candidates whose allowed connections are all blocked.
* Returns the input reference when nothing changed.
*
* `skip` (#9133) lets the read-only candidate inspector
* (`open-sse/handlers/autoComboCandidates.ts`) opt out entirely: it needs the
* FULL pool so it can decorate blocked candidates as `reachable:false`
* instead of dropping them before they are ever surfaced. Routing callers
* never pass it, so dispatch behavior is unchanged.
*/
export function filterResilienceBlockedCandidates<T extends ResilienceFilterCandidate>(
pool: T[],
connectionsById: Map<string, ConnectionResilienceView>
connectionsById: Map<string, ConnectionResilienceView>,
skip = false
): T[] {
if (!Array.isArray(pool) || pool.length === 0) return pool;
if (skip || !Array.isArray(pool) || pool.length === 0) return pool;
let changed = false;
const filtered = pool.flatMap((candidate) => {

View File

@@ -31,6 +31,7 @@ import { buildFamilyCandidateFilter, type ModelFamily } from "./modelFamily";
import { getHiddenModelsByProvider } from "@/models";
import { getSyncedAvailableModelsByConnection, getCustomModels } from "@/lib/db/models";
import { filterPaidOnlyCandidates } from "./paidModelFilter";
import { filterModelExposureCandidates } from "./modelExposureFilter";
import {
filterSubscriptionOnlyCandidates,
orderPoolByRung,
@@ -44,8 +45,8 @@ import { filterExcludedCandidates } from "./candidateOverrides";
import { getExcludedConnectionIds } from "@/lib/db/autoCandidateOverrides";
import {
filterResilienceBlockedCandidates,
buildConnectionResilienceMap,
SYNTHETIC_NOAUTH_CONNECTION_ID as RESILIENCE_NOAUTH_CONNECTION_ID,
type ConnectionResilienceView,
} from "./resilienceCandidateFilter";
import type { ChaosTuning } from "./chaosEngine";
@@ -584,7 +585,8 @@ export async function prepareVirtualAutoComboInputs(
options: {
includeResolvedCapabilities?: boolean;
resolutionSnapshot?: ModelCapabilityResolutionSnapshot;
} = {}
} = {},
skip = false // #9133 — inspector opt-out, see filterResilienceBlockedCandidates
): Promise<PreparedVirtualAutoComboInputs> {
const [rawConnections, rawDisabledNoAuthConnections, settings] = await Promise.all([
getCachedProviderConnections({ isActive: true }) as Promise<VirtualFactoryConn[]>,
@@ -708,10 +710,10 @@ export async function prepareVirtualAutoComboInputs(
// #7623: honor existing model lockouts + connection cooldown/terminal state so
// auto/* never advertises models the dispatch path would immediately skip.
const connectionsById = new Map<string, ConnectionResilienceView>();
for (const conn of [...runtimeConnections, ...disabledNoAuthConnections]) {
connectionsById.set(conn.id, conn);
}
const connectionsById = buildConnectionResilienceMap([
...runtimeConnections,
...disabledNoAuthConnections,
]);
const connectedProviders = new Set(validConnections.map((conn) => conn.provider));
const buildPreparedPool = (bypassNoAuthAllowlist: boolean) => {
@@ -727,7 +729,7 @@ export async function prepareVirtualAutoComboInputs(
),
];
const resilienceFilteredPool = filterResilienceBlockedCandidates(pool, connectionsById);
const resilienceFilteredPool = filterResilienceBlockedCandidates(pool, connectionsById, skip);
if (resilienceFilteredPool !== pool) pool = resilienceFilteredPool;
// #6512 (follow-up to #6328/#6495): when the operator opts into `hidePaidModels`,
@@ -735,6 +737,11 @@ export async function prepareVirtualAutoComboInputs(
const paidFilteredPool = filterPaidOnlyCandidates(pool, settings.hidePaidModels === true);
if (paidFilteredPool !== pool) pool = paidFilteredPool;
// #11481: mandatory mirror of the /v1/models exposure allow/deny list —
// see src/shared/utils/modelExposureList.ts for why (#6512's lesson).
const exposureFilteredPool = filterModelExposureCandidates(pool, settings);
if (exposureFilteredPool !== pool) pool = exposureFilteredPool;
// STRICT_ZERO_COST: opt-in, off by default (`settings.freeAccessPolicy !== "strict"`
// leaves `pool` byte-identical, same contract as `hidePaidModels`). See
// `strictZeroCostFilter.ts` for why this is stricter than `hidePaidModels` alone —

View File

@@ -1,22 +1,20 @@
import { v4 as uuidv4 } from "uuid";
import type { BatchItemCheckpoint, BatchRecord } from "@/lib/localDb";
import {
type BatchItemCheckpoint,
type BatchRecord,
countBatchItemCheckpoints,
createFile,
deleteFile,
ensureBatchItemCheckpoints,
getApiKeyById,
getBatch,
getFileContent,
getPendingBatches,
getTerminalBatches,
listBatchItemCheckpoints,
listFiles,
markBatchItemError,
markBatchItemProcessing,
markBatchItemResult,
updateBatch,
} from "@/lib/localDb";
} from "@/lib/db/batches";
import { createFile, deleteFile, getFileContent, listFiles } from "@/lib/db/files";
import { getApiKeyById } from "@/lib/db/apiKeys";
import { dispatch } from "@/lib/batches/dispatch";
import type { SupportedBatchEndpoint } from "@/shared/constants/batchEndpoints";
import { DEFAULT_BATCH_EXPIRATION_SECONDS } from "@/shared/constants/batch";

View File

@@ -16,6 +16,7 @@ import {
hasPerModelQuota,
isAccountSemaphoreFull,
isModelLocked,
lockModelIfPerModelQuota,
MODEL_ACCESS_DENIED_PATTERNS,
recordModelLockoutFailure,
recordProviderFailure,
@@ -356,6 +357,33 @@ export function releaseStickyPinOnFailure(
clearStickyBinding(messageHash);
}
/**
* Clear persisted LKGP pins when a target fails or is skipped due to
* exhaustion, cooldown, or unavailability (#11911 #919).
*/
export function clearStaleLKGP(
comboName: string,
executionKey?: string | null,
comboId?: string | null,
log?: { warn?: (tag: string, msg: string, data?: unknown) => void } | null,
tag: string = "COMBO"
): void {
void (async () => {
try {
const { clearLKGP } = await import("@/lib/db/settings");
const promises: Promise<void>[] = [clearLKGP(comboName, comboId || comboName)];
if (executionKey) {
promises.push(clearLKGP(comboName, executionKey));
}
await Promise.all(promises);
} catch (err) {
log?.warn?.(tag, "Failed to clear Last Known Good Provider. This is non-fatal.", {
err,
});
}
})();
}
const DEFAULT_MODEL_P95_MS: Record<string, number> = {
"grok-4-fast-non-reasoning": 1143,
"grok-4-1-fast-non-reasoning": 1244,
@@ -399,7 +427,7 @@ export async function buildAutoCandidates(
// apply, so auto-routing behavior is unchanged.
const quotaCutoffEnabled =
(resilienceSettings ?? resolveResilienceSettings(null))?.quotaPreflight?.enabled === true;
const { getPricingForModel } = await import("../../src/lib/localDb");
const { getPricingForModel } = await import("@/lib/db/settings");
const quotaPromises = new Map<string, Promise<unknown>>();
let historicalLatencyStats: Record<string, HistoricalLatencyStatsEntry> = {};
try {
@@ -1203,6 +1231,7 @@ async function handleComboChatInner({
strategy === "priority" && target.fallbackOnlyOnQuotaExhaustion === true;
const stopProtectedPriorityTarget = (message: string) => {
observeFailure(false, target.executionKey);
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
return protectedPriorityTarget
? { ok: false, response: errorResponse(503, message) }
: null;
@@ -1264,6 +1293,7 @@ async function handleComboChatInner({
);
if (persistedSkip) {
log.info("COMBO", persistedSkip);
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
if (i > 0) fallbackCount++;
return null;
}
@@ -1322,6 +1352,7 @@ async function handleComboChatInner({
"COMBO",
`Skipping ${modelStr} — quota exhaustion cutoff (${quotaCutoff.reason || "quota_exhausted"})`
);
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
recordComboDecision(traceInvocationId, {
step: target.executionKey,
target: modelStr,
@@ -1359,6 +1390,7 @@ async function handleComboChatInner({
"COMBO",
`Skipping ${modelStr} — quota budget ${quotaDecision.reason} (remaining ${quotaDecision.tokensRemaining ?? 0}, cost ${quotaDecision.estimatedCost ?? 0})`
);
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
if (i > 0) fallbackCount++;
return null;
}
@@ -1376,6 +1408,7 @@ async function handleComboChatInner({
"COMBO",
`Skipping ${modelStr} — no credentials available or model excluded`
);
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
recordComboDecision(traceInvocationId, {
step: target.executionKey,
target: modelStr,
@@ -1607,7 +1640,8 @@ async function handleComboChatInner({
lastModel,
modelStr,
`Model routing: ${lastModel}${modelStr}`,
existingHandoff
existingHandoff,
universalHandoffConfig.relayMode
);
}
}
@@ -1936,7 +1970,7 @@ async function handleComboChatInner({
const connId = effectiveConnectionId || undefined;
void (async () => {
try {
const { setLKGP } = await import("../../src/lib/localDb");
const { setLKGP } = await import("@/lib/db/settings");
await Promise.all([
setLKGP(combo.name, target.executionKey, provider, connId),
setLKGP(combo.name, combo.id || combo.name, provider, connId),
@@ -2181,6 +2215,13 @@ async function handleComboChatInner({
// exhausted — if it's the currently sticky-bound one, release the pin now
// rather than waiting for the next turn's lazy headroom/status recheck.
releaseStickyPinOnFailure(_sticky.messageHash, targetWithConnection.connectionId);
if (
providerExhausted ||
exhaustedConnections.has(`${provider}:${targetWithConnection.connectionId}`) ||
(provider && exhaustedProviders.has(provider))
) {
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
}
// #2101: Prevent infinite fallback loops with 400 Bad Request errors that are genuinely
// body-specific (malformed JSON, bad format, missing required fields).
@@ -2227,6 +2268,7 @@ async function handleComboChatInner({
lastStatus = result.status;
if (i > 0) fallbackCount++;
log.warn("COMBO", `Model ${modelStr} failed with body-specific error, stopping combo`);
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
// #4279: surface the 400 via the {ok,response} contract so the OUTER
// target loop resolves the combo and stops. A bare `break` here only
// exits the inner retry loop; executeTarget then returns null, which
@@ -2236,6 +2278,26 @@ async function handleComboChatInner({
return { ok: false, response: result };
}
// A model-scoped 400 ("The requested model is not supported" / "not
// available for integrator") is permanent for THIS connection — the
// account/integration will not gain support for the model mid-session.
// Combo still advances to the next target immediately (unchanged,
// preserves #5249's cross-provider fallback), but without a lockout
// here the SAME dead model gets retried on every future, separate
// request forever (observed: every auto-combo request wasted several
// upstream 400s on the same GitHub models, all day). isModelLocked()
// is checked before dispatch (see the pre-check above this loop), so
// this lockout is honored on the next request.
if (result.status === 400 && isModelScoped400(errorText) && provider && rawModel) {
lockModelIfPerModelQuota(
provider,
targetWithConnection.connectionId || "",
rawModel,
"model_capacity",
60 * 60 * 1000 // 1h
);
}
// Trigger shared provider circuit breaker for 5xx errors and connection failures. If the
// next target is on the same provider, don't mark it failed (a different model may still
// succeed) — #8376: EXCEPT a proxy-unreachable failure, which poisons every model alike.
@@ -2395,19 +2457,7 @@ async function handleComboChatInner({
// *next* separate request. Circuit breaker / model lockout deliberately
// don't react to request-scoped failure classes (see scopedFailure below),
// so nothing else clears this stale pin.
void (async () => {
try {
const { clearLKGP } = await import("../../src/lib/localDb");
await Promise.all([
clearLKGP(combo.name, target.executionKey),
clearLKGP(combo.name, combo.id || combo.name),
]);
} catch (err) {
log.warn("COMBO", "Failed to clear Last Known Good Provider. This is non-fatal.", {
err,
});
}
})();
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO");
recordedAttempts++;
lastError = errorText || String(result.status);
comboErrors.push({
@@ -3228,6 +3278,7 @@ async function handleRoundRobinCombo({
"COMBO-RR",
`Skipping ${modelStr} — no credentials available or model excluded`
);
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR");
if (offset > 0) fallbackCount++;
continue;
}
@@ -3243,6 +3294,7 @@ async function handleRoundRobinCombo({
)
) {
log.info("COMBO-RR", `Skipping ${modelStr} — provider ${provider} in global cooldown`);
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR");
if (offset > 0) fallbackCount++;
continue;
}
@@ -3255,6 +3307,7 @@ async function handleRoundRobinCombo({
);
if (exhaustedSkip) {
log.info("COMBO-RR", exhaustedSkip);
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR");
if (offset > 0) fallbackCount++;
continue;
}
@@ -3302,24 +3355,20 @@ async function handleRoundRobinCombo({
"COMBO-RR",
`Maximum combo attempts (${maxGlobalAttempts}) exceeded. Terminating loop to prevent runaway requests.`
);
return errorResponseWithComboDiagnostics(
503,
"Maximum combo retry limit reached",
{
poolSize: modelCount,
attempted: globalAttempts,
excluded: [
...[...exhaustedProviders].map((p) => ({ provider: p, reason: "exhausted" })),
...[...exhaustedConnections].map((c) => formatExhaustedConnectionKey(String(c))),
],
attemptOrder: rrOutcomes.map((o) => ({
provider: o.model.split("/")[0] || "unknown",
model: o.model,
})),
terminalReason: "max_attempts_exceeded",
recovery: buildRecoveryHint("max_attempts_exceeded"),
}
);
return errorResponseWithComboDiagnostics(503, "Maximum combo retry limit reached", {
poolSize: modelCount,
attempted: globalAttempts,
excluded: [
...[...exhaustedProviders].map((p) => ({ provider: p, reason: "exhausted" })),
...[...exhaustedConnections].map((c) => formatExhaustedConnectionKey(String(c))),
],
attemptOrder: rrOutcomes.map((o) => ({
provider: o.model.split("/")[0] || "unknown",
model: o.model,
})),
terminalReason: "max_attempts_exceeded",
recovery: buildRecoveryHint("max_attempts_exceeded"),
});
}
if (retry > 0) {
log.info(
@@ -3518,7 +3567,7 @@ async function handleRoundRobinCombo({
const connId = effectiveConnectionId || undefined;
void (async () => {
try {
const { setLKGP } = await import("../../src/lib/localDb");
const { setLKGP } = await import("@/lib/db/settings");
await Promise.all([
setLKGP(combo.name, target.executionKey, provider, connId),
setLKGP(combo.name, combo.id || combo.name, provider, connId),
@@ -3694,6 +3743,13 @@ async function handleRoundRobinCombo({
_rrSessionSticky.messageHash,
targetWithConnection.connectionId
);
if (
providerExhausted ||
exhaustedConnections.has(`${provider}:${targetWithConnection.connectionId}`) ||
(provider && exhaustedProviders.has(provider))
) {
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR");
}
// Transient errors → mark in semaphore so round-robin stops stampeding this target.
if (
@@ -3749,19 +3805,7 @@ async function handleRoundRobinCombo({
// LKGP (#919) mirror of handleComboChat's failure-path clear above — see
// that comment for why this must happen (nothing else clears a pin left
// by a request-scoped failure class like a stream-readiness timeout).
void (async () => {
try {
const { clearLKGP } = await import("../../src/lib/localDb");
await Promise.all([
clearLKGP(combo.name, target.executionKey),
clearLKGP(combo.name, combo.id || combo.name),
]);
} catch (err) {
log.warn("COMBO-RR", "Failed to clear Last Known Good Provider. This is non-fatal.", {
err,
});
}
})();
clearStaleLKGP(combo.name, target.executionKey, combo.id, log, "COMBO-RR");
recordedAttempts++;
lastError = errorText || String(result.status);
lastStatus = result.status;

View File

@@ -67,7 +67,7 @@ export async function applyStrategyOrdering(
if (strategy === "lkgp") {
try {
const { getLKGP } = await import("../../../src/lib/localDb");
const { getLKGP } = await import("@/lib/db/settings");
const lkgpProvider = await getLKGP(combo.name, combo.id || combo.name);
if (lkgpProvider) {

View File

@@ -19,7 +19,7 @@
* shrinking (Quality Gate / #3501).
*/
import { getCachedProviderConnectionById } from "@/lib/localDb";
import { getCachedProviderConnectionById } from "@/lib/db/readCache";
import { effectiveMaxConcurrency } from "./comboPredicates.ts";
import type { ResolvedComboTarget } from "./types.ts";

View File

@@ -19,7 +19,7 @@ import {
type PreflightQuotaThresholds,
type QuotaInfo,
} from "../quotaPreflight.ts";
import { getCachedProviderConnectionById } from "@/lib/localDb";
import { getCachedProviderConnectionById } from "@/lib/db/readCache";
import {
resolveResilienceSettings,
type ResilienceSettings,
@@ -109,8 +109,7 @@ export async function resolveQuotaExhaustionCutoffForTarget(
let connection: Record<string, unknown> | undefined;
try {
connection = (await getCachedProviderConnectionById(connectionId)) as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
} catch {
connection = undefined;
}

View File

@@ -245,7 +245,7 @@ export async function resolveAutoStrategyOrder(
let lastKnownGoodProvider: string | undefined;
try {
const { getLKGP } = await import("../../../src/lib/localDb");
const { getLKGP } = await import("@/lib/db/settings");
const lkgp = await getLKGP(combo.name, combo.id || combo.name);
if (lkgp) lastKnownGoodProvider = lkgp.provider;
} catch (err) {

View File

@@ -65,7 +65,7 @@ export function orderTargetsForWeightedFallback<T extends { executionKey: string
*/
export async function sortModelsByCost(models: string[]): Promise<string[]> {
try {
const { getPricingForModel } = await import("../../../src/lib/localDb");
const { getPricingForModel } = await import("@/lib/db/settings");
const withCost = await Promise.all(
models.map(async (modelStr) => {
const parsed = parseModel(modelStr);

View File

@@ -48,6 +48,7 @@ export interface ContextRelayConfig {
handoffThreshold?: number;
handoffProviders?: string[];
maxMessagesForSummary?: number;
relayMode?: "schema-locked" | "standard";
}
export interface UniversalHandoffConfig {
@@ -65,6 +66,7 @@ export interface UniversalHandoffConfig {
ttlMinutes: number;
/** Preserve existing system prompt when injecting handoff */
preserveSystemPrompt: boolean;
relayMode?: "schema-locked" | "standard";
}
export const DEFAULT_UNIVERSAL_HANDOFF_CONFIG: UniversalHandoffConfig = {
@@ -155,6 +157,8 @@ export function resolveUniversalHandoffConfig(
"preserveSystemPrompt",
DEFAULT_UNIVERSAL_HANDOFF_CONFIG.preserveSystemPrompt
),
relayMode:
getString("relayMode", "standard") === "schema-locked" ? "schema-locked" : "standard",
};
}
export interface ParsedHandoffContent {
@@ -192,6 +196,7 @@ export function resolveContextRelayConfig(
Number.isFinite(rawMaxMessages) && rawMaxMessages >= 5 && rawMaxMessages <= 100
? Math.round(rawMaxMessages)
: DEFAULT_MAX_MESSAGES_FOR_SUMMARY,
relayMode: config?.relayMode === "schema-locked" ? "schema-locked" : "standard",
};
}
@@ -232,7 +237,8 @@ function formatMessagesForPrompt(messages: MessageLike[]): string {
export function selectMessagesForSummary(
messages: MessageLike[],
maxMessages: number
maxMessages: number,
relayMode?: "schema-locked" | "standard"
): MessageLike[] {
const validMessages = messages.filter((m) => m && typeof m === "object");
const system = validMessages.filter(
@@ -242,15 +248,23 @@ export function selectMessagesForSummary(
(m) => typeof m.role !== "string" || (m.role !== "system" && m.role !== "developer")
);
const recentMessages = [...system, ...nonSystem.slice(-maxMessages)];
const recentMessages =
relayMode === "schema-locked"
? [...nonSystem.slice(-maxMessages)]
: [...system, ...nonSystem.slice(-maxMessages)];
let working = [...recentMessages];
while (working.length > system.length + 1) {
const minWorkingLength = relayMode === "schema-locked" ? 1 : system.length + 1;
while (working.length > minWorkingLength) {
const history = formatMessagesForPrompt(working);
if (estimateTokens(history) <= MAX_HISTORY_TOKENS_FOR_SUMMARY) {
return working;
}
working = [...system, ...working.slice(system.length + 1)];
working =
relayMode === "schema-locked"
? working.slice(1)
: [...system, ...working.slice(system.length + 1)];
}
const fallbackHistory = formatMessagesForPrompt(working);
@@ -258,7 +272,7 @@ export function selectMessagesForSummary(
// If there are system messages, return them so the caller can still produce context.
// If there are no system messages (system=[]), fall back to the single most-recent
// non-system message rather than returning [] which would silently drop the handoff.
if (system.length > 0) {
if (relayMode !== "schema-locked" && system.length > 0) {
return system;
}
const lastNonSystem = nonSystem[nonSystem.length - 1];
@@ -386,7 +400,8 @@ async function generateHandoffAsync(options: {
const summaryModel = relayConfig.handoffModel || options.model;
const selectedMessages = selectMessagesForSummary(
Array.isArray(options.messages) ? options.messages : [],
relayConfig.maxMessagesForSummary
relayConfig.maxMessagesForSummary,
relayConfig.relayMode
);
const historyText = formatMessagesForPrompt(selectedMessages);
if (!historyText) return;
@@ -499,7 +514,8 @@ The context above contains a concise summary of the prior work. Continue seamles
export function injectHandoffIntoBody(
body: Record<string, unknown>,
payload: HandoffPayload
payload: HandoffPayload,
_relayMode?: "schema-locked" | "standard"
): Record<string, unknown> {
const handoffContent = buildHandoffSystemMessage(payload);
const isResponsesRequest =
@@ -680,12 +696,14 @@ async function generateUniversalHandoffAsync(options: {
handoffModel: string;
ttlMs: number;
maxMessages: number;
relayMode?: "schema-locked" | "standard";
providerAllowlist: string[];
handleSingleModel: (body: Record<string, unknown>, modelStr: string) => Promise<Response>;
}): Promise<UniversalHandoffOutcome> {
const selectedMessages = selectMessagesForSummary(
Array.isArray(options.messages) ? options.messages : [],
options.maxMessages
options.maxMessages,
options.relayMode
);
const historyText = formatMessagesForPrompt(selectedMessages);
if (!historyText) return "unavailable";
@@ -789,6 +807,7 @@ export function maybeGenerateUniversalHandoff(options: {
handoffModel: options.universalConfig.handoffModel || options.currModel,
ttlMs,
maxMessages: options.universalConfig.maxMessagesForSummary,
relayMode: options.universalConfig.relayMode,
providerAllowlist: options.universalConfig.providerAllowlist,
handleSingleModel: options.handleSingleModel,
})
@@ -812,7 +831,8 @@ export function injectUniversalHandoffBody(
prevModel: string,
currModel: string,
reason: string,
existingPayload?: HandoffPayload | null
existingPayload?: HandoffPayload | null,
_relayMode?: "schema-locked" | "standard"
): Record<string, unknown> {
const handoffContent = buildUniversalHandoffSystemMessage(
prevModel,

View File

@@ -12,7 +12,7 @@
* - Provider-specific rules: "openai/gpt-4"
*/
import { checkKeyModelAccess, getKeyGroupsForApiKey } from "@/lib/localDb";
import { checkKeyModelAccess, getKeyGroupsForApiKey } from "@/lib/db/apiKeyGroups";
export interface KeyGroupAuthResult {
/** Whether the request is authorized */

View File

@@ -332,7 +332,7 @@ function getProviderIdFromConnection(connection: unknown) {
async function getActiveProviderSet() {
try {
const { getCachedProviderConnections } = await import("@/lib/localDb");
const { getCachedProviderConnections } = await import("@/lib/db/readCache");
const conns = (await getCachedProviderConnections()) as unknown[];
const providers = conns
.map(getProviderIdFromConnection)
@@ -345,7 +345,7 @@ async function getActiveProviderSet() {
async function getActiveSyncedProvidersForModel(modelId: string) {
try {
const { getActiveProvidersWithSyncedModel } = await import("@/lib/localDb");
const { getActiveProvidersWithSyncedModel } = await import("@/lib/db/models");
const providers = await getActiveProvidersWithSyncedModel(modelId);
return providers
.map(resolveProviderAlias)
@@ -403,7 +403,7 @@ function isTruthyEnv(value: string | undefined) {
async function getPreferClaudeCodeForUnprefixedClaudeModels() {
try {
const { getCachedSettings } = await import("@/lib/localDb");
const { getCachedSettings } = await import("@/lib/db/readCache");
const settings = (await getCachedSettings()) as Record<string, unknown>;
if (typeof settings.preferClaudeCodeForUnprefixedClaudeModels === "boolean") {
return settings.preferClaudeCodeForUnprefixedClaudeModels;

View File

@@ -223,7 +223,7 @@ export function clearPayloadRulesConfigOverride() {
// silently reverting to the (usually empty) file config.
async function loadPayloadRulesFromSettings(): Promise<PayloadRulesConfig | null> {
try {
const { getCachedSettings } = await import("@/lib/localDb");
const { getCachedSettings } = await import("@/lib/db/readCache");
const settings = (await getCachedSettings()) as { payloadRules?: unknown };
const raw = settings?.payloadRules;
if (raw === null || raw === undefined) return null;

View File

@@ -338,7 +338,8 @@ export async function initializeRateLimits() {
applyBottleneckHeartbeatPatch();
try {
const { getCachedProviderConnections, getSettings } = await import("@/lib/localDb");
const { getCachedProviderConnections } = await import("@/lib/db/readCache");
const { getSettings } = await import("@/lib/db/settings");
const [connections, settings] = await Promise.all([
getCachedProviderConnections(),
getSettings(),
@@ -385,7 +386,7 @@ export async function applyRequestQueueSettings(nextSettings: RequestQueueSettin
currentRequestQueueSettings = { ...nextSettings };
// Global policy changes invalidate snapshots from the previous generation.
preservedReplacementSettings.clear();
const { getCachedProviderConnections } = await import("@/lib/localDb");
const { getCachedProviderConnections } = await import("@/lib/db/readCache");
const connections = await getCachedProviderConnections();
// Also discard any snapshot created while the asynchronous DB read yielded.
preservedReplacementSettings.clear();

View File

@@ -20,7 +20,7 @@ import {
getTokenLimitsForRequest,
logTokenLimitReset,
type TokenLimit,
} from "@/lib/localDb";
} from "@/lib/db/tokenLimits";
interface CacheEntry {
windowStart: string;
@@ -295,8 +295,7 @@ export function recordTokenUsage(
ORDER BY window_start DESC LIMIT 1`
)
.get(limit.id, windowStart) as
| { window_start?: string; tokens_used?: number }
| undefined;
{ window_start?: string; tokens_used?: number } | undefined;
const prevTokens =
priorRow && typeof priorRow.tokens_used === "number" ? priorRow.tokens_used : 0;
if (prevTokens > 0) {

View File

@@ -155,7 +155,7 @@ export async function recommendStrategyOverride(
// Check if adaptive routing is enabled globally
try {
const { getSettings } = await import("@/lib/localDb");
const { getSettings } = await import("@/lib/db/settings");
const settings = await getSettings();
if (!settings.adaptiveVolumeRouting) {
return noOverride;

View File

@@ -1,7 +1,7 @@
/**
* Optional-sharp image normalization.
*
* Rationale (migrated from freellmapi `server/src/lib/image-normalize.ts:40-58`):
* Rationale:
* OpenAI resizes images to a long-edge cap of 2048px server-side, Anthropic applies
* a similar cap. Downscaling client-side before upload reduces tokens/latency without
* changing model behavior. `sharp` is loaded via dynamic import so that a platform

View File

@@ -20,7 +20,7 @@ async function getConfig() {
if (_cachedConfig && now < _cacheExpiry) return _cachedConfig;
try {
const { getProxyConfig } = await import("../../src/lib/localDb");
const { getProxyConfig } = await import("@/lib/db/settings");
_cachedConfig = await getProxyConfig();
_cacheExpiry = now + 30_000; // Cache for 30s
return _cachedConfig;

View File

@@ -10,7 +10,8 @@
import { fetch as undiciFetch } from "undici";
import { createProxyDispatcher, normalizeProxyUrl } from "./proxyDispatcher.ts";
import { resolveProxyForScopeFromRegistry, listProxies, listOneproxyProxies } from "@/lib/localDb";
import { resolveProxyForScopeFromRegistry, listProxies } from "@/lib/db/proxies";
import { listOneproxyProxies } from "@/lib/db/oneproxy";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
// ---------------------------------------------------------------------------

View File

@@ -29,6 +29,7 @@ export type ResourceSignals = {
currentBytes: ResourceMetricBytes;
maxBytes: ResourceMetricBytes;
highBytes: ResourceMetricBytes;
fileBytes: ResourceMetricBytes;
events: {
low: ResourceMetricBytes;
high: ResourceMetricBytes;
@@ -176,9 +177,17 @@ export function classifyAdaptiveResourcePressure(
best,
ratioLevel(signals.v8.heapUsedBytes, signals.v8.heapLimitBytes, thresholds, "v8_heap_ratio")
);
// memory.current includes reclaimable page cache; the kernel drops those
// pages under allocation pressure (memory.events high/max stay 0). Ratio the
// working set (current minus file cache) so cache-heavy-but-healthy hosts do
// not trip the guard. Without memory.stat, fall back to the raw ratio.
// memory.high keeps the raw current: the kernel throttles on TOTAL charge
// (file cache included) when crossing high, so a workingset ratio there
// would miss kernel-side reclaim stalls.
const cgroupWorkingSetBytes = workingSetBytes(signals.cgroup);
best = maxLevel(
best,
ratioLevel(signals.cgroup.currentBytes, signals.cgroup.maxBytes, thresholds, "cgroup_ratio")
ratioLevel(cgroupWorkingSetBytes, signals.cgroup.maxBytes, thresholds, "cgroup_ratio")
);
best = maxLevel(
best,
@@ -188,10 +197,22 @@ export function classifyAdaptiveResourcePressure(
return maxLevel(best, psiLevel(signals.psi?.fullAvg10 ?? null, thresholds, "psi_full"));
}
function workingSetBytes(cgroup: ResourceSignals["cgroup"]): number | null {
if (cgroup.currentBytes == null) return null;
if (cgroup.fileBytes == null || cgroup.fileBytes <= 0) return cgroup.currentBytes;
// memory.current and memory.stat are separate, non-atomic reads; under churn
// file can momentarily exceed a fresher current. Treat that as a bad sample
// and fall back to the raw ratio rather than clamping to 0, which would
// read as zero pressure and could force a premature recovery.
if (cgroup.fileBytes > cgroup.currentBytes) return cgroup.currentBytes;
return cgroup.currentBytes - cgroup.fileBytes;
}
function isRecovered(signals: ResourceSignals, thresholds: ResourcePressureThresholds): boolean {
const cgroupWorkingSetBytes = workingSetBytes(signals.cgroup);
const ratios: Array<readonly [number | null, number | null]> = [
[signals.v8.heapUsedBytes, signals.v8.heapLimitBytes],
[signals.cgroup.currentBytes, signals.cgroup.maxBytes],
[cgroupWorkingSetBytes, signals.cgroup.maxBytes],
[signals.cgroup.currentBytes, signals.cgroup.highBytes],
];
if (

View File

@@ -180,6 +180,22 @@ function parsePsiNumber(line: string, name: string): number | null {
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
function parseMemoryStatFileBytes(text: string | null): number | null {
if (!text) return null;
for (const line of text.split("\n")) {
const [key, rawValue] = line.trim().split(/\s+/, 2);
if (key !== "file" || rawValue == null) continue;
// sanitizeMemoryBytes rejects every falsy magnitude INCLUDING zero, but a
// zero file cache is a valid reading (workingSetBytes falls back to the
// raw ratio for it). The zero short-circuit below is coupled to that
// contract on purpose; if sanitizeMemoryBytes ever accepts zero, this
// branch becomes dead but harmless.
if (rawValue === "0") return 0;
return sanitizeMemoryBytes(rawValue);
}
return null;
}
function parsePsi(text: string | null): ResourceSignals["psi"] {
if (!text) return null;
const result: NonNullable<ResourceSignals["psi"]> = {
@@ -226,14 +242,25 @@ export async function sampleResourceSignals(
}
const cgroupDirectory = await resolveCgroupDirectory(readText);
const cgroupContents = cgroupDirectory
const cgroupReads = cgroupDirectory
? await Promise.all([
readText(path.join(cgroupDirectory, "memory.current")),
readText(path.join(cgroupDirectory, "memory.max")),
readText(path.join(cgroupDirectory, "memory.high")),
readText(path.join(cgroupDirectory, "memory.events")),
readText(path.join(cgroupDirectory, "memory.stat")),
])
: [null, null, null, null];
: null;
// Named bindings instead of positional indices: the read order above is
// easy to shuffle on edit, and a silent index shift would corrupt the
// current/max/high/stat mapping.
const cgroupFiles = {
current: cgroupReads?.[0] ?? null,
max: cgroupReads?.[1] ?? null,
high: cgroupReads?.[2] ?? null,
events: cgroupReads?.[3] ?? null,
stat: cgroupReads?.[4] ?? null,
};
const psi = await readText("/proc/pressure/memory").catch(() => null);
return {
@@ -247,10 +274,11 @@ export async function sampleResourceSignals(
constrainedBytes: safeNumber(deps.constrainedMemory ?? (() => process.constrainedMemory?.())),
},
cgroup: {
currentBytes: sanitizeMemoryBytes(cgroupContents[0]),
maxBytes: sanitizeMemoryBytes(cgroupContents[1]),
highBytes: sanitizeMemoryBytes(cgroupContents[2]),
events: parseMemoryEvents(cgroupContents[3]),
currentBytes: sanitizeMemoryBytes(cgroupFiles.current),
maxBytes: sanitizeMemoryBytes(cgroupFiles.max),
highBytes: sanitizeMemoryBytes(cgroupFiles.high),
fileBytes: parseMemoryStatFileBytes(cgroupFiles.stat),
events: parseMemoryEvents(cgroupFiles.events),
},
psi: parsePsi(psi),
};

525
package-lock.json generated
View File

@@ -72,6 +72,7 @@
"react-is": "^19.2.6",
"react-markdown": "^10.1.0",
"react-reconciler": "^0.33.0",
"react18-json-view": "0.2.10",
"recharts": "^3.8.1",
"safe-regex": "^2.1.1",
"selfsigned": "^5.5.0",
@@ -16994,6 +16995,15 @@
"node": ">=6.6.0"
}
},
"node_modules/copy-to-clipboard": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
"integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
"license": "MIT",
"dependencies": {
"toggle-selection": "^1.0.6"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -20992,490 +21002,6 @@
}
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/fumadocs-mdx/node_modules/magic-string": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.2.tgz",
@@ -26176,17 +25702,6 @@
"node": ">= 14"
}
},
"node_modules/libxmljs2/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/libxmljs2/node_modules/cacache": {
"version": "19.0.1",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz",
@@ -31633,7 +31148,9 @@
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"bin": {
"playwright-core": "cli.js"
},
@@ -33257,6 +32774,18 @@
}
}
},
"node_modules/react18-json-view": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/react18-json-view/-/react18-json-view-0.2.10.tgz",
"integrity": "sha512-rYEbaCG/U4THY1qp1xY14/Kbnp9yY3W6Qm3Rmu+jlCIdxzMS5EcD+wI97kCKRoN3CuJyJU8hqkax5xWfl8A4EA==",
"license": "MIT",
"dependencies": {
"copy-to-clipboard": "^3.3.3"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/read-cmd-shim": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz",
@@ -36292,6 +35821,12 @@
"node": ">=8.0"
}
},
"node_modules/toggle-selection": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",

View File

@@ -323,6 +323,7 @@
"react-is": "^19.2.6",
"react-markdown": "^10.1.0",
"react-reconciler": "^0.33.0",
"react18-json-view": "0.2.10",
"recharts": "^3.8.1",
"safe-regex": "^2.1.1",
"selfsigned": "^5.5.0",

View File

@@ -328,10 +328,10 @@ const chatGptWebCodexMcpDestFile = join(
if (existsSync(chatGptWebCodexMcpSrcFile)) {
console.log(" 🔨 Bundling ChatGPT Web (Codex) MCP bridge...");
mkdirSync(dirname(chatGptWebCodexMcpDestFile), { recursive: true });
execFileSync(
NPX_BIN,
runBuildTool(
"esbuild",
"esbuild",
[
"esbuild",
"open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.ts",
"--bundle",
"--platform=node",
@@ -483,9 +483,11 @@ if (existsSync(cliSrcFile)) {
// flow for every downstream user.
const opencodePluginSrc = join(ROOT, "@omniroute", "opencode-plugin");
const opencodePluginDist = join(opencodePluginSrc, "dist", "index.js");
const opencodePluginCjs = join(opencodePluginSrc, "dist", "index.cjs");
if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package.json"))) {
const pluginAlreadyBuilt = existsSync(opencodePluginDist) && existsSync(opencodePluginCjs);
// The plugin's tsup config is ESM-only (format: ["esm"]), so a successful
// build only ever produces dist/index.js (+ dist/index.d.ts) — never
// dist/index.cjs. Gate the skip solely on dist/index.js.
const pluginAlreadyBuilt = existsSync(opencodePluginDist);
if (!pluginAlreadyBuilt) {
console.log("\n 🔨 Building @omniroute/opencode-plugin (tsup)...");
try {

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env node
// scripts/check/check-api-typecheck.mjs
// API-route-scoped typecheck gate (#11601).
//
// `typecheck:core` uses a curated file allowlist, the dashboard typecheck gate
// only covers src/app/(dashboard)/**, and Next builds ignore TypeScript build
// errors. That leaves src/app/api/** without a blocking typecheck gate.
//
// This gate runs `tsc` scoped to src/app/api/**/*.{ts,tsx} via
// tsconfig.typecheck-api.json and compares live diagnostics against a frozen
// per-file/per-TS-code count baseline. New diagnostics or count increases fail;
// reductions are reported as improvements and can be ratcheted with --update.
//
// Run:
// node scripts/check/check-api-typecheck.mjs
// node scripts/check/check-api-typecheck.mjs --update
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const TSCONFIG = path.join(ROOT, "tsconfig.typecheck-api.json");
const BASELINE_PATH = path.join(ROOT, "config/quality/api-typecheck-baseline.json");
const UPDATE = process.argv.includes("--update");
const TSC_ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+):/;
export function parseTscOutput(raw) {
const counts = {};
for (const line of String(raw).split("\n")) {
const match = TSC_ERROR_LINE.exec(line);
if (!match) continue;
const [, file, , , code] = match;
if (!counts[file]) counts[file] = {};
counts[file][code] = (counts[file][code] || 0) + 1;
}
return counts;
}
export function diffAgainstBaseline(live, baseline) {
const regressions = [];
const improvements = [];
for (const [file, codes] of Object.entries(live)) {
for (const [code, liveCount] of Object.entries(codes)) {
const baselineCount = (baseline[file] && baseline[file][code]) || 0;
if (liveCount > baselineCount) {
regressions.push({ file, code, liveCount, baselineCount });
} else if (liveCount < baselineCount) {
improvements.push({ file, code, liveCount, baselineCount });
}
}
}
for (const [file, codes] of Object.entries(baseline)) {
for (const [code, baselineCount] of Object.entries(codes)) {
const liveCount = (live[file] && live[file][code]) || 0;
if (liveCount === 0 && baselineCount > 0) {
improvements.push({ file, code, liveCount: 0, baselineCount });
}
}
}
return { regressions, improvements };
}
function runTsc() {
try {
return execFileSync(
process.platform === "win32" ? "npx.cmd" : "npx",
["tsc", "--pretty", "false", "--noEmit", "-p", TSCONFIG],
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024, cwd: ROOT }
);
} catch (err) {
if (err.stdout) return String(err.stdout);
throw err;
}
}
function loadBaseline() {
if (!fs.existsSync(BASELINE_PATH)) return {};
return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
}
function writeBaseline(counts) {
fs.writeFileSync(BASELINE_PATH, JSON.stringify(counts, null, 2) + "\n");
}
function main() {
if (!fs.existsSync(TSCONFIG)) {
process.stderr.write(`[api-typecheck] FAIL — tsconfig not found at ${TSCONFIG}\n`);
process.exit(2);
}
console.log("[api-typecheck] Running tsc scoped to src/app/api/**…");
const stdout = runTsc();
const live = parseTscOutput(stdout);
const baseline = loadBaseline();
const { regressions, improvements } = diffAgainstBaseline(live, baseline);
const liveErrorCount = Object.values(live).reduce(
(sum, codes) => sum + Object.values(codes).reduce((s, c) => s + c, 0),
0
);
console.log(`apiTypecheckErrors=${liveErrorCount}`);
if (UPDATE) {
writeBaseline(live);
console.log(`[api-typecheck] baseline rewritten (${liveErrorCount} errors frozen).`);
process.exit(0);
}
if (improvements.length > 0) {
console.log(
`[api-typecheck] ${improvements.length} baselined error(s) no longer present ` +
`— run 'node scripts/check/check-api-typecheck.mjs --update' to ratchet the baseline down:\n` +
improvements
.map((i) => ` - ${i.file} ${i.code} (baseline ${i.baselineCount} -> live ${i.liveCount})`)
.join("\n")
);
}
if (regressions.length > 0) {
process.stderr.write(
`[api-typecheck] FAIL — ${regressions.length} new/regressed TypeScript error(s) ` +
`under src/app/api/ not covered by the frozen baseline:\n` +
regressions
.map((r) => `${r.file} ${r.code} (baseline ${r.baselineCount}, live ${r.liveCount})`)
.join("\n") +
`\n\nFix new API-route TypeScript regressions rather than widening the baseline.\n`
);
process.exit(1);
}
console.log(
`[api-typecheck] OK — ${liveErrorCount} pre-existing error(s), all within frozen baseline.`
);
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
main();
}

View File

@@ -1,89 +1,24 @@
#!/usr/bin/env node
// scripts/check/check-db-rules.mjs
// Gate de convenções de banco (CLAUDE.md Hard Rules #2 e #5). Três verificações:
// (a) Todo módulo de domínio em src/lib/db/*.ts deve ser re-exportado por
// src/lib/localDb.ts (camada de compat). Um módulo db NOVO que não é
// re-exportado (e não está congelado) falha — força a decisão consciente
// de expor ou justificar (Hard Rule #2).
// (b) src/lib/localDb.ts é APENAS camada de re-export: nada de lógica
// (function/class/arrow de negócio). Mata o anti-padrão de "só uma
// funçãozinha aqui" que vira regra de negócio fora dos módulos db/.
// Gate de convenções de banco (CLAUDE.md Hard Rules #2 e #5). Uma verificação:
// (c) Nenhum SQL cru em src/app/api/**/route.ts ou open-sse/handlers/*.ts.
// SQL deve viver em src/lib/db/ (Hard Rule #5). Ofensores pré-existentes
// são congelados; QUALQUER novo SQL cru em rota/handler falha.
// Stale-enforcement (6A.3): entradas em INTENTIONALLY_INTERNAL / EXTERNAL_DB_ALLOWED
// que não suprimem nenhuma violação real → gate falha com instrução de remoção.
// As antigas verificações (a) re-export completo via src/lib/localDb.ts e
// (b) localDb.ts sem lógica foram REMOVIDAS: o barrel src/lib/localDb.ts foi
// deletado (#11795) — consumidores importam módulos src/lib/db/* diretamente
// (regra "never barrel-import", aplicada por eslint no-restricted-imports).
// Stale-enforcement (6A.3): entradas em EXTERNAL_DB_ALLOWED que não suprimem
// nenhuma violação real → gate falha com instrução de remoção.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
const cwd = process.cwd();
const DB_DIR = path.join(cwd, "src/lib/db");
const LOCAL_DB = path.join(cwd, "src/lib/localDb.ts");
const API_DIR = path.join(cwd, "src/app/api");
const HANDLERS_DIR = path.join(cwd, "open-sse/handlers");
// (a) Módulos db/ que NÃO são re-exportados por localDb.ts por DESIGN (Hard Rule #2:
// "Never barrel-import from localDb.ts — import specific db/ modules instead").
// Cada entrada aqui foi auditada e é consumida via import direto de "@/lib/db/X"
// (estático ou dinâmico) pelos seus consumidores — exatamente o padrão correto.
// Re-exportar esses módulos via localDb.ts INCENTIVARIA o anti-padrão proibido.
// O gate ainda bloqueia QUALQUER módulo db/ NOVO que não seja re-exportado E não
// esteja nessa lista — mantendo a decisão consciente obrigatória (Hard Rule #2).
// Legenda de classificação:
// type-only = exporta apenas tipos (sem runtime API), não há o que re-exportar
// db-internal = importado apenas dentro de src/lib/db/ (coordenação interna)
// intentionally-internal = consumido por import direto fora de db/ (correto per Rule #2)
// DEAD? = zero importers encontrados na auditoria de 2026-06-11; não deletar
// sem investigação — pode ser reserva de schema ou F2 pendente
export const INTENTIONALLY_INTERNAL = new Set([
"_rowTypes", // type-only: 5 importers internos em db/ (AgentBridge/Inspector row types)
"accessTokens", // intentionally-internal: 4 rotas /api/cli/* (connect, whoami, tokens, tokens/[id]) + server/authz/accessTokenAuth.ts via import direto "@/lib/db/accessTokens" (Rule #2)
"apiKeyColumnFallbacks", // db-internal: importado só por db/apiKeys.ts (API_KEY_COLUMN_FALLBACKS — fallbacks de coluna split do apiKeys.ts)
"apiKeyUsageLimitFields", // db-internal: importado só por db/apiKeys.ts (helpers de campo de limite de uso split do apiKeys.ts; mig 101)
"backupRetention", // db-internal: importado só por db/backup.ts e db/migrationRunner.ts (política de retenção compartilhada; mora fora de backup.ts porque core.ts importa migrationRunner.ts — importar backup.ts de lá fecharia um ciclo, #10421)
"caseMapping", // db-internal: importado só por db/core.ts (toSnakeCase/toCamelCase/objToSnake — column-mapping snake↔camel split do core.ts, #4947)
"cleanup", // intentionally-internal: 3 API routes (purge-quota-snapshots, purge-call-logs, purge-detailed-logs)
"cliToolState", // intentionally-internal: 14+ API routes em /api/cli-tools/*-settings
"comboForecast", // intentionally-internal: src/lib/usage/comboForecast.ts
"commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/*
"compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config)
"compressionDetailNormalizers", // db-internal: importado só por db/compression.ts (normalizeSessionDedupConfig/normalizeCcrConfig/buildDetailConfigDefaults/applyDetailConfigUpdate — normalizadores do detail-config split do compression.ts, #8404)
"connectionRuntimeState", // intentionally-internal: warmupScheduler sqlite/redis stores importam diretamente de @/lib/db/connectionRuntimeState (Rule #2)
"vacuumScheduler", // intentionally-internal: src/instrumentation-node.ts (dynamic import, lifecycle wiring per Rule #2)
"detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler)
"discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery
"domainState", // intentionally-internal: 5 callers (batchWriter, circuitBreaker, costRules, fallbackPolicy, lockoutPolicy)
"encryption", // intentionally-internal: 8+ callers (container, webhookDispatcher, cloudAgent/credentials, services/apiKey, 4+ routes, open-sse)
"healthCheck", // db-internal: importado por db/core.ts (runDbHealthCheck)
"jsonMigration", // intentionally-internal: src/app/api/settings/import-json/route.ts
"migrationRunner", // db-internal: importado por db/core.ts (runMigrations ao inicializar o DB)
"modelCapabilityOverrides", // intentionally-internal: src/app/api/model-capability-overrides/route.ts via import direto "@/lib/db/modelCapabilityOverrides" (#6727 — evita empurrar localDb.ts para o cap de 800 linhas)
"notion", // intentionally-internal: settings/notion API route + open-sse/mcp-server/tools/notionTools.ts
"obsidian", // intentionally-internal: src/lib/obsidianSync.ts + settings/obsidian route + MCP obsidianTools.ts
"optimizationSettings", // db-internal: imported by db/core.ts for SQLite PRAGMA application helpers that require the live adapter
"pluginMetrics", // DEAD? (production): write path não foi conectado ainda (documentado no cabeçalho do módulo); testado por tests/unit/plugins-metrics.test.ts
"prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts
"probeUtils", // db-internal: importado so por db/core.ts (retryProbeIfTransient no caminho da corruption-probe, #9541); testado por tests/unit/probe-9541-repro.test.ts
"providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421)
"providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts
"proxyLatency", // intentionally-internal: imported directly by src/lib/db/proxies.ts (anti-barrel, #6798)
"proxySubscriptions", // db-internal: importado só por db/proxies.ts (addProxiesToScopePool — split do proxies.ts para ficar sob o cap de tamanho congelado, #7299); a função já é re-exportada por proxies.ts (que localDb.ts re-exporta)
"recovery", // intentionally-internal: bin/cli/runtime.mjs (import() dinâmico) + tests
"schemaColumns", // db-internal: importado só por db/core.ts (ensureProviderConnections/UsageHistory/CallLogsColumns + hasColumn/hasTable/getTableColumns — schema-column reconciliation split do core.ts, #4948)
"secrets", // intentionally-internal: src/instrumentation-node.ts (import() dinâmico na inicialização)
"serviceModels", // intentionally-internal: 3 callers (services/modelSync, services/bootstrap, /api/services/9router/models)
"stateReset", // db-internal: 3 callers dentro de src/lib/db/ (core, backup, apiKeys) para coordenação de reset
"stats", // intentionally-internal: src/app/api/settings/database/refresh-stats/route.ts
"tierConfig", // intentionally-internal: open-sse/services/tierResolver.ts (require() dinâmico)
"webSessionDedup", // db-internal: importado só por db/providers.ts (webSessionCredentialKey/parseProviderSpecificData — helpers puros de dedup de credencial web-session split do providers.ts, #3368 PR6)
]);
// Alias para retrocompatibilidade com os testes existentes que importam KNOWN_UNEXPORTED.
// O comportamento do gate é idêntico — só o nome e os comentários mudaram (#3499).
export const KNOWN_UNEXPORTED = INTENTIONALLY_INTERNAL;
// (c) Leituras de SQL contra bancos EXTERNOS, permitidas por design (#3500).
// Esta rota NÃO consulta o DB do OmniRoute (getDbInstance) — ela abre o
// SQLite de OUTRO aplicativo (Kiro) para auto-importar credenciais.
@@ -97,16 +32,13 @@ export const KNOWN_UNEXPORTED = INTENTIONALLY_INTERNAL;
// precisa de entrada aqui: o SQL contra o state.vscdb externo do Cursor vive
// em src/lib/cursor/tokenExtractor.ts, fora do escopo desta checagem (que só
// varre src/app/api/**/route.ts e open-sse/handlers/*.ts).
const EXTERNAL_DB_ALLOWED = new Set([
export const EXTERNAL_DB_ALLOWED = new Set([
"src/app/api/oauth/kiro/auto-import/route.ts", // read-only no SQLite do Kiro (DB externo)
]);
// Alias de retrocompatibilidade (testes/consumidores que importam KNOWN_RAW_SQL).
// Comportamento do gate idêntico — só o nome e o enquadramento mudaram (#3500).
const KNOWN_RAW_SQL = EXTERNAL_DB_ALLOWED;
// Módulos sempre excluídos da checagem (a): não são domínio re-exportável.
const DB_MODULE_EXCLUDE = new Set(["core", "localDb", "index"]);
export const KNOWN_RAW_SQL = EXTERNAL_DB_ALLOWED;
function walk(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
@@ -118,59 +50,6 @@ function walk(dir, acc = []) {
return acc;
}
// Lista os módulos de domínio em src/lib/db (top-level *.ts), excluindo
// core/localDb/index, *.d.ts e qualquer subdiretório (migrations/, adapters/, __tests__/).
export function collectDbModules(dbDir = DB_DIR) {
if (!fs.existsSync(dbDir)) return [];
return fs
.readdirSync(dbDir, { withFileTypes: true })
.filter((e) => e.isFile() && /\.ts$/.test(e.name) && !/\.d\.ts$/.test(e.name))
.map((e) => e.name.replace(/\.ts$/, ""))
.filter((name) => !DB_MODULE_EXCLUDE.has(name))
.sort();
}
// Extrai os nomes de módulo re-exportados de localDb.ts a partir de
// `... from "./db/X"` (cobre export {…}, export * e export type {…}).
export function extractReexportedModules(localDbSource) {
const re = /from\s+["']\.\/db\/([A-Za-z0-9_]+)["']/g;
const out = new Set();
let m;
while ((m = re.exec(localDbSource))) out.add(m[1]);
return out;
}
// (a) Módulos db/ que não são re-exportados e não estão na lista de
// intencionalmente-internos (INTENTIONALLY_INTERNAL). O gate falha para
// qualquer módulo NOVO que não seja re-exportado nem justificado.
export function findMissingReexports(dbModules, reexported, allowlist = INTENTIONALLY_INTERNAL) {
return dbModules.filter((mod) => !reexported.has(mod) && !allowlist.has(mod));
}
// (b) localDb.ts deve conter SOMENTE import/export + comentários (sem lógica).
// Remove comentários e strings, depois procura declarações de runtime.
export function hasLogic(localDbSource) {
const stripped = localDbSource
// comentários de bloco
.replace(/\/\*[\s\S]*?\*\//g, "")
// comentários de linha
.replace(/\/\/[^\n]*/g, "")
// template strings
.replace(/`(?:\\[\s\S]|[^\\`])*`/g, '""')
// strings simples/duplas (paths de import etc.)
.replace(/"(?:\\.|[^"\\])*"/g, '""')
.replace(/'(?:\\.|[^'\\])*'/g, '""');
// function/class declaradas, ou atribuição a função (const X = (…) =>, const X = function).
const logicPatterns = [
/(^|[^.\w])function\s+[A-Za-z_$]/, // function decl (não method .foo())
/(^|[^.\w])class\s+[A-Za-z_$]/, // class decl
/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?\(/, // const X = (…) ... (arrow/call)
/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s+)?function\b/, // const X = function
];
return logicPatterns.some((rx) => rx.test(stripped));
}
// SQL cru é sempre uma STRING passada a db.prepare()/exec(): casamos os padrões
// SÓ dentro de literais de string (não em código JS — `import … from`, `.set(`,
// `new Set(`, `delete x` etc. são falsos positivos se varrermos o código todo).
@@ -198,7 +77,7 @@ export function extractStringLiterals(code) {
// tira as aspas/crases delimitadoras
out.push(m[0].slice(1, -1));
}
return out.join("\n\n"); // separador que nenhum padrão SQL atravessa
return out.join("\n\u0000\n"); // separador que nenhum padrão SQL atravessa
}
// (c) Arquivos com SQL cru dentro de literais de string (linhas não-comentário),
@@ -217,7 +96,7 @@ export function findRawSql(files, allowlist = KNOWN_RAW_SQL) {
// Match each literal independently. Joining literals before scanning would
// turn harmless code such as `update(...)` plus a later `"set"` string into
// a false UPDATE ... SET SQL match.
const literals = extractStringLiterals(stripComments(src)).split("\n\0\n");
const literals = extractStringLiterals(stripComments(src)).split("\n\u0000\n");
if (literals.some((literal) => SQL_PATTERNS.some((rx) => rx.test(literal)))) {
offenders.push(rel);
}
@@ -239,33 +118,6 @@ export function collectSqlScanFiles(apiDir = API_DIR, handlersDir = HANDLERS_DIR
function main() {
const failures = [];
const localDbSource = fs.readFileSync(LOCAL_DB, "utf8");
// (a) re-export completeness
const dbModules = collectDbModules();
const reexported = extractReexportedModules(localDbSource);
// Live unexported modules BEFORE allowlist filtering (needed for stale-enforcement).
const liveUnexported = dbModules.filter((mod) => !reexported.has(mod));
assertNoStale(INTENTIONALLY_INTERNAL, liveUnexported, "check-db-rules:unexported");
const missing = findMissingReexports(dbModules, reexported);
if (missing.length) {
failures.push(
`[#2 re-export] ${missing.length} módulo(s) db/ não re-exportado(s) por src/lib/localDb.ts:\n` +
missing.map((m) => ` ✗ src/lib/db/${m}.ts`).join("\n") +
`\n → re-exporte de src/lib/localDb.ts (apenas a lista de re-export, nada de lógica)` +
` ou adicione a INTENTIONALLY_INTERNAL com justificativa (import direto de "@/lib/db/${missing[0]}").`
);
}
// (b) localDb sem lógica
if (hasLogic(localDbSource)) {
failures.push(
`[#2 sem-lógica] src/lib/localDb.ts contém lógica (function/class/arrow). É camada de` +
` re-export apenas — mova a lógica para um módulo src/lib/db/.`
);
}
// (c) SQL cru fora de db/
// Live raw-SQL offenders BEFORE allowlist filtering (needed for stale-enforcement).
@@ -289,8 +141,8 @@ function main() {
}
if (!process.exitCode) {
console.log(
`[check-db-rules] OK (${dbModules.length} módulos db/, ${reexported.size} re-exportados, ` +
`${INTENTIONALLY_INTERNAL.size} intencionalmente-internos (Rule #2); ${EXTERNAL_DB_ALLOWED.size} leituras de DB externo permitidas (#3500))`
`[check-db-rules] OK (${scanFiles.length} arquivos varridos; ` +
`${EXTERNAL_DB_ALLOWED.size} leituras de DB externo permitidas (#3500))`
);
}
}

View File

@@ -123,6 +123,48 @@ function discoverPackagedExecutable() {
throw new Error(`Packaged Electron smoke check does not support ${platform()}.`);
}
/**
* The packaged app opens SQLite lazily: `/login` (the readiness URL) never touches the
* database, so a smoke that only waits for readiness sees no `[DB]` line at all. After
* readiness the smoke requests a DB-backed endpoint and waits for evidence that the
* database opened. The primary open path does NOT print "[DB] Driver: ..." (only the
* recovery path and the sql.js fallback do), so the evidence is any `[DB]`/`[Migration]`
* startup line — and the #7592 guard below rejects the fallback's own line explicitly.
*/
export const DB_TOUCH_PATH = "/api/monitoring/health";
export const DB_OPEN_EVIDENCE_PATTERN =
/\[DB\] (Driver: |SQLite database ready|Added [^\n]* column|Changing cache_size|cache_size changed)|\[Migration\] (Applied|Pre-migration backup)/;
export async function waitForDatabaseOpen(getLogs, { timeoutMs = 15_000, pollMs = 250 } = {}) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const logs = getLogs();
assertNoFatalLogs(logs);
if (DB_OPEN_EVIDENCE_PATTERN.test(logs)) return logs;
await sleep(pollMs);
}
throw new Error(
`Packaged Electron app logged no [DB]/[Migration] startup line within ${timeoutMs}ms of ` +
`touching ${DB_TOUCH_PATH} — the database never opened, so the SQLite driver cannot be verified.`
);
}
async function openDatabaseForSmoke({ logs, smokeUrl }) {
const touchUrl = new URL(DB_TOUCH_PATH, smokeUrl).toString();
try {
const response = await fetchWithTimeout(touchUrl, 5_000);
console.log(
`[electron-smoke] touched ${touchUrl} (HTTP ${response.status}) to open the database`
);
} catch (error) {
console.log(
`[electron-smoke] touching ${touchUrl} failed (${error instanceof Error ? error.message : String(error)}) — waiting for the database anyway`
);
}
await waitForDatabaseOpen(() => logs.value);
console.log("[electron-smoke] database opened");
}
async function fetchWithTimeout(url, timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -465,8 +507,13 @@ export function assertNativeDriverSelected(logs) {
);
}
// The primary open path prints no "[DB] Driver: ..." line at all (only the recovery path and
// the sql.js fallback do), so a database that demonstrably opened WITHOUT the fallback's own
// line is the native driver — that is exactly what #7592 guards.
if (DB_OPEN_EVIDENCE_PATTERN.test(logs)) return;
throw new Error(
"Packaged Electron app logs contain no '[DB] Driver: ...' line — cannot confirm which SQLite " +
"Packaged Electron app logs show no database activity at all — cannot confirm which SQLite " +
"driver loaded."
);
}
@@ -560,6 +607,8 @@ async function launchAndCollectLogs({
try {
await waitForReady({ logs, smokeUrl, timeoutMs, settleMs, exitState });
// Outside waitForReady on purpose: a missing database is a verdict, not a readiness retry.
await openDatabaseForSmoke({ logs, smokeUrl });
return logs.value;
} catch (error) {
if (!streamLogs) {

View File

@@ -0,0 +1,295 @@
#!/usr/bin/env node
/**
* Video Bridge FU-07/FU-09 promotion-evidence harness (#11656).
*
* This is the CONSUMER side of the promotion pipeline: given (a) a frozen case manifest
* (src/lib/guardrails/videoBridgePromotionManifest.ts) and (b) a JSON file of raw per-run
* observations already collected by calling a real model against fixtures materialized
* from src/lib/guardrails/videoBridgePromotionFixtures.ts, it aggregates medians/p95
* (videoBridgePromotionAggregator.ts), derives the FU-07/FU-09 comparison inputs
* (videoBridgePromotionComparison.ts), evaluates both promotion verdicts
* (videoBridgePromotionEvaluator.ts), and prints a report that persists metrics + response
* DIGESTS only (videoBridgePromotionDigest.ts) — never raw media or raw model responses.
*
* It does not call any model itself and ships no fabricated data: without a real
* observations file it always reports HOLD. Collecting real observations requires a live
* model endpoint and the deterministic fixtures this repo can only describe, not execute —
* see the PR's "Pending live validation" section for the exact commands to run on
* VPS 192.168.0.15.
*
* Run: node --import tsx/esm scripts/perf/video-bridge-promotion-eval.ts --manifest <manifest.json>
* node --import tsx/esm scripts/perf/video-bridge-promotion-eval.ts --manifest <manifest.json> --observations <runs.json>
*/
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import {
aggregatePromotionObservations,
type VideoBridgePromotionAggregate,
} from "../../src/lib/guardrails/videoBridgePromotionAggregator";
import {
buildFu07PromotionInputFromAggregates,
buildFu09PromotionInputFromAggregates,
} from "../../src/lib/guardrails/videoBridgePromotionComparison";
import {
buildPersistablePromotionRecord,
type PersistablePromotionRecord,
} from "../../src/lib/guardrails/videoBridgePromotionDigest";
import {
evaluateFu07Promotion,
evaluateFu09Promotion,
type PromotionVerdict,
} from "../../src/lib/guardrails/videoBridgePromotionEvaluator";
import {
videoBridgePromotionManifestSchema,
videoBridgePromotionMetricNameSchema,
type VideoBridgePromotionManifest,
} from "../../src/lib/guardrails/videoBridgePromotionManifest";
const OVERALL_CASE_ID = "__overall__";
const videoBridgePromotionRunSchema = z
.object({
caseId: z.string().min(1),
metrics: z.partialRecord(videoBridgePromotionMetricNameSchema, z.number().finite()),
model: z.string().min(1),
rawResponseText: z.string(),
role: z.enum(["baseline", "candidate"]),
})
.strict();
const videoBridgePromotionCaseObservationsSchema = z
.object({
caseId: z.string().min(1),
criticalFactLoss: z.boolean(),
isSecurityCase: z.boolean(),
runs: z.array(videoBridgePromotionRunSchema).min(1),
securityCasePassed: z.boolean(),
})
.strict();
export const videoBridgePromotionRunFileSchema = z
.object({
cases: z.array(videoBridgePromotionCaseObservationsSchema).min(1),
manifestId: z.string().min(1),
})
.strict();
export type VideoBridgePromotionRunFile = z.infer<typeof videoBridgePromotionRunFileSchema>;
export interface VideoBridgePromotionReport {
candidateModel: string | null;
execution: { state: "executed" | "not-configured" };
fu07: PromotionVerdict;
fu09: PromotionVerdict;
generatedAt: string;
kind: "video-bridge-fu07-fu09-promotion-eval";
manifestId: string | null;
missingConfiguration: string[];
records: PersistablePromotionRecord[];
schemaVersion: 1;
}
export function createVideoBridgePromotionHoldReport(
missingConfiguration: string[]
): VideoBridgePromotionReport {
const reasons = ["REAL_EVIDENCE_RUN_NOT_CONFIGURED"];
return {
candidateModel: null,
execution: { state: "not-configured" },
fu07: { reasons, status: "hold" },
fu09: { reasons, status: "hold" },
generatedAt: new Date().toISOString(),
kind: "video-bridge-fu07-fu09-promotion-eval",
manifestId: null,
missingConfiguration,
records: [],
schemaVersion: 1,
};
}
function toAggregate(aggregate: VideoBridgePromotionAggregate | undefined): {
medians: VideoBridgePromotionAggregate["medians"];
p95: VideoBridgePromotionAggregate["p95"];
} {
return { medians: aggregate?.medians ?? {}, p95: aggregate?.p95 ?? {} };
}
function aggregateByRole(
runFile: VideoBridgePromotionRunFile,
role: "baseline" | "candidate"
): VideoBridgePromotionAggregate | undefined {
const observations = runFile.cases.flatMap((currentCase) =>
currentCase.runs
.filter((run) => run.role === role)
.map((run) => ({ caseId: OVERALL_CASE_ID, metrics: run.metrics, model: run.model }))
);
return aggregatePromotionObservations(observations)[0];
}
function resolveModel(
runFile: VideoBridgePromotionRunFile,
role: "baseline" | "candidate"
): string | null {
for (const currentCase of runFile.cases) {
const match = currentCase.runs.find((run) => run.role === role);
if (match) return match.model;
}
return null;
}
function overallCriticalFactLoss(runFile: VideoBridgePromotionRunFile): boolean {
return runFile.cases.some((currentCase) => currentCase.criticalFactLoss);
}
/**
* #11656: "passes every security case". A manifest requires >=1 security case
* (videoBridgePromotionManifestSchema); if the run file supplies no case flagged
* `isSecurityCase`, that requirement was never exercised, so it fails closed.
*/
function overallSecurityCasesPassed(runFile: VideoBridgePromotionRunFile): boolean {
const securityCases = runFile.cases.filter((currentCase) => currentCase.isSecurityCase);
if (securityCases.length === 0) return false;
return securityCases.every((currentCase) => currentCase.securityCasePassed);
}
/**
* #11656: "missing usage remains HOLD". Read strictly: token usage is available only when
* EVERY run in the file recorded `totalTokens` — a single incomplete measurement is enough
* to withhold the verdict, not just a metric absent from every run.
*/
function tokenUsageAvailable(runFile: VideoBridgePromotionRunFile): boolean {
return runFile.cases.every((currentCase) =>
currentCase.runs.every((run) => typeof run.metrics.totalTokens === "number")
);
}
function digestAllRuns(runFile: VideoBridgePromotionRunFile): PersistablePromotionRecord[] {
return runFile.cases.flatMap((currentCase) =>
currentCase.runs.map((run) =>
buildPersistablePromotionRecord({
caseId: run.caseId,
metrics: run.metrics,
model: run.model,
rawResponseText: run.rawResponseText,
})
)
);
}
/**
* Composes aggregate -> compare -> evaluate -> digest for one baseline/candidate pair
* spanning every case in `runFile`. Pure aside from `Date.now()` in `generatedAt` — the
* verdicts themselves are deterministic given identical `manifest`/`runFile` input, which
* is what #11656's "two consecutive runs produce the same eligible verdict" depends on.
*/
export function buildVideoBridgePromotionReport(
manifest: VideoBridgePromotionManifest,
runFile: VideoBridgePromotionRunFile
): VideoBridgePromotionReport {
videoBridgePromotionManifestSchema.parse(manifest);
videoBridgePromotionRunFileSchema.parse(runFile);
const baselineAggregate = aggregateByRole(runFile, "baseline");
const candidateAggregate = aggregateByRole(runFile, "candidate");
const baseline = toAggregate(baselineAggregate);
const candidate = toAggregate(candidateAggregate);
const criticalFactLoss = overallCriticalFactLoss(runFile);
const securityCasesPassed = overallSecurityCasesPassed(runFile);
const usageAvailable = tokenUsageAvailable(runFile);
const fu07 = evaluateFu07Promotion(
buildFu07PromotionInputFromAggregates({
baseline,
candidate,
criticalFactLoss,
securityCasesPassed,
tokenUsageAvailable: usageAvailable,
})
);
const fu09 = evaluateFu09Promotion(
buildFu09PromotionInputFromAggregates({
baseline,
candidate,
criticalOrSecurityLoss: criticalFactLoss || !securityCasesPassed,
tokenUsageAvailable: usageAvailable,
})
);
return {
candidateModel: resolveModel(runFile, "candidate"),
execution: { state: "executed" },
fu07,
fu09,
generatedAt: new Date().toISOString(),
kind: "video-bridge-fu07-fu09-promotion-eval",
manifestId: manifest.id,
missingConfiguration: [],
records: digestAllRuns(runFile),
schemaVersion: 1,
};
}
function readArgument(name: string): string | undefined {
const index = process.argv.indexOf(`--${name}`);
if (index < 0) return undefined;
const value = process.argv[index + 1];
return value && !value.startsWith("--") ? value : undefined;
}
function printUsage(): void {
console.log(
[
"Usage:",
" node --import tsx/esm scripts/perf/video-bridge-promotion-eval.ts --manifest <manifest.json>",
" node --import tsx/esm scripts/perf/video-bridge-promotion-eval.ts --manifest <manifest.json> --observations <runs.json>",
"",
"Without --observations this always prints a HOLD report: collecting real",
"observations requires a live model endpoint and fixtures materialized from",
"src/lib/guardrails/videoBridgePromotionFixtures.ts on a real VPS run.",
"",
"--manifest must satisfy videoBridgePromotionManifestSchema (8 frozen case kinds,",
">=3 repetitions per case, >=1 security case).",
"--observations must satisfy videoBridgePromotionRunFileSchema: per-case",
"baseline/candidate runs with metrics, a criticalFactLoss flag, and (for the",
"security case) a securityCasePassed flag.",
].join("\n")
);
}
async function loadJson<T>(filePath: string, schema: z.ZodType<T>): Promise<T> {
const raw = await readFile(path.resolve(filePath), "utf8");
return schema.parse(JSON.parse(raw));
}
async function main(): Promise<void> {
if (process.argv.includes("--help") || process.argv.includes("-h")) {
printUsage();
return;
}
const manifestPath = readArgument("manifest");
const observationsPath = readArgument("observations");
const missingConfiguration: string[] = [];
if (!manifestPath) missingConfiguration.push("--manifest");
if (!observationsPath) missingConfiguration.push("--observations");
if (missingConfiguration.length > 0) {
console.log(JSON.stringify(createVideoBridgePromotionHoldReport(missingConfiguration), null, 2));
return;
}
const manifest = await loadJson(manifestPath!, videoBridgePromotionManifestSchema);
const runFile = await loadJson(observationsPath!, videoBridgePromotionRunFileSchema);
console.log(JSON.stringify(buildVideoBridgePromotionReport(manifest, runFile), null, 2));
}
const isMainModule =
typeof process.argv[1] === "string" &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
main().catch((error: unknown) => {
console.error("Video Bridge promotion eval failed validation or execution.", error);
process.exitCode = 1;
});
}

View File

@@ -36,6 +36,12 @@ const args = [
".eslintcache",
"--suppressions-location",
"config/quality/eslint-suppressions.json",
// An "unpruned" suppression means a previously-frozen violation was legitimately
// fixed — release-time housekeeping (same bucket as ratchet drift), never a
// contributor-blocking defect. Without this flag ESLint 9.x exits 2 for that
// reason alone, which would fail this script's own JSON pass on a clean tree
// (same failure class already fixed in validate-release-green.mjs — #7837 / #11600).
"--pass-on-unpruned-suppressions",
"--format",
"json",
"--output-file",

View File

@@ -26,13 +26,23 @@ interface VideoState {
modalityBridgeVideoTimeout: number;
}
interface RuntimeStatus {
available: boolean;
ffmpegVersion: string | null;
ffprobeVersion: string | null;
reason?: string;
restricted?: boolean;
}
// Explicit UI states for the FFmpeg/ffprobe runtime probe (#11657):
// - "unknown" — not yet checked, or the check could not be completed
// (in-flight fetch, network error, non-2xx response other
// than the loopback classification below). MUST NOT be
// presented as "unavailable" — that would tell an operator
// to install FFmpeg when the real cause may be transient.
// - "restricted" — this dashboard host is not loopback, so the probe is
// intentionally skipped (client-side classification, no
// request sent).
// - "unavailable" — the probe ran and confirmed FFmpeg/ffprobe are missing
// or failed to start.
// - "available" — the probe ran and confirmed a working runtime.
type RuntimeStatus =
| { state: "unknown" }
| { state: "restricted" }
| { state: "unavailable"; reason?: string }
| { state: "available"; ffmpegVersion: string | null; ffprobeVersion: string | null };
interface ModalityBridgeVideoTabProps {
runtimeHostname?: string;
@@ -55,21 +65,22 @@ function fromApi(value: unknown): VideoState {
};
}
function parseRuntimeStatus(value: unknown): RuntimeStatus | null {
function parseRuntimeStatus(value: unknown): RuntimeStatus {
const record = asRecord(value);
if (record.restricted === true) {
if (record.restricted === true) return { state: "restricted" };
// A missing/malformed `available` field means the probe could not be
// completed (in-flight, network failure, or an unexpected response shape)
// — that is "unknown", never "unavailable".
if (typeof record.available !== "boolean") return { state: "unknown" };
if (record.available) {
return {
available: false,
ffmpegVersion: null,
ffprobeVersion: null,
restricted: true,
state: "available",
ffmpegVersion: typeof record.ffmpegVersion === "string" ? record.ffmpegVersion : null,
ffprobeVersion: typeof record.ffprobeVersion === "string" ? record.ffprobeVersion : null,
};
}
if (typeof record.available !== "boolean") return null;
return {
available: record.available,
ffmpegVersion: typeof record.ffmpegVersion === "string" ? record.ffmpegVersion : null,
ffprobeVersion: typeof record.ffprobeVersion === "string" ? record.ffprobeVersion : null,
state: "unavailable",
reason: typeof record.reason === "string" ? record.reason : undefined,
};
}
@@ -95,7 +106,7 @@ export default function ModalityBridgeVideoTab({
const t = useTranslations("settings");
const tRoot = useTranslations();
const [settings, setSettings] = useState<VideoState | null>(null);
const [runtime, setRuntime] = useState<RuntimeStatus | null>(null);
const [runtime, setRuntime] = useState<RuntimeStatus>({ state: "unknown" });
const [errorState, setErrorState] = useState<"load" | "save" | null>(null);
const persistedSettings = useRef<VideoState | null>(null);
const isVisionModel = useCallback((model: ApiModel) => model.supportsVision === true, []);
@@ -191,31 +202,35 @@ export default function ModalityBridgeVideoTab({
) : null}
<div
className={`rounded-control border p-3 text-sm ${
runtime?.available
runtime.state === "available"
? "border-success/30 bg-success/5 text-success"
: "border-warning/30 bg-warning/5 text-text-muted"
}`}
aria-live="polite"
>
{runtime?.available ? (
{runtime.state === "available" ? (
<>
<strong>{t("modalityBridgeVideoRuntimeReady")}</strong>
<div className="mt-1 text-xs">
FFmpeg {runtime.ffmpegVersion} · ffprobe {runtime.ffprobeVersion}
</div>
</>
) : runtime?.restricted ? (
) : runtime.state === "restricted" ? (
<>
<strong>{t("authz.badge.strict")}</strong>
<div className="mt-1 text-xs">{tRoot("endpoint.badgeLoopbackTooltip")}</div>
</>
) : (
) : runtime.state === "unavailable" ? (
<>
<strong>{t("modalityBridgeVideoRuntimeUnavailable")}</strong>
<div className="mt-1 text-xs">
{runtime?.reason || t("modalityBridgeVideoRuntimeInstall")}
{runtime.reason || t("modalityBridgeVideoRuntimeInstall")}
</div>
</>
) : (
<>
<strong>{t("modalityBridgeVideoRuntimeChecking")}</strong>
</>
)}
</div>

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import { JsonViewer } from "../shared/JsonViewer";
import { shortCallId } from "@/shared/utils/formatting";
interface ToolCallBlockProps {
id: string;
@@ -23,7 +24,9 @@ export function ToolCallBlock({ id, name, input }: ToolCallBlockProps) {
{expanded ? "expand_less" : "expand_more"}
</span>
<span className="text-amber-300 font-mono font-medium">{name}</span>
<span className="text-text-muted text-xs font-mono ml-auto">{id.slice(0, 8)}</span>
<span className="text-text-muted text-xs font-mono ml-auto" title={id}>
{shortCallId(id)}
</span>
</button>
{expanded && (
<div className="mt-2 border-t border-amber-500/20 pt-2">

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import { JsonViewer } from "../shared/JsonViewer";
import { shortCallId } from "@/shared/utils/formatting";
interface ToolResultBlockProps {
toolUseId: string;
@@ -22,7 +23,9 @@ export function ToolResultBlock({ toolUseId, content }: ToolResultBlockProps) {
{expanded ? "expand_less" : "expand_more"}
</span>
<span className="text-green-300 font-mono font-medium text-xs">tool_result</span>
<span className="text-text-muted text-xs font-mono ml-auto">{toolUseId.slice(0, 8)}</span>
<span className="text-text-muted text-xs font-mono ml-auto" title={toolUseId}>
{shortCallId(toolUseId)}
</span>
</button>
{expanded && (
<div className="mt-2 border-t border-green-500/20 pt-2">

View File

@@ -1,5 +1,5 @@
import { getMachineId } from "@/shared/utils/machine";
import { getSettings } from "@/lib/localDb";
import { getSettings } from "@/lib/db/settings";
import HomePageClient from "../dashboard/HomePageClient";
import BootstrapBanner from "../dashboard/BootstrapBanner";
import KimiSponsorBanner from "../dashboard/KimiSponsorBanner";

Some files were not shown because too many files have changed in this diff Show More