Compare commits

..

64 Commits

Author SHA1 Message Date
Xiangzhe
aa597ab0c1 fix(security): match cookie domains by suffix, not substring
CodeQL js/incomplete-url-substring-sanitization, alerts #860 and #861:
volcengineConsoleAutoLogin accepted any cookie whose `domain` merely *contained*
"volcengine.com".

That check is an authorization decision, not a string test. The console
auto-login harvests `digest`, `AccountID`, `csrfToken` and `userInfo` out of the
Playwright context and persists them as the operator's Volcengine credentials,
so a cookie set by `volcengine.com.attacker.tld` — or `notvolcengine.com` — was
captured and stored as a provider connection.

Add `matchesCookieDomain()` (open-sse/utils/cookieDomain.ts): exact host or
dot-boundary suffix, leading dots and case normalized on both sides, failing
closed on an empty expected domain. Same shape as the existing
`isAdobeCookieDomain` in adobeFireflyBrowserLogin.ts, which already got this
right.

While sweeping the class, inAppLoginService's cookie capture had the identical
weakness — `c.domain.includes(domain.replace(/^\./, ""))` — with the identical
consequence: a look-alike host's cookie stored as the operator's credential.
CodeQL did not flag it because the expected domain comes from
TOKEN_EXTRACTION_CONFIGS rather than a literal. Both callsites now share the
helper.

tests/unit/volcengine-cookie-domain-suffix.test.ts — 5 tests, red before the
fix, covering the real domains, seven look-alikes, empty/missing input, and the
config-supplied path.
2026-08-24 15:57:16 -03:00
Diego Rodrigues de Sa e Souza
8bbe92c692 fix(docker): size the Next build worker pool for a 16 GB runner (#11419)
Every "Publish to Docker Hub" run has failed since 2026-08-22 23:14 UTC — 96 of
the last 100. The builder stage dies with:

  ERROR: failed to solve: ResourceExhausted: process "/bin/sh -c ... npm run
  build ..." did not complete successfully: cannot allocate memory

That is the kernel, not V8. The log puts it precisely: the compile phase always
finishes ("✓ Compiled successfully in 4.2min") and the build is killed right
after "Collecting page data using 7 workers".

Each page-data worker is its own process and inherits NODE_OPTIONS, so the
--max-old-space-size ceiling is per PROCESS, not per build. CIRCLE_NODE_TOTAL=8
means 7 workers, and 7 of them alongside the parent no longer fit the 16 GB /
4 vCPU GitHub-hosted runners the pipeline builds on. It was intermittent for a
while before going 100%, which is what a threshold crossed by ordinary codebase
growth looks like — 7 was also oversubscribing a 4 vCPU runner.

Lower the pool to 3 (2 workers) and make it a build arg, so a big builder can
raise it back with `--build-arg OMNIROUTE_BUILD_WORKERS=8`.

tests/unit/docker-build-memory-budget.test.ts pins the budget: it reads the two
ARG defaults out of the Dockerfile and fails if `parent heap + workers × peak`
outgrows the runner, or if the pool oversubscribes its CPUs. Red on the base
(3/3), green here (3/3). The per-worker peak it budgets with is documented as an
inference from this failure, not a measurement.

DOCKER_GUIDE's build-arg table was stale (it still listed the pre-#10060 4096 MB
default); updated and given the new knob plus the symptom to recognize.
CIRCLE_NODE_TOTAL and OMNIROUTE_BUILD_WORKERS are allowlisted in the
fabricated-docs gate with the reason: neither is read via process.env here — one
is a Dockerfile ARG, the other is read by Next itself.

Note: the real proof is the next publish run. This failure mode only reproduces
on a memory-constrained host, so it cannot be reproduced by the unit suite; the
test guards the arithmetic, not the outcome.

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-24 15:47:47 -03:00
Diego Rodrigues de Sa e Souza
bbc7bf4351 fix(authz): match exact public routes exactly, not as prefixes (#11417)
`isPublicApiRoute()` matched every entry of PUBLIC_API_ROUTE_PREFIXES with
`startsWith()`, but 11 of the 15 entries name ONE route, not a subtree. As a
prefix each also marked every adjacent path sharing its leading characters as
PUBLIC, which skips the MANAGEMENT auth gate.

That is reachable today: Next resolves `/api/usage/om-usage<anything>` to the
dynamic route `/api/usage/[connectionId]`, and that handler carries no auth of
its own — it relies entirely on being classified MANAGEMENT. An unauthenticated
caller therefore reaches `fetchAndPersistProviderLimits()`, which is an
existence oracle over connection ids (409/404/400/200) and, for a connection id
actually starting with `om-usage`, discloses live quota JSON and can drive an
OAuth token refresh (a write side effect) with no credentials.

Split the allowlist by shape:

- PUBLIC_API_ROUTE_PREFIXES keeps only genuine subtrees, every entry ending in
  "/" (asserted by a unit test, so the class cannot come back silently).
- PUBLIC_API_ROUTES_EXACT holds the single routes, matched exactly in both
  spellings.
- The three read-only "prefixes" were single routes too and move to
  PUBLIC_READONLY_CORS_API_ROUTES, matched exactly. classify.ts now asks
  `isPublicReadonlyCorsRoute()` instead of scanning the raw list, so the CORS
  origin relaxation pipeline.ts keys on cannot be inherited by a sibling either
  (`/api/monitoring/health-detail` was taking it).
- `/api/health` deliberately stays in its own set so it keeps classifying as
  `public_prefix`; folding it into the read-only set would widen CORS on it.

dashboardCsrf.ts had a second copy of the prefix scan; it now shares
`isPublicApiRoute()` so the client CSRF exemption and the server classification
cannot disagree. Side effect in the safe direction: the three LOCAL_ONLY oauth
auto-import routes were CSRF-exempt on the client while the server already
required the token — the client now attaches it.

Reported by @ntdat812 (GHSA-74g9-q8f6-793h), with the shape of the fix and the
two gotchas above called out in the report.

Closes GHSA-74g9-q8f6-793h

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
Co-authored-by: Nguyen Thanh Dat <ntdat812.dev@gmail.com>
2026-08-24 15:47:34 -03:00
Jacob Stoner
56d64e29a4 fix(dashboard): expose custom mode-pack option (#11407)
Validado em lote combinado (batch-0824f, junto de #11399/#11400/#11402) contra o tip de release/v3.8.50: typecheck:core limpo, file-size/changelog/complexity/cognitive-complexity OK, 56/56 testes focados passando incluindo os deste PR (tests/unit/autocombo-unification.test.ts).

Baixo risco: expõe a opção "custom" já suportada em runtime (`getModePack("custom") === undefined`, cai de volta para os pesos explícitos dos sliders) no seletor compartilhado de mode-pack da UI. Obrigado pela contribuição!
2026-08-24 14:16:37 -03:00
Jacob Stoner
7b36e45df8 fix(dashboard): normalize explicit auto weights (#11402)
Validado em lote combinado (batch-0824f, junto de #11399/#11400/#11407) contra o tip de release/v3.8.50: typecheck:core limpo, file-size/changelog/complexity/cognitive-complexity OK, 56/56 testes focados passando incluindo os deste PR (tests/unit/combo-scoring-inspector.test.ts).

Baixo risco: normaliza pesos parciais/não-unitários no inspector de diagnóstico (`comboScoringInspector.ts`) reutilizando o normalizador já existente do motor real de scoring, mantendo diagnósticos consistentes com o runtime. Obrigado pela contribuição!
2026-08-24 14:16:20 -03:00
Jacob Stoner
ddee064f1b fix(sse): preserve auto scoring order (#11400)
Validado em lote combinado (batch-0824f, junto de #11399/#11402/#11407) contra o tip de release/v3.8.50: typecheck:core limpo, file-size/changelog/complexity/cognitive-complexity OK, 56/56 testes focados passando incluindo os deste PR (tests/unit/combo-task-aware.test.ts).

Remove `auto` da lista de estratégias task-routing genéricas — coerente com o #11399, que também protege a ordem já computada pelo `auto` contra reordenação por outro pós-processamento. Obrigado pela contribuição!
2026-08-24 14:16:04 -03:00
Jacob Stoner
b1fdfd5ea4 fix(sse): preserve auto-selected first target (#11399)
Validado em lote combinado (batch-0824f, junto de #11400/#11402/#11407) contra o tip de release/v3.8.50: typecheck:core limpo, file-size/changelog/complexity/cognitive-complexity OK (abaixo do baseline), 56/56 testes focados passando incluindo os deste PR (tests/unit/8370-priority-affinity-reorder.test.ts).

Aditivo e coerente: protege a ordem já decidida pelo `auto` contra reordenação pelo pós-processamento de prompt-cache-affinity — mesma linha do #11400. Obrigado pela contribuição!
2026-08-24 14:15:52 -03:00
Markus Hartung
71eeaf293c fix(combo): reconcile #11360 retry-loop persisted-cooldown recheck return shape
The retry-loop recheck returned a non-conforming {ok:false, reason} object
that breaks typecheck against the established {ok, response?} contract used
everywhere else in this function. Aligns with the pre-dispatch skip pattern
(return null after fallbackCount++), matching the PR's own intent: skip this
target and move to the next, not error the whole attempt.

This is a live fix — the broken shape reached origin/release/v3.8.50 via
#11360's own squash-merge and was breaking typecheck:core until now.
2026-08-24 12:32:09 -03:00
Markus Hartung
406f4524ff chore(quality): rebaseline file-size for #11355/#11344/#11381/#11362/#11382/#11383 growth
These entries were already validated in an earlier merge-batch worktree but
never reached origin (worktree discarded before pushing). Re-adding them
here since #11355's test/route.ts growth (1215->1237) is now live on
origin/release/v3.8.50 and fails the frozen cap otherwise.
2026-08-24 12:24:40 -03:00
Markus Hartung
dfc5b5eec4 perf(providers): lazy validate provider schema on demand to reduce startup heap 2026-08-24 12:23:50 -03:00
ggdayup
0a53c8a2ce test(providers): update reserved-prefix count fixture to 391 after upstream merge
Upstream 65e81158a added new providers to the registry; the reserved set
is a full REGISTRY walk, so the pinned count moves 329 -> 391. The
tracked-artifacts pre-commit gate fails on this branch because the same
upstream commit force-tracked two docs/superpowers/ files that its own
.gitignore excludes — an inherited upstream issue unrelated to this fix,
so hooks are skipped for this fixture-only commit with operator approval.
2026-08-24 12:22:38 -03:00
ggdayup
93da24cd79 fix(providers): reject reserved provider prefixes on compatible-node create/update
A compatible node created with prefix "tokenrouter" was silently
unreachable: the runtime model resolver (src/sse/services/model.ts)
skips compatible-node lookup for built-in registry ids/aliases, so
"tokenrouter/qwen/..." routed to the built-in tokenrouter provider and
failed with "No active credentials for provider: tokenrouter" even
though the node itself worked when addressed by its internal id.

Reject reserved prefixes at the write path instead:

- new shared module src/shared/constants/reservedProviderPrefixes.ts
  (REGISTRY ids + aliases, case-sensitive, built lazily) — single
  source of truth consumed by both the runtime guard and the
  validation schemas so they can never drift apart
- createProviderNodeSchema / updateProviderNodeSchema now reject
  reserved prefixes with a clear message naming the colliding prefix
- src/sse/services/model.ts consumes the shared module; runtime
  behavior is byte-for-byte unchanged (verified e2e)

Set semantics mirror the old inline guard exactly: manual alias ids
outside REGISTRY (xiaomi/llamacpp/aq) do not intercept nodes at
runtime and stay allowed; mixed-case input (TokenRouter) does not
collide with the exact-match runtime lookup either.
2026-08-24 12:22:38 -03:00
杨思源
815c7c2864 fix(volcengine): exempt volcengine-console from the -web naming convention
Upstream added a lint test requiring every web-cookie provider ID to end
with -web. volcengine-console extracts a console session (not a chat-web
credential), so it is exempted explicitly.
2026-08-24 12:22:00 -03:00
deploy
8f15b79a84 feat(volcengine): phone/SMS auto-login for console with MFA + identity selection
- Session-based headless login service (volcengineConsoleAutoLogin)
- API: POST /connect {phone} + /code /status /cancel /resend /identity sub-routes
- Dashboard modal: phone → SMS code → MFA step-up → identity selection
- Falls back to the legacy headful manual flow on risk-control/TOTP-binding
- Route guard: connect subtree stays LOCAL_ONLY + spawn-capable
2026-08-24 12:22:00 -03:00
yangsiyuan.rengar
07a378c86c feat(volcengine): switch Agent Plan discovery to ListAgentPlanLatestModel 2026-08-24 12:21:59 -03:00
yangsiyuan.rengar
34150506f2 fix(volcengine): retain API-callable Agent Plan models 2026-08-24 12:21:59 -03:00
yangsiyuan.rengar
76ac1c8b7e feat(volcengine): live model discovery for Ark plan providers
Replace the static curated model lists for volcengine-agent-plan and
volcengine-coding-plan with live discovery from the console APIs
(GetAgentPlanModelMappingMeta / ListArkCodeLatestModel), authenticated by
the console cookie+csrf already captured at plan binding time.

- Add volcenginePlanModelDiscovery.ts: fetch + parse + capability enrichment
  (family->contextLength/vision/reasoning map, conservative default fallback).
  Console calls go through a dynamic undici import to bypass OmniRoute's
  global fetch patch (built for LLM provider traffic, reroutes console hits).
  Coding plan's ListArkCodeLatestModel needs {AccountId:<number>} extracted
  from the console cookie; agent plan's GetAgentPlanModelMappingMeta filters
  PlatformAllowStatus===true && Type==='llm'.
- Remove both plan ids from CURATED_MODEL_ONLY_PROVIDERS so synced models
  merge into /v1/models and the dashboard Sync Models button works.
- sync-models route: short-circuit to console discovery for plan providers
  (the chat API has no /models endpoint); persist via
  replaceSyncedAvailableModelsForConnection.
- volcenginePlanBinding: set autoSync:true on new plan connections so the
  24h modelSyncScheduler refreshes them automatically.
- volcPlanAutoSyncBackfill: idempotent boot-time backfill so pre-existing
  plan connections also enter the scheduler.

Verified end-to-end on local OmniRoute build against live Volcano console:
agent plan synced 7 LLMs, coding plan synced 11 models, /v1/models exposes
all of them (incl. new glm-5-3-260801 / deepseek-v4-flash-260801).
2026-08-24 12:21:59 -03:00
yangsiyuan.rengar
d732cf615d feat(volcengine): add Ark plan providers 2026-08-24 12:21:59 -03:00
Yao Lu
f58e8bef6f fix(opencode): close Muse Responses streams at completion 2026-08-24 12:21:51 -03:00
Nicolas Duran Garces
243445f210 docs(changelog): record Codex tool call fix 2026-08-24 12:21:44 -03:00
Nicolas Duran Garces
13e29f2f39 fix(translator): preserve Claude tool call state 2026-08-24 12:21:44 -03:00
Zius
2544ee9498 feat: enable Linux PATH inheritance for autostart & extend loginShellPath to Linux (#11372)
Merged via consolidated batch validation. Fixes autostart on Linux failing to inherit the user's shell PATH (CLI-dependent features like Kiro's Google OAuth broke). Resolved a conflict against a batch sibling in bin/cli/commands/doctor.mjs (kept the more complete prebuilds-aware candidate list) and setup-claude.mjs (formatting only). Own test (login-shell-path-3321.test.ts, 10/10) passes + typecheck:core clean. Thanks!
2026-08-24 12:20:02 -03:00
Prabhudutt Dash
440113c8e8 fix(dashboard): align sync interval slider ticks via magnetic checkpoints (#11394)
Merged via consolidated batch validation. Model Database sync-interval slider used two incompatible coordinate systems (evenly spaced labels vs a linear 1-168h scale); moves the slider to checkpoint-space so the thumb and labels agree. Own test passes.
2026-08-24 12:13:45 -03:00
Bob.Hou
3c2906a80e fix(sse): kill entire process tree on Linux for adobe firefly sign-in to prevent orphan browser instances (#11387)
Merged via consolidated batch validation. Fixes orphaned browser processes on Linux for Adobe Firefly sign-in: spawns Chrome as a process-group leader (detached:true) and kills -pid instead of the single PID, with self-termination guards. Own test passes.
2026-08-24 12:13:40 -03:00
Bob.Hou
095f424658 fix(sse): spare live user message across all aggressive compression sub-paths (#11386)
Merged via consolidated batch validation. Aggressive compression could collapse the live user's active prompt into a [COMPRESSED:summary] marker; now spares the last user message across all sub-paths (applyAging, fallback summarizer, caveman/lite). Own test passes.
2026-08-24 12:13:36 -03:00
Mr White
20de0d9c79 fix(usage): parse CREDIT_LIMIT rows from z.ai coding-plan quota API (#11378)
Merged via consolidated batch validation. Z.ai's quota API now returns CREDIT_LIMIT rows for GLM Coding Plan subscription keys instead of TOKENS_LIMIT, breaking the dashboard quota card. Own test passes.
2026-08-24 12:13:19 -03:00
Nguyen Thanh Dat
9f30b76057 fix(live-ws): resolve the public socket URL at runtime (#11377)
Merged via consolidated batch validation. Fixes live-ws public socket URL resolution for prebuilt Docker/npm images, where NEXT_PUBLIC_* is inlined at build time and can never carry an operator's runtime value. Own test passes.
2026-08-24 12:13:15 -03:00
Nguyen Thanh Dat
019ad33a61 fix(auth): keep the real upstream reason in lastError (#11376)
Merged via consolidated batch validation. markAccountUnavailable collapsed every non-string upstream error reason to a generic 'Provider error' literal, hiding the actual upstream detail operators need in lastError. Own test passes.
2026-08-24 12:13:11 -03:00
Nguyen Thanh Dat
dfc9257b07 fix(cli): spawn npm the way Windows needs in omniroute update (#11374)
Merged via consolidated batch validation. Fixes omniroute update on Windows (npm.cmd cannot be execFile'd without a shell on Node >=24, nodejs/node#52554). Extracts a shared bin/cli/npm-exec.mjs (also handles Bun, windowsHide) mirroring the existing server-side pattern in src/lib/services/installers/utils.ts. Own tests pass. Note: #11336 fixed the same underlying bug (#11335) with a narrower inline change; closed as duplicate crediting this more complete fix.
2026-08-24 12:13:06 -03:00
MSiva
37e71915db fix(translator): preserve functionCall id in Gemini to OpenAI request translation (#11365)
Merged via consolidated batch validation. Fixes geminiToOpenAIRequest discarding functionCall.id in favor of a random generated id, causing multi-turn tool-call id mismatches against OpenAI-compatible upstreams. Own test passes.
2026-08-24 12:13:02 -03:00
Nguyễn Viết Tuấn
077bc1a8a2 fix(compression): use pathToFileURL for workerUrl to prevent bundler resolution failure (#11364)
Merged via consolidated batch validation. Fixes Webpack/Turbopack production build failure (Module not found: compressionWorker.js) by using pathToFileURL(join(...)) instead of new URL(..., import.meta.url), which static bundler scanning misidentifies as an asset import.
2026-08-24 12:12:43 -03:00
sprintberlin
378eff0f75 fix(combo): pre-skip targets with persisted connection cooldown and re-check on retry (#11360)
Merged via consolidated batch validation, with one fix applied during batch validation: the retry-loop persisted-cooldown recheck returned a non-conforming {ok:false, reason} shape that failed typecheck against the established {ok, response?} contract — aligned it with the pre-dispatch skip pattern (return null after fallbackCount++), matching this PR's own intent (skip the target, don't error the whole attempt). Pre-skips combo targets with a persisted connection cooldown and re-checks fresh before transient retries. Own regression suite (13/13, including the fixed retry-recheck path) passes.
2026-08-24 12:12:38 -03:00
Rouzbeh†
6de542b9b6 fix(providers): mark Antigravity connects with no Cloud Code projectId as degraded (#11284) (#11358)
Merged via consolidated batch validation. Production evidence (VPS docker instance): Antigravity OAuth connects ending without a Cloud Code projectId were persisted as silently active while every model call failed; now persisted as degraded. Own tests pass.
2026-08-24 12:12:33 -03:00
sprintberlin
315b0a94e1 fix(resilience): preserve active cooldowns during recovery and probes (#11355)
Merged via consolidated batch validation (fix applied for a cross-PR interaction with #11360, both boarded in the same batch — see combo.ts reconciliation commit). Startup crash recovery cleared every non-terminal transient cooldown unconditionally, erasing legitimate multi-day weekly quota cooldowns on restart. Now only clears expired/unparseable ones. Own repro tests pass.
2026-08-24 12:12:30 -03:00
sprintberlin
e1c2b347f9 fix(quota): parse absolute ISO datetime reset timestamps in weekly quota fallback (#11353)
Merged via consolidated batch validation. Fixes GLM/Z.AI weekly quota fallback: parseDayGranularityResetMs only recognized 'reset in N days', dropping the real multi-day cooldown when upstream returns a full absolute ISO datetime. Own repro test passes.
2026-08-24 12:12:26 -03:00
Paco Cartones
f88aa48847 test(db): make exclusive-connection-lease uniqueness test self-contained (#11341)
Merged via consolidated batch validation. Test-only fix: exclusive-connection-lease uniqueness test implicitly depended on lease state from an earlier test in the same file (shared DB instance, reset only in test.after) — now self-contained. No production change.
2026-08-24 12:12:04 -03:00
Paco Cartones
8301984734 fix(i18n): complete zh-CN/zh-TW CLI locales and guard their parity (#11339)
Merged via consolidated batch validation. Completes 45 missing zh-CN/zh-TW CLI locale keys and adds a parity guard so future gaps fail CI. Own tests pass.
2026-08-24 12:12:00 -03:00
Paco Cartones
028f1b91e4 fix(release): count sweep-stale matches by their real category in the summary (#11338)
Merged via consolidated batch validation. Fixes sweep-stale-fragments.mjs miscounting: classifyFragments never actually produces matchedBy==="ref" (only "pr-number"/"text"), so the pr-number bucket was permanently 0 in the release captain's report. Own test passes.
2026-08-24 12:11:56 -03:00
stanley
2af1326adf fix(catalog): add Stealth Ox Alpha (stealth/ox-alpha) to the openrouter free roster (#11337)
Merged via consolidated batch validation. Data fix so stealth/ox-alpha becomes visible in /v1/models under hidePaidModels (synced-provider-row filter drops pricing metadata before isFreeModel; adds :free suffix handling). Own test passes.
2026-08-24 12:11:52 -03:00
Paco Cartones
644dd32d3f fix(cli): resolve tray runtime import to a file:// URL so --tray works on Windows (#11332)
Merged via consolidated batch validation. Fixes omniroute server --tray on Windows: absolute paths passed to dynamic import() are parsed as URLs, and a Windows drive letter (C:) isn't a supported URL scheme. Resolves via pathToFileURL. Own regression test passes.
2026-08-24 12:11:49 -03:00
Diego Rodrigues de Sa e Souza
9df3f8923d fix(build): stop bundling the better-sqlite3 stub at runtime (#11343) (#11391)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824e`, 27-PR batch). Critical fix: next.config.mjs unconditionally aliased better-sqlite3 to its build-time stub, but Turbopack's resolveAlias applies at RUNTIME too — every request on any build from the release/v3.8.50 tip answered HTTP 500 because the real driver was never loaded. Gates green; own tests pass.
2026-08-24 12:11:36 -03:00
Markus Hartung
0b7ac870ef sync with tip before push 2026-08-24 09:55:31 -03:00
Markus Hartung
9fedc1c411 merge #11381 onto updated tip 2026-08-24 09:50:48 -03:00
Markus Hartung
e589831952 sync with tip before push 2026-08-24 09:46:03 -03:00
Diego Rodrigues de Sa e Souza
04d2a60331 fix(video): make one-frame scene sampling deterministic (#11344)
Merged via consolidated batch validation. Makes scene_aware Video Bridge sampling deterministic for a one-frame budget: falls back to the midpoint of the active full-video/focus window and reports policyEffective: uniform (a single scene candidate can't preserve both temporal ends). Adds opt-in real-FFmpeg fixture matrix (rapid edge cuts, one-frame budget, static/gradual scenes, sub-second clips, detector failure). Static gates green; own regression suite (videoBridgeSampler.test.ts, video-bridge-sampler-ffmpeg.test.ts) passed in the combined-batch run. Related to #9760. Thanks!
2026-08-24 09:44:50 -03:00
Markus Hartung
d23bfefec0 merge #11383 onto updated tip 2026-08-24 09:44:36 -03:00
Markus Hartung
c8ad44e018 merge #11350 onto updated tip 2026-08-24 09:41:55 -03:00
Diego Rodrigues de Sa e Souza
c83116e634 fix(video): isolate drill-down cache by principal (#11369)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`). Video Bridge FU-08 drill-down cache substrate hardening (explicitly PARTIAL per the PR body — no production producer/callsite feeds this cache yet): canonical isolation by principalId+sessionId+videoRef, loopback broker auth, strict Zod contracts, per-principal + global LRU quotas, full JPEG decode/re-encode with truncated-scan and polyglot-tail rejection, cancellation-safe atomic replacement. Static gates green; own regression suite (videoBridgeDrilldown.test.ts, video-bridge-drilldown-authz.test.ts, video-bridge-drilldown-route.test.ts) passed in the combined-batch run. Thanks!
2026-08-24 09:39:40 -03:00
Diego Rodrigues de Sa e Souza
7715825cb8 fix(video): harden visual frame deduplication (#11382)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`), stacked on the just-merged #11362 as documented. Moves the Video Bridge frame cap to post-dedup, bounds the perceptual candidate pool to at most 2x budget (max 16), includes the dedup policy/version in result-cache identity, adds cooperative abort checks to the comparator loop. Static gates green; own dedup/cache-version regression suite passed in the combined-batch run (grayscale-16x16-mean-cells-v2 policy, real fixtures). Thanks!
2026-08-24 09:31:00 -03:00
Diego Rodrigues de Sa e Souza
761d38f433 fix(video): harden result cache identity and coalescing (#11362)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`). Completes the Video Bridge FU-01 cache-hardening slice: fingerprints authorized video bytes + result-affecting dimensions before a persistent cache hit, strict metadata validation with corrupt-entry recompute, TTL/LRU bounds by count/entry-bytes/aggregate-bytes, coalesced protected HTTPS downloads isolated by tenant, deadline/abort-bounded model selection. Static gates green; own regression suite (tests/unit/guardrails/videoBridgeResultCache.test.ts) passed in the combined-batch run. Thanks!
2026-08-24 09:26:06 -03:00
Diego Rodrigues de Sa e Souza
c6963ca5dd fix(changelog): require verified reconciliation ledger (#11345)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`). Removes the broad ALLOW_CHANGELOG_REMOVALS bypass from the anti-CHANGELOG-eat gate and requires a reviewed, SHA-256-bound reconciliation ledger for intentional release-note rewrites (fails closed on malformed/stale/partial ledgers, retired bypass usage). Static gates green; own regression suite (tests/unit/check-changelog-integrity.test.ts, tests/unit/merge-train-plan.test.ts) passed in the combined-batch run — 15/15 CLI/ledger cases. Related to #9985. Thanks!
2026-08-24 09:25:40 -03:00
Diego Rodrigues de Sa e Souza
b010d8bf86 docs(readme): reconcile v3.8.50 metrics and contributors (#11356)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`). Reconciles README/diagram claims against the live release branch with explicit, non-conflated denominators (merged-PR ranking vs GitHub Contributors REST vs normalized Git census) and adds a repository-local SVG validator. Static gates green; own SVG-validator + render-pipeline tests (tests/unit/docs-validate-svg.test.ts) passed in the combined-batch run, docs:check-all clean per the PR's own evidence. Thanks!
2026-08-24 09:25:29 -03:00
Diego Rodrigues de Sa e Souza
fdcd15e6a9 docs(openapi): document try proxy operation (#11363)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`). Restores the OpenAPI operation-coverage ratchet by documenting POST /api/openapi/try (allowlist, verbs, header denylist, auth, response envelope). Static gates green (typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity); own contract test (tests/unit/openapi-security-tiers.test.ts) passed in the combined-batch run. Thanks!
2026-08-24 09:25:18 -03:00
Diego Rodrigues de Sa e Souza
12b8df02dd fix(catalog): keep large builds event-loop responsive (#11367)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`, 11-PR video-bridge/catalog/ops batch, tip `dafb4ae8`). Fixes the #9147 catalog-scale event-loop regression: reuses one build-local capability snapshot, yields cooperatively during catalog/virtual-pool construction, reads only persisted TTL settings. Static gates: typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity all green. Own regression test (tests/unit/9147-catalog-eventloop-yield.test.ts) reproduced the RED→GREEN transition in isolated runs per the PR's own evidence; under current shared-devbox load (10-15, multiple parallel sessions) the test intermittently reports INFRA-RED exactly as the PR body pre-disclosed (documented starvation signature, not a code defect). Thanks for the careful RED/GREEN + INFRA-RED discipline.
2026-08-24 09:24:59 -03:00
Diego Rodrigues de Sa e Souza
38d21afc2d docs(changelog): link FU-04 pull request 2026-08-24 08:40:33 -03:00
Diego Rodrigues de Sa e Souza
05e76d6e76 docs(changelog): link FU-07 pull request 2026-08-24 08:31:59 -03:00
Diego Rodrigues de Sa e Souza
93135f8e18 feat(guardrails): add focused video analysis mode 2026-08-24 07:30:28 -03:00
Diego Rodrigues de Sa e Souza
22086a73fa fix(video-bridge): validate structural segment sampling 2026-08-24 06:35:58 -03:00
Diego Rodrigues de Sa e Souza
d4ade9d1d3 fix(video): separate tenant scope from download hash 2026-08-24 05:55:00 -03:00
Diego Rodrigues de Sa e Souza
f54c93c879 fix(video): key download flights with process HMAC 2026-08-24 05:16:13 -03:00
Diego Rodrigues de Sa e Souza
e2e48fdab8 docs(changelog): link Video Bridge cache fix PR 2026-08-24 04:37:54 -03:00
Diego Rodrigues de Sa e Souza
d2cea0811a fix(video): harden result cache identity and bounds 2026-08-24 04:31:27 -03:00
Diego Rodrigues de Sa e Souza
2f18a85310 docs(changelog): link Video Bridge contact-sheet fix 2026-08-24 03:35:10 -03:00
Diego Rodrigues de Sa e Souza
38969ad16b fix(video-bridge): render timestamped contact sheets 2026-08-24 03:20:43 -03:00
209 changed files with 16988 additions and 1293 deletions

View File

@@ -2433,10 +2433,10 @@ APP_LOG_TO_FILE=true
# test suite must NEVER mutate the OS trust store (a fake test PEM installed via
# update-ca-certificates broke all system TLS on a persistent runner, 2026-07-05).
# OMNIROUTE_SKIP_SYSTEM_TRUST=1
# check-changelog-integrity.mjs (anti CHANGELOG-eat gate): explicit base ref
# override, and the justified-removal escape hatch for intentional bullet removals.
# check-changelog-integrity.mjs (anti CHANGELOG-eat gate): explicit base ref override.
# Intentional transformations require an exact reviewed entry in
# config/release/changelog-reconciliations.json; there is no runtime bypass.
# CHANGELOG_BASE_REF=origin/release/v0.0.0
# ALLOW_CHANGELOG_REMOVALS=1
# ── Remote audio provider nodes ──
# Used by: src/app/api/v1/_shared/audioProviderNodes.ts — lets the /v1/audio/*

View File

@@ -180,6 +180,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
### 🐛 Bug Fixes
- **fix(build):** every route no longer answers HTTP 500 on artifacts built from the release tip ([#11343](https://github.com/diegosouzapw/OmniRoute/issues/11343)) — `next.config.mjs` aliased `better-sqlite3` to its build-time stub **unconditionally**, on the premise that `serverExternalPackages` still won at runtime. It does not: a Turbopack `resolveAlias` rewrites the request *before* the externals check, so the request stopped matching the `better-sqlite3` external entry and the stub was baked into the shipped bundle. The sync driver then failed with `r(...) is not a constructor`, fell through `node:sqlite` and sql.js, and the instrumentation hook aborted at boot. Same failure shape as [#6344](https://github.com/diegosouzapw/OmniRoute/issues/6344), so it gets the same treatment: the alias is opt-in via `OMNIROUTE_BETTER_SQLITE3_STUB=1` through the shared `scripts/build/better-sqlite3-stub-flag.mjs` helper — set it only on a build host that actually hits the SIGABRT build-worker teardown ([#10060](https://github.com/diegosouzapw/OmniRoute/issues/10060)); default builds externalize the real native addon. Regression guards: `tests/unit/better-sqlite3-stub-alias-11343.test.mjs` (5) and the env matrix in `tests/unit/next-config.test.ts`.
- **security(search)**: block SSRF via `/v1/search` `provider_options.baseUrl` for the Firecrawl search provider — the client-controlled override is now validated as a public URL before it is used to build the server-side fetch target, so a caller with a valid API key can no longer redirect search requests at loopback, RFC1918, or cloud-metadata hosts — thanks @zmf963
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
- **cli**: route provider test commands through configured connection test endpoints (#10570)

View File

@@ -181,10 +181,23 @@ ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
# workers for page-data collection (31 on a 32-core builder); on memory-tight
# hosts 31 workers + webpack's multi-GB heap blow past RAM and a worker dies
# with SIGSEGV at teardown ("worker exited with code: null and signal: SIGSEGV"),
# silently leaving no standalone bundle. Next derives the default worker count
# from CIRCLE_NODE_TOTAL (workers = N-1), so N=8 → 7 workers: fast enough while
# fitting comfortably in RAM on any host. (#10060)
ENV CIRCLE_NODE_TOTAL=8
# silently leaving no standalone bundle. Next derives the worker count from
# CIRCLE_NODE_TOTAL (workers = N-1). (#10060)
#
# Lowered 8 → 3 (7 workers → 2). Every page-data worker inherits NODE_OPTIONS
# above, so the ceiling is per PROCESS, not per build: 7 workers on a 16 GB
# GitHub runner (ubuntu-24.04 / ubuntu-24.04-arm, 4 vCPU) exhausted the host and
# buildkit failed the whole step with `ResourceExhausted: ... cannot allocate
# memory`. The compile phase always finished ("✓ Compiled successfully in
# 4.2min"); the kernel killed the build right after "Collecting page data using
# 7 workers". It was intermittent for a while and went 100% on 2026-08-22, which
# is what a threshold being crossed by ordinary codebase growth looks like.
# tests/unit/docker-build-memory-budget.test.ts does the arithmetic and fails if
# either knob is raised past what a 16 GB runner holds. 2 workers also stops
# oversubscribing the runner's 4 vCPU, which 7 did. Override for a big builder:
# `--build-arg OMNIROUTE_BUILD_WORKERS=8`.
ARG OMNIROUTE_BUILD_WORKERS=3
ENV CIRCLE_NODE_TOTAL=${OMNIROUTE_BUILD_WORKERS}
COPY . ./
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \

407
README.md
View File

@@ -17,9 +17,9 @@
</div>
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute aggregates the **documented** free tiers of **42 provider pools / 495 models** into one honest number and shows it live on the dashboard (`/dashboard/free-tiers`).
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **455 free-tier entries across 40 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`).
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from the documented free tiers of 42 provider pools / 495 models behind one endpoint. Honest pool-deduped math — each shared pool counted once (counting every rate limit 24/7 would read ~10B; not published), 15 providers ToS-flagged so you decide. Budget bar of the countable free pools with per-model grid (Mistral Large 3 1B, GPT-4o mini 150M, Gemini 2.5 Flash 60M … Claude Sonnet 4.5 25K), one-time first-month signup credits (vertex 300M, agentrouter 200M, predibase 25M, together 25M, glm-cn 20M, doubao 15M, ai21 10M, longcat 10M, deepseek 5M, hyperbolic 5M, nscale 5M), plus permanently-free no-token-cap providers (SiliconFlow, Z.AI GLM-Flash, Kilo, OpenCode Zen, baidu …) and a $10 OpenRouter top-up unlocking +24M/mo — surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 40 documented recurring pool keys covering 455 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 15 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
> Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**.
>
@@ -61,14 +61,14 @@
<div align="center">
| | v3.8.49 | **v3.8.50** | `v3.8.51+` |
| ------------------------- | :-----: | :---------: | :---------: |
| 🌐 Providers | 290 | **342** | more queued |
| 🧠 Documented models | 1185 | **1202** | — |
| 🖼️ Modality Bridge | — | 🆕 vision | video |
| 📡 Radar free catalog | — | 🆕 opt-in | — |
| ⚖️ Quota-aware scheduling | — | | 🔭 next |
| 📊 Quota telemetry | — | | 🔭 next |
| | v3.8.49 | **v3.8.50** | `v3.8.51+` |
| ------------------------- | :-----: | :-----------------------: | :---------: |
| 🌐 Providers | 290 | **350** | more queued |
| 🧠 Unique chat model IDs | 1185 | **1312** | — |
| 🖼️ Modality Bridge | — | 🆕 vision + audio + video | — |
| 📡 Radar free catalog | — | 🆕 opt-in | — |
| ⚖️ Quota-aware scheduling | — | 🆕 Quota-Share | |
| 📊 Quota telemetry | — | 🆕 live | |
**→ [Roadmap](ROADMAP.md) — riding the rail to `v3.9.0 LTS`**
@@ -101,7 +101,7 @@
<tr>
<td align="right"><b>⚙️ Features</b></td>
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
<td align="center"><a href="#-349-ai-providers--90-free">🌐 Providers</a></td>
<td align="center"><a href="#-350-ai-providers--154-catalog-marked-free">🌐 Providers</a></td>
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI &amp; MCP</a></td>
</tr>
<tr>
@@ -126,7 +126,7 @@
<td align="right"><b>📦 Project</b></td>
<td align="center"><a href="#%EF%B8%8F-tech-stack">🛠️ Tech Stack</a></td>
<td align="center"><a href="#-documentation">📖 Docs</a></td>
<td align="center"><a href="#-500-contributors">👥 Contributors</a></td>
<td align="center"><a href="#-600-contributors">👥 Contributors</a></td>
</tr>
</table>
@@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 350 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 350 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 1595%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI Claude Gemini Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 350 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 350 providers · up to 95% token savings on eligible workloads · $0 to start with 90+ free tiers and 56 recurring/keyless free-forever providers · 35 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
<br/>
<br/>
@@ -225,7 +225,7 @@ curl http://localhost:20128/v1/chat/completions \
<div align="center">
<img src="./docs/diagrams/tier-cascade.svg" width="100%" alt="OmniRoute request flow: your IDE or CLI (Claude Code, Cursor, Cline…) calls one local endpoint (http://localhost:20128/v1); the OmniRoute Smart Router (RTK + Caveman compression, 19 routing strategies, circuit breakers, TLS stealth, MCP, A2A, guardrails) auto-falls back across 4 provider tiers — Tier 1 Subscription (Claude Code, Codex, Copilot), quota out? Tier 2 API Key (DeepSeek, Groq, xAI), budget hit? Tier 3 Cheap (GLM $0.5, MiniMax $0.2), budget hit? Tier 4 Free (Kiro, Qoder, Pollinations) — always on."/>
<img src="./docs/diagrams/tier-cascade.svg" width="100%" alt="OmniRoute request flow: your IDE or CLI (Claude Code, Cursor, Cline…) calls one local endpoint (http://localhost:20128/v1); the OmniRoute Smart Router (RTK + Caveman compression, 19 routing strategies, circuit breakers, TLS stealth, MCP, A2A, guardrails) can fall back across 4 provider tiers while an eligible healthy target remains — Tier 1 Subscription, Tier 2 API Key, Tier 3 Cheap and Tier 4 Free."/>
</div>
@@ -318,7 +318,7 @@ curl http://localhost:20128/v1/chat/completions \
<img src="./docs/diagrams/strategies-grid.svg" width="100%" alt="All 19 combo routing strategies animated — one tile per strategy: priority, fill-first, weighted, round-robin, p2c, least-used, random, strict-random, cost-optimized, headroom, reset-window, reset-aware, context-relay, context-optimized, cache-optimized, lkgp, auto, fusion, pipeline. See the table above for what each one does."/>
> A **combo** is a chain of models OmniRoute routes across **automatically**. Quota runs out, a provider fails, or costs spike the combo silently slides to the next model. **This is what makes OmniRoute unbreakable.** 🛡️
> A **combo** is a chain of models OmniRoute routes across **automatically**. If quota runs out, a provider fails, or costs spike, the combo can move to the next eligible healthy model. 🛡️
### ⚡ Zero-config — just use `auto`
@@ -429,7 +429,7 @@ All **19** strategies — mix & match per combo step:
<tr>
<td align="center">17</td>
<td nowrap><code>auto</code></td>
<td>14-factor live scoring across every connection 🤖</td>
<td>15-factor live scoring across every connection 🤖</td>
</tr>
<tr>
<td align="center">18</td>
@@ -443,7 +443,7 @@ All **19** strategies — mix & match per combo step:
</tr>
</table>
<sub>The Auto-Combo engine scores every candidate on **14 factors** (health, quota, cost, latency, success rate, freshness…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md).</sub>
<sub>The Auto-Combo engine scores every candidate on **15 factors** (health, quota, cost, latency, task fit, quality, session availability…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md).</sub>
##
@@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step:
</div>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 350 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project&apos;s docs."/>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 350 providers, 90+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 43 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology."/>
<sub>📊 Full methodology &amp; per-feature detail vs 9router, OpenRouter, CLIProxyAPI &amp; LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
@@ -517,9 +517,9 @@ Pix copia-e-cola:
## 📡 OmniRoute Radar
The main free-tier headline remains **~1.53B tokens/month** from the documented,
The main free-tier headline remains **~1.51B tokens/month** from the documented,
pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
month to **~2.15B**. Radar is an optional, signed catalog overlay for people who want fresher
month to **~2.13B**. Radar is an optional, signed catalog overlay for people who want fresher
free-model availability between OmniRoute releases; the community catalog and every existing free
feature remain free.
@@ -548,7 +548,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
- **🗜️ Compression hardening** — default-on inflation guard, Caveman packs for DE / FR / JA + Chinese (wényán), RTK filters for Gradle & .NET. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
- **💸 Honest flat-rate cost** — subscription / coding-plan providers read **$0** in cost analytics; budget, quota & routing keep estimating. → [API Reference](docs/reference/API_REFERENCE.md)
- **⚖️ Quota-Share routing** — split a shared account's quota fairly across pooled keys, work-conserving so idle slices are lent out. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
- **🤖 One-command CLI/agent setup** — `setup-*` configures 12+ coding tools; `omniroute run` launches 7 CLIs (Claude Code, Codex, Aider, Goose, OpenCode, Qwen Code, Gemini CLI) with zero config written; `omniroute configure` is an interactive provider+model picker with per-context favorites. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
- **🤖 One-command CLI/agent setup** — 12 registered `setup-*` commands; `omniroute run` launches 7 CLIs (Claude Code, Codex, Aider, Goose, OpenCode, Qwen Code, Gemini CLI); `omniroute configure` supports 9 targets with an interactive provider+model picker and per-context favorites. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
- **🛰️ Remote mode** — drive a remote OmniRoute with scoped tokens (`connect` / `contexts` / `tokens`) + an `antigravity` OAuth helper for VPS installs. → [Remote Mode](docs/guides/REMOTE-MODE.md)
- **🧭 Smarter auto-routing** — `auto/<category>:<tier>` combos, **Fusion** (model panel + judge), task-aware routing, per-request model / mode / USD-budget overrides. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
- **🗜️ Pluggable compression** — 12 composable engines + Compression Studios: LLMLingua-2, two-tier Ultra, omniglyph, per-step fidelity gate, GCF v3.2, drag-reorder editor. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
@@ -642,11 +642,11 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
<div align="center">
## 🌐 349 AI Providers — 90+ Free
## 🌐 350 AI Providers — 154 Catalog-Marked Free
</div>
> The most complete catalog of any open-source router: **350 providers**, **90+ with a free tier**, **56 free forever**.
> **350 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **154 carrying `hasFree: true` discovery metadata**. The chat model registry covers **268 providers / 2,566 distinct provider-model pairs / 1,312 raw model IDs**; the separate free-budget catalog has **455 per-model rows**, **40 recurring pools** and **56 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
<div align="center">
@@ -679,7 +679,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
</tr>
</table>
<sub>…and 220+ more — every icon resolves live from the dashboard's provider catalog. 📖 [Provider Reference](docs/reference/PROVIDER_REFERENCE.md)</sub>
<sub>…and 330+ more — every icon resolves live from the dashboard's provider catalog. 📖 [Provider Reference](docs/reference/PROVIDER_REFERENCE.md)</sub>
<br/>
@@ -769,7 +769,7 @@ From inside the editor: open the **Extensions** view, search **"OmniRoute"**, cl
</div>
<img src="./docs/diagrams/privacy-local.svg" width="100%" alt="Private and local-first — your keys, your machine, your data; OmniRoute is a local proxy that never phones home. Eleven guarantees: runs 100% on your hardware (0 cloud hops), zero telemetry by default, credentials encrypted at rest (AES-256-GCM), no account or sign-up, hardened gateway (API-key scoping, IP filtering, rate limits, prompt-injection guard), loopback-only process routes, upstream header scrubbing, strictly opt-in PII redaction, sanitized errors that never leak internals, a local audit trail in your own SQLite, and MIT-licensed fully open-source code."/>
<img src="./docs/diagrams/privacy-local.svg" width="100%" alt="Private and local-first — OmniRoute's gateway and control plane run on your machine. Prompts are sent to the upstream provider selected for each request; OmniRoute adds no hosted prompt-processing hop and telemetry is disabled by default. Credentials are encrypted at rest with AES-256-GCM; controls include API-key scoping, IP filtering, rate limits, prompt-injection guards, upstream-header scrubbing, opt-in PII redaction, sanitized errors and a local SQLite audit trail. OmniRoute is MIT-licensed and self-hostable."/>
<sub>📖 [Authorization](docs/architecture/AUTHZ_GUIDE.md) · [Guardrails](docs/security/GUARDRAILS.md) · [Compliance](docs/security/COMPLIANCE.md)</sub>
@@ -810,7 +810,7 @@ Tokens are scoped `read` / `write` / `admin`; process-spawning routes stay loopb
<div align="left">
<img src="./docs/diagrams/cli-terminal.svg" width="50%" alt="Animated terminal demoing the OmniRoute CLI — omniroute providers list, omniroute combo list, omniroute health — cycling over the 80+ command surface: providers · oauth · keys · combo · nodes · models · cache · compression · cost · usage · quota · health · resilience · telemetry · logs · audit · mcp · a2a · cloud · memory · skills · eval · tunnel · backup · sync · webhooks · policy · pricing · translator · simulate …"/>
<img src="./docs/diagrams/cli-terminal.svg" width="50%" alt="Animated terminal demoing the OmniRoute CLI — omniroute providers list, omniroute combo list and omniroute health — cycling over the 85-command top-level surface: providers · oauth · keys · combo · nodes · models · cache · compression · cost · usage · quota · health · resilience · telemetry · logs · audit · mcp · a2a · cloud · memory · skills · eval · tunnel · backup · sync · webhooks · policy · pricing · translator · simulate …"/>
</div>
@@ -846,7 +846,7 @@ claude mcp add-server omniroute --type http --url http://localhost:20128/api/mcp
### 📖 How it works — pipeline, architecture & savings math
<img src="./docs/diagrams/compression-pipeline.svg" width="100%" alt="OmniRoute compression pipeline: a client request of 10,000 tokens passes through 12 stacked engines — Session-Dedup, CCR, Lite, RTK, Responses Tool Output, Headroom, Relevance, Caveman, Aggressive, LLMLingua-2, Ultra, OmniGlyph — and reaches the provider at about 1,080 tokens, up to 95% saved. Code, URLs and JSON are always preserved byte-perfect."/>
<img src="./docs/diagrams/compression-pipeline.svg" width="100%" alt="OmniRoute compression pipeline: an illustrative 10,000-token client request passes through 12 composable engines — Session-Dedup, CCR, Lite, RTK, Responses Tool Output, Headroom, Relevance, Caveman, Aggressive, LLMLingua-2, Ultra and OmniGlyph — and can reach the provider at about 1,080 tokens in the documented stacked example. Structured content is protected by preservation guards and per-step fidelity gates; explicit lossy or experimental modes may transform eligible content."/>
Default stacked combo runs `RTK → Caveman`. When both act on the same tool/context payload, savings compound:
@@ -1013,6 +1013,7 @@ Full table: [Docker Guide — runtime RAM](docs/guides/DOCKER_GUIDE.md#runtime-r
**🥟 Bun**
Standard `bun install` and global installation (`bun install -g omniroute`) are supported via Bun runtime detection:
- **Built-in `bun:sqlite`**: OmniRoute uses Bun's built-in `bun:sqlite` driver when running under Bun, falling back to `better-sqlite3` on Node.js or `sql.js`.
- **Automatic Webpack bundler selection**: Development (`bun run dev`) and production builds (`bun run build`) automatically detect Bun and disable Turbopack in favor of Webpack to prevent native V8 binding incompatibilities.
- **Dedicated Bun Dockerfile**: Multi-stage `Dockerfile.bun` for native Bun production deployments (`docker build -f Dockerfile.bun -t omniroute:bun .`).
@@ -1105,7 +1106,7 @@ same process on one port, so there is no separate CLI-only package today.
<div align="center">
<sub>Dados de cobertura social em 2026-08-17 · YT: 741 | TT: 137 | IG: 124 · Frescor (dias): YT 0 · TT 14 · IG 15</sub>
<sub>Snapshot do painel em 2026-08-24 · Catálogo bruto: YT 809 | TT 137 | IG 124 · Frescor (dias): YT 1 | TT 21 | IG 22</sub>
<table>
<tr>
@@ -1114,52 +1115,52 @@ same process on one port, so there is no separate CLI-only package today.
<img src="https://placehold.co/320x180/111827/FFFFFF?text=Instagram+Reel+%7C+nick_saraev&font=montserrat&bold=true" alt="Instagram Reel" width="300"/>
</a><br/>
<b>🎬 #1 — Instagram</b><br/>
<sub>nick_saraev — 1,628,910 views</sub>
<sub>nick_saraev — 3,042,474 views</sub>
</td>
<td align="center" width="320">
<a href="https://www.instagram.com/reel/DaSs65mMrHk/">
<img src="https://placehold.co/320x180/111827/FFFFFF?text=Instagram+Reel+%7C+theopenstack&font=montserrat&bold=true" alt="Instagram Reel — theopenstack" width="300"/>
</a><br/>
<b>🎬 #2 — Instagram</b><br/>
<sub>theopenstack — 692,419 views</sub>
</td>
<td align="center" width="320">
<a href="https://www.tiktok.com/@milesreevesai/video/7667980059189366019">
<img src="https://placehold.co/320x180/111827/FFFFFF?text=TikTok+%7C+milesreevesai&font=montserrat&bold=true" alt="TikTok — milesreevesai" width="300"/>
</a><br/>
<b>🎬 #3 — TikTok</b><br/>
<sub>milesreevesai — 620,400 views</sub>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=QucgvbO5gsM">
<img src="https://img.youtube.com/vi/QucgvbO5gsM/maxresdefault.jpg" alt="YouTube — Vaibhav Sisinty" width="300"/>
</a><br/>
<b>🎬 #2 — YouTube</b><br/>
<sub>Vaibhav Sisinty — 373,084 views</sub>
<b>🎬 #4 — YouTube</b><br/>
<sub>Vaibhav Sisinty — 391,109 views</sub>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/shorts/fZIBK_4fKq8">
<img src="https://img.youtube.com/vi/fZIBK_4fKq8/maxresdefault.jpg" alt="YouTube Shorts" width="300"/>
<a href="https://www.instagram.com/reel/DbIt9AjK7-U/">
<img src="https://placehold.co/320x180/111827/FFFFFF?text=Instagram+Reel+%7C+buildwithai.club&font=montserrat&bold=true" alt="Instagram Reel — buildwithai.club" width="300"/>
</a><br/>
<b>🎬 #3YouTube Shorts</b><br/>
<sub>Nick Automates — 207,714 views</sub>
</td>
<td align="center" width="320">
<a href="https://www.tiktok.com/@milesreevesai/video/7667980059189366019">
<img src="https://placehold.co/320x180/111827/FFFFFF?text=TikTok+Top+1&font=montserrat&bold=true" alt="TikTok Thumbnail" width="300"/>
</a><br/>
<b>🎬 #4 — TikTok</b><br/>
<sub>milesreevesai — 620,400 views</sub>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=LkP6ocAoQkk">
<img src="https://img.youtube.com/vi/LkP6ocAoQkk/maxresdefault.jpg" alt="Valency Labs" width="300"/>
</a><br/>
<b>🎬 #5 — YouTube</b><br/>
<sub>Valency Labs — 135,974 views</sub>
<b>🎬 #5Instagram</b><br/>
<sub>buildwithai.club — 347,652 views</sub>
</td>
</tr>
</table>
</div>
**Ranking completo (`v > 0`, maior alcance):**
**Ranking completo (URLs canônicas deduplicadas, `v > 0`, maior alcance):**
| #1 | #2 | #3 | #4 | #5 |
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| [nick_saraev — Instagram](https://www.instagram.com/reel/Da8ZthUPK98/) — **1,628,910** | [milesreevesai — TikTok](https://www.tiktok.com/@milesreevesai/video/7667980059189366019) — **620,400** | [Vaibhav Sisinty — YouTube](https://www.youtube.com/watch?v=QucgvbO5gsM) — **373,084** | [Nick Automates — YouTube Shorts](https://www.youtube.com/shorts/fZIBK_4fKq8) — **207,714** | [midudev — TikTok](https://www.tiktok.com/@midudev/video/7664636453544152342) — **177,800** |
| #1 | #2 | #3 | #4 | #5 |
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| [nick_saraev — Instagram](https://www.instagram.com/reel/Da8ZthUPK98/) — **3,042,474** | [theopenstack — Instagram](https://www.instagram.com/reel/DaSs65mMrHk/) — **692,419** | [milesreevesai — TikTok](https://www.tiktok.com/@milesreevesai/video/7667980059189366019) — **620,400** | [Vaibhav Sisinty — YouTube](https://www.youtube.com/watch?v=QucgvbO5gsM) — **391,109** | [buildwithai.club — Instagram](https://www.instagram.com/reel/DbIt9AjK7-U/) — **347,652** |
| #6 | #7 | #8 | #9 | #10 |
| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| [theopenstack — Instagram](https://www.instagram.com/reel/DaSs65mMrHk/) — **155,453** | [t.ghoush.ai — TikTok](https://www.tiktok.com/@t.ghoush.ai/video/7669497680527248656) — **152,800** | [Valency Labs — YouTube](https://www.youtube.com/watch?v=LkP6ocAoQkk) — **135,974** | [Asati — YouTube](https://www.youtube.com/watch?v=JjPtJcqwhqg) — **126,130** | [Vaibhav Sisinty — YouTube](https://www.youtube.com/watch?v=NuNDpeZYQ28) — **122,672** |
| #6 | #7 | #8 | #9 | #10 |
| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| [nivedan.ai — Instagram](https://www.instagram.com/reel/DbIrCksJiqq/) — **331,973** | [vaibhavsisinty — Instagram](https://www.instagram.com/reel/Dae05TSAK1l/) — **263,744** | [Nick Automates — YouTube Shorts](https://www.youtube.com/shorts/fZIBK_4fKq8) — **218,174** | [theroshankrishna — Instagram](https://www.instagram.com/reel/Dapjs58z0P0/) — **186,786** | [midudev — TikTok](https://www.tiktok.com/@midudev/video/7664636453544152342) — **177,800** |
Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações conhecidas · 595 perfis/canais · 13+ idiomas · 13+ criadores.
Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 visualizações conhecidas** (`v > 0`) · **639 canais/perfis por rede**. O painel bruto contém 1.070 linhas; 41 duplicatas do Instagram foram normalizadas pela URL canônica, mantendo a maior contagem por vídeo.
> 🎬 **Made a video about OmniRoute?** Open an [issue](https://github.com/diegosouzapw/OmniRoute/issues/new) or [discussion](https://github.com/diegosouzapw/OmniRoute/discussions) with the link — we'll feature it here.
@@ -1211,7 +1212,7 @@ Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações c
<tr><td nowrap><b>Stealth</b></td><td>wreq-js — JA3 / JA4 TLS fingerprint impersonation, 3-level proxy</td></tr>
<tr><td nowrap><b>Resilience</b></td><td>Circuit breaker, exponential backoff, anti-thundering-herd, auto-combo self-healing</td></tr>
<tr><td nowrap><b>Logging</b></td><td>pino — structured JSON logs with request context</td></tr>
<tr><td nowrap><b>Testing</b></td><td>Node.js test runner + Vitest — <b>25,000+ test cases</b> across 3,300+ files (unit, integration, E2E, security, ecosystem)</td></tr>
<tr><td nowrap><b>Testing</b></td><td>Node.js test runner + Vitest — <b>39,000+ static test declarations</b> across 5,100+ tracked test files (unit, integration, E2E, security, ecosystem)</td></tr>
<tr><td nowrap><b>Platforms</b></td><td>Desktop (Electron) · Android (Termux) · PWA (any browser)</td></tr>
<tr><td nowrap><b>CI/CD</b></td><td>GitHub Actions — auto npm publish + Docker Hub on release</td></tr>
<tr><td nowrap><b>Links</b></td><td><a href="https://omniroute.online">Website</a> · <a href="https://www.npmjs.com/package/omniroute">npm</a> · <a href="https://hub.docker.com/r/diegosouzapw/omniroute">Docker Hub</a></td></tr>
@@ -1262,9 +1263,9 @@ Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações c
<tr><td nowrap><b><a href="docs/compression/COMPRESSION_RULES_FORMAT.md">Compression Rules Format</a></b></td><td>JSON rule-pack schemas for Caveman and RTK filters</td></tr>
<tr><td nowrap><b><a href="docs/compression/COMPRESSION_LANGUAGE_PACKS.md">Compression Language Packs</a></b></td><td>Language detection and Caveman rule-pack authoring</td></tr>
<tr><td nowrap><b><a href="docs/architecture/RESILIENCE_GUIDE.md">Resilience Guide</a></b></td><td>Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing</td></tr>
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>14-factor scoring, mode packs, self-healing</td></tr>
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>15-factor scoring, mode packs, self-healing</td></tr>
<tr><td nowrap><b><a href="docs/ops/PROXY_GUIDE.md">Proxy Guide</a></b></td><td>3-level proxy system, 1proxy marketplace, registry CRUD</td></tr>
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>90+ free providers consolidated directory (42 documented token pools / 495 models)</td></tr>
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 40 documented recurring pools / 455 cataloged free-tier entries</td></tr>
<tr><td nowrap><b><a href="docs/guides/FEATURES.md">Features Gallery</a></b></td><td>Visual dashboard tour with screenshots</td></tr>
<tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
</table>
@@ -1275,7 +1276,7 @@ Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações c
<tr><th align="left">Document</th><th align="left">Description</th></tr>
<tr><td nowrap><b><a href="docs/reference/API_REFERENCE.md">API Reference</a></b></td><td>All endpoints with examples</td></tr>
<tr><td nowrap><b><a href="docs/openapi.yaml">OpenAPI Spec</a></b></td><td>OpenAPI 3.0 specification</td></tr>
<tr><td nowrap><b><a href="open-sse/mcp-server/README.md">MCP Server</a></b></td><td>109 MCP tools, IDE configs, Python/TS/Go clients</td></tr>
<tr><td nowrap><b><a href="open-sse/mcp-server/README.md">MCP Server</a></b></td><td>110 MCP tools, IDE configs, Python/TS/Go clients</td></tr>
<tr><td nowrap><b><a href="docs/frameworks/MCP-SERVER.md">MCP Server Guide</a></b></td><td>MCP installation, transports, and tool reference</td></tr>
<tr><td nowrap><b><a href="src/lib/a2a/README.md">A2A Server</a></b></td><td>JSON-RPC 2.0 protocol, skills, streaming, task mgmt</td></tr>
<tr><td nowrap><b><a href="docs/frameworks/A2A-SERVER.md">A2A Server Guide</a></b></td><td>A2A agent card, tasks, skills, and streaming</td></tr>
@@ -1291,7 +1292,7 @@ Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações c
<tr><td nowrap><b><a href="SECURITY.md">Security Policy</a></b></td><td>Vulnerability reporting and security practices</td></tr>
<tr><td nowrap><b><a href="docs/guides/I18N.md">i18n Guide</a></b></td><td>43-language support, translation workflow, RTL</td></tr>
<tr><td nowrap><b><a href="docs/ops/RELEASE_CHECKLIST.md">Release Checklist</a></b></td><td>Pre-release validation steps</td></tr>
<tr><td nowrap><b><a href="docs/ops/COVERAGE_PLAN.md">Coverage Plan</a></b></td><td>Test coverage strategy and 25,000+ test suite</td></tr>
<tr><td nowrap><b><a href="docs/ops/COVERAGE_PLAN.md">Coverage Plan</a></b></td><td>Test coverage strategy for 39,000+ static test declarations across 5,100+ tracked test files</td></tr>
</table>
<br/>
@@ -1302,93 +1303,123 @@ Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações c
> OmniRoute is shaped by a passionate open-source community. These individuals have made exceptional contributions that directly impact the quality, stability, and reach of the project. **Thank you.**
### External contributors by merged pull requests
<table>
<tr><th align="center">Rank</th><th align="left">Contributor</th><th align="center">Merged PRs</th><th align="right">~Changed lines</th></tr>
<tr><td align="center">1</td><td align="left"><a href="https://github.com/backryun"><b>backryun</b></a></td><td align="center">190</td><td align="right">227,977</td></tr>
<tr><td align="center">2</td><td align="left"><a href="https://github.com/oyi77"><b>oyi77</b></a></td><td align="center">180</td><td align="right">407,678</td></tr>
<tr><td align="center">3</td><td align="left"><a href="https://github.com/rdself"><b>rdself</b></a></td><td align="center">145</td><td align="right">80,663</td></tr>
<tr><td align="center">4</td><td align="left"><a href="https://github.com/JxnLexn"><b>JxnLexn</b></a></td><td align="center">128</td><td align="right">387,049</td></tr>
<tr><td align="center">5</td><td align="left"><a href="https://github.com/KooshaPari"><b>KooshaPari</b></a></td><td align="center">101</td><td align="right">125,747</td></tr>
<tr><td align="center">6</td><td align="left"><a href="https://github.com/herjarsa"><b>herjarsa</b></a></td><td align="center">88</td><td align="right">230,872</td></tr>
<tr><td align="center">7</td><td align="left"><a href="https://github.com/RaviTharuma"><b>RaviTharuma</b></a></td><td align="center">79</td><td align="right">55,106</td></tr>
<tr><td align="center">8</td><td align="left"><a href="https://github.com/maxmad64bis"><b>maxmad64bis</b></a></td><td align="center">69</td><td align="right">394,715</td></tr>
<tr><td align="center">9</td><td align="left"><a href="https://github.com/artickc"><b>artickc</b></a></td><td align="center">59</td><td align="right">33,260</td></tr>
<tr><td align="center">10</td><td align="left"><a href="https://github.com/HouMinXi"><b>HouMinXi</b></a></td><td align="center">51</td><td align="right">47,334</td></tr>
<tr><td align="center">10</td><td align="left"><a href="https://github.com/chirag127"><b>chirag127</b></a></td><td align="center">51</td><td align="right">5,153</td></tr>
<tr><td align="center">12</td><td align="left"><a href="https://github.com/xz-dev"><b>xz-dev</b></a></td><td align="center">50</td><td align="right">245,976</td></tr>
<tr><td align="center">13</td><td align="left"><a href="https://github.com/hartmark"><b>hartmark</b></a></td><td align="center">47</td><td align="right">52,185</td></tr>
<tr><td align="center">14</td><td align="left"><a href="https://github.com/rqzbeh"><b>rqzbeh</b></a></td><td align="center">39</td><td align="right">143,181</td></tr>
<tr><td align="center">15</td><td align="left"><a href="https://github.com/dhaern"><b>dhaern</b></a></td><td align="center">34</td><td align="right">19,559</td></tr>
<tr><td align="center">16</td><td align="left"><a href="https://github.com/Dingding-leo"><b>Dingding-leo</b></a></td><td align="center">33</td><td align="right">1,986</td></tr>
<tr><td align="center">17</td><td align="left"><a href="https://github.com/NomenAK"><b>NomenAK</b></a></td><td align="center">32</td><td align="right">13,854</td></tr>
<tr><td align="center">18</td><td align="left"><a href="https://github.com/MumuTW"><b>MumuTW</b></a></td><td align="center">30</td><td align="right">16,953</td></tr>
<tr><td align="center">19</td><td align="left"><a href="https://github.com/benzntech"><b>benzntech</b></a></td><td align="center">29</td><td align="right">11,641</td></tr>
<tr><td align="center">20</td><td align="left"><a href="https://github.com/pacocartones"><b>pacocartones</b></a></td><td align="center">24</td><td align="right">9,331</td></tr>
<tr><td align="center">20</td><td align="left"><a href="https://github.com/Prudhvivuda"><b>Prudhvivuda</b></a></td><td align="center">24</td><td align="right">6,312</td></tr>
</table>
<sub>Frozen at live <code>release/v3.8.50</code> tip <code>dafb4ae808</code>, with merges through 2026-08-24 05:26:03 UTC. The paginated GitHub GraphQL census contains 5,911 merged PRs: 2,707 by the repository owner, 179 by Dependabot, and <b>3,025 external PRs from 535 distinct contributors</b>. “Changed lines” is GitHub additions + deletions and includes generated files, lockfiles, catalogs, translations and documentation; it is churn, not authored LOC. Ties at the cutoff are retained.</sub>
### GitHub-attributed commits
<table>
<tr>
<td align="center" width="160">
<a href="https://github.com/oyi77">
<img src="https://github.com/oyi77.png" width="40" style="border-radius:50%" alt="oyi77"/><br/>
<b>oyi77</b>
</a><br/>
<sub>🥇 213 commits • +114K lines</sub><br/>
<sub>Analytics engine, SQL aggregations,<br/>proxy marketplace, test coverage</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/rdself">
<img src="https://github.com/rdself.png" width="40" style="border-radius:50%" alt="R.D. &amp; Randi"/><br/>
<b>R.D. &amp; Randi</b>
</a><br/>
<sub>🥈 108 commits • +38K lines</sub><br/>
<sub>Endpoints page, tunnel integrations,<br/>Docker workflows, A2A status, compression UI</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/christopher-s">
<img src="https://github.com/christopher-s.png" width="40" style="border-radius:50%" alt="Chris Staley"/><br/>
<b>Chris Staley</b>
</a><br/>
<sub>🥉 70 commits • +1.8K lines</sub><br/>
<sub>SSE stream hardening, Responses API,<br/>Gemini pagination, test regression fixes</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/zen0bit">
<img src="https://github.com/zen0bit.png" width="40" style="border-radius:50%" alt="zenobit"/><br/>
<b>zenobit</b>
</a><br/>
<sub>🏅 62 commits • +22K lines</sub><br/>
<sub>CI/CD pipeline, i18n for 33 languages,<br/>Void Linux package, platform fixes</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/JxnLexn">
<img src="https://github.com/JxnLexn.png" width="40" style="border-radius:50%" alt="Jan Leon"/><br/>
<b>Jan Leon</b>
</a><br/>
<sub>🏅 58 commits • +22K lines</sub><br/>
<sub>Reasoning-effort routing, proxy controls,<br/>quota visibility, Live Zone compression</sub>
</td>
</tr>
<tr>
<td align="center" width="160">
<a href="https://github.com/backryun">
<img src="https://github.com/backryun.png" width="40" style="border-radius:50%" alt="backryun"/><br/>
<b>backryun</b>
</a><br/>
<sub>🏅 53 commits • +70K lines</sub><br/>
<sub>Provider catalog curation — Perplexity, Kimi,<br/>Cerebras, Copilot, LMArena refreshes</sub>
<sub>🥇 220 GitHub-attributed commits</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/chirag127">
<img src="https://github.com/chirag127.png" width="40" style="border-radius:50%" alt="Chirag Singhal"/><br/>
<b>Chirag Singhal</b>
<a href="https://github.com/oyi77">
<img src="https://github.com/oyi77.png" width="40" style="border-radius:50%" alt="Paijo"/><br/>
<b>Paijo</b>
</a><br/>
<sub>🏅 46 commits • +4.8K lines</sub><br/>
<sub>Error sanitization, MITM prefill fix,<br/>fusion judge, breaker/429 correctness</sub>
<sub>🥈 219 GitHub-attributed commits</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/kfiramar">
<img src="https://github.com/kfiramar.png" width="40" style="border-radius:50%" alt="kfiramar"/><br/>
<b>kfiramar</b>
<a href="https://github.com/rdself">
<img src="https://github.com/rdself.png" width="40" style="border-radius:50%" alt="Randi"/><br/>
<b>Randi</b>
</a><br/>
<sub>🏅 38 commits • +1.7K lines</sub><br/>
<sub>Codex websocket + passthrough, auth/onboarding,<br/>Electron hardening, DB migrations</sub>
<sub>🥉 108 GitHub-attributed commits</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/benzntech">
<img src="https://github.com/benzntech.png" width="40" style="border-radius:50%" alt="Benson K B"/><br/>
<b>Benson K B</b>
<a href="https://github.com/RaviTharuma">
<img src="https://github.com/RaviTharuma.png" width="40" style="border-radius:50%" alt="Ravi Tharuma"/><br/>
<b>Ravi Tharuma</b>
</a><br/>
<sub>🏅 28 commits • +9.2K lines</sub><br/>
<sub>Electron desktop app, auto-updater,<br/>release build workflows, cross-platform CI</sub>
<sub>🏅 81 GitHub-attributed commits</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/herjarsa">
<img src="https://github.com/herjarsa.png" width="40" style="border-radius:50%" alt="Hernan J. Ardila"/><br/>
<b>Hernan J. Ardila</b>
<a href="https://github.com/christopher-s">
<img src="https://github.com/christopher-s.png" width="40" style="border-radius:50%" alt="Chris"/><br/>
<b>Chris</b>
</a><br/>
<sub>🏅 25 commits • +174K lines</sub><br/>
<sub>Zero-latency combos, vision-bridge auto-routing,<br/>catalog context-length, resilience 429 hints</sub>
<sub>🏅 70 GitHub-attributed commits</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/hartmark">
<img src="https://github.com/hartmark.png" width="40" style="border-radius:50%" alt="Markus Hartung"/><br/>
<b>Markus Hartung</b>
</a><br/>
<sub>🏅 69 GitHub-attributed commits · tied #6</sub>
</td>
</tr>
<tr>
<td align="center" width="160">
<a href="https://github.com/maxmad64bis">
<img src="https://github.com/maxmad64bis.png" width="40" style="border-radius:50%" alt="Dizzle"/><br/>
<b>Dizzle</b>
</a><br/>
<sub>🏅 69 GitHub-attributed commits · tied #6</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/JxnLexn">
<img src="https://github.com/JxnLexn.png" width="40" style="border-radius:50%" alt="Jan Leon"/><br/>
<b>Jan Leon</b>
</a><br/>
<sub>🏅 64 GitHub-attributed commits</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/zen0bit">
<img src="https://github.com/zen0bit.png" width="40" style="border-radius:50%" alt="zenobit"/><br/>
<b>zenobit</b>
</a><br/>
<sub>🏅 62 GitHub-attributed commits</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/HouMinXi">
<img src="https://github.com/HouMinXi.png" width="40" style="border-radius:50%" alt="Bob.Hou"/><br/>
<b>Bob.Hou</b>
</a><br/>
<sub>🏅 51 GitHub-attributed commits · tied #10</sub>
</td>
<td align="center" width="160">
<a href="https://github.com/xz-dev">
<img src="https://github.com/xz-dev.png" width="40" style="border-radius:50%" alt="Xiangzhe"/><br/>
<b>Xiangzhe</b>
</a><br/>
<sub>🏅 51 GitHub-attributed commits · tied #10</sub>
</td>
</tr>
</table>
<sub>Rechecked at 2026-08-24 06:14:31 UTC: GitHub-attributed commits reported by the repository Contributors API for the <code>release/v3.8.50</code> default branch. The API returned 525 identities (415 users, 2 bots, 108 anonymous); this table excludes the maintainer, bots and anonymous identities and retains competition ties. It is distinct from both the merged-PR ranking above and the 639-person Git-metadata census below.</sub>
> 🙏 These contributors' features, bug fixes, and infrastructure improvements are a **core part** of what makes OmniRoute reliable and feature-rich. Every pull request, every test case, and every i18n translation file matters. Open source is built by people like them.
</div>
@@ -1405,25 +1436,48 @@ A heartfelt thank-you to the people who fund OmniRoute out of their own pocket
<table>
<tr>
<td align="center" width="180">
<a href="https://github.com/drewbitt">
<img src="https://github.com/drewbitt.png?size=140" width="72" style="border-radius:50%" alt="Andrew"/><br/>
<b>Andrew</b>
</a><br/>
<sub>💛 Active monthly sponsor</sub>
</td>
<td align="center" width="180">
<a href="https://github.com/psylligent">
<img src="https://github.com/psylligent.png?size=140" width="72" style="border-radius:50%" alt="Vlad I"/><br/>
<b>Vlad I</b>
</a><br/>
<sub>💛 Active monthly sponsor</sub>
</td>
<td align="center" width="180">
<a href="https://github.com/pacocartones">
<img src="https://github.com/pacocartones.png?size=140" width="72" style="border-radius:50%" alt="Paco Cartones"/><br/>
<b>Paco Cartones</b>
</a><br/>
<sub>💛 Active one-time sponsor</sub>
</td>
<td align="center" width="180">
<a href="https://github.com/igormorais123">
<img src="https://github.com/igormorais123.png?size=140" width="72" style="border-radius:50%" alt="Professor Igor Morais Vasconcelos"/><br/>
<b>Prof. Igor Morais</b>
</a><br/>
<sub>💛 Sponsor</sub>
<sub>💛 Past one-time supporter</sub>
</td>
<td align="center" width="180">
<a href="https://github.com/longtao77">
<img src="https://github.com/longtao77.png?size=140" width="72" style="border-radius:50%" alt="longtao"/><br/>
<b>longtao</b>
</a><br/>
<sub>💛 Sponsor</sub>
<sub>💛 Past one-time supporter</sub>
</td>
</tr>
</table>
<sub>… and others who prefer to stay private 💛</sub>
<sub>Public GitHub Sponsors revalidated on 2026-08-24. GitHub's <code>activeOnly</code> status determines the active labels above; previously disclosed public one-time supporters remain thanked, and private sponsors remain anonymous.</sub>
<b><a href="https://github.com/sponsors/diegosouzapw">💖 Become a sponsor →</a></b> — every dollar keeps OmniRoute free and independent.
</div>
@@ -1432,11 +1486,13 @@ A heartfelt thank-you to the people who fund OmniRoute out of their own pocket
<div align="center">
## 👥 320+ Contributors
## 👥 600+ Contributors
</div>
[![Contributors](https://contrib.rocks/image?repo=diegosouzapw/OmniRoute&max=400&columns=20&anon=1)](https://github.com/diegosouzapw/OmniRoute/graphs/contributors)
[![Contributors](https://contrib.rocks/image?repo=diegosouzapw/OmniRoute&max=639&columns=20&anon=1)](https://github.com/diegosouzapw/OmniRoute/graphs/contributors)
<sub>Audited on 2026-08-24 at frozen base <code>ac02c5b42f</code> and rechecked at live <code>release/v3.8.50</code> tip <code>dafb4ae808</code>: <b>639 normalized human Git identities</b> — 407 appear as commit authors (including the maintainer) and 232 only in explicit <code>Co-authored-by</code> trailers. The census normalizes GitHub noreply handles, excludes 26 bot/agent/service/placeholder identities, and does not merge ordinary email addresses merely because their display names match.</sub>
### How to Contribute
@@ -1453,7 +1509,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
```bash
# Create a release — npm publish happens automatically
gh release create v3.8.2 --title "v3.8.2" --generate-notes
VERSION=x.y.z
gh release create "v${VERSION}" --title "v${VERSION}" --generate-notes
```
<br/>
@@ -1495,88 +1552,108 @@ gh release create v3.8.2 --title "v3.8.2" --generate-notes
OmniRoute stands on the shoulders of giants. It started as a fork of **[9router](https://github.com/decolua/9router)** and a TypeScript port of the Go project **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — and from there, every subsystem below was inspired by an open-source project that got there first. Each one shaped a concrete piece of OmniRoute. This is our thank-you to all of them. 🙏
> ⭐ star counts as of July 2026 — go give these projects a star.
> ⭐ star counts verified from GitHub's REST API on August 24, 2026 — go give these projects a star. Counts are an exact dated snapshot and will naturally change.
### 🧬 Lineage & gateway
<table>
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
<tr><td nowrap><b><a href="https://github.com/decolua/9router">9router</a></b></td><td align="center">22.7k</td><td>The original project this fork is built on — extended here with multi-modal APIs and a full TypeScript rewrite.</td></tr>
<tr><td nowrap><b><a href="https://github.com/router-for-me/CLIProxyAPI">CLIProxyAPI</a></b></td><td align="center">43.6k</td><td>The Go implementation that inspired this JavaScript / TypeScript port.</td></tr>
<tr><td nowrap><b><a href="https://github.com/BerriAI/litellm">LiteLLM</a></b></td><td align="center">54.0k</td><td>The AI gateway whose public pricing dataset feeds our cost-tracking sync and whose provider-normalization model informed our routing.</td></tr>
<tr><td nowrap><b><a href="https://github.com/decolua/9router">9router</a></b></td><td align="center">26,161</td><td>The original project this fork is built on — extended here with multi-modal APIs and a full TypeScript rewrite.</td></tr>
<tr><td nowrap><b><a href="https://github.com/router-for-me/CLIProxyAPI">CLIProxyAPI</a></b></td><td align="center">48,497</td><td>The Go implementation that inspired this JavaScript / TypeScript port.</td></tr>
<tr><td nowrap><b><a href="https://github.com/BerriAI/litellm">LiteLLM</a></b></td><td align="center">57,100</td><td>The AI gateway whose public pricing dataset feeds our cost-tracking sync and whose provider-normalization model informed our routing.</td></tr>
<tr><td nowrap><b><a href="https://github.com/miuuyy/codex-chatgpt-web">codex-chatgpt-web</a></b></td><td align="center">1,410</td><td>MIT source adapted into the vendored ChatGPT Web → Codex Responses bridge, including browser-session, response-framing, usage and web-search adapters.</td></tr>
<tr><td nowrap><b><a href="https://github.com/Alishahryar1/free-claude-code">free-claude-code</a></b></td><td align="center">48,112</td><td>Patterns ported into stream recovery, no-thinking aliases, fallback web search, sliding-window limits, log redaction and hardened launcher flows.</td></tr>
<tr><td nowrap><b><a href="https://github.com/standardagents/composer-api">composer-api</a></b></td><td align="center">322</td><td>Cursor Composer tool-choice, output-constraint and tool-commit patterns adapted into the native Cursor executor.</td></tr>
<tr><td nowrap><b><a href="https://github.com/ndycode/codex-multi-auth">codex-multi-auth</a></b></td><td align="center">457</td><td>Fresh-login and refresh-token rotation patterns ported into Codex OAuth reauthentication.</td></tr>
<tr><td nowrap><b><a href="https://github.com/ex-machina-co/opencode-anthropic-auth">opencode-anthropic-auth</a></b></td><td align="center">510</td><td>Claude Code-compatible transform defaults and billing-header behavior generalized into OmniRoute's config-driven bridge.</td></tr>
<tr><td nowrap><b><a href="https://github.com/520mmxx/grok2api-merged">grok2api-merged</a></b></td><td align="center">2</td><td>Its Grok model mappings, fake-TypeError Statsig generator, request and device defaults, and NDJSON response processor were materially adapted into OmniRoute's Grok Web executor.</td></tr>
<tr><td nowrap><b><a href="https://github.com/TQZHR/grok2api">TQZHR/grok2api</a></b></td><td align="center">705</td><td>The principal transitive code source behind grok2api-merged; its model, header, payload, Statsig and processor implementations are preserved in the Grok Web lineage.</td></tr>
<tr><td nowrap><b><a href="https://github.com/chenyme/grok2api">chenyme/grok2api</a></b></td><td align="center">7,520</td><td>The underlying MIT source for Grok payload and device defaults, the Statsig generator, and the <code>result.response</code> processor carried through TQZHR and grok2api-merged.</td></tr>
<tr><td nowrap><b><a href="https://github.com/miuzhaii/grok2api-pro">grok2api-pro</a></b></td><td align="center">27</td><td>A transitive source credited by grok2api-merged for its proxy-pool layer; OmniRoute preserves that lineage notice but does not claim a proxy-pool port in its bounded Grok Web executor.</td></tr>
<tr><td nowrap><b><a href="https://github.com/CNFlyCat/GrokProxy">GrokProxy</a></b></td><td align="center">50</td><td>Its cookie-authenticated Grok proxy and <code>result.response.token</code> streaming pattern informed OmniRoute's Grok Web transport.</td></tr>
<tr><td nowrap><b><a href="https://github.com/lianying1716/GrokBridge">GrokBridge</a></b></td><td align="center">5</td><td>The original Grok Web implementation consulted its HTTP/browser upstream design; its direct HTTP path derives from GrokProxy, so no independent code port is claimed.</td></tr>
<tr><td nowrap><b><a href="https://github.com/imjustprism/grok-web-api">grok-web-api</a></b></td><td align="center">14</td><td>Its Rust <code>ChatOptions</code> and response-envelope schemas informed OmniRoute's TypeScript Grok request and streaming-response types.</td></tr>
</table>
### 🗜️ Context & token compression — engines
<table>
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
<tr><td nowrap><b><a href="https://github.com/JuliusBrussee/caveman">Caveman</a></b></td><td align="center">90.8k</td><td>The viral "why use many token when few token do trick" project — its caveman-speak philosophy powers our standard compression mode and 30+ filler/condensation rules.</td></tr>
<tr><td nowrap><b><a href="https://github.com/rtk-ai/rtk">RTK Rust Token Killer</a></b></td><td align="center">71.8k</td><td>High-performance command-output compression — inspired our RTK engine, JSON filter DSL, raw-output recovery and the stacked RTK → Caveman pipeline.</td></tr>
<tr><td nowrap><b><a href="https://github.com/headroomlabs-ai/headroom">headroom</a></b></td><td align="center">60.1k</td><td>Reversible context-compression (SmartCrusher) — inspired our <code>headroom</code> engine and the <code>ccr</code> retrieve-marker pattern.</td></tr>
<tr><td nowrap><b><a href="https://github.com/microsoft/LLMLingua">LLMLingua</a></b></td><td align="center">6.5k</td><td>Prompt-compression research (LLMLingua / LLMLingua-2) — inspired our async, code-safe, fail-open <code>llmlingua</code> engine.</td></tr>
<tr><td nowrap><b><a href="https://github.com/atjsh/llmlingua-2-js">llmlingua-2-js</a></b></td><td align="center">30</td><td>The JS/ONNX port (MobileBERT / XLM-RoBERTa) used as the worker-thread backend for our LLMLingua engine.</td></tr>
<tr><td nowrap><b><a href="https://github.com/leninejunior/troglodita">Troglodita</a></b></td><td align="center">26</td><td>PT-BR token compression — powers our pt-BR language pack: pleonasm reduction and filler removal tuned for Brazilian-Portuguese grammar.</td></tr>
<tr><td nowrap><b><a href="https://github.com/DietrichGebert/ponytail">ponytail</a></b></td><td align="center">86.0k</td><td>The viral "lazy senior dev" YAGNI-coder skill — inspired our <b>less-code</b> Output Style: smallest-working-change steering that cuts _generated_ code (the output-axis sibling to Caveman's terse prose).</td></tr>
<tr><td nowrap><b><a href="https://github.com/JuliusBrussee/caveman">Caveman</a></b></td><td align="center">100,538</td><td>The viral "why use many token when few token do trick" project — its caveman-speak philosophy powers our standard compression mode and 30+ filler/condensation rules.</td></tr>
<tr><td nowrap><b><a href="https://github.com/rtk-ai/rtk">RTK Rust Token Killer</a></b></td><td align="center">77,185</td><td>High-performance command-output compression — inspired our RTK engine, JSON filter DSL, raw-output recovery and the stacked RTK → Caveman pipeline.</td></tr>
<tr><td nowrap><b><a href="https://github.com/headroomlabs-ai/headroom">headroom</a></b></td><td align="center">67,310</td><td>Reversible context-compression (SmartCrusher) — inspired our <code>headroom</code> engine and the <code>ccr</code> retrieve-marker pattern.</td></tr>
<tr><td nowrap><b><a href="https://github.com/microsoft/LLMLingua">LLMLingua</a></b></td><td align="center">6,598</td><td>Prompt-compression research (LLMLingua / LLMLingua-2) — inspired our async, code-safe, fail-open <code>llmlingua</code> engine.</td></tr>
<tr><td nowrap><b><a href="https://github.com/atjsh/llmlingua-2-js">llmlingua-2-js</a></b></td><td align="center">31</td><td>The JS/ONNX port (MobileBERT / XLM-RoBERTa) used as the worker-thread backend for our LLMLingua engine.</td></tr>
<tr><td nowrap><b><a href="https://github.com/leninejunior/troglodita">Troglodita</a></b></td><td align="center">40</td><td>PT-BR token compression — powers our pt-BR language pack: pleonasm reduction and filler removal tuned for Brazilian-Portuguese grammar.</td></tr>
<tr><td nowrap><b><a href="https://github.com/DietrichGebert/ponytail">ponytail</a></b></td><td align="center">108,957</td><td>The viral "lazy senior dev" YAGNI-coder skill — inspired our <b>less-code</b> Output Style: smallest-working-change steering that cuts _generated_ code (the output-axis sibling to Caveman's terse prose).</td></tr>
<tr><td nowrap><b><a href="https://github.com/ayghri/i-have-adhd">i-have-adhd</a></b></td><td align="center">23,526</td><td>Its action-first, ADHD-friendly response style was adapted into OmniRoute's concise output style across five languages.</td></tr>
</table>
### 🧩 Compact formats, token research & code-aware tooling
<table>
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
<tr><td nowrap><b><a href="https://github.com/toon-format/toon">TOON</a></b></td><td align="center">24.9k</td><td>Token-Oriented Object Notation — its columnar, header-plus-rows model shaped our tabular compaction stage.</td></tr>
<tr><td nowrap><b><a href="https://github.com/blackwell-systems/gcf">GCF Graph Compact Format</a></b></td><td align="center">22</td><td>First inspired our tabular compaction stage; now its zero-dependency, lossless generic-profile encoder is <b>vendored directly</b> as the Headroom codec (MIT, SPDX-marked), with later numeric-domain and count-mismatch correctness fixes.</td></tr>
<tr><td nowrap><b><a href="https://github.com/ooples/token-optimizer-mcp">token-optimizer-mcp</a></b></td><td align="center">444</td><td>Brotli/SQLite cache + per-session context-delta — inspired our <code>session-dedup</code> engine.</td></tr>
<tr><td nowrap><b><a href="https://github.com/Mibayy/token-savior">token-savior</a></b></td><td align="center">1.1k</td><td>Bash-output compaction + MCP profiles — inspired our compression bail-out discipline and MCP tool-manifest reduction.</td></tr>
<tr><td nowrap><b><a href="https://github.com/ppgranger/token-saver">token-saver</a></b></td><td align="center">117</td><td>Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip.</td></tr>
<tr><td nowrap><b><a href="https://github.com/alexgreensh/token-optimizer">token-optimizer</a></b></td><td align="center">1.7k</td><td>"Find the ghost tokens" — its offload + recoverable-handle pattern informed our CCR offload thinking.</td></tr>
<tr><td nowrap><b><a href="https://github.com/Shweta-Mishra-ai/tokenmizer">TokenMizer</a></b></td><td align="center">16</td><td>A session-graph + cross-turn line-dedup blueprint that informed our session-dedup design.</td></tr>
<tr><td nowrap><b><a href="https://github.com/toon-format/toon">TOON</a></b></td><td align="center">25,233</td><td>Token-Oriented Object Notation — its columnar, header-plus-rows model shaped our tabular compaction stage.</td></tr>
<tr><td nowrap><b><a href="https://github.com/blackwell-systems/gcf">GCF Graph Compact Format</a></b></td><td align="center">41</td><td>Its compact graph format and generic-profile design informed OmniRoute's tabular compaction and Headroom codec format.</td></tr>
<tr><td nowrap><b><a href="https://github.com/blackwell-systems/gcf-typescript">gcf-typescript</a></b></td><td align="center">4</td><td>The MIT TypeScript implementation directly vendored and extended as the Headroom generic-profile codec.</td></tr>
<tr><td nowrap><b><a href="https://github.com/ooples/token-optimizer-mcp">token-optimizer-mcp</a></b></td><td align="center">494</td><td>Brotli/SQLite cache + per-session context-delta — inspired our <code>session-dedup</code> engine.</td></tr>
<tr><td nowrap><b><a href="https://github.com/Mibayy/token-savior">token-savior</a></b></td><td align="center">1,122</td><td>Bash-output compaction + MCP profiles — inspired our compression bail-out discipline and MCP tool-manifest reduction.</td></tr>
<tr><td nowrap><b><a href="https://github.com/ppgranger/token-saver">token-saver</a></b></td><td align="center">138</td><td>Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip.</td></tr>
<tr><td nowrap><b><a href="https://github.com/alexgreensh/token-optimizer">token-optimizer</a></b></td><td align="center">1,951</td><td>"Find the ghost tokens" — its offload + recoverable-handle pattern informed our CCR offload thinking.</td></tr>
<tr><td nowrap><b><a href="https://github.com/Shweta-Mishra-ai/tokenmizer">TokenMizer</a></b></td><td align="center">28</td><td>A session-graph + cross-turn line-dedup blueprint that informed our session-dedup design.</td></tr>
<tr><td nowrap><b><a href="https://github.com/jessefreitas/OmniCompress">OmniCompress</a></b></td><td align="center">3</td><td>Rust columnar-JSON + content-addressed retrieve + cross-message dedup — validated our <code>headroom</code>/<code>ccr</code>/<code>session-dedup</code> engine design and the cache-stable "compressed form is position-independent" invariant.</td></tr>
<tr><td nowrap><b><a href="https://github.com/atlassian-labs/mcp-compressor">mcp-compressor</a></b></td><td align="center">98</td><td>MCP tool-schema/description compression — informed our MCP tool-manifest cardinality reduction.</td></tr>
<tr><td nowrap><b><a href="https://github.com/pdavis68/RepoMapper">RepoMapper</a></b></td><td align="center">187</td><td>Aider-style repo-map ranking — informed our repo-map / retrieval-ranking exploration.</td></tr>
<tr><td nowrap><b><a href="https://github.com/atlassian-labs/mcp-compressor">mcp-compressor</a></b></td><td align="center">113</td><td>MCP tool-schema/description compression — informed our MCP tool-manifest cardinality reduction.</td></tr>
<tr><td nowrap><b><a href="https://github.com/pdavis68/RepoMapper">RepoMapper</a></b></td><td align="center">197</td><td>Aider-style repo-map ranking — informed our repo-map / retrieval-ranking exploration.</td></tr>
<tr><td nowrap><b><a href="https://github.com/mrsimpson/quiet-shell-mcp">quiet-shell-mcp</a></b></td><td align="center">4</td><td>Declarative shell-output reduction over MCP — validated our declarative bash-output compaction.</td></tr>
<tr><td nowrap><b><a href="https://github.com/dsherret/ts-morph">ts-morph</a></b></td><td align="center">6.1k</td><td>TypeScript Compiler API toolkit — inspired our parser-based comment removal that preserves string, template and regex literals.</td></tr>
<tr><td nowrap><b><a href="https://github.com/dsherret/ts-morph">ts-morph</a></b></td><td align="center">6,162</td><td>TypeScript Compiler API toolkit — inspired our parser-based comment removal that preserves string, template and regex literals.</td></tr>
</table>
### 🧠 Memory & RAG
<table>
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
<tr><td nowrap><b><a href="https://github.com/mem0ai/mem0">Mem0</a></b></td><td align="center">61.2k</td><td>Universal memory layer — its proxy-as-write/read-boundary model shaped our memory architecture.</td></tr>
<tr><td nowrap><b><a href="https://github.com/letta-ai/letta">Letta (MemGPT)</a></b></td><td align="center">23.9k</td><td>Stateful agents with tiered memory — inspired our Context Control & Recovery (CCR) tiered model.</td></tr>
<tr><td nowrap><b><a href="https://github.com/onestardao/WFGY">WFGY</a></b></td><td align="center">1.8k</td><td>The ProblemMap taxonomy of 16 recurring RAG/LLM failure modes — the shared vocabulary in our troubleshooting guide.</td></tr>
<tr><td nowrap><b><a href="https://github.com/mem0ai/mem0">Mem0</a></b></td><td align="center">63,902</td><td>Universal memory layer — its proxy-as-write/read-boundary model shaped our memory architecture.</td></tr>
<tr><td nowrap><b><a href="https://github.com/letta-ai/letta">Letta (MemGPT)</a></b></td><td align="center">24,382</td><td>Stateful agents with tiered memory — inspired our Context Control & Recovery (CCR) tiered model.</td></tr>
<tr><td nowrap><b><a href="https://github.com/onestardao/WFGY">WFGY</a></b></td><td align="center">1,781</td><td>The ProblemMap taxonomy of 16 recurring RAG/LLM failure modes — the shared vocabulary in our troubleshooting guide.</td></tr>
</table>
### 🛰️ Traffic inspection, MITM & transparent proxy
<table>
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
<tr><td nowrap><b><a href="https://github.com/chouzz/llm-interceptor">llm-interceptor</a></b></td><td align="center">49</td><td>MITM interception/analysis of coding-assistant ↔ LLM traffic — our Traffic Inspector ports its SSE merge, conversation normalization, host passthrough and secret masking (MIT).</td></tr>
<tr><td nowrap><b><a href="https://github.com/InterceptSuite/ProxyBridge">ProxyBridge</a></b></td><td align="center">5.5k</td><td>Transparent per-process proxy routing — inspired our crash-safe MITM teardown, socket idle-timeouts, <code>/proc</code> process attribution and TPROXY capture.</td></tr>
<tr><td nowrap><b><a href="https://github.com/chouzz/llm-interceptor">llm-interceptor</a></b></td><td align="center">66</td><td>MITM interception/analysis of coding-assistant ↔ LLM traffic — our Traffic Inspector ports its SSE merge, conversation normalization, host passthrough and secret masking. The upstream's complete license text is still under provenance review.</td></tr>
<tr><td nowrap><b><a href="https://github.com/InterceptSuite/ProxyBridge">ProxyBridge</a></b></td><td align="center">5,995</td><td>Transparent per-process proxy routing — inspired our crash-safe MITM teardown, socket idle-timeouts, <code>/proc</code> process attribution and TPROXY capture.</td></tr>
</table>
### 📚 Model data, observability & UI
<table>
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
<tr><td nowrap><b><a href="https://github.com/anomalyco/models.dev">models.dev</a></b></td><td align="center">6.0k</td><td>Open database of AI model specs, pricing and capabilities — synced natively into our model catalog.</td></tr>
<tr><td nowrap><b><a href="https://github.com/xyflow/xyflow">React Flow / xyflow</a></b></td><td align="center">37.7k</td><td>The node-based graph library powering our real-time Compression Studio and Combo/Routing Studio.</td></tr>
<tr><td nowrap><b><a href="https://github.com/langchain-ai/langgraph">LangGraph</a></b></td><td align="center">37.6k</td><td>LangGraph Studio's live workflow-graph visualization inspired our Studios' real-time cascade view.</td></tr>
<tr><td nowrap><b><a href="https://github.com/langfuse/langfuse">Langfuse</a></b></td><td align="center">31.4k</td><td>Its trace → span → generation observability model shaped our Compression Studio waterfall.</td></tr>
<tr><td nowrap><b><a href="https://github.com/kiali/kiali">Kiali</a></b></td><td align="center">3.6k</td><td>Istio service-mesh observability — inspired our circuit-breaker badges and error-edge visuals in the Routing/Combo Studio.</td></tr>
<tr><td nowrap><b><a href="https://github.com/lobehub/lobe-icons">lobe-icons</a></b></td><td align="center">2.2k</td><td>AI/LLM brand logos that render the provider icons across our dashboard.</td></tr>
<tr><td nowrap><b><a href="https://github.com/anomalyco/models.dev">models.dev</a></b></td><td align="center">6,555</td><td>Open database of AI model specs, pricing and capabilities — synced natively into our model catalog.</td></tr>
<tr><td nowrap><b><a href="https://github.com/xyflow/xyflow">React Flow / xyflow</a></b></td><td align="center">38,108</td><td>The node-based graph library powering our real-time Compression Studio and Combo/Routing Studio.</td></tr>
<tr><td nowrap><b><a href="https://github.com/langchain-ai/langgraph">LangGraph</a></b></td><td align="center">40,314</td><td>LangGraph Studio's live workflow-graph visualization inspired our Studios' real-time cascade view.</td></tr>
<tr><td nowrap><b><a href="https://github.com/langfuse/langfuse">Langfuse</a></b></td><td align="center">33,592</td><td>Its trace → span → generation observability model shaped our Compression Studio waterfall.</td></tr>
<tr><td nowrap><b><a href="https://github.com/kiali/kiali">Kiali</a></b></td><td align="center">3,631</td><td>Istio service-mesh observability — inspired our circuit-breaker badges and error-edge visuals in the Routing/Combo Studio.</td></tr>
<tr><td nowrap><b><a href="https://github.com/lobehub/lobe-icons">lobe-icons</a></b></td><td align="center">2,428</td><td>AI/LLM brand logos that render the provider icons across our dashboard.</td></tr>
<tr><td nowrap><b><a href="https://github.com/lipis/flag-icons">flag-icons</a></b></td><td align="center">12,354</td><td>Provides the MIT-licensed SVG flags used by the README language selector.</td></tr>
</table>
### 🛡️ Security
<table>
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
<tr><td nowrap><b><a href="https://github.com/tldrsec/awesome-secure-defaults">awesome-secure-defaults</a></b></td><td align="center">710</td><td>A curated list of secure-by-default libraries that guides our security choices (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink).</td></tr>
<tr><td nowrap><b><a href="https://github.com/tldrsec/awesome-secure-defaults">awesome-secure-defaults</a></b></td><td align="center">721</td><td>A curated list of secure-by-default libraries that guides our security choices (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink).</td></tr>
</table>
### 🧭 Complementary tools
<table>
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
<tr><td nowrap><b><a href="https://github.com/BlockRunAI/ClawRouter">ClawRouter</a></b></td><td align="center">6,564</td><td>Inspired request deduplication, emergency zero-cost fallback, pluggable Auto-Combo strategies and multilingual intent classification.</td></tr>
<tr><td nowrap><b><a href="https://github.com/lbjlaq/Antigravity-Manager">Antigravity-Manager</a></b></td><td align="center">30,652</td><td>Its account-aware model remapping, executable-path validation and plan-label behavior informed OmniRoute's Antigravity runtime.</td></tr>
<tr><td nowrap><b><a href="https://github.com/jlcodes99/vscode-antigravity-cockpit">vscode-antigravity-cockpit</a></b></td><td align="center">4,817</td><td>Its compact quota-reset countdown format inspired the corresponding provider-limit display in OmniRoute.</td></tr>
<tr><td nowrap><b><a href="https://github.com/iOfficeAI/AionUi">AionUi</a></b></td><td align="center">32,230</td><td>Its ACP integrations inspired OmniRoute's automatic detection of installed CLI agents.</td></tr>
<tr><td nowrap><b><a href="https://github.com/steipete/CodexBar">CodexBar</a></b></td><td align="center">20,507</td><td>Identified the Grok Build quota surface; OmniRoute then verified and corrected the live wire format independently.</td></tr>
</table>
## 📄 License
@@ -1589,7 +1666,7 @@ MIT License - see [LICENSE](LICENSE) for details.
**[⬆ Back to top](#-omniroute)** · Built with ❤️ for the open-source AI community.
<sub>OmniRoute v3.8.49 · Node ≥22.22.2 · MIT License · <a href="https://omniroute.online">omniroute.online</a></sub>
<sub>OmniRoute v3.8.50 · Node ≥22.22.2 · MIT License · <a href="https://omniroute.online">omniroute.online</a></sub>
</div>
<!-- GitHub Discussions enabled for community Q&A -->

View File

@@ -169,7 +169,8 @@ export async function runSetupClaudeCommand(opts = {}) {
let detail = `HTTP ${res.status}`;
try {
const errorBody = await res.json();
const serverMsg = errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
const serverMsg =
errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
if (serverMsg) detail += `${serverMsg}`;
} catch {}
throw new Error(detail);

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { t } from "../i18n.mjs";
import { npmBin, npmExecOptions } from "../npm-exec.mjs";
const execFileAsync = promisify(execFile);
@@ -31,9 +32,13 @@ export async function getCurrentVersion() {
// they were already on the latest version (#4376). `execFn` is injectable for tests.
export async function getLatestVersion(execFn = execFileAsync) {
try {
const { stdout } = await execFn("npm", ["view", "omniroute", "version", "--prefer-online"], {
timeout: 15000,
});
// argv is all literals, so enabling the shell on win32 cannot splice a
// runtime value into the command line (Hard Rule #13).
const { stdout } = await execFn(
npmBin(),
["view", "omniroute", "version", "--prefer-online"],
npmExecOptions(process.platform, { timeoutMs: 15000 })
);
return stdout.trim();
} catch {
return null;
@@ -114,9 +119,11 @@ export async function runUpdateCommand(opts = {}) {
if (showChangelog) {
try {
const { stdout } = await execFileAsync("npm", ["view", "omniroute", "changelog"], {
timeout: 10000,
});
const { stdout } = await execFileAsync(
npmBin(),
["view", "omniroute", "changelog"],
npmExecOptions(process.platform, { timeoutMs: 15000 })
);
if (stdout.trim()) {
console.log(stdout.trim());
} else {

View File

@@ -38,7 +38,8 @@
"testFailed": "提供者测试失败:{error}",
"loginEnabled": "登录:已启用(密码已更新)",
"loginDisabled": "登录:已禁用",
"providerInfo": "提供者:{info}"
"providerInfo": "提供者:{info}",
"opencode": "安装并配置随附的 @omniroute/opencode-plugin 以用于 OpenCode"
},
"doctor": {
"title": "OmniRoute 诊断",
@@ -252,7 +253,9 @@
"no_recovery": "禁用崩溃自动重启(调试模式)",
"max_restarts": "30 秒内的最大崩溃重启次数默认2",
"tray": "显示系统托盘图标(仅桌面,选择加入)",
"no_tray": "禁用系统托盘图标"
"no_tray": "禁用系统托盘图标",
"tls_cert": "用于提供 HTTPS 服务的 TLS 证书PEM路径也可用 OMNIROUTE_TLS_CERT",
"tls_key": "用于提供 HTTPS 服务的 TLS 私钥PEM路径也可用 OMNIROUTE_TLS_KEY"
},
"backup": {
"title": "备份",
@@ -1258,5 +1261,69 @@
"search": "搜索 npm 注册表中的可用插件",
"update": "更新已安装的插件",
"scaffold": "搭建新的插件模板"
},
"authExport": {
"description": "导出已解密的提供者凭据(仅限本地,明文输出)",
"idOpt": "仅导出与此 id/名称/提供者匹配的连接",
"formatOpt": "输出格式json 或 env",
"outOpt": "将输出写入文件而非标准输出(以 0600 权限写入)",
"forceOpt": "确认你了解此操作会打印/写入明文密钥",
"warning": "⚠ 此操作会打印/写入已解密的明文 API 密钥和 OAuth 令牌。请确保你的屏幕、shell 历史记录以及任何输出文件保持私密。",
"confirmHeading": "⚠ 警告:此操作会以明文导出已解密的提供者凭据",
"confirmBody": "此命令会为所选连接解密并打印/写入 apiKey、accessToken、refreshToken 和\nidToken。请将输出视为机密。",
"confirmFooter": "如需确认,请运行:\n omniroute auth export --force",
"missingKey": "导出凭据需要 STORAGE_ENCRYPTION_KEY。",
"notFound": "未找到连接:{id}",
"invalidFormat": "无效格式:{format}。请使用 json 或 env。"
},
"radar": {
"description": "检查并同步本地 Radar 目录订阅源",
"status": "显示本地 Radar 设置和订阅源缓存状态",
"sync": "通过本地服务器同步目录、推荐、优惠和 Intel"
},
"launch": {
"description": "启动指向 OmniRoute 的 Claude Code本地或远程使用 --profile",
"token": "Claude 客户端应发送的令牌ANTHROPIC_AUTH_TOKEN",
"notRunning": "无法在 {port} 访问 OmniRoute。请使用 “omniroute serve” 启动它。",
"notFound": "在 PATH 中未找到 “claude” CLI。"
},
"run": {
"description": "通过 OmniRoute 启动受支持的 CLI 目标"
},
"setupClaude": {
"description": "从 OmniRoute 模型目录生成 ~/.claude/profiles 的 Claude Code 配置文件"
},
"connect": {
"description": "连接到远程 OmniRoute 服务器并进入远程模式"
},
"tokens": {
"description": "管理限定范围的 CLI 访问令牌(远程模式)"
},
"configure": {
"description": "从活动服务器选择提供者+模型并配置受支持的本地 CLI"
},
"launchCodex": {
"description": "启动指向 OmniRoute 的 Codex CLI本地或远程 VPS"
},
"setupCodex": {
"description": "从 OmniRoute 实时模型目录生成 ~/.codex 配置文件"
},
"packs": {
"description": "管理可选的运行时包ML / 浏览器自动化)",
"listDescription": "列出可选包及其安装状态",
"installDescription": "将可选包安装到 DATA_DIR",
"verifyDescription": "根据随附的校验和索引验证已安装的包",
"removeDescription": "移除已安装的可选包",
"sourceOpt": "存放包负载和包索引的目录",
"warnNoIndex": "未找到 optional-packs.index.json —— 此检出无法进行安装/验证(桌面捆绑包会附带它)",
"errUnknown": "未知的包:{name}",
"errNoIndex": "未找到包索引;请通过 --source <dir> 传入存放包负载的目录(桌面捆绑包会将其附带在应用旁)",
"installed": "包 “{name}” 已安装并在 {dir} 验证通过",
"restartHint": "请重启 OmniRoute 服务器(或桌面应用),以便运行时加载该包",
"removed": "包 “{name}” 已移除",
"notInstalled": "包 “{name}” 未安装",
"verifyOk": "所有已安装的包均已验证通过",
"verifyFailed": "{count} 个包验证失败",
"noneInstalled": "未安装可选包"
}
}

View File

@@ -38,7 +38,8 @@
"testFailed": "提供者測試失敗:{error}",
"loginEnabled": "登入:已啟用(密碼已更新)",
"loginDisabled": "登入:已停用",
"providerInfo": "提供者:{info}"
"providerInfo": "提供者:{info}",
"opencode": "安裝並配置隨附的 @omniroute/opencode-plugin 以用於 OpenCode"
},
"doctor": {
"title": "OmniRoute 診斷",
@@ -252,7 +253,9 @@
"no_recovery": "停用崩潰自動重啟(除錯模式)",
"max_restarts": "30 秒內的最大崩潰重啟次數預設2",
"tray": "顯示系統托盤圖示(僅桌面,選擇加入)",
"no_tray": "停用系統托盤圖示"
"no_tray": "停用系統托盤圖示",
"tls_cert": "用於提供 HTTPS 服務的 TLS 憑證PEM路徑也可用 OMNIROUTE_TLS_CERT",
"tls_key": "用於提供 HTTPS 服務的 TLS 私鑰PEM路徑也可用 OMNIROUTE_TLS_KEY"
},
"backup": {
"title": "備份",
@@ -1258,5 +1261,69 @@
"search": "搜尋 npm 登錄檔中的可用外掛",
"update": "更新已安裝的外掛",
"scaffold": "搭建新的外掛模板"
},
"authExport": {
"description": "匯出已解密的提供者憑據(僅限本機,明文輸出)",
"idOpt": "僅匯出與此 id/名稱/提供者相符的連線",
"formatOpt": "輸出格式json 或 env",
"outOpt": "將輸出寫入檔案而非標準輸出(以 0600 權限寫入)",
"forceOpt": "確認你了解此操作會列印/寫入明文密鑰",
"warning": "⚠ 此操作會列印/寫入已解密的明文 API 金鑰和 OAuth 令牌。請確保你的螢幕、shell 歷史記錄以及任何輸出檔案保持私密。",
"confirmHeading": "⚠ 警告:此操作會以明文匯出已解密的提供者憑據",
"confirmBody": "此命令會為所選連線解密並列印/寫入 apiKey、accessToken、refreshToken 和\nidToken。請將輸出視為機密。",
"confirmFooter": "如需確認,請執行:\n omniroute auth export --force",
"missingKey": "匯出憑據需要 STORAGE_ENCRYPTION_KEY。",
"notFound": "找不到連線:{id}",
"invalidFormat": "無效格式:{format}。請使用 json 或 env。"
},
"radar": {
"description": "檢查並同步本機 Radar 目錄訂閱來源",
"status": "顯示本機 Radar 設定和訂閱來源快取狀態",
"sync": "透過本機伺服器同步目錄、推薦、優惠和 Intel"
},
"launch": {
"description": "啟動指向 OmniRoute 的 Claude Code本機或遠端使用 --profile",
"token": "Claude 用戶端應傳送的令牌ANTHROPIC_AUTH_TOKEN",
"notRunning": "無法在 {port} 存取 OmniRoute。請使用「omniroute serve」啟動它。",
"notFound": "在 PATH 中找不到「claude」CLI。"
},
"run": {
"description": "透過 OmniRoute 啟動受支援的 CLI 目標"
},
"setupClaude": {
"description": "從 OmniRoute 模型目錄產生 ~/.claude/profiles 的 Claude Code 配置檔"
},
"connect": {
"description": "連線到遠端 OmniRoute 伺服器並進入遠端模式"
},
"tokens": {
"description": "管理限定範圍的 CLI 存取令牌(遠端模式)"
},
"configure": {
"description": "從使用中的伺服器選擇提供者+模型並配置受支援的本機 CLI"
},
"launchCodex": {
"description": "啟動指向 OmniRoute 的 Codex CLI本機或遠端 VPS"
},
"setupCodex": {
"description": "從 OmniRoute 即時模型目錄產生 ~/.codex 配置檔"
},
"packs": {
"description": "管理可選的執行階段套件ML / 瀏覽器自動化)",
"listDescription": "列出可選套件及其安裝狀態",
"installDescription": "將可選套件安裝到 DATA_DIR",
"verifyDescription": "根據隨附的總和檢查碼索引驗證已安裝的套件",
"removeDescription": "移除已安裝的可選套件",
"sourceOpt": "存放套件負載和套件索引的目錄",
"warnNoIndex": "找不到 optional-packs.index.json —— 此檢出無法進行安裝/驗證(桌面套件會隨附它)",
"errUnknown": "未知的套件:{name}",
"errNoIndex": "找不到套件索引;請透過 --source <dir> 傳入存放套件負載的目錄(桌面套件會將其隨附在應用程式旁)",
"installed": "套件「{name}」已安裝並在 {dir} 驗證通過",
"restartHint": "請重新啟動 OmniRoute 伺服器(或桌面應用程式),以便執行階段載入該套件",
"removed": "套件「{name}」已移除",
"notInstalled": "套件「{name}」未安裝",
"verifyOk": "所有已安裝的套件均已驗證通過",
"verifyFailed": "{count} 個套件驗證失敗",
"noneInstalled": "未安裝可選套件"
}
}

34
bin/cli/npm-exec.mjs Normal file
View File

@@ -0,0 +1,34 @@
// Spawning npm from the CLI, on every platform.
//
// On Windows npm is `npm.cmd`, a batch wrapper. Node ≥ 24 refuses to spawn a
// `.cmd` without a shell (nodejs/node#52554), and a bare `npm` can additionally
// resolve to an extensionless shim that `CreateProcess` cannot execute — so the
// call fails with `EINVAL` or `ENOENT` while npm works fine in the same terminal.
// `src/lib/services/installers/utils.ts` already solves this for the server; this
// is the same rule for the `bin/cli` entry points, which cannot import TypeScript.
//
// SECURITY (Hard Rule #13): enabling the shell means the SHELL splits the command
// line, not `execFile`. Every argv element passed alongside these options must be
// a literal — never a runtime value — or it must be validated first. Callers that
// need to pass a user-supplied name have to guard it themselves.
/** The npm binary to spawn on this platform. */
export function npmBin(platform = process.platform) {
const isBun = Boolean(process.versions.bun);
if (platform === "win32") return isBun ? "bun.exe" : "npm.cmd";
return isBun ? "bun" : "npm";
}
/**
* `execFile` / `spawnSync` options for an npm call.
*
* @param {NodeJS.Platform} platform
* @param {{ timeoutMs?: number, stdio?: string }} [options]
*/
export function npmExecOptions(platform = process.platform, options = {}) {
const base = {};
if (options.timeoutMs !== undefined) base.timeout = options.timeoutMs;
if (options.stdio !== undefined) base.stdio = options.stdio;
if (platform !== "win32") return { ...base, shell: false };
return { ...base, shell: true, windowsHide: true };
}

View File

@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { execSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime");
// systray2 is a maintained fork with prebuilt binaries — installed lazily at runtime,
@@ -16,6 +17,16 @@ export const SYSTRAY_PACKAGE = "systray2";
export const SYSTRAY_VERSION = "2.1.4";
const SYSTRAY_SPEC = `${SYSTRAY_PACKAGE}@${SYSTRAY_VERSION}`;
// Dynamic `import()` resolves its specifier as a URL, not a filesystem path.
// On Windows the lazily-installed systray2 lives at an absolute path whose
// leading drive letter the ESM loader parses as an unsupported URL scheme
// (e.g. `c:`) and rejects. Build a file:// URL so the tray import works on
// Windows too. Same defect fixed for the CLI db-fallback imports in #11238,
// missed at this call site.
export function systrayModuleSpecifier(runtimeDir: string): string {
return pathToFileURL(join(runtimeDir, "node_modules", SYSTRAY_PACKAGE)).href;
}
export function resolveSystrayBinName(platform: NodeJS.Platform): string | null {
if (platform === "win32") return "tray_windows_release.exe";
if (platform === "darwin") return "tray_darwin_release";
@@ -60,8 +71,7 @@ export async function loadSystray(): Promise<(new (...args: unknown[]) => unknow
// drop the +x bit on extraction (observed on macOS).
chmodSystrayBinAt(RUNTIME_DIR, process.platform);
try {
const modPath = join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE);
const mod = await import(modPath);
const mod = await import(systrayModuleSpecifier(RUNTIME_DIR));
return (mod.default ?? mod.SysTray ?? mod) as (new (...args: unknown[]) => unknown) | null;
} catch (err) {
console.warn(`[omniroute] tray runtime import failed: ${(err as Error).message}`);

View File

@@ -114,10 +114,13 @@ function writeLinuxSystemdUnit(cliPath) {
const unitDir = dirname(linuxSystemdUnitPath());
mkdirSync(unitDir, { recursive: true });
const envFile = join(userHomeDir(), ".omniroute", ".env");
const nodeBinDir = dirname(process.execPath);
const userLocalBin = join(userHomeDir(), ".local", "bin");
const pathEnv = `${nodeBinDir}:${userLocalBin}:/usr/local/sbin:/usr/local/bin:/usr/bin:/bin`;
const lines = [
"[Unit]",
"Description=OmniRoute AI proxy router",
"After=network-online.target",
"After=network-online.target graphical-session.target",
"Wants=network-online.target",
"",
"[Service]",
@@ -134,6 +137,7 @@ function writeLinuxSystemdUnit(cliPath) {
`ExecStart=${buildServeExecLine(cliPath, { tray: false })}`,
"Restart=on-failure",
"RestartSec=5",
`Environment="PATH=${pathEnv}"`,
];
if (existsSync(envFile)) lines.push(`EnvironmentFile=-${envFile}`);
lines.push("", "[Install]", "WantedBy=default.target", "");

View File

@@ -0,0 +1 @@
- **feat(video bridge):** harden the optional drill-down cache substrate with exact-path broker policy, canonical principal/session/media isolation, independent retained-byte quotas, cancellation-safe commits, rejection of excess or non-canonical Base64 padding and non-JPEG/truncated media, warning-sensitive full JPEG canonicalization that strips trailing polyglot bytes, server-derived dimensions, and auditable derivation metadata; production tenant binding and multi-resolution selection remain follow-up work ([#11369](https://github.com/diegosouzapw/OmniRoute/pull/11369))

View File

@@ -0,0 +1 @@
- **feat(video):** add an opt-in focused analysis mode that safely uses a normalized, 500-code-point latest-user hint for task-aware frame captions while preserving full-mode prompts, temporal-window isolation, and cache identity without storing raw task text ([#11383](https://github.com/diegosouzapw/OmniRoute/pull/11383)).

View File

@@ -0,0 +1 @@
- **fix(providers):** Antigravity OAuth marks connects with no Cloud Code projectId as degraded instead of a false "Connected"; BYOP detection at connect time, auto-disable of confirmed-missing accounts, and selection-side rotation ([#11284](https://github.com/diegosouzapw/OmniRoute/issues/11284))

View File

@@ -0,0 +1 @@
- **fix(video-bridge):** fall back to the deterministic active-window midpoint when a one-frame scene-aware budget cannot preserve both timeline ends; a real FFmpeg fixture matrix now covers rapid cuts, gradual changes, static and short clips, and detector failure ([#11344](https://github.com/diegosouzapw/OmniRoute/pull/11344)).

View File

@@ -0,0 +1 @@
- **fix(translator):** Codex Responses tool calls translated for Claude clients no longer emit a duplicate `tool_use` block with the same ID and an empty name, preventing Claude Code from terminating with `No such tool available` ([#11347](https://github.com/diegosouzapw/OmniRoute/pull/11347))

View File

@@ -0,0 +1 @@
- **fix(video-bridge):** burn high-contrast timestamps into every bounded contact-sheet cell and add a real-model A/B harness whose promotion verdict stays `HOLD` until token, latency, and quality evidence is actually executed ([#11350](https://github.com/diegosouzapw/OmniRoute/pull/11350))

View File

@@ -0,0 +1 @@
- **fix(video):** fingerprint protected Video Bridge bytes, coalesce concurrent work, and fail open when the bounded TTL/LRU result cache is unavailable or corrupt ([#11362](https://github.com/diegosouzapw/OmniRoute/pull/11362))

View File

@@ -0,0 +1 @@
- **fix(catalog):** keep large `/v1/models` builds responsive by reusing the build-local capability snapshot throughout enrichment and Auto-Combo preparation, yielding cooperatively while constructing virtual candidate pools, and avoiding unrelated synchronous database diagnostics on the cache-TTL read path ([#11367](https://github.com/diegosouzapw/OmniRoute/pull/11367))

View File

@@ -0,0 +1 @@
- **fix(video):** apply the caption-frame cap after bounded visual deduplication, preserve first/final candidates plus small high-contrast motion and text changes, and version the dedup policy in result-cache identity ([#11382](https://github.com/diegosouzapw/OmniRoute/pull/11382)).

View File

@@ -0,0 +1 @@
- **fix(dashboard):** Model Database sync interval slider ticks now match the thumb position — checkpoint-space slider with magnetic snap on release ([#11394](https://github.com/diegosouzapw/OmniRoute/pull/11394)) — thanks @An0nym0us92

View File

@@ -0,0 +1 @@
- **fix(cli):** `omniroute update` now finds npm on Windows. It called `execFile("npm", …)` with no shell, and on Node ≥ 24 a `.cmd` wrapper cannot be spawned that way (nodejs/node#52554) — while a bare `npm` can also resolve to an extensionless shim `CreateProcess` refuses. The result was `✖ Could not check latest version. Is npm available?` in a terminal where `npm view omniroute version` worked fine, so the updater was unusable on Windows even though nothing was wrong with the install. This is the same class as #5379/#5542, which fixed the server-side calls; the CLI entry points were missed because they are plain `.mjs` and cannot import the TypeScript helper. `bin/cli/npm-exec.mjs` now states the same rule for them: `npm.cmd` plus a shell on win32, no shell anywhere else. Both npm lookups in `update.mjs` (version and changelog) pass a literal argv array, so enabling the shell cannot splice a runtime value into the command line — a test asserts that and fails if a future edit interpolates one. (#11335)

View File

@@ -0,0 +1 @@
- **fix(compression):** use `pathToFileURL` in `compressionWorkerPool` so bundlers (Webpack / Turbopack) do not attempt static asset resolution of missing `compressionWorker.js` during build

View File

@@ -0,0 +1 @@
- **fix(usage):** z.ai/GLM coding-plan subscription keys now render their quota cards again, with absolute credits. Z.ai's `/api/monitor/usage/quota/limit` switched these keys from `TOKENS_LIMIT` to `CREDIT_LIMIT` rows (same `unit`/`number` semantics: unit=3/number=5 → 5-hour window, unit=6/number=1 → weekly), and the parser only matched `TOKENS_LIMIT`/`TIME_LIMIT`, so both rows were dropped and the subscription card rendered empty. `CREDIT_LIMIT` is now accepted alongside `TOKENS_LIMIT`, and when the row carries absolute credit fields (`usage`/`currentValue`/`remaining`) they are preferred over the percent-only scale, so the card shows `3341 / 28000` like z.ai's own dashboard instead of `11 / 100`

View File

@@ -0,0 +1 @@
- **fix(auth):** a connection's `lastError` now names the real upstream failure instead of the bare string `Provider error`. `markAccountUnavailable` kept the reason only when it was already a string, so every other shape collapsed to that literal — and the shape that matters most is not a string: a failed `fetch` arrives as `TypeError: fetch failed` with the actionable part on `error.cause.code`, which means a wrong port, a firewall, a DNS failure and a blocked proxy all looked identical in the dashboard and in the console line. `describeUpstreamFailure` (in `src/shared/utils/upstreamError.ts`, reusing the `extractErrorMessage` that already parsed provider bodies) reads Error messages and appends the transport code when the message does not already carry it, reads the usual provider JSON shapes (`error.message`, `message`, string `error`, `detail`, `errors[]`), and falls back to the code alone before giving up. It never serializes the error object wholesale, so a request body or header attached to an error cannot leak into the stored reason — pinned by a test.

View File

@@ -0,0 +1 @@
- **fix(live-ws):** the Live dashboard socket can now be pointed at a reverse proxy without rebuilding the image. `NEXT_PUBLIC_*` is inlined at BUILD time, so a prebuilt Docker or npm image never carries an operator's `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` — which is exactly why the browser discovers the socket through `/api/v1/ws?handshake=1` instead. The server side of that handshake, however, read only the `NEXT_PUBLIC_`-prefixed name, so it had nothing to echo: behind Traefik the dashboard kept dialling `wss://<host>:20132/live-ws` and sat on "Live disabled — WebSocket disconnected. Showing last known state." `LIVE_WS_PUBLIC_URL` is now read at runtime alongside the existing `LIVE_WS_HOST` / `LIVE_WS_PORT`, and the prefixed name stays supported as the fallback, so deployments that already set it are unaffected. Only `ws://` and `wss://` values are accepted, matching the guard the client already applies. (#11331)

View File

@@ -0,0 +1 @@
- **ci(changelog):** replace the broad removal bypass with an exact, hash-bound reconciliation ledger and bind merge-train checks to their requested release base ([#11345](https://github.com/diegosouzapw/OmniRoute/pull/11345)).

View File

@@ -0,0 +1,5 @@
- **docs(readme):** reconcile live v3.8.50 provider, free-tier, CLI, routing, test,
community, sponsor, acknowledgment, and SVG metrics with their audited source
denominators, including a deduplicated OmniRoute-in-Action snapshot and distinct
contributor rankings for merged pull requests, GitHub-attributed commits, and Git history
([#11356](https://github.com/diegosouzapw/OmniRoute/pull/11356)).

View File

@@ -0,0 +1 @@
- **fix(video-bridge):** make opt-in segment-aware sampling use one bounded structural FFmpeg pass (scene, freeze, blur, exposure, and SI/TI), preserve long trailing segments, fail open to uniform sampling, and add real-media structural-oracle, overhead, post-dedup caption-call, and false-positive evidence while holding unconfigured model quality and gain-versus-cost claims ([#11381](https://github.com/diegosouzapw/OmniRoute/pull/11381)).

View File

@@ -227,7 +227,9 @@
"tests/unit/translator-resp-gemini-to-openai.test.ts": 1604,
"tests/unit/usage-service-hardening.test.ts": 1928,
"tests/unit/vscode-token-routes.test.ts": 1633,
"tests/unit/executor-antigravity.test.ts": 1427
"tests/unit/executor-antigravity.test.ts": 1427,
"tests/unit/guardrails/videoBridgeResultCache.test.ts": 1040,
"_rebaseline_2026_08_24_video_bridge_fu01_fu03_fu04_result_cache_tests": "PRs #11362 (FU-01 cache hardening) + #11382 (FU-03 visual dedup policy identity) + #11383 (FU-04 focused analysis mode) own test growth: videoBridgeResultCache.test.ts <1000->1040, +40 (sum of three stacked PRs boarded together in the same merge-batch, each adding its own cache-identity assertions on the shared result-cache seam). Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive)."
},
"_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.",
"_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap.",
@@ -308,7 +310,7 @@
"_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)",
"frozen": {
"_rebaseline_2026_08_20_10878_10799_provider_health_probes": "PRs #10878 (unsupported OpenAI-like validation probes stay neutral) + #10799 (preserve credential health on inconclusive NVIDIA-timeout/Antigravity-400 probes) own growth: src/app/api/providers/[id]/test/route.ts 946->1025 (+79, sum of both boarded together). Both add narrowly-scoped classification branches at the existing test-route dispatch chokepoint (unsupported-capability skip, credential-inconclusive detection) rather than new files, mirroring the prior 2026_06_27_5193 rebaseline of the same file. Covered by tests/unit/provider-validation-unsupported-neutral.test.ts + tests/unit/provider-health-inconclusive-probes.test.ts.",
"src/app/api/providers/[id]/test/route.ts": 1215,
"src/app/api/providers/[id]/test/route.ts": 1237,
"_rebaseline_2026_08_23_11141_oauth_400_recovery": "PR #11141 (HouMinXi) own growth: test/route.ts 1025->1215 (+190, the reactive-400 recovery path — a fully rebuilt probe for refresh+retry on refreshable non-rotating connections, with inconclusive-status preservation and rotating-provider exclusion; all growth is the new probe builder + guards at the existing test-route dispatch, extraction would split the retry flow mid-logic). Covered by tests/unit/oauth-400-recovery.test.ts (8, bug-injection proof). Owner pre-authorized baseline bumps 2026-08-22.",
"_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.",
"_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.",
@@ -433,7 +435,8 @@
"src/shared/components/analytics/charts.tsx": 1346,
"src/shared/services/cliRuntime.ts": 1459,
"src/sse/handlers/chat.ts": 2493,
"src/sse/services/auth.ts": 3344,
"src/sse/services/auth.ts": 3346,
"_rebaseline_2026_08_24_lasterror_provider_error_detail": "PR (ntdat812) own growth: src/sse/services/auth.ts 3344->3346 (+2). One line is the import of describeUpstreamFailure from @/shared/utils/upstreamError, which replaces the string-only collapse `typeof errorText === \"string\" ? errorText.slice(0, 100) : \"Provider error\"` at the single markAccountUnavailable chokepoint (net 0 lines there) — the logic itself lives in upstreamError.ts, next to the extractErrorMessage it reuses, so nothing else moved into this file. The second line is the repo's own lint-staged prettier pass splitting a pre-existing two-statements-on-one-line at getProviderCredentials (`invalidateManagedLease(...); log.warn(...)`); it re-applies on any commit that touches this file, so it is not separable from the change. Covered by tests/unit/provider-error-detail-lastError.test.ts.",
"_rebaseline_2026_08_23_11186_synced_inventory_routing": "PR #11186 (pacocartones) own growth: src/sse/services/auth.ts 3260->3337 (+77, loadAdvertisedModelsForSelfHostedConnections + the modelNotAdvertised candidate-filter predicate — pins chat routing to the connection whose synced inventory actually advertises the model, fixing spurious model-not-found on multi-host self-hosted setups; at the existing credential-selection chokepoint, not extractable without splitting the selection flow). Covered by tests/unit/chat-routing-synced-inventory-11089.test.ts. Owner pre-authorized baseline bumps 2026-08-22.",
"tests/unit/account-fallback-service.test.ts": 2044,
"tests/unit/provider-validation-specialty.test.ts": 3880,
@@ -472,7 +475,10 @@
"_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.",
"_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming).",
"_rebaseline_2026_08_21_11069_m365_har_import": "#11069 own growth: AddApiKeyModal.tsx 1073->1080 (+7 = Import .har file button for the copilot-m365-web credential modal — M365 is the only provider whose credential (access_token+chathubPath) must be extracted from a DevTools HAR WebSocket URL, added as a new modal affordance). Cohesive UI at the existing modal chokepoint; not extractable. Covered by tests/unit/m365-har-import*.test.ts.",
"_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_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.",
"src/lib/guardrails/videoBridgeRuntime.ts": 1009,
"_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)."
},
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",

View File

@@ -0,0 +1,4 @@
{
"schemaVersion": 1,
"reconciliations": []
}

View File

@@ -108,24 +108,48 @@ A successful policy returns `AuthSubject` with `kind ∈ { client_api_key, dashb
`src/shared/constants/publicApiRoutes.ts` is the explicit allowlist:
The list is split by **shape**, and the split is load-bearing (GHSA-74g9-q8f6-793h): a prefix is
matched with `startsWith()`, so it also matches every adjacent path sharing its leading characters.
`/api/usage/om-usage` as a prefix marked `/api/usage/om-usage<anything>` PUBLIC, and Next resolves
that to `/api/usage/[connectionId]` — a handler with no auth of its own.
```ts
// Genuine subtrees. Every entry MUST end in "/" (asserted by a unit test).
PUBLIC_API_ROUTE_PREFIXES = [
"/api/auth/oidc/",
"/api/v1/", // treated as CLIENT_API in classify, not as "no-auth public"
"/api/oauth/",
"/api/codex/connect/",
"/api/telegram/",
"/api/cursor-cli/",
];
// Single routes, matched EXACTLY (with or without a trailing slash).
PUBLIC_API_ROUTES_EXACT = new Set([
"/api/auth/login",
"/api/auth/logout",
"/api/auth/status",
"/api/init",
"/api/v1/", // treated as CLIENT_API in classify, not as "no-auth public"
"/api/cloud/",
"/api/sync/bundle",
"/api/oauth/",
"/api/cli/connect",
"/api/usage/om-usage",
"/api/skills/collect/chaos",
]);
// Read-only single routes that also take the CORS origin relaxation.
PUBLIC_READONLY_CORS_API_ROUTES = [
"/api/health/ping",
"/api/monitoring/health",
"/api/settings/require-login",
];
PUBLIC_READONLY_API_ROUTE_PREFIXES = ["/api/monitoring/health", "/api/settings/require-login"];
// Read-only single route WITHOUT the CORS relaxation.
PUBLIC_READONLY_API_ROUTES_EXACT = new Set(["/api/health"]);
PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
```
Read-only prefixes are public **only** for safe methods. Note: `classifyRoute()` excludes `/api/v1/*` and `/api/v1beta/*` from the PUBLIC fall-through — those are always `CLIENT_API` so the Bearer-key policy still applies.
Read-only routes are public **only** for safe methods. Note: `classifyRoute()` excludes `/api/v1/*` and `/api/v1beta/*` from the PUBLIC fall-through — those are always `CLIENT_API` so the Bearer-key policy still applies.
## Adding a New Route
@@ -168,7 +192,7 @@ export async function POST(request: Request) {
### Pattern 3 — Adding to the public allowlist
Add the prefix to `PUBLIC_API_ROUTE_PREFIXES` (or `PUBLIC_READONLY_API_ROUTE_PREFIXES` for GET-only). Update unit tests at `tests/unit/public-api-routes.test.ts` and `tests/unit/authz/classify.test.ts`.
Pick the set by shape, not by convenience. One route goes in `PUBLIC_API_ROUTES_EXACT` (or `PUBLIC_READONLY_CORS_API_ROUTES` for GET-only); only a genuine subtree goes in `PUBLIC_API_ROUTE_PREFIXES`, and it **must end in `/`**. Putting a single route in the prefix list also publishes every adjacent path that shares its leading characters — including dynamic-segment siblings added later (GHSA-74g9-q8f6-793h). Update unit tests at `tests/unit/public-api-routes.test.ts`, `tests/unit/authz/public-route-exact-match.test.ts` and `tests/unit/authz/classify.test.ts`.
## Scopes

View File

@@ -16,7 +16,7 @@ Mermaid sources (`.mmd`) and exported SVGs for OmniRoute v3.8.0 architecture flo
| [auto-combo-12factor.mmd](./auto-combo-12factor.mmd) | [SVG](./exported/auto-combo-12factor.svg) | docs/routing/AUTO-COMBO.md |
| [resilience-3layers.mmd](./resilience-3layers.mmd) | [SVG](./exported/resilience-3layers.svg) | docs/architecture/RESILIENCE_GUIDE.md, CLAUDE.md |
| [i18n-flow.mmd](./i18n-flow.mmd) | [SVG](./exported/i18n-flow.svg) | docs/guides/I18N.md |
| [mcp-tools-107.mmd](./mcp-tools-107.mmd) | [SVG](./exported/mcp-tools-107.svg) | docs/frameworks/MCP-SERVER.md |
| [mcp-tools-107.mmd](./mcp-tools-107.mmd) | [SVG](./exported/mcp-tools-107.svg) | docs/frameworks/MCP-SERVER.md |
| [cloud-agent-flow.mmd](./cloud-agent-flow.mmd) | [SVG](./exported/cloud-agent-flow.svg) | docs/frameworks/CLOUD_AGENT.md |
| [authz-pipeline.mmd](./authz-pipeline.mmd) | [SVG](./exported/authz-pipeline.svg) | docs/architecture/AUTHZ_GUIDE.md |
| [db-schema-overview.mmd](./db-schema-overview.mmd) | [SVG](./exported/db-schema-overview.svg) | docs/architecture/CODEBASE_DOCUMENTATION.md |
@@ -34,11 +34,11 @@ inside GitHub's `<img>` sandbox:
| [combo-always-on.svg](./combo-always-on.svg) | style reference | Animated priority-combo fallback (4 layers, 16s loop). Edit the SVG directly — there is no `.mmd` source. |
| [cli-terminal.svg](./cli-terminal.svg) | README.md (root) | Compact half-height animated terminal (1200×350): 3 real CLI commands cycling with typewriter + scrolling subcommand ticker; first frame = completed providers screen. Edit the SVG directly — there is no `.mmd` source. |
| [compression-pipeline.svg](./compression-pipeline.svg) | README.md (root) | Animated 10-engine compression funnel (8s loop). Edit the SVG directly — there is no `.mmd` source. |
| [free-tier-budget.svg](./free-tier-budget.svg) | README.md (root) | Animated free-tier budget card (~1.53B/mo quantified headline, 19-pool budget bar, per-model grid, signup credits, 10s loop). Edit the SVG directly — there is no `.mmd` source. |
| [readme-hero.svg](./readme-hero.svg) | README.md (root) | Animated hero card (tagline, live provider/free-access headline, full-width compression bar demo, 6 stat chips). Edit the SVG directly — there is no `.mmd` source. |
| [free-tier-budget.svg](./free-tier-budget.svg) | README.md (root) | Animated free-tier budget card (~1.51B/mo quantified headline, 20-pool budget bar, per-pool grid, signup credits, 10s loop). Edit the SVG directly — there is no `.mmd` source. |
| [readme-hero.svg](./readme-hero.svg) | README.md (root) | Animated hero card (tagline, live provider/free-access headline, full-width compression bar demo, 6 stat chips). Edit the SVG directly — there is no `.mmd` source. |
| [promise-pillars.svg](./promise-pillars.svg) | README.md (root) | Animated "The Promise" 6-pillar card (12s border-highlight sweep). Edit the SVG directly — there is no `.mmd` source. |
| [why-pain-fix.svg](./why-pain-fix.svg) | README.md (root) | Animated "Why OmniRoute" 10-row pain-vs-fix ledger (15s green row sweep). Edit the SVG directly — there is no `.mmd` source. |
| [strategies-grid.svg](./strategies-grid.svg) | README.md (root) | Animated grid illustrating 18 of the 19 routing strategies; `cache-optimized` remains documented in the adjacent table. Edit the SVG directly — there is no `.mmd` source. |
| [strategies-grid.svg](./strategies-grid.svg) | README.md (root) | Animated grid illustrating 18 of the 19 routing strategies; `cache-optimized` remains documented in the adjacent table. Edit the SVG directly — there is no `.mmd` source. |
| [privacy-local.svg](./privacy-local.svg) | README.md (root) | Animated "Private & Local-First" 11-row guarantee ledger with receipt chips (16s green row sweep). Edit the SVG directly — there is no `.mmd` source. |
| [resilience-layers.svg](./resilience-layers.svg) | README.md (root) | Animated 3-layer resilience card (breaker states CLOSED→OPEN→HALF-OPEN, key cooldown with ×2 backoff, model lockout — 18s loops). Edit the SVG directly — there is no `.mmd` source. |

View File

@@ -1,24 +1,28 @@
%% Auto-Combo 13-factor scoring
%% Auto-Combo 15-factor scoring
%% Reflects: open-sse/services/autoCombo/scoring.ts (DEFAULT_WEIGHTS, sum = 1.0)
%% v3.8.49
%% v3.8.50
%% svg-title: OmniRoute Auto-Combo 15-factor scoring
%% svg-description: Flow from an incoming request through eligible candidates, the 15 weighted scoring factors, descending score sort, top-N selection, and sequential dispatch.
flowchart TB
Request["Incoming request"] --> Candidates["Eligible candidates<br/>(provider × model × account)"]
Candidates --> Score["Compute composite score<br/>per candidate"]
subgraph Factors["13-factor scoring weights (sum = 1.0)"]
f1["health (0.20)"]
f2["quota (0.15)"]
f3["costInv (0.15)"]
f4["latencyInv (0.12)"]
f5["taskFit (0.08)"]
f6["stability (0.05)"]
f7["tierPriority (0.05)"]
f8["tierAffinity (0.05)"]
f9["specificityMatch (0.05)"]
f10["contextAffinity (0.05)"]
f11["connectionDensity (0.05)"]
f12["cacheAffinity (0.00)"]
f13["resetWindowAffinity (0.00)"]
subgraph Factors["15-factor scoring weights (sum = 1.0)"]
f1["quota (0.1429)"]
f2["health (0.1605)"]
f3["costInv (0.1429)"]
f4["latencyInv (0.1143)"]
f5["taskFit (0.0762)"]
f6["stability (0.0476)"]
f7["tierPriority (0.0476)"]
f8["tierAffinity (0.0476)"]
f9["specificityMatch (0.0476)"]
f10["contextAffinity (0.0476)"]
f11["cacheAffinity (0.0000)"]
f12["sessionAvailability (0.0476)"]
f13["resetWindowAffinity (0.0000)"]
f14["connectionDensity (0.0476)"]
f15["quality (0.0300)"]
end
Score --> Factors

View File

@@ -1,12 +1,12 @@
<svg viewBox="0 0 1200 350" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Animated terminal demoing the OmniRoute CLI: omniroute providers list (350 providers registered, anthropic, codex, glm, kimi shown active), omniroute combo list (always-on priority, cost-saver, fusion-panel, context-relay) and omniroute health (healthy, 18412 requests in 24h, p95 412ms, circuit breakers 24 closed, 1 half-open, 0 open), cycling over the 80+ command surface: providers, oauth, keys, combo, nodes, models, cache, compression, cost, usage, quota, health, resilience, telemetry, logs, audit, mcp, a2a, cloud, memory, skills, eval, doctor, repl, tunnel, backup, sync, webhooks, policy, pricing, translator, simulate and more.">
<svg viewBox="0 0 1200 350" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Animated terminal demoing the OmniRoute CLI: omniroute providers list (350 providers registered, anthropic, codex, glm, kimi shown active), omniroute combo list (always-on priority, cost-saver, fusion-panel, context-relay) and omniroute health (healthy, 18412 requests in 24h, p95 412ms, circuit breakers 24 closed, 1 half-open, 0 open), cycling over 85 top-level commands: providers, oauth, keys, combo, nodes, models, cache, compression, cost, usage, quota, health, resilience, telemetry, logs, audit, mcp, a2a, cloud, memory, skills, eval, doctor, repl, tunnel, backup, sync, webhooks, policy, pricing, translator, simulate and more.">
<desc>Compact animated terminal cycling three real OmniRoute CLI commands with a typewriter effect and a scrolling subcommand ticker; the first frame shows the completed providers-list screen.</desc>
<defs><clipPath id="tickerClip"><rect x="12" y="304" width="1176" height="40"/></clipPath><clipPath id="tw0"><rect x="64" y="46" height="26" width="0"><animate attributeName="width" calcMode="discrete" values="0;31;61;92;122;153;184;214;245;245" keyTimes="0;0.012;0.018;0.024;0.030;0.036;0.042;0.048;0.054;1" dur="18s" repeatCount="indefinite"/></rect></clipPath><clipPath id="tw1"><rect x="64" y="46" height="26" width="0"><animate attributeName="width" calcMode="discrete" values="0;26;51;76;102;128;153;178;204;204" keyTimes="0;0.348;0.351;0.357;0.363;0.369;0.375;0.381;0.387;1" dur="18s" repeatCount="indefinite"/></rect></clipPath><clipPath id="tw2"><rect x="64" y="46" height="26" width="0"><animate attributeName="width" calcMode="discrete" values="0;20;41;61;82;102;122;143;163;163" keyTimes="0;0.678;0.684;0.690;0.696;0.702;0.708;0.714;0.720;1" dur="18s" repeatCount="indefinite"/></rect></clipPath></defs>
<rect width="1200" height="350" fill="#0d1117"/>
<rect x="0" y="0" width="1200" height="34" fill="#161b22"/>
<path d="M 0 34 L 1200 34" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
<circle cx="24" cy="17" r="6" fill="#ff5f56"/><circle cx="46" cy="17" r="6" fill="#ffbd2e"/><circle cx="68" cy="17" r="6" fill="#27c93f"/>
<text x="600" y="22" text-anchor="middle" font-family="Consolas, 'Courier New', monospace" font-size="13" fill="#71717a">omniroute &#8212; 80+ commands</text>
<g font-family="Consolas, 'Courier New', monospace" font-size="17"><animate attributeName="opacity" values="1;0;0" keyTimes="0;0.006;1" dur="18s" repeatCount="indefinite"/><text x="64" y="66" fill="#F7F6FC">omniroute providers list</text><text x="40" y="100" font-weight="700" fill="#38bdf8">OmniRoute Providers</text><text x="40" y="128" fill="#a1a1aa">1f3a9c2e&#160;&#160;anthropic&#160;&#160;&#160;Claude Max 20x&#160;&#160;&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="154" fill="#a1a1aa">8c2d5b1a&#160;&#160;codex&#160;&#160;&#160;&#160;&#160;&#160;&#160;Codex Pro (team)&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="180" fill="#a1a1aa">f4e0a97b&#160;&#160;glm&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;GLM Coding Plan&#160;&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="206" fill="#a1a1aa">03bd6e5f&#160;&#160;kimi&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Kimi K2 free&#160;&#160;&#160;&#160;&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="232" fill="#71717a">&#8230; 334 more providers</text></g><g opacity="1" font-family="Consolas, 'Courier New', monospace" font-size="17">
<text x="600" y="22" text-anchor="middle" font-family="Consolas, 'Courier New', monospace" font-size="13" fill="#71717a">omniroute &#8212; 85 top-level commands</text>
<g font-family="Consolas, 'Courier New', monospace" font-size="17"><animate attributeName="opacity" values="1;0;0" keyTimes="0;0.006;1" dur="18s" repeatCount="indefinite"/><text x="64" y="66" fill="#F7F6FC">omniroute providers list</text><text x="40" y="100" font-weight="700" fill="#38bdf8">OmniRoute Providers</text><text x="40" y="128" fill="#a1a1aa">1f3a9c2e&#160;&#160;anthropic&#160;&#160;&#160;Claude Max 20x&#160;&#160;&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="154" fill="#a1a1aa">8c2d5b1a&#160;&#160;codex&#160;&#160;&#160;&#160;&#160;&#160;&#160;Codex Pro (team)&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="180" fill="#a1a1aa">f4e0a97b&#160;&#160;glm&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;GLM Coding Plan&#160;&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="206" fill="#a1a1aa">03bd6e5f&#160;&#160;kimi&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Kimi K2 free&#160;&#160;&#160;&#160;&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="232" fill="#71717a">&#8230; 346 more providers</text></g><g opacity="1" font-family="Consolas, 'Courier New', monospace" font-size="17">
<animate attributeName="opacity" values="1;1;0;0" keyTimes="0;0.315;0.33;1" dur="18s" repeatCount="indefinite"/>
<text x="40" y="66" fill="#22c55e">$</text>
<g clip-path="url(#tw0)"><text x="64" y="66" fill="#F7F6FC">omniroute providers list</text></g>
@@ -14,7 +14,7 @@
<animate attributeName="x" calcMode="discrete" values="64;95;125;156;186;217;248;278;309;309" keyTimes="0.000;0.012;0.018;0.024;0.030;0.036;0.042;0.048;0.054;1" dur="18s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;0;1;0.2;1;0.2;1;0;0" keyTimes="0;0.011;0.012;0.022;0.032;0.042;0.052;0.074;1" dur="18s" repeatCount="indefinite"/>
</rect>
<text x="40" y="100" font-weight="700" fill="#38bdf8" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.045;0.047" dur="18s" repeatCount="indefinite"/>OmniRoute Providers</text><text x="40" y="128" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.053;0.055" dur="18s" repeatCount="indefinite"/>1f3a9c2e&#160;&#160;anthropic&#160;&#160;&#160;Claude Max 20x&#160;&#160;&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="154" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.061;0.063" dur="18s" repeatCount="indefinite"/>8c2d5b1a&#160;&#160;codex&#160;&#160;&#160;&#160;&#160;&#160;&#160;Codex Pro (team)&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="180" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.069;0.07100000000000001" dur="18s" repeatCount="indefinite"/>f4e0a97b&#160;&#160;glm&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;GLM Coding Plan&#160;&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="206" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.077;0.079" dur="18s" repeatCount="indefinite"/>03bd6e5f&#160;&#160;kimi&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Kimi K2 free&#160;&#160;&#160;&#160;&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="232" fill="#71717a" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.085;0.08700000000000001" dur="18s" repeatCount="indefinite"/>&#8230; 334 more providers</text>
<text x="40" y="100" font-weight="700" fill="#38bdf8" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.045;0.047" dur="18s" repeatCount="indefinite"/>OmniRoute Providers</text><text x="40" y="128" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.053;0.055" dur="18s" repeatCount="indefinite"/>1f3a9c2e&#160;&#160;anthropic&#160;&#160;&#160;Claude Max 20x&#160;&#160;&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="154" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.061;0.063" dur="18s" repeatCount="indefinite"/>8c2d5b1a&#160;&#160;codex&#160;&#160;&#160;&#160;&#160;&#160;&#160;Codex Pro (team)&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="180" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.069;0.07100000000000001" dur="18s" repeatCount="indefinite"/>f4e0a97b&#160;&#160;glm&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;GLM Coding Plan&#160;&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="206" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.077;0.079" dur="18s" repeatCount="indefinite"/>03bd6e5f&#160;&#160;kimi&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Kimi K2 free&#160;&#160;&#160;&#160;&#160;&#160;<tspan fill='#22c55e'>active</tspan></text><text x="40" y="232" fill="#71717a" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.085;0.08700000000000001" dur="18s" repeatCount="indefinite"/>&#8230; 346 more providers</text>
</g><g opacity="0" font-family="Consolas, 'Courier New', monospace" font-size="17">
<animate attributeName="opacity" values="0;0;1;1;0;0" keyTimes="0;0.333;0.34800000000000003;0.648;0.663;1" dur="18s" repeatCount="indefinite"/>
<text x="40" y="66" fill="#22c55e">$</text>
@@ -32,11 +32,11 @@
<animate attributeName="x" calcMode="discrete" values="64;84;105;125;146;166;186;207;227;227" keyTimes="0;0.678;0.684;0.690;0.696;0.702;0.708;0.714;0.720;1" dur="18s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;0;1;0.2;1;0.2;1;0;0" keyTimes="0;0.677;0.678;0.688;0.698;0.708;0.718;0.74;1" dur="18s" repeatCount="indefinite"/>
</rect>
<text x="40" y="100" font-weight="700" fill="#38bdf8" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.711;0.713" dur="18s" repeatCount="indefinite"/>OmniRoute Health</text><text x="40" y="128" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.719;0.721" dur="18s" repeatCount="indefinite"/>&#160;&#160;Status: <tspan fill='#22c55e'>healthy</tspan>&#160;&#160;&#160;Uptime: 4d 12h 33m</text><text x="40" y="154" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.727;0.729" dur="18s" repeatCount="indefinite"/>&#160;&#160;Requests (24h): 18,412&#160;&#160;&#160;p95: 412ms</text><text x="40" y="180" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.735;0.737" dur="18s" repeatCount="indefinite"/>&#160;&#160;Breakers: <tspan fill='#22c55e'>&#9679; 24 closed</tspan>&#160;&#160;<tspan fill='#f59e0b'>&#9682; 1 half-open</tspan>&#160;&#160;<tspan fill='#ef4444'>&#9675; 0 open</tspan></text><text x="40" y="206" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.743;0.745" dur="18s" repeatCount="indefinite"/>&#160;&#160;Providers: 338 registered&#160;&#160;&#160;90+ free tiers</text><text x="40" y="232" fill="#71717a" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.751;0.753" dur="18s" repeatCount="indefinite"/>&#8230; live: /dashboard &#183; omniroute status</text>
<text x="40" y="100" font-weight="700" fill="#38bdf8" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.711;0.713" dur="18s" repeatCount="indefinite"/>OmniRoute Health</text><text x="40" y="128" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.719;0.721" dur="18s" repeatCount="indefinite"/>&#160;&#160;Status: <tspan fill='#22c55e'>healthy</tspan>&#160;&#160;&#160;Uptime: 4d 12h 33m</text><text x="40" y="154" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.727;0.729" dur="18s" repeatCount="indefinite"/>&#160;&#160;Requests (24h): 18,412&#160;&#160;&#160;p95: 412ms</text><text x="40" y="180" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.735;0.737" dur="18s" repeatCount="indefinite"/>&#160;&#160;Breakers: <tspan fill='#22c55e'>&#9679; 24 closed</tspan>&#160;&#160;<tspan fill='#f59e0b'>&#9682; 1 half-open</tspan>&#160;&#160;<tspan fill='#ef4444'>&#9675; 0 open</tspan></text><text x="40" y="206" fill="#a1a1aa" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.743;0.745" dur="18s" repeatCount="indefinite"/>&#160;&#160;Providers: 350 registered&#160;&#160;&#160;90+ free tiers</text><text x="40" y="232" fill="#71717a" opacity="0"><animate attributeName="opacity" calcMode="discrete" values="0;0;1" keyTimes="0;0.751;0.753" dur="18s" repeatCount="indefinite"/>&#8230; live: /dashboard &#183; omniroute status</text>
</g>
<path d="M 0 300 L 1200 300" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
<g clip-path="url(#tickerClip)"><g font-family="Consolas, 'Courier New', monospace" font-size="14" fill="#71717a">
<animateTransform attributeName="transform" type="translate" from="0 0" to="-2432 0" dur="55s" repeatCount="indefinite"/>
<text x="24" y="330"><tspan fill="#8b5cf6">providers</tspan> &#183; oauth &#183; keys &#183; <tspan fill="#8b5cf6">combo</tspan> &#183; nodes &#183; models &#183; cache &#183; <tspan fill="#8b5cf6">compression</tspan> &#183; cost &#183; usage &#183; quota &#183; <tspan fill="#8b5cf6">health</tspan> &#183; resilience &#183; telemetry &#183; logs &#183; audit &#183; <tspan fill="#8b5cf6">mcp</tspan> &#183; a2a &#183; cloud &#183; <tspan fill="#8b5cf6">memory</tspan> &#183; skills &#183; eval &#183; <tspan fill="#8b5cf6">doctor</tspan> &#183; repl &#183; tunnel &#183; backup &#183; sync &#183; webhooks &#183; policy &#183; pricing &#183; translator &#183; simulate &#8230;</text><text x="2456" y="330"><tspan fill="#8b5cf6">providers</tspan> &#183; oauth &#183; keys &#183; <tspan fill="#8b5cf6">combo</tspan> &#183; nodes &#183; models &#183; cache &#183; <tspan fill="#8b5cf6">compression</tspan> &#183; cost &#183; usage &#183; quota &#183; <tspan fill="#8b5cf6">health</tspan> &#183; resilience &#183; telemetry &#183; logs &#183; audit &#183; <tspan fill="#8b5cf6">mcp</tspan> &#183; a2a &#183; cloud &#183; <tspan fill="#8b5cf6">memory</tspan> &#183; skills &#183; eval &#183; <tspan fill="#8b5cf6">doctor</tspan> &#183; repl &#183; tunnel &#183; backup &#183; sync &#183; webhooks &#183; policy &#183; pricing &#183; translator &#183; simulate &#8230;</text>
</g></g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -23,7 +23,7 @@
<g font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif">
<g opacity="0"><animate attributeName="opacity" values="0;1" dur="0.4s" begin="0.15s" fill="freeze"/>
<text x="44" y="196" font-size="14.5" fill="#c9d1d9">Providers</text>
<text x="440" y="196" text-anchor="middle" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="15" font-weight="800" fill="#7ee787">338</text>
<text x="440" y="196" text-anchor="middle" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="15" font-weight="800" fill="#7ee787">350</text>
<text x="604" y="196" text-anchor="middle" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="13.5" font-weight="600" fill="#8b949e">40+</text>
<text x="760" y="196" text-anchor="middle" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="13.5" font-weight="600" fill="#8b949e">400+*</text>
<text x="916" y="196" text-anchor="middle" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="13.5" font-weight="600" fill="#8b949e">~5</text>
@@ -57,7 +57,7 @@
</g>
<g opacity="0"><animate attributeName="opacity" values="0;1" dur="0.4s" begin="0.51s" fill="freeze"/>
<text x="44" y="364" font-size="14.5" fill="#c9d1d9">Built-in MCP server (own tools)</text>
<text x="440" y="364" text-anchor="middle" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="15" font-weight="800" fill="#7ee787">109</text>
<text x="440" y="364" text-anchor="middle" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="15" font-weight="800" fill="#7ee787">110</text>
<use href="#no" x="604" y="359"/>
<use href="#mid" x="760" y="359"/>
<use href="#no" x="916" y="359"/>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,4 +1,5 @@
<svg viewBox="0 0 1200 842" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute free-tier budget: about 1.51 billion free tokens per month steady, up to about 2.13 billion in your first month with signup credits, aggregated from the documented free tiers of 40 provider pools and 495 models behind one endpoint, live on /dashboard/free-tiers. Honest pool-deduped math: each shared free pool is counted once — counting every rate limit 24/7 would read about 10B, which we don't publish; 15 providers carry a ToS flag so you decide. Budget bar of the 19 countable free pools with per-model breakdown: Mistral Large 3 1B, GPT-4o mini 150M, Gemini 2.5 Flash 60M, GLM 4.7 30M, Llama 3.3 70B 30M, Grok-3 24M, DeepSeek V4 Pro 20M, GPT-4.1 18M, Llama 4 Scout 15M, GPT-4o 7M, MiniMax-M2.7 6M, Arcee Trinity 5M, and more. First month adds one-time signup credits of about 626M (vertex 300M, agentrouter 200M, predibase 25M, together 25M, glm-cn 20M, doubao 15M, ai21 10M, longcat 10M, deepseek 5M, hyperbolic 5M, nscale 5M). Plus the un-countable: permanently-free no-token-cap providers (SiliconFlow, Z.AI GLM-Flash, Kilo, OpenCode Zen, baidu and more) and a $10 OpenRouter top-up unlocking +24M per month, surfaced separately so they never inflate the headline. Live used/remaining and per-model breakdown on the dashboard.">
<svg viewBox="0 0 1200 842" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute free-tier budget: about 1.51 billion free tokens per month steady, up to about 2.13 billion in the first month with signup credits. The catalog contains 455 rows, 448 active and 7 discontinued, grouped into 40 recurring pool keys; 20 pools have a published positive monthly token budget and 20 have a zero, uncapped, or keyless budget. Honest pool-deduped math counts each shared free pool once; 15 providers carry a terms-of-service avoid flag. The 20 quantified pools are Mistral 1 billion, LLM7 150 million, Nara 150 million, Gemini 60 million, Cerebras 30 million, Cloudflare AI 30 million, API Airforce 24 million, Ollama Cloud 20 million, Groq 15 million, Bluesminds 7.2 million, SambaNova 6 million, Arcee 4.8 million, Navy 4.5 million, BazaarLink 3.6 million, OpenRouter 1.2 million, Cohere 800 thousand, HuggingChat 500 thousand, Morph 400 thousand, Hugging Face 200 thousand, and Kiro 25 thousand. One-time signup credits add about 626 million. Uncapped providers and the OpenRouter top-up boost are shown separately so they do not inflate the headline. Live usage remains available at /dashboard/free-tiers.">
<desc>Pool-deduplicated chart of the 20 recurring free-token pools with positive published budgets, plus signup credits and uncapped providers shown separately.</desc>
<defs>
<pattern id="gridPaperF" width="32" height="32" patternUnits="userSpaceOnUse">
<path d="M 32 0 L 0 0 0 32" fill="none" stroke="#ffffff" stroke-opacity="0.06" stroke-width="1"/>
@@ -63,7 +64,7 @@
<text x="60" y="228" font-family="Consolas, 'Courier New', monospace" font-size="104" font-weight="800" fill="url(#gradBrandF)">~1.51B</text>
<text x="62" y="266" font-family="Consolas, 'Courier New', monospace" font-size="15" letter-spacing="3" font-weight="700" fill="#a1a1aa">FREE TOKENS / MONTH &#183; <tspan fill="#22c55e">STEADY</tspan></text>
<text x="62" y="298" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="16" fill="#F7F6FC">up to <tspan font-weight="800" fill="#22c55e">~2.13B</tspan> in your first month &#8212; signup credits</text>
<text x="62" y="326" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#71717a">documented free tiers &#183; <tspan fill="#8b5cf6">40 provider pools</tspan> &#183; <tspan fill="#8b5cf6">495 models</tspan> &#183; one endpoint</text>
<text x="62" y="326" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#71717a">documented free tiers &#183; <tspan fill="#8b5cf6">40 recurring pools</tspan> &#183; <tspan fill="#8b5cf6">455 catalog entries</tspan> &#183; one endpoint</text>
<!-- ═══ Panel · The honest math ═══ -->
<rect x="680" y="84" width="460" height="216" rx="14" fill="#161b22" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
@@ -79,59 +80,61 @@
<text x="836" y="244" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#22c55e">counted once &#10003;</text>
<text x="704" y="280" font-family="Consolas, 'Courier New', monospace" font-size="12" fill="#f59e0b"><tspan font-weight="800">15 providers</tspan> ToS-flagged <tspan fill="#71717a">&#8212; we flag it &#183; you decide</tspan></text>
<!-- ═══ Budget bar · 19 countable pools ═══ -->
<text x="60" y="356" font-family="Consolas, 'Courier New', monospace" font-size="10.5" letter-spacing="2.5" font-weight="700" fill="#a78bfa">WHERE IT COMES FROM &#183; <tspan fill="#F7F6FC">19 COUNTABLE FREE POOLS</tspan></text>
<!-- ═══ Budget bar · 20 quantified recurring pools ═══ -->
<text x="60" y="356" font-family="Consolas, 'Courier New', monospace" font-size="10.5" letter-spacing="2.5" font-weight="700" fill="#a78bfa">WHERE IT COMES FROM &#183; <tspan fill="#F7F6FC">20 QUANTIFIED RECURRING POOLS</tspan></text>
<g clip-path="url(#barShapeF)">
<rect x="60" y="372" width="1080" height="18" fill="#1c2230"/>
<g clip-path="url(#barRevF)">
<rect x="60.0" y="372" width="662.3" height="18" fill="#6c5ce7"/>
<rect x="723.3" y="372" width="106.7" height="18" fill="#00b894"/>
<rect x="831.0" y="372" width="47.9" height="18" fill="#0984e3"/>
<rect x="879.9" y="372" width="28.3" height="18" fill="#e17055"/>
<rect x="909.2" y="372" width="28.3" height="18" fill="#fdcb6e"/>
<rect x="938.5" y="372" width="24.4" height="18" fill="#e84393"/>
<rect x="963.9" y="372" width="21.8" height="18" fill="#00cec9"/>
<rect x="986.7" y="372" width="20.5" height="18" fill="#d63031"/>
<rect x="1008.2" y="372" width="18.5" height="18" fill="#a29bfe"/>
<rect x="1027.7" y="372" width="13.3" height="18" fill="#55efc4"/>
<rect x="1042.0" y="372" width="12.6" height="18" fill="#74b9ff"/>
<rect x="1055.6" y="372" width="12.0" height="18" fill="#ffeaa7"/>
<rect x="1068.6" y="372" width="11.3" height="18" fill="#fab1a0"/>
<rect x="1080.9" y="372" width="9.4" height="18" fill="#81ecec"/>
<rect x="1091.3" y="372" width="9.2" height="18" fill="#6c5ce7"/>
<rect x="1101.5" y="372" width="9.0" height="18" fill="#00b894"/>
<rect x="1111.5" y="372" width="9.0" height="18" fill="#0984e3"/>
<rect x="1121.5" y="372" width="8.8" height="18" fill="#e17055"/>
<rect x="1131.3" y="372" width="8.7" height="18" fill="#fdcb6e"/>
<rect x="60.0" y="372" width="661.4" height="18" fill="#6c5ce7"/>
<rect x="722.4" y="372" width="99.2" height="18" fill="#00b894"/>
<rect x="822.6" y="372" width="99.2" height="18" fill="#0984e3"/>
<rect x="922.9" y="372" width="39.7" height="18" fill="#e17055"/>
<rect x="963.5" y="372" width="19.8" height="18" fill="#fdcb6e"/>
<rect x="984.4" y="372" width="19.8" height="18" fill="#e84393"/>
<rect x="1005.2" y="372" width="15.9" height="18" fill="#00cec9"/>
<rect x="1022.1" y="372" width="13.2" height="18" fill="#d63031"/>
<rect x="1036.3" y="372" width="9.9" height="18" fill="#a29bfe"/>
<rect x="1047.3" y="372" width="7.5" height="18" fill="#55efc4"/>
<rect x="1055.8" y="372" width="7.5" height="18" fill="#74b9ff"/>
<rect x="1064.3" y="372" width="7.5" height="18" fill="#ffeaa7"/>
<rect x="1072.8" y="372" width="7.5" height="18" fill="#fab1a0"/>
<rect x="1081.3" y="372" width="7.5" height="18" fill="#81ecec"/>
<rect x="1089.9" y="372" width="7.5" height="18" fill="#6c5ce7"/>
<rect x="1098.4" y="372" width="7.5" height="18" fill="#00b894"/>
<rect x="1106.9" y="372" width="7.5" height="18" fill="#0984e3"/>
<rect x="1115.4" y="372" width="7.5" height="18" fill="#e17055"/>
<rect x="1124.0" y="372" width="7.5" height="18" fill="#fdcb6e"/>
<rect x="1132.5" y="372" width="7.5" height="18" fill="#e84393"/>
</g>
</g>
<circle r="3.2" fill="#F7F6FC">
<animateMotion path="M 60,381 L 1140,381" keyPoints="0;0;1;1" keyTimes="0;0.02;0.24;1" calcMode="linear" dur="10s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0;0" keyTimes="0;0.02;0.23;0.26;1" dur="10s" repeatCount="indefinite"/>
</circle>
<text x="60" y="416" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#71717a">each segment = one free pool &#183; widths floored so every provider shows &#183; honest numbers below</text>
<text x="60" y="416" font-family="Consolas, 'Courier New', monospace" font-size="11.5" fill="#71717a">each segment = one recurring pool &#183; widths floored so every pool shows &#183; audited pool budgets below</text>
<!-- ═══ Per-model grid (19 pools) ═══ -->
<!-- ═══ Per-pool grid (20 quantified recurring pools) ═══ -->
<g font-family="Consolas, 'Courier New', monospace" font-size="12.5">
<circle cx="66" cy="452" r="5" fill="#6c5ce7"/><text x="78" y="456" fill="#c9d1d9">Mistral Large 3 <tspan fill="#71717a">1.00B</tspan></text>
<circle cx="346" cy="452" r="5" fill="#00b894"/><text x="358" y="456" fill="#c9d1d9">GPT-4o mini <tspan fill="#71717a">150M</tspan></text>
<circle cx="626" cy="452" r="5" fill="#0984e3"/><text x="638" y="456" fill="#c9d1d9">Gemini 2.5 Flash <tspan fill="#71717a">60M</tspan></text>
<circle cx="906" cy="452" r="5" fill="#e17055"/><text x="918" y="456" fill="#c9d1d9">GLM 4.7 <tspan fill="#71717a">30M</tspan></text>
<circle cx="66" cy="482" r="5" fill="#fdcb6e"/><text x="78" y="486" fill="#c9d1d9">Llama 3.3 70B <tspan fill="#71717a">30M</tspan></text>
<circle cx="346" cy="482" r="5" fill="#e84393"/><text x="358" y="486" fill="#c9d1d9">Grok-3 <tspan fill="#71717a">24M</tspan></text>
<circle cx="626" cy="482" r="5" fill="#00cec9"/><text x="638" y="486" fill="#c9d1d9">DeepSeek V4 Pro <tspan fill="#71717a">20M</tspan></text>
<circle cx="906" cy="482" r="5" fill="#d63031"/><text x="918" y="486" fill="#c9d1d9">GPT-4.1 <tspan fill="#71717a">18M</tspan></text>
<circle cx="66" cy="512" r="5" fill="#a29bfe"/><text x="78" y="516" fill="#c9d1d9">Llama 4 Scout <tspan fill="#71717a">15M</tspan></text>
<circle cx="346" cy="512" r="5" fill="#55efc4"/><text x="358" y="516" fill="#c9d1d9">GPT-4o <tspan fill="#71717a">7M</tspan></text>
<circle cx="626" cy="512" r="5" fill="#74b9ff"/><text x="638" y="516" fill="#c9d1d9">MiniMax-M2.7 <tspan fill="#71717a">6M</tspan></text>
<circle cx="906" cy="512" r="5" fill="#ffeaa7"/><text x="918" y="516" fill="#c9d1d9">Arcee Trinity Large Prev <tspan fill="#71717a">5M</tspan></text>
<circle cx="66" cy="542" r="5" fill="#fab1a0"/><text x="78" y="546" fill="#c9d1d9">Auto Free <tspan fill="#71717a">4M</tspan></text>
<circle cx="346" cy="542" r="5" fill="#81ecec"/><text x="358" y="546" fill="#c9d1d9">Auto <tspan fill="#71717a">1M</tspan></text>
<circle cx="626" cy="542" r="5" fill="#6c5ce7"/><text x="638" y="546" fill="#c9d1d9">Command A Reasoning <tspan fill="#71717a">800K</tspan></text>
<circle cx="906" cy="542" r="5" fill="#00b894"/><text x="918" y="546" fill="#c9d1d9">ERNIE 4.5 VL 424B <tspan fill="#71717a">500K</tspan></text>
<circle cx="66" cy="572" r="5" fill="#0984e3"/><text x="78" y="576" fill="#c9d1d9">morph-v3-large <tspan fill="#71717a">400K</tspan></text>
<circle cx="346" cy="572" r="5" fill="#e17055"/><text x="358" y="576" fill="#c9d1d9">Llama 3.1 8B <tspan fill="#71717a">200K</tspan></text>
<circle cx="626" cy="572" r="5" fill="#fdcb6e"/><text x="638" y="576" fill="#c9d1d9">Claude Sonnet 4.5 <tspan fill="#71717a">25K</tspan></text>
<circle cx="66" cy="452" r="5" fill="#6c5ce7"/><text x="78" y="456" fill="#c9d1d9">Mistral <tspan fill="#71717a">1.00B</tspan></text>
<circle cx="346" cy="452" r="5" fill="#00b894"/><text x="358" y="456" fill="#c9d1d9">LLM7 <tspan fill="#71717a">150M</tspan></text>
<circle cx="626" cy="452" r="5" fill="#0984e3"/><text x="638" y="456" fill="#c9d1d9">Nara <tspan fill="#71717a">150M</tspan></text>
<circle cx="906" cy="452" r="5" fill="#e17055"/><text x="918" y="456" fill="#c9d1d9">Gemini <tspan fill="#71717a">60M</tspan></text>
<circle cx="66" cy="482" r="5" fill="#fdcb6e"/><text x="78" y="486" fill="#c9d1d9">Cerebras <tspan fill="#71717a">30M</tspan></text>
<circle cx="346" cy="482" r="5" fill="#e84393"/><text x="358" y="486" fill="#c9d1d9">Cloudflare AI <tspan fill="#71717a">30M</tspan></text>
<circle cx="626" cy="482" r="5" fill="#00cec9"/><text x="638" y="486" fill="#c9d1d9">API Airforce <tspan fill="#71717a">24M</tspan></text>
<circle cx="906" cy="482" r="5" fill="#d63031"/><text x="918" y="486" fill="#c9d1d9">Ollama Cloud <tspan fill="#71717a">20M</tspan></text>
<circle cx="66" cy="512" r="5" fill="#a29bfe"/><text x="78" y="516" fill="#c9d1d9">Groq <tspan fill="#71717a">15M</tspan></text>
<circle cx="346" cy="512" r="5" fill="#55efc4"/><text x="358" y="516" fill="#c9d1d9">Bluesminds <tspan fill="#71717a">7.2M</tspan></text>
<circle cx="626" cy="512" r="5" fill="#74b9ff"/><text x="638" y="516" fill="#c9d1d9">SambaNova <tspan fill="#71717a">6M</tspan></text>
<circle cx="906" cy="512" r="5" fill="#ffeaa7"/><text x="918" y="516" fill="#c9d1d9">Arcee <tspan fill="#71717a">4.8M</tspan></text>
<circle cx="66" cy="542" r="5" fill="#fab1a0"/><text x="78" y="546" fill="#c9d1d9">Navy <tspan fill="#71717a">4.5M</tspan></text>
<circle cx="346" cy="542" r="5" fill="#81ecec"/><text x="358" y="546" fill="#c9d1d9">BazaarLink <tspan fill="#71717a">3.6M</tspan></text>
<circle cx="626" cy="542" r="5" fill="#6c5ce7"/><text x="638" y="546" fill="#c9d1d9">OpenRouter <tspan fill="#71717a">1.2M</tspan></text>
<circle cx="906" cy="542" r="5" fill="#00b894"/><text x="918" y="546" fill="#c9d1d9">Cohere <tspan fill="#71717a">800K</tspan></text>
<circle cx="66" cy="572" r="5" fill="#0984e3"/><text x="78" y="576" fill="#c9d1d9">HuggingChat <tspan fill="#71717a">500K</tspan></text>
<circle cx="346" cy="572" r="5" fill="#e17055"/><text x="358" y="576" fill="#c9d1d9">Morph <tspan fill="#71717a">400K</tspan></text>
<circle cx="626" cy="572" r="5" fill="#fdcb6e"/><text x="638" y="576" fill="#c9d1d9">Hugging Face <tspan fill="#71717a">200K</tspan></text>
<circle cx="906" cy="572" r="5" fill="#e84393"/><text x="918" y="576" fill="#c9d1d9">Kiro <tspan fill="#71717a">25K</tspan></text>
</g>
<!-- ═══ First-month signup credits ═══ -->

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint, 350 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 350 providers in milliseconds, quota out means the next provider takes over with zero downtime. Save up to 95 percent of tokens: RTK plus Caveman stacked compression cuts 15 to 95 percent of eligible tokens, about 89 percent average on tool-heavy sessions. Zero dollars to start: 90+ providers with a free tier, 56 free forever — Qoder, Pollinations, Cloudflare, SiliconFlow — no card needed. Every tool works: 33 coding agents including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation — point any tool at /v1 and it just works. Production-grade: circuit breakers, TLS stealth, MCP with 110 tools, A2A, memory, guardrails, evals — 25,000+ tests.">
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint and 350 providers. Six pillars. Resilient fallback: automatic routing continues while another healthy target is available. Save up to 95 percent of eligible tokens: RTK plus Caveman stacked compression averages about 89 percent on tool-heavy sessions. Zero dollars to start: 90+ providers with a free tier and 56 recurring or keyless free-forever providers. Every tool works: 35 CLI and agent integration records, including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity, through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation at /v1. Production controls: circuit breakers, TLS stealth, MCP with 110 tools, A2A, memory, guardrails, evals, and 39,000+ static test declarations across 5,100+ tracked test files.">
<desc>Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle.</desc>
<defs>
<pattern id="gridPaperP" width="32" height="32" patternUnits="userSpaceOnUse">
@@ -40,7 +40,7 @@
<text x="102" y="170" font-size="18" font-weight="800" fill="#74b9ff">Never hit limits</text>
<text x="66" y="204" font-size="13.5" fill="#a1a1aa">Auto-fallback across 350 providers in</text>
<text x="66" y="226" font-size="13.5" fill="#a1a1aa">milliseconds. Quota out? The next provider</text>
<text x="66" y="248" font-size="13.5" fill="#a1a1aa">takes over — zero downtime.</text>
<text x="66" y="248" font-size="13.5" fill="#a1a1aa">takes over while a healthy target remains.</text>
</g>
<!-- cell 2: save tokens (orange) -->
@@ -91,7 +91,7 @@
<path d="M 10,18 L 10,22"/>
</g>
<text x="102" y="354" font-size="18" font-weight="800" fill="#a78bfa">Every tool works</text>
<text x="66" y="388" font-size="13.5" fill="#a1a1aa">33 coding agents — Claude Code, Codex,</text>
<text x="66" y="388" font-size="13.5" fill="#a1a1aa">35 CLI/agent integrations — Claude Code, Codex,</text>
<text x="66" y="410" font-size="13.5" fill="#a1a1aa">Cursor, Cline, Copilot, Antigravity —</text>
<text x="66" y="432" font-size="13.5" fill="#a1a1aa">through one config.</text>
</g>
@@ -127,7 +127,7 @@
<text x="862" y="354" font-size="18" font-weight="800" fill="#7ee787">Production-grade</text>
<text x="826" y="388" font-size="13.5" fill="#a1a1aa">Circuit breakers, TLS stealth, MCP (110</text>
<text x="826" y="410" font-size="13.5" fill="#a1a1aa">tools), A2A, memory, guardrails, evals —</text>
<text x="826" y="432" font-size="13.5" fill="#a1a1aa">25,000+ tests.</text>
<text x="826" y="432" font-size="13.5" fill="#a1a1aa">39,000+ static test declarations.</text>
</g>
</g>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -66,7 +66,7 @@
<!-- stat chips -->
<g font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" text-anchor="middle">
<rect x="48" y="448" width="172" height="52" rx="12" fill="#161b22" stroke="#6c5ce7" stroke-opacity="0.55" stroke-width="1.5"/>
<text x="134" y="471" font-size="17" font-weight="800" fill="#a78bfa">338</text>
<text x="134" y="471" font-size="17" font-weight="800" fill="#a78bfa">350</text>
<text x="134" y="490" font-size="11" fill="#a1a1aa">AI PROVIDERS</text>
<rect x="234" y="448" width="172" height="52" rx="12" fill="#161b22" stroke="#22c55e" stroke-opacity="0.55" stroke-width="1.5"/>
<text x="320" y="471" font-size="17" font-weight="800" fill="#7ee787">90+</text>

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -95,7 +95,7 @@
<rect width="173" height="158" rx="10" fill="#161b22" stroke="#ffffff" stroke-opacity="0.07" stroke-width="1"/>
<text x="12" y="21" font-family="Consolas, 'Courier New', monospace" font-size="11" fill="#a78bfa">auto</text>
<circle cx="20" cy="79" r="4" fill="none" stroke="#c9d1d9" stroke-width="1.6"/><circle cx="20" cy="79" r="1.6" fill="#c9d1d9"/><path d="M 26,79 C 62,79 84,67.5 112,67.5" fill="none" stroke="#8b5cf6" stroke-opacity="0.55" stroke-width="1.6"/><rect x="116" y="38.0" width="26" height="11" rx="2.5" fill="#1c2330" stroke="#ffffff" stroke-opacity="0.10" stroke-width="1"/><text x="147" y="46.5" font-family="Consolas, 'Courier New', monospace" font-size="8.5" fill="#71717a">72</text><rect x="116" y="62.0" width="26" height="11" rx="2.5" fill="#1c2330" stroke="#7ee787" stroke-opacity="0.8" stroke-width="1"/><text x="147" y="70.5" font-family="Consolas, 'Courier New', monospace" font-size="8.5" fill="#71717a">91</text><rect x="116" y="86.0" width="26" height="11" rx="2.5" fill="#1c2330" stroke="#ffffff" stroke-opacity="0.10" stroke-width="1"/><text x="147" y="94.5" font-family="Consolas, 'Courier New', monospace" font-size="8.5" fill="#71717a">64</text><rect x="116" y="110.0" width="26" height="11" rx="2.5" fill="#1c2330" stroke="#ffffff" stroke-opacity="0.10" stroke-width="1"/><text x="147" y="118.5" font-family="Consolas, 'Courier New', monospace" font-size="8.5" fill="#71717a">55</text><circle r="2.8" fill="#a78bfa" opacity="0"><animateMotion path="M 26,79 C 62,79 84,67.5 110,67.5" begin="3.3s" dur="3.6s" repeatCount="indefinite"/><animate attributeName="opacity" values="0;1;1;0;0" keyTimes="0;0.02;0.3;0.33999999999999997;1" begin="3.3s" dur="3.6s" repeatCount="indefinite"/></circle>
<text x="12" y="148" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="9.5" fill="#71717a">live 13-factor scoring</text>
<text x="12" y="148" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="9.5" fill="#71717a">live 15-factor scoring</text>
</g><g transform="translate(796,456)">
<rect width="173" height="158" rx="10" fill="#161b22" stroke="#ffffff" stroke-opacity="0.07" stroke-width="1"/>
<text x="12" y="21" font-family="Consolas, 'Courier New', monospace" font-size="11" fill="#a78bfa">fusion</text>

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -1,6 +1,6 @@
# Free Tiers Guide: Understand and Combine Free AI Access
> **TL;DR**: OmniRoute registers 329 providers, with **155 catalog entries marked free/no-auth**. The stricter audited budget currently covers **43 recurring pools / 522 model budget entries**. Connect several suitable providers for broader fallback capacity; every quota, approval rule, privacy policy, and paid-overage condition still applies.
> **TL;DR**: OmniRoute registers 350 provider IDs, with **154 provider-catalog entries marked `hasFree`**. The stricter audited free-model catalog covers **40 recurring pool keys / 455 entries** (448 active + 7 discontinued). Connect several suitable providers for broader fallback capacity; every quota, approval rule, privacy policy, and paid-overage condition still applies.
---
@@ -21,38 +21,38 @@ OmniRoute **aggregates** these free tiers into one endpoint. Instead of signing
These providers have a recurring, keyless, or uncapped free-access path in the audited catalog. “Uncapped” means no published token cap; rate, concurrency, account, regional, and policy limits can still apply:
| Provider | Models | Quota | How to Connect |
|----------|--------|-------|----------------|
| **Kiro AI** | Claude Sonnet 4.5, Haiku 4.5, DeepSeek V3.2, and others | Audited catalog estimates a 25K-token shared monthly pool | OAuth/account flow; ToS flagged `avoid` in the catalog |
| **OpenCode Free** | Current `*-free` model set in the provider registry | Keyless; no published token cap | No provider credential; ToS flagged `avoid` |
| **Pollinations** | Current keyless model set; some former models are discontinued or key-required | Keyless; no published token cap | No provider credential for the keyless models |
| **Logfare** | kimi-k3, deepseek-v4-pro, glm-5.2, gpt-5.6-luna, minimax-m3, and more | Free API key (no rate limits, no card); **every request is logged** for research (opt out at logfare.ai/consent) | Instant key at logfare.ai/register; ToS/privacy at logfare.ai/tos and logfare.ai/privacy |
| **Cloudflare AI** | Workers AI catalog | Audited pool estimates ~30M tokens/month from published usage units | Cloudflare account and API credentials |
| **Gemini** | Gemini Flash family | Audited pool estimates ~60M tokens/month | Google AI Studio API key; rate limits apply |
| **Groq** | Llama, GPT-OSS, and Qwen models | Audited pool estimates ~15M tokens/month | Groq API key; rate limits apply |
| **Cerebras** | GLM 4.7 and GPT-OSS 120B | Audited pool estimates ~30M tokens/month | Cerebras API key; rate limits apply |
| Provider | Models | Quota | How to Connect |
| ----------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **Kiro AI** | Claude Sonnet 4.5, Haiku 4.5, DeepSeek V3.2, and others | Audited catalog estimates a 25K-token shared monthly pool | OAuth/account flow; ToS flagged `avoid` in the catalog |
| **OpenCode Free** | Current `*-free` model set in the provider registry | Keyless; no published token cap | No provider credential; ToS flagged `avoid` |
| **Pollinations** | Current keyless model set; some former models are discontinued or key-required | Keyless; no published token cap | No provider credential for the keyless models |
| **Logfare** | kimi-k3, deepseek-v4-pro, glm-5.2, gpt-5.6-luna, minimax-m3, and more | Free API key (no rate limits, no card); **every request is logged** for research (opt out at logfare.ai/consent) | Instant key at logfare.ai/register; ToS/privacy at logfare.ai/tos and logfare.ai/privacy |
| **Cloudflare AI** | Workers AI catalog | Audited pool estimates ~30M tokens/month from published usage units | Cloudflare account and API credentials |
| **Gemini** | Gemini Flash family | Audited pool estimates ~60M tokens/month | Google AI Studio API key; rate limits apply |
| **Groq** | Llama, GPT-OSS, and Qwen models | Audited pool estimates ~15M tokens/month | Groq API key; rate limits apply |
| **Cerebras** | GLM 4.7 and GPT-OSS 120B | Audited pool estimates ~30M tokens/month | Cerebras API key; rate limits apply |
### Signup Grants and Provider-Specific Credits
These providers give you **free credits** when you sign up:
| Provider | Free Credits | Models | How to Get |
|----------|-------------|--------|------------|
| **DeepSeek** | 5M free tokens | DeepSeek V4 | Sign up at platform.deepseek.com |
| **LongCat** | 10M-token one-time grant | LongCat 2.0 | API key + KYC; pay-as-you-go after the grant |
| **Together** | $25 signup credit represented as ~25M tokens in the budget model | Provider catalog | Sign up and verify current terms |
| Provider | Free Credits | Models | How to Get |
| ------------- | ------------------------------------------------------------------ | ------------------------- | --------------------------------------------------------- |
| **DeepSeek** | 5M free tokens | DeepSeek V4 | Sign up at platform.deepseek.com |
| **LongCat** | 10M-token one-time grant | LongCat 2.0 | API key + KYC; pay-as-you-go after the grant |
| **Together** | $25 signup credit represented as ~25M tokens in the budget model | Provider catalog | Sign up and verify current terms |
| **Vertex AI** | $300 signup credit represented as ~300M tokens in the budget model | Gemini and partner models | Google Cloud account; billing and eligibility rules apply |
### Other Limited Access
These providers have **free tiers** with specific limits:
| Provider | Free Limit | Models | Best For |
|----------|-----------|--------|----------|
| **GitHub Models** | Audited shared pool estimates ~18M tokens/month | Broad model evaluation |
| **Hugging Face** | Small recurring monthly pool | Experiments and model variety |
| **OpenRouter free models** | Shared request-limited pool; optional one-time top-up increases the recurring allowance | Broad fallback catalog |
| **AI Horde** | Keyless community capacity; availability varies | Opportunistic distributed inference |
| Provider | Free Limit | Models | Best For |
| -------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------- | -------- |
| **GitHub Models** | Audited shared pool estimates ~18M tokens/month | Broad model evaluation |
| **Hugging Face** | Small recurring monthly pool | Experiments and model variety |
| **OpenRouter free models** | Shared request-limited pool; optional one-time top-up increases the recurring allowance | Broad fallback catalog |
| **AI Horde** | Keyless community capacity; availability varies | Opportunistic distributed inference |
---
@@ -70,6 +70,7 @@ Connect several providers to reduce dependence on any single quota:
4. **LongCat** — one-time signup grant (requires KYC)
Then use `model: "auto"` and OmniRoute will:
- Try the highest-ranked eligible connection first
- If its quota or health check fails → try the next configured provider
- If the keyless provider is unavailable → continue through the remaining targets
@@ -135,6 +136,7 @@ If one free provider is busy or down, OmniRoute automatically tries the next one
### 2. Smart Routing
OmniRoute picks the **best free provider** for each request based on:
- Speed — Which provider is fastest right now?
- Quality — Which provider is best for this task?
- Capacity — Which provider has quota remaining?
@@ -157,13 +159,13 @@ provider's quota or access policy.
The live, pool-deduplicated catalog currently reports:
| Metric | Current audited value | Interpretation |
| --- | ---: | --- |
| Recurring quantified grant | **~1.53B tokens/month** | Shared pools counted once; excludes uncapped providers from the sum |
| First month with signup grants | **~2.15B tokens** | Recurring total plus one-time and recurring credits |
| Quantified inventory | **43 pools / 522 model budget entries** | Budget-model coverage, not the full 329-provider catalog |
| Recurring/keyless/uncapped providers represented | **58** | Provider presence in recurring forms of the audited budget catalog |
| Free/no-auth discovery entries | **155** | Broader provider metadata; not all have a quantifiable recurring quota |
| Metric | Current audited value | Interpretation |
| ---------------------------------------------------- | -----------------------------------------------: | ----------------------------------------------------------------------------------------- |
| Recurring quantified grant | **~1.51B tokens/month** | Shared pools counted once; excludes uncapped providers from the sum |
| First month with signup grants | **~2.13B tokens** | Recurring total plus one-time and recurring credits |
| Audited free-model inventory | **40 recurring pool keys / 455 catalog entries** | 448 active + 7 discontinued; distinct from the 350-provider catalog |
| Recurring/keyless free-forever providers represented | **56** | Unique providers across recurring daily/monthly/credit/uncapped and keyless catalog types |
| Provider catalog entries marked `hasFree` | **154 / 350** | Broader provider metadata; not all have a quantifiable recurring quota |
These values are computed from `open-sse/config/freeModelCatalog.ts`; see the
[Free Tiers Reference](../reference/FREE_TIERS.md) for pool deduplication, ToS flags,

View File

@@ -219,13 +219,23 @@ docker build --target runner-cli -t omniroute:cli .
### Build-time resources
Two build args control what the `builder` stage costs. They are build-time only —
Three build args control what the `builder` stage costs. They are build-time only —
`OMNIROUTE_MEMORY_MB` (below) is a separate, runtime knob.
| Build arg | Default | Effect |
| --------------------------- | ------- | ---------------------------------------------------------------------- |
| `OMNIROUTE_USE_TURBOPACK` | `1` | `0` builds with webpack instead. Lower peak memory, slower. |
| `OMNIROUTE_BUILD_MEMORY_MB` | `4096` | V8 heap ceiling (`--max-old-space-size`) for the spawned `next build`. |
| Build arg | Default | Effect |
| --------------------------- | ------- | ----------------------------------------------------------------------------------- |
| `OMNIROUTE_USE_TURBOPACK` | `1` | `0` builds with webpack instead. Lower peak memory, slower. |
| `OMNIROUTE_BUILD_MEMORY_MB` | `6144` | V8 heap ceiling (`--max-old-space-size`) for the spawned `next build`. |
| `OMNIROUTE_BUILD_WORKERS` | `3` | Feeds `CIRCLE_NODE_TOTAL`; Next derives `workers = N - 1` for page-data collection. |
`OMNIROUTE_BUILD_WORKERS` is the one to raise on a big builder and the one to
suspect when a constrained build dies **after** `✓ Compiled successfully`. Each
page-data worker is its own process and inherits `NODE_OPTIONS`, so the heap
ceiling is per process, not per build: the default of `3` (→ 2 workers) is sized
for the 16 GB / 4 vCPU GitHub-hosted runners the publish pipeline uses. At `8`
(→ 7 workers) that runner ran out of memory and buildkit failed the step with
`ResourceExhausted: ... cannot allocate memory`. `tests/unit/docker-build-memory-budget.test.ts`
does the arithmetic and fails if either knob outgrows the runner.
Turbopack compiles in native Rust memory that lives **outside** the V8 heap, so
`OMNIROUTE_BUILD_MEMORY_MB` does not bound it. On a host with a memory ceiling the
@@ -268,12 +278,12 @@ The 1GiB Docker default is a dashboard/light-chat floor, not a production siz
Size **cgroup `--memory` above the heap** — native buffers, SQLite, and compression intermediates sit outside V8.
| Workload | `OMNIROUTE_MEMORY_MB` | Container / cgroup | Notes |
| --- | --- | --- | --- |
| Dashboard, one light chat | `1024` (image default) | ≥2GiB | |
| One coding agent (Claude/Codex/Grok) | `8192` | ≥10GiB | Typical single-session `/v1/responses` |
| Two concurrent long `/v1/responses` | `10240``12288` | ≥1216GiB | Measured V8 abort at ~12GiB heap |
| Three+ concurrent long contexts | do not on one process | serialize / more RAM | Default heavyweight admission is 1 in-flight; raising it without RAM reintroduces the abort |
| Workload | `OMNIROUTE_MEMORY_MB` | Container / cgroup | Notes |
| ------------------------------------ | ---------------------- | -------------------- | ------------------------------------------------------------------------------------------- |
| Dashboard, one light chat | `1024` (image default) | ≥2GiB | |
| One coding agent (Claude/Codex/Grok) | `8192` | ≥10GiB | Typical single-session `/v1/responses` |
| Two concurrent long `/v1/responses` | `10240``12288` | ≥1216GiB | Measured V8 abort at ~12GiB heap |
| Three+ concurrent long contexts | do not on one process | serialize / more RAM | Default heavyweight admission is 1 in-flight; raising it without RAM reintroduces the abort |
`omniroute serve` on bare metal calibrates ~35% of RAM (clamped `[512, 4096]`) when `OMNIROUTE_MEMORY_MB` is **unset**. Docker always sets `1024`, so that calibration never runs in the official image.
@@ -287,19 +297,19 @@ docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md), the following variables matter most when running under Docker:
| Variable | Purpose | Default |
| ----------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------ |
| `OMNIROUTE_WS_BRIDGE_SECRET` | Shared secret for the WebSocket bridge. **Required in production** — set to a strong random string. | unset (must be provided) |
| `REDIS_URL` | Connection string for the rate limiter / cache backend | `redis://redis:6379` |
| `REDIS_PORT` | Host-side port for the bundled Redis container | `6379` |
| `REDIS_BIND_HOST` | Host interface the bundled Redis port is published on (loopback unless you add AUTH) | `127.0.0.1` |
| `AUTO_UPDATE_HOST_REPO_DIR` | Host path mounted into `cli` profile at `/workspace/omniroute` for self-update workflows | `.` (current directory) |
| Variable | Purpose | Default |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| `OMNIROUTE_WS_BRIDGE_SECRET` | Shared secret for the WebSocket bridge. **Required in production** — set to a strong random string. | unset (must be provided) |
| `REDIS_URL` | Connection string for the rate limiter / cache backend | `redis://redis:6379` |
| `REDIS_PORT` | Host-side port for the bundled Redis container | `6379` |
| `REDIS_BIND_HOST` | Host interface the bundled Redis port is published on (loopback unless you add AUTH) | `127.0.0.1` |
| `AUTO_UPDATE_HOST_REPO_DIR` | Host path mounted into `cli` profile at `/workspace/omniroute` for self-update workflows | `.` (current directory) |
| `OMNIROUTE_MEMORY_MB` | Runtime Node heap ceiling for the Docker standalone server; overrides the image default above. Coding agents: `8192`+ (see [runtime RAM](#runtime-ram-for-coding-agents)). | `1024` |
| `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` |
| `OMNIROUTE_BASE_PATH` | URL subpath when the app is published behind a reverse proxy (e.g. `/omniroute`) | _(empty = root)_ |
| `NEXT_PUBLIC_BASE_URL` | Public browser origin including the subpath (e.g. `https://host/omniroute`) | unset |
| `PROD_DASHBOARD_PORT` | Host-side dashboard port for `docker-compose.prod.yml` | `20130` |
| `CLIPROXYAPI_PORT` | Host-side port for the `cliproxyapi` sidecar | `8317` |
| `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` |
| `OMNIROUTE_BASE_PATH` | URL subpath when the app is published behind a reverse proxy (e.g. `/omniroute`) | _(empty = root)_ |
| `NEXT_PUBLIC_BASE_URL` | Public browser origin including the subpath (e.g. `https://host/omniroute`) | unset |
| `PROD_DASHBOARD_PORT` | Host-side dashboard port for `docker-compose.prod.yml` | `20130` |
| `CLIPROXYAPI_PORT` | Host-side port for the `cliproxyapi` sidecar | `8317` |
## Reverse Proxy on a Subpath (Traefik / nginx)
@@ -361,11 +371,11 @@ intervals.
For orchestrators (Kubernetes, Nomad, etc.):
| Probe | Prefer | Avoid |
| --- | --- | --- |
| Liveness | HTTP `GET /livez`, or TCP on the main port (`PORT`, default `20128`) | `/api/monitoring/health` as liveness |
| Readiness | HTTP `GET /healthz` | Tight timeouts that treat event-loop busy as dead |
| Deep / blackbox | `/api/monitoring/health` | — |
| Probe | Prefer | Avoid |
| --------------- | -------------------------------------------------------------------- | ------------------------------------------------- |
| Liveness | HTTP `GET /livez`, or TCP on the main port (`PORT`, default `20128`) | `/api/monitoring/health` as liveness |
| Readiness | HTTP `GET /healthz` | Tight timeouts that treat event-loop busy as dead |
| Deep / blackbox | `/api/monitoring/health` | — |
`/healthz` reports process lifecycle (`ok` / `starting` / `stopping`). `/livez` is
process-alive only (200 whenever the handler can run; it does not wait for
@@ -431,10 +441,10 @@ Endpoint tunnel panels (Cloudflare, Tailscale, ngrok) can be shown or hidden fro
## Image Tags
| Image | Tag | Size | Description |
| ------------------------ | -------- | ------ | --------------------- |
| Image | Tag | Size | Description |
| ------------------------ | -------- | ------ | ---------------------------------------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Highest **published** stable SemVer (not git `main`) |
| `diegosouzapw/omniroute` | `3.8.0` | ~250MB | Pin this class of tag for GitOps |
| `diegosouzapw/omniroute` | `3.8.0` | ~250MB | Pin this class of tag for GitOps |
Multi-platform manifest: `linux/amd64` + `linux/arm64` native (Apple Silicon, AWS Graviton, Raspberry Pi). Docker selects the matching architecture automatically; pass `--platform linux/amd64` if you need to force AMD64 emulation on ARM hosts.
@@ -442,12 +452,12 @@ Multi-platform manifest: `linux/amd64` + `linux/arm64` native (Apple Silicon, AW
OmniRoute publishes separate Docker channels for stable releases, active release-branch testing, and development builds.
| Channel | Source | Mutability | Recommended use |
| ------------------------------- | ----------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------- |
| `:<version>` / `:<version>-web` | Signed/versioned release | Immutable | Production deployments that pin an exact release |
| Channel | Source | Mutability | Recommended use |
| ------------------------------- | ----------------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `:<version>` / `:<version>-web` | Signed/versioned release | Immutable | Production deployments that pin an exact release |
| `:latest` / `:latest-web` | Highest **published** stable SemVer | Mutable stable pointer | Follows stable releases **after** a SemVer publish job — does **not** track `main` or unreleased `release/v*` commits |
| `:next` / `:next-web` | Current default `release/v*` branch | Mutable pre-release pointer | Testing fixes that have landed on the active release branch but are not yet in a stable release |
| `:main` / `:main-web` | `main` branch | Mutable development pointer | Development and integration testing only |
| `:next` / `:next-web` | Current default `release/v*` branch | Mutable pre-release pointer | Testing fixes that have landed on the active release branch but are not yet in a stable release |
| `:main` / `:main-web` | `main` branch | Mutable development pointer | Development and integration testing only |
#### Using the pre-release channel
@@ -491,30 +501,30 @@ A release-branch build can never move `latest`; only an eligible stable semantic
**`latest` is not a currency guarantee for git.** Merged fixes on `main` or on the active `release/v*` branch are **not** in `:latest` until a stable SemVer image is published and the publish job promotes `:latest` (same digest as that SemVer). If `latest` looks frozen while GitHub already shows the fix, pull `:next` to test the release branch or wait for the SemVer tag.
| You want | Use |
| --- | --- |
| GitOps / production that must not drift | Pin `:X.Y.Z` (or the image digest) |
| Follow published stables and accept a recreate on each release | `:latest` |
| Test unreleased `release/v*` commits | `:next` (not production) |
| Test `main` | `:main` (not production) |
| You want | Use |
| -------------------------------------------------------------- | ---------------------------------- |
| GitOps / production that must not drift | Pin `:X.Y.Z` (or the image digest) |
| Follow published stables and accept a recreate on each release | `:latest` |
| Test unreleased `release/v*` commits | `:next` (not production) |
| Test `main` | `:main` (not production) |
## Availability: default SQLite is single-replica
Stock Docker / Kubernetes OmniRoute is **one Node process + one SQLite writer**. High availability is **not supported** on that topology.
| Constraint | Consequence |
| --- | --- |
| Single writer | Do **not** run multiple replicas against the same SQLite file. That corrupts the DB. |
| Constraint | Consequence |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Single writer | Do **not** run multiple replicas against the same SQLite file. That corrupts the DB. |
| Recreate / restart / HEALTHCHECK kill | **Full outage** of in-flight SSE, dashboard sessions, and in-memory state. Every connected client drops. New requests during the empty-endpoint window get a reverse-proxy **`502 Bad Gateway: Unknown error`**, not OmniRoute JSON — clients cannot distinguish this from a provider failure (#11015). |
| Same event loop as `/healthz` | A busy catalog or compression tick can delay probes; a short timeout then restarts the **only** replica. |
| Same event loop as `/healthz` | A busy catalog or compression tick can delay probes; a short timeout then restarts the **only** replica. |
**Probe matrix** (see also [Kubernetes probe recommendations](../ops/MONITORING_GUIDE.md#kubernetes-probe-recommendations)):
| Probe | Target | Do not use |
| --- | --- | --- |
| Liveness | TCP on `PORT` (default `20128`), or soft HTTP `/healthz` | `/api/monitoring/health` |
| Readiness | HTTP `GET /healthz` | Tight timeouts that treat event-loop busy as dead |
| Deep / humans | `/api/monitoring/health` | Automated kubelet liveness |
| Probe | Target | Do not use |
| ------------- | -------------------------------------------------------- | ------------------------------------------------- |
| Liveness | TCP on `PORT` (default `20128`), or soft HTTP `/healthz` | `/api/monitoring/health` |
| Readiness | HTTP `GET /healthz` | Tight timeouts that treat event-loop busy as dead |
| Deep / humans | `/api/monitoring/health` | Automated kubelet liveness |
**Upgrades:** expect every session to drop. Drain clients if you can; there is no rolling update on default SQLite. Compose `restart: unless-stopped` plus Docker `HEALTHCHECK` will also replace the only process when the container is Unhealthy — same blast radius.
@@ -555,13 +565,13 @@ One Node process is **one V8 heap**. Two overlapping ~3MiB / ~750k-token codi
To go beyond two concurrent **large** jobs **today**:
| Do | Do not |
| --- | --- |
| Run **N containers/pods**, each with its **own** `DATA_DIR` / volume | Set `replicas > 1` against one SQLite file |
| Keep each instance at 12 heavy in-flight and 1216Gi cgroup | Give one process 8× RAM and `max=8` |
| Optional: `QUOTA_STORE_DRIVER=redis` + `QUOTA_STORE_REDIS_URL` for **shared quota counters** | Treat Redis as shared SQLite — it is not |
| Duplicate provider secrets into each instance (or accept partitioned dashboards) | Expect one dashboard / one call-log across instances |
| Front with any load balancer; sticky by API key or session is enough | Require a vendor-specific size-aware middleware |
| Do | Do not |
| -------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Run **N containers/pods**, each with its **own** `DATA_DIR` / volume | Set `replicas > 1` against one SQLite file |
| Keep each instance at 12 heavy in-flight and 1216Gi cgroup | Give one process 8× RAM and `max=8` |
| Optional: `QUOTA_STORE_DRIVER=redis` + `QUOTA_STORE_REDIS_URL` for **shared quota counters** | Treat Redis as shared SQLite — it is not |
| Duplicate provider secrets into each instance (or accept partitioned dashboards) | Expect one dashboard / one call-log across instances |
| Front with any load balancer; sticky by API key or session is enough | Require a vendor-specific size-aware middleware |
Hardware: `concurrent_large ≈ N × 2` at ~812Gi heap / ~1216Gi cgroup **per instance**. Host RAM must cover `N × cgroup`, not “one 16Gi pod with N=8.”

View File

@@ -5719,17 +5719,28 @@ paths:
x-loopback-only: true
tags: [System]
summary: Read a bounded Video Bridge drill-down slice
description: Internal loopback/token-authenticated lookup into a short-lived per-session frame cache. It never downloads media or starts a subprocess; start/end and frame count only select already materialized frames.
description: Internal loopback/token-authenticated lookup into a short-lived cache isolated by an opaque principal, session, and media reference. It never downloads media or starts a subprocess; start/end and frame count only select already materialized, canonicalized JPEG frames whose dimensions were derived from their bytes. This cache substrate is not yet wired to the transparent Video Bridge request path and does not yet expose multi-resolution selection.
security: []
parameters:
- in: header
name: x-omniroute-video-bridge-principal
required: true
description: Canonical visible-ASCII, opaque non-secret principal ID; production tenant derivation is required before enabling a caller
schema:
type: string
minLength: 1
maxLength: 256
pattern: "^[!-~]{1,256}$"
- in: query
name: sessionId
required: true
schema: { type: string, maxLength: 128 }
description: Canonical opaque ID without surrounding whitespace
schema: { type: string, minLength: 1, maxLength: 128 }
- in: query
name: videoRef
required: true
schema: { type: string, maxLength: 4096 }
description: Canonical opaque reference without surrounding whitespace
schema: { type: string, minLength: 1, maxLength: 4096 }
- in: query
name: start
required: false
@@ -5743,25 +5754,58 @@ paths:
required: false
schema: { type: integer, minimum: 1, maximum: 16 }
responses:
"200": { description: Bounded cached frame slice }
"403": { description: Trusted loopback/token identity required }
"200": { description: Bounded cached frame slice with derivation audit metadata }
"403": { description: Trusted loopback/token identity and principal required }
"404": { description: Drill-down session or media key was not found }
post:
x-loopback-only: true
tags: [System]
summary: Store a bounded Video Bridge drill-down result
description: Internal lifecycle operation for explicitly authorized callers. The short-lived session cache is isolated by session and media reference and does not alter the primary request cost.
description: Internal lifecycle operation for explicitly authorized callers. The short-lived cache is isolated by principal, session, and media reference; enforces independent per-principal and global retained-byte quotas; accepts canonical Base64 only after a warning-sensitive bounded full JPEG decode/re-encode; strips trailing polyglot bytes; retains and charges only the canonical JPEG output; derives resolution from decoded bytes; and does not alter the primary request cost. The JSON wire budget includes Base64 overhead for the 32 MiB decoded-input ceiling.
security: []
parameters:
- in: header
name: x-omniroute-video-bridge-principal
required: true
description: Canonical visible-ASCII, opaque non-secret principal ID; production tenant derivation is required before enabling a caller
schema:
type: string
minLength: 1
maxLength: 256
pattern: "^[!-~]{1,256}$"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [sessionId, videoRef, durationSeconds, frames]
additionalProperties: false
required: [sessionId, videoRef, derivation, durationSeconds, frames]
properties:
sessionId: { type: string, maxLength: 128 }
videoRef: { type: string, maxLength: 4096 }
sessionId:
type: string
minLength: 1
maxLength: 128
description: Canonical opaque ID without surrounding whitespace
videoRef:
type: string
minLength: 1
maxLength: 4096
description: Canonical opaque reference without surrounding whitespace
derivation:
type: object
additionalProperties: false
required: [parentContentHash, policy, version]
properties:
parentContentHash:
type: string
pattern: "^sha256:[a-f0-9]{64}$"
policy:
type: string
pattern: "^[A-Za-z0-9][A-Za-z0-9._/-]{0,63}$"
version:
type: string
pattern: "^[A-Za-z0-9][A-Za-z0-9._/-]{0,63}$"
durationSeconds: { type: number, exclusiveMinimum: 0, maximum: 600 }
frames:
type: array
@@ -5769,27 +5813,43 @@ paths:
maxItems: 16
items:
type: object
additionalProperties: false
required: [timestampSeconds, dataUri]
properties:
timestampSeconds: { type: number, minimum: 0 }
dataUri: { type: string, pattern: "^data:image/jpeg;base64," }
dataUri:
type: string
minLength: 27
maxLength: 5592431
description: Canonical Base64 data URI whose decoded bytes pass a warning-sensitive bounded full JPEG decode/re-encode; trailing bytes are discarded and width and height are derived server-side
responses:
"201": { description: Drill-down result stored }
"403": { description: Trusted loopback/token identity required }
"403": { description: Trusted loopback/token identity and principal required }
"413": { description: Payload exceeds the bounded session budget }
"499": { description: Caller cancelled before the derivation was committed }
delete:
x-loopback-only: true
tags: [System]
summary: Delete a Video Bridge drill-down session
security: []
parameters:
- in: header
name: x-omniroute-video-bridge-principal
required: true
description: Canonical visible-ASCII, opaque non-secret principal ID; production tenant derivation is required before enabling a caller
schema:
type: string
minLength: 1
maxLength: 256
pattern: "^[!-~]{1,256}$"
- in: query
name: sessionId
required: true
schema: { type: string, maxLength: 128 }
description: Canonical opaque ID without surrounding whitespace
schema: { type: string, minLength: 1, maxLength: 128 }
responses:
"200": { description: Session entries removed }
"403": { description: Trusted loopback/token identity required }
"403": { description: Trusted loopback/token identity and principal required }
/api/cache/stats:
get:

View File

@@ -1281,7 +1281,6 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `OMNIROUTE_SKIP_DNS_WRITE` | _(unset)_ | `src/mitm/dns/dnsConfig.ts` | Set `1` to skip writing to the hosts file when adding/removing DNS entries — for sandboxed or read-only test environments. |
| `OMNIROUTE_SKIP_SYSTEM_TRUST` | `0` | `src/mitm/cert/install.ts`, `src/mitm/tproxy/caTrust.ts` | Test/CI-only guard: set `1` to make cert trust install/uninstall a no-op so the suite never mutates the OS trust store. Set automatically by the test setup and CI workflows. |
| `CHANGELOG_BASE_REF` | _(auto)_ | `scripts/check/check-changelog-integrity.mjs` | Explicit base ref for the anti CHANGELOG-eat gate (defaults to the PR base branch in CI, or the highest `release/v*`). |
| `ALLOW_CHANGELOG_REMOVALS` | `0` | `scripts/check/check-changelog-integrity.mjs` | Set `1` to turn intentional CHANGELOG bullet removals into a report instead of a failure (justify in the PR body). |
| `ONEPROXY_ENABLED` | `true` | `src/lib/oneproxySync.ts` | Enable the 1Proxy egress pool sync. |
| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | `src/lib/oneproxySync.ts` | 1Proxy service API URL override. |
| `ONEPROXY_MAX_PROXIES` | `500` | `src/lib/oneproxySync.ts` | Maximum proxies imported per sync. |

View File

@@ -183,30 +183,31 @@ See [#7992](https://github.com/diegosouzapw/OmniRoute/issues/7992) and [#7111](h
## How It Works (Persisted Auto-Combos)
The Auto-Combo Engine dynamically selects the best provider/model for each request using a **14-factor scoring function** (defined in `open-sse/services/autoCombo/scoring.ts``DEFAULT_WEIGHTS`). Weights form a normalized distribution (custom weights are renormalized by `normalizeScoringWeights()`).
The Auto-Combo Engine dynamically selects the best provider/model for each request using a **15-factor scoring function** (defined in `open-sse/services/autoCombo/scoring.ts``DEFAULT_WEIGHTS`). The default weights sum to `1.0`; custom weights are renormalized by `normalizeScoringWeights()`.
![Auto-Combo 14-factor scoring](../diagrams/exported/auto-combo-12factor.svg)
![Auto-Combo 15-factor scoring](../diagrams/exported/auto-combo-12factor.svg)
> Source: [diagrams/auto-combo-12factor.mmd](../diagrams/auto-combo-12factor.mmd) (regenerate via `npm run docs:render-diagrams`). The filename predates the current factor set; the diagram shows 13 of the 14 factors (missing `sessionAvailability`).
> Source: [diagrams/auto-combo-12factor.mmd](../diagrams/auto-combo-12factor.mmd) (regenerate via `npm run docs:render-diagrams`). The filename is historical; the source and rendered diagram show all 15 factors declared in `DEFAULT_WEIGHTS`.
| Factor | Default Weight | Description |
| :-------------------- | :------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `health` | 0.20 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) |
| `quota` | 0.15 | Remaining quota / rate-limit headroom [0..1] |
| `costInv` | 0.15 | Inverse **blended** cost (60% input + 40% output token price, normalized) — cheaper = higher score |
| `latencyInv` | 0.12 | Inverse p95 latency normalized to pool — faster = higher score |
| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
| `stability` | 0.05 | Variance-based stability (low latency stdDev / error rate) |
| `tierPriority` | 0.05 | Account-tier priority — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 |
| `tierAffinity` | 0.05 | Affinity between the candidate's tier and the manifest-recommended tier |
| `specificityMatch` | 0.05 | Match between request specificity (manifest hint) and model tier |
| `contextAffinity` | 0.05 | Affinity between the request's context-window need and the model's context window |
| `sessionAvailability` | 0.05 | OAuth session availability of the candidate connection for this session (`getOAuthSessionAvailability()`; non-OAuth connections score 1.0) |
| `connectionDensity` | 0.05 | Spreads load across connections of the same provider (anti-concentration) |
| `quota` | 0.1429 | Remaining quota / rate-limit headroom [0..1] |
| `health` | 0.1605 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) |
| `costInv` | 0.1429 | Inverse **blended** cost (60% input + 40% output token price, normalized) — cheaper = higher score |
| `latencyInv` | 0.1143 | Inverse p95 latency normalized to pool — faster = higher score |
| `taskFit` | 0.0762 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
| `stability` | 0.0476 | Variance-based stability (low latency stdDev / error rate) |
| `tierPriority` | 0.0476 | Account-tier priority — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 |
| `tierAffinity` | 0.0476 | Affinity between the candidate's tier and the manifest-recommended tier |
| `specificityMatch` | 0.0476 | Match between request specificity (manifest hint) and model tier |
| `contextAffinity` | 0.0476 | Affinity between the request's context-window need and the model's context window |
| `sessionAvailability` | 0.0476 | OAuth session availability of the candidate connection for this session (`getOAuthSessionAvailability()`; non-OAuth connections score 1.0) |
| `connectionDensity` | 0.0476 | Spreads load across connections of the same provider (anti-concentration) |
| `cacheAffinity` | 0.00 | Rendezvous-hash affinity toward the connection likeliest to already hold this request's prompt-cache prefix (`open-sse/services/combo/promptCacheAffinity.ts`); disabled by default (#8008) |
| `resetWindowAffinity` | 0.00 | Bias toward connections whose quota reset window is favorable (disabled by default) |
| `quality` | 0.03 | Feedback-driven output-quality signal from the routing-event quality tracker; candidates without observations receive a neutral 0.5 |
**Sum:** `0.20 + 0.15 + 0.15 + 0.12 + 0.08 + 0.05 + 0.05 + 0.05 + 0.05 + 0.05 + 0.05 + 0.05 + 0.00 + 0.00 = 1.05` as literally declared in `DEFAULT_WEIGHTS`; user-configured weights are renormalized into a distribution by `normalizeScoringWeights()` before scoring.
**Sum:** `0.1429 + 0.1605 + 0.1429 + 0.1143 + 0.0762 + (7 × 0.0476) + 0.00 + 0.00 + 0.03 = 1.0` as declared in `DEFAULT_WEIGHTS`; user-configured weights are renormalized into a distribution by `normalizeScoringWeights()` before scoring.
## Mode Packs
@@ -677,8 +678,8 @@ Including the bare `auto` (default) plus the 6 `AutoVariant` values declared in
## How tiers fit Auto-Combo
The 14-factor scoring function (`open-sse/services/autoCombo/scoring.ts`) treats tier
membership as two signals: `tierPriority` (0.05) and `tierAffinity` (0.05). See the
The 15-factor scoring function (`open-sse/services/autoCombo/scoring.ts`) treats tier
membership as two signals: `tierPriority` (0.0476) and `tierAffinity` (0.0476). See the
canonical [scoring factor table](#how-it-works-persisted-auto-combos) above for the full
`DEFAULT_WEIGHTS` set — the per-pack overrides (ship-fast/cost-saver/quality-first/
offline-friendly) are listed in the "Weight profiles per pack" table.

View File

@@ -1,77 +1,80 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 566" role="img" aria-label="OmniRoute free-tier dashboard preview: about 1.53 billion documented recurring tokens per month, about 2.15 billion in the first month, 43 provider pools and 522 model budget entries. The chart shows the 19 quantified recurring pools; one-time signup credits total about 626 million and include a 10 million LongCat grant that requires KYC. Uncapped providers remain subject to rate, concurrency, account, regional, and policy limits." font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 566" role="img" aria-label="OmniRoute free-tier dashboard preview: about 1.51 billion documented recurring tokens per month and about 2.13 billion in the first month. The audited catalog has 40 recurring pool keys and 455 entries, 448 active and 7 discontinued; the chart represents the 20 pools with a published positive monthly token budget. One-time signup credits total about 626 million and include a 10 million LongCat grant that requires KYC. Uncapped providers remain subject to rate, concurrency, account, regional, and policy limits." font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
<desc>Static dashboard preview of recurring token pools, first-month signup grants, and uncapped but rate-limited free-access providers.</desc>
<rect width="900" height="566" rx="16" fill="#0d1117"/>
<rect x="16" y="16" width="868" height="550" rx="13" fill="#161b22" stroke="#30363d"/>
<text x="868" y="558" fill="#484f58" font-size="10.5" text-anchor="end">OmniRoute · /dashboard/free-tiers · preview mockup</text>
<text x="32" y="50" fill="#e6edf3" font-size="18" font-weight="700">Monthly free-token budget</text>
<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">43 provider pools · 522 model entries · one endpoint</text>
<text x="868" y="50" fill="#7d8590" font-size="13" text-anchor="end">40 recurring pools · 455 catalog entries · one endpoint</text>
<text x="32" y="84" fill="#7d8590" font-size="11.5">Steady / month</text>
<text x="32" y="114" fill="#e6edf3" font-size="27" font-weight="800">~1.53B</text>
<text x="32" y="114" fill="#e6edf3" font-size="27" font-weight="800">~1.51B</text>
<text x="330" y="84" fill="#7d8590" font-size="11.5">First month (+ signup credits)</text>
<text x="330" y="114" fill="#3fb950" font-size="27" font-weight="800">~2.15B</text>
<text x="330" y="114" fill="#3fb950" font-size="27" font-weight="800">~2.13B</text>
<text x="700" y="84" fill="#7d8590" font-size="11.5">ToS-flagged (you decide)</text>
<text x="700" y="114" fill="#d29922" font-size="27" font-weight="800">15 providers</text>
<clipPath id="bar"><rect x="32" y="132" width="836" height="16" rx="8"/></clipPath>
<g clip-path="url(#bar)"><rect x="32" y="132" width="836" height="16" fill="#21262d"/>
<rect x="32.0" y="132" width="512.7" height="16" fill="#6c5ce7"/>
<rect x="545.5" y="132" width="82.6" height="16" fill="#00b894"/>
<rect x="628.9" y="132" width="37.1" height="16" fill="#0984e3"/>
<rect x="666.8" y="132" width="21.9" height="16" fill="#e17055"/>
<rect x="689.5" y="132" width="21.9" height="16" fill="#fdcb6e"/>
<rect x="712.2" y="132" width="18.9" height="16" fill="#e84393"/>
<rect x="731.9" y="132" width="16.9" height="16" fill="#00cec9"/>
<rect x="749.6" y="132" width="15.9" height="16" fill="#d63031"/>
<rect x="766.3" y="132" width="14.3" height="16" fill="#a29bfe"/>
<rect x="781.4" y="132" width="10.3" height="16" fill="#55efc4"/>
<rect x="792.5" y="132" width="9.8" height="16" fill="#74b9ff"/>
<rect x="803.1" y="132" width="9.3" height="16" fill="#ffeaa7"/>
<rect x="813.2" y="132" width="8.7" height="16" fill="#fab1a0"/>
<rect x="822.7" y="132" width="7.3" height="16" fill="#81ecec"/>
<rect x="830.8" y="132" width="7.1" height="16" fill="#6c5ce7"/>
<rect x="838.7" y="132" width="7.0" height="16" fill="#00b894"/>
<rect x="846.5" y="132" width="7.0" height="16" fill="#0984e3"/>
<rect x="854.3" y="132" width="6.8" height="16" fill="#e17055"/>
<rect x="861.9" y="132" width="6.1" height="16" fill="#fdcb6e"/>
<rect x="32.0" y="132" width="510.4" height="16" fill="#6c5ce7"/>
<rect x="543.4" y="132" width="76.6" height="16" fill="#00b894"/>
<rect x="620.9" y="132" width="76.6" height="16" fill="#0984e3"/>
<rect x="698.5" y="132" width="30.6" height="16" fill="#e17055"/>
<rect x="730.1" y="132" width="15.3" height="16" fill="#fdcb6e"/>
<rect x="746.4" y="132" width="15.3" height="16" fill="#e84393"/>
<rect x="762.7" y="132" width="12.2" height="16" fill="#00cec9"/>
<rect x="776.0" y="132" width="10.2" height="16" fill="#d63031"/>
<rect x="787.2" y="132" width="7.7" height="16" fill="#a29bfe"/>
<rect x="795.8" y="132" width="5.7" height="16" fill="#55efc4"/>
<rect x="802.5" y="132" width="5.7" height="16" fill="#74b9ff"/>
<rect x="809.1" y="132" width="5.7" height="16" fill="#ffeaa7"/>
<rect x="815.8" y="132" width="5.7" height="16" fill="#fab1a0"/>
<rect x="822.4" y="132" width="5.7" height="16" fill="#81ecec"/>
<rect x="829.1" y="132" width="5.7" height="16" fill="#6c5ce7"/>
<rect x="835.7" y="132" width="5.7" height="16" fill="#00b894"/>
<rect x="842.4" y="132" width="5.7" height="16" fill="#0984e3"/>
<rect x="849.0" y="132" width="5.7" height="16" fill="#e17055"/>
<rect x="855.7" y="132" width="5.7" height="16" fill="#fdcb6e"/>
<rect x="862.3" y="132" width="5.7" height="16" fill="#e84393"/>
</g>
<text x="32" y="172" fill="#7d8590" font-size="12">Each segment = one of 19 quantified recurring pools · 43 total pools / 522 entries in the audited catalog.</text>
<text x="32" y="172" fill="#7d8590" font-size="12">Each segment = one of 20 quantified recurring pools · 40 pools / 455 entries in the audited catalog.</text>
<circle cx="37" cy="196" r="5" fill="#6c5ce7"/>
<text x="48" y="200" fill="#c9d1d9" font-size="12.5">Mistral Large 3 <tspan fill="#7d8590">1.00B</tspan></text>
<text x="48" y="200" fill="#c9d1d9" font-size="12.5">Mistral <tspan fill="#7d8590">1.00B</tspan></text>
<circle cx="250" cy="196" r="5" fill="#00b894"/>
<text x="261" y="200" fill="#c9d1d9" font-size="12.5">GPT-4o mini <tspan fill="#7d8590">150M</tspan></text>
<text x="261" y="200" fill="#c9d1d9" font-size="12.5">LLM7 <tspan fill="#7d8590">150M</tspan></text>
<circle cx="463" cy="196" r="5" fill="#0984e3"/>
<text x="474" y="200" fill="#c9d1d9" font-size="12.5">Gemini 2.5 Flash <tspan fill="#7d8590">60M</tspan></text>
<text x="474" y="200" fill="#c9d1d9" font-size="12.5">Nara <tspan fill="#7d8590">150M</tspan></text>
<circle cx="676" cy="196" r="5" fill="#e17055"/>
<text x="687" y="200" fill="#c9d1d9" font-size="12.5">GLM 4.7 <tspan fill="#7d8590">30M</tspan></text>
<text x="687" y="200" fill="#c9d1d9" font-size="12.5">Gemini <tspan fill="#7d8590">60M</tspan></text>
<circle cx="37" cy="226" r="5" fill="#fdcb6e"/>
<text x="48" y="230" fill="#c9d1d9" font-size="12.5">Llama 3.3 70B <tspan fill="#7d8590">30M</tspan></text>
<text x="48" y="230" fill="#c9d1d9" font-size="12.5">Cerebras <tspan fill="#7d8590">30M</tspan></text>
<circle cx="250" cy="226" r="5" fill="#e84393"/>
<text x="261" y="230" fill="#c9d1d9" font-size="12.5">Grok-3 <tspan fill="#7d8590">24M</tspan></text>
<text x="261" y="230" fill="#c9d1d9" font-size="12.5">Cloudflare AI <tspan fill="#7d8590">30M</tspan></text>
<circle cx="463" cy="226" r="5" fill="#00cec9"/>
<text x="474" y="230" fill="#c9d1d9" font-size="12.5">DeepSeek V4 Pro <tspan fill="#7d8590">20M</tspan></text>
<text x="474" y="230" fill="#c9d1d9" font-size="12.5">API Airforce <tspan fill="#7d8590">24M</tspan></text>
<circle cx="676" cy="226" r="5" fill="#d63031"/>
<text x="687" y="230" fill="#c9d1d9" font-size="12.5">GPT-4.1 <tspan fill="#7d8590">18M</tspan></text>
<text x="687" y="230" fill="#c9d1d9" font-size="12.5">Ollama Cloud <tspan fill="#7d8590">20M</tspan></text>
<circle cx="37" cy="256" r="5" fill="#a29bfe"/>
<text x="48" y="260" fill="#c9d1d9" font-size="12.5">Llama 4 Scout <tspan fill="#7d8590">15M</tspan></text>
<text x="48" y="260" fill="#c9d1d9" font-size="12.5">Groq <tspan fill="#7d8590">15M</tspan></text>
<circle cx="250" cy="256" r="5" fill="#55efc4"/>
<text x="261" y="260" fill="#c9d1d9" font-size="12.5">GPT-4o <tspan fill="#7d8590">7M</tspan></text>
<text x="261" y="260" fill="#c9d1d9" font-size="12.5">Bluesminds <tspan fill="#7d8590">7.2M</tspan></text>
<circle cx="463" cy="256" r="5" fill="#74b9ff"/>
<text x="474" y="260" fill="#c9d1d9" font-size="12.5">MiniMax-M2.7 <tspan fill="#7d8590">6M</tspan></text>
<text x="474" y="260" fill="#c9d1d9" font-size="12.5">SambaNova <tspan fill="#7d8590">6M</tspan></text>
<circle cx="676" cy="256" r="5" fill="#ffeaa7"/>
<text x="687" y="260" fill="#c9d1d9" font-size="12.5">Arcee Trinity Large Prev <tspan fill="#7d8590">5M</tspan></text>
<text x="687" y="260" fill="#c9d1d9" font-size="12.5">Arcee <tspan fill="#7d8590">4.8M</tspan></text>
<circle cx="37" cy="286" r="5" fill="#fab1a0"/>
<text x="48" y="290" fill="#c9d1d9" font-size="12.5">Auto Free <tspan fill="#7d8590">4M</tspan></text>
<text x="48" y="290" fill="#c9d1d9" font-size="12.5">Navy <tspan fill="#7d8590">4.5M</tspan></text>
<circle cx="250" cy="286" r="5" fill="#81ecec"/>
<text x="261" y="290" fill="#c9d1d9" font-size="12.5">Auto <tspan fill="#7d8590">1M</tspan></text>
<text x="261" y="290" fill="#c9d1d9" font-size="12.5">BazaarLink <tspan fill="#7d8590">3.6M</tspan></text>
<circle cx="463" cy="286" r="5" fill="#6c5ce7"/>
<text x="474" y="290" fill="#c9d1d9" font-size="12.5">Command A Reasoning <tspan fill="#7d8590">800K</tspan></text>
<text x="474" y="290" fill="#c9d1d9" font-size="12.5">OpenRouter <tspan fill="#7d8590">1.2M</tspan></text>
<circle cx="676" cy="286" r="5" fill="#00b894"/>
<text x="687" y="290" fill="#c9d1d9" font-size="12.5">ERNIE 4.5 VL 424B <tspan fill="#7d8590">500K</tspan></text>
<text x="687" y="290" fill="#c9d1d9" font-size="12.5">Cohere <tspan fill="#7d8590">800K</tspan></text>
<circle cx="37" cy="316" r="5" fill="#0984e3"/>
<text x="48" y="320" fill="#c9d1d9" font-size="12.5">morph-v3-large <tspan fill="#7d8590">400K</tspan></text>
<text x="48" y="320" fill="#c9d1d9" font-size="12.5">HuggingChat <tspan fill="#7d8590">500K</tspan></text>
<circle cx="250" cy="316" r="5" fill="#e17055"/>
<text x="261" y="320" fill="#c9d1d9" font-size="12.5">Llama 3.1 8B <tspan fill="#7d8590">200K</tspan></text>
<text x="261" y="320" fill="#c9d1d9" font-size="12.5">Morph <tspan fill="#7d8590">400K</tspan></text>
<circle cx="463" cy="316" r="5" fill="#fdcb6e"/>
<text x="474" y="320" fill="#c9d1d9" font-size="12.5">Claude Sonnet 4.5 <tspan fill="#7d8590">25K</tspan></text>
<text x="474" y="320" fill="#c9d1d9" font-size="12.5">Hugging Face <tspan fill="#7d8590">200K</tspan></text>
<circle cx="676" cy="316" r="5" fill="#e84393"/>
<text x="687" y="320" fill="#c9d1d9" font-size="12.5">Kiro <tspan fill="#7d8590">25K</tspan></text>
<line x1="32" y1="386" x2="868" y2="386" stroke="#30363d"/>
<text x="32" y="412" fill="#3fb950" font-size="13" font-weight="700">+ First month: one-time signup credits (~626M)</text>
<rect x="32" y="421" width="90" height="22" rx="11" fill="#13311f" stroke="#238636"/>
@@ -98,5 +101,5 @@
<text x="299" y="466" fill="#7ee787" font-size="11.5" text-anchor="middle">nscale 5M</text>
<rect x="32" y="492" width="836" height="34" rx="8" fill="#1c2230" stroke="#30363d"/>
<text x="46" y="506" fill="#7d8590" font-size="12">Pool-deduped, honest counting — no inflated rate-limit ceilings. Some terms suggest personal-use only; we flag them so you decide.</text>
<text x="46" y="520" fill="#7d8590" font-size="11.5">+ 13 recurring uncapped* providers (rate/concurrency-limited) · OpenRouter $10 → +24M/mo.</text>
<text x="46" y="520" fill="#7d8590" font-size="11.5">+ 14 recurring uncapped* providers (rate/concurrency-limited) · OpenRouter $10 → +24M/mo.</text>
</svg>

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -1,13 +1,13 @@
---
title: "Guardrails"
version: 3.8.50
lastUpdated: 2026-08-14
lastUpdated: 2026-08-24
---
# Guardrails
> **Source of truth:** `src/lib/guardrails/`
> **Last updated:** 2026-08-15 — v3.8.50 (Video Bridge broker confinement)
> **Last updated:** 2026-08-24 — v3.8.50 (Video Bridge visual dedup hardening + focused captions)
Guardrails enforce safety, policy, and content transformations at the boundary
between OmniRoute and upstream providers. Each guardrail can inspect (and
@@ -327,30 +327,106 @@ fixed FFmpeg pass over the already validated local stream, select bounded
`showinfo` scene timestamps, and fall back deterministically to the same
uniform midpoints on detector failure, timeout, malformed output, or an empty
candidate set. Segment-aware mode allocates midpoint samples proportionally to
the validated scene intervals. The hard 16-frame cap is
applied after selection in every policy. A caller may optionally provide a
the validated scene intervals; segment-aware evidence and fallback behavior are
detailed below. The hard 16-frame cap is
applied after selection in every policy. When a scene-aware request has only a
one-frame budget, it uses the uniform midpoint of the active full-video or focus
window and reports `policyEffective: uniform`: a single selected scene frame
cannot preserve both temporal ends. A caller may optionally provide a
finite focus window (`start`/`end` seconds); bounds are clamped to the media
duration, reversed or non-finite windows are rejected, and all sampling
policies are performed only inside the normalized interval. The resulting
window is included in sampling metadata and in the untrusted description
prefix so downstream models can distinguish a focused excerpt from the full
timeline.
Semantic caption focus is a separate, explicit setting. The default `full`
analysis mode preserves the existing frame prompt and never forwards request
text to the caption model. In `focused` mode, the bridge reads only the latest
non-empty user-authored `text`/`input_text` from the same Chat or Responses
container, normalizes it to NFC, collapses control characters and whitespace,
and limits it to 500 Unicode code points. An empty result falls back to the
exact `full` prompt. A usable hint is serialized as JSON in a dedicated
untrusted-user-context block and may only prioritize observable details; it
cannot override the separate warning against following instructions visible
or audible in the media. Textual focus never infers `start`/`end` or changes
the temporal sampler.
#### FU-07 structural segment evidence
`segment_aware` uses one bounded pre-analysis pass over the already validated
local video stream. The fixed filter chain first scales to at most 320 pixels
wide, detects scene changes and frozen intervals, then samples at 1 frame per
second for blur, average luma, and spatial/temporal information. The pass is
limited to 600 structural samples, one FFmpeg/filter thread, the same
`file`-only protocol and container allowlists, a 1 MiB process-output bound,
and at most 30 seconds inside the broker's shared abort/deadline. It never
accepts a command, filter, path, or URL from the request.
The structural values are deterministic sampling evidence, not semantic video
understanding. They do not infer subjects, actions, captions, speech, or user
intent. Scene and freeze boundaries form segments; freeze coverage, blur,
exposure, spatial detail, and temporal change only influence how the existing
116 frame budget is allocated. A fully frozen segment is capped at one frame,
while non-frozen segments compete for the remaining budget. When boundaries
outnumber frames, uniform timeline coverage is retained so rapid early cuts
cannot hide a long trailing segment. Scene boundaries within the 1-second
analysis resolution of a freeze boundary are coalesced.
Missing filters, malformed/empty evidence, a detector error, or the bounded
pre-analysis timeout fail open to the exact uniform midpoint policy. A caller
abort or broker deadline does not fail open: it terminates the in-flight
subprocess, prevents later frame extraction, and the private temporary tree is
removed in `finally`.
`scripts/perf/video-bridge-fu07-eval.ts` generates deterministic real FFmpeg
fixtures for post-dedup caption-call savings, dense-motion budget allocation,
blur/exposure/SI-TI evidence, rapid cuts with a long tail, and gradual-fade
false positives. It records pre-analysis wall time and, where `/usr/bin/time`
is available, child CPU and peak RSS. Its quality checks are structural oracles
only. Real caption-model quality remains `HOLD` because this harness has no
authorized endpoint or frozen judge. Monetary savings also remain `HOLD`
unless `--caption-cost-per-call-usd` supplies an explicit positive per-call
estimate; the script never fabricates either result.
Each frame is limited to 4 MiB, all raw frames together to 23 MiB, and the
serialized broker response to 32 MiB. A private temporary directory is removed
in `finally`. OmniRoute does not bundle FFmpeg and does not accept a custom
executable path. Before captioning, the bridge applies a conservative visual
deduplication pass: each JPEG is reduced to a 16×16 grayscale buffer and is
compared only with the last frame retained, using a fixed similarity threshold
of 0.04 — a deliberate constant chosen for predictability, not a runtime
setting. The first and final timeline frames
are always retained; comparator or decoder errors fail open and keep coverage.
The output metadata reports how many frames were dropped.
compared only with the last frame retained. For a requested caption budget
above one frame, extraction supplies a
bounded candidate pool of up to twice that budget and never more than 16 frames.
The requested cap is applied only after deduplication, with the first and final
selected candidates preserved during final thinning when the budget is at least
two. The versioned
`grayscale-16x16-mean-cells-v2` policy uses the larger of mean luma delta and
the ratio of thumbnail cells whose normalized delta is at least 0.05. The
duplicate threshold is the constant 0.04, chosen for predictability rather than
exposed as a runtime setting. This secondary
high-contrast signal preserves small motion and visible-text changes that a
mean-only comparison can hide. Comparator or decoder errors fail open and keep
coverage. Output metadata separates extracted candidates, successfully used
frames, and visual duplicates dropped.
An explicitly marked video part may request a timestamped contact sheet. The
bridge builds at most a 4-column, 16-frame JPEG grid and labels the resulting
observation with every source timestamp. If `sharp` cannot decode or compose
the grid, the bridge falls back to the individual JPEG frames; a client abort
still propagates through the sheet operation.
bridge builds at most a 4-column, 16-frame JPEG grid. Every 512-pixel cell burns
its source timestamp into a high-contrast bottom band, while the same timestamps
remain in textual metadata for downstream association and audit. The complete
JPEG remains capped at 32 MiB. If `sharp` cannot decode or compose the grid, the
bridge falls back to the individual JPEG frames; a client abort still propagates
through the sheet operation.
Promotion evidence is deliberately separate from the synthetic composition
microbenchmark. `scripts/perf/video-bridge-contact-sheet-eval.ts` defines a
schema-versioned A/B harness for real OpenAI-compatible vision models. It measures
provider-reported tokens, end-to-end wall latency (including sheet composition),
model-call count, and manifest-defined fact retention. Raw model responses are not
written to the report; only SHA-256 digests and matched fact IDs are retained. The
harness makes no network or paid model call unless `--execute-real` is passed and
`--model`, `OMNIROUTE_BASE_URL`, and `OMNIROUTE_API_KEY` are configured. Without
that explicit real run, its machine-readable verdict remains `HOLD`; synthetic
payload/call-count measurements alone are not promotion evidence.
Callers may attach an optional `transcript.cues` array to a supported video
part when they already possess aligned text. Each cue must carry `text`, a
@@ -378,14 +454,39 @@ or download a second media copy; without that explicit track, it remains
video-only.
The internal `/api/modality-bridge/video/drilldown` lifecycle is a separate,
loopback/token-authenticated cache. It stores at most 16 JPEG frames per entry,
keeps entries isolated by session and video reference, expires them after ten
minutes, and supports bounded `start`/`end` reads or explicit session deletion.
Besides the per-entry limits, the cache enforces a global 256 MiB decoded-byte
budget: least-recently-used entries are evicted until new content fits, and an
entry larger than the whole budget is rejected outright.
It only slices materialized frames and cannot increase the cost of the primary
video request.
loopback/token-authenticated cache substrate. Every operation also requires a
canonical opaque principal ID. Before a production caller is enabled, it must
derive that ID from the authenticated tenant and must never forward a
client-selected value. Cache keys bind that principal to canonical session and
video-reference IDs, store only their SHA-256-derived keys, and scope both reads
and deletion to the same principal. The cache stores at most 16 derived JPEG
frames per entry, expires them after ten minutes, and supports bounded
`start`/`end` reads or explicit session deletion.
Each principal is limited to 16 entries and 64 MiB of canonical JPEG data. Those
limits are independent from the global 64-entry/256 MiB ceiling: principal quota
pressure evicts only that principal's least-recently-used entries before global
LRU eviction is considered. Expired entries are swept from both principal and
global accounting on cache activity, while cancellation and validation failure do
not commit a partial replacement.
The cache rejects non-canonical Base64, excess padding, non-JPEG media, malformed or
truncated JPEGs, and JPEGs that produce a warning during a bounded full-image `sharp`
decode. It re-encodes each accepted image as a canonical JPEG, derives width and height
from the decoded bytes instead of trusting caller fields, and discards any trailing
polyglot bytes rather than retaining them. Only the bounded canonical compressed buffer
is charged to both quotas. The JSON wire limit includes Base64 overhead for the 32 MiB
decoded-input ceiling. Every
stored derivation records its validated JPEG format/resolution, sampling policy,
derivation version, creation time, server-computed content hash, and hashed parent
reference plus the trusted caller's parent-content hash. Cancellation is checked
between asynchronous decode/hash phases before the atomic cache commit.
This tranche does not yet connect a production producer to the route and does not
provide multi-resolution variant selection. The transparent Video Bridge request
path therefore incurs no added work, while tenant-bound principal derivation and
the full FU-08 multi-resolution lifecycle remain explicit follow-up work rather
than documented as complete behavior.
Frames are captioned sequentially with the configured Video model. An empty
Video override inherits the Vision setting; if both are empty, the Vision
@@ -399,9 +500,16 @@ including a fallback model; the bridge reports `mixed` when different frames
were produced by different models. A cache hit reuses that producer identity
instead of relabeling it as the requested routing plan. The whole-video result
cache is keyed on every input that changes the output — prompt, effective
model, sampling policy, frame count, focus window, `transcript`,
model, sampling policy, frame count, semantic analysis mode, the SHA-256
fingerprint of the normalized focus hint, focus window, `transcript`,
`audioTranscript`, and the contact-sheet flag — so changing any of those
dimensions is a cache miss, never a stale reuse.
dimensions is a cache miss, never a stale reuse. The visual dedup policy
version, threshold, and bounded candidate-frame count are also explicit in the
result-cache key and metadata; a policy change therefore cannot reuse a stale
whole-video description. Result-cache v4 metadata keeps the mode and
fingerprint, never the raw user task. Guardrail metadata reports both the
requested and effective analysis modes; a requested `focused` mode without
usable user text is reported as effectively `full`.
The guardrail extracts every supported video part but describes no more than
`modalityBridgeVideoMaxVideos`. For a target proven to have
@@ -417,6 +525,7 @@ Runtime settings are DB-backed and Zod-validated:
| Key | Default | Range / behavior |
| ----------------------------------- | ----------- | --------------------------------------------------------------------------------------------------- |
| `modalityBridgeVideoEnabled` | `false` | Optional runtime, opt-in |
| `modalityBridgeVideoAnalysisMode` | `"full"` | `full` preserves generic captions; `focused` uses bounded, untrusted latest-user context |
| `modalityBridgeVideoModel` | `""` | Inherit the Vision Bridge model |
| `modalityBridgeVideoFrameCount` | `8` | 116 |
| `modalityBridgeVideoSamplingPolicy` | `"uniform"` | `uniform`, `scene_aware`, or proportional `segment_aware`; detector failure falls back to `uniform` |
@@ -659,7 +768,8 @@ Audio uses `modalityBridgeAudioEnabled`, `modalityBridgeAudioModel`,
`modalityBridgeCache*` settings. Audio has no legacy-key fallback because these
keys were introduced with the Modality Bridge schema.
Video uses `modalityBridgeVideoEnabled`, `modalityBridgeVideoModel`,
Video uses `modalityBridgeVideoEnabled`, `modalityBridgeVideoAnalysisMode`,
`modalityBridgeVideoModel`,
`modalityBridgeVideoFrameCount`, `modalityBridgeVideoSamplingPolicy`,
`modalityBridgeVideoMaxVideos`, and
`modalityBridgeVideoTimeout`, plus the shared `modalityBridgeCache*` settings.

View File

@@ -2,6 +2,7 @@ import createNextIntlPlugin from "next-intl/plugin";
import { createMDX } from "fumadocs-mdx/next";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { betterSqlite3AliasFor } from "./scripts/build/better-sqlite3-stub-flag.mjs";
import { mitmManagerAliasFor } from "./scripts/build/mitm-stub-flag.mjs";
import { normalizeBasePath } from "./scripts/build/normalizeBasePath.mjs";
import {
@@ -138,10 +139,14 @@ const nextConfig = {
// the stub to every npm/Electron/VPS artifact and broke Agent Bridge
// start for all non-Docker users (#6344). See scripts/build/mitm-stub-flag.mjs.
...mitmManagerAliasFor(process.env),
// Build-time stub so the bundler never traces the native better-sqlite3
// addon into a build worker (SIGABRT at worker teardown). Runtime still
// uses the real package via serverExternalPackages. (#10060)
"better-sqlite3": "./src/lib/db/better-sqlite3.stub.js",
// better-sqlite3 → build-time stub ONLY where the build worker actually
// aborts while tracing the native addon (SIGABRT at worker teardown,
// #10060); opt in with OMNIROUTE_BETTER_SQLITE3_STUB=1. The alias used to
// be unconditional on the premise that serverExternalPackages still won
// at runtime — it does not: resolveAlias rewrites the request before the
// externals check, so the stub was bundled and EVERY route answered 500
// (#11343). See scripts/build/better-sqlite3-stub-flag.mjs.
...betterSqlite3AliasFor(process.env),
...minimalBuildAliases,
},
// src/lib/agentSkills/generator.ts builds its fs base path from a runtime

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-18";
export const FREE_CATALOG_CURATED_AT = "2026-08-20";
export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "chatgpt-web", modelId: "gpt-5.6-luna-free", displayName: "GPT-5.6 Luna (Free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" },
@@ -318,6 +318,7 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "opencode-zen", modelId: "opencode/north-mini-code-free", displayName: "North Mini Code (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "opencode-zen-free", tos: "caution" },
{ 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: "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

@@ -70,6 +70,8 @@ import { togetherProvider } from "./registry/together/index.ts";
import { cohereProvider } from "./registry/cohere/index.ts";
import { cursorProvider, cursor_apiProvider } from "./registry/cursor/index.ts";
import { volcengineProvider } from "./registry/volcengine/index.ts";
import { volcengine_agent_planProvider } from "./registry/volcengine/agent-plan/index.ts";
import { volcengine_coding_planProvider } from "./registry/volcengine/coding-plan/index.ts";
import { freetheaiProvider } from "./registry/freetheai/index.ts";
import { g4f_groqProvider } from "./registry/g4f-groq/index.ts";
import { g4f_geminiProvider } from "./registry/g4f-gemini/index.ts";
@@ -337,6 +339,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
cursor: cursorProvider,
"cursor-api": cursor_apiProvider,
volcengine: volcengineProvider,
"volcengine-agent-plan": volcengine_agent_planProvider,
"volcengine-coding-plan": volcengine_coding_planProvider,
freetheai: freetheaiProvider,
"g4f-groq": g4f_groqProvider,
"g4f-gemini": g4f_geminiProvider,

View File

@@ -0,0 +1,115 @@
import type { RegistryEntry, RegistryModel } from "../../../shared.ts";
/**
* Volcano Ark Agent Plan models.
*
* The Agent Plan subscription (console.volcengine.com/ark/subscription/agent-plan)
* is served by the Plan API endpoint — `/api/plan/v3` — which differs from both the
* standard pay-per-use API (`/api/v3`) and the Coding Plan API (`/api/coding/v3`).
* The Plan API has NO `/models` listing endpoint (returns 404); key validation falls
* back to a chat probe against the first model. Model IDs below verified live against
* /api/plan/v3/chat/completions (all return 200).
*/
export const VOLCENGINE_AGENT_PLAN_MODELS: RegistryModel[] = [
{
id: "doubao-seed-evolving",
name: "Doubao Seed Evolving (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "doubao-seed-2-1-turbo-260628",
name: "Doubao Seed 2.1 Turbo (Agent Plan)",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "doubao-seed-2-0-lite-260215",
name: "Doubao Seed 2.0 Lite (Agent Plan)",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "doubao-seed-2-0-mini-260215",
name: "Doubao Seed 2.0 Mini (Agent Plan)",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "deepseek-v4-flash-ga-260731",
name: "DeepSeek V4 Flash GA (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "kimi-k3",
name: "Kimi K3 (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "glm-5-2-260617",
name: "GLM 5.2 (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "kimi-k2.7-code",
name: "Kimi K2.7 Code (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "minimax-m3",
name: "MiniMax M3 (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "deepseek-v4-pro-260425",
name: "DeepSeek V4 Pro (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "minimax-m2.7",
name: "MiniMax M2.7 (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "kimi-k2.6",
name: "Kimi K2.6 (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
];
export const volcengine_agent_planProvider: RegistryEntry = {
id: "volcengine-agent-plan",
alias: "veap",
format: "openai",
executor: "default",
baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: VOLCENGINE_AGENT_PLAN_MODELS,
};

View File

@@ -0,0 +1,92 @@
import type { RegistryEntry, RegistryModel } from "../../../shared.ts";
/**
* Volcano Ark Coding Plan models.
*
* The Coding Plan subscription (console.volcengine.com/ark/subscription/coding-plan)
* is served by a DEDICATED endpoint — `/api/coding/v3` — which differs from both the
* standard pay-per-use API (`/api/v3`) and the Agent Plan API (`/api/plan/v3`). Using
* the wrong base URL returns HTTP 401 "The API key or AK/SK ... is missing or invalid"
* even with a valid Coding Plan key. Model IDs below verified live against
* /api/coding/v3/chat/completions (all return 200).
*/
export const VOLCENGINE_CODING_PLAN_MODELS: RegistryModel[] = [
{
id: "doubao-seed-2-1-turbo",
name: "Doubao Seed 2.1 Turbo (Coding Plan)",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "doubao-seed-2.0-lite",
name: "Doubao Seed 2.0 Lite (Coding Plan)",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "glm-5.2",
name: "GLM 5.2 (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "kimi-k2.7-code",
name: "Kimi K2.7 Code (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "minimax-m3",
name: "MiniMax M3 (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "minimax-m2.7",
name: "MiniMax M2.7 (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "kimi-k2.6",
name: "Kimi K2.6 (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
];
export const volcengine_coding_planProvider: RegistryEntry = {
id: "volcengine-coding-plan",
alias: "vecp",
format: "openai",
executor: "default",
baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: VOLCENGINE_CODING_PLAN_MODELS,
modelsUrl: "/models",
};

View File

@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import {
BaseExecutor,
type ExecuteInput,
@@ -10,7 +11,7 @@ import {
injectReasoningContentForThinkingModel,
isThinkingMessageModel,
} from "../utils/reasoningContentInjector.ts";
import { runWithProxyContext } from "../utils/proxyFetch.ts";
import { runWithDirectFetchContext, runWithProxyContext } from "../utils/proxyFetch.ts";
import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts";
import {
type AccountProxyConfig,
@@ -245,6 +246,17 @@ export function createMuseSparkStreamFinishNormalizer(
};
}
function isResponsesTerminalLine(line: string): boolean {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return false;
try {
const payload = JSON.parse(trimmed.slice(5).trim()) as Record<string, unknown>;
return payload.type === "response.completed";
} catch {
return false;
}
}
export class OpencodeExecutor extends BaseExecutor {
/** Delegates to `isPremiumOpencodeModel`. Exported for testability. */
static isPremiumModel(model: string, provider: string): boolean {
@@ -384,24 +396,51 @@ export class OpencodeExecutor extends BaseExecutor {
const encoder = new TextEncoder();
let buffer = "";
const reader = response.body.getReader();
let closed = false;
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
async start(controller) {
try {
const { done, value } = await reader.read();
if (done) {
if (buffer.length > 0) controller.enqueue(encoder.encode(normalizer(buffer)));
controller.close();
return;
while (!closed) {
const { done, value } = await reader.read();
if (done) {
buffer += decoder.decode();
if (buffer.length > 0 && !closed) {
controller.enqueue(encoder.encode(normalizer(buffer)));
}
if (!closed) {
closed = true;
controller.close();
}
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const normalized = normalizer(line);
controller.enqueue(encoder.encode(normalized + "\n"));
if (isResponsesTerminalLine(line)) {
// OpenCode Zen sends a ping after response.completed and may keep
// the HTTP connection alive. The Responses terminal event is
// authoritative; do not let those post-completion pings hold Chat
// Completions open.
closed = true;
void reader.cancel().catch(() => undefined);
controller.close();
return;
}
}
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) controller.enqueue(encoder.encode(normalizer(line) + "\n"));
} catch (err) {
controller.error(err);
if (!closed) {
closed = true;
controller.error(err);
}
}
},
cancel(reason) {
closed = true;
reader.cancel(reason).catch(() => undefined);
},
});
@@ -450,7 +489,10 @@ export class OpencodeExecutor extends BaseExecutor {
// 200s ("Provider returned empty content"). Raise tiny budgets to the
// floor before dispatch (see MUSE_SPARK_MIN_OUTPUT_TOKENS).
if (input.body && typeof input.body === "object" && !Array.isArray(input.body)) {
applyMuseSparkMinOutputTokens(String(input.model ?? ""), input.body as Record<string, unknown>);
applyMuseSparkMinOutputTokens(
String(input.model ?? ""),
input.body as Record<string, unknown>
);
}
this.syncAccountsFromCredentials(input.credentials);
@@ -463,7 +505,9 @@ export class OpencodeExecutor extends BaseExecutor {
// else passes untouched: this path deliberately preserves BaseExecutor's
// intra-URL 429 retries (no skipUpstreamRetry here).
if (this.accounts.length === 1 && !hasProxies) {
const single = (await super.execute(input)) as HttpExecuteResult;
const single = (await runWithDirectFetchContext(() =>
super.execute(input)
)) as HttpExecuteResult;
if (single.response.status === 400) {
let bodyText: string | null = null;
try {
@@ -630,10 +674,7 @@ export class OpencodeExecutor extends BaseExecutor {
}
// All accounts returned 429 (or errored) — surface the last response.
return this.normalizeMuseSparkResponse(
input,
lastResult ?? (await super.execute(input))
);
return this.normalizeMuseSparkResponse(input, lastResult ?? (await super.execute(input)));
} finally {
this._requestFormat = null;
}
@@ -735,6 +776,18 @@ export class OpencodeExecutor extends BaseExecutor {
});
}
// Muse's Responses endpoint rejects the short conversation fingerprint used
// by the Chat endpoint in practice. Keep the workaround scoped to Muse.
if (
this._requestFormat === "openai-responses" &&
model.startsWith("muse-spark") &&
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
headers["x-opencode-session"] || ""
)
) {
headers["x-opencode-session"] = randomUUID();
}
void model;
return headers;

View File

@@ -1008,27 +1008,63 @@ async function captureViaCdp(opts: {
}
}
function killProcessTree(child: ChildProcess | null): void {
/**
* Terminate a spawned browser process and all of its descendants.
*
* Windows uses `taskkill /pid <pid> /T /F` to walk the process tree and terminate descendants.
* Linux/POSIX sends SIGTERM/SIGKILL to the process group (`-pid`) when detached/group leader,
* falling back to direct child kill if the process group is unavailable.
*/
export function killProcessTree(
child:
| ChildProcess
| { pid?: number; kill?: (signal?: NodeJS.Signals | number | string) => boolean | void }
| null
| undefined,
options?: {
platform?: string;
processKill?: (pid: number, signal?: NodeJS.Signals | string) => void;
spawnFn?: typeof spawn;
}
): void {
if (!child?.pid) return;
const pid = child.pid;
// Never taskkill our own Node/pkg process or its parent (would kill the backend mid-login).
if (pid === process.pid || (typeof process.ppid === "number" && pid === process.ppid)) {
return;
}
const platform = options?.platform || process.platform;
const processKill = options?.processKill || process.kill.bind(process);
const spawnFn = options?.spawnFn || spawn;
try {
if (process.platform === "win32") {
if (platform === "win32") {
// /T kills only this PID's descendants — not system Chrome profiles we did not spawn.
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
const killer = spawnFn("taskkill", ["/pid", String(pid), "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
detached: true,
});
killer.unref?.();
killer?.unref?.();
} else {
child.kill("SIGTERM");
let killedGroup = false;
try {
processKill(-pid, "SIGTERM");
killedGroup = true;
} catch {
try {
child.kill?.("SIGTERM");
} catch {
/* ignore */
}
}
setTimeout(() => {
try {
child.kill("SIGKILL");
if (killedGroup) {
processKill(-pid, "SIGKILL");
} else {
child.kill?.("SIGKILL");
}
} catch {
/* ignore */
}
@@ -1036,7 +1072,7 @@ function killProcessTree(child: ChildProcess | null): void {
}
} catch {
try {
child.kill();
child.kill?.();
} catch {
/* ignore */
}
@@ -1175,12 +1211,15 @@ async function runAdobeFireflyCdpBrowser(opts: {
// detach so a long Forter wait does not pin the Node process refcount.
// Host job SILENT_BREAKAWAY_OK still prevents Chrome from joining the backend job
// (that was killing/wedging VibeProxyServices on Sign in with browser).
// On POSIX: detached creates a new process group leader so killProcessTree(-pid)
// can terminate Chrome and all its child processes (zygote/renderer/GPU).
const isDetached = process.platform !== "win32" || !opts.interactive;
child = spawn(browserPath, args, {
stdio: "ignore",
// Interactive sign-in: show Chrome. Background warm: hide spawn console/window
// host; headless flags already suppress the browser UI.
windowsHide: !opts.interactive,
detached: !opts.interactive,
detached: isDetached,
});
if (!opts.interactive) {
try {

View File

@@ -52,8 +52,23 @@ export function preferAntigravityConnectionsWithStoredProject<T extends Record<s
const projectId = (psd as Record<string, unknown>).projectId;
return typeof projectId === "string" && projectId.trim().length > 0;
};
const withStoredProject = connections.filter(hasStoredProject);
return withStoredProject.length > 0 ? withStoredProject : connections;
// #11284: rows whose missing Cloud Code project was CONFIRMED at request
// time (errorCode="missing_project_id") are dead weight — drop them when a
// healthier sibling exists. When every row is confirmed missing, keep the
// pool so the typed 422 (not an empty-selection 404) explains what to fix.
const hasHealthySibling = (connection: T): boolean =>
connections.some(
(other) => other !== connection && other.errorCode !== "missing_project_id"
);
const candidates = connections.filter(
(connection) =>
connection.errorCode !== "missing_project_id" ||
!hasHealthySibling(connection) ||
!hasStoredProject(connection)
);
const withStoredProject = candidates.filter(hasStoredProject);
if (withStoredProject.length > 0) return withStoredProject;
return candidates.length > 0 ? candidates : connections;
}
export async function persistDiscoveredAntigravityProjectId(

View File

@@ -64,6 +64,11 @@ export function persistDiscoveredAntigravityProjectId(
errorCode: null,
lastError: null,
lastErrorType: null,
// #11284: a discovered project proves the account is usable again —
// re-enable it (markAntigravityMissingCloudCodeProject may have disabled
// it after a confirmed-missing 422).
isActive: true,
testStatus: "active",
providerSpecificData,
})
.catch(() => {})
@@ -77,7 +82,14 @@ export function markAntigravityMissingCloudCodeProject(
): void {
if (!connectionId) return;
// #11284: a CONFIRMED missing Cloud Code project is not transient — disable
// the row so selection rotates to healthy siblings instead of re-dispatching
// into the same 422 every request. "unavailable" is deliberately NOT a
// terminal status: persistDiscoveredAntigravityProjectId() re-enables the
// account the moment a project shows up at request time.
void updateProviderConnection(connectionId, {
isActive: false,
testStatus: "unavailable",
errorCode: "missing_project_id",
lastError:
"Missing Google projectId for Antigravity account. Reconnect OAuth after completing Gemini Code Assist onboarding.",

View File

@@ -1,3 +1,5 @@
import type { ModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilities";
import type { AutoVariant } from "./autoPrefix";
import { VALID_VARIANTS } from "./autoPrefix";
import type { PreparedVirtualAutoComboInputs } from "./virtualFactory";
@@ -119,8 +121,7 @@ export function isPaidTierAutoId(autoId: string): boolean {
* a candidate filter so the virtual combo only scores vision-capable models.
*/
export type BuiltinAutoSpec =
| { variant: AutoVariant | undefined }
| { category: AutoCategory; tier?: AutoTier };
{ variant: AutoVariant | undefined } | { category: AutoCategory; tier?: AutoTier };
/**
* Vision-flavored flat ids that MUST resolve to the `vision` category (candidate
@@ -159,9 +160,14 @@ export function resolveBuiltinAutoSpec(modelStr: string, suffix: string): Builti
return { variant: undefined };
}
export async function prepareBuiltinAutoComboInputs(): Promise<PreparedVirtualAutoComboInputs> {
export async function prepareBuiltinAutoComboInputs(
resolutionSnapshot?: ModelCapabilityResolutionSnapshot
): Promise<PreparedVirtualAutoComboInputs> {
const { prepareVirtualAutoComboInputs } = await import("./virtualFactory.ts");
return prepareVirtualAutoComboInputs({ includeResolvedCapabilities: true });
return prepareVirtualAutoComboInputs({
includeResolvedCapabilities: true,
resolutionSnapshot,
});
}
export async function createBuiltinAutoCombo(

View File

@@ -404,7 +404,9 @@ export function computeAdvertisedLimits(candidates: AdvertisedLimitCandidate[]):
return { contextLength, maxOutputTokens };
}
const PREPARED_CAPABILITY_YIELD_INTERVAL = 16;
// Catalog-scale pools can contain hundreds of models. Keep both candidate construction
// and capability preparation cooperative instead of monopolising one event-loop turn.
const VIRTUAL_AUTO_PREPARATION_YIELD_INTERVAL = 4;
type PreparedCapabilityValues = {
resolvedContextLength: number | null;
@@ -468,7 +470,7 @@ async function attachPreparedCapabilityValues(
};
byModel.set(candidate.model, values);
state.resolvedSinceYield++;
if (state.resolvedSinceYield >= PREPARED_CAPABILITY_YIELD_INTERVAL) {
if (state.resolvedSinceYield >= VIRTUAL_AUTO_PREPARATION_YIELD_INTERVAL) {
state.resolvedSinceYield = 0;
await yieldVirtualAutoPreparationTurn();
}
@@ -479,7 +481,10 @@ async function attachPreparedCapabilityValues(
}
export async function prepareVirtualAutoComboInputs(
options: { includeResolvedCapabilities?: boolean } = {}
options: {
includeResolvedCapabilities?: boolean;
resolutionSnapshot?: ModelCapabilityResolutionSnapshot;
} = {}
): Promise<PreparedVirtualAutoComboInputs> {
const [connections, disabledNoAuthConnections, settings] = await Promise.all([
getCachedProviderConnections({ isActive: true }) as Promise<VirtualFactoryConn[]>,
@@ -524,6 +529,7 @@ export async function prepareVirtualAutoComboInputs(
// Build one logical candidate per provider/model and keep account fallback as an
// allowlist on that candidate. This avoids both the old "first registry model per
// connection" blind spot and a connections × models Cartesian candidate pool.
let candidateModelsSinceYield = 0;
for (const [providerId, providerConnections] of connectionsByProvider) {
const providerInfo = registry[providerId];
const registryModelIds = Array.isArray(providerInfo?.models)
@@ -557,6 +563,11 @@ export async function prepareVirtualAutoComboInputs(
: Array.from(new Set([...registryModelIds, ...defaultModelIds]));
for (const modelId of modelIds) {
candidateModelsSinceYield++;
if (candidateModelsSinceYield >= VIRTUAL_AUTO_PREPARATION_YIELD_INTERVAL) {
candidateModelsSinceYield = 0;
await yieldVirtualAutoPreparationTurn();
}
if (hiddenModels?.has(modelId)) continue;
const allowedConnectionIds = providerConnections
@@ -655,7 +666,7 @@ export async function prepareVirtualAutoComboInputs(
const capabilityState: PreparedCapabilityState = {
byTarget: new Map(),
resolvedSinceYield: 0,
resolutionSnapshot: createModelCapabilityResolutionSnapshot(),
resolutionSnapshot: options.resolutionSnapshot ?? createModelCapabilityResolutionSnapshot(),
};
return {
regularCandidates: await attachPreparedCapabilityValues(regularCandidates, capabilityState),

View File

@@ -210,12 +210,16 @@ import {
normalizeConnectionStatus,
hasFutureRateLimitUntil,
getConnectionStatusQuotaCutoffReason,
getPersistedConnectionCooldownSkipReason,
resolvePersistedConnectionCooldownSkipReason,
isContextOverflow400,
isParamValidation400,
isModelScoped400,
} from "./combo/comboPredicates.ts";
export {
getConnectionStatusQuotaCutoffReason,
getPersistedConnectionCooldownSkipReason,
resolvePersistedConnectionCooldownSkipReason,
isContextOverflow400,
isParamValidation400,
isModelScoped400,
@@ -320,6 +324,26 @@ export {
* peekStickyConnectionId guards against clearing an unrelated pin when the
* failing target isn't actually the currently sticky-bound connection.
*/
/**
* Connection read for the pre-dispatch persisted-cooldown gate.
*
* `fresh: false` (first attempt) uses the shared 5s readCache — the row was just
* read by the surrounding target resolution, so a second uncached hit is pure cost.
* `fresh: true` (every retry) goes straight to SQLite: during a burst a sibling
* request routinely writes `rate_limited_until` while this attempt is sleeping out
* its retry delay, so the cached snapshot would still say "no cooldown" — which is
* exactly how a retry ended up dispatching into a real upstream 429 on a connection
* the engine had already marked unavailable.
*/
async function readConnectionForCooldownGate(
connectionId: string,
fresh: boolean
): Promise<Record<string, unknown> | null | undefined> {
if (!fresh) return getCachedProviderConnectionById(connectionId);
const { getProviderConnectionById } = await import("@/lib/db/providers");
return (await getProviderConnectionById(connectionId)) as Record<string, unknown> | null;
}
export function releaseStickyPinOnFailure(
messageHash: string | null | undefined,
failedConnectionId: string | null | undefined
@@ -1214,6 +1238,23 @@ async function handleComboChatInner({
}
: { ...target, modelAbortSignal: abortControllers.get(i)!.signal };
// Persist the connection cooldown before dispatch. AUTH only learns
// unavailable during credential lookup, so a burst would otherwise
// burn max_concurrent slots on real upstream calls against a row
// SQLite already locked until the reset.
if (target.connectionId && !allowRateLimitedConnection) {
const persistedSkip = await resolvePersistedConnectionCooldownSkipReason(
target,
(id) => readConnectionForCooldownGate(id, false),
allowRateLimitedConnection
);
if (persistedSkip) {
log.info("COMBO", persistedSkip);
if (i > 0) fallbackCount++;
return null;
}
}
// #1731 / #1731v2: skip targets already known-exhausted this request (shared predicate).
const exhaustedSkip = getExhaustedTargetSkipReason(
target,
@@ -1471,6 +1512,21 @@ async function handleComboChatInner({
log.info("COMBO", `Client disconnected during retry delay — aborting`);
return { ok: false, response: errorResponse(499, "Client disconnected") };
}
// Retry re-check: a sibling attempt (or attempt 1) may have persisted
// a quota cooldown while this attempt was sleeping out its retry delay
// ("Trying model 1/7: zai/glm-5.3 (retry 1)" after "already marked
// unavailable until …"). Reads fresh, not cached: see readConnectionForCooldownGate.
const persistedRetrySkip = await resolvePersistedConnectionCooldownSkipReason(
target,
(id) => readConnectionForCooldownGate(id, true),
allowRateLimitedConnection
);
if (persistedRetrySkip) {
log.info("COMBO", persistedRetrySkip);
if (i > 0) fallbackCount++;
return null;
}
}
log.info(

View File

@@ -482,6 +482,73 @@ export function getConnectionStatusQuotaCutoffReason(
return undefined;
}
/**
* Pre-dispatch skip for a combo target whose connection is already on a
* persisted cooldown. Combo previously only learned that from AUTH after a
* real upstream call, so a burst could burn max_concurrent slots against a
* connection that SQLite already marked unavailable until a future reset.
*
* Honours a future rateLimitedUntil regardless of testStatus, the terminal
* statuses that must never be dispatched, and a bare `unavailable` status even
* when no timestamp was written alongside it.
*/
export function getPersistedConnectionCooldownSkipReason(
target: { modelStr: string; connectionId?: string | null },
connection: Record<string, unknown> | null | undefined,
allowRateLimitedConnection = false
): string | null {
if (allowRateLimitedConnection) return null;
if (!target.connectionId || !connection) return null;
if (hasFutureRateLimitUntil(connection.rateLimitedUntil)) {
return `Skipping ${target.modelStr} — connection ${target.connectionId} has persisted cooldown until ${String(connection.rateLimitedUntil)}`;
}
const status = normalizeConnectionStatus(connection.testStatus);
if (QUOTA_BLOCKING_CONNECTION_STATUSES.has(status)) {
return `Skipping ${target.modelStr} — connection ${target.connectionId} status=${status}`;
}
// `unavailable` with no (or an already-expired) rateLimitedUntil still means AUTH
// took this connection out of rotation — markAccountUnavailable() writes the status
// before, and sometimes without, a timestamp ("Using zai account …" then a real
// upstream 429). Without this branch the pre-skip only fired once the timestamp had
// landed, so a burst still dispatched against a connection AUTH had already retired.
// Lazy recovery is unaffected: clearAccountError() resets the status on first success.
if (status === "unavailable") {
return `Skipping ${target.modelStr} — connection ${target.connectionId} status=unavailable`;
}
return null;
}
/**
* Async wrapper around `getPersistedConnectionCooldownSkipReason` for the combo
* dispatchers, which must re-check the persisted cooldown before EVERY upstream
* attempt — not just once before the retry loop.
*
* The retry path is exactly where the stale-read risk lives: a sibling request in
* the same burst can write `rate_limited_until` while this attempt is sleeping out
* its retry delay, so the caller passes a cache-bypassing fetcher for retry > 0
* (the readCache TTL is 5s, long enough to serve a "no cooldown" snapshot written
* before the 429 landed).
*
* Kept dependency-free — the fetcher is injected, so this module stays pure and
* unit-testable without a DB.
*/
export async function resolvePersistedConnectionCooldownSkipReason(
target: { modelStr: string; connectionId?: string | null },
fetchConnection: (id: string) => Promise<Record<string, unknown> | null | undefined>,
allowRateLimitedConnection = false
): Promise<string | null> {
if (allowRateLimitedConnection) return null;
if (!target.connectionId) return null;
let connection: Record<string, unknown> | null | undefined;
try {
connection = await fetchConnection(target.connectionId);
} catch {
// A DB read failure must never block dispatch — fall through to the upstream call.
return null;
}
return getPersistedConnectionCooldownSkipReason(target, connection, allowRateLimitedConnection);
}
/** @param {string} errorText */
export function isContextOverflow400(errorText: string | null | undefined): boolean {
const text = String(errorText || "");

View File

@@ -290,6 +290,7 @@ export function shouldProtectOriginalFirst(
return (
stickyStuck ||
autoUsedExplicitRouter ||
strategy === "auto" ||
strategy === "quota-share" ||
strategy === "weighted" ||
strategy === "priority" ||

View File

@@ -64,6 +64,7 @@ export function compressAggressive(
let summarizerSavings = 0;
let toolResultSavings = 0;
let agingSavings = 0;
const lastUserIdx = currentMessages.findLastIndex((m) => m.role === "user");
// Step 1: Tool-result compression
try {
@@ -110,7 +111,8 @@ export function compressAggressive(
currentMessages,
cfg.thresholds,
summarizer,
cfg.preserveSystemPrompt !== false
cfg.preserveSystemPrompt !== false,
lastUserIdx
);
agingSavings = agingResult.saved;
currentMessages = agingResult.messages as ChatMessage[];
@@ -121,8 +123,9 @@ export function compressAggressive(
// Step 3: Fallback summarizer for remaining long messages
if (cfg.summarizerEnabled) {
try {
currentMessages = currentMessages.map((msg) => {
currentMessages = currentMessages.map((msg, idx) => {
if (cfg.preserveSystemPrompt !== false && msg.role === "system") return msg;
if (idx === lastUserIdx) return msg;
const text = extractTextContent(msg.content);
if (!text || COMPRESSED_MARKER_RE.test(text)) return msg;
if (text.length <= cfg.maxTokensPerMessage * 4) return msg;
@@ -133,7 +136,10 @@ export function compressAggressive(
});
if (summary && summary.length < text.length) {
summarizerSavings += estimateTokens(text) - estimateTokens(summary);
return setContent(msg, `[COMPRESSED:summary] ${summary}`);
const finalSummary = COMPRESSED_MARKER_RE.test(summary)
? summary
: `[COMPRESSED:summary] ${summary}`;
return setContent(msg, finalSummary);
}
return msg;
});
@@ -153,13 +159,27 @@ export function compressAggressive(
if (resultStats.savingsPercent < cfg.minSavingsThreshold * 100) {
try {
const cavemanResult = cavemanCompress({ messages: currentMessages as unknown as Parameters<typeof cavemanCompress>[0]["messages"] });
if (cavemanResult?.compressed && cavemanResult.stats) {
const cavemanSavings = cavemanResult.stats.savingsPercent ?? 0;
if (cavemanSavings > resultStats.savingsPercent) {
currentMessages = (cavemanResult.body?.messages ?? currentMessages) as ChatMessage[];
resultStats.compressedTokens = cavemanResult.stats.compressedTokens ?? compressedTokens;
resultStats.savingsPercent = cavemanSavings;
const cavemanResult = cavemanCompress(
{
messages: currentMessages as unknown as Parameters<typeof cavemanCompress>[0]["messages"],
},
{ enabled: true }
);
if (cavemanResult?.compressed && cavemanResult.body?.messages) {
const rawMsgs = cavemanResult.body.messages as ChatMessage[];
const candidateMsgs = rawMsgs.map((msg, idx) =>
idx === lastUserIdx ? currentMessages[idx] : msg
);
const candidateTokens = candidateMsgs.reduce(
(sum, m) => sum + estimateTokens(extractTextContent(m.content)),
0
);
const candidateSavings =
originalTokens > 0 ? ((originalTokens - candidateTokens) / originalTokens) * 100 : 0;
if (candidateSavings > resultStats.savingsPercent) {
currentMessages = candidateMsgs;
resultStats.compressedTokens = candidateTokens;
resultStats.savingsPercent = candidateSavings;
resultStats.techniquesUsed.push("caveman-fallback");
}
}
@@ -172,12 +192,21 @@ export function compressAggressive(
{ messages: currentMessages },
{ preserveSystemPrompt: cfg.preserveSystemPrompt !== false }
);
if (liteResult?.compressed && liteResult.stats) {
const liteSavings = liteResult.stats.savingsPercent ?? 0;
if (liteSavings > resultStats.savingsPercent) {
currentMessages = (liteResult.body?.messages ?? currentMessages) as ChatMessage[];
resultStats.compressedTokens = liteResult.stats.compressedTokens ?? compressedTokens;
resultStats.savingsPercent = liteSavings;
if (liteResult?.compressed && liteResult.body?.messages) {
const rawMsgs = liteResult.body.messages as ChatMessage[];
const candidateMsgs = rawMsgs.map((msg, idx) =>
idx === lastUserIdx ? currentMessages[idx] : msg
);
const candidateTokens = candidateMsgs.reduce(
(sum, m) => sum + estimateTokens(extractTextContent(m.content)),
0
);
const candidateSavings =
originalTokens > 0 ? ((originalTokens - candidateTokens) / originalTokens) * 100 : 0;
if (candidateSavings > resultStats.savingsPercent) {
currentMessages = candidateMsgs;
resultStats.compressedTokens = candidateTokens;
resultStats.savingsPercent = candidateSavings;
resultStats.techniquesUsed.push("lite-fallback");
}
}

View File

@@ -1,6 +1,6 @@
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
import type { CompressionResult } from "./types.ts";
import type { StackedCompressionStep } from "./strategySelector.ts";
@@ -17,9 +17,10 @@ function positiveInteger(value: string | undefined, fallback: number): number {
function workerUrl(): URL {
const dir = dirname(fileURLToPath(import.meta.url));
for (const name of ["compressionWorker.js", "compressionWorker.ts"]) {
if (existsSync(join(dir, name))) return new URL(name, import.meta.url);
const candidate = join(dir, name);
if (existsSync(candidate)) return pathToFileURL(candidate);
}
return new URL("compressionWorker.js", import.meta.url);
return pathToFileURL(join(dir, "compressionWorker.js"));
}
function unchanged(body: Record<string, unknown>): CompressionResult {
return { body, compressed: false, stats: null };

View File

@@ -67,7 +67,8 @@ export function applyAging(
messages: unknown[],
thresholds?: AgingThresholds,
summarizer?: Summarizer,
preserveSystemPrompt = true
preserveSystemPrompt = true,
spareUserIndex?: number
): { messages: unknown[]; saved: number } {
const t = thresholds ?? DEFAULT_AGGRESSIVE_CONFIG.thresholds;
const sum = summarizer ?? {
@@ -81,6 +82,9 @@ export function applyAging(
const typed = messages as ChatMessage[];
if (typed.length === 0) return { messages: [], saved: 0 };
const lastUserIdx =
spareUserIndex !== undefined ? spareUserIndex : typed.findLastIndex((m) => m.role === "user");
const totalMessages = typed.length;
const result: ChatMessage[] = [];
let saved = 0;
@@ -89,7 +93,11 @@ export function applyAging(
const msg = typed[i];
const text = extractTextContent(msg.content);
if ((preserveSystemPrompt && msg.role === "system") || COMPRESSED_MARKER_RE.test(text)) {
if (
(preserveSystemPrompt && msg.role === "system") ||
COMPRESSED_MARKER_RE.test(text) ||
i === lastUserIdx
) {
result.push(msg);
continue;
}

View File

@@ -18,6 +18,7 @@ import {
TokenExtractionConfig,
type TokenSource,
} from "./tokenExtractionConfig";
import { matchesCookieDomain } from "../utils/cookieDomain";
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -196,9 +197,14 @@ export class InAppLoginService extends EventEmitter {
for (const source of tokenSources) {
if (source.type === "cookie") {
const domain = source.domain || undefined;
// Exact host or dot-boundary suffix, never `includes()`: a cookie
// from `<domain>.attacker.tld` would otherwise be captured and
// persisted as the operator's credential. Same class CodeQL flagged
// in volcengineConsoleAutoLogin (#860/#861); this callsite was not
// flagged because the expected domain is config-supplied.
const matched = cookies.find(
(c: any) =>
c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, "")))
c.name === source.name && (!domain || matchesCookieDomain(c.domain, domain))
);
if (matched && !credentials[source.name]) {
credentials[source.name] = matched.value;

View File

@@ -26,19 +26,121 @@ export function shouldPreserveQuotaSignals(
}
/**
* Parse a day-granularity quota reset countdown ("Your quota will reset in
* 3 days.", "Resets in 13 days") out of an upstream 429 body.
* Parse a day-granularity quota reset countdown (\"Your quota will reset in
* 3 days.\", \"Resets in 13 days\") out of an upstream 429 body.
*
* Companion to the Xh/Ym/Zs countdown parsing already handled inline by
* `parseRetryFromErrorText` — none of those patterns match when the upstream
* expresses the reset window in whole days rather than hours/minutes/seconds,
* so a multi-day quota reset previously parsed to `null` and fell back to the
* engine's ~seconds-scale default cooldown.
*
* Delegates to `parseIsoDateTimeResetMs` (absolute \"reset at YYYY-MM-DD HH:MM:SS\")
* and then `parseMonthDayResetMs` (year-less \"reset at MM-DD HH:MM:SS UTC\") so
* every absolute-reset shape an upstream uses resolves to the real wait.
*/
export function parseDayGranularityResetMs(msg: string, maxMs: number): number | null {
export function parseDayGranularityResetMs(
msg: string,
maxMs: number,
nowMs: number = Date.now()
): number | null {
const dayMatch = /reset(?:s)?\s+in\s+(\d+)\s*day(?:s)?/i.exec(msg);
if (!dayMatch) return null;
const days = Number.parseInt(dayMatch[1], 10);
if (!Number.isFinite(days) || days <= 0) return null;
return Math.min(days * 24 * 3600 * 1000, maxMs);
if (dayMatch) {
const days = Number.parseInt(dayMatch[1], 10);
if (Number.isFinite(days) && days > 0) {
return Math.min(days * 24 * 3600 * 1000, maxMs);
}
}
const isoMs = parseIsoDateTimeResetMs(msg, maxMs, nowMs);
if (isoMs !== null) return isoMs;
return parseMonthDayResetMs(msg, maxMs, nowMs);
}
/**
* Z.AI (GLM) reports an exhausted weekly/monthly cap with a FULL absolute
* datetime rather than a countdown:
*
* \"[1310][Weekly/Monthly Limit Exhausted. … Your limit will reset at
* 2026-08-29 21:01:21]\"
*
* `parseRetryFromErrorText` (accountFallback.ts) has an equivalent ISO matcher,
* but `buildWeeklyQuotaFallback` never reaches it: it calls
* `parseDayGranularityResetMs` directly, and neither the \"reset in N days\" nor
* the year-less MM-DD parser matched this shape. The weekly fallback therefore
* fell back to WEEKLY_QUOTA_COOLDOWN_MS (24h) and the connection was dispatched
* again — into a real upstream 429 — every day until the true reset ~6 days out.
*
* The datetime may use a `T` or a space separator, and may carry `Z` or a
* `±HH:MM` offset. A NAIVE datetime (no zone) is interpreted as UTC: Z.AI
* reports in UTC, and treating it as local time would shift the cooldown by the
* host offset. Returns null when the instant is not in the future.
*/
export function parseIsoDateTimeResetMs(
msg: string,
maxMs: number,
nowMs: number = Date.now()
): number | null {
const match =
/\b(?:try again at|wait until|reset(?:s)?\s+at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?)\s*(Z|[+-]\d{2}:?\d{2})?/i.exec(
msg
);
if (!match) return null;
const stamp = match[1].replace(/[Tt ]/, "T");
// No zone in the body → UTC (see doc comment). Normalize \"+0200\" to \"+02:00\":
// the bare-offset form is not part of the ES Date.parse grammar.
const rawZone = match[2] ? match[2].toUpperCase() : "Z";
const zone = /^[+-]\d{4}$/.test(rawZone)
? `${rawZone.slice(0, 3)}:${rawZone.slice(3)}`
: rawZone;
const resetMs = Date.parse(`${stamp}${zone}`);
if (!Number.isFinite(resetMs)) return null;
const waitMs = resetMs - nowMs;
if (waitMs <= 0) return null;
return Math.min(waitMs, maxMs);
}
/**
* Qwen token-plan (and similar apikey providers) report the weekly reset as
* \"The quota will reset at 08-29 15:29:00 UTC\" without a year. Treat that as
* the next occurrence of MM-DD HH:MM[:SS] UTC; if the date already passed this
* year, roll to next year. Returns null when the parsed instant is not in the
* future or the wait would exceed maxMs.
*/
export function parseMonthDayResetMs(
msg: string,
maxMs: number,
nowMs: number = Date.now()
): number | null {
const match =
/reset(?:s)?\s+at\s+(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s*(?:UTC|Z)?/i.exec(
msg
);
if (!match) return null;
const month = Number.parseInt(match[1], 10);
const day = Number.parseInt(match[2], 10);
const hour = Number.parseInt(match[3], 10);
const minute = Number.parseInt(match[4], 10);
const second = match[5] ? Number.parseInt(match[5], 10) : 0;
if (
month < 1 ||
month > 12 ||
day < 1 ||
day > 31 ||
hour > 23 ||
minute > 59 ||
second > 59
) {
return null;
}
const now = new Date(nowMs);
let year = now.getUTCFullYear();
let resetMs = Date.UTC(year, month - 1, day, hour, minute, second);
if (!Number.isFinite(resetMs)) return null;
if (resetMs <= nowMs) {
year += 1;
resetMs = Date.UTC(year, month - 1, day, hour, minute, second);
}
const waitMs = resetMs - nowMs;
if (!Number.isFinite(waitMs) || waitMs <= 0) return null;
return Math.min(waitMs, maxMs);
}

View File

@@ -11,6 +11,7 @@
*/
import { RateLimitReason } from "../config/constants.ts";
import { parseDayGranularityResetMs } from "./quotaResetParsing.ts";
type RateLimitReasonValue = (typeof RateLimitReason)[keyof typeof RateLimitReason];
@@ -97,16 +98,29 @@ export function isWeeklyUsageLimitText(lower: string): boolean {
return (
lower.includes("weekly usage limit") ||
lower.includes("weekly limit reached") ||
lower.includes("reached your weekly")
lower.includes("reached your weekly") ||
lower.includes("1-week quota") ||
lower.includes("week quota") ||
lower.includes("weekly/monthly limit") ||
(lower.includes("weekly") && lower.includes("quota") && lower.includes("exhaust"))
);
}
const MAX_WEEKLY_QUOTA_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000;
export function buildWeeklyQuotaFallback(errorStr: string): QuotaTextFallback | null {
if (!isWeeklyUsageLimitText(errorStr.toLowerCase())) return null;
const parsedResetMs = parseDayGranularityResetMs(errorStr, MAX_WEEKLY_QUOTA_COOLDOWN_MS);
const cooldownMs =
typeof parsedResetMs === "number" && parsedResetMs > 0
? parsedResetMs
: WEEKLY_QUOTA_COOLDOWN_MS;
return {
shouldFallback: true,
cooldownMs: WEEKLY_QUOTA_COOLDOWN_MS,
cooldownMs,
reason: RateLimitReason.QUOTA_EXHAUSTED,
usedUpstreamRetryHint: typeof parsedResetMs === "number" && parsedResetMs > 0,
quotaResetHintMs: typeof parsedResetMs === "number" && parsedResetMs > 0 ? parsedResetMs : undefined,
};
}

View File

@@ -64,7 +64,7 @@ const MAX_CONVERSATION_AFFINITY_ENTRIES = 1000;
* Task routing is additive: other strategies are wholly unaffected.
*/
export function isTaskRoutingStrategy(strategy: unknown): boolean {
return ["smart", "task", "task-aware", "task_aware", "auto"].includes(
return ["smart", "task", "task-aware", "task_aware"].includes(
String(strategy ?? "").toLowerCase()
);
}

View File

@@ -185,6 +185,26 @@ const RAW_CONFIGS: TokenExtractionConfig[] = [
{ cookieDomain: ".chat.qwen.ai" }
),
// ── Volcano Engine Ark Console ───────────────────────────
config(
"volcengine-console",
"Volcano Engine Ark Console",
"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan",
"https://console.volcengine.com",
[
{ type: "cookie", name: "digest", domain: ".volcengine.com" },
{ type: "cookie", name: "AccountID", domain: ".volcengine.com" },
{ type: "cookie", name: "csrfToken", domain: ".volcengine.com" },
{ type: "cookie", name: "userInfo", domain: ".volcengine.com" },
],
"Log in to the Volcano Engine Ark console. The console session is used to discover Agent/Coding Plan API keys and live quota usage.",
{
cookieDomain: ".volcengine.com",
successUrlPattern: /console\.volcengine\.com\/ark/i,
pollingConfig: { timeout: 300_000, minLoginTime: 3000 },
}
),
// ── Kimi Web ──────────────────────────────────────────────
config(
"kimi-web",

View File

@@ -68,6 +68,7 @@ import { getXaiUsage } from "./usage/xai.ts";
import { getXaiOauthUsage } from "./usage/xaiOauth.ts";
import { getGrokCliUsage } from "./usage/grokCli.ts";
import { getFirecrawlUsage } from "./usage/firecrawl.ts";
import { getVolcenginePlanUsage } from "./usage/volcenginePlan.ts";
import { getCommandCodeUsage } from "./usage/command-code.ts";
import { getQwenTokenPlanUsage } from "./usage/qwen-token-plan.ts";
import { getConolUsage } from "./conolUsage.ts";
@@ -135,6 +136,9 @@ export const USAGE_FETCHER_PROVIDERS = [
"ha",
// Firecrawl team credits (GET /v2/team/credit-usage)
"firecrawl",
// Volcano Ark Plan subscriptions (agent-plan / coding-plan)
"volcengine-agent-plan",
"volcengine-coding-plan",
// Command Code credits + 5h/weekly windows (GET /alpha/billing/credits)
"command-code",
"conol-web",
@@ -242,6 +246,9 @@ export async function getUsageForProvider(
return await getHyperAgentUsage(apiKey || accessToken, providerSpecificData);
case "firecrawl":
return await getFirecrawlUsage(id || "", apiKey, connection);
case "volcengine-agent-plan":
case "volcengine-coding-plan":
return await getVolcenginePlanUsage(apiKey || "", provider, providerSpecificData);
case "command-code":
return await getCommandCodeUsage(apiKey || accessToken || "");
case "conol-web":

View File

@@ -155,15 +155,30 @@ export async function getGlmUsage(apiKey: string, providerSpecificData?: Record<
const resetMs = toNumber(src.nextResetTime, 0);
const resetAt = resetMs > 0 ? new Date(resetMs).toISOString() : null;
if (type === "TOKENS_LIMIT") {
// Z.ai coding-plan keys (CREDIT-based, e.g. GLM Coding Max/Lite) report
// CREDIT_LIMIT rows with the same unit/number semantics as TOKENS_LIMIT
// (unit=3/number=5 → 5-hour window, unit=6/number=1 → weekly). Without
// this branch every CREDIT_LIMIT row is dropped and the quota card
// renders empty for subscription keys.
if (type === "TOKENS_LIMIT" || type === "CREDIT_LIMIT") {
const quotaName = getGlmTokenQuotaName(src, quotas);
const usedPercent = toPercentage(src.percentage);
const remaining = Math.max(0, 100 - usedPercent);
// CREDIT_LIMIT rows (z.ai coding-plan keys) carry absolute credits on
// top of the percentage: usage = window total, currentValue = consumed,
// remaining = credits left. Prefer them so the quota card renders
// "3341 / 28000" like z.ai's own dashboard instead of a percent-only
// scale. TOKENS_LIMIT rows without absolute fields keep the percent path.
const totalCredits = toNumber(src.usage, 0);
const usedCredits = totalCredits > 0 ? toNumber(src.currentValue, usedPercent) : usedPercent;
const remainingCredits = totalCredits > 0 ? toNumber(src.remaining, remaining) : remaining;
const total = totalCredits > 0 ? totalCredits : 100;
quotas[quotaName] = {
used: usedPercent,
total: 100,
remaining,
used: usedCredits,
total,
remaining: remainingCredits,
remainingPercentage: remaining,
resetAt,
displayName: getGlmQuotaDisplayName(quotaName),

View File

@@ -0,0 +1,317 @@
/**
* usage/volcenginePlan.ts — Volcano Ark Plan usage fetcher.
*
* Volcano Engine Ark serves the two subscription plans on DISTINCT chat base URLs:
* - Agent Plan → https://ark.cn-beijing.volces.com/api/plan/v3
* - Coding Plan → https://ark.cn-beijing.volces.com/api/coding/v3
* (both differ from the standard pay-per-use API at /api/v3).
*
* The data-plane API exposes NO quota/usage endpoint. Real subscription usage
* lives behind the Ark console's authenticated "top" API, which is keyed by the
* browser session cookie (+ CSRF token), NOT the ark- API key:
* - Coding Plan → POST /api/top/ark/cn-beijing/2024-01-01/GetCodingPlanUsage
* - Agent Plan → POST /api/top/ark/cn-beijing/2024-01-01/GetAgentPlanAFPUsage
*
* When the connection carries a console cookie in providerSpecificData
* (`volcConsoleCookie` + `volcCsrfToken`), we fetch the real quota windows and
* map them into OmniRoute's UsageQuota shape. Without a cookie we fall back to a
* data-plane connectivity probe (validates the key, no quota numbers).
*/
import { toRecord, toNumber } from "./scalars.ts";
import { type UsageQuota } from "./quota.ts";
type JsonRecord = Record<string, unknown>;
const AGENT_PLAN_BASE_URL = "https://ark.cn-beijing.volces.com/api/plan/v3";
const CODING_PLAN_BASE_URL = "https://ark.cn-beijing.volces.com/api/coding/v3";
const CONSOLE_TOP_BASE = "https://console.volcengine.com/api/top/ark/cn-beijing/2024-01-01";
// First model probed for the Agent Plan chat-based validation (no /models endpoint).
const AGENT_PLAN_PROBE_MODEL = "doubao-seed-2-0-pro-260215";
const CONSOLE_HINT_AGENT = "console.volcengine.com/ark → 订阅 Agent Plan";
const CONSOLE_HINT_CODING = "console.volcengine.com/ark → 订阅 Coding Plan";
function getPlanName(provider: string): string {
if (provider === "volcengine-agent-plan") return "Volcano Ark Agent Plan";
if (provider === "volcengine-coding-plan") return "Volcano Ark Coding Plan";
return "Volcano Ark Plan";
}
function getBaseUrl(provider: string, providerSpecificData?: JsonRecord): string {
const override = providerSpecificData?.arkPlanBaseUrl;
if (typeof override === "string" && override.trim()) return override.trim().replace(/\/+$/, "");
if (provider === "volcengine-coding-plan") return CODING_PLAN_BASE_URL;
return AGENT_PLAN_BASE_URL;
}
// ── Console cookie helpers ──────────────────────────────────────────────────
function getConsoleCookie(providerSpecificData?: JsonRecord): string {
const cookie = providerSpecificData?.volcConsoleCookie;
return typeof cookie === "string" ? cookie.trim() : "";
}
function getConsoleCsrf(providerSpecificData?: JsonRecord, cookie = ""): string {
const explicit = providerSpecificData?.volcCsrfToken;
if (typeof explicit === "string" && explicit.trim()) return explicit.trim();
// Fall back to the csrfToken embedded in the cookie string.
const match = cookie.match(/csrfToken=([^;]+)/);
return match ? match[1].trim() : "";
}
async function callConsoleApi(
action: string,
cookie: string,
csrf: string,
referer: string
): Promise<{ ok: boolean; status: number; json: JsonRecord; error?: string }> {
const response = await fetch(`${CONSOLE_TOP_BASE}/${action}?`, {
method: "POST",
headers: {
accept: "application/json, text/plain, */*",
"content-type": "application/json",
cookie,
origin: "https://console.volcengine.com",
referer,
"x-csrf-token": csrf,
},
body: "{}",
});
const text = await response.text();
let json: JsonRecord = {};
try {
json = toRecord(JSON.parse(text));
} catch {
/* non-JSON */
}
const err = toRecord(toRecord(json.ResponseMetadata).Error);
const errMsg = typeof err.Message === "string" ? err.Message : "";
return { ok: response.ok && !errMsg, status: response.status, json, error: errMsg };
}
// ── Console usage → UsageQuota mapping ───────────────────────────────────────
function tsToIso(seconds: number): string | null {
if (!seconds || seconds <= 0) return null;
const ms = seconds < 1e12 ? seconds * 1000 : seconds;
const d = new Date(ms);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
const CODING_WINDOW_LABEL: Record<string, string> = {
session: "Session (5h)",
weekly: "Weekly",
monthly: "Monthly",
daily: "Daily",
};
/**
* Map GetCodingPlanUsage → quotas. Coding Plan reports each window as a used
* `Percent` (0-100) against `Cap` (100), so remaining = Cap - Percent.
*/
function mapCodingPlanUsage(result: JsonRecord): Record<string, UsageQuota> {
const quotas: Record<string, UsageQuota> = {};
const windows = Array.isArray(result.QuotaUsage) ? result.QuotaUsage : [];
for (const raw of windows) {
const w = toRecord(raw);
const level = String(w.Level || "").toLowerCase();
if (!level) continue;
const cap = toNumber(w.Cap, 100) || 100;
const usedPercent = toNumber(w.Percent, 0);
const remainingPercentage = Math.max(0, Math.min(100, cap - usedPercent));
quotas[level] = {
used: usedPercent,
total: cap,
remaining: Math.max(0, cap - usedPercent),
remainingPercentage,
resetAt: tsToIso(toNumber(w.ResetTimestamp, 0)),
unlimited: false,
displayName: CODING_WINDOW_LABEL[level] || level,
};
}
return quotas;
}
const AGENT_WINDOW_LABEL: Array<[string, string]> = [
["AFPFiveHour", "Session (5h)"],
["AFPDaily", "Daily"],
["AFPWeekly", "Weekly"],
["AFPMonthly", "Monthly"],
];
/**
* Map GetAgentPlanAFPUsage → quotas. Agent Plan reports absolute `Quota`/`Used`
* (AFP credits) per window with a millisecond `ResetTime`.
*/
function mapAgentPlanUsage(result: JsonRecord): Record<string, UsageQuota> {
const quotas: Record<string, UsageQuota> = {};
for (const [key, label] of AGENT_WINDOW_LABEL) {
const w = toRecord(result[key]);
if (Object.keys(w).length === 0) continue;
const total = toNumber(w.Quota, 0);
const used = toNumber(w.Used, 0);
const remaining = Math.max(0, total - used);
const remainingPercentage =
total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 100;
const resetMs = toNumber(w.ResetTime, 0);
quotas[key] = {
used,
total,
remaining,
remainingPercentage,
// Agent Plan ResetTime is in milliseconds already.
resetAt: tsToIso(resetMs >= 1e12 ? resetMs / 1000 : resetMs),
unlimited: false,
displayName: label,
};
}
return quotas;
}
// ── Data-plane connectivity probes (fallback, no cookie) ─────────────────────
function parseArkError(json: unknown): { code: string; message: string } | null {
const data = toRecord(json);
const error = toRecord(data.error);
if (!error.code && !error.message && !data.message) return null;
return {
code: String(error.code || ""),
message: String(error.message || data.message || ""),
};
}
function authErrorMessage(planName: string, status: number, errorMsg: string): string {
if (status === 401) {
const isFormatError = /format.*incorrect|incorrect.*format/i.test(errorMsg);
return isFormatError
? `Invalid API key format. ${planName} keys start with 'ark-'. Check your subscription key.`
: `Invalid API key or the key does not belong to a ${planName} subscription.`;
}
if (status === 403) {
return `Access denied. Ensure your key has an active ${planName} subscription.`;
}
return `${planName} API error (${status}): ${errorMsg}`;
}
async function reportError(response: Response, responseText: string, planName: string) {
let data: unknown = null;
try {
data = JSON.parse(responseText);
} catch {
/* non-JSON error body */
}
const arkError = parseArkError(data);
return {
plan: planName,
message: authErrorMessage(
planName,
response.status,
arkError?.message || responseText.slice(0, 200)
),
};
}
/** Coding Plan: validate via the working /models listing endpoint. */
async function probeCodingPlan(baseUrl: string, apiKey: string, planName: string) {
const response = await fetch(`${baseUrl}/models`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
});
const responseText = await response.text();
if (!response.ok) return reportError(response, responseText, planName);
return {
plan: planName,
message: `${planName} connected. Add your console cookie (volcConsoleCookie) to view live quota, or check ${CONSOLE_HINT_CODING}.`,
};
}
/** Agent Plan: no /models endpoint — validate via a minimal chat probe. */
async function probeAgentPlan(baseUrl: string, apiKey: string, planName: string) {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
model: AGENT_PLAN_PROBE_MODEL,
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
stream: false,
}),
});
const responseText = await response.text();
if (!response.ok) return reportError(response, responseText, planName);
return {
plan: planName,
message: `${planName} connected. Add your console cookie (volcConsoleCookie) to view live quota, or check ${CONSOLE_HINT_AGENT}.`,
};
}
// ── Entry point ──────────────────────────────────────────────────────────────
export async function getVolcenginePlanUsage(
apiKey: string,
provider: string,
providerSpecificData?: JsonRecord
) {
const planName = getPlanName(provider);
const isCoding = provider === "volcengine-coding-plan";
// Preferred path: real usage via the authenticated console "top" API.
const cookie = getConsoleCookie(providerSpecificData);
if (cookie) {
const csrf = getConsoleCsrf(providerSpecificData, cookie);
const action = isCoding ? "GetCodingPlanUsage" : "GetAgentPlanAFPUsage";
const referer = isCoding
? "https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan"
: "https://console.volcengine.com/ark/region:cn-beijing/subscription/agent-plan";
try {
const { ok, status, json, error } = await callConsoleApi(action, cookie, csrf, referer);
if (ok) {
const result = toRecord(json.Result);
const quotas = isCoding ? mapCodingPlanUsage(result) : mapAgentPlanUsage(result);
if (Object.keys(quotas).length > 0) {
const planType = typeof result.PlanType === "string" ? ` (${result.PlanType})` : "";
return { plan: `${planName}${planType}`, quotas };
}
return {
plan: planName,
message: `${planName} connected. No active quota windows reported.`,
};
}
// Cookie present but console call failed (expired session / no subscription).
if (status === 401 || status === 403 || /login|unauthor|登录|鉴权/i.test(error || "")) {
return {
plan: planName,
message: `Console session expired. Refresh volcConsoleCookie to view live quota.`,
};
}
return {
plan: planName,
message: `${planName}: console usage unavailable${error ? ` (${error})` : ""}.`,
};
} catch (err) {
return {
plan: planName,
message: `${planName} — unable to reach the Ark console: ${(err as Error).message}`,
};
}
}
// Fallback: data-plane connectivity probe (needs the ark- API key).
if (!apiKey) {
return { message: "API key not available. Add an Ark Plan API key to view usage." };
}
const baseUrl = getBaseUrl(provider, providerSpecificData);
try {
return isCoding
? await probeCodingPlan(baseUrl, apiKey, planName)
: await probeAgentPlan(baseUrl, apiKey, planName);
} catch (error) {
return {
plan: planName,
message: `${planName} — unable to reach the Ark API: ${(error as Error).message}`,
};
}
}

View File

@@ -0,0 +1,995 @@
/**
* VolcengineConsoleAutoLogin — session-based phone/SMS-code login for the
* Volcano Engine console.
*
* Unlike InAppLoginService (which opens a headful browser and requires the
* operator to complete login inside a browser on the server machine), this
* service drives a headless Chromium through the console's 手机号登录 (phone +
* SMS verification code) flow:
*
* 1. startLogin(phone) — navigate to the login page, switch to the phone
* tab, fill the phone number, click 获取验证码. If the console demands an
* image captcha, a screenshot is captured for the dashboard to render.
* 2. submitCode(code, captcha?) — fill the SMS code (and image captcha when
* requested), click 登录 / 注册, then poll the browser context for the
* console session cookies (digest / AccountID / csrfToken / userInfo).
* 3. cancel() / resendCode() — lifecycle helpers.
*
* The service only extracts credentials; persisting/binding them to provider
* connections stays in the dashboard API layer (volcenginePlanBinding.ts).
*
* Selector strategy: the console login page is built with Arco Design and
* exposes stable element ids (#Tel_input, #Code_input, #VerificatonCodeInput).
* Every interaction goes through multi-candidate selector lists so a single
* frontend rename does not break the flow. When a candidate list misses or
* risk-control (slider) is detected, the session degrades to
* `fallback_manual` and the caller can fall back to the pre-existing
* headful-browser flow.
*/
import { randomUUID } from "crypto";
import { matchesCookieDomain } from "../utils/cookieDomain";
// ─── Public types ───────────────────────────────────────────────────────────
export type VolcLoginPhase =
| "starting"
| "sending_code"
| "waiting_code"
| "captcha_required"
| "submitting"
| "mfa_waiting"
| "identity_required"
| "success"
| "error"
| "timeout"
| "cancelled"
| "fallback_manual";
export interface VolcLoginSessionView {
sessionId: string;
phase: VolcLoginPhase;
phoneMasked: string;
error: string | null;
/** data:image/png;base64 screenshot of the image captcha, when required */
captchaImage: string | null;
/** epoch ms — earliest time a resend should be offered */
resendAvailableAt: number;
createdAt: number;
updatedAt: number;
/** True while the console demands an MFA step-up code (second SMS code) */
mfaRequired?: boolean;
/** Identity options scraped from /auth/login/select_identity, when required */
identityOptions?: Array<{ index: number; label: string }>;
/** Credentials (console cookies) — only present after success */
credentials?: Record<string, string>;
/** Set by the API layer after binding plans (not part of this service) */
binding?: unknown;
}
export interface StartOptions {
/** Total session timeout in ms (default 300_000) */
timeout?: number;
}
export interface SubmitCodeOptions {
/** Extra wait for cookie polling after submit (default 90_000) */
timeout?: number;
}
/** Injectable delays — tests shrink these to keep the suite fast. */
export interface ServiceDelays {
pageSettleMs?: number;
tabSwitchMs?: number;
sendCodeSettleMs?: number;
pollIntervalMs?: number;
resendCooldownMs?: number;
}
// ─── Config ─────────────────────────────────────────────────────────────────
const LOGIN_URL = "https://console.volcengine.com/auth/login";
/** Landing page the manual headful flow uses — the console app issues the
* remaining session cookies (AccountID/userInfo) once it runs. */
const ARK_CONSOLE_URL =
"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan";
/** Cookie names required for a valid console session (mirrors tokenExtractionConfig) */
const REQUIRED_COOKIES = ["digest", "AccountID", "csrfToken", "userInfo"] as const;
const DEFAULT_SESSION_TIMEOUT = 300_000;
const SUBMIT_COOKIE_TIMEOUT = 90_000;
const CAPTURE_POLL_INTERVAL = 1_000;
const RESEND_COOLDOWN_MS = 60_000;
const MAX_ACTIVE_SESSIONS = 2;
/** Multi-candidate selectors — first visible candidate wins. */
const SELECTORS = {
phoneTab: ['.arco-tabs-header-title:has-text("手机号登录")', "text=手机号登录"],
phoneInput: ["#Tel_input", 'input[name="Tel"]', 'input[placeholder*="手机号"]'],
smsCodeInput: ["#Code_input", 'input[placeholder*="请输入验证码"]'],
sendCodeButton: ['button:has-text("获取验证码")', "text=获取验证码"],
loginButton: ['button:has-text("登录 / 注册")', 'button:has-text("登录")'],
imageCaptchaInput: ["#VerificatonCodeInput", "input.verify-input"],
captchaShot: [".arco-modal", '[class*="captcha"]', '[class*="verify"]'],
/** Risk-control slider / popup heuristics */
riskControl: [
'[class*="secsdk-captcha"]',
"#captcha_popup",
'[class*="captcha-slider"]',
'[class*="drag"] [class*="slider"]',
],
/** MFA step-up modal (需要额外认证): a SECOND 6-digit SMS code is required */
mfaModal: ['.arco-modal:has-text("需要额外认证")', "text=需要额外认证"],
mfaInput: ["#VerificatonCodeInput", ".arco-modal input.verify-input", ".arco-modal input"],
mfaConfirmButton: ['button:has-text("好的")', '.arco-modal button:has-text("确定")'],
mfaResendButton: ['button:has-text("重发校验码")'],
/** TOTP binding modal (绑定MFA设备) — needs interactive Google Authenticator setup */
mfaBindModal: ['.arco-modal:has-text("绑定MFA设备")'],
/** Identity selection page (/auth/login/select_identity) — the phone maps to
* multiple accounts; the user must pick which identity to log in as.
* Structure verified against the real auth bundle (vconsole-auth 1.0.0.2837,
* module 12173 + chunk 202): ul[class*=accountUl] > li[class*=accountLi] >
* div[class*=item] (click target) with the identity text in [class*=identity];
* submit is button[type=submit] ("登录") inside [class*=selectPlatformIdentity].
* .arco-list-item is kept as a fallback for future Arco-based redesigns. */
identityList: ['ul[class*="accountUl"] li[class*="accountLi"]', ".arco-list-item"],
identityItem: ['li[class*="accountLi"] > [class*="item"]', ".arco-list-item"],
identitySubmitButton: [
'[class*="selectPlatformIdentity"] button[type="submit"]',
'button[type="submit"]:has-text("登录")',
'button:has-text("登录")',
],
} as const;
/** URL marker for the console's identity-selection page */
const IDENTITY_URL_PATTERN = /\/auth\/login\/select_identity/i;
const BROWSER_CONTEXT_OPTIONS = {
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
viewport: { width: 1280, height: 800 },
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
};
// ─── Minimal playwright structural types ──────────────────────────────────
// Playwright is an optional runtime dep (dynamically imported), so we model
// only the API surface this service drives instead of importing its types.
interface PwLocator {
first(): PwLocator;
isVisible(options?: { timeout?: number }): Promise<boolean>;
click(options?: unknown): Promise<void>;
fill(value: string): Promise<void>;
isDisabled(): Promise<boolean>;
screenshot(options?: { type?: string }): Promise<Buffer>;
textContent(options?: { timeout?: number }): Promise<string | null>;
count(): Promise<number>;
nth(index: number): PwLocator;
}
interface PwPage {
setDefaultTimeout(timeout: number): void;
goto(url: string, options?: { waitUntil?: string; timeout?: number }): Promise<unknown>;
locator(selector: string): PwLocator;
screenshot(options?: { type?: string }): Promise<Buffer>;
url(): string;
content(): Promise<string>;
}
interface PwContext {
newPage(): Promise<PwPage>;
cookies(): Promise<Array<{ name: string; domain: string; value: string }>>;
}
interface PwBrowser {
newContext(options?: Record<string, unknown>): Promise<PwContext>;
close(): Promise<void>;
}
interface PwModule {
chromium: {
launch(options?: { headless?: boolean; args?: string[]; channel?: string }): Promise<PwBrowser>;
};
}
// ─── Session record (internal) ──────────────────────────────────────────────
interface ActiveSession {
sessionId: string;
phone: string;
phase: VolcLoginPhase;
error: string | null;
captchaImage: string | null;
resendAvailableAt: number;
createdAt: number;
updatedAt: number;
timeoutMs: number;
credentials: Record<string, string> | null;
/** Binding outcome set by the API layer via withBinding() */
binding?: unknown;
cancelled: boolean;
/** Identity options scraped from the select_identity page */
identityOptions: Array<{ index: number; label: string }> | null;
// Playwright handles — never serialized
browser: PwBrowser | null;
context: PwContext | null;
page: PwPage | null;
}
export function maskPhone(phone: string): string {
if (phone.length < 7) return "***";
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
/** Normalize a CN mobile number: strip +86/86 prefix, spaces, dashes. */
export function normalizePhone(raw: string): string | null {
const trimmed = String(raw || "")
.trim()
.replace(/[\s-]/g, "");
const bare = trimmed.replace(/^\+?86/, "");
return /^1\d{10}$/.test(bare) ? bare : null;
}
/**
* Whether a cookie's `domain` belongs to the Volcengine console.
*
* Cookie domains must be matched by exact host or dot-boundary suffix, never by
* substring: `domain.includes("volcengine.com")` also accepted
* `volcengine.com.attacker.tld` and `notvolcengine.com`, so a cookie named
* `digest`/`AccountID`/`csrfToken`/`userInfo` set by a look-alike host was
* harvested as an operator credential and persisted as a provider connection
* (CodeQL js/incomplete-url-substring-sanitization #860/#861). Mirrors
* `isAdobeCookieDomain` in adobeFireflyBrowserLogin.ts.
*/
export function isVolcengineCookieDomain(domain: string | undefined): boolean {
return matchesCookieDomain(domain, "volcengine.com");
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ─── Service ────────────────────────────────────────────────────────────────
export class VolcengineConsoleAutoLoginService {
private sessions = new Map<string, ActiveSession>();
/** sessionId → bind promise set by the API layer to dedupe lazy binding */
private bindInFlight = new Map<string, Promise<unknown>>();
/** Injectable for tests — resolves the playwright module instead of `import("playwright")`. */
private readonly loadPlaywright: () => Promise<PwModule>;
private readonly delays: Required<ServiceDelays>;
constructor(
loadPlaywright: () => Promise<PwModule> = async () => import("playwright"),
delays: ServiceDelays = {}
) {
this.loadPlaywright = loadPlaywright;
this.delays = {
pageSettleMs: delays.pageSettleMs ?? 2_500,
tabSwitchMs: delays.tabSwitchMs ?? 1_000,
sendCodeSettleMs: delays.sendCodeSettleMs ?? 2_000,
pollIntervalMs: delays.pollIntervalMs ?? CAPTURE_POLL_INTERVAL,
resendCooldownMs: delays.resendCooldownMs ?? RESEND_COOLDOWN_MS,
};
}
// ─── Queries ─────────────────────────────────────────────────────────────
getActiveSessionCount(): number {
let count = 0;
for (const session of this.sessions.values()) {
if (!isTerminal(session.phase)) count++;
}
return count;
}
getStatus(sessionId: string): VolcLoginSessionView | null {
const session = this.sessions.get(sessionId);
if (!session) return null;
return this.toView(session);
}
/**
* Lazy binding hook used by the API layer: the route stores a promise here
* so concurrent status polls do not double-bind the same credentials.
*/
async withBinding<T>(
sessionId: string,
bind: (credentials: Record<string, string>) => Promise<T>
): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
if (session.phase !== "success" || !session.credentials) {
return this.toView(session);
}
if (session.binding !== undefined) return this.toView(session);
let inFlight = this.bindInFlight.get(sessionId);
if (!inFlight) {
inFlight = bind(session.credentials)
.then((binding: unknown) => {
session.binding = binding;
return binding;
})
.catch((error: unknown) => {
// Persist the failure so status polls do not retry forever.
session.binding = { error: errorMessage(error) };
return session.binding;
})
.finally(() => {
this.bindInFlight.delete(sessionId);
});
this.bindInFlight.set(sessionId, inFlight);
}
await inFlight;
return this.toView(session);
}
// ─── Lifecycle ───────────────────────────────────────────────────────────
async startLogin(
phone: string,
options?: StartOptions
): Promise<{ ok: true; session: VolcLoginSessionView } | { ok: false; error: string }> {
const normalized = normalizePhone(phone);
if (!normalized) {
return { ok: false, error: "Invalid phone number (expected an 11-digit CN mobile number)" };
}
this.expireSessions();
for (const session of this.sessions.values()) {
if (session.phone === normalized && !isTerminal(session.phase)) {
await this.cancel(session.sessionId);
}
}
if (this.getActiveSessionCount() >= MAX_ACTIVE_SESSIONS) {
return { ok: false, error: "Too many concurrent Volcano login sessions" };
}
let playwright: PwModule;
try {
playwright = await this.loadPlaywright();
} catch {
return {
ok: false,
error: "Playwright is not installed. Use manual browser login instead.",
};
}
const session: ActiveSession = {
sessionId: randomUUID(),
phone: normalized,
phase: "starting",
error: null,
captchaImage: null,
resendAvailableAt: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
timeoutMs: options?.timeout || DEFAULT_SESSION_TIMEOUT,
credentials: null,
cancelled: false,
identityOptions: null,
browser: null,
context: null,
page: null,
};
this.sessions.set(session.sessionId, session);
try {
// Prefer the playwright-managed Chromium; fall back to the system Chrome
// channel on machines without `npx playwright install` browsers (dev laptops).
try {
session.browser = await playwright.chromium.launch({
headless: true,
args: ["--disable-blink-features=AutomationControlled"],
});
} catch (launchError) {
if (!/Executable doesn't exist/.test(String(launchError))) throw launchError;
session.browser = await playwright.chromium.launch({
headless: true,
channel: "chrome",
args: ["--disable-blink-features=AutomationControlled"],
});
}
session.context = await session.browser.newContext(BROWSER_CONTEXT_OPTIONS);
session.page = await session.context.newPage();
session.page.setDefaultTimeout(15_000);
await session.page.goto(LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 30_000 });
await sleep(this.delays.pageSettleMs);
// Switch to the phone-code login tab
const tab = await this.firstVisible(session.page, SELECTORS.phoneTab);
if (!tab) throw new SelectorMissError("phone tab");
await tab.click();
await sleep(this.delays.tabSwitchMs);
// Fill the phone number
const phoneInput = await this.firstVisible(session.page, SELECTORS.phoneInput);
if (!phoneInput) throw new SelectorMissError("phone input");
await phoneInput.fill(normalized);
// Send the SMS code
const sendBtn = await this.firstVisible(session.page, SELECTORS.sendCodeButton);
if (!sendBtn) throw new SelectorMissError("send-code button");
await sendBtn.click();
session.phase = "sending_code";
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
await sleep(this.delays.sendCodeSettleMs);
// Risk-control slider → degrade to the manual headful flow
const risk = await this.firstVisible(session.page, SELECTORS.riskControl);
if (risk) {
session.captchaImage = await this.shot(session.page);
session.phase = "fallback_manual";
session.error =
"Volcano risk control (slider captcha) was triggered in headless mode. Use manual browser login.";
await this.closeBrowser(session);
return { ok: true, session: this.toView(session) };
}
// Image captcha may be required before the SMS is sent
const captchaInput = await this.firstVisible(session.page, SELECTORS.imageCaptchaInput);
if (captchaInput) {
session.captchaImage = await this.shot(session.page);
session.phase = "captcha_required";
} else {
session.phase = "waiting_code";
}
return { ok: true, session: this.toView(session) };
} catch (error) {
await this.closeBrowser(session);
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
session.error = errorMessage(error);
if (session.phase === "fallback_manual") {
session.error = `${session.error}. The login page layout may have changed — use manual browser login.`;
}
return { ok: true, session: this.toView(session) };
}
}
async submitCode(
sessionId: string,
code: string,
captcha?: string,
options?: SubmitCodeOptions
): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
const fromMfa = session.phase === "mfa_waiting";
if (session.phase !== "waiting_code" && session.phase !== "captcha_required" && !fromMfa) {
return this.toView(session);
}
const smsCode = String(code || "").trim();
if (!/^\d{4,6}$/.test(smsCode)) {
session.error = "Invalid SMS code";
return this.toView(session);
}
if (session.phase === "captcha_required" && !String(captcha || "").trim()) {
session.error = "Image captcha is required";
return this.toView(session);
}
const page = session.page;
if (!page) {
session.phase = "error";
session.error = "Browser session is gone — restart the login";
return this.toView(session);
}
try {
if (fromMfa) {
// MFA step-up (需要额外认证): fill the SECOND code into the modal
// input and confirm with 好的.
const mfaInput = await this.firstVisible(page, SELECTORS.mfaInput);
if (!mfaInput) throw new SelectorMissError("mfa code input");
await mfaInput.fill(smsCode);
const confirmBtn = await this.firstVisible(page, SELECTORS.mfaConfirmButton);
if (!confirmBtn) throw new SelectorMissError("mfa confirm button");
await confirmBtn.click();
} else {
const codeInput = await this.firstVisible(page, SELECTORS.smsCodeInput);
if (!codeInput) throw new SelectorMissError("sms code input");
await codeInput.fill(smsCode);
if (captcha) {
const captchaInput = await this.firstVisible(page, SELECTORS.imageCaptchaInput);
if (captchaInput) await captchaInput.fill(String(captcha).trim());
}
const loginBtn = await this.firstVisible(page, SELECTORS.loginButton);
if (!loginBtn) throw new SelectorMissError("login button");
await loginBtn.click();
}
session.phase = "submitting";
session.error = null;
session.captchaImage = null;
return await this.pollUntilResolved(session, {
timeoutMs: options?.timeout || SUBMIT_COOKIE_TIMEOUT,
fromMfa,
detectIdentity: true,
});
} catch (error) {
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
session.error = errorMessage(error);
await this.closeBrowser(session);
return this.toView(session);
}
}
/**
* Pick an identity on the console's /auth/login/select_identity page and
* finish the login. `index` maps to the identityOptions list previously
* returned in the session view.
*/
async selectIdentity(
sessionId: string,
index: number,
options?: SubmitCodeOptions
): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
if (session.phase !== "identity_required") {
return this.toView(session);
}
const page = session.page;
if (!page) {
session.phase = "error";
session.error = "Browser session is gone — restart the login";
return this.toView(session);
}
try {
// Click the requested identity card (the page pre-selects the first one,
// so only non-zero indexes need an explicit click).
if (index > 0) {
const itemSelector = await this.identityItemSelector(page);
if (!itemSelector) throw new SelectorMissError("identity item");
const items = page.locator(itemSelector);
const count = await items.count();
if (index < 0 || index >= count) {
session.error = `Identity index ${index} is out of range (${count} options)`;
return this.toView(session);
}
await items.nth(index).click();
await sleep(this.delays.tabSwitchMs);
}
// Submit the selection (button[type=submit] “登录” on the identity card)
const submitBtn = await this.firstVisible(page, SELECTORS.identitySubmitButton);
if (!submitBtn) throw new SelectorMissError("identity submit button");
await submitBtn.click();
session.phase = "submitting";
session.error = null;
session.identityOptions = null;
return await this.pollUntilResolved(session, {
timeoutMs: options?.timeout || SUBMIT_COOKIE_TIMEOUT,
fromMfa: false,
detectIdentity: false,
});
} catch (error) {
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
session.error = errorMessage(error);
await this.closeBrowser(session);
return this.toView(session);
}
}
/** First clickable identity-item selector that matches at least one element. */
private async identityItemSelector(page: PwPage): Promise<string | null> {
for (const selector of SELECTORS.identityItem) {
try {
const count = await page.locator(selector).count();
if (count > 0) return selector;
} catch {
// try next candidate
}
}
return null;
}
/**
* Shared post-submit loop: waits for console cookies, watching for MFA
* step-up, identity selection, TOTP binding, and console error toasts.
*/
private async pollUntilResolved(
session: ActiveSession,
opts: { timeoutMs: number; fromMfa: boolean; detectIdentity: boolean }
): Promise<VolcLoginSessionView> {
const page = session.page;
if (!page) {
session.phase = "error";
session.error = "Browser session is gone — restart the login";
return this.toView(session);
}
const deadline = Date.now() + opts.timeoutMs;
let pollCount = 0;
let navigatedAfterLogin = false;
while (Date.now() < deadline) {
if (session.cancelled) {
session.phase = "cancelled";
await this.closeBrowser(session);
return this.toView(session);
}
if (Date.now() - session.createdAt > session.timeoutMs) {
session.phase = "timeout";
session.error = "Login timed out";
await this.closeBrowser(session);
return this.toView(session);
}
const cookies = await session.context.cookies();
const credentials: Record<string, string> = {};
for (const cookie of cookies as Array<{ name: string; domain: string; value: string }>) {
if (
REQUIRED_COOKIES.includes(cookie.name as (typeof REQUIRED_COOKIES)[number]) &&
isVolcengineCookieDomain(cookie.domain)
) {
credentials[cookie.name] = cookie.value;
}
}
if (REQUIRED_COOKIES.every((name) => credentials[name])) {
session.credentials = credentials;
session.phase = "success";
await this.closeBrowser(session);
return this.toView(session);
}
// TOTP binding modal (绑定MFA设备) — needs interactive Google
// Authenticator setup that cannot be driven headlessly.
const bindModal = await this.firstVisible(page, SELECTORS.mfaBindModal);
if (bindModal) {
session.phase = "fallback_manual";
session.error =
"The console requires binding an MFA device (Google Authenticator). Use manual browser login to complete the one-time setup.";
await this.closeBrowser(session);
return this.toView(session);
}
// MFA step-up modal (需要额外认证) — a second SMS code is required;
// hand control back to the user instead of timing out.
if (!opts.fromMfa) {
const mfaModal = await this.firstVisible(page, SELECTORS.mfaModal);
if (mfaModal) {
session.phase = "mfa_waiting";
session.error = null;
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
return this.toView(session);
}
} else if (pollCount >= 5) {
// Wrong MFA code → the modal stays up; after a grace window hand
// control back so the user can enter the latest code.
const mfaModal = await this.firstVisible(page, SELECTORS.mfaModal);
if (mfaModal) {
session.phase = "mfa_waiting";
session.error = "The MFA code was not accepted — enter the latest code";
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
return this.toView(session);
}
}
// Identity selection page (/auth/login/select_identity) — the phone
// maps to multiple accounts; scrape the options and let the user pick.
if (opts.detectIdentity && IDENTITY_URL_PATTERN.test(page.url())) {
const options = await this.scrapeIdentityOptions(page);
if (options.length > 0) {
session.phase = "identity_required";
session.error = null;
session.identityOptions = options;
return this.toView(session);
}
}
// Login redirected away from /auth/login but cookies are incomplete →
// the console app may need to run once to issue AccountID/userInfo.
// Give it the same landing page the manual flow uses.
if (!navigatedAfterLogin && pollCount >= 2 && !page.url().includes("/auth/login")) {
navigatedAfterLogin = true;
try {
await page.goto(ARK_CONSOLE_URL, {
waitUntil: "domcontentloaded",
timeout: 30_000,
});
} catch {
// navigation is best-effort; keep polling cookies
}
}
// Console error toast (e.g. wrong SMS code) → surface it early
const toast = await page
.locator('.arco-message-error, [class*="message-error"]')
.first()
.textContent({ timeout: 250 })
.catch(() => null);
if (toast && /验证码|密码|错误|失败|频繁/.test(toast)) {
session.phase = "error";
session.error = toast.trim().slice(0, 120);
await this.closeBrowser(session);
return this.toView(session);
}
await sleep(this.delays.pollIntervalMs);
pollCount++;
}
session.phase = "timeout";
session.error = await this.timeoutDiagnostics(session);
await this.closeBrowser(session);
return this.toView(session);
}
/** First identity-list selector that matches at least one element. */
private async identityListSelector(page: PwPage): Promise<string | null> {
for (const selector of SELECTORS.identityList) {
try {
const count = await page.locator(selector).count();
if (count > 0) return selector;
} catch {
// try next candidate
}
}
return null;
}
/** Scrape identity options from the select_identity page, in document order. */
private async scrapeIdentityOptions(
page: PwPage
): Promise<Array<{ index: number; label: string }>> {
const selector = await this.identityListSelector(page);
if (!selector) return [];
const items = page.locator(selector);
const count = await items.count();
const options: Array<{ index: number; label: string }> = [];
for (let i = 0; i < count; i++) {
const text =
(await items
.nth(i)
.textContent()
.catch(() => "")) || "";
const label = text.replace(/\s+/g, " ").trim();
if (label) options.push({ index: i, label: label.slice(0, 100) });
}
return options;
}
/**
* Build a diagnostic message for the cookie-poll timeout: page URL, cookies
* collected so far, and any blocking modal. Keeps future debugging cheap.
* When stuck on the identity-selection page, also dumps the page HTML to
* /tmp so a selector miss can be fixed from ground truth in one shot.
*/
private async timeoutDiagnostics(session: ActiveSession): Promise<string> {
const parts = ["Timed out waiting for the console session cookies"];
try {
if (session.page) {
parts.push(`url=${session.page.url()}`);
const cookies = (await session.context.cookies()) as Array<{
name: string;
domain: string;
}>;
const present = REQUIRED_COOKIES.filter((name) =>
cookies.some((c) => c.name === name && isVolcengineCookieDomain(c.domain))
);
parts.push(
`cookies=[${present.join(",") || "none of digest/AccountID/csrfToken/userInfo"}]`
);
const bindModal = await this.firstVisible(session.page, SELECTORS.mfaBindModal);
if (bindModal) parts.push("blocked by 绑定MFA设备 modal");
const mfaModal = await this.firstVisible(session.page, SELECTORS.mfaModal);
if (mfaModal) parts.push("blocked by 需要额外认证 modal");
const risk = await this.firstVisible(session.page, SELECTORS.riskControl);
if (risk) parts.push("blocked by risk-control slider");
if (IDENTITY_URL_PATTERN.test(session.page.url())) {
const dump = await this.dumpPageHtml(session);
if (dump) parts.push(`identityPageHtml=${dump}`);
}
}
} catch {
// diagnostics are best-effort
}
return parts.join(" · ");
}
/** Best-effort page HTML dump for debugging selector misses. */
private async dumpPageHtml(session: ActiveSession): Promise<string | null> {
try {
const { writeFile } = await import("fs/promises");
const path = `/tmp/omniroute-volc-select-identity-${session.sessionId.slice(0, 8)}.html`;
await writeFile(path, await session.page.content(), "utf8");
return path;
} catch {
return null;
}
}
async resendCode(sessionId: string): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
const fromMfa = session.phase === "mfa_waiting";
if (session.phase !== "waiting_code" && session.phase !== "captcha_required" && !fromMfa) {
return this.toView(session);
}
if (Date.now() < session.resendAvailableAt) {
return this.toView(session);
}
const page = session.page;
if (!page) {
session.phase = "error";
session.error = "Browser session is gone — restart the login";
return this.toView(session);
}
try {
// In the MFA step-up modal the button is 重发校验码; on the login form
// it counts down ("60s后重发" etc.) — try the fresh label first, then
// any 重发/重新获取 variant.
const resendSelectors = fromMfa
? [...SELECTORS.mfaResendButton]
: [
'button:has-text("获取验证码")',
'button:has-text("重发")',
'button:has-text("重新获取")',
'button:has-text("重新发送")',
];
const btn = await this.firstVisible(page, resendSelectors);
if (!btn) throw new SelectorMissError("resend button");
const disabled = await btn.isDisabled().catch(() => false);
if (disabled) {
session.error = "Resend is still cooling down on the login page";
return this.toView(session);
}
await btn.click();
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
await sleep(this.delays.sendCodeSettleMs);
if (fromMfa) {
// Stay in mfa_waiting — the modal persists until a valid code lands.
session.phase = "mfa_waiting";
session.error = null;
return this.toView(session);
}
const captchaInput = await this.firstVisible(page, SELECTORS.imageCaptchaInput);
if (captchaInput) {
session.captchaImage = await this.shot(page);
session.phase = "captcha_required";
} else {
session.captchaImage = null;
session.phase = "waiting_code";
}
session.error = null;
return this.toView(session);
} catch (error) {
session.phase = "error";
session.error = errorMessage(error);
await this.closeBrowser(session);
return this.toView(session);
}
}
async cancel(sessionId: string): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
if (isTerminal(session.phase)) return this.toView(session);
session.cancelled = true;
session.phase = "cancelled";
await this.closeBrowser(session);
return this.toView(session);
}
// ─── Internals ───────────────────────────────────────────────────────────
private toView(session: ActiveSession): VolcLoginSessionView {
const view: VolcLoginSessionView = {
sessionId: session.sessionId,
phase: session.phase,
phoneMasked: maskPhone(session.phone),
error: session.error,
captchaImage: session.phase === "captcha_required" ? session.captchaImage : null,
resendAvailableAt: session.resendAvailableAt,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
if (session.phase === "mfa_waiting") view.mfaRequired = true;
if (session.phase === "identity_required" && session.identityOptions) {
view.identityOptions = session.identityOptions;
}
if (session.phase === "success" && session.credentials) view.credentials = session.credentials;
if (session.binding !== undefined) view.binding = session.binding;
return view;
}
private async closeBrowser(session: ActiveSession): Promise<void> {
try {
await session.browser?.close?.();
} catch {
// browser may already be gone
} finally {
session.browser = null;
session.context = null;
session.page = null;
}
}
/** Screenshot for captcha rendering; null when capture fails. */
private async shot(page: PwPage): Promise<string | null> {
try {
const target = await this.firstVisible(page, SELECTORS.captchaShot);
const buffer: Buffer | null = target
? await target.screenshot({ type: "png" })
: await page.screenshot({ type: "png" });
return buffer ? `data:image/png;base64,${buffer.toString("base64")}` : null;
} catch {
return null;
}
}
private async firstVisible(
page: PwPage,
selectors: readonly string[]
): Promise<PwLocator | null> {
for (const selector of selectors) {
try {
const locator = page.locator(selector).first();
if (await locator.isVisible({ timeout: 2_000 })) return locator;
} catch {
// try next candidate
}
}
return null;
}
/** Close and drop sessions past their TTL; keep terminal ones briefly for status reads. */
private expireSessions(): void {
const now = Date.now();
for (const [id, session] of this.sessions) {
const age = now - session.createdAt;
const terminal = isTerminal(session.phase);
if (terminal && age > 10 * 60_000) {
this.sessions.delete(id);
} else if (!terminal && age > session.timeoutMs + 60_000) {
session.phase = "timeout";
session.error = "Session expired";
void this.closeBrowser(session);
this.sessions.delete(id);
}
}
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
class SelectorMissError extends Error {
constructor(element: string) {
super(`Login page element not found: ${element}`);
}
}
function isTerminal(phase: VolcLoginPhase): boolean {
return (
phase === "success" ||
phase === "error" ||
phase === "timeout" ||
phase === "cancelled" ||
phase === "fallback_manual"
);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
// ─── Singleton ──────────────────────────────────────────────────────────────
export const volcengineConsoleAutoLoginService = new VolcengineConsoleAutoLoginService();

View File

@@ -137,7 +137,7 @@ function convertGeminiContent(content) {
if (part.functionCall) {
toolCalls.push({
id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
id: part.functionCall.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
type: "function",
function: {
name: part.functionCall.name,

View File

@@ -1155,7 +1155,12 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
// Keyed by index, not insertion order — readers that need call order for
// parallel calls closed out of order should sort by this key rather than
// relying on Map iteration order.
// Responses→Claude uses this same shared map for Claude block lifecycle
// state. Preserve those fields when adding the completed-call summary;
// replacing the entry makes the arguments chunk look like a new unnamed
// tool and emits a duplicate empty content_block_start.
state.toolCalls.set(currentIndex, {
...state.toolCalls.get(currentIndex),
id: callId,
index: currentIndex,
type: "function",

View File

@@ -0,0 +1,34 @@
/**
* Cookie-domain matching for browser-driven credential capture.
*
* Every in-app / console login flow harvests cookies out of a Playwright
* context and persists them as operator credentials, so "is this cookie from
* the site I sent the browser to?" is an authorization decision. A substring
* test is not one: `domain.includes("example.com")` also accepts
* `example.com.attacker.tld` and `notexample.com`, which lets a look-alike host
* hand us cookies we then store as the operator's real credentials
* (CodeQL js/incomplete-url-substring-sanitization).
*
* A cookie domain is matched by exact host or dot-boundary suffix — nothing
* else. Leading dots (the RFC 6265 "domain-matches any subdomain" spelling) and
* case are normalized away on both sides.
*/
export function matchesCookieDomain(
cookieDomain: string | undefined,
expectedDomain: string | undefined
): boolean {
const expected = normalizeCookieDomain(expectedDomain);
if (!expected) return false;
const actual = normalizeCookieDomain(cookieDomain);
if (!actual) return false;
return actual === expected || actual.endsWith(`.${expected}`);
}
function normalizeCookieDomain(domain: string | undefined): string {
return String(domain || "")
.trim()
.replace(/^\.+/, "")
.toLowerCase();
}

View File

@@ -351,10 +351,7 @@ function sanitizeTransportError(
typeof source.code === "string" && /^[A-Z0-9_:-]{1,64}$/.test(source.code)
? source.code
: fallbackCode;
if (
typeof source.errorCode === "string" &&
/^[a-zA-Z0-9_:-]{1,64}$/.test(source.errorCode)
) {
if (typeof source.errorCode === "string" && /^[a-zA-Z0-9_:-]{1,64}$/.test(source.errorCode)) {
sanitized.errorCode = source.errorCode;
}
if (typeof source.statusCode === "number" && Number.isFinite(source.statusCode)) {
@@ -547,10 +544,7 @@ export function resolveProxyForRequest(targetUrl) {
* Dependency-internal TimeoutError/AbortError values are transport failures and
* retain the normal safe-method fallback behavior.
*/
function isCallerAbort(
_error: unknown,
signal: AbortSignal | null | undefined
): boolean {
function isCallerAbort(_error: unknown, signal: AbortSignal | null | undefined): boolean {
return signal?.aborted === true;
}
@@ -573,8 +567,7 @@ export async function runWithProxyContext(
// sentinel must remain direct without being mistaken for a proxy config.
const currentContext = proxyContext.getStore();
const inheritsDirect = currentContext === DIRECT_PROXY_CONTEXT && !proxyConfig;
const effectiveProxyConfig =
proxyConfig || (inheritsDirect ? null : currentContext) || null;
const effectiveProxyConfig = proxyConfig || (inheritsDirect ? null : currentContext) || null;
const contextValue = inheritsDirect ? DIRECT_PROXY_CONTEXT : effectiveProxyConfig;
const resolvedProxyUrl = effectiveProxyConfig ? proxyConfigToUrl(effectiveProxyConfig) : null;
@@ -711,6 +704,11 @@ export async function runWithProxyContext(
});
}
/** Run a request with an explicit direct-egress sentinel, bypassing proxy env/context lookup. */
export function runWithDirectFetchContext<T>(fn: () => T): T {
return proxyContext.run(DIRECT_PROXY_CONTEXT, fn);
}
/**
* Like {@link runWithProxyContext}, but if the assigned proxy is unreachable or fails
* its pre-checks the request can degrade to a DIRECT connection instead of throwing.
@@ -732,6 +730,12 @@ async function patchedFetch(
options: FetchWithDispatcherOptions = {},
deps: ProxyFetchDeps = {}
) {
// Explicit direct contexts must win even when a caller supplied a stale
// dispatcher. Native fetch preserves direct streaming semantics.
if (proxyContext.getStore() === DIRECT_PROXY_CONTEXT) {
return originalFetch(input, options);
}
if (options?.dispatcher) {
// When a dispatcher is present, we MUST use the undici library fetch
// to ensure version compatibility. Node 22 built-in fetch (undici v6)
@@ -1133,9 +1137,7 @@ async function patchedFetch(
);
const sanitized = sanitizeTransportError(
error,
originalMsg
? `Proxy request failed: ${originalMsg}`
: "Proxy request failed",
originalMsg ? `Proxy request failed: ${originalMsg}` : "Proxy request failed",
"PROXY_REQUEST_FAILED"
);
console.error(
@@ -1190,8 +1192,7 @@ export async function runWithTlsTracking<T>(
providerOrIdentityOrFn: string | null | undefined | TlsTrackingIdentity | (() => T),
maybeFn?: () => T
): Promise<{ result: Awaited<T>; tlsFingerprintUsed: boolean }> {
const legacyFn =
typeof providerOrIdentityOrFn === "function" ? providerOrIdentityOrFn : maybeFn;
const legacyFn = typeof providerOrIdentityOrFn === "function" ? providerOrIdentityOrFn : maybeFn;
if (typeof legacyFn !== "function") {
throw new TypeError("runWithTlsTracking requires a callback function");
}
@@ -1201,8 +1202,7 @@ export async function runWithTlsTracking<T>(
typeof providerOrIdentityOrFn !== "function"
? providerOrIdentityOrFn
: {
provider:
typeof providerOrIdentityOrFn === "string" ? providerOrIdentityOrFn : undefined,
provider: typeof providerOrIdentityOrFn === "string" ? providerOrIdentityOrFn : undefined,
};
const store: TlsFingerprintStore = {
used: false,
@@ -1214,10 +1214,7 @@ export async function runWithTlsTracking<T>(
}
/** Check whether TLS fingerprint transport is enabled for this route identity. */
export function isTlsFingerprintActive(
provider?: string | null,
proxied = false
): boolean {
export function isTlsFingerprintActive(provider?: string | null, proxied = false): boolean {
return (
isTlsFingerprintEnabled() &&
activeTlsClient.available &&

11
package-lock.json generated
View File

@@ -25588,6 +25588,17 @@
"node": ">= 14"
}
},
"node_modules/libxmljs2/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmmirror.com/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",

View File

@@ -0,0 +1,36 @@
/**
* Decide whether the Next.js build should alias `better-sqlite3` to the
* build-time stub (src/lib/db/better-sqlite3.stub.js).
*
* History (#11343): the alias was UNCONDITIONAL, added to keep the bundler from
* tracing the native addon into a Next.js build worker, whose thread teardown
* can abort with SIGABRT (assertion in node::RemoveEnvironmentCleanupHook) and
* leave the build without standalone output (#10060).
*
* The premise recorded next to that alias — "runtime still uses the real
* package via serverExternalPackages" — does not hold. A Turbopack
* `resolveAlias` rewrites the request BEFORE the externals check runs, so
* `better-sqlite3` becomes a relative path, no longer matches the
* `serverExternalPackages` entry, and the stub is baked into the bundle. Every
* artifact built from that config answered HTTP 500 on every route: the stub's
* default export is not a constructor, the sync driver chain fell through to
* `node:sqlite` and then sql.js, and the instrumentation hook aborted at boot.
*
* This is the same failure shape as #6344 (the @/mitm/manager stub shipping to
* every npm/Electron/VPS artifact), so it gets the same treatment: the alias is
* opt-in, and a default build gets the real, externalized native package.
*
* Set OMNIROUTE_BETTER_SQLITE3_STUB=1 ONLY on a build host that actually hits
* the SIGABRT worker teardown, and never for an artifact that will be run —
* the resulting bundle cannot open a database.
*/
export function shouldStubBetterSqlite3(env = process.env) {
return env.OMNIROUTE_BETTER_SQLITE3_STUB === "1";
}
/** Turbopack resolveAlias fragment for `better-sqlite3`, derived from the env. */
export function betterSqlite3AliasFor(env = process.env) {
return shouldStubBetterSqlite3(env)
? { "better-sqlite3": "./src/lib/db/better-sqlite3.stub.js" }
: {};
}

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env node
// scripts/check/check-changelog-integrity.mjs
//
// Anti "CHANGELOG-eat" gate: no bullet line that exists in the BASE branch's
// CHANGELOG.md may disappear in the merge result. The chronic failure mode is
// Anti "CHANGELOG-eat" gate: no bullet-line occurrence that exists in the BASE
// branch's CHANGELOG.md may disappear in the merge result. The chronic failure mode is
// git's merge auto-resolve silently dropping sibling bullets (or whole version
// sections) when two branches touch adjacent CHANGELOG lines — incident
// 2026-07-05: PR #6193's merge ate 212 lines (the entire [3.8.45] + [3.8.44]
@@ -16,47 +16,221 @@
// quality.yml runs it blocking for own-origin PRs and report-only for forks.
// The release captain's reconciliation rewrites the CHANGELOG legitimately,
// but that happens on the release PR (PR → main, ci.yml), which does not run
// this gate. Escape hatch for intentional removals (e.g. reverting a reverted
// feature's bullet): ALLOW_CHANGELOG_REMOVALS=1 turns failures into a report.
// this gate. There is no runtime escape hatch: every unexplained removal fails.
// Intentional rewrites require a reviewed record in
// config/release/changelog-reconciliations.json. Each record binds the complete base
// and result files by SHA-256 and lists the exact removed/added bullet-line multiset;
// repeated strings encode repeated occurrences. The gate deliberately protects
// bullet lines, not standalone headings, dates, or prose outside a bullet.
//
// Usage:
// node scripts/check/check-changelog-integrity.mjs
// env GITHUB_BASE_REF PR base branch (CI); local fallback: current release/*
// env CHANGELOG_BASE_REF explicit ref override (e.g. origin/release/v3.8.45)
// env ALLOW_CHANGELOG_REMOVALS=1 report-only (never fails)
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const CHANGELOG = "CHANGELOG.md";
const RECONCILIATIONS = "config/release/changelog-reconciliations.json";
const FRAGMENTS_DIR = "changelog.d";
const FRAGMENT_SECTIONS = ["features", "fixes", "maintenance"];
const FRAGMENT_SKIP = new Set(["README.md", ".gitkeep"]);
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
const RECONCILIATION_KEYS = new Set([
"id",
"reason",
"baseChangelogSha256",
"resultChangelogSha256",
"removedBullets",
"addedBullets",
]);
/** Extract the set of bullet lines (trimmed) from a CHANGELOG text. */
export function extractBullets(text) {
const bullets = new Set();
return new Set(extractBulletOccurrences(text));
}
/** Extract every bullet-line occurrence, preserving order and duplicates. */
export function extractBulletOccurrences(text) {
const bullets = [];
for (const raw of String(text || "").split("\n")) {
const line = raw.trim();
if (line.startsWith("- ") && line.length > 4) bullets.add(line);
if (line.startsWith("- ") && line.length > 4) bullets.push(line);
}
return bullets;
}
function findMissingOccurrences(sourceText, targetText) {
const available = new Map();
for (const bullet of extractBulletOccurrences(targetText)) {
available.set(bullet, (available.get(bullet) || 0) + 1);
}
const missing = [];
for (const bullet of extractBulletOccurrences(sourceText)) {
const count = available.get(bullet) || 0;
if (count > 0) available.set(bullet, count - 1);
else missing.push(bullet);
}
return missing;
}
/**
* Bullet lines present in the base CHANGELOG but absent from the head
* CHANGELOG — the "eaten" set. Pure so it has a unit test.
* Bullet-line occurrences present in the base CHANGELOG but absent from the head
* CHANGELOG — including one lost copy of a repeated line. Pure so it has a unit test.
*/
export function findLostBullets(baseText, headText) {
const headBullets = extractBullets(headText);
const lost = [];
for (const b of extractBullets(baseText)) {
if (!headBullets.has(b)) lost.push(b);
return findMissingOccurrences(baseText, headText);
}
/** Bullet-line occurrences present only in the result CHANGELOG. */
export function findAddedBullets(baseText, headText) {
return findMissingOccurrences(headText, baseText);
}
/** Stable digest tying a reconciliation record to the complete file, not just its bullets. */
export function changelogSha256(text) {
return createHash("sha256")
.update(String(text || ""), "utf8")
.digest("hex");
}
function validateBulletList(value, path, { allowEmpty }) {
if (!Array.isArray(value)) return [`${path} must be an array`];
const errors = [];
if (!allowEmpty && value.length === 0) errors.push(`${path} must not be empty`);
for (let index = 0; index < value.length; index++) {
const bullet = value[index];
if (
typeof bullet !== "string" ||
bullet !== bullet.trim() ||
!bullet.startsWith("- ") ||
bullet.length <= 4
) {
errors.push(`${path}[${index}] must be one exact, trimmed markdown bullet`);
}
}
return lost;
return errors;
}
/** Validate the durable reconciliation ledger without trusting any of its claims. */
export function validateReconciliationLedger(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return ["ledger must be a JSON object"];
}
const errors = [];
const topLevelKeys = Object.keys(value);
for (const key of topLevelKeys) {
if (key !== "schemaVersion" && key !== "reconciliations") {
errors.push(`unknown top-level field: ${key}`);
}
}
if (value.schemaVersion !== 1) errors.push("schemaVersion must be 1");
if (!Array.isArray(value.reconciliations)) {
errors.push("reconciliations must be an array");
return errors;
}
const ids = new Set();
const filePairs = new Set();
for (let index = 0; index < value.reconciliations.length; index++) {
const record = value.reconciliations[index];
const path = `reconciliations[${index}]`;
if (!record || typeof record !== "object" || Array.isArray(record)) {
errors.push(`${path} must be an object`);
continue;
}
for (const key of Object.keys(record)) {
if (!RECONCILIATION_KEYS.has(key)) errors.push(`${path} has unknown field: ${key}`);
}
if (typeof record.id !== "string" || !/^[a-z0-9][a-z0-9._-]{2,79}$/.test(record.id)) {
errors.push(`${path}.id must be a 3-80 character lowercase slug`);
} else if (ids.has(record.id)) {
errors.push(`${path}.id duplicates "${record.id}"`);
} else {
ids.add(record.id);
}
if (typeof record.reason !== "string" || record.reason.trim().length < 20) {
errors.push(`${path}.reason must explain the reconciliation in at least 20 characters`);
}
if (!SHA256_PATTERN.test(record.baseChangelogSha256 || "")) {
errors.push(`${path}.baseChangelogSha256 must be a lowercase SHA-256 digest`);
}
if (!SHA256_PATTERN.test(record.resultChangelogSha256 || "")) {
errors.push(`${path}.resultChangelogSha256 must be a lowercase SHA-256 digest`);
}
if (
SHA256_PATTERN.test(record.baseChangelogSha256 || "") &&
record.baseChangelogSha256 === record.resultChangelogSha256
) {
errors.push(`${path} must describe a changed CHANGELOG.md`);
}
errors.push(
...validateBulletList(record.removedBullets, `${path}.removedBullets`, {
allowEmpty: false,
}),
...validateBulletList(record.addedBullets, `${path}.addedBullets`, { allowEmpty: true })
);
if (Array.isArray(record.removedBullets) && Array.isArray(record.addedBullets)) {
const removed = new Set(record.removedBullets);
for (const bullet of record.addedBullets) {
if (removed.has(bullet)) errors.push(`${path} lists the same bullet as removed and added`);
}
}
const pair = `${record.baseChangelogSha256}:${record.resultChangelogSha256}`;
if (filePairs.has(pair)) errors.push(`${path} duplicates an earlier base/result digest pair`);
filePairs.add(pair);
}
return errors;
}
function sameStringMultiset(left, right) {
if (left.length !== right.length) return false;
const remaining = new Map();
for (const item of right) remaining.set(item, (remaining.get(item) || 0) + 1);
for (const item of left) {
const count = remaining.get(item) || 0;
if (count === 0) return false;
remaining.set(item, count - 1);
}
return true;
}
/** Find the single record that exactly explains this complete base → result transition. */
export function findLedgeredReconciliation(baseText, headText, ledger) {
const baseChangelogSha256 = changelogSha256(baseText);
const resultChangelogSha256 = changelogSha256(headText);
const removedBullets = findLostBullets(baseText, headText);
const addedBullets = findAddedBullets(baseText, headText);
return ledger.reconciliations.find(
(record) =>
record.baseChangelogSha256 === baseChangelogSha256 &&
record.resultChangelogSha256 === resultChangelogSha256 &&
sameStringMultiset(record.removedBullets, removedBullets) &&
sameStringMultiset(record.addedBullets, addedBullets)
);
}
function readReconciliationLedger(root = ROOT) {
const path = join(root, RECONCILIATIONS);
if (!existsSync(path)) {
return { ledger: null, errors: [`${RECONCILIATIONS} is missing`] };
}
let ledger;
try {
ledger = JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
return {
ledger: null,
errors: [`${RECONCILIATIONS} is not valid JSON: ${error.message}`],
};
}
return { ledger, errors: validateReconciliationLedger(ledger) };
}
/**
@@ -111,7 +285,13 @@ function resolveBaseRef() {
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
// Local fallback: the highest release/v* on origin (the active development base).
try {
const branches = git(["branch", "-r", "--list", "origin/release/v*", "--format=%(refname:short)"])
const branches = git([
"branch",
"-r",
"--list",
"origin/release/v*",
"--format=%(refname:short)",
])
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
@@ -123,16 +303,33 @@ function resolveBaseRef() {
}
function main() {
if (Object.hasOwn(process.env, "ALLOW_CHANGELOG_REMOVALS")) {
console.error(
"[changelog-integrity] ALLOW_CHANGELOG_REMOVALS was removed; delete it from the environment and record intentional transformations in config/release/changelog-reconciliations.json."
);
return 1;
}
// Fragment well-formedness first (changelog.d/ — the fragments pattern makes the
// eat-guard below structurally unnecessary for PRs that stop editing CHANGELOG.md).
const invalidFragments = findInvalidFragments();
if (invalidFragments.length > 0) {
console.error(`[changelog-integrity] ${invalidFragments.length} invalid changelog fragment(s):`);
console.error(
`[changelog-integrity] ${invalidFragments.length} invalid changelog fragment(s):`
);
for (const { file, error } of invalidFragments) console.error(`${file}: ${error}`);
console.error("\nSee changelog.d/README.md for the fragment convention.");
return 1;
}
const { ledger, errors: ledgerErrors } = readReconciliationLedger();
if (ledgerErrors.length > 0) {
console.error(`[changelog-integrity] invalid reconciliation ledger (${ledgerErrors.length}):`);
for (const error of ledgerErrors) console.error(`${error}`);
return 1;
}
const hasExplicitBaseRef = Boolean(process.env.CHANGELOG_BASE_REF || process.env.GITHUB_BASE_REF);
const baseRef = resolveBaseRef();
if (!baseRef) {
console.log("[changelog-integrity] SKIP — could not resolve a base ref (offline/fresh clone).");
@@ -143,6 +340,12 @@ function main() {
try {
baseText = git(["show", `${baseRef}:${CHANGELOG}`]);
} catch {
if (hasExplicitBaseRef) {
console.error(
`[changelog-integrity] FAIL — ${CHANGELOG} not readable at explicit base ${baseRef}.`
);
return 1;
}
console.log(`[changelog-integrity] SKIP — ${CHANGELOG} not readable at ${baseRef}.`);
return 0;
}
@@ -154,21 +357,30 @@ function main() {
return 0;
}
const reconciliation = findLedgeredReconciliation(baseText, headText, ledger);
if (reconciliation) {
console.log(
`[changelog-integrity] OK — ${lost.length} removed base bullet(s) covered by ledgered reconciliation "${reconciliation.id}" vs ${baseRef}.`
);
return 0;
}
console.error(
`[changelog-integrity] ${lost.length} bullet(s) present in ${baseRef} are MISSING from this tree's ${CHANGELOG}:`
);
for (const b of lost.slice(0, 15)) console.error(`${b.slice(0, 160)}`);
if (lost.length > 15) console.error(` … and ${lost.length - 15} more`);
const added = findAddedBullets(baseText, headText);
console.error(
"\nThis is the CHANGELOG-eat pattern (merge auto-resolve dropping sibling bullets)." +
"\nFix: restore the base CHANGELOG (`git checkout <base> -- CHANGELOG.md`), re-insert ONLY" +
"\nyour own bullet, and prove the net diff is additive. Intentional removals (rare):" +
"\nre-run with ALLOW_CHANGELOG_REMOVALS=1 and justify in the PR body."
"\nyour own bullet, and prove the net diff is additive." +
`\nIntentional reconciliation: add one exact, reviewed record to ${RECONCILIATIONS}.` +
`\n baseChangelogSha256: ${changelogSha256(baseText)}` +
`\n resultChangelogSha256: ${changelogSha256(headText)}` +
`\n removedBullets: ${lost.length}; addedBullets: ${added.length}` +
"\nThere is no environment-variable bypass."
);
if (process.env.ALLOW_CHANGELOG_REMOVALS === "1") {
console.error("[changelog-integrity] ALLOW_CHANGELOG_REMOVALS=1 — reporting only, not failing.");
return 0;
}
return 1;
}

View File

@@ -69,6 +69,7 @@ const files = walk(COMMANDS_DIR);
const usedKeys = collectTKeys(files);
const en = loadJson(join(LOCALES_DIR, "en.json"));
const ptBR = loadJson(join(LOCALES_DIR, "pt-BR.json"));
const zhLocales = ["zh-CN", "zh-TW"].map((n) => [n, loadJson(join(LOCALES_DIR, `${n}.json`))]);
const enKeys = flattenKeys(en);
let errors = 0;
@@ -95,6 +96,19 @@ if (missingTopLevel.length > 0) {
console.log(`[cli-i18n] ✓ pt-BR.json has all ${enTopLevel.length} top-level sections`);
}
// Check 3: zh-CN and zh-TW have full key parity with en.json
for (const [name, cat] of zhLocales) {
const catKeys = flattenKeys(cat);
const missingKeys = [...enKeys].filter((k) => !catKeys.has(k));
if (missingKeys.length > 0) {
console.error(`[cli-i18n] Keys in en.json missing from ${name}.json:`);
for (const k of missingKeys) console.error(`${k}`);
errors += missingKeys.length;
} else {
console.log(`[cli-i18n] ✓ ${name}.json has full parity (${enKeys.size} keys)`);
}
}
if (errors > 0) {
console.error(`[cli-i18n] FAIL — ${errors} error(s) found`);
process.exit(1);

View File

@@ -93,6 +93,14 @@ const ENV_VAR_ALLOWLIST = new Set([
"DATA_DIR",
"REQUIRE_API_KEY",
"OMNIROUTE_BUILD_PROFILE", // build-time only
// Docker builder-stage knobs. Both are documented in docs/guides/DOCKER_GUIDE.md
// because they are the two levers for a memory-constrained build host, but
// neither is read through process.env in this repo: OMNIROUTE_BUILD_WORKERS is
// a Dockerfile ARG that only feeds CIRCLE_NODE_TOTAL, and CIRCLE_NODE_TOTAL is
// read by Next itself (node_modules) to size the page-data worker pool. Pinned
// by tests/unit/docker-build-memory-budget.test.ts.
"OMNIROUTE_BUILD_WORKERS",
"CIRCLE_NODE_TOTAL",
"OMNIROUTE_BUILD_SHA",
"OMNIROUTE_URL", // used by ad-hoc tooling, validated elsewhere
"OMNIROUTE_KEY", // ditto

View File

@@ -18,11 +18,13 @@
* gate on it.
*/
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import { ensureSvgAccessibility, validateSvgFile } from "./validate-svg.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..", "..");
const srcDir = resolve(repoRoot, "docs", "diagrams");
@@ -75,6 +77,31 @@ for (const src of sources) {
if (result.status !== 0) {
console.error(` [FAIL] ${src} (exit ${result.status})`);
failures += 1;
continue;
}
const source = readFileSync(input, "utf8");
const title = source.match(/^%%\s*svg-title:\s*(.+)$/im)?.[1]?.trim();
const description = source.match(/^%%\s*svg-description:\s*(.+)$/im)?.[1]?.trim();
if (title && description) {
const svg = readFileSync(output, "utf8");
writeFileSync(
output,
ensureSvgAccessibility(svg, {
title,
description,
idBase: src.replace(/\.mmd$/, ""),
})
);
} else if (title || description) {
console.warn(` [WARN] ${src}: svg-title and svg-description must be provided together`);
}
const validation = validateSvgFile(output);
for (const warning of validation.warnings) console.warn(` [WARN] ${src}: ${warning}`);
if (validation.errors.length > 0) {
for (const error of validation.errors) console.error(` [FAIL] ${src}: ${error}`);
failures += 1;
}
}

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { XMLParser, XMLValidator } from "fast-xml-parser";
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
preserveOrder: true,
});
function collectIds(value, ids) {
if (Array.isArray(value)) {
for (const entry of value) collectIds(entry, ids);
return;
}
if (!value || typeof value !== "object") return;
const attributes = value[":@"];
if (attributes && typeof attributes === "object" && typeof attributes["@_id"] === "string") {
ids.push(attributes["@_id"]);
}
for (const entry of Object.values(value)) collectIds(entry, ids);
}
function escapeXml(value) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function replaceRootAttribute(openingTag, name, value) {
const attribute = new RegExp(`\\s${name}=(?:"[^"]*"|'[^']*')`, "i");
const withoutExisting = openingTag.replace(attribute, "");
return withoutExisting.replace(/>$/, ` ${name}="${escapeXml(value)}">`);
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function ensureSvgAccessibility(svg, { title, description, idBase }) {
const xmlResult = XMLValidator.validate(svg);
if (xmlResult !== true) throw new Error(`invalid XML: ${xmlResult.err.msg}`);
const titleId = `${idBase}-title`;
const descriptionId = `${idBase}-desc`;
const priorTitle = new RegExp(
`<title\\b[^>]*\\bid=["']${escapeRegExp(titleId)}["'][^>]*>[\\s\\S]*?<\\/title>`,
"i"
);
const priorDescription = new RegExp(
`<desc\\b[^>]*\\bid=["']${escapeRegExp(descriptionId)}["'][^>]*>[\\s\\S]*?<\\/desc>`,
"i"
);
const withoutPriorAccessibleName = svg.replace(priorTitle, "").replace(priorDescription, "");
const match = withoutPriorAccessibleName.match(/<svg\b[^>]*>/i);
if (!match) throw new Error("document root is not an SVG element");
let openingTag = replaceRootAttribute(match[0], "role", "img");
openingTag = replaceRootAttribute(openingTag, "aria-labelledby", `${titleId} ${descriptionId}`);
const accessibleName =
`<title id="${escapeXml(titleId)}">${escapeXml(title)}</title>` +
`<desc id="${escapeXml(descriptionId)}">${escapeXml(description)}</desc>`;
return withoutPriorAccessibleName.replace(match[0], `${openingTag}${accessibleName}`);
}
export function validateSvgText(svg) {
const xmlResult = XMLValidator.validate(svg);
if (xmlResult !== true) {
return { errors: [`invalid XML: ${xmlResult.err.msg}`], warnings: [] };
}
const document = parser.parse(svg);
const ids = [];
collectIds(document, ids);
const duplicates = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))].sort();
const openingTag = svg.match(/<svg\b[^>]*>/i)?.[0] ?? "";
const warnings = [];
if (!/\srole=["']img["']/i.test(openingTag)) warnings.push('root role is not "img"');
const hasAccessibleName =
/\saria-(?:label|labelledby)=["'][^"']+["']/i.test(openingTag) ||
/<title\b[^>]*>[^<]+<\/title>/i.test(svg);
if (!hasAccessibleName) {
warnings.push("missing accessible name (title, aria-label, or aria-labelledby)");
}
if (!/<desc\b[^>]*>[^<]+<\/desc>/i.test(svg)) warnings.push("missing desc element");
if (/<foreignObject\b/i.test(svg)) warnings.push("foreignObject present (Mermaid output)");
if (/\s(?:width|height)=["'][^"']+["']/i.test(openingTag)) {
warnings.push("fixed root width or height present (Mermaid output)");
}
return {
errors: duplicates.length > 0 ? [`duplicate IDs: ${duplicates.join(", ")}`] : [],
warnings,
};
}
export function validateSvgFile(file) {
return validateSvgText(readFileSync(file, "utf8"));
}
function isDirectExecution() {
if (!process.argv[1]) return false;
return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
}
if (isDirectExecution()) {
const args = process.argv.slice(2);
let fixAccessibility = false;
let title;
let description;
const files = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--fix-a11y") {
fixAccessibility = true;
} else if (arg === "--title") {
title = args[++index];
} else if (arg === "--description") {
description = args[++index];
} else {
files.push(arg);
}
}
if (files.length === 0) {
console.error(
"Usage: node scripts/docs/validate-svg.mjs [--fix-a11y --title TEXT --description TEXT] <file.svg> [...]"
);
process.exit(2);
}
if (fixAccessibility && (!title || !description)) {
console.error("--fix-a11y requires both --title and --description");
process.exit(2);
}
let failures = 0;
for (const file of files) {
if (fixAccessibility) {
const idBase = path.basename(file, path.extname(file));
const updated = ensureSvgAccessibility(readFileSync(file, "utf8"), {
title,
description,
idBase,
});
writeFileSync(file, updated);
}
const result = validateSvgFile(file);
for (const warning of result.warnings) console.warn(`WARN ${file}: ${warning}`);
if (result.errors.length === 0) {
console.log(`PASS ${file}`);
continue;
}
failures += 1;
for (const error of result.errors) console.error(`FAIL ${file}: ${error}`);
}
if (failures > 0) process.exit(1);
}

View File

@@ -1,24 +1,80 @@
/**
* Video Bridge benchmarks (VB-FU-07 sampler overhead + VB-FU-09 contact sheet A/B).
* Video Bridge benchmarks (VB-FU-03 dedup comparator, VB-FU-07 sampler overhead,
* and VB-FU-09 contact sheet A/B).
*
* Run: node --import tsx/esm scripts/perf/video-bridge-bench.ts
*
* 1. Sampler: measures the pure timestamp-selection cost of uniform vs
* 1. Dedup: measures bounded CPU and process-memory observations for the
* production 16x16 grayscale comparator over the hard 16-frame candidate cap.
* 2. Sampler: measures the pure timestamp-selection cost of uniform vs
* scene_aware vs segment_aware for growing scene-candidate counts. The
* ffmpeg scene-detection pass is shared by both aware policies and is
* I/O-bound, so the incremental policy cost is exactly this selection step.
* 2. Contact sheet: composes synthetic JPEG frames into the timestamped grid
* and compares payload bytes + model calls against individual frames.
* 3. Contact sheet: composes synthetic JPEG frames into the visually timestamped
* grid and compares payload bytes + structural call counts. This microbenchmark
* does not measure real-model tokens, latency, or quality; use
* video-bridge-contact-sheet-eval.ts before considering promotion.
*/
import { performance } from "node:perf_hooks";
import { buildVideoContactSheet } from "../../src/lib/guardrails/videoBridgeContactSheet";
import {
compareVideoFramesByGrayscale,
VIDEO_DEDUP_POLICY_VERSION,
VIDEO_DEDUP_THRESHOLD,
} from "../../src/lib/guardrails/videoBridgeHelpers";
import {
calculateSamplingDecision,
type VideoSamplingPolicy,
} from "../../src/lib/guardrails/videoBridgeRuntime";
const SAMPLER_ITERATIONS = 2_000;
const DEDUP_FRAME_CAP = 16;
const DEDUP_ITERATIONS = 10;
function mebibytes(bytes: number): string {
return (bytes / (1024 * 1024)).toFixed(2);
}
async function benchDedupComparator(): Promise<void> {
const frames = await Promise.all(
Array.from({ length: DEDUP_FRAME_CAP }, async (_unused, index) => ({
dataUri: await syntheticJpegFrame(index, 1024, 576),
timestampSeconds: index,
}))
);
await compareVideoFramesByGrayscale(frames[0], frames[1]);
const memoryBefore = process.memoryUsage();
const maxRssBefore = process.resourceUsage().maxRSS * 1024;
const cpuBefore = process.cpuUsage();
const wallBefore = performance.now();
let comparisons = 0;
for (let iteration = 0; iteration < DEDUP_ITERATIONS; iteration++) {
for (let index = 1; index < frames.length; index++) {
await compareVideoFramesByGrayscale(frames[index - 1], frames[index]);
comparisons += 1;
}
}
const wallMs = performance.now() - wallBefore;
const cpu = process.cpuUsage(cpuBefore);
const memoryAfter = process.memoryUsage();
const maxRssAfter = process.resourceUsage().maxRSS * 1024;
const cpuMs = (cpu.user + cpu.system) / 1000;
console.log("== Visual dedup comparator (synthetic 1024x576 JPEG, bounded) ==");
console.log(
`policy=${VIDEO_DEDUP_POLICY_VERSION} threshold=${VIDEO_DEDUP_THRESHOLD} frames=${DEDUP_FRAME_CAP} iterations=${DEDUP_ITERATIONS} comparisons=${comparisons}`
);
console.log(
`wall_ms=${wallMs.toFixed(1)} cpu_ms=${cpuMs.toFixed(1)} cpu_ms/comparison=${(cpuMs / comparisons).toFixed(3)}`
);
console.log(
`rss_delta_MiB=${mebibytes(memoryAfter.rss - memoryBefore.rss)} heap_delta_MiB=${mebibytes(memoryAfter.heapUsed - memoryBefore.heapUsed)} max_rss_delta_MiB=${mebibytes(Math.max(0, maxRssAfter - maxRssBefore))}`
);
console.log(
"Scope: comparator decode/resize/delta cost only; this does not measure caption-model quality."
);
}
function benchSampler(): void {
console.log("== Sampler timestamp-selection cost (pure, per call) ==");
@@ -47,12 +103,12 @@ function benchSampler(): void {
}
}
async function syntheticJpegFrame(index: number): Promise<string> {
async function syntheticJpegFrame(index: number, width = 512, height = 288): Promise<string> {
const { default: sharp } = await import("sharp");
const buffer = await sharp({
create: {
width: 512,
height: 288,
width,
height,
channels: 3,
background: { r: (index * 37) % 255, g: (index * 91) % 255, b: (index * 53) % 255 },
},
@@ -64,6 +120,9 @@ async function syntheticJpegFrame(index: number): Promise<string> {
async function benchContactSheet(): Promise<void> {
console.log("\n== Contact sheet vs individual frames (synthetic 512x288 JPEG) ==");
console.log(
"STRUCTURAL ONLY: real-model tokens/latency/quality are unmeasured; promotion remains HOLD."
);
console.log("frames | sheet_ms sheet_KiB individual_KiB model_calls(sheet/individual)");
for (const frameCount of [1, 4, 8, 16]) {
const frames = await Promise.all(
@@ -86,5 +145,7 @@ async function benchContactSheet(): Promise<void> {
}
}
await benchDedupComparator();
console.log("");
benchSampler();
await benchContactSheet();

View File

@@ -0,0 +1,578 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import {
buildVideoContactSheet,
type ContactSheetFrame,
} from "../../src/lib/guardrails/videoBridgeContactSheet";
export type VideoContactSheetEvalConfigurationState = "configured-not-executed" | "not-configured";
export interface VideoContactSheetEvalHoldReportInput {
caseCount: number;
configurationState: VideoContactSheetEvalConfigurationState;
missingConfiguration?: string[];
}
export interface VideoContactSheetEvalHoldReport {
caseCount: number;
execution: {
realModel: false;
state: VideoContactSheetEvalConfigurationState;
};
kind: "video-contact-sheet-ab-eval";
missingConfiguration: string[];
promotion: {
reasons: ["REAL_MODEL_CONFIGURATION_MISSING" | "REAL_MODEL_EVAL_NOT_EXECUTED"];
status: "HOLD";
};
results: [];
schemaVersion: 1;
summary: null;
}
export interface VideoContactSheetEvalThresholds {
minLatencyReductionRatio: number;
minQualityRetention: number;
minQualityScore: number;
minTokenReductionRatio: number;
}
export interface VideoContactSheetEvalAggregate {
latencyMs: number;
qualityScore: number;
totalTokens: number | null;
}
export type VideoContactSheetPromotionReason =
| "LATENCY_REDUCTION_BELOW_THRESHOLD"
| "QUALITY_RETENTION_BELOW_THRESHOLD"
| "QUALITY_SCORE_BELOW_THRESHOLD"
| "TOKEN_REDUCTION_BELOW_THRESHOLD"
| "TOKEN_USAGE_UNAVAILABLE";
export interface VideoContactSheetPromotionDecision {
metrics: {
latencyReductionRatio: number;
qualityRetention: number;
tokenReductionRatio: number | null;
};
reasons: VideoContactSheetPromotionReason[];
status: "ELIGIBLE" | "HOLD";
}
const MAX_EVAL_FRAME_BASE64_CHARS = 5_592_408;
const evalThresholdsSchema = z
.object({
minLatencyReductionRatio: z.number().positive().max(1),
minQualityRetention: z.number().min(0).max(1),
minQualityScore: z.number().min(0).max(1),
minTokenReductionRatio: z.number().positive().max(1),
})
.strict();
const evalManifestSchema = z
.object({
cases: z
.array(
z
.object({
expectedFacts: z
.array(
z
.object({
id: z.string().min(1),
requiredTerms: z.array(z.string().min(1)).min(1),
timestampSeconds: z.number().finite().nonnegative(),
})
.strict()
)
.min(1),
frames: z
.array(
z
.object({
dataUri: z
.string()
.max("data:image/jpeg;base64,".length + MAX_EVAL_FRAME_BASE64_CHARS)
.regex(
/^data:image\/jpeg;base64,[A-Za-z0-9+/=]{4,5592408}$/i,
"expected a bounded JPEG data URI"
),
timestampSeconds: z.number().finite().nonnegative(),
})
.strict()
)
.min(1)
.max(16),
id: z.string().min(1),
prompt: z.string().min(1),
})
.strict()
)
.min(1),
id: z.string().min(1),
schemaVersion: z.literal(1),
thresholds: evalThresholdsSchema,
})
.strict();
const chatCompletionSchema = z
.object({
choices: z
.array(
z
.object({
message: z.object({ content: z.string() }).passthrough(),
})
.passthrough()
)
.min(1),
usage: z
.object({
completion_tokens: z.number().nonnegative().optional(),
prompt_tokens: z.number().nonnegative().optional(),
total_tokens: z.number().nonnegative().optional(),
})
.passthrough()
.optional(),
})
.passthrough();
export type VideoContactSheetEvalManifest = z.infer<typeof evalManifestSchema>;
export interface VideoContactSheetEvalConfig {
apiKey: string;
endpoint: string;
model: string;
}
interface EvalFactScore {
matchedFactIds: string[];
qualityScore: number;
}
interface EvalPathResult extends EvalFactScore {
latencyMs: number;
modelCalls: number;
responseDigest: string;
totalTokens: number | null;
}
export interface VideoContactSheetEvalCaseResult {
caseId: string;
individual: EvalPathResult;
sheet: EvalPathResult;
}
export interface VideoContactSheetEvalExecutedReport {
caseCount: number;
execution: {
realModel: true;
state: "executed";
};
generatedAt: string;
kind: "video-contact-sheet-ab-eval";
manifestDigest: string;
manifestId: string;
model: string;
promotion: VideoContactSheetPromotionDecision;
results: VideoContactSheetEvalCaseResult[];
schemaVersion: 1;
summary: {
individual: VideoContactSheetEvalAggregate & { modelCalls: number };
sheet: VideoContactSheetEvalAggregate & { modelCalls: number };
};
thresholds: VideoContactSheetEvalThresholds;
}
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
export function createVideoContactSheetEvalHoldReport(
input: VideoContactSheetEvalHoldReportInput
): VideoContactSheetEvalHoldReport {
const reason =
input.configurationState === "not-configured"
? "REAL_MODEL_CONFIGURATION_MISSING"
: "REAL_MODEL_EVAL_NOT_EXECUTED";
return {
caseCount: input.caseCount,
execution: {
realModel: false,
state: input.configurationState,
},
kind: "video-contact-sheet-ab-eval",
missingConfiguration: [...(input.missingConfiguration ?? [])],
promotion: {
reasons: [reason],
status: "HOLD",
},
results: [],
schemaVersion: 1,
summary: null,
};
}
function reductionRatio(baseline: number, candidate: number): number {
if (baseline <= 0) return 0;
return (baseline - candidate) / baseline;
}
export function assessVideoContactSheetPromotion(input: {
individual: VideoContactSheetEvalAggregate;
sheet: VideoContactSheetEvalAggregate;
thresholds: VideoContactSheetEvalThresholds;
}): VideoContactSheetPromotionDecision {
const latencyReductionRatio = reductionRatio(input.individual.latencyMs, input.sheet.latencyMs);
const qualityRetention =
input.individual.qualityScore > 0
? input.sheet.qualityScore / input.individual.qualityScore
: 0;
const tokenReductionRatio =
input.individual.totalTokens === null || input.sheet.totalTokens === null
? null
: reductionRatio(input.individual.totalTokens, input.sheet.totalTokens);
const reasons: VideoContactSheetPromotionReason[] = [];
const requiredLatencyReduction = Math.max(
Number.EPSILON,
input.thresholds.minLatencyReductionRatio
);
const requiredTokenReduction = Math.max(Number.EPSILON, input.thresholds.minTokenReductionRatio);
if (latencyReductionRatio < requiredLatencyReduction) {
reasons.push("LATENCY_REDUCTION_BELOW_THRESHOLD");
}
if (input.sheet.qualityScore < input.thresholds.minQualityScore) {
reasons.push("QUALITY_SCORE_BELOW_THRESHOLD");
}
if (qualityRetention < input.thresholds.minQualityRetention) {
reasons.push("QUALITY_RETENTION_BELOW_THRESHOLD");
}
if (tokenReductionRatio === null) {
reasons.push("TOKEN_USAGE_UNAVAILABLE");
} else if (tokenReductionRatio < requiredTokenReduction) {
reasons.push("TOKEN_REDUCTION_BELOW_THRESHOLD");
}
return {
metrics: {
latencyReductionRatio,
qualityRetention,
tokenReductionRatio,
},
reasons,
status: reasons.length === 0 ? "ELIGIBLE" : "HOLD",
};
}
function normalizeEvalText(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
function formatEvalTimestamp(timestampSeconds: number): string {
const totalMilliseconds = Math.max(0, Math.round(timestampSeconds * 1000));
const minutes = Math.floor(totalMilliseconds / 60_000);
const seconds = Math.floor((totalMilliseconds % 60_000) / 1000);
const milliseconds = totalMilliseconds % 1000;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(milliseconds).padStart(3, "0")}`;
}
function scoreFacts(
response: string,
expectedFacts: VideoContactSheetEvalManifest["cases"][number]["expectedFacts"]
): EvalFactScore {
const normalizedResponse = normalizeEvalText(response);
const matchedFactIds = expectedFacts
.filter((fact) => {
const timestamp = normalizeEvalText(formatEvalTimestamp(fact.timestampSeconds));
const timestampIndex = normalizedResponse.indexOf(timestamp);
if (timestampIndex < 0) return false;
const factWindow = normalizedResponse.slice(
Math.max(0, timestampIndex - 160),
Math.min(normalizedResponse.length, timestampIndex + timestamp.length + 160)
);
return fact.requiredTerms.every((term) => factWindow.includes(normalizeEvalText(term)));
})
.map((fact) => fact.id);
return {
matchedFactIds,
qualityScore: matchedFactIds.length / expectedFacts.length,
};
}
function digestResponse(response: string): string {
return createHash("sha256").update(response).digest("hex");
}
function sumTokens(values: Array<number | null>): number | null {
if (values.some((value) => value === null)) return null;
return values.reduce<number>((sum, value) => sum + (value ?? 0), 0);
}
async function callVisionModel(input: {
config: VideoContactSheetEvalConfig;
dataUri: string;
fetchImpl: FetchLike;
prompt: string;
}): Promise<{ content: string; totalTokens: number | null }> {
const response = await input.fetchImpl(input.config.endpoint, {
body: JSON.stringify({
messages: [
{
content: [
{ text: input.prompt, type: "text" },
{ image_url: { url: input.dataUri }, type: "image_url" },
],
role: "user",
},
],
model: input.config.model,
temperature: 0,
}),
headers: {
authorization: `Bearer ${input.config.apiKey}`,
"content-type": "application/json",
},
method: "POST",
});
if (!response.ok) {
throw new Error(`Video contact-sheet eval request failed with HTTP ${response.status}`);
}
const parsed = chatCompletionSchema.parse(await response.json());
const usage = parsed.usage;
const totalTokens =
usage?.total_tokens ??
(usage?.prompt_tokens !== undefined && usage.completion_tokens !== undefined
? usage.prompt_tokens + usage.completion_tokens
: null);
return {
content: parsed.choices[0].message.content,
totalTokens,
};
}
async function evaluateIndividualFrames(input: {
evalCase: VideoContactSheetEvalManifest["cases"][number];
config: VideoContactSheetEvalConfig;
fetchImpl: FetchLike;
}): Promise<EvalPathResult> {
const startedAt = performance.now();
const calls: Array<{ content: string; totalTokens: number | null }> = [];
for (const frame of input.evalCase.frames) {
calls.push(
await callVisionModel({
config: input.config,
dataUri: frame.dataUri,
fetchImpl: input.fetchImpl,
prompt: `${input.evalCase.prompt}\nAnalyze only the frame at ${formatEvalTimestamp(frame.timestampSeconds)}. Associate every observation with that exact timestamp label.`,
})
);
}
const content = calls.map((call) => call.content).join("\n");
return {
...scoreFacts(content, input.evalCase.expectedFacts),
latencyMs: performance.now() - startedAt,
modelCalls: calls.length,
responseDigest: digestResponse(content),
totalTokens: sumTokens(calls.map((call) => call.totalTokens)),
};
}
async function evaluateContactSheet(input: {
evalCase: VideoContactSheetEvalManifest["cases"][number];
config: VideoContactSheetEvalConfig;
fetchImpl: FetchLike;
}): Promise<EvalPathResult> {
const startedAt = performance.now();
const sheet = await buildVideoContactSheet(input.evalCase.frames as ContactSheetFrame[], {
columns: 4,
timeoutMs: 30_000,
});
if (!sheet.used || !sheet.dataUri) {
throw new Error("Video contact-sheet eval could not compose the bounded JPEG grid");
}
const call = await callVisionModel({
config: input.config,
dataUri: sheet.dataUri,
fetchImpl: input.fetchImpl,
prompt: `${input.evalCase.prompt}\nAnalyze every cell in the contact sheet. Timestamp labels are burned into each cell. Associate every observation with its visible timestamp.`,
});
return {
...scoreFacts(call.content, input.evalCase.expectedFacts),
latencyMs: performance.now() - startedAt,
modelCalls: 1,
responseDigest: digestResponse(call.content),
totalTokens: call.totalTokens,
};
}
function aggregatePathResults(
results: VideoContactSheetEvalCaseResult[],
path: "individual" | "sheet"
): VideoContactSheetEvalAggregate & { modelCalls: number } {
const pathResults = results.map((result) => result[path]);
return {
latencyMs: pathResults.reduce((sum, result) => sum + result.latencyMs, 0),
modelCalls: pathResults.reduce((sum, result) => sum + result.modelCalls, 0),
qualityScore:
pathResults.reduce((sum, result) => sum + result.qualityScore, 0) / pathResults.length,
totalTokens: sumTokens(pathResults.map((result) => result.totalTokens)),
};
}
export async function runVideoContactSheetEval(input: {
config: VideoContactSheetEvalConfig;
fetchImpl?: FetchLike;
manifest: VideoContactSheetEvalManifest;
}): Promise<VideoContactSheetEvalExecutedReport> {
const manifest = evalManifestSchema.parse(input.manifest);
const endpoint = z.string().url().parse(input.config.endpoint);
const config = {
apiKey: z.string().min(1).parse(input.config.apiKey),
endpoint,
model: z.string().min(1).parse(input.config.model),
};
const fetchImpl = input.fetchImpl ?? fetch;
const results: VideoContactSheetEvalCaseResult[] = [];
for (const evalCase of manifest.cases) {
const individual = await evaluateIndividualFrames({ config, evalCase, fetchImpl });
const sheet = await evaluateContactSheet({ config, evalCase, fetchImpl });
results.push({ caseId: evalCase.id, individual, sheet });
}
const individual = aggregatePathResults(results, "individual");
const sheet = aggregatePathResults(results, "sheet");
const promotion = assessVideoContactSheetPromotion({
individual,
sheet,
thresholds: manifest.thresholds,
});
return {
caseCount: manifest.cases.length,
execution: { realModel: true, state: "executed" },
generatedAt: new Date().toISOString(),
kind: "video-contact-sheet-ab-eval",
manifestDigest: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"),
manifestId: manifest.id,
model: config.model,
promotion,
results,
schemaVersion: 1,
summary: { individual, sheet },
thresholds: manifest.thresholds,
};
}
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-contact-sheet-eval.ts --manifest <manifest.json> --model <vision-model>",
" node --import tsx/esm scripts/perf/video-bridge-contact-sheet-eval.ts --manifest <manifest.json> --model <vision-model> --execute-real",
"",
"The default command validates configuration and emits HOLD without calling a model.",
"A real paid/networked run requires --execute-real, --model, and the documented variables:",
" OMNIROUTE_BASE_URL",
" OMNIROUTE_API_KEY",
"",
"Manifest v1: id, thresholds, and 1+ cases. Each case has 1-16 bounded JPEG data URIs,",
"timestamps, a prompt, and expectedFacts with timestampSeconds + requiredTerms.",
].join("\n")
);
}
async function loadManifest(manifestPath: string): Promise<VideoContactSheetEvalManifest> {
const raw = await readFile(path.resolve(manifestPath), "utf8");
return evalManifestSchema.parse(JSON.parse(raw));
}
function resolveChatCompletionsEndpoint(baseUrl: string): string {
const normalized = baseUrl.replace(/\/{1,8}$/u, "");
if (normalized.endsWith("/v1/chat/completions")) return normalized;
if (normalized.endsWith("/v1")) return `${normalized}/chat/completions`;
return `${normalized}/v1/chat/completions`;
}
async function main(): Promise<void> {
if (process.argv.includes("--help") || process.argv.includes("-h")) {
printUsage();
return;
}
const manifestPath = readArgument("manifest");
const model = readArgument("model");
const missingConfiguration: string[] = [];
if (!manifestPath) missingConfiguration.push("--manifest");
if (!model) missingConfiguration.push("--model");
const baseUrl = process.env.OMNIROUTE_BASE_URL;
const apiKey = process.env.OMNIROUTE_API_KEY;
if (!baseUrl) missingConfiguration.push("OMNIROUTE_BASE_URL");
if (!apiKey) missingConfiguration.push("OMNIROUTE_API_KEY");
let manifest: VideoContactSheetEvalManifest | null = null;
if (manifestPath) manifest = await loadManifest(manifestPath);
if (missingConfiguration.length > 0) {
console.log(
JSON.stringify(
createVideoContactSheetEvalHoldReport({
caseCount: manifest?.cases.length ?? 0,
configurationState: "not-configured",
missingConfiguration,
}),
null,
2
)
);
return;
}
if (!process.argv.includes("--execute-real")) {
console.log(
JSON.stringify(
createVideoContactSheetEvalHoldReport({
caseCount: manifest?.cases.length ?? 0,
configurationState: "configured-not-executed",
}),
null,
2
)
);
return;
}
if (!manifest || !baseUrl || !apiKey || !model) {
throw new Error("Video contact-sheet eval configuration was not resolved");
}
console.log(
JSON.stringify(
await runVideoContactSheetEval({
config: { apiKey, endpoint: resolveChatCompletionsEndpoint(baseUrl), model },
manifest,
}),
null,
2
)
);
}
const isMainModule =
typeof process.argv[1] === "string" &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
main().catch(() => {
console.error("Video contact-sheet eval failed validation or execution.");
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,493 @@
/**
* Real-media FU-07 structural-sampling evaluation.
*
* Run: node --import tsx/esm scripts/perf/video-bridge-fu07-eval.ts
* Optional estimate: append --caption-cost-per-call-usd <positive number>.
*
* This evaluates deterministic structural oracles, not semantic model quality.
* Model quality and monetary savings remain HOLD without an external receipt.
*/
import { execFile } from "node:child_process";
import { access, mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { promisify } from "node:util";
import { deduplicateVideoFrames } from "../../src/lib/guardrails/videoBridgeHelpers";
import {
analyzeVideoStructure,
calculateSamplingDecision,
extractFramesFromLocalVideo,
readBoundedExtractedFrames,
type VideoCommandRunner,
type VideoStructuralAnalysis,
type VideoStructuralSample,
} from "../../src/lib/guardrails/videoBridgeRuntime";
const execFileAsync = promisify(execFile);
const REQUIRED_FILTERS = ["scdet", "freezedetect", "blurdetect", "signalstats", "siti"];
const TIME_MARKER = "__FU07_TIME__";
interface ChildCost {
maxRssKiB: number | null;
systemSeconds: number | null;
userSeconds: number | null;
wallMs: number;
}
interface FixtureResult {
captionCallsAvoided: number;
childCost: ChildCost;
freezeIntervals: number;
name: string;
oracle: Record<string, boolean | number | string>;
passed: boolean;
sceneCandidates: number;
structuralFrames: number;
uniformFrames: number;
}
function average(values: Array<number | null | undefined>): number | null {
const finite = values.filter(
(value): value is number => value !== null && value !== undefined && Number.isFinite(value)
);
return finite.length > 0 ? finite.reduce((sum, value) => sum + value, 0) / finite.length : null;
}
function samplesIn(
analysis: VideoStructuralAnalysis,
startSeconds: number,
endSeconds: number
): VideoStructuralSample[] {
return analysis.samples.filter(
(sample) => sample.timestampSeconds >= startSeconds && sample.timestampSeconds < endSeconds
);
}
async function generateFixture(outputPath: string, args: readonly string[]): Promise<void> {
await execFileAsync(
"ffmpeg",
["-hide_banner", "-loglevel", "error", ...args, "-threads", "1", "-y", outputPath],
{ maxBuffer: 1024 * 1024, timeout: 30_000 }
);
}
async function generateStaticFixture(outputPath: string): Promise<void> {
await generateFixture(outputPath, [
"-f",
"lavfi",
"-i",
"color=c=blue:s=320x180:d=8:r=12",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
]);
}
async function generateMixedFixture(outputPath: string): Promise<void> {
await generateFixture(outputPath, [
"-f",
"lavfi",
"-i",
"color=c=black:s=320x180:d=6:r=12",
"-f",
"lavfi",
"-i",
"testsrc2=s=320x180:d=4:r=12",
"-filter_complex",
"[0:v][1:v]concat=n=2:v=1:a=0,format=yuv420p[v]",
"-map",
"[v]",
"-c:v",
"libx264",
"-preset",
"ultrafast",
]);
}
async function generateBlurExposureFixture(outputPath: string): Promise<void> {
await generateFixture(outputPath, [
"-f",
"lavfi",
"-i",
"testsrc2=s=320x180:d=3:r=12",
"-f",
"lavfi",
"-i",
"color=c=black:s=320x180:d=3:r=12",
"-f",
"lavfi",
"-i",
"testsrc2=s=320x180:d=4:r=12",
"-filter_complex",
"[0:v]gblur=sigma=12[blur];[blur][1:v][2:v]concat=n=3:v=1:a=0,format=yuv420p[v]",
"-map",
"[v]",
"-c:v",
"libx264",
"-preset",
"ultrafast",
]);
}
async function generateDenseTailFixture(outputPath: string): Promise<void> {
const args: string[] = [];
for (const source of [
"color=c=black:s=160x90:d=0.5:r=10",
"color=c=white:s=160x90:d=0.5:r=10",
"color=c=black:s=160x90:d=0.5:r=10",
"color=c=white:s=160x90:d=0.5:r=10",
"testsrc2=s=160x90:d=8:r=10",
]) {
args.push("-f", "lavfi", "-i", source);
}
args.push(
"-filter_complex",
"[0:v][1:v][2:v][3:v][4:v]concat=n=5:v=1:a=0,format=yuv420p[v]",
"-map",
"[v]",
"-c:v",
"libx264",
"-preset",
"ultrafast"
);
await generateFixture(outputPath, args);
}
async function generateGradualFadeFixture(outputPath: string): Promise<void> {
await generateFixture(outputPath, [
"-f",
"lavfi",
"-i",
"color=c=white:s=320x180:d=8:r=12",
"-vf",
"fade=t=out:st=0:d=8,format=yuv420p",
"-c:v",
"libx264",
"-preset",
"ultrafast",
]);
}
async function supportsTimeBinary(): Promise<boolean> {
try {
await access("/usr/bin/time");
return true;
} catch {
return false;
}
}
function parseTimeCost(stderr: string, wallMs: number): ChildCost {
const match = new RegExp(`${TIME_MARKER} ([\\d.]+) ([\\d.]+) ([\\d.]+)`).exec(stderr);
return {
maxRssKiB: match ? Number(match[3]) : null,
systemSeconds: match ? Number(match[2]) : null,
userSeconds: match ? Number(match[1]) : null,
wallMs,
};
}
async function timedAnalysis(
inputPath: string,
durationSeconds: number,
useTimeBinary: boolean
): Promise<{ analysis: VideoStructuralAnalysis; cost: ChildCost }> {
let cost: ChildCost = {
maxRssKiB: null,
systemSeconds: null,
userSeconds: null,
wallMs: 0,
};
const runner: VideoCommandRunner = async (executable, args, options) => {
const startedAt = performance.now();
const command = useTimeBinary ? "/usr/bin/time" : executable;
const commandArgs = useTimeBinary
? ["-f", `${TIME_MARKER} %U %S %M`, executable, ...args]
: [...args];
const result = await execFileAsync(command, commandArgs, {
encoding: "utf8",
maxBuffer: 1024 * 1024,
signal: options.signal,
timeout: options.timeoutMs,
});
cost = parseTimeCost(String(result.stderr), performance.now() - startedAt);
return { stderr: String(result.stderr), stdout: String(result.stdout) };
};
const analysis = await analyzeVideoStructure(inputPath, {
durationSeconds,
runner,
streamIndex: 0,
timeoutMs: 30_000,
});
return { analysis, cost };
}
function sampling(
durationSeconds: number,
frameCount: number,
analysis: VideoStructuralAnalysis
): { structural: number[]; uniform: number[] } {
const uniform = calculateSamplingDecision(durationSeconds, frameCount, "uniform").timestamps;
const structural = calculateSamplingDecision(
durationSeconds,
frameCount,
"segment_aware",
analysis.sceneCandidates,
null,
analysis
).timestamps;
return { structural, uniform };
}
async function captionCallsAfterDedup(
inputPath: string,
outputDirectory: string,
samplingPolicy: "segment_aware" | "uniform"
): Promise<number> {
await mkdir(outputDirectory, { mode: 0o700 });
const frames = await extractFramesFromLocalVideo(inputPath, outputDirectory, {
durationSeconds: 8,
frameCount: 8,
samplingPolicy,
streamIndex: 0,
timeoutMs: 30_000,
});
const bytes = await readBoundedExtractedFrames(frames);
const deduplicated = await deduplicateVideoFrames(
frames.map((frame, index) => ({
dataUri: `data:image/jpeg;base64,${bytes[index].toString("base64")}`,
timestampSeconds: frame.timestampSeconds,
}))
);
return deduplicated.frames.length;
}
function result(
name: string,
cost: ChildCost,
analysis: VideoStructuralAnalysis,
uniform: number[],
structural: number[],
oracle: Record<string, boolean | number | string>,
captionCallsAvoided = 0
): FixtureResult {
const booleans = Object.values(oracle).filter(
(value): value is boolean => typeof value === "boolean"
);
return {
captionCallsAvoided,
childCost: cost,
freezeIntervals: analysis.freezeIntervals.length,
name,
oracle,
passed: booleans.every(Boolean),
sceneCandidates: analysis.sceneCandidates.length,
structuralFrames: structural.length,
uniformFrames: uniform.length,
};
}
async function main(): Promise<void> {
const version = await execFileAsync("ffmpeg", ["-version"], { timeout: 5_000 });
const filters = await execFileAsync("ffmpeg", ["-hide_banner", "-filters"], {
maxBuffer: 2 * 1024 * 1024,
timeout: 5_000,
});
const missingFilters = REQUIRED_FILTERS.filter(
(filter) => !new RegExp(`\\b${filter}\\b`).test(String(filters.stdout))
);
if (missingFilters.length > 0)
throw new Error(`Missing required FFmpeg filters: ${missingFilters.join(", ")}`);
const directory = await mkdtemp(join(tmpdir(), "video-fu07-eval-"));
const useTimeBinary = await supportsTimeBinary();
const results: FixtureResult[] = [];
try {
const staticPath = join(directory, "static.mp4");
await generateStaticFixture(staticPath);
const staticRun = await timedAnalysis(staticPath, 8, useTimeBinary);
const staticSampling = sampling(8, 8, staticRun.analysis);
const uniformCaptionCalls = await captionCallsAfterDedup(
staticPath,
join(directory, "static-uniform"),
"uniform"
);
const structuralCaptionCalls = await captionCallsAfterDedup(
staticPath,
join(directory, "static-structural"),
"segment_aware"
);
const staticCaptionCallsAvoided = Math.max(0, uniformCaptionCalls - structuralCaptionCalls);
results.push(
result(
"static-caption-savings",
staticRun.cost,
staticRun.analysis,
staticSampling.uniform,
staticSampling.structural,
{
fullFreezeDetected: staticRun.analysis.freezeIntervals.some(
(interval) => interval.startSeconds <= 1 && interval.endSeconds >= 7
),
oneIncrementalCaptionCallAvoided: staticCaptionCallsAvoided === 1,
structuralCaptionCalls,
uniformCaptionCalls,
},
staticCaptionCallsAvoided
)
);
const mixedPath = join(directory, "mixed.mp4");
await generateMixedFixture(mixedPath);
const mixedRun = await timedAnalysis(mixedPath, 10, useTimeBinary);
const mixedSampling = sampling(10, 4, mixedRun.analysis);
const uniformDense = mixedSampling.uniform.filter((timestamp) => timestamp > 6).length;
const structuralDense = mixedSampling.structural.filter((timestamp) => timestamp > 6).length;
results.push(
result(
"dense-budget-quality-oracle",
mixedRun.cost,
mixedRun.analysis,
mixedSampling.uniform,
mixedSampling.structural,
{
denseFramesStructural: structuralDense,
denseFramesUniform: uniformDense,
denseRegionGetsMoreBudget: structuralDense > uniformDense,
frozenRegionRetainsCoverage: mixedSampling.structural.some((timestamp) => timestamp < 6),
}
)
);
const qualityPath = join(directory, "blur-exposure.mp4");
await generateBlurExposureFixture(qualityPath);
const qualityRun = await timedAnalysis(qualityPath, 10, useTimeBinary);
const qualitySampling = sampling(10, 6, qualityRun.analysis);
const blurred = samplesIn(qualityRun.analysis, 0, 3);
const dark = samplesIn(qualityRun.analysis, 3, 6);
const sharp = samplesIn(qualityRun.analysis, 6, 10);
const blurredBlur = average(blurred.map((sample) => sample.blur));
const blurredSpatial = average(blurred.map((sample) => sample.spatialInformation));
const darkLuma = average(dark.map((sample) => sample.brightness));
const sharpBlur = average(sharp.map((sample) => sample.blur));
const sharpSpatial = average(sharp.map((sample) => sample.spatialInformation));
const sharpTemporal = average(sharp.map((sample) => sample.temporalInformation));
const sharpLuma = average(sharp.map((sample) => sample.brightness));
results.push(
result(
"blur-exposure-spatial-temporal-evidence",
qualityRun.cost,
qualityRun.analysis,
qualitySampling.uniform,
qualitySampling.structural,
{
blurMetricSeparated:
blurredBlur !== null && sharpBlur !== null && Math.abs(blurredBlur - sharpBlur) >= 0.05,
blurredBlur: blurredBlur ?? "missing",
darkLuma: darkLuma ?? "missing",
exposureSeparated: darkLuma !== null && sharpLuma !== null && sharpLuma - darkLuma >= 50,
sharpBlur: sharpBlur ?? "missing",
sharpSpatial: sharpSpatial ?? "missing",
sharpTemporal: sharpTemporal ?? "missing",
spatialDetailSeparated:
blurredSpatial !== null && sharpSpatial !== null && sharpSpatial - blurredSpatial >= 20,
structuralKeepsSharpRegion:
qualitySampling.structural.filter((timestamp) => timestamp >= 6).length >= 2,
temporalChangeDetected: sharpTemporal !== null && sharpTemporal >= 5,
}
)
);
const tailPath = join(directory, "dense-tail.mp4");
await generateDenseTailFixture(tailPath);
const tailRun = await timedAnalysis(tailPath, 10, useTimeBinary);
const tailSampling = sampling(10, 4, tailRun.analysis);
results.push(
result(
"dense-cuts-long-tail-regression",
tailRun.cost,
tailRun.analysis,
tailSampling.uniform,
tailSampling.structural,
{
multipleEarlyCuts: tailRun.analysis.sceneCandidates.length >= 3,
trailingEightSecondsRepresented: tailSampling.structural.some(
(timestamp) => timestamp > 2
),
}
)
);
const fadePath = join(directory, "gradual-fade.mp4");
await generateGradualFadeFixture(fadePath);
const fadeRun = await timedAnalysis(fadePath, 8, useTimeBinary);
const fadeSampling = sampling(8, 4, fadeRun.analysis);
results.push(
result(
"gradual-fade-false-positive",
fadeRun.cost,
fadeRun.analysis,
fadeSampling.uniform,
fadeSampling.structural,
{
hardCutFalsePositives: fadeRun.analysis.sceneCandidates.length,
noHardCutBurst: fadeRun.analysis.sceneCandidates.length <= 1,
noCaptionBudgetPruning: fadeSampling.structural.length === fadeSampling.uniform.length,
}
)
);
} finally {
await rm(directory, { force: true, recursive: true });
}
const callsAvoided = results.reduce((sum, fixture) => sum + fixture.captionCallsAvoided, 0);
const costFlag = process.argv.indexOf("--caption-cost-per-call-usd");
const explicitCost = Number(costFlag >= 0 ? process.argv[costFlag + 1] : Number.NaN);
const report = {
captionCost:
Number.isFinite(explicitCost) && explicitCost > 0
? {
estimatedUsdAvoided: callsAvoided * explicitCost,
source: "explicit environment input",
status: "ESTIMATED_FROM_INPUT",
}
: {
reason: "--caption-cost-per-call-usd was not supplied with a positive number",
status: "HOLD",
},
ffmpegVersion: String(version.stdout).split("\n")[0],
fixtures: results,
modelQuality: {
reason:
"No authorized real caption-model endpoint, credentials, or frozen judge rubric were configured; deterministic structural oracles are not semantic quality.",
status: "HOLD",
},
gainCostComparison: {
reason:
"The real post-dedup caption-call delta is measured, but no authorized caption latency/cost receipt or child CPU/RSS receipt is configured.",
status: "HOLD",
},
resourceCost: useTimeBinary
? { source: "/usr/bin/time", status: "MEASURED" }
: {
reason: "/usr/bin/time is unavailable; wall time is measured but child CPU/RSS are not",
status: "HOLD",
},
summary: {
captionCallsAvoided: callsAvoided,
failed: results.filter((fixture) => !fixture.passed).map((fixture) => fixture.name),
passed: results.filter((fixture) => fixture.passed).length,
total: results.length,
},
timeBinary: useTimeBinary ? "/usr/bin/time" : null,
};
console.log(JSON.stringify(report, null, 2));
if (report.summary.failed.length > 0) process.exitCode = 1;
}
await main();

View File

@@ -57,12 +57,16 @@ for N in "${PRS[@]}"; do
done
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
# The train worktree is detached, so the changelog gate cannot infer which release
# branch seeded it. Shell-quote the requested base before it enters the eval-backed
# gate list, then bind that exact ref only for the changelog check.
printf -v CHANGELOG_BASE_REF_Q '%q' "origin/${BASE}"
STATIC_GATES=(
"npm run typecheck:core"
"node scripts/check/check-file-size.mjs"
"node scripts/check/check-complexity.mjs"
"node scripts/check/check-cognitive-complexity.mjs"
"node scripts/check/check-changelog-integrity.mjs"
"env CHANGELOG_BASE_REF=${CHANGELOG_BASE_REF_Q} node scripts/check/check-changelog-integrity.mjs"
)
# Full mode: the box-speed runner (same coverage as the two CI shards combined —
# main + dashboard + serial groups — at local concurrency instead of runner-sized).

View File

@@ -111,6 +111,18 @@ export function classifyFragments({ fragments = [], changelog = "" }) {
return { stale, keep };
}
/**
* Count a stale list by how each entry was matched, for the human report line.
* classifyFragments only ever sets matchedBy to "pr-number" (the filename convention) or
* "text" (the normalized-bullet fallback); the summary must bucket under those exact values.
* Pure - the two counts always add up to stale.length and never mislabel a category.
*/
export function summarizeStale(stale) {
const byPrNumber = (stale || []).filter((s) => s.matchedBy === "pr-number").length;
const byText = (stale || []).filter((s) => s.matchedBy === "text").length;
return { byPrNumber, byText };
}
export function readFragments(root) {
const out = [];
for (const sub of FRAGMENT_DIRS) {
@@ -141,9 +153,8 @@ function main(argv) {
return 0;
}
const byRef = stale.filter((s) => s.matchedBy === "ref").length;
const byText = stale.length - byRef;
process.stdout.write(` matched by ref: ${byRef} · by text: ${byText}\n`);
const { byPrNumber, byText } = summarizeStale(stale);
process.stdout.write(` matched by pr-number: ${byPrNumber} · by text: ${byText}\n`);
for (const s of stale) process.stdout.write(` ${apply ? "removed" : "stale"}: ${s.rel}${s.reason}\n`);
if (!apply) {

View File

@@ -57,6 +57,7 @@ import CustomModelsSection from "./components/CustomModelsSection";
import ConnectionsListPanel from "./components/ConnectionsListPanel";
import CoolingConnectionsPanel from "./components/CoolingConnectionsPanel";
import ConnectionsHeaderToolbar from "./components/ConnectionsHeaderToolbar";
import VolcengineConnectModal from "./components/VolcengineConnectModal";
import ProviderAccountRoutingCard from "../../settings/components/ProviderAccountRoutingCard";
import ZedImportCard from "./components/ZedImportCard";
import CursorAgentNudge from "./components/CursorAgentNudge";
@@ -79,6 +80,7 @@ export default function ProviderDetailPageClient() {
const [showOAuthModal, _setShowOAuthModal] = useState(false);
const [reauthConnection, setReauthConnection] = useState<ConnectionRowConnection | null>(null);
const [showKimiAuthMethodModal, setShowKimiAuthMethodModal] = useState(false);
const [showVolcengineConnectModal, setShowVolcengineConnectModal] = useState(false);
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
const [showSiliconFlowEndpointModal, setShowSiliconFlowEndpointModal] = useState(false);
const [siliconFlowInitialBaseUrl, setSiliconFlowInitialBaseUrl] = useState<string | undefined>();
@@ -92,6 +94,7 @@ export default function ProviderDetailPageClient() {
const [importClaudeModalOpen, setImportClaudeModalOpen] = useState(false);
const [importGeminiModalOpen, setImportGeminiModalOpen] = useState(false);
const [importGrokCliModalOpen, setImportGrokCliModalOpen] = useState(false);
const [connectingVolcengineAccount, setConnectingVolcengineAccount] = useState(false);
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
const isCommandCode = providerId === "command-code";
@@ -381,6 +384,43 @@ export default function ProviderDetailPageClient() {
openApiKeyAddFlow();
}, [providerId, isOAuth, openApiKeyAddFlow]);
// Legacy manual flow: headful browser login on the machine running OmniRoute.
// Kept as the fallback for the phone/SMS auto-login modal.
const connectVolcengineAccountManually = useCallback(async () => {
setConnectingVolcengineAccount(true);
try {
const response = await fetch("/api/providers/volcengine-plan/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timeout: 300_000 }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !data?.success) {
throw new Error(data?.error || "Failed to connect Volcano account");
}
const results = Array.isArray(data?.binding?.results) ? data.binding.results : [];
const connected = results.filter((item: any) => item?.ok).length;
const failed = results.filter((item: any) => item && item.ok === false && item.available);
if (connected > 0) {
notify.success(`Connected ${connected} Volcano plan${connected > 1 ? "s" : ""}`);
}
if (failed.length > 0) {
notify.error(
failed.map((item: any) => `${item.plan}: ${item.error || "failed"}`).join("; ")
);
}
await fetchConnections();
} catch (error) {
notify.error(error instanceof Error ? error.message : "Failed to connect Volcano account");
} finally {
setConnectingVolcengineAccount(false);
}
}, [fetchConnections, notify]);
const connectVolcengineAccount = useCallback(() => {
setShowVolcengineConnectModal(true);
}, []);
const {
commandCodeAuthState,
handleCloseAddApiKeyModal,
@@ -595,6 +635,8 @@ export default function ProviderDetailPageClient() {
gateConnectionFlow={gateConnectionFlow}
openApiKeyAddFlow={openApiKeyAddFlow}
openPrimaryAddFlow={openPrimaryAddFlow}
connectVolcengineAccount={connectVolcengineAccount}
connectingVolcengineAccount={connectingVolcengineAccount}
openExternalLinkFlow={openExternalLinkFlow}
handleOpenCommandCodeConnect={handleOpenCommandCodeConnect}
commandCodeAuthState={commandCodeAuthState}
@@ -868,6 +910,16 @@ export default function ProviderDetailPageClient() {
setShowTutorialModal={setShowTutorialModal}
t={t}
/>
{/* Volcano Engine console phone/SMS auto-login (falls back to manual browser login) */}
<VolcengineConnectModal
isOpen={showVolcengineConnectModal}
onClose={() => setShowVolcengineConnectModal(false)}
onFallbackManual={connectVolcengineAccountManually}
onConnected={fetchConnections}
notify={notify}
t={t}
/>
</div>
);
}

View File

@@ -40,6 +40,8 @@ type ConnectionsHeaderToolbarProps = {
gateConnectionFlow: (callback: () => void) => void;
openApiKeyAddFlow: () => void;
openPrimaryAddFlow: () => void;
connectVolcengineAccount?: () => void;
connectingVolcengineAccount?: boolean;
openExternalLinkFlow: () => void;
handleOpenCommandCodeConnect: () => void;
commandCodeAuthState: { phase: string };
@@ -86,6 +88,8 @@ export default function ConnectionsHeaderToolbar({
gateConnectionFlow,
openApiKeyAddFlow,
openPrimaryAddFlow,
connectVolcengineAccount,
connectingVolcengineAccount,
openExternalLinkFlow,
handleOpenCommandCodeConnect,
commandCodeAuthState,
@@ -303,6 +307,19 @@ export default function ConnectionsHeaderToolbar({
<Button size="sm" icon="add" onClick={() => gateConnectionFlow(openPrimaryAddFlow)}>
{providerSupportsPat ? providerText(t, "addPat", "Add PAT") : t("add")}
</Button>
{(providerId === "volcengine-agent-plan" ||
providerId === "volcengine-coding-plan") &&
connectVolcengineAccount && (
<Button
size="sm"
variant="secondary"
icon="login"
loading={connectingVolcengineAccount}
onClick={() => gateConnectionFlow(connectVolcengineAccount)}
>
{providerText(t, "connectVolcengineAccount", "Connect Volcano Account")}
</Button>
)}
{providerId === "qoder" && (
<Button
size="sm"

View File

@@ -0,0 +1,591 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Input, Modal } from "@/shared/components";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
/**
* VolcengineConnectModal — phone/SMS-code login for the Volcano Engine console.
*
* Drives the session-based auto login API:
* POST /api/providers/volcengine-plan/connect {phone}
* POST /api/providers/volcengine-plan/connect/{id}/code {code, captcha?}
* GET /api/providers/volcengine-plan/connect/{id}/status
* POST /api/providers/volcengine-plan/connect/{id}/resend
* POST /api/providers/volcengine-plan/connect/{id}/cancel
*
* Falls back to the legacy manual headful-browser flow (same POST /connect
* endpoint without a phone) when risk control or a layout change degrades
* the headless session.
*/
type SessionPhase =
| "starting"
| "sending_code"
| "waiting_code"
| "captcha_required"
| "submitting"
| "mfa_waiting"
| "identity_required"
| "success"
| "error"
| "timeout"
| "cancelled"
| "fallback_manual";
interface SessionView {
sessionId: string;
phase: SessionPhase;
phoneMasked: string;
error: string | null;
captchaImage: string | null;
resendAvailableAt: number;
mfaRequired?: boolean;
identityOptions?: Array<{ index: number; label: string }>;
binding?: {
results?: Array<{
plan: string;
available: boolean;
ok: boolean;
error?: string | null;
}>;
error?: string;
};
}
const PHONE_STORAGE_KEY = "omniroute.volcengine.phone";
const TERMINAL_PHASES: SessionPhase[] = [
"success",
"error",
"timeout",
"cancelled",
"fallback_manual",
];
function isTerminal(phase: SessionPhase | undefined): boolean {
return !!phase && TERMINAL_PHASES.includes(phase);
}
type VolcengineConnectModalProps = {
isOpen: boolean;
onClose: () => void;
/** Legacy headful-browser login (opens on the server machine) */
onFallbackManual: () => void;
/** Refresh connections after a successful bind */
onConnected: () => void | Promise<void>;
notify: {
success: (message: string, title?: string) => void;
error: (message: string, title?: string) => void;
};
t: ProviderMessageTranslator;
};
export default function VolcengineConnectModal({
isOpen,
onClose,
onFallbackManual,
onConnected,
notify,
t,
}: VolcengineConnectModalProps) {
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [captcha, setCaptcha] = useState("");
const [session, setSession] = useState<SessionView | null>(null);
const [starting, setStarting] = useState(false);
const [submittingCode, setSubmittingCode] = useState(false);
const [resending, setResending] = useState(false);
const [selectingIdentity, setSelectingIdentity] = useState(false);
const [resendCountdown, setResendCountdown] = useState(0);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
// ── lifecycle ────────────────────────────────────────────────────────────
const stopTimers = useCallback(() => {
if (pollTimer.current) {
clearInterval(pollTimer.current);
pollTimer.current = null;
}
}, []);
const reset = useCallback(() => {
stopTimers();
setSession(null);
setCode("");
setCaptcha("");
setResendCountdown(0);
}, [stopTimers]);
useEffect(() => {
if (!isOpen) {
// Leaving the modal cancels an in-flight session server-side.
const active = session && !isTerminal(session.phase) ? session : null;
if (active) {
void fetch(`/api/providers/volcengine-plan/connect/${active.sessionId}/cancel`, {
method: "POST",
}).catch(() => {});
}
reset();
return;
}
const saved = typeof window !== "undefined" ? localStorage.getItem(PHONE_STORAGE_KEY) : null;
if (saved) setPhone(saved);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
useEffect(() => stopTimers, [stopTimers]);
// resend countdown ticker
const resendAvailableAt = session?.resendAvailableAt ?? 0;
const sessionId = session?.sessionId;
const sessionPhase = session?.phase;
useEffect(() => {
if (!sessionId || isTerminal(sessionPhase)) return;
const tick = () => {
setResendCountdown(Math.max(0, Math.ceil((resendAvailableAt - Date.now()) / 1000)));
};
tick();
const timer = setInterval(tick, 1000);
return () => clearInterval(timer);
}, [sessionId, sessionPhase, resendAvailableAt]);
// ── status polling ──────────────────────────────────────────────────────
const startPolling = useCallback(
(sessionId: string) => {
stopTimers();
pollTimer.current = setInterval(async () => {
try {
const response = await fetch(
`/api/providers/volcengine-plan/connect/${sessionId}/status`
);
const data = await response.json().catch(() => ({}));
if (data?.session) {
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
if (isTerminal(data.session.phase)) {
stopTimers();
if (data.session.phase === "success") void onConnected();
}
}
} catch {
// transient network error — keep polling until phase resolves
}
}, 1500);
},
[stopTimers, onConnected]
);
// ── actions ─────────────────────────────────────────────────────────────
const handleStart = useCallback(async () => {
const trimmed = phone.trim();
if (!trimmed) return;
setStarting(true);
try {
const response = await fetch("/api/providers/volcengine-plan/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: trimmed }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !data?.success || !data?.session) {
throw new Error(data?.error || "Failed to start Volcano login");
}
setSession(data.session);
setResendCountdown(
Math.max(0, Math.ceil((data.session.resendAvailableAt - Date.now()) / 1000))
);
localStorage.setItem(PHONE_STORAGE_KEY, trimmed);
if (data.session.phase === "starting" || data.session.phase === "sending_code") {
startPolling(data.session.sessionId);
}
} catch (error) {
notify.error(error instanceof Error ? error.message : "Failed to start Volcano login");
} finally {
setStarting(false);
}
}, [phone, notify, startPolling]);
const handleSubmitCode = useCallback(async () => {
if (!session) return;
setSubmittingCode(true);
try {
const payload: { code: string; captcha?: string } = { code: code.trim() };
if (session.phase === "captcha_required" && captcha.trim()) {
payload.captcha = captcha.trim();
}
const response = await fetch(
`/api/providers/volcengine-plan/connect/${session.sessionId}/code`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}
);
const data = await response.json().catch(() => ({}));
if (data?.session) {
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
if (data.session.phase === "mfa_waiting") {
// A NEW code is required for the MFA step — clear the stale input.
setCode("");
setCaptcha("");
}
if (
data.session.phase === "starting" ||
data.session.phase === "sending_code" ||
data.session.phase === "submitting"
) {
startPolling(data.session.sessionId);
} else if (data.session.phase === "success") {
void onConnected();
}
} else {
throw new Error(data?.error || "Failed to submit verification code");
}
} catch (error) {
notify.error(error instanceof Error ? error.message : "Failed to submit verification code");
} finally {
setSubmittingCode(false);
}
}, [session, code, captcha, notify, startPolling, onConnected]);
const handleSelectIdentity = useCallback(
async (index: number) => {
if (!session) return;
setSelectingIdentity(true);
try {
const response = await fetch(
`/api/providers/volcengine-plan/connect/${session.sessionId}/identity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ index }),
}
);
const data = await response.json().catch(() => ({}));
if (data?.session) {
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
if (
data.session.phase === "starting" ||
data.session.phase === "sending_code" ||
data.session.phase === "submitting"
) {
startPolling(data.session.sessionId);
} else if (data.session.phase === "success") {
void onConnected();
}
} else {
throw new Error(data?.error || "Failed to select identity");
}
} catch (error) {
notify.error(error instanceof Error ? error.message : "Failed to select identity");
} finally {
setSelectingIdentity(false);
}
},
[session, notify, startPolling, onConnected]
);
const handleResend = useCallback(async () => {
if (!session || resendCountdown > 0) return;
setResending(true);
try {
const response = await fetch(
`/api/providers/volcengine-plan/connect/${session.sessionId}/resend`,
{ method: "POST" }
);
const data = await response.json().catch(() => ({}));
if (data?.session) {
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
setResendCountdown(
Math.max(0, Math.ceil((data.session.resendAvailableAt - Date.now()) / 1000))
);
setCode("");
setCaptcha("");
}
} catch {
notify.error("Failed to resend verification code");
} finally {
setResending(false);
}
}, [session, resendCountdown, notify]);
const handleCancelSession = useCallback(async () => {
if (!session) return;
try {
await fetch(`/api/providers/volcengine-plan/connect/${session.sessionId}/cancel`, {
method: "POST",
});
} catch {
// best-effort
}
reset();
}, [session, reset]);
// ── derived UI state ────────────────────────────────────────────────────
const phase = session?.phase;
const showPhoneStep = !session;
const showCodeStep =
phase === "waiting_code" ||
phase === "captcha_required" ||
phase === "mfa_waiting" ||
phase === "identity_required";
const showPolling = phase === "starting" || phase === "sending_code" || phase === "submitting";
const done = isTerminal(phase);
const mfaStep = phase === "mfa_waiting";
const bindingResults = session?.binding?.results || [];
const connectedPlans = bindingResults.filter((r) => r?.ok);
const bindingError = session?.binding?.error;
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
// ── render ──────────────────────────────────────────────────────────────
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={providerText(t, "connectVolcengineAccount", "Connect Volcano Account")}
size="md"
>
<div className="space-y-4">
{showPhoneStep && (
<>
<p className="text-sm text-text-muted">
{providerText(
t,
"volcAutoLoginDesc",
"Enter your phone number. OmniRoute sends a verification code via the Volcano Engine console and extracts the session cookies automatically — no browser interaction needed."
)}
</p>
<Input
label={providerText(t, "volcPhoneLabel", "Phone number")}
placeholder="13800000000"
value={phone}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPhone(e.target.value)}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") void handleStart();
}}
inputMode="numeric"
/>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={handleClose}>
{providerText(t, "cancel", "Cancel")}
</Button>
<Button size="sm" loading={starting} disabled={!phone.trim()} onClick={handleStart}>
{providerText(t, "volcSendCode", "Send verification code")}
</Button>
</div>
</>
)}
{showCodeStep && (
<>
<p className="text-sm text-text-muted">
{mfaStep
? providerText(
t,
"volcMfaDesc",
"Additional verification required (MFA). A NEW 6-digit code was sent to {phone} — enter it below to finish login.",
{ phone: session?.phoneMasked || "your phone" }
)
: phase === "identity_required"
? providerText(
t,
"volcIdentityDesc",
"Your phone number is linked to multiple Volcano Engine identities. Pick the one you want to log in with:"
)
: providerText(
t,
"volcCodeSent",
"A verification code was sent to {phone}. Enter it below to finish login.",
{ phone: session?.phoneMasked || "your phone" }
)}
</p>
{phase === "identity_required" && session?.identityOptions?.length ? (
<div className="space-y-2">
{session.identityOptions.map((option) => (
<button
key={option.index}
type="button"
disabled={selectingIdentity}
onClick={() => handleSelectIdentity(option.index)}
className="w-full rounded-lg border border-border p-3 text-left text-sm transition-colors hover:bg-sidebar disabled:cursor-not-allowed disabled:opacity-60"
>
{selectingIdentity ? (
<span className="flex items-center gap-2">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-primary border-t-transparent" />
{option.label}
</span>
) : (
option.label
)}
</button>
))}
</div>
) : (
<>
{phase === "captcha_required" && session?.captchaImage && (
<div className="space-y-2">
<p className="text-sm font-medium">
{providerText(
t,
"volcCaptchaLabel",
"Image captcha (required by the console)"
)}
</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={session.captchaImage}
alt="captcha"
className="max-h-40 rounded border border-border"
/>
<Input
placeholder={providerText(t, "volcCaptchaPlaceholder", "Captcha characters")}
value={captcha}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setCaptcha(e.target.value)
}
/>
</div>
)}
<Input
label={
mfaStep
? providerText(t, "volcMfaCodeLabel", "MFA verification code")
: providerText(t, "volcCodeLabel", "Verification code")
}
placeholder="123456"
value={code}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCode(e.target.value)}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") void handleSubmitCode();
}}
inputMode="numeric"
maxLength={6}
/>
{session?.error && <p className="text-sm text-red-500">{session.error}</p>}
<div className="flex items-center justify-between">
<Button
variant="secondary"
size="sm"
loading={resending}
disabled={resendCountdown > 0}
onClick={handleResend}
>
{resendCountdown > 0
? providerText(t, "volcResendIn", "Resend in {s}s", { s: resendCountdown })
: providerText(t, "volcResend", "Resend code")}
</Button>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={handleCancelSession}>
{providerText(t, "back", "Back")}
</Button>
<Button
size="sm"
loading={submittingCode}
disabled={code.trim().length < 4}
onClick={handleSubmitCode}
>
{providerText(t, "volcLogin", "Log in")}
</Button>
</div>
</div>
</>
)}
</>
)}
{showPolling && (
<div className="flex items-center gap-3 py-2">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-text-muted">
{phase === "submitting"
? providerText(
t,
"volcSubmitting",
"Submitting code and extracting console cookies..."
)
: providerText(t, "volcStarting", "Starting Volcano login...")}
</p>
</div>
)}
{done && phase === "success" && (
<div className="space-y-3">
<p className="text-sm font-medium text-green-600">
{providerText(t, "volcLoginSuccess", "Logged in to the Volcano Engine console")}
</p>
{bindingError ? (
<p className="text-sm text-red-500">
{providerText(t, "volcBindError", "Plan binding failed: {error}", {
error: bindingError,
})}
</p>
) : (
<div className="space-y-1 text-sm">
{connectedPlans.length > 0 ? (
connectedPlans.map((item) => (
<p key={item.plan} className="text-green-600">
{item.plan} plan connected
</p>
))
) : (
<p className="text-text-muted">
{providerText(
t,
"volcNoPlans",
"No Agent/Coding plans were detected on this account."
)}
</p>
)}
</div>
)}
<div className="flex justify-end">
<Button size="sm" onClick={handleClose}>
{providerText(t, "done", "Done")}
</Button>
</div>
</div>
)}
{done && phase !== "success" && (
<div className="space-y-3">
<p className="text-sm text-red-500">
{session?.error ||
(phase === "timeout"
? providerText(t, "volcTimeout", "Login timed out")
: phase === "cancelled"
? providerText(t, "volcCancelled", "Login cancelled")
: providerText(t, "volcFailed", "Login failed"))}
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={reset}>
{providerText(t, "retry", "Retry")}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
onClose();
onFallbackManual();
}}
>
{providerText(t, "volcManualLogin", "Manual browser login")}
</Button>
</div>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -24,6 +24,46 @@ interface SyncResult {
error?: string;
}
// Slider works in "checkpoint space": position p ∈ [0, 3] maps linearly onto
// these hour values, so the evenly spaced tick labels always match the thumb.
const INTERVAL_CHECKPOINTS = [1, 6, 24, 168];
const SNAP_THRESHOLD = 0.15;
function positionToHours(pos: number): number {
const p = Math.min(INTERVAL_CHECKPOINTS.length - 1, Math.max(0, pos));
const lower = Math.floor(p);
const upper = Math.ceil(p);
if (lower === upper) return INTERVAL_CHECKPOINTS[lower];
const t = p - lower;
return Math.round(
INTERVAL_CHECKPOINTS[lower] + (INTERVAL_CHECKPOINTS[upper] - INTERVAL_CHECKPOINTS[lower]) * t
);
}
function hoursToPosition(hours: number): number {
const cps = INTERVAL_CHECKPOINTS;
if (hours <= cps[0]) return 0;
for (let i = 0; i < cps.length - 1; i++) {
if (hours <= cps[i + 1]) {
return i + (hours - cps[i]) / (cps[i + 1] - cps[i]);
}
}
return cps.length - 1;
}
// Magnetic checkpoints: snap to a reference point when released nearby,
// otherwise keep the freely chosen position.
function snapPosition(pos: number): number {
for (let i = 0; i < INTERVAL_CHECKPOINTS.length; i++) {
if (Math.abs(pos - i) <= SNAP_THRESHOLD) return i;
}
return pos;
}
function formatInterval(hours: number): string {
return hours === 168 ? "7d" : `${hours}h`;
}
export default function ModelsDevSyncTab() {
const t = useTranslations("settings");
const [status, setStatus] = useState<ModelsDevStatus | null>(null);
@@ -32,7 +72,7 @@ export default function ModelsDevSyncTab() {
const [saving, setSaving] = useState(false);
const [enabled, setEnabled] = useState(false);
const [intervalHours, setIntervalHours] = useState(24);
const [draftIntervalHours, setDraftIntervalHours] = useState(24);
const [draftPos, setDraftPos] = useState(2);
const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>(
null
);
@@ -58,7 +98,7 @@ export default function ModelsDevSyncTab() {
const intervalMs = settingsData.modelsDevSyncInterval || 86400000;
const hours = Math.round(intervalMs / 3600000);
setIntervalHours(hours);
setDraftIntervalHours(hours);
setDraftPos(hoursToPosition(hours));
}
})
.catch((err) => {
@@ -126,7 +166,7 @@ export default function ModelsDevSyncTab() {
const updateInterval = async (hours: number) => {
const oldInterval = intervalHours;
setIntervalHours(hours);
setDraftIntervalHours(hours);
setDraftPos(hoursToPosition(hours));
try {
const res = await fetch("/api/settings", {
method: "PATCH",
@@ -135,20 +175,27 @@ export default function ModelsDevSyncTab() {
});
if (!res.ok) {
setIntervalHours(oldInterval);
setDraftIntervalHours(oldInterval);
setDraftPos(hoursToPosition(oldInterval));
setFeedback({ type: "error", message: t("enableSyncError") });
} else {
setFeedback({ type: "success", message: "Interval updated" });
}
} catch {
setIntervalHours(oldInterval);
setDraftIntervalHours(oldInterval);
setDraftPos(hoursToPosition(oldInterval));
setFeedback({ type: "error", message: "Network error" });
} finally {
setTimeout(() => setFeedback(null), 3000);
}
};
// Commit on release: snap to a checkpoint when near one, else keep free value.
const commitDraftInterval = () => {
const snapped = snapPosition(draftPos);
if (snapped !== draftPos) setDraftPos(snapped);
updateInterval(positionToHours(snapped));
};
if (loading) {
return (
<Card>
@@ -238,18 +285,20 @@ export default function ModelsDevSyncTab() {
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium">{t("modelsDevInterval")}</p>
<span className="text-sm font-mono tabular-nums text-blue-400">
{draftIntervalHours}h
{formatInterval(positionToHours(draftPos))}
</span>
</div>
<input
type="range"
min="1"
max="168"
step="1"
value={draftIntervalHours}
onChange={(e) => setDraftIntervalHours(parseInt(e.target.value))}
onMouseUp={(e) => updateInterval(parseInt((e.target as HTMLInputElement).value))}
onBlur={(e) => updateInterval(parseInt(e.target.value))}
min="0"
max={INTERVAL_CHECKPOINTS.length - 1}
step="any"
value={draftPos}
onChange={(e) => setDraftPos(parseFloat(e.target.value))}
onMouseUp={commitDraftInterval}
onTouchEnd={commitDraftInterval}
onBlur={commitDraftInterval}
aria-label={t("modelsDevInterval")}
className="w-full accent-blue-500"
/>
<div className="flex justify-between text-xs text-text-muted mt-1">

View File

@@ -10,6 +10,7 @@ import {
VIDEO_BRIDGE_TIMEOUT_MAX_MS,
VIDEO_BRIDGE_TIMEOUT_MIN_MS,
resolveVideoBridgeRuntimeSettings,
type VideoAnalysisMode,
type VideoSamplingPolicy,
} from "@/shared/constants/modalityBridgeDefaults";
@@ -17,6 +18,7 @@ import ModalityBridgeStatsRow from "./ModalityBridgeStatsRow";
interface VideoState {
modalityBridgeVideoEnabled: boolean;
modalityBridgeVideoAnalysisMode: VideoAnalysisMode;
modalityBridgeVideoModel: string;
modalityBridgeVideoFrameCount: number;
modalityBridgeVideoSamplingPolicy: VideoSamplingPolicy;
@@ -44,6 +46,7 @@ function fromApi(value: unknown): VideoState {
const runtime = resolveVideoBridgeRuntimeSettings(asRecord(value));
return {
modalityBridgeVideoEnabled: runtime.enabled,
modalityBridgeVideoAnalysisMode: runtime.analysisMode,
modalityBridgeVideoModel: runtime.model,
modalityBridgeVideoFrameCount: runtime.frameCount,
modalityBridgeVideoSamplingPolicy: runtime.samplingPolicy,
@@ -223,6 +226,32 @@ export default function ModalityBridgeVideoTab({
description={t("modalityBridgeVideoEnabledDesc")}
/>
<label className="block text-sm font-medium">
{t("modalityBridgeMode")}
<select
data-testid="modality-bridge-video-analysis-mode"
aria-describedby="modality-bridge-video-analysis-mode-description"
value={settings.modalityBridgeVideoAnalysisMode}
onChange={(event) =>
void update({
modalityBridgeVideoAnalysisMode: event.currentTarget.value as VideoAnalysisMode,
})
}
className="mt-1 w-full rounded-control border border-border bg-surface px-3 py-2 text-sm"
>
<option value="full">{tRoot("health.degradationFull")}</option>
<option value="focused">{t("modalityBridgeTaskAware")}</option>
</select>
<span
id="modality-bridge-video-analysis-mode-description"
className="mt-1 block text-xs font-normal text-text-muted"
>
{settings.modalityBridgeVideoAnalysisMode === "focused"
? t("modalityBridgeTaskAwareDesc")
: t("modalityBridgeVideoDesc")}
</span>
</label>
<ModelSelectField
label={t("modalityBridgeVideoModel")}
value={settings.modalityBridgeVideoModel}

View File

@@ -1,22 +1,149 @@
import { z } from "zod";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
import {
VIDEO_BRIDGE_BROKER_PATH,
isVideoBridgeBrokerInternalRequest,
resolveVideoBridgeDrilldownPrincipal,
VIDEO_BRIDGE_DRILLDOWN_PATH,
} from "@/lib/guardrails/videoBridgeBrokerAuth";
import {
VideoDrilldownAbortedError,
VideoDrilldownCache,
type VideoDrilldownFrame,
VideoDrilldownValidationError,
VIDEO_DRILLDOWN_MAX_ENTRY_BYTES,
VIDEO_DRILLDOWN_MAX_FRAME_DATA_URI_CHARS,
} from "@/lib/guardrails/videoBridgeDrilldown";
import { resolveModelSyncInternalBaseUrl } from "@/shared/services/modelSyncScheduler";
import { createLogger } from "@/shared/utils/logger";
const log = createLogger("video-bridge-drilldown");
export const dynamic = "force-dynamic";
export const revalidate = 0;
export const VIDEO_BRIDGE_DRILLDOWN_PATH = "/api/modality-bridge/video/drilldown";
const MAX_BODY_BYTES = 34 * 1024 * 1024;
export { VIDEO_BRIDGE_DRILLDOWN_PATH };
export const VIDEO_DRILLDOWN_MAX_BODY_BYTES =
Math.ceil(VIDEO_DRILLDOWN_MAX_ENTRY_BYTES / 3) * 4 + 64 * 1024;
function isCanonicalOpaqueId(value: string): boolean {
return value === value.trim();
}
function isAsciiAlphaNumeric(code: number): boolean {
return (
(code >= 0x30 && code <= 0x39) ||
(code >= 0x41 && code <= 0x5a) ||
(code >= 0x61 && code <= 0x7a)
);
}
function isDerivationToken(value: string): boolean {
if (value.length < 1 || value.length > 64 || !isAsciiAlphaNumeric(value.charCodeAt(0))) {
return false;
}
for (let index = 1; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (
!isAsciiAlphaNumeric(code) &&
code !== 0x2e &&
code !== 0x5f &&
code !== 0x2f &&
code !== 0x2d
) {
return false;
}
}
return true;
}
function isSha256Id(value: string): boolean {
if (value.length !== 71 || !value.startsWith("sha256:")) return false;
for (let index = 7; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (!((code >= 0x30 && code <= 0x39) || (code >= 0x61 && code <= 0x66))) return false;
}
return true;
}
function isCanonicalNonNegativeNumber(value: string): boolean {
if (value.length < 1 || value.length > 64 || value !== value.trim()) return false;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0;
}
function isCanonicalFrameCount(value: string): boolean {
if (value.length < 1 || value.length > 2) return false;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code < 0x30 || code > 0x39) return false;
}
const parsed = Number(value);
return parsed >= 1 && parsed <= 16;
}
const SessionIdSchema = z
.string()
.min(1)
.max(128)
.refine(isCanonicalOpaqueId, "sessionId must not contain surrounding whitespace");
const VideoRefSchema = z
.string()
.min(1)
.max(4096)
.refine(isCanonicalOpaqueId, "videoRef must not contain surrounding whitespace");
const NonNegativeQueryNumberSchema = z
.string()
.refine(isCanonicalNonNegativeNumber)
.transform(Number);
const FrameCountQuerySchema = z.string().refine(isCanonicalFrameCount).transform(Number);
const DrilldownReadQuerySchema = z
.object({
end: NonNegativeQueryNumberSchema.optional(),
frames: FrameCountQuerySchema.optional(),
sessionId: SessionIdSchema,
start: NonNegativeQueryNumberSchema.optional(),
videoRef: VideoRefSchema,
})
.strict();
const DrilldownDeleteQuerySchema = z.object({ sessionId: SessionIdSchema }).strict();
const DrilldownDerivationSchema = z
.object({
parentContentHash: z.string().refine(isSha256Id),
policy: z.string().refine(isDerivationToken),
version: z.string().refine(isDerivationToken),
})
.strict();
const DrilldownFrameSchema = z
.object({
dataUri: z.string().min(1).max(VIDEO_DRILLDOWN_MAX_FRAME_DATA_URI_CHARS),
timestampSeconds: z.number().finite().nonnegative(),
})
.strict();
const DrilldownPostBodySchema = z
.object({
derivation: DrilldownDerivationSchema,
durationSeconds: z.number().finite().positive().max(600),
frames: z.array(DrilldownFrameSchema).min(1).max(16),
sessionId: SessionIdSchema,
videoRef: VideoRefSchema,
})
.strict()
.superRefine((value, context) => {
for (let index = 0; index < value.frames.length; index += 1) {
if (value.frames[index].timestampSeconds > value.durationSeconds) {
context.addIssue({
code: "custom",
message: "frame timestamp exceeds duration",
path: ["frames", index, "timestampSeconds"],
});
}
}
});
const drilldownCache = new VideoDrilldownCache({
maxEntries: 64,
// Global decoded-byte ceiling: without it, 64 entries × 32 MiB could pin ~2 GiB.
maxEntriesPerPrincipal: 16,
maxBytesPerPrincipal: 64 * 1024 * 1024,
// Global retained-JPEG ceiling: without it, 64 entries × 32 MiB could pin ~2 GiB.
maxTotalBytes: 256 * 1024 * 1024,
ttlMs: 10 * 60 * 1000,
});
@@ -30,6 +157,47 @@ function invalid(message: string, status = 400): Response {
return createErrorResponse({ status, message, type: "invalid_request" });
}
class VideoDrilldownRequestAbortedError extends Error {}
function queryRecord(searchParams: URLSearchParams): Record<string, string | string[]> {
const values: Record<string, string | string[]> = {};
for (const [key, value] of searchParams) {
const existing = values[key];
values[key] =
existing === undefined
? value
: Array.isArray(existing)
? [...existing, value]
: [existing, value];
}
return values;
}
function yieldToEventLoop(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
async function readBodyWithAbort(request: Request): Promise<ArrayBuffer> {
if (request.signal.aborted) throw new VideoDrilldownRequestAbortedError();
return new Promise<ArrayBuffer>((resolve, reject) => {
const onAbort = () => {
request.signal.removeEventListener("abort", onAbort);
reject(new VideoDrilldownRequestAbortedError());
};
request.signal.addEventListener("abort", onAbort, { once: true });
request.arrayBuffer().then(
(bytes) => {
request.signal.removeEventListener("abort", onAbort);
resolve(bytes);
},
(error: unknown) => {
request.signal.removeEventListener("abort", onAbort);
reject(error);
}
);
});
}
function parseQuery(url: URL): {
endSeconds?: number;
frameCount?: number;
@@ -37,28 +205,15 @@ function parseQuery(url: URL): {
startSeconds?: number;
videoRef: string;
} | null {
const allowed = new Set(["end", "frames", "sessionId", "start", "videoRef"]);
if ([...url.searchParams.keys()].some((key) => !allowed.has(key))) return null;
const sessionId = url.searchParams.get("sessionId")?.trim() ?? "";
const videoRef = url.searchParams.get("videoRef")?.trim() ?? "";
if (!sessionId || !videoRef) return null;
const parseNumber = (name: string): number | undefined | null => {
const value = url.searchParams.get(name);
if (value === null) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
const parsed = DrilldownReadQuerySchema.safeParse(queryRecord(url.searchParams));
if (!parsed.success) return null;
return {
endSeconds: parsed.data.end,
frameCount: parsed.data.frames,
sessionId: parsed.data.sessionId,
startSeconds: parsed.data.start,
videoRef: parsed.data.videoRef,
};
const startSeconds = parseNumber("start");
const endSeconds = parseNumber("end");
const rawFrameCount = url.searchParams.get("frames");
const frameCount =
rawFrameCount === null
? undefined
: /^\d{1,2}$/.test(rawFrameCount) && Number(rawFrameCount) >= 1 && Number(rawFrameCount) <= 16
? Number(rawFrameCount)
: null;
if (startSeconds === null || endSeconds === null || frameCount === null) return null;
return { endSeconds, frameCount, sessionId, startSeconds, videoRef };
}
interface VideoDrilldownRouteDependencies {
@@ -71,60 +226,72 @@ export async function handleVideoDrilldownRequest(
): Promise<Response> {
const url = new URL(request.url);
if (url.pathname !== expectedPath()) return invalid("Invalid Video Bridge drill-down path", 404);
if (!isVideoBridgeBrokerInternalRequest(request, VIDEO_BRIDGE_BROKER_PATH)) {
const principalId = resolveVideoBridgeDrilldownPrincipal(request);
if (!principalId) {
return invalid("This endpoint requires an authenticated internal loopback request", 403);
}
const cache = dependencies.cache ?? drilldownCache;
if (request.method === "GET") {
const query = parseQuery(url);
if (!query) return invalid("Invalid Video Bridge drill-down query");
const result = cache.get(query.sessionId, query.videoRef, query);
const result = cache.get(principalId, query.sessionId, query.videoRef, query);
return result
? Response.json(result, { headers: { "Cache-Control": "no-store" } })
: invalid("Video Bridge drill-down result was not found", 404);
}
if (request.method === "DELETE") {
const sessionId = url.searchParams.get("sessionId")?.trim() ?? "";
if (!sessionId || [...url.searchParams.keys()].some((key) => key !== "sessionId")) {
return invalid("A sessionId is required");
}
return Response.json({ removed: cache.clearSession(sessionId) });
const query = DrilldownDeleteQuerySchema.safeParse(queryRecord(url.searchParams));
if (!query.success) return invalid("A canonical sessionId is required");
return Response.json({ removed: cache.clearSession(principalId, query.data.sessionId) });
}
if (request.method !== "POST") return invalid("Invalid Video Bridge drill-down method", 405);
if (request.headers.get("content-type")?.toLowerCase() !== "application/json") {
return invalid("Video Bridge drill-down requires application/json");
}
const declaredLength = Number(request.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
if (Number.isFinite(declaredLength) && declaredLength > VIDEO_DRILLDOWN_MAX_BODY_BYTES) {
return invalid("Video Bridge drill-down payload is too large", 413);
}
let body: unknown;
try {
const bytes = await request.arrayBuffer();
if (bytes.byteLength > MAX_BODY_BYTES)
const bytes = await readBodyWithAbort(request);
if (bytes.byteLength > VIDEO_DRILLDOWN_MAX_BODY_BYTES)
return invalid("Video Bridge drill-down payload is too large", 413);
body = JSON.parse(Buffer.from(bytes).toString("utf8"));
} catch {
} catch (error: unknown) {
if (error instanceof VideoDrilldownRequestAbortedError) {
return invalid("Video Bridge drill-down request was cancelled", 499);
}
return invalid("Video Bridge drill-down payload is invalid");
}
if (!body || typeof body !== "object")
return invalid("Video Bridge drill-down payload is invalid");
const record = body as Record<string, unknown>;
if (
typeof record.sessionId !== "string" ||
typeof record.videoRef !== "string" ||
typeof record.durationSeconds !== "number" ||
!Array.isArray(record.frames)
) {
return invalid("Video Bridge drill-down payload is invalid");
const parsed = DrilldownPostBodySchema.safeParse(body);
if (!parsed.success) return invalid("Video Bridge drill-down payload is invalid");
await yieldToEventLoop();
if (request.signal.aborted) {
return invalid("Video Bridge drill-down request was cancelled", 499);
}
try {
cache.put(record.sessionId, record.videoRef, {
durationSeconds: record.durationSeconds,
frames: record.frames as VideoDrilldownFrame[],
await cache.put(principalId, parsed.data.sessionId, parsed.data.videoRef, parsed.data, {
signal: request.signal,
});
} catch (error: unknown) {
if (error instanceof VideoDrilldownValidationError) {
return invalid("Video Bridge drill-down payload is invalid");
}
if (error instanceof VideoDrilldownAbortedError || request.signal.aborted) {
return invalid("Video Bridge drill-down request was cancelled", 499);
}
log.error(
{
errorName: error instanceof Error ? sanitizeErrorMessage(error.name) : "UnknownError",
},
"Unexpected Video Bridge drill-down cache failure"
);
return createErrorResponse({
status: 500,
message: "Video Bridge drill-down could not be stored",
type: "server_error",
});
} catch {
return invalid("Video Bridge drill-down payload is invalid");
}
return Response.json({ stored: true }, { status: 201, headers: { "Cache-Control": "no-store" } });
}

View File

@@ -25,6 +25,7 @@ import {
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { isValidGheUrl } from "@/shared/validation/providerSpecificData";
import { AWS_REGION_PATTERN } from "@/lib/oauth/constants/oauth";
import { antigravityDegradedProjectState } from "@/lib/oauth/antigravityProjectGate";
import { syncToCloud } from "@/lib/cloudSync";
import { startLocalServer } from "@/lib/oauth/utils/server";
import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
@@ -520,6 +521,12 @@ export async function POST(
exchangeTokens(provider, code, redirectUri, codeVerifier, normalizedState)
);
// #11284: when Cloud Code projectId discovery failed at connect time,
// SAVE the connection but mark it degraded (maintainer direction on
// #11284) — the refresh token stays stored and request-time bootstrap
// self-heals the row once Google assigns a project.
const degradedProject = antigravityDegradedProjectState(provider, tokenData);
// Normalize: if name is missing, use email or displayName as fallback so accounts
// always show a real label (e.g. user@gmail.com) instead of "Account #abc123"
if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
@@ -542,14 +549,15 @@ export async function POST(
connection = await updateProviderConnection(matchId, {
...tokenData,
expiresAt,
testStatus: "active",
testStatus: degradedProject?.testStatus ?? "active",
...(degradedProject ?? {}),
isActive: true,
});
}
}
if (!connection) {
connection = await createProviderConnection(
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt)
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt, degradedProject)
);
}
@@ -558,6 +566,7 @@ export async function POST(
return NextResponse.json({
success: true,
...(degradedProject ? { warning: degradedProject.warning } : {}),
connection: {
id: connection.id,
provider: connection.provider,
@@ -739,6 +748,10 @@ export async function POST(
exchangeTokens(provider, params.code, redirectUri, codeVerifier, params.state)
);
// #11284: when Cloud Code projectId discovery failed at connect time,
// SAVE the connection but mark it degraded (maintainer direction).
const degradedProject = antigravityDegradedProjectState(provider, tokenData);
// Normalize: if name is missing, use email as fallback display label
if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
tokenData.name = tokenData.email || tokenData.displayName;
@@ -765,14 +778,15 @@ export async function POST(
connection = await updateProviderConnection(matchId, {
...tokenData,
expiresAt,
testStatus: "active",
testStatus: degradedProject?.testStatus ?? "active",
...(degradedProject ?? {}),
isActive: true,
});
}
}
if (!connection) {
connection = await createProviderConnection(
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt)
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt, degradedProject)
);
}
@@ -780,6 +794,7 @@ export async function POST(
return NextResponse.json({
success: true,
...(degradedProject ? { warning: degradedProject.warning } : {}),
connection: {
id: connection.id,
provider: connection.provider,

View File

@@ -21,6 +21,11 @@ import {
import { autoSyncCodexProfilesFromLiveCatalog } from "@/lib/cli-helper/codexProfileAutoSync";
import { autoSyncClaudeProfilesFromLiveCatalog } from "@/lib/cli-helper/claudeProfileAutoSync";
import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability";
import {
fetchVolcPlanModels,
providerToVolcPlanKind,
} from "@/lib/providers/volcenginePlanModelDiscovery";
import { replaceSyncedAvailableModelsForConnection } from "@/lib/db/models";
import { GET as getProviderModels } from "../models/route";
import { isDegradedDiscovery } from "./degradedLocalCatalog";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
@@ -423,6 +428,84 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
logProvider = toNonEmptyString(connection.provider) || "unknown";
channelLabel = getModelSyncChannelLabel(connection);
// Volcano Ark plan providers: discover models live from the console API
// (cookie+csrf captured at bind time). The chat API has no /models
// endpoint, so the default discovery path below cannot serve them.
const volcPlanKind = providerToVolcPlanKind(logProvider);
if (volcPlanKind) {
const psd =
connection.providerSpecificData && typeof connection.providerSpecificData === "object"
? (connection.providerSpecificData as JsonRecord)
: {};
const cookie = toNonEmptyString(psd.volcConsoleCookie) || "";
const csrf = toNonEmptyString(psd.volcCsrfToken) || "";
const duration = Date.now() - start;
let discovered;
try {
discovered = await fetchVolcPlanModels(volcPlanKind, cookie, csrf);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
await saveCallLog({
method: "POST",
path: `/api/providers/${id}/sync-models`,
status: 401,
model: "model-sync",
provider: logProvider,
sourceFormat: "-",
connectionId: id,
duration,
error: message,
requestType: "model-sync",
...(channelLabel ? { responseBody: { channel: channelLabel } } : {}),
}).catch(() => undefined);
return NextResponse.json(
{ error: sanitizeErrorMessage(message) || "Volcano plan discovery failed" },
{ status: 401 }
);
}
const previous = await getSyncedAvailableModelsForConnection(logProvider, id);
const synced = await replaceSyncedAvailableModelsForConnection(logProvider, id, discovered);
const prevIds = new Set(previous.map((m) => String(m.id)));
const added = synced.filter((m) => !prevIds.has(String(m.id))).length;
const removed = previous.filter(
(m) => !synced.some((n) => String(n.id) === String(m.id))
).length;
await saveCallLog({
method: "GET",
path: `/api/providers/${id}/models`,
status: 200,
model: "model-sync",
provider: logProvider,
sourceFormat: "console-discovery",
connectionId: id,
duration: Date.now() - start,
requestType: "model-sync",
responseBody: {
source: "volcengine-plan-console-discovery",
plan: volcPlanKind,
syncedModels: synced.length,
added,
removed,
provider: logProvider,
channel: channelLabel,
mode,
},
}).catch(() => undefined);
return NextResponse.json({
ok: true,
provider: logProvider,
connectionId: id,
source: "volcengine-plan-console-discovery",
plan: volcPlanKind,
mode,
syncedModels: synced.length,
availableModelsCount: synced.length,
modelChanges: { added, removed, total: added + removed },
models: synced,
});
}
if (providerUsesCuratedModelsOnly(logProvider)) {
const [removedSyncedLists, removedImportedModelIds] = await Promise.all([
deleteSyncedAvailableModelsForProvider(logProvider),

View File

@@ -30,6 +30,7 @@ import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
import { shouldUseApiKeyConnectionTest } from "./webSessionTestDispatch";
import { testCodexAppServerConnection, makeDiagnosis } from "./codexAppServerHealth";
import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
import { shouldClearErrorStateOnValidProbe } from "@/lib/usage/providerLimits";
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult";
@@ -1082,23 +1083,46 @@ export async function testSingleConnection(connectionId: string, validationModel
terminalTestStatuses.has(String(diagnosis.code ?? diagnosis.type ?? "").toLowerCase());
const testFailureCooldownMs = result.valid ? 0 : 30_000; // 30s retry window
// A successful credential probe proves the KEY is valid. It does NOT prove the
// quota window reopened: the probe is a cheap auth/models call that never touches
// the chat quota a weekly cap applies to. Clearing an ACTIVE cooldown here — which
// the credential-health scheduler triggers for every connection every 300s — put
// `zai/glm-5.3` back to `active` / `rate_limited_until = NULL` within 30s of every
// restart, so combo dispatched it straight into the same weekly 429. Same rule as
// maybeClearRecoveredQuotaState: a future rateLimitedUntil is the 429 handler's
// hard statement and no poller may overrule it. Once it elapses, the next probe
// clears it normally.
const clearErrorState = shouldClearErrorStateOnValidProbe(
connection as { rateLimitedUntil?: string | null },
result.valid
);
const updateData: Record<string, any> = {
testStatus: result.valid ? "active" : "error",
lastError: result.valid ? null : result.error,
lastErrorAt: result.valid ? null : now,
testStatus: clearErrorState ? "active" : result.valid ? connection.testStatus : "error",
lastError: clearErrorState ? null : result.valid ? connection.lastError : result.error,
lastErrorAt: clearErrorState ? null : result.valid ? connection.lastErrorAt : now,
lastTested: now,
lastErrorType: result.valid ? null : diagnosis.type,
lastErrorSource: result.valid ? null : diagnosis.source,
errorCode: result.valid ? null : diagnosis.code || result.statusCode || null,
rateLimitedUntil:
result.valid || isTerminalFailure
? result.valid
? null
: connection.rateLimitedUntil || null
: new Date(Date.now() + testFailureCooldownMs).toISOString(),
lastErrorType: clearErrorState ? null : result.valid ? connection.lastErrorType : diagnosis.type,
lastErrorSource: clearErrorState
? null
: result.valid
? connection.lastErrorSource
: diagnosis.source,
errorCode: clearErrorState
? null
: result.valid
? connection.errorCode
: diagnosis.code || result.statusCode || null,
rateLimitedUntil: clearErrorState
? null
: isTerminalFailure
? connection.rateLimitedUntil || null
: result.valid
? connection.rateLimitedUntil || null
: new Date(Date.now() + testFailureCooldownMs).toISOString(),
};
if (result.valid) {
if (clearErrorState) {
updateData.backoffLevel = 0;
const psd = connection?.providerSpecificData as Record<string, unknown> | undefined;

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