Compare commits

...

116 Commits

Author SHA1 Message Date
diegosouzapw
d4e321f1dd fix(i18n): restore canonical Traditional Chinese glossary 2026-08-09 22:46:44 -03:00
diegosouzapw
92499fc876 fix(i18n): sync capability filter locales after release merge 2026-08-09 22:15:54 -03:00
diegosouzapw
2d42c54a67 docs(modality-bridge): align migration note with dead-code decision 2026-08-09 21:39:50 -03:00
diegosouzapw
bbaa820efb merge: reconcile Modality Bridge with release/v3.8.50 2026-08-09 21:37:10 -03:00
diegosouzapw
964e321588 chore(i18n): prepare conflict-free Modality Bridge base sync 2026-08-09 21:30:16 -03:00
Diego Rodrigues de Sa e Souza
06a691f8fe Merge pull request #9424 from diegosouzapw/feat/5696-layer-a-capability-filter
feat(core): add Layer A capability filter at router (#5696)
2026-08-09 20:51:28 -03:00
SB Yoon
40e1562850 fix(i18n): re-escape CC discovery-alias angle brackets for next-intl (#9917)
* fix(i18n): re-escape CC discovery-alias angle brackets for next-intl

Restore #8747 HTML-entity escaping for claude/<provider>/<model> in the
three CC discovery-alias message keys so next-intl stops logging
INVALID_MESSAGE: UNCLOSED_TAG on provider detail pages after the bulk
entity-unescape regression.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(i18n): align conflict context with release

* fix(i18n): cover localized CC alias placeholders

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-08-09 20:27:18 -03:00
diegosouzapw
63bf4b909d Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9424
# Conflicts:
#	config/quality/file-size-baseline.json
2026-08-09 20:02:17 -03:00
Diego Rodrigues de Sa e Souza
fade1cbab3 Merge pull request #9341 from diegosouzapw/feat/9268-gemini-schema-recursive-type-empty-choices
feat(gemini): recursive schema type:object + empty choices interceptor (#9268)
2026-08-09 20:01:44 -03:00
Diego Rodrigues de Sa e Souza
3aa0a7306f Merge pull request #9337 from diegosouzapw/feat/9322-nanogpt-endpoint-surface
feat: expose full NanoGPT endpoint surface (#9322)
2026-08-09 20:01:41 -03:00
Diego Rodrigues de Sa e Souza
9d06dcfd6c Merge pull request #9493 from diegosouzapw/feat/8468-bun-windows-ci-coverage
feat(ci): add windows-latest leg to test-bun-sqlite job (#8468)
2026-08-09 20:01:39 -03:00
diegosouzapw
9747123f00 test(flags): account for capability filter flag 2026-08-09 19:41:17 -03:00
Diego Rodrigues de Sa e Souza
ab8f3e83b7 fix(ci): allow test-masking to finish in release preflight (#9964)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-09 19:41:03 -03:00
diegosouzapw
8685815235 fix(i18n): translate capability filter messages 2026-08-09 19:23:58 -03:00
diegosouzapw
e53ca645f3 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9424 2026-08-09 19:23:58 -03:00
Diego Rodrigues de Sa e Souza
1828b6c14a Merge pull request #9784 from benzntech/feat/cookie-editor-guide-ui
feat(providers): add Cookie Editor fast-path to web session credential guide
2026-08-09 19:10:32 -03:00
diegosouzapw
cf0f416da3 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9424 2026-08-09 18:57:50 -03:00
diegosouzapw
2c8cb81fc0 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9341 2026-08-09 18:57:46 -03:00
diegosouzapw
1287a4f2ec Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9337 2026-08-09 18:57:00 -03:00
diegosouzapw
471d39bc1f Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9493 2026-08-09 18:56:57 -03:00
diegosouzapw
99781e000d Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave2b-9784 2026-08-09 18:56:19 -03:00
Diego Rodrigues de Sa e Souza
159b90125c Merge pull request #9749 from benzntech/fix/lean-defaults-three-defects
fix(logging): make stream-chunk capture and request-shape logging opt-in
2026-08-09 18:55:56 -03:00
diegosouzapw
70fca36f9d fix(i18n): complete web session guide translations
Co-authored-by: benzntech <4044180+benzntech@users.noreply.github.com>
2026-08-09 18:54:55 -03:00
diegosouzapw
94d386dbc5 fix(quality): update capability gate frozen cap 2026-08-09 18:51:34 -03:00
diegosouzapw
3ff25a484f fix(stream): type empty-choice collector events 2026-08-09 18:47:22 -03:00
diegosouzapw
2bea34b0a6 chore(quality): attribute capability gate growth 2026-08-09 18:47:22 -03:00
diegosouzapw
f3ba480c08 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9341 2026-08-09 18:28:40 -03:00
diegosouzapw
8e812c53e5 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9424 2026-08-09 18:28:40 -03:00
diegosouzapw
f39daccfcb Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9337 2026-08-09 18:28:39 -03:00
diegosouzapw
40f9a81b66 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9493 2026-08-09 18:28:38 -03:00
diegosouzapw
c8f4efa158 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave2b-9784 2026-08-09 18:28:07 -03:00
diegosouzapw
bfc846fae6 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave2b-9749 2026-08-09 18:28:06 -03:00
Diego Rodrigues de Sa e Souza
5f16478c7e Merge pull request #9781 from benzntech/docs/chatgpt-web-cookie-guide
docs(providers): ChatGPT Web session credential guide (Cookie Editor workflow)
2026-08-09 18:27:48 -03:00
diegosouzapw
493cae2ed9 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9424
# Conflicts:
#	config/quality/file-size-baseline.json
#	src/i18n/messages/en.json
#	src/i18n/messages/pt-BR.json
#	src/shared/constants/featureFlagDefinitions.ts
#	stryker.conf.json
2026-08-09 18:25:44 -03:00
diegosouzapw
a6f095c583 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9341
# Conflicts:
#	config/quality/file-size-baseline.json
2026-08-09 18:25:42 -03:00
diegosouzapw
26daeabcf2 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9337
# Conflicts:
#	open-sse/config/providers/registry/nanogpt/index.ts
2026-08-09 18:25:16 -03:00
diegosouzapw
5560b90322 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave3b-9493 2026-08-09 18:23:15 -03:00
diegosouzapw
c6de098630 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave2b-9784 2026-08-09 18:21:12 -03:00
diegosouzapw
ee088198bf Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave2b-9781 2026-08-09 18:21:05 -03:00
diegosouzapw
792626c3c4 Merge remote-tracking branch 'origin/release/v3.8.50' into codex/wave2b-9749 2026-08-09 18:20:53 -03:00
Diego Rodrigues de Sa e Souza
dc66e3be29 Merge pull request #9945 from diegosouzapw/fix/release-v3.8.50-drain-base-red
fix(release): repair post-sweep base regressions
2026-08-09 18:19:54 -03:00
Diego Rodrigues de Sa e Souza
7d4584db56 Merge pull request #9619 from wgordon17/fix/basered-changelog-integrity-fabricated-docs
fix(quality): clears two release/v3.8.50 base-red gates
2026-08-09 18:19:11 -03:00
diegosouzapw
181828625b fix: clear release unit and quality regressions 2026-08-09 17:54:23 -03:00
diegosouzapw
b7bad4006b fix(release): clear remaining Adobe and typecheck gates 2026-08-09 17:02:47 -03:00
Diego Rodrigues de Sa e Souza
c6195cec62 fix(security): harden Adobe credential parsing gates 2026-08-09 16:31:24 -03:00
diegosouzapw
8c009f55e9 Merge commit 'refs/codex/pr-9619-head' into merge-prs-base-9945 2026-08-09 16:23:20 -03:00
benzntech
14b6183693 test(providers): cover web session fast path
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-09 16:20:54 -03:00
benzntech
0bb3bc093c fix(logging): cover opt-in diagnostics
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-09 16:20:54 -03:00
Diego Rodrigues de Sa e Souza
88b05fdb92 Merge remote-tracking branch 'origin/release/v3.8.50' into merge-prs-9784-canonical
# Conflicts:
#	package.json
2026-08-09 16:17:33 -03:00
Diego Rodrigues de Sa e Souza
9f4186a2b2 Merge remote-tracking branch 'origin/release/v3.8.50' into merge-prs-9749-canonical
# Conflicts:
#	package.json
2026-08-09 16:17:32 -03:00
benzntech
afd5169b69 docs(providers): reconcile ChatGPT Web credential guide
Sync the contributor guide onto the active release, remove inherited dependency drift, and align the Cookie Editor workflow with the current extension and source-backed OmniRoute contract.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-09 15:44:47 -03:00
Will Gordon
326a975084 fix(combo): restore hard capability filtering
Restore the shared media detector and the hard-reason set lost by the maintainer cherry-pick. Re-document the two live low-memory controls and cover nested case-insensitive image indicators.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-09 15:39:22 -03:00
diegosouzapw
0d36801f20 Merge remote-tracking branch 'origin/release/v3.8.50' into fix/release-v3.8.50-post-sweep-base-red
# Conflicts:
#	src/i18n/messages/vi.json
2026-08-09 15:21:37 -03:00
diegosouzapw
754ba0fa86 fix(release): repair post-sweep base regressions 2026-08-09 15:16:55 -03:00
rinseaid
240b9b5bc4 fix(image): return fal defaults as base64 (#9932)
Co-authored-by: rinseaid <rinseaid@rinseaid.net>
2026-08-09 14:19:22 -03:00
Diego Rodrigues de Sa e Souza
2afaab52a1 maint: final follow-up cherry-pick #9619 (#9901)
* fix(quality): clears two release/v3.8.50 base-red gates

Unblocks Merge integrity and Docs Gates for every PR against
release/v3.8.50, not just this branch:

- changelog.d/features/9415-newapi-sub2api-aggregator-balance.md had a
  non-standard YAML frontmatter header that no other fragment in the
  tree uses. check-changelog-integrity.mjs reads a fragment's first
  non-blank line to validate it starts with a markdown bullet; the
  frontmatter's leading `---` made that check fail regardless of the
  actual bullet content further down. Removed the frontmatter and
  reformatted the body to match the documented changelog.d/README.md
  bullet convention.
- docs/ops/VM_DEPLOYMENT_GUIDE.md documented OMNIROUTE_MAX_POOL_SIZE
  and OMNIROUTE_DB_POOL_SIZE as tunable env vars, but neither is read
  anywhere in the codebase (confirmed via full-repo grep) — this repo
  uses SQLite, which has no connection-pool concept these vars could
  plausibly control. check:fabricated-docs --strict correctly flags
  fabricated env-var claims; removed the bullet rather than
  implementing a feature to match invented documentation.

* fix(i18n): completes Vietnamese parity, fixes empty migration query

Two more release/v3.8.50 base-red items, both surfaced while chasing
CI failures on unrelated PRs:

- vi.json was missing 8 keys that #9539 (NewAPI/Sub2API aggregator
  balance) added to en.json without a matching i18n:sync-ui run —
  pt-BR.json already had all 8, only Vietnamese drifted. Added
  translations for the 6 provider-settings strings, the feature-flag
  description, and the quota tooltip; verified against
  tests/unit/i18n-vi-completeness.test.ts (parity, placeholder
  preservation, ICU parse — all 5 assertions pass).

- src/lib/db/migrations/120_interception_rules.sql was pure comments
  documenting a no-schema-change key_value namespace, with no
  executable SQL statement — the migration runner logged
  "FAILED: 120_interception_rules — Query contained no valid SQL
  statement" on every fresh DB init. 118_provider_param_filters.sql
  (same pattern, two migrations earlier) already ends with a bare
  `SELECT 1;` no-op for exactly this reason; 120 was just missing it.
  Verified directly against better-sqlite3 that the file now executes
  without error.

* fix(types): clears 6 pre-existing release/v3.8.50 typecheck errors

typecheck:core is its own blocking CI job (quality.yml), separate from
Docs Gates/Merge integrity. Confirmed pre-existing and unrelated to
any current work by branching this worktree directly from
upstream/release/v3.8.50 with no other merges applied.

- accountSemaphore.ts: isBypassed() already excludes null/<=0
  maxConcurrency before ensureGate() is called, but a boolean-
  returning helper isn't a type predicate TS can narrow through.
  Added a targeted `as number` at the one call site, with a comment
  explaining why it's safe.

- combo/comboStructure.ts: two module-scope `const HARD_COMPAT_REASONS`
  declarations with different values — a genuine "can't redeclare"
  compile error, not a narrowing gap. The first (4-item set including
  "output_tokens") had zero usages between its own declaration and the
  second; the second (3-item set, matching the CompatFilterOptions doc
  comment exactly) is what hasHardCapabilityFailure/
  describeCapabilityFilterExhaustion/the third call site all actually
  use. Removed the dead first declaration.

- combo/comboStructure.ts + combo/fusionPanel.ts: both accessed
  `.prompt`/`.model` on a `ComboModelStep | ComboProviderWildcardStep`
  union after only excluding `combo-ref`, but `ComboProviderWildcardStep`
  has neither field — a real latent bug (fusionPanel would have pushed
  `undefined` into a fusion panel for a wildcard step). Narrowed to
  `step.kind === "model"` in comboStructure, and switched to the
  already-existing `getComboModelString()` helper in fusionPanel (which
  correctly resolves to null for unsupported step kinds, mirroring how
  combo-ref is already skipped there). Verified directly via a
  standalone script exercising both branches (wildcard vs. model step).

- combo/quotaStrategies.ts: imported `preferAntigravityConnectionsWithStoredProject`
  from a module that never existed (`../antigravityProjectPersistence.ts`,
  distinct from the real `antigravityProjectPersist.ts`) — the function
  itself was referenced nowhere else in the codebase. Wrote the missing
  implementation: prefers Antigravity connections with a discovered
  `projectId` for reset-aware routing, failing open to the full list
  when none have one yet (per the file's own "Exclude... from reset-aware
  pool" changelog note, softened to a preference — strict exclusion
  would empty the pool entirely for a fleet of freshly-added accounts).
  Verified directly via a standalone script.

- compression/engines/ccr/index.ts: `enforceGlobalBudget(owner, bytes)`
  was called with only `bytes` at one of its two call sites, missing the
  `owner` argument the other call site (and the function's own doc
  comment on preferring the calling principal's LRU eviction) already
  uses correctly. Added the missing `entry.principalId` argument.

- firecrawlQuotaFetcher.ts: `fetchFirecrawlQuota` was annotated to
  return `Promise<QuotaInfo | null>` but every return path constructs a
  `FirecrawlQuota` (QuotaInfo extended with remainingCredits/planCredits/
  extraCreditsInferred/overPlan) — the type the file already defines and
  the type `parseFirecrawlCreditUsage` already correctly returns.
  Widened the annotation to match; `FirecrawlQuota extends QuotaInfo` so
  this stays compatible with the `QuotaFetcher` contract.

npm run typecheck:core and npm run check:dashboard-typecheck both pass
cleanly. A subset of DB-backed tests in this area also fail, but 100%
attributably to an already-tracked, unrelated migration version
collision (134 -> [ccr_blocks, proxy_logs_egress_ip], see
_tasks/features-v3.8.4/9route/POST-MERGE-AUDIT.md) — confirmed by every
failure's stack trace bottoming out at that exact error, not at
anything touched here.

* fix(sse): update stale ALL_ACCOUNTS_INACTIVE test assertions to ALL_TARGETS_SKIPPED

Two combo-routing-engine.test.ts cases assert the pre-dispatch-skip scenario (isModelAvailable always false, zero dispatch attempts) returns ALL_ACCOUNTS_INACTIVE. Production code already distinguishes this case via the recordedAttempts === 0 branch and returns the more precise ALL_TARGETS_SKIPPED -- the tests were never updated when that branch shipped upstream, so they fail on a clean release/v3.8.50 checkout independent of this PR's changes.

* fix(sse): update second stale ALL_ACCOUNTS_INACTIVE assertion (T24)

Same pre-existing upstream test-drift as 038035f93: t23-t24-fallback-resilience.test.ts's T24 case asserts the pre-dispatch-skip scenario returns ALL_ACCOUNTS_INACTIVE, but production code returns the more precise ALL_TARGETS_SKIPPED when recordedAttempts === 0. Caught by this PR's own fresh CI run after the dirty-mergeable-state fix.

* fix(quality): rebaseline combo-routing-engine.test.ts own-comment growth

The ALL_ACCOUNTS_INACTIVE->ALL_TARGETS_SKIPPED fix (58ab721fe) added explanatory comments (+7 lines), pushing the file past its frozen 3457 cap. CI's PR-mode check:file-size caught it; local check-file-size.mjs was not re-run after that specific commit.

* chore(tests): drop explanatory comments on ALL_TARGETS_SKIPPED assertions

Kept the assertion value fix (ALL_ACCOUNTS_INACTIVE -> ALL_TARGETS_SKIPPED); the comments were unnecessary. Reverts the file-size baseline bump these comments caused (combo-routing-engine.test.ts back to its original 3457).

---------

Co-authored-by: Will Gordon <wgordon@redhat.com>
2026-08-09 14:15:53 -03:00
diegosouzapw
04483ed211 chore: ignore playwright cli artifact dir 2026-08-09 14:11:34 -03:00
Will Gordon
3a87712b56 chore: merges release/v3.8.50 into basered-changelog-docs 2026-08-09 11:22:08 -04:00
Diego Rodrigues de Sa e Souza
382449d593 maint: follow-up cherry-pick fix-in-place #9711 (conflict-resolved fallback) (#9891)
* fix(sse): grace period before finalizing a client disconnect as 499 (#9653)

A client that closes its connection right after reading a fully-completed
SSE stream can race OmniRoute's own completion bookkeeping: the bytes
already reached the client, but the transform stream's own completion
callback (onStreamComplete, which flips streamCompletionRecorded) hasn't
finished bubbling up when the disconnect handler fires, so the request gets
persisted as a false 499 with zero token usage even though it delivered its
full response.

Confirmed live on real traffic before this fix: a request whose server log
showed "disconnect: request_signal_aborted" at 18236ms was persisted with
status 200 and full token usage (82814/1292) once the grace period let the
real completion win the race, matching what the client actually received.

createClientDisconnectGraceHandler (new leaf in
streamFailureFinalization.ts) polls isStreamCompletionRecorded() for up to
STREAM_DISCONNECT_GRACE_PERIOD_MS (default 10s, env-configurable, 0
disables) before finalizing as a failure. If a real completion lands within
the window, handleStreamFailure's own guard is a no-op and the genuine 200
stands.

Covered by tests/unit/stream-disconnect-grace-period-9653.test.ts (fake-timer
driven: already-recorded completion short-circuits, disabled-grace-period
finalizes immediately, a completion landing mid-window skips finalize
entirely, and no completion ever landing finalizes once the deadline
passes).

(cherry picked from commit 5d0fe28c42)

* chore(quality): rebaseline chatCore.ts for the disconnect grace-period fix

Own growth from the disconnect grace-period fix: 5030->5039 (+9, the
createClientDisconnectGraceHandler wiring at the existing
onClientDisconnectFinalize call site).

---------

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-09 10:08:25 -03:00
Diego Rodrigues de Sa e Souza
807a0d2022 maint: follow-up cherry-pick fix-in-place #9704 (conflict-resolved fallback) (#9889)
* fix(sse): persist per-tool-call JSON escape state across SSE delta chunks

escapeJsonStringValues() reset its inString/pendingEscape state on every
call instead of carrying it forward per tool-call index, so a raw newline
byte (or an already-escaped \n) split across two delta chunks got corrupted
in transit — the model's own output was correctly escaped, OmniRoute broke
it. Root-caused via a dispatched investigation into real OpenClaw traffic
that looked like model-generation quality but wasn't.

Fix: escapeJsonStringValues now takes and mutates a persistent per-call
state object (JsonStringEscapeState), keyed per tool-call index in the
translator's init state and cleared when a tool call is superseded.

* chore(quality): rebaseline openai-responses.ts for the escape-state fix

Own growth from the extracted per-tool-call JSON escape-state fix
(previous commit): open-sse/translator/response/openai-responses.ts
1204->1249 (+45).

---------

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-09 10:07:36 -03:00
Diego Rodrigues de Sa e Souza
54bba33e2f maint: follow-up cherry-pick fix-in-place #9629 (conflict-resolved fallback) (#9885)
* fix(compression): add Lite tool truncation toggle

* fix(antigravity): add missing antigravityProjectPersistence.ts module

The quota-strategy engine (quotaStrategies.ts) imports from
antigravityProjectPersistence.ts, but only antigravityProjectPersist.ts
existed in the tree.  Add the missing module with the expected
preferAntigravityConnectionsWithStoredProject() helper and re-export
the existing persistDiscoveredAntigravityProjectId().

Co-authored-by: diegosouzapw <diegosouza.pw@outlook.com>

* fix(file-size): rebaseline strategySelector.ts for Lite truncation toggle

The PR adds one line to threading options?.config?.lite into
applyLiteCompression. Update the frozen size from 1060 to 1061.

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>

Refs #9629

---------

Co-authored-by: Xiangzhe <xiangzhedev@gmail.com>
Co-authored-by: xz-dev <xz-dev@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-09 10:07:30 -03:00
Diego Rodrigues de Sa e Souza
a54c1f73af fix(db): resolve ccr migration version collision (#9884)
Renumber the CCR block-store migration from 134 to 139, reconcile databases that already applied the legacy slot, and add regression coverage for both upgrade paths.

Co-authored-by: fenix007 <fenix007@users.noreply.github.com>
2026-08-09 10:07:23 -03:00
Diego Rodrigues de Sa e Souza
57fb90d734 maint: follow-up cherry-pick fix-in-place #9549 (conflict-resolved fallback) (#9881)
* fix(adobe-firefly): open browser sign-in and resolve provider slug in /login

POST /api/providers/[id]/login passed the connection DB id to
inAppLoginService.startLogin, but that service looks up the provider by
slug in TOKEN_EXTRACTION_CONFIGS. The lookup always missed and returned
"No extraction config" without launching a browser — so the VibeProxy
"Sign in" button for Adobe Firefly (and every other web-cookie provider)
never opened a browser.

Adobe Firefly additionally had no extraction config because its IMS JWT
is never in cookies/localStorage — it only rides on the Authorization:
Bearer header of firefly-3p.ff.adobe.io XHRs.

- Resolve the provider slug from the connection row and pass the slug
  (not the DB id) to inAppLoginService.startLogin.
- Add open-sse/services/adobeFireflyBrowserLogin.ts: a Playwright
  service that launches a visible browser at firefly.adobe.com and
  intercepts firefly-3p requests to capture the IMS JWT + sherlockToken
  cookie. Wire it into the /login route for the adobe-firefly slug.
- Fix latent bug: updateProviderConnection reads camelCase keys
  (apiKey, providerSpecificData), so the previous snake_case call never
  persisted extracted credentials.

* fix(adobe-firefly): open browser sign-in and resolve provider slug in /login

POST /api/providers/[id]/login passed the connection DB id to
inAppLoginService.startLogin, but TOKEN_EXTRACTION_CONFIGS is keyed by
provider slug — so browser login never launched for web-cookie providers.

Adobe Firefly also cannot use cookie extraction: the IMS JWT only appears
on Authorization headers to firefly-3p.ff.adobe.io. Add a dedicated
Playwright interceptor and persist credentials with camelCase keys that
updateProviderConnection actually reads.

* fix(adobe-firefly): use system Chrome/Edge CDP for browser sign-in

Playwright is not available inside the pkg-packaged VibeProxyServices.exe,
so import('playwright') always failed with 'Playwright not installed' and
never opened a window. Launch Chrome/Edge with --remote-debugging-port and
capture the firefly-3p Authorization Bearer via pure CDP WebSocket instead.

* fix(adobe-firefly): live x-arp-session-id / Arkose wire (stop 408 under load)

Browser generate-async requires x-arp-session-id as base64({sid,ark,ftr}) with a
real Arkose blob (sherlockToken). JWT alone frequently returns colligo HTTP 408
system under load while credits still work.

- Match live ftr magic __UDF43-m4_31ck + Arkose pk in synthetic ARP fallback
- Ranked extract of sherlockToken / x-arp from Cookie, HAR, fetch() paste, and
  space-joined JWT+ARP (PasswordBox newline collapse)
- Reuse one ARP for storage upload + generate-async
- Clearer 408 errors when browser ARP is missing vs stale
- Unit suite 42/42

* fix(adobe-firefly): durable session ARP rebuild and aux_sid false-positive

Rebuild x-arp-session-id from forterToken/arkose/ff_session_guid instead of
ranking long Cookie pairs (e.g. aux_sid=…) as opaque ARP, which caused colligo
HTTP 408. Cache IMS JWT + cookie sessions, rotate ARP on 408 retries, and keep
Playwright warm-up opt-in only (headless Forter is rejected).

Also expand synthetic ARP shape with bfp/fpjs to match live successful captures.

* fix(adobe-firefly): durable session, off-screen Chrome recovery, browser sign-in

Rebuild x-arp-session-id from Cookie pieces (sid/ark/forter) so aux_sid is never
sent as ARP. Sticky ARP + submit spacing reduce mid-batch colligo 408 thrash.

Add optional managed Chrome warm (off-screen headed by default; Forter rejects
headless) and POST /api/providers/{id}/login browser sign-in that returns JWT+Cookie
after a fresh SSO. Visible sign-in resets off-screen window placement and clears
prior Adobe session when adding another account.

* fix(adobe-firefly): renew sessions through durable CDP

* fix(adobe-firefly): isolate browser sessions per account

* fix(adobe-firefly): make account login fresh and deterministic

* chore(adobe-firefly): remove obsolete browser fallback

* docs(adobe-firefly): document renewal controls

* fix(adobe-firefly): harden CDP warm, risk session, and browser sign-in

Stop colligo 408 thrash from stale Forter and frozen Google login during
Sign in with browser:

- CDP warm: clear Firefly origin storage + risk cookies (keep SSO); require
  forter age under 10 minutes on loop and timeout paths; dual CDP queues;
  await Runtime.runIfWaitingForDebugger; profile-lock launch retries
- Session: connectionId fingerprint; write-back JWT+Cookie; warm-fail
  cooldown; fail closed risk_session_stale when forter is known-stale
- Client: submit gate around generate-async; max 2 attempts when forter
  known-stale; poll 401 one refresh; pass sessionBrowserKey through handlers
- Login route: pure system Chrome/Edge CDP only; camelCase credential persist
- Unit: browser-login + firefly suites green (60)

---------

Co-authored-by: artickc <artur1992123@mail.ru>
2026-08-09 10:07:17 -03:00
Diego Rodrigues de Sa e Souza
5eba045175 maint: follow-up cherry-pick fix-in-place #9510 (fallback resolution) (#9880)
* feat(api): add GET /api/resilience/connections for per-account state

The three temporary-failure mechanisms each have their own scope -- the
provider circuit breaker covers a whole provider, connection cooldown covers
one account, model lockout covers a provider/connection/model triple -- and
until now nothing showed them side by side. Diagnosing "why is this key being
skipped" meant reading three separate surfaces and correlating by hand, which
is exactly what the docs' own debugging guidance asks an operator to do.

The route returns all three keyed by connection, plus the breaker's transition
history so a flapping provider is visible as a sequence rather than a single
current state. getStatus() already assembled everything except that history;
it now returns a copy of it and carries an explicit CircuitBreakerStatus type
instead of an inferred one.

Reading raw connection rows for this meant widening getRawProviderConnections'
column projection, so the existing allowlist is exported and the route selects
through it. A test asserts every column the route names is in that allowlist,
which turns a future typo into a failure here rather than a silent empty field.

Each of the three data sources is wrapped independently: one of them throwing
degrades that section and sets meta.degraded rather than failing the whole
response, since a partial view still answers most of the questions the page
exists for.

Loopback-gated. It spawns nothing, unlike every other entry on that list, but
it exposes per-account operational state and the comment says so to keep it
from being read as precedent for gating read-only routes generally.

Tests are real isolated-DB integration tests rather than mocks -- ESM mocking
is unavailable here (no mock.module, non-configurable exports) and the
codebase already has the isolated-DB pattern, which exercises more than a mock
would anyway.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* feat(dashboard): add the per-account resilience connections page

Renders what the API added: every connection with its cooldown, its provider
breaker, and its model lockouts in one table, with a detail view per connection
and the breaker's transitions drawn as a timeline. The timeline is the part that
is hard to get from the existing surfaces -- a breaker sitting at CLOSED right
now looks healthy, and only the sequence shows it has opened four times in the
last hour.

Polls rather than streams. The state it displays changes on the order of
seconds to minutes and the page is loopback-gated, so an SSE channel would buy
nothing over an interval.

ModelCooldownsCard had its own formatRemaining. The new table needs the same
countdown format and two copies would drift, so it moves to
shared/utils/formatRemaining.ts and both import it -- behaviour unchanged, the
extracted version differs from the deleted one only in local variable names.
DataTable's column and row interfaces are exported for the same reason: the new
table types against them rather than restating their shape.

Signed-off-by: Minxi Hou <houminxi@gmail.com>

* fix(i18n): translate new resilience-connections screen strings

PR #9510 added the "Connection Resilience" dashboard screen but the
sync-added i18n keys (sidebar.resilienceConnections/Subtitle and the
full resilienceConnections namespace) were left as __MISSING__: in
every non-English locale, dropping i18nUiCoverage.pct below the 99
ratchet baseline.

Translate all ~78 new leaf strings into all 41 non-English locales.
Pre-existing unrelated __MISSING__ debt (hermesRole*, apiProtocol*,
grokAutoTopUp*, featureFlagExposeFunctionalGatewayMirrorsDescription)
is left untouched — out of scope for this fix.

Co-authored-by: HouMinXi <HouMinXi@users.noreply.github.com>

---------

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: HouMinXi <HouMinXi@users.noreply.github.com>
2026-08-09 10:07:10 -03:00
Diego Rodrigues de Sa e Souza
06727f0e74 cherry-pick(pr-9556): fix(translator): preserve Kimi K3 Responses reasoning (#9879)
* fix(translator): preserve Kimi K3 Responses reasoning

* fix(translator): make K3 reasoning preservation model-driven

* fix(translator): replay cached Kimi reasoning before fallback

* fix(translator): keep authentic K3 reasoning through cleanup

* refactor(reasoning): use replay policy for K3

---------

Co-authored-by: jackjinke <jack.kejin@gmail.com>
2026-08-09 10:07:03 -03:00
Diego Rodrigues de Sa e Souza
5f75abe4a2 cherry-pick(pr-9634): fix(test): reconcile base-drifted test expectations on release/v3.8.50 (#9874)
* fix(combo): restore routing module load

* fix(db): resolve ccr migration version collision

Renumber the CCR block-store migration from 134 to 139, reconcile databases that already applied the legacy slot, and add regression coverage for both upgrade paths.

Co-Authored-By: GPT-5 <noreply@openai.com>

* fix(changelog): format the aggregator balance fragment as a bullet

The fragment landed with YAML frontmatter rather than the bullet the
aggregator reads, so check:changelog-integrity exits 1 on every branch and
takes the merge-integrity job down with it regardless of what the branch
changed.

Only the format changes. The entry text is the author's, unedited, and now
carries the link to the pull request that shipped it.

* fix(test): update expected auth/vision/provider schema for base-drifted expectations

* fix(test): narrow this branch to the drifted test expectations

Three other PRs already cover what this one was carrying. #9618 renumbers the
colliding ccr_blocks migration, #9632 repairs the malformed aggregator changelog
fragment, and #9676 restores the combo module load by implementing the selection
helper the import was reaching for, rather than deleting the caller the way this
branch did. Keeping any of it here would put two files back on the same migration
slot and overwrite a better fix with a worse one.

What survives is the part none of them touch. Once the combo barrel loads again,
three assertions in the context-window filter suite start failing: they demand
that catalog-too-small targets be dropped, while the file's own header and its
four neighbouring tests say those targets stay available as runtime fallback.
The unresolved import was masking them. A new case pins the output-token limit
as a genuine hard requirement so the relaxation cannot drift further.

The provider count assertion kept one literal at the old value after the rest of
the file moved to 198, so the partition check failed on a sum that was correct.

* chore(quality): re-time migrationRunner for the 139 guard on the new tip

---------

Co-authored-by: alexey.nazarov@softmg.ru <alexey.nazarov@softmg.ru>
Co-authored-by: GPT-5 <noreply@openai.com>
Co-authored-by: Minxi Hou <houminxi@gmail.com>
2026-08-09 10:06:57 -03:00
Diego Rodrigues de Sa e Souza
58f0ff1b41 cherry-pick(pr-9675): fix(providers): per-provider opt-out for anonymous no-auth fallback (opencode-go/zen 401s) (#9873)
* fix(providers): add per-provider opt-out for anonymous no-auth fallback

API-key providers with anonymousFallback: true (opencode-go, opencode-zen,
pollinations, kilocode) receive a synthetic "noauth" connection whenever all
real connections are terminal (credits_exhausted/banned/expired) or
unavailable. The opencode upstream now rejects anonymous requests with
401 Missing API key, so the fallback adds a guaranteed-failing round trip
and health/reconnect noise before the combo moves on.

Add a noAuthFallbackDisabledProviders settings array (zod-validated,
persisted via /api/settings, following the blockedProviders pattern).
When a provider is listed, maybeSyntheticNoAuthFallback returns null for
anonymousFallback-only providers, so exhausted providers are skipped
immediately as allExpired/allRateLimited while real keyed connections keep
working and recover automatically once quota state clears. True no-auth
providers are unaffected; blockedProviders remains their disable mechanism.
Default (absent/empty list) preserves current behavior.

Provider detail pages for anonymousFallback providers gain an
"Anonymous fallback" toggle (default ON) backed by the new setting.

Refs #9674

* fix(auth): reduce file size

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Hermes Agent <hermes@hermes-chloe.hyades.io>
2026-08-09 10:06:50 -03:00
diegosouzapw
54ec0bf7a1 Merge remote-tracking branch 'origin/release/v3.8.50' into feat/modality-bridge-page
# Conflicts:
#	src/i18n/messages/ar.json
#	src/i18n/messages/az.json
#	src/i18n/messages/bg.json
#	src/i18n/messages/bn.json
#	src/i18n/messages/cs.json
#	src/i18n/messages/da.json
#	src/i18n/messages/de.json
#	src/i18n/messages/es.json
#	src/i18n/messages/fa.json
#	src/i18n/messages/fi.json
#	src/i18n/messages/fr.json
#	src/i18n/messages/gu.json
#	src/i18n/messages/he.json
#	src/i18n/messages/hi.json
#	src/i18n/messages/hu.json
#	src/i18n/messages/id.json
#	src/i18n/messages/in.json
#	src/i18n/messages/it.json
#	src/i18n/messages/ja.json
#	src/i18n/messages/ko.json
#	src/i18n/messages/mr.json
#	src/i18n/messages/ms.json
#	src/i18n/messages/nl.json
#	src/i18n/messages/no.json
#	src/i18n/messages/phi.json
#	src/i18n/messages/pl.json
#	src/i18n/messages/pt-BR.json
#	src/i18n/messages/pt.json
#	src/i18n/messages/ro.json
#	src/i18n/messages/ru.json
#	src/i18n/messages/sk.json
#	src/i18n/messages/sv.json
#	src/i18n/messages/sw.json
#	src/i18n/messages/ta.json
#	src/i18n/messages/te.json
#	src/i18n/messages/th.json
#	src/i18n/messages/tr.json
#	src/i18n/messages/uk-UA.json
#	src/i18n/messages/ur.json
#	src/i18n/messages/vi.json
#	src/i18n/messages/zh-CN.json
#	src/i18n/messages/zh-TW.json
2026-08-09 09:42:28 -03:00
diegosouzapw
3eab125745 fix(i18n): isolate modality bridge availability copy 2026-08-09 01:38:35 -03:00
diegosouzapw
c34f69928c Merge remote-tracking branch 'origin/release/v3.8.50' into feat/modality-bridge-page 2026-08-09 01:26:29 -03:00
diegosouzapw
25e8a27045 Merge remote-tracking branch 'origin/release/v3.8.50' into HEAD 2026-08-09 00:29:30 -03:00
diegosouzapw
e66feceb76 fix(settings): use catalog-only modality labels 2026-08-08 19:56:06 -03:00
diegosouzapw
0298b82442 Merge remote-tracking branch 'origin/release/v3.8.50' into feat/modality-bridge-page 2026-08-08 19:46:36 -03:00
diegosouzapw
60c0f19289 fix(modality-bridge): clear dashboard quality regressions 2026-08-08 14:52:27 -03:00
diegosouzapw
44eb33a256 fix(i18n): preserve canonical Chinese glossary terms 2026-08-08 14:25:16 -03:00
diegosouzapw
a83f85b765 fix(modality-bridge): satisfy i18n quality gates 2026-08-08 14:21:44 -03:00
diegosouzapw
a5dc8731de Merge remote-tracking branch 'origin/release/v3.8.50' into feat/8468-bun-windows-ci-coverage
# Conflicts:
#	CLAUDE.md
2026-08-08 11:37:35 -03:00
Will Gordon
2c8093f73f chore(tests): drop explanatory comments on ALL_TARGETS_SKIPPED assertions
Kept the assertion value fix (ALL_ACCOUNTS_INACTIVE -> ALL_TARGETS_SKIPPED); the comments were unnecessary. Reverts the file-size baseline bump these comments caused (combo-routing-engine.test.ts back to its original 3457).
2026-08-08 09:59:34 -04:00
benzntech
b294c76719 feat(providers): add Cookie Editor fast-path to web session credential guide
The 'How to get the session credential' instructions in the provider
add-connection modal only described the manual DevTools flow. Add a
fast-path step using the Cookie Editor extension (export as Cookie
header, select all numbered session-token chunks) and demote the
DevTools walkthrough to the manual alternative.

New i18n keys (webSessionGuideStep2Fast, webSessionGuideStep3Manual)
ship in en.json; other locales fall back to English until translated.
2026-08-08 19:04:07 +05:30
Will Gordon
439854ce65 Merge remote-tracking branch 'upstream/release/v3.8.50' into fix/basered-changelog-integrity-fabricated-docs
# Conflicts:
#	config/quality/file-size-baseline.json
#	src/i18n/messages/vi.json
#	tests/unit/combo-routing-engine.test.ts
#	tests/unit/t23-t24-fallback-resilience.test.ts
2026-08-08 09:33:57 -04:00
benzntech
5e4a684bad docs(providers): use canonical chromewebstore URL for Cookie Editor install link 2026-08-08 19:03:56 +05:30
Will Gordon
eb817932e4 fix(quality): rebaseline combo-routing-engine.test.ts own-comment growth
The ALL_ACCOUNTS_INACTIVE->ALL_TARGETS_SKIPPED fix (58ab721fe) added explanatory comments (+7 lines), pushing the file past its frozen 3457 cap. CI's PR-mode check:file-size caught it; local check-file-size.mjs was not re-run after that specific commit.
2026-08-08 09:22:02 -04:00
benzntech
788d56fa07 docs(providers): add ChatGPT Web session credential guide
Add docs/providers/CHATGPT_WEB.md covering how to obtain and update
chatgpt-web session credentials via the Cookie Editor extension:

- extension option settings (export format, HttpOnly, domain filter)
- verifying __Secure-next-auth.session-token in a live network request
- adding/updating credentials in the dashboard + bulk/session-pool APIs
- contributing changes back via a PR

Fill the previously _(verify)_ ChatGPT Web row in WEB-COOKIE-GUIDE.md.
2026-08-08 18:48:51 +05:30
diegosouzapw
64c204f68c chore: preserve upstream formatting after base merge 2026-08-08 10:13:38 -03:00
diegosouzapw
ee5f84c168 Merge remote-tracking branch 'origin/release/v3.8.50' into feat/modality-bridge-page
# Conflicts:
#	src/i18n/messages/vi.json
2026-08-08 09:34:21 -03:00
diegosouzapw
37ef7a7d9b Merge remote-tracking branch 'origin/release/v3.8.50' into feat/modality-bridge-page 2026-08-08 08:37:34 -03:00
diegosouzapw
f725dac4b2 docs(guardrails): document Modality Bridge dashboard 2026-08-08 08:37:29 -03:00
diegosouzapw
904a54d602 feat(dashboard): relocate vision bridge card to link + media-providers shortcuts 2026-08-08 08:34:54 -03:00
diegosouzapw
1b9cd59740 feat(dashboard): Modality Bridge settings page with vision/audio/video tabs + sidebar entry 2026-08-08 08:18:53 -03:00
diegosouzapw
b5c522604f feat(dashboard): ModalityBridgeVisionTab + stats row + test button 2026-08-08 08:13:45 -03:00
diegosouzapw
1e2ef990e6 Merge remote-tracking branch 'origin/release/v3.8.50' into feat/modality-bridge-page 2026-08-08 08:00:36 -03:00
diegosouzapw
2dcb5bd422 feat(i18n): modality bridge page strings (en + synced locales) 2026-08-08 08:00:27 -03:00
benzntech
390690dd0a fix(logging): make stream-chunk capture and request-shape logging opt-in
Flip two heavy/noisy defaults to reduce resource load and log volume:

- CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS now defaults to false.
  Stream chunks are the largest call-log artifact; capturing them on
  every request by default is what grows ~/.omniroute/call_logs by
  hundreds of MB in days. Operators can re-enable with =true.
- OMNIROUTE_LOG_REQUEST_SHAPE now logs only when explicitly set to
  "1" (was: enabled unless set to "0"). Large-body diagnostics
  are debug tooling, not default behavior.

Docs (.env.example + ENVIRONMENT.md) updated to match the new defaults.
2026-08-08 08:19:41 +05:30
Will Gordon
15b9cb194f Merge remote-tracking branch 'upstream/release/v3.8.50' into fix/basered-changelog-integrity-fabricated-docs
# Conflicts:
#	open-sse/services/combo.ts
2026-08-07 16:24:34 -04:00
Will Gordon
58ab721fe2 fix(sse): update second stale ALL_ACCOUNTS_INACTIVE assertion (T24)
Same pre-existing upstream test-drift as 038035f93: t23-t24-fallback-resilience.test.ts's T24 case asserts the pre-dispatch-skip scenario returns ALL_ACCOUNTS_INACTIVE, but production code returns the more precise ALL_TARGETS_SKIPPED when recordedAttempts === 0. Caught by this PR's own fresh CI run after the dirty-mergeable-state fix.
2026-08-07 16:11:08 -04:00
Will Gordon
038035f937 fix(sse): update stale ALL_ACCOUNTS_INACTIVE test assertions to ALL_TARGETS_SKIPPED
Two combo-routing-engine.test.ts cases assert the pre-dispatch-skip scenario (isModelAvailable always false, zero dispatch attempts) returns ALL_ACCOUNTS_INACTIVE. Production code already distinguishes this case via the recordedAttempts === 0 branch and returns the more precise ALL_TARGETS_SKIPPED -- the tests were never updated when that branch shipped upstream, so they fail on a clean release/v3.8.50 checkout independent of this PR's changes.
2026-08-07 15:50:53 -04:00
Will Gordon
da771f8d86 Merge remote-tracking branch 'upstream/release/v3.8.50' into fix/basered-changelog-integrity-fabricated-docs
# Conflicts:
#	changelog.d/features/9415-newapi-sub2api-aggregator-balance.md
#	docs/ops/VM_DEPLOYMENT_GUIDE.md
#	open-sse/services/accountSemaphore.ts
#	open-sse/services/antigravityProjectPersistence.ts
#	open-sse/services/combo/comboStructure.ts
#	open-sse/services/combo/fusionPanel.ts
#	open-sse/services/compression/engines/ccr/index.ts
#	open-sse/services/firecrawlQuotaFetcher.ts
2026-08-07 15:34:00 -04:00
Will Gordon
7a0515038b ci: re-trigger checks (previous push event was dropped) 2026-08-07 15:05:03 -04:00
Will Gordon
cf7e4148c5 ci: re-trigger checks after GitHub Actions incident (2026-08-07, resolved) 2026-08-07 10:56:52 -04:00
Diego Rodrigues de Sa e Souza
153f453b0b fix(deps): bump deps for 13 Dependabot + audit cleanup on main
Same overrides as #9464 (ip-address, hono, fast-uri, socket.io-parser, undici) + brace-expansion, js-yaml v4, mermaid. npm audit → 0 vulnerabilities. Closes Dependabot #161-#188.
2026-08-06 23:13:28 -03:00
diegosouzapw
9233a9483c fix(deps): bump transitive deps for 6 Dependabot + remaining audit vulns on main
Same overrides as #9464 (ip-address, hono, fast-uri, socket.io-parser, undici)
applied directly to main. Also covers brace-expansion (scoped), js-yaml v4 copies,
and mermaid.

npm audit: 6→0 vulnerabilities.
Closes Dependabot #161-#166.
2026-08-06 18:58:51 -03:00
Will Gordon
3b411c7da7 ci: re-trigger checks after transient runner shutdown 2026-08-06 14:08:39 -04:00
Will Gordon
3ea174d531 fix(types): clears 6 pre-existing release/v3.8.50 typecheck errors
typecheck:core is its own blocking CI job (quality.yml), separate from
Docs Gates/Merge integrity. Confirmed pre-existing and unrelated to
any current work by branching this worktree directly from
upstream/release/v3.8.50 with no other merges applied.

- accountSemaphore.ts: isBypassed() already excludes null/<=0
  maxConcurrency before ensureGate() is called, but a boolean-
  returning helper isn't a type predicate TS can narrow through.
  Added a targeted `as number` at the one call site, with a comment
  explaining why it's safe.

- combo/comboStructure.ts: two module-scope `const HARD_COMPAT_REASONS`
  declarations with different values — a genuine "can't redeclare"
  compile error, not a narrowing gap. The first (4-item set including
  "output_tokens") had zero usages between its own declaration and the
  second; the second (3-item set, matching the CompatFilterOptions doc
  comment exactly) is what hasHardCapabilityFailure/
  describeCapabilityFilterExhaustion/the third call site all actually
  use. Removed the dead first declaration.

- combo/comboStructure.ts + combo/fusionPanel.ts: both accessed
  `.prompt`/`.model` on a `ComboModelStep | ComboProviderWildcardStep`
  union after only excluding `combo-ref`, but `ComboProviderWildcardStep`
  has neither field — a real latent bug (fusionPanel would have pushed
  `undefined` into a fusion panel for a wildcard step). Narrowed to
  `step.kind === "model"` in comboStructure, and switched to the
  already-existing `getComboModelString()` helper in fusionPanel (which
  correctly resolves to null for unsupported step kinds, mirroring how
  combo-ref is already skipped there). Verified directly via a
  standalone script exercising both branches (wildcard vs. model step).

- combo/quotaStrategies.ts: imported `preferAntigravityConnectionsWithStoredProject`
  from a module that never existed (`../antigravityProjectPersistence.ts`,
  distinct from the real `antigravityProjectPersist.ts`) — the function
  itself was referenced nowhere else in the codebase. Wrote the missing
  implementation: prefers Antigravity connections with a discovered
  `projectId` for reset-aware routing, failing open to the full list
  when none have one yet (per the file's own "Exclude... from reset-aware
  pool" changelog note, softened to a preference — strict exclusion
  would empty the pool entirely for a fleet of freshly-added accounts).
  Verified directly via a standalone script.

- compression/engines/ccr/index.ts: `enforceGlobalBudget(owner, bytes)`
  was called with only `bytes` at one of its two call sites, missing the
  `owner` argument the other call site (and the function's own doc
  comment on preferring the calling principal's LRU eviction) already
  uses correctly. Added the missing `entry.principalId` argument.

- firecrawlQuotaFetcher.ts: `fetchFirecrawlQuota` was annotated to
  return `Promise<QuotaInfo | null>` but every return path constructs a
  `FirecrawlQuota` (QuotaInfo extended with remainingCredits/planCredits/
  extraCreditsInferred/overPlan) — the type the file already defines and
  the type `parseFirecrawlCreditUsage` already correctly returns.
  Widened the annotation to match; `FirecrawlQuota extends QuotaInfo` so
  this stays compatible with the `QuotaFetcher` contract.

npm run typecheck:core and npm run check:dashboard-typecheck both pass
cleanly. A subset of DB-backed tests in this area also fail, but 100%
attributably to an already-tracked, unrelated migration version
collision (134 -> [ccr_blocks, proxy_logs_egress_ip], see
_tasks/features-v3.8.4/9route/POST-MERGE-AUDIT.md) — confirmed by every
failure's stack trace bottoming out at that exact error, not at
anything touched here.
2026-08-06 13:08:34 -04:00
Will Gordon
f1fda94047 fix(i18n): completes Vietnamese parity, fixes empty migration query
Two more release/v3.8.50 base-red items, both surfaced while chasing
CI failures on unrelated PRs:

- vi.json was missing 8 keys that #9539 (NewAPI/Sub2API aggregator
  balance) added to en.json without a matching i18n:sync-ui run —
  pt-BR.json already had all 8, only Vietnamese drifted. Added
  translations for the 6 provider-settings strings, the feature-flag
  description, and the quota tooltip; verified against
  tests/unit/i18n-vi-completeness.test.ts (parity, placeholder
  preservation, ICU parse — all 5 assertions pass).

- src/lib/db/migrations/120_interception_rules.sql was pure comments
  documenting a no-schema-change key_value namespace, with no
  executable SQL statement — the migration runner logged
  "FAILED: 120_interception_rules — Query contained no valid SQL
  statement" on every fresh DB init. 118_provider_param_filters.sql
  (same pattern, two migrations earlier) already ends with a bare
  `SELECT 1;` no-op for exactly this reason; 120 was just missing it.
  Verified directly against better-sqlite3 that the file now executes
  without error.
2026-08-06 11:39:08 -04:00
Will Gordon
034db3c3dd fix(quality): clears two release/v3.8.50 base-red gates
Unblocks Merge integrity and Docs Gates for every PR against
release/v3.8.50, not just this branch:

- changelog.d/features/9415-newapi-sub2api-aggregator-balance.md had a
  non-standard YAML frontmatter header that no other fragment in the
  tree uses. check-changelog-integrity.mjs reads a fragment's first
  non-blank line to validate it starts with a markdown bullet; the
  frontmatter's leading `---` made that check fail regardless of the
  actual bullet content further down. Removed the frontmatter and
  reformatted the body to match the documented changelog.d/README.md
  bullet convention.
- docs/ops/VM_DEPLOYMENT_GUIDE.md documented OMNIROUTE_MAX_POOL_SIZE
  and OMNIROUTE_DB_POOL_SIZE as tunable env vars, but neither is read
  anywhere in the codebase (confirmed via full-repo grep) — this repo
  uses SQLite, which has no connection-pool concept these vars could
  plausibly control. check:fabricated-docs --strict correctly flags
  fabricated env-var claims; removed the bullet rather than
  implementing a feature to match invented documentation.
2026-08-06 10:27:10 -04:00
diegosouzapw
4dbbaeb746 test(mutation): register capability-filter.test.ts in stryker tap.testFiles
The mutation test-coverage drift gate (check:mutation-test-coverage --strict)
failed because tests/unit/capability-filter.test.ts covers
open-sse/utils/error.ts (a mutated module) but was missing from
stryker.conf.json tap.testFiles.
2026-08-05 19:55:14 -03:00
Diego Rodrigues de Sa e Souza
2404556a62 Merge branch 'release/v3.8.50' into feat/5696-layer-a-capability-filter 2026-08-05 16:24:56 -03:00
Diego Rodrigues de Sa e Souza
3edb317889 Merge branch 'release/v3.8.50' into feat/8468-bun-windows-ci-coverage 2026-08-05 16:23:25 -03:00
Diego Rodrigues de Sa e Souza
247a00ce66 Merge branch 'release/v3.8.50' into feat/5696-layer-a-capability-filter 2026-08-05 13:22:05 -03:00
Diego Rodrigues de Sa e Souza
88b883a6c2 Merge branch 'release/v3.8.50' into feat/8468-bun-windows-ci-coverage 2026-08-05 13:20:51 -03:00
Diego Rodrigues de Sa e Souza
71e7e2bfd7 Merge branch 'release/v3.8.50' into feat/8468-bun-windows-ci-coverage 2026-08-05 12:00:00 -03:00
diegosouzapw
02dd5e723e feat(ci): add windows-latest leg to test-bun-sqlite job (#8468)
Add a windows-latest matrix leg to the test-bun-sqlite CI job with
continue-on-error: true for advisory Windows+Bun coverage.
Update CLAUDE.md Bun section to note the advisory Windows leg.
2026-08-05 02:39:54 -03:00
diegosouzapw
ee94b0378d feat(core): add Layer A capability filter at router (#5696) 2026-08-04 08:51:41 -03:00
diegosouzapw
b263905984 chore: remove _tasks symlink from tracking 2026-08-04 03:49:51 -03:00
diegosouzapw
848fca7eb0 feat(gemini): recursive schema type:object + empty choices interceptor (#9268) 2026-08-04 03:49:35 -03:00
diegosouzapw
ec09949e6d feat(providers): expose full NanoGPT endpoint surface (#9322) 2026-08-04 03:35:39 -03:00
234 changed files with 42493 additions and 23938 deletions

View File

@@ -67,6 +67,14 @@ DISABLE_SQLITE_AUTO_BACKUP=false
# Used by: src/shared/utils/rateLimiter.ts
# Example: redis://localhost:6379 (or redis://redis:6379 in Docker)
# REDIS_URL=redis://localhost:6379
# Host interface docker-compose publishes the Redis sidecar on.
# Default: 127.0.0.1 (loopback only). The compose Redis runs WITHOUT
# `requirepass`, and app containers reach it over the compose network
# (redis:6379) — the published port is only for host-side tooling. Setting this
# to 0.0.0.0 exposes an unauthenticated Redis to your whole LAN.
# REDIS_BIND_HOST=127.0.0.1
# Host port for the compose Redis sidecar. Default: 6379.
# REDIS_PORT=6379
# ═══════════════════════════════════════════════════════════════════════════════
# 3. NETWORK & PORTS
@@ -337,14 +345,18 @@ ALLOW_API_KEY_REVEAL=false
# OMNIROUTE_CHAT_HEAVY_TOOL_COUNT=64
# Conservative string-size token estimate that classifies a request as heavyweight. Default 32000.
# OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS=32000
# Hard message-count cap; excess receives compact-required 413. Default 800.
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=800
# Optional opt-in hard message-count cap; excess receives compact-required 413 before
# compression can run. Unset/0 (the default) means no history cap: heap growth is bounded
# by OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT and the heap-pressure shed instead. Set a positive
# value only on memory-constrained deployments that need a hard ceiling.
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=0
# Hard cap (bytes) for a non-streaming upstream response buffered fully into memory
# (#5152). Past this the upstream reader is cancelled and the request fails fast
# instead of growing an unbounded string until the V8 heap is exhausted.
# Used by: open-sse/handlers/chatCore/nonStreamingResponseBody.ts
# Default: 67108864 (64 MB)
# OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES=768
# OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES=67108864
# CORS configuration — controls which cross-origin browser clients can call the API.
@@ -445,6 +457,13 @@ ALLOW_API_KEY_REVEAL=false
# Default: false
# OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS=false
# Per-model concurrency cap for round-robin combos (#9100).
# Used by: open-sse/services/comboConfig.ts — the round-robin combo semaphore
# was hard-capped at 3 concurrent requests per model with no override, which
# serialized higher-concurrency traffic behind that cap.
# Validated to >= 1, clamped to <= 32. | Default: 3
# COMBO_CONCURRENCY_PER_MODEL=3
# ═══════════════════════════════════════════════════════════════════════════════
# 7. URLS & CLOUD SYNC
# ═══════════════════════════════════════════════════════════════════════════════
@@ -520,16 +539,6 @@ NEXT_PUBLIC_BASE_URL=http://localhost:20128
# cost of more upstream polling; raise to reduce request volume.
# OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS=4000
# Timeout for the /api/jobs/:id/run-now endpoint, in milliseconds.
# This bounds the CALL, not the job. runNow() dispatches the handler with
# `void` and returns as soon as it has decided to start, so on the normal
# path it resolves in milliseconds. It only matters when the job is already
# running: runNow() then waits for the in-flight run before starting the
# queued one, and this timeout prevents that wait from hanging forever.
# Used by: src/app/api/jobs/[id]/run-now/route.ts
# Default: 30000 (30 seconds)
# OMNIROUTE_RUNNOW_TIMEOUT_MS=30000
# Public cloud URL — client-side mirror of CLOUD_URL.
NEXT_PUBLIC_CLOUD_URL=
@@ -788,6 +797,16 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Disable the proactive recovery scheduler entirely (default: false).
# OMNIROUTE_DISABLE_CONNECTION_RECOVERY=false
# Proactive Claude warmup scheduler (#8848): fires a trivial request to opted-in
# OAuth connections on a cron schedule (America/Los_Angeles) so accounts do not
# hit the 5-hour sliding window cold. Off by default — set ENABLED=1 and flip
# per-connection flags in settings.claudeWarmup.connections to activate.
# Used by: src/lib/warmupScheduler.ts.
# OMNIROUTE_WARMUP_ENABLED=false
# OMNIROUTE_WARMUP_CRON="0 7 * * *"
# OMNIROUTE_WARMUP_CONCURRENCY=3
# OMNIROUTE_WARMUP_MODEL=
# Background job interval for budget reset checks (ms). Default: 600000 (10m).
# Used by: src/lib/jobs/budgetResetJob.ts. Floor: 10000.
#OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS=600000
@@ -852,6 +871,12 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# (>= 3 retrievals = never compressed). 1 disables the ramp (binary skip at the threshold only).
# Used by: open-sse/services/compression/engines/ccr/index.ts. Default: 2.
#COMPRESSION_CCR_RETRIEVAL_RAMP_FACTOR=2
# CCR durable block store (#9061). The in-memory store loses blocks to LRU eviction, the TTL, a
# restart, or a retrieve landing on another instance, while the model is told it can retrieve them
# verbatim. Set to false to keep blocks in memory only, at the cost of that promise. Blocks over
# 512KB and cloud runtimes are memory-only regardless.
# Used by: open-sse/services/compression/engines/ccr/index.ts. Default: true.
#COMPRESSION_CCR_DURABLE_STORE=true
# T08/H5 — usage-observed prefix freeze (OPT-IN, default off). When enabled, a system prompt seen
# >= THRESHOLD times is treated as a stable cacheable prefix and preserved from compression even
# for providers the static cache-aware heuristic does not recognize (freeze = preserve, never
@@ -1036,6 +1061,17 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# VISION_BRIDGE_BASE_URL=
# VISION_BRIDGE_API_KEY=
# ── Raycast Pro (local auto-import) ──
# Raycast Pro AI is a reverse-engineered, unofficial API — local/personal use
# only (no OAuth client_id/secret; token is captured via macOS Auto-Import
# from the Keychain + local Raycast SQLite DB, or pasted manually). These
# vars are optional manual overrides used by open-sse/services/raycast.ts
# and the direct-probe benchmark script scripts/raycast/usage-benchmark.mjs.
# RAYCAST_BEARER_TOKEN=
# RAYCAST_DEVICE_ID=
# RAYCAST_AID=
# RAYCAST_SIG_SECRET=
# ─────────────────────────────────────────────────────────────────────────────
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
# ─────────────────────────────────────────────────────────────────────────────
@@ -1176,6 +1212,17 @@ CURSOR_USER_AGENT="Cursor/3.4"
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
# OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS=120000
# ── Proxy/relay fetch (connection pooling, #9158) ──
# Used by: open-sse/utils/proxyFetch.ts.
# A hung relay must fail BEFORE the client/agent timeout (typically 30s) so the
# caller sees a relay-specific failure instead of a generic upstream timeout.
# Capped at 29000ms so this timeout always fires first. Default: 25000 (25s).
# OMNIROUTE_RELAY_FETCH_TIMEOUT_MS=25000
# Shared retry backoff (ms) for the direct/relay/proxy retry-once paths.
# 0 = retry immediately. Default: 10.
# OMNIROUTE_RETRY_BACKOFF_MS=10
# ── Firecrawl web-fetch executor ──
# Point at a self-hosted Firecrawl instance (defaults to the public cloud API).
# When set to a non-cloud base URL, the API key becomes optional.
@@ -1237,6 +1284,14 @@ CURSOR_USER_AGENT="Cursor/3.4"
# OMNIROUTE_BROWSER_POOL=on
# WEB_COOKIE_USE_BROWSER=0
# ── Adobe Firefly browser sign-in (system Chrome/Edge CDP) ──
# Used by: open-sse/services/adobeFireflyBrowserLogin.ts. The Firefly login
# flow drives a real, system-installed Chrome or Microsoft Edge via CDP so the
# user can sign in interactively; the executable is auto-detected from common
# install paths per OS. Set this to override that detection (e.g. a portable
# install or a non-standard path) when auto-detection fails.
# OMNIROUTE_LOGIN_BROWSER_PATH=
# ── Circuit breaker thresholds and reset windows ──
# Used by: open-sse/config/constants.ts → src/lib/resilience/settings.ts.
# Defaults match historical PROVIDER_PROFILES values (post-scaling for
@@ -1348,6 +1403,10 @@ APP_LOG_TO_FILE=true
# Default: 100000
# CALL_LOGS_TABLE_MAX_ROWS=100000
# Force detailed request logging on or off, overriding the dashboard setting.
# Values: true | false | Default: unset (follow dashboard setting)
# ENABLE_REQUEST_LOGS=false
# Maximum age for orphaned active request log entries before the in-memory
# pending-request reaper removes them. Accepts milliseconds.
# Default: 3600000 (1 hour)
@@ -1355,7 +1414,7 @@ APP_LOG_TO_FILE=true
# Whether call log pipeline capture stores stream chunks when enabled in settings.
# Only applies when call_log_pipeline_enabled=true.
# Default: true
# Default: false (opt-in — saves disk: stream chunks are the biggest call-log artifact)
# CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS=true
# Maximum call log artifact size for pipeline captures, in KB.
@@ -1367,10 +1426,9 @@ APP_LOG_TO_FILE=true
# bodies is retained in the database.
# Used by: open-sse/handlers/chatCore.ts — cloneBoundedChatLogPayload()
# CHAT_LOG_TEXT_LIMIT=65536 # Max string length before truncation (default: 64 KB)
# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128)
# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128)
# CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6)
# CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit)
# CHAT_LOG_MAX_BODY_KB=1024 # Max request/response body size before summarizing, in KB (default: 1024)
# Maximum rows in the proxy_logs SQLite table.
# Default: 100000
@@ -1425,10 +1483,6 @@ APP_LOG_TO_FILE=true
# Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree.
# OMNIROUTE_PLUGIN_PATH=
# Allow plugins to request the 'exec' permission (spawn child processes from the
# plugin worker sandbox). Disabled by default; set to 1 to enable (local operator only).
# OMNIROUTE_PLUGINS_ALLOW_EXEC=0
# ── Prompt cache (system prompt deduplication) ──
# Used by: open-sse/services — caches identical system prompts across requests.
# PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50)
@@ -1486,6 +1540,15 @@ APP_LOG_TO_FILE=true
# ═══════════════════════════════════════════════════════════════════════════════
# 19. MODEL SYNC (Dev)
# ═══════════════════════════════════════════════════════════════════════════════
# Enable the models.dev capability sync. Default: false (opt-in only).
# Also settable from Dashboard > Settings > AI. This variable wins over that
# setting whenever it is set to anything non-empty, in either direction, so a
# deployment can pin the sync on or off without depending on database state
# surviving a rebuild. Leave it unset to let the dashboard toggle decide.
# On: 1, true, yes or on (any casing). Any other value is off.
# Used by: src/lib/modelsDevSync.ts
# MODELS_DEV_SYNC_ENABLED=false
# Development-time model catalog sync interval in seconds.
# Used by: src/lib/modelsDevSync.ts
# Default: 86400 (24 hours)
@@ -1508,6 +1571,14 @@ APP_LOG_TO_FILE=true
# Default: 86400000 (24 hours)
# OPENROUTER_CATALOG_TTL_MS=86400000
# Enrich the dashboard providers list with OpenRouter weekly ranking stats.
# ON by default; set false to skip the background fetch entirely (#9324).
# Used by: src/lib/catalog/openrouterProviderStats.ts
# OPENROUTER_PROVIDER_STATS_ENABLED=true
# Cache TTL for the OpenRouter provider stats snapshot, in ms.
# Default: 86400000 (24 hours)
# OPENROUTER_PROVIDER_STATS_TTL_MS=86400000
# ── Model catalog response shape ──
# Include display-friendly name fields in /v1/models responses.
# Disable for clients that expect model IDs only.
@@ -1528,6 +1599,13 @@ APP_LOG_TO_FILE=true
# DESIGNER_WEB_POLL_TIMEOUT_MS=60000 # Max wait for job completion (default: 60s)
# DESIGNER_WEB_POLL_INTERVAL_MS=2000 # Poll frequency (default: 2s)
# ── Adobe Firefly (Image Upscale) ──
# Base delay (ms) for the submit-retry exponential backoff when Adobe Firefly's
# upscale job submission is rate-limited. Used by:
# open-sse/services/adobeFireflyUpscale.ts::submitRetryDelayMs.
# Default: 8000 (20 under NODE_ENV=test/VITEST/NODE_TEST_CONTEXT).
# ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS=8000
# ── AWS Bedrock (Kiro / Audio) ──
# Region used to construct AWS Bedrock endpoints. Used by:
# src/lib/providers/validation.ts and open-sse/handlers/audioSpeech.ts.
@@ -1622,6 +1700,26 @@ APP_LOG_TO_FILE=true
# Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts
# MUX_SERVICE_PORT=8322
# ── Dario embedded service ──
# Override the host/port the embedded Dario (Claude Code subscription proxy)
# daemon binds to and is reached at. Always bound to 127.0.0.1 — never
# configurable to 0.0.0.0. Rarely needed — defaults to 127.0.0.1:3456.
# Used by: src/lib/services/installers/dario.ts, src/lib/services/bootstrap.ts,
# src/app/api/services/dario/_lib.ts, src/app/api/services/dario/admin/_lib.ts,
# open-sse/executors/dario.ts
# DARIO_HOST=127.0.0.1
# DARIO_PORT=3456
# ── Dario embedded service ──
# Override the host/port the embedded Dario (Claude Code subscription proxy)
# daemon binds to and is reached at. Always bound to 127.0.0.1 — never
# configurable to 0.0.0.0. Rarely needed — defaults to 127.0.0.1:3456.
# Used by: src/lib/services/installers/dario.ts, src/lib/services/bootstrap.ts,
# src/app/api/services/dario/_lib.ts, src/app/api/services/dario/admin/_lib.ts,
# open-sse/executors/dario.ts
# DARIO_HOST=127.0.0.1
# DARIO_PORT=3456
# ── Local hostnames (Docker networking) ──
# Comma-separated additional hostnames treated as "local" for provider routing.
# Used by: open-sse/config/providerRegistry.ts — allows Docker service names.
@@ -1795,7 +1893,7 @@ APP_LOG_TO_FILE=true
# Log request shape (content-type + content-length) for large chat payloads.
# Used by: src/app/api/v1/chat/completions/route.ts. Set to "0" to silence.
# Default: enabled.
# Default: disabled (opt-in).
# OMNIROUTE_LOG_REQUEST_SHAPE=1
# Write raw (untruncated) request/response JSON in call log artifacts.
@@ -1854,6 +1952,18 @@ APP_LOG_TO_FILE=true
# ── Devin CLI binary path ──
# Used by: open-sse/executors/devin-cli.ts. Default: looked up via PATH.
# CLI_DEVIN_BIN=devin
# Agentic bridge-only binary override. The bridge still executes ACP stdio only.
# CLI_DEVIN_AGENTIC_BIN=devin
# Required isolated HOME for the agentic Devin child process.
# DEVIN_AGENTIC_HOME=/home/bridge
# Bounded ACP turn timeout in milliseconds. Default: 120000.
# DEVIN_AGENTIC_ACP_TIMEOUT_MS=120000
# Agentic bridge model aliases. Values must keep the devin-cli-agentic/ prefix.
# DEVIN_BRIDGE_MODEL=devin-cli-agentic/swe-1-7
# DEVIN_BRIDGE_SONNET_MODEL=devin-cli-agentic/swe-1-7
# DEVIN_BRIDGE_OPUS_MODEL=devin-cli-agentic/swe-1-7
# DEVIN_BRIDGE_HAIKU_MODEL=devin-cli-agentic/swe-1-7
# DEVIN_BRIDGE_SUBAGENT_MODEL=devin-cli-agentic/swe-1-7
# ── Command Code (custom CLI) callback ──
# Local port used for OAuth-style callbacks from the Command Code CLI helper.
@@ -1908,6 +2018,15 @@ APP_LOG_TO_FILE=true
# 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/*
# routes use an OpenAI-compatible provider node hosted outside localhost.
# OFF by default: routing audio to a remote host changes egress identity, so it
# must be an explicit operator decision. Loopback/private nodes (localhost,
# 127.0.0.1, 172.16-31.x) are always allowed and unaffected by this flag.
# When enabled, the node authenticates with the API key stored on its connection.
# AUDIO_REMOTE_PROVIDER_NODES=false
# ── 1Proxy egress pool ──
# Used by: src/lib/oneproxySync.ts — fetches proxy nodes from the OmniRoute
# CrofAI 1Proxy service. Disable, override URL, or tune the import quality.
@@ -2115,6 +2234,11 @@ PLAYGROUND_COMPARE_MAX_COLUMNS=4
# MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too
# MEMORY_TYPED_DECAY_ACCESS_IMMUNITY=3 # access_count >= N → immune; 0 disables access immunity
# MEMORY_TYPED_DECAY_SWEEP_INTERVAL=0 # periodic sweep interval (seconds); 0 = no periodic sweep
# ─── Memory Backend Connectors (Generic HTTP) ──────────────────────────────
# NOTION_API_KEY=
# NOTION_API_URL=
# OBSIDIAN_API_KEY=
# OBSIDIAN_API_URL=
# AgentBridge + Traffic Inspector (Group A)
# AgentBridge
@@ -2130,6 +2254,15 @@ INSPECTOR_MAX_BODY_KB=1024
INSPECTOR_MASK_SECRETS=true
INSPECTOR_LLM_HOSTS_EXTRA=
INSPECTOR_INTERNAL_INGEST_TOKEN=
# Shared secret for identity-preserving internal REST hops (#9260): when an
# OmniRoute component calls another local OmniRoute route, this token (sent as
# x-omniroute-internal-service-token) marks the request as internal so the
# original caller identity is preserved. OPT-IN: unset disables the mechanism.
# Used by: src/lib/api/internalServiceAuth.ts
# OMNIROUTE_INTERNAL_SERVICE_TOKEN=
# File-based variant (secret-file pattern; wins only when the inline var is
# unset): path to a file whose trimmed content is the token.
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
# Quota Sharing (Group B — planos 16+22)
QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
@@ -2240,6 +2373,11 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# Host port for the 1-click Redis launcher. Default: 6379. Bump if the host
# already binds 6379. The container's internal port stays 6379.
# OMNIROUTE_REDIS_HOST_PORT=
# Host interface the 1-click Redis launcher publishes on. Default: 127.0.0.1
# (loopback only). The launcher starts Redis WITHOUT a password, so binding
# 0.0.0.0 hands every host on your LAN an unauthenticated Redis — only widen
# this if you also set a password on the instance yourself.
# OMNIROUTE_REDIS_BIND_HOST=
# Redis image used by the 1-click Redis launcher. Default: redis:7-alpine.
# Override to redis:8-alpine or a private registry mirror as needed.
# OMNIROUTE_REDIS_IMAGE=
@@ -2342,20 +2480,80 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# ─────────────────────────────────────────────────────────────────────────────
# VIBEPROXY_DATA_DIR=
# ─────────────────────────────────────────────────────────────────────────────
# Telegram Mini App (inbound bot webhook + Mini App chat)
# Used by: src/lib/telegram/*, src/app/api/telegram/update/route.ts
# ─────────────────────────────────────────────────────────────────────────────
# Bot token from @BotFather (<numeric_id>:<secret>). Enables the inbound
# update webhook and doubles as the HMAC secret for Mini App initData
# verification. When unset, /api/telegram/update returns 503.
# ── Internal service auth (management-plane service-to-service calls) ─────────
# Inline token for internal service authentication; prefer the _FILE variant in
# containerized deployments so the secret never lands in the environment table.
# OMNIROUTE_INTERNAL_SERVICE_TOKEN=
# Path to a file containing the internal service token (overrides the inline var).
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
# ═══════════════════════════════════════════════════════════════════════════════
# 26. RADAR FEED (SELF-HOSTING)
# ═══════════════════════════════════════════════════════════════════════════════
# Optional add-on (feature flag RADAR_ENABLED, default off — see feature flag
# settings, not an env var) that overlays a signed, freshly-curated free-model
# catalog on top of the release baseline. All four variables below are optional
# and only needed to point the client at a self-hosted/forked feed or
# supporter-key flow instead of the default OmniRoute Radar service. Used by:
# src/lib/radar/sync.ts, src/lib/radar/pinnedKeys.ts, src/lib/radar/links.ts.
# Base URL of the Radar feed service. Overrides the built-in default so forks
# and self-hosters can point at their own signed feed.
# RADAR_FEED_URL=https://radar.omniroute.online
# Ed25519 public key (base64-DER SPKI or PEM) used to verify the feed
# signature, replacing the pinned default key. Required when self-hosting a
# feed signed with a different key pair.
# RADAR_FEED_PUBKEY=
# URL the dashboard's "I'm a contributor" button opens (GitHub OAuth
# supporter-key claim flow). No pricing/value lives in this repo — only the
# link.
# RADAR_CONTRIBUTOR_CLAIM_URL=https://radar.omniroute.online/auth/github
# URL the dashboard's "Support the project" button opens (payment/plans
# page). No pricing/value lives in this repo — only the link.
# RADAR_SUPPORTER_PLANS_URL=https://radar.omniroute.online/planos
# ═══════════════════════════════════════════════════════════════════════════════
# 27. RELEASE v3.8.50 ADDITIONS
# ═══════════════════════════════════════════════════════════════════════════════
# Heavy chat admission queue wait before returning retryable 503. Set 0 for the
# legacy immediate rejection. Used by: src/shared/middleware/chatBodyAdmission.ts.
# Default: 5000 (5 seconds)
# OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=5000
# Timeout for /api/jobs/:id/run-now while it waits for an in-flight run.
# Used by: src/app/api/jobs/[id]/run-now/route.ts. Default: 30000 (30 seconds)
# OMNIROUTE_RUNNOW_TIMEOUT_MS=30000
# Maximum request/response body size before chat-log summarization, in KiB.
# Used by: src/lib/chatLogTruncation.ts. Default: 1024
# CHAT_LOG_MAX_BODY_KB=1024
# Adobe Firefly browser renewal and durable session cache (enabled by default).
# Used by: open-sse/services/adobeFireflySession.ts.
# ADOBE_FIREFLY_BROWSER_REFRESH=1
# ADOBE_FIREFLY_SESSION_DISK=1
# Minimum spacing between submissions and the extra pause after every third success.
# ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS=12000
# ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS=15000
# Chrome CDP runtime used by Adobe Firefly renewal. True headless is debug-only:
# Adobe colligo normally rejects risk tokens minted without a headed browser.
# ADOBE_FIREFLY_CHROME_CDP_PORT=9334
# ADOBE_FIREFLY_CHROME_VISIBLE=0
# ADOBE_FIREFLY_CHROME_HEADLESS=0
# ADOBE_FIREFLY_CHROME_FORCE_RESTART=0
# ADOBE_FIREFLY_CHROME_PING=auto
# ADOBE_FIREFLY_LOGIN_WAIT_MS=0
# ADOBE_FIREFLY_FORTER_WAIT_MS=45000
# Optional absolute Chrome executable; auto-detected when unset.
# CHROME_PATH=
# Telegram Mini App bridge. The update endpoint remains disabled while the bot
# token is unset. Used by: src/lib/telegram/* and src/app/api/telegram/update/route.ts.
# TELEGRAM_BOT_TOKEN=
# Model used for Telegram chat replies (default: auto/chat).
# TELEGRAM_DEFAULT_MODEL=auto/chat
# Bot API base URL override (for proxies/self-hosted Bot API servers).
# TELEGRAM_BOT_API_BASE=https://api.telegram.org
# Timeout (ms) for outbound Bot API calls (sendMessage/setWebhook).
# TELEGRAM_WEBHOOK_TIMEOUT_MS=60000

View File

@@ -811,7 +811,12 @@ jobs:
test-bun-sqlite:
name: Bun SQLite Compatibility
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
fail-fast: false
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.os == 'windows-latest' }}
timeout-minutes: 10
needs: changes
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
@@ -824,6 +829,15 @@ jobs:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Install Bun (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
powershell -c "iwr bun.sh/install.ps1 -useb | iex"
echo "$env:USERPROFILE\.bun\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
- name: Install Bun (non-Windows)
if: runner.os != 'Windows'
run: npm install -g bun
- run: npm run test:bun:db
test-vitest:

3
.gitignore vendored
View File

@@ -275,3 +275,6 @@ docker-compose.yml.bak
# ignora um SYMLINK chamado _tasks; /_tasks (ancorado) cobre arquivo/symlink/dir na raiz
# e impede que um git add -A recapture o symlink (incidente 2026-08-08).
/_tasks
# CLI local cache/state
.playwright-cli

View File

@@ -0,0 +1 @@
- **feat(core):** add Layer A capability filter at router (#5696)

View File

@@ -0,0 +1 @@
- feat(ci): add windows-latest leg to test-bun-sqlite job (#8468)

View File

@@ -0,0 +1 @@
- **feat(gemini):** recursive type:object injection in schema normalizer + empty choices interceptor for streaming (#9268)

View File

@@ -0,0 +1 @@
- **feat(providers):** expanded the NanoGPT (`nano-gpt.com`) upstream provider from chat-only to the full OpenAI-compatible endpoint surface: audio transcriptions (`/api/v1/audio/transcriptions`), audio speech (`/api/v1/audio/speech`), video generation (`/api/v1/video/generations`), embeddings (`/v1/embeddings`), and the Responses API (`responsesBaseUrl``/api/v1/responses`) ([#9322](https://github.com/diegosouzapw/OmniRoute/issues/9322))

View File

@@ -1 +1 @@
- **sse:** New-API / One-API / Sub2API aggregator balance detection for compatible nodes — with the "Aggregator Gateway" toggle on, OmniRoute queries the aggregator's `/api/user/self` to read the account balance, shows it as a dashboard badge and lets quota-preflight routing skip exhausted accounts. Gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default off), with a `quotaPerUnit` override for aggregators that do not use the default 500000 units/$1 rate ([#9415](https://github.com/diegosouzapw/OmniRoute/issues/9415))
- **feat(sse):** New-API/One-API/Sub2API aggregator balance detection for compatible provider nodes — when the "Aggregator Gateway" toggle is enabled, OmniRoute queries the aggregator's `/api/user/self` endpoint to detect the account balance; the dashboard shows a balance badge and quota-preflight routing skips exhausted accounts. Gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default: off), with a custom `quotaPerUnit` override for aggregators that use a different rate than the default 500000 units/$1 ([#9415](https://github.com/diegosouzapw/OmniRoute/issues/9415))

View File

@@ -0,0 +1 @@
- **feat(settings):** add a dedicated Modality Bridge settings page with Vision controls, runtime stats, and URL-addressable Audio and Video tabs ([#9782](https://github.com/diegosouzapw/OmniRoute/pull/9782))

View File

@@ -0,0 +1 @@
- fix(translator): preserve authentic K3 Responses reasoning by model across providers, keep it on the matching assistant turn, and make Kimi Coding prefer client reasoning then cached replay before its empty-marker fallback (#9496)

View File

@@ -0,0 +1 @@
- **fix(providers):** new per-provider `noAuthFallbackDisabledProviders` setting lets operators disable the synthetic anonymous (no-auth) credential fallback for API-key providers whose static definition declares `anonymousFallback: true` (e.g. `opencode-go`, `opencode-zen`) — upstream endpoints now reject anonymous requests with `401 Missing API key`, so the fallback added latency and caused UI health/reconnect churn. Real keyed connections keep working and recover automatically once quota state clears; true no-auth providers (`opencode`, `mimocode`, …) are unaffected, with `blockedProviders` remaining their disable mechanism. Default behavior is unchanged ([#9675](https://github.com/diegosouzapw/OmniRoute/pull/9675))

View File

@@ -0,0 +1 @@
- fix(i18n): re-escape CC discovery-alias `claude/<provider>/<model>` to HTML entities so next-intl stops logging INVALID_MESSAGE: UNCLOSED_TAG on provider detail pages (#8747 regression)

View File

@@ -0,0 +1 @@
- **fix(test):** reconcile test expectations that drifted from the code they guard on `release/v3.8.50` — auth/vision/provider schema snapshots, and three context-aware combo compatibility assertions that contradicted the same file's own stated contract (catalog-too-small targets stay available as runtime fallback rather than being dropped). The combo assertions were masked by an unresolved import that stopped `combo.ts` from loading at all, so they only become reachable once that import is repaired.

View File

@@ -0,0 +1 @@
- Repair release-sweep regressions in locale and environment contracts, package metadata, scripts, dependency, size and dead-code ratchets, OpenAPI coverage, Telegram error sanitization, Openference public-credential handling, DB-module classification, resilience UI test assertions, strict CodeBuddy CN tests, Lite compression typing, and the job-registry migration number.

View File

@@ -0,0 +1 @@
- Let the release-green validator finish the test-masking gate on loaded runners while preserving the existing timeout for every other full-CI gate, and report Node.js `ETIMEDOUT` errors as explicit timeout failures.

View File

@@ -115,6 +115,7 @@
"recharts",
"safe-regex",
"selfsigned",
"sharp",
"size-limit",
"smol-toml",
"socks",

View File

@@ -2036,11 +2036,6 @@
"count": 3
}
},
"tests/unit/codebuddy-cn-provider.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"tests/unit/codex-banked-reset-credits-5199.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 7

View File

@@ -163,9 +163,11 @@
"_rebaseline_2026_06_20_4389_thinking_toolchoice": "Re-baseline base.ts 1387->1399 (#4389): tool_choice-forced thinking guard at the existing Claude wire-image injection chokepoint (effThinking gate avoids the Anthropic 400 when tool_choice forces a tool). Cohesive guard; structural shrink tracked in #3501.",
"_rebaseline_2026_07_18_6979_codex_test": "PR #6979 own growth: executor-codex.test.ts 1340->1347 (+7 = generalized ensureThinkingBudget assertion added to the existing codex thinking-budget cases). antigravity-test bump 942->977 REVERTED here: #7408's test split dropped that file to 888, so this PR's +35 fits under the original 942 frozen cap.",
"_rebaseline_2026_07_24_8354_logs_timeline_sidebar": "PR #8354 (hartmark, feature/scrolling-log) own growth: src/shared/constants/sidebarVisibility/sections.ts 812->820 (+8, the single new logs-timeline SidebarItemDefinition entry added to LOGS_GROUP.items for the new /dashboard/logs/timeline scrolling request-timeline page). Irreducible data-literal wiring at the existing sidebar-sections chokepoint, same shape as every other item in the file; not extractable without an ad-hoc single-item exception to the file's otherwise-uniform multi-line item style.",
"_rebaseline_2026_08_09_v3850_post_sweep_tip": "Release-captain reconciliation of absolute file-size drift on pure tip 382449d593 after the authorized cherry-pick wave. The affected production growth already belongs to merged, tested commits: Adobe Firefly CDP/session recovery (#9881), model capability serialization (#9296), Modality Bridge request wiring (#9759), disconnect-grace/reasoning-cache chatCore wiring (#9653/#9183), stacked Lite precedence, and Responses tool-call index/argument handling (#9843 plus the release translator fixes). This repair adds only the compact migration-146 retroactive guard, covered by db-job-registry-migration-renumber-139.test.ts. Values are the exact check:file-size split-newline measurements and remain shrink-only; structural decomposition remains tracked by the existing #3501 notes.",
"cap": 1000,
"testCap": 1000,
"testFrozen": {
"tests/unit/adobe-firefly.test.ts": 1136,
"tests/unit/reasoning-cache.test.ts": 1035,
"_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).",
"_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.",
@@ -355,7 +357,7 @@
"open-sse/executors/deepseek-web.ts": 1148,
"open-sse/executors/grok-web.ts": 1044,
"open-sse/executors/muse-spark-web.ts": 1405,
"open-sse/handlers/chatCore.ts": 5042,
"open-sse/handlers/chatCore.ts": 5061,
"open-sse/handlers/imageGeneration.ts": 3101,
"open-sse/handlers/responseSanitizer.ts": 1128,
"open-sse/handlers/search.ts": 1536,
@@ -364,12 +366,15 @@
"open-sse/mcp-server/server.ts": 1448,
"open-sse/mcp-server/tools/advancedTools.ts": 1120,
"open-sse/services/accountFallback.ts": 1978,
"open-sse/services/adobeFireflyClient.ts": 2385,
"open-sse/services/adobeFireflyBrowserLogin.ts": 1362,
"open-sse/services/adobeFireflyChromeRuntime.ts": 1201,
"open-sse/services/adobeFireflyClient.ts": 2999,
"open-sse/services/adobeFireflySession.ts": 1003,
"open-sse/services/claudeCodeCompatible.ts": 1202,
"open-sse/services/combo.ts": 3648,
"open-sse/services/compression/strategySelector.ts": 1060,
"open-sse/services/compression/strategySelector.ts": 1061,
"open-sse/services/rateLimitManager.ts": 1167,
"open-sse/translator/response/openai-responses.ts": 1224,
"open-sse/translator/response/openai-responses.ts": 1271,
"open-sse/utils/cursorAgentProtobuf.ts": 1505,
"open-sse/utils/stream.ts": 2889,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1388,
@@ -396,7 +401,7 @@
"src/app/api/v1/models/catalog.ts": 1597,
"src/lib/db/apiKeys.ts": 1529,
"src/lib/db/core.ts": 1639,
"src/lib/db/migrationRunner.ts": 1094,
"src/lib/db/migrationRunner.ts": 1101,
"src/lib/db/models.ts": 1097,
"src/lib/db/providers.ts": 1034,
"src/lib/memory/retrieval.ts": 1073,
@@ -406,8 +411,8 @@
"src/shared/components/RequestLoggerV2.tsx": 1629,
"src/shared/components/analytics/charts.tsx": 1035,
"src/shared/services/cliRuntime.ts": 1122,
"src/sse/handlers/chat.ts": 1904,
"src/sse/services/auth.ts": 2520,
"src/sse/handlers/chat.ts": 1918,
"src/sse/services/auth.ts": 2508,
"tests/unit/account-fallback-service.test.ts": 1572,
"tests/unit/provider-validation-specialty.test.ts": 2985,
"open-sse/executors/hyperagent.ts": 1026,
@@ -426,9 +431,6 @@
"_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.",
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
"_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests.",
"_rebaseline_2026_08_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.",
"_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.",
"_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.",
"_rebaseline_2026_07_25_dario_upstream_proxy_selector": "PR #8523 (Dario embedded service): upstream-proxy mode selector replaces the binary CLIProxyAPI toggle with Native/CLIProxyAPI/Dario/Fallback + a fallback-backend picker. ProviderDetailPageClient.tsx 798->804 (+6, new hook fields threaded through to ConnectionsListPanel), ConnectionRow.tsx 942->958 (+16, the mode <select> + conditional fallback-backend <select> replacing a single pill button), useProviderConnections.ts 954->986 (+32, upstreamProxyMode/upstreamProxyFallbackBackend state + handleSetUpstreamProxyMode, handleToggleCliproxyapiMode kept as a thin backward-compat wrapper for the existing hook-shape test). All additive UI/state for the new modes — no unrelated refactor.",
"_rebaseline_2026_08_02_9242_token_health_transient": "PR #9242 (fix/refresh-circuit-transient): src/lib/tokenHealthCheck.ts 1021 (new file, above cap 1000). The file consolidates token-refresh health checking logic that was previously scattered across auth.ts and tokenRefresh.ts. Cohesive single-responsibility module for refresh circuit state management; not extractable without splitting the refresh state machine. Covered by tests/unit/tokenHealthCheck-transient.test.ts.",
"_rebaseline_2026_07_28_8870_firefly_ref_cap_timeout": "PR #8870 (fix/adobe-firefly-gpt-ref-cap-timeout) own growth: adobeFireflyClient.ts 2322->2385 (+63 = gpt-image subject-ref hard cap at 2 + adaptive poll timeout budget (base 300s + 60s/ref, max 600s) + defensive .slice on referenceBlobs for gpt/nano/generic families). Fixes live 504s on multi-screenshot listing jobs (Featured Promo / Box Art) where 34+ subject refs stall colligo until the old 180s poll budget expires. Helpers adobeFireflyMaxImageRefs/adobeFireflyImageTimeoutMs live next to the existing payload/poll chokepoint (not extractable without splitting the wire recipe mid-PR). Covered by tests/unit/adobe-firefly.test.ts (ref-cap + timeout cases). Structural shrink tracked in #3501.",
@@ -440,6 +442,7 @@
"_rebaseline_2026_08_06b_v3850_sweepreds_drift": "Segunda reconciliacao de 2026-08-06 (/sweep-reds sobre o tip puro 2ddbbc61a6): 3 arquivos voltaram a passar do frozen apos os merges do mesmo dia, com atribuicao 1:1 por commit. (1) src/app/(dashboard)/dashboard/providers/page.tsx 1928->1944 e (2) open-sse/executors/base.ts 1635->1640, ambos do #9515 (feat(radar): flag-gated signed free-model catalog overlay, commit e7f6b1d130) — o overlay do Radar entra por wiring nos chokepoints ja existentes (a resolucao/verificacao do catalogo assinado mora fora destes dois arquivos); +16 e +5 linhas liquidas nao sao extraiveis sem inventar um leaf por callsite. (3) open-sse/services/accountFallback.ts 1966->1972 do #8704 (commit c4527f97bd), +6 linhas de dados em CREDITS_EXHAUSTED_SIGNALS ('has been exhausted', fixes #8631). src/sse/handlers/chat.ts 1880>1877 tambem estava violando e NAO entra aqui de proposito: e drenado por encolhimento na PR #9598, sem rebaseline. Crescimento proprio DESTA PR: src/lib/db/migrationRunner.ts 1077->1084 (+7) — o guard retroativo em isSchemaAlreadyApplied para os arquivos renumerados 137/138, exigido pela propria mensagem de erro de colisao do runner (ambas as migracoes sao ALTER TABLE ADD COLUMN puro, nao idempotente). Dois `case` + dois `return hasColumn(...)` + 3 linhas de comentario dentro do switch existente; nao extraivel.",
"_rebaseline_2026_08_06c_v3850_sweepreds_pr2": "Segunda PR do /sweep-reds (fix/release-v3.8.50-basereds-0806b): tests/unit/provider-models-route.test.ts 1784->1787 (medido pelo gate, que conta split(\"\\n\").length) (+2 apos compressao de comentarios) — alinhamento de contrato forcado por dois merges do dia: #9106 tornou gemini-3.1-pro-high user-callable (a entry do alias entra na lista esperada do teste de discovery-retry, +1 linha de dado + 1 de comentario) e ff012ff420 adicionou onboardUser como bootstrap hop (exclusao no mock, ja comprimida a 1 linha). Nao ha o que encolher sem apagar o comentario que explica o porque.",
"_rebaseline_2026_08_07_v3850_sweepreds_pr2_toolnamemap": "tests/unit/translator-openai-to-gemini.test.ts 1616->1619 (+3). O frozen estava EXATAMENTE no tamanho da base, entao qualquer linha nova viola. #9568 (c9a3361e5a) fez buildChangedToolNameMap emitir entradas IDENTIDADE (o Gemini minusculiza nomes de tool nas respostas, entao o tradutor de resposta precisa da chave para mapear de volta), o que passou a incluir `_toolNameMap` no envelope Antigravity de qualquer request com tools. As 3 linhas sao: a chave nova na lista esperada de Object.keys, 1 comentario explicando POR QUE ela aparece (sem ele o proximo leitor tenta remove-la de novo) e 1 assert do CONTEUDO do map — presenca de chave sozinha nao provaria a entrada identidade, que e justamente o comportamento novo. Nao ha o que extrair: e alinhamento de contrato dentro de um teste existente.",
"_rebaseline_2026_08_08_9634_migration_139_guard": "PR #9634 (fix/release-v3850-basereds) own growth, re-measured on e0ce95c59 after rebase: src/lib/db/migrationRunner.ts 1094->1096 (+2, the isSchemaAlreadyApplied case-139 retroactive guard for the renumbered ccr migration). Irreducible, matches the per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts.",
"_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.",
"_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \\\"headroom\\\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, <cap) and the async orderer orderTargetsByHeadroom is appended to the existing open-sse/services/combo/quotaStrategies.ts (<cap) next to its sibling reset-aware/reset-window orderers (reuses their connection-expansion machinery). headroom = 1 - max(util_5h, util_7d) from getSaturation (src/lib/quota/saturationSignals.ts), prefers the connection with the most free capacity. Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors the reset-aware/reset-window/context-optimized branches); not extractable without hiding the call site. fill-first stays default; all existing strategies untouched. Covered by tests/unit/combo-headroom-ranking.test.ts (pure helper) + tests/unit/combo-headroom-strategy.test.ts (orderer, saturation injected). Structural shrink of combo.ts tracked in #3501.",
@@ -520,7 +523,7 @@
"open-sse/services/rateLimitManager.ts": "1167",
"open-sse/translator/response/openai-responses.ts": "1204",
"open-sse/utils/cursorAgentProtobuf.ts": "1505",
"open-sse/utils/stream.ts": "2889",
"open-sse/utils/stream.ts": 2915,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": "1388",
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": "1031",
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": "3117",
@@ -546,7 +549,7 @@
"src/lib/tokenHealthCheck.ts": "1053",
"src/lib/db/apiKeys.ts": "1529",
"src/lib/db/core.ts": "1639",
"src/lib/db/migrationRunner.ts": "1094",
"src/lib/db/migrationRunner.ts": "1096",
"src/lib/db/models.ts": "1097",
"src/lib/db/providers.ts": "1034",
"src/lib/memory/retrieval.ts": "1073",
@@ -567,5 +570,9 @@
"open-sse/utils/sseHeartbeat.ts": "142",
"_rebaseline_2026_08_04_9305_sse_comments": "#9305 fix: broadened sseCommentsEnabled()",
"_rebaseline_2026_08_09_v3850_release_close": "Release v3.8.50 close reconciliation on e0ce95c592: src/sse/handlers/chat.ts 1904->1918 is the irreducible request-pipeline wiring from #9759 that invokes the Modality Bridge guardrail without moving its implementation into the handler; covered by the 17 Vision Bridge canaries plus the PR-1 focused suite. open-sse/translator/response/openai-responses.ts 1204->1215 is #9168's Responses tool-call argument delta buffering/normalization at the existing translator state-machine chokepoint; covered by its dedicated translator regression tests. Both values are measured by check:file-size (split-newline semantics), and the gate remains frozen at the new exact sizes.",
"_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them — the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario."
"_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them — the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario.",
"_rebaseline_2026_08_03_9255_adobe_firefly_durable_sessions": "PR #9255 own cohesive growth: open-sse/services/adobeFireflyClient.ts 2322->2894 adds authenticated-vs-guest IMS classification, browser-risk ARP validation/rebuild, bounded 408 retry/recovery, sticky accepted-session handling, and matching image/video submit recovery at the existing Adobe upstream client chokepoints. This client was already explicitly frozen as a single self-contained upstream integration by #8006/#8510; splitting only the retry/auth helpers now would scatter one request state machine while structural shrink remains tracked in #3501. tests/unit/adobe-firefly.test.ts 871->1136 adds direct regression coverage for guest-token rejection, cookie/ARP rebuilding, 408 retries, sticky accepted ARP reuse, forced auth recovery, and cookie-to-IMS exchange. The obsolete 1179-line managed-Chrome fallback module was deleted rather than rebaselined after the packaged-safe pure-CDP path became authoritative. Focused Adobe suite: 61/61.",
"_rebaseline_2026_08_07_9653_disconnect_grace_period": "Extracted fix(sse): grace period before finalizing a client disconnect as 499 (#9653) — a client that closes its connection right after reading a fully-completed SSE stream can race OmniRoute's own completion bookkeeping, getting persisted as a false 499/0-tokens even though it delivered the full response (live-confirmed: a real disconnect at 18236ms was corrected to 200/82814+1292 tokens). Own growth: open-sse/handlers/chatCore.ts 5030->5039 (+9, wiring createClientDisconnectGraceHandler at the existing onClientDisconnectFinalize call site) — irreducible call-site wiring, the actual grace-period logic lives in the new leaf createClientDisconnectGraceHandler (open-sse/utils/streamFailureFinalization.ts, not frozen). Re-measured to 5042 after rebasing onto a newer release/v3.8.50 tip: the file carries an unrelated +3 base drift from already-merged upstream commits between this PR's original branch point and the rebase target, not covered by this entry. Covered by tests/unit/stream-disconnect-grace-period-9653.test.ts (4/4, fake-timer driven). Other file-size gate violations present on this base tip are pre-existing/unrelated to this change (base-red #9679, re-verify current issue number at merge time).",
"_rebaseline_2026_08_04_9268_gemini_schema_empty_choices": "Feature #9268 own growth: open-sse/utils/stream.ts 2889->2915 (+26 = irreducible call-site wiring for the empty-choices interceptor). The translate-mode flush now rejects a stream that completed without forwarding any valuable chunk (all-empty `choices: []`, no content/tool_calls/finish_reason) as a retryable 502 \"empty content\" instead of a clean empty 200 — the missing streaming counterpart of chatCore.ts's non-streaming isEmptyContentResponse. All rejection logic lives in the NEW leaf module open-sse/utils/streamEmptyChoices.ts (<cap, not frozen, unit-tested via tests/unit/stream-empty-choices-interceptor.test.ts); stream.ts only carries the `forwardedValuableChunk` boolean (declared at createSSEStream scope, set in emitTranslatedClientItem where the sole hasValuableContent check passes) plus the one flush-time rejectEmptyChoicesStream() call — the wait/orchestration at the chokepoint, not a movable block (mirrors the comboCooldownRetry.ts precedent). Schema-side twin fix: recursive type:\"object\" injection in open-sse/translator/helpers/geminiHelper.ts (not frozen, +33) for nested schemas with properties but no type (Gemini 400).",
"_rebaseline_2026_08_09_5696_capability_filter": "PR #9424 own growth: open-sse/handlers/chatCore.ts 5050->5061 (+11). The Layer A capability gate is irreducible wiring at the existing pre-dispatch chokepoint: feature-flag check, capability derivation, compatibility decision, sanitized 400 response, pending-request cleanup, and warning telemetry. All matching and message logic lives outside the god-file in src/shared/constants/capabilities/capabilityFilter.ts; only orchestration remains here. Covered by tests/unit/capability-filter.test.ts (20 cases, including flag-off and sanitized error behavior). Structural shrink remains tracked separately."
}

View File

@@ -92,17 +92,19 @@
"_rebaseline_2026_07_13_v3847_release": "39.3 -> 38.0 (-1.3, beyond the 0.5 eps). v3.8.47 cycle drift: the cycle merged ~45 PRs adding API routes (relay repair/free-pool #6909, backpressure #6590, combo context requirements #6907, services/usage endpoints) faster than openapi.yaml documentation; same class as the v3.8.34/v3.8.39 rebaselines. Documented follow-up: raise coverage next cycle via docs/openapi.yaml additions."
},
"i18nUiCoverage.pct": {
"value": 99,
"value": 100,
"direction": "up",
"eps": 0.5,
"_tighten_2026_08_08_modality_bridge": "99 -> 100. Tighten required by the PR quality gate after the Modality Bridge UI keys were translated across all 42 non-English locales. CI collect-metrics on PR #9782 measured i18nUiCoverage.pct=100 with 0 ESLint warnings and 0 ESLint errors; locale dry-sync and UI coverage also report 100% with no missing keys or placeholders.",
"_rebaseline_2026_07_04_v3844_release": "77.5 -> 76.8 (-0.7, beyond the 0.5 eps). v3.8.44 cycle drift surfaced only on the release PR (i18n-ui-coverage does NOT run on PR->release fast-gates). The cycle added ~1352 new UI keys to the en.json denominator (Discovery dashboard tab #5939, Bifrost/Mux embedded-service tabs #5817/#6034, proxy batch-ops #5918, fusion defaults #5598, tool-source toggle #5978, quota-row collapse #5977, CodeWhale/Crush CLI cards #5996/#5970, etc.) that the async i18n translation workflow has not yet back-filled (worst locales measure 76.8; __MISSING__ placeholders count as uncovered by design). Same shape and remedy as _rebaseline_2026_06_28_v3839_release. Recover via the i18n workflow next cycle; tighten with --require-tighten once translations land.",
"_rebaseline_2026_06_28_v3839_release": "78.4 -> 77.5 (-0.9, beyond the 0.5 eps). v3.8.39 cycle drift surfaced ONLY on the release PR (i18n-ui-coverage does NOT run on PR->release fast-gates). The cycle added new UI strings (compression studio TOON A/B table, antigravity remote-login dashboard field, amber warning icon) to the en denominator faster than the 37 non-en locales were translated; those locales need `npm run i18n:run` with OMNIROUTE_TRANSLATION_API_KEY (unavailable locally) — same precedent as _rebaseline_2026_06_18_v3828_cycle_close + _quality_rebaseline_2026_06_20_ci_ratchet. Measured by CI collect-metrics (run 28317145160) = 77.5. My release-finalize tree changes no src/i18n/messages/*.json. Tightening is tracked as follow-up (run i18n:run with creds).",
"_rebaseline_2026_07_13_v3847_release": "76.8 -> 75.5 (-1.3, beyond the 0.5 eps). v3.8.47 cycle drift: merged UI features added EN strings (relay repair UI #6909, combo builder #6907/#6991, capability override UI #6727) ahead of the 42-locale mirrors; same class as the v3.8.39/v3.8.44 rebaselines.",
"_rebaseline_2026_07_28_v3849_release": "75.5 -> 99 (+23.5). Aperto EXIGIDO pelo modo --require-tighten do ratchet: a métrica melhorou de verdade no ciclo v3.8.49. A causa é o workflow assíncrono de tradução, que finalmente alcançou o denominador em EN — as rebaselines anteriores (v3.8.39/.44/.47) foram todas afrouxamentos registrando o atraso das traduções, e agora ele foi pago. O coletor SUBTRAI os placeholders (present - placeholder em scripts/quality/collect-metrics.mjs), então os 317 marcadores __MISSING__ que esta release introduziu para o drift de valor já estão descontados dos 99 — o número é honesto, não inflado por placeholder. Medido pelo collect-metrics do CI no run 30404226939."
},
"deadExports": {
"value": 227,
"value": 230,
"direction": "down",
"_rebaseline_2026_08_09_v3850_post_sweep": "227 -> 230. Measured by npm run check:dead-code on the unmodified release/v3.8.50 tip 382449d593 during the mandatory --full-ci pre-flight. The +3 is inherited cycle drift from the authorized merge sweep; this repair adds no production exports. Rebaseline records the actual tip so ci.yml quality-gate can run, while structural cleanup remains separate debt.",
"_rebaseline_2026_07_01_v3843_release": "225->227 (+2). v3.8.43 cycle drift, surfaced in the Quality Ratchet job after eslintWarnings was rebaselined (check:dead-code runs there). 227 = measured by check:dead-code (knip) on the release tip 4635076eb. The 5 CI fixes add 0 dead exports: safeHttpHref in linkify.ts is module-local AND used (called by linkifyText); no new exports; test files are not scanned. Tighten via --update next cycle.",
"dedicatedGate": true,
"_rebaseline_2026_06_30_v3842_deadcode_wave": "310 -> 225. Measured by `node scripts/check/check-dead-code.mjs` on the v3.8.42 tip after the JxnLexn dead-code (#5463/#5464/#5466) + duplication (#5471..#5500) wave landed: DEAD_EXPORTS=133 + DEAD_FILES=92 = 225. The stale 310 was the v3.8.38 release snapshot never ratcheted on PR->release fast-gates (check:dead-code runs only on ci.yml PR->main, not quality.yml). Tightening to the true measured value; release-time captain rebaselines up if parallel cycle merges add dead exports.",

View File

@@ -18,7 +18,7 @@ Unlike API-key providers, Web Cookie providers authenticate using the credential
Many authentication issues are caused by copying cookies from the wrong place.
## Do NOT copy from Cookie Storage
## Do NOT copy from Cookie Storage
Most browsers expose stored cookies through:
@@ -36,7 +36,7 @@ Although these cookies look correct, they may be:
Using these values may cause authentication failures even if they appear valid.
## Copy from a Live Request
## Copy from a Live Request
Instead, use the cookies from a successful request:
@@ -80,14 +80,14 @@ The exact credentials required depend on the provider.
Different websites store authentication differently. Some require only cookies, while others may require additional headers or tokens.
| Provider | Credential Format | Provider Guide |
|----------|-------------------|----------------|
| Claude Web | Full Cookie request header | `docs/providers/CLAUDE_WEB.md` |
| ChatGPT Web | _(verify)_ | |
| Gemini Web | _(verify)_ | |
| Copilot Web | _(verify)_ | |
| Grok Web | _(verify)_ | |
| ... | ... | ... |
| Provider | Credential Format | Provider Guide |
| ----------- | -------------------------------------------------------------- | ------------------------------- |
| Claude Web | Full Cookie request header | `docs/providers/CLAUDE_WEB.md` |
| ChatGPT Web | Full Cookie header or `__Secure-next-auth.session-token` value | `docs/providers/CHATGPT_WEB.md` |
| Gemini Web | _(verify)_ | |
| Copilot Web | _(verify)_ | |
| Grok Web | _(verify)_ | |
| ... | ... | ... |
> Update this table as new Web Cookie providers are added or existing providers change their authentication requirements.

View File

@@ -5225,6 +5225,102 @@ paths:
"200":
description: Sync initialized
# ─── Background Jobs (local-only administration) ───────────────
/api/jobs:
get:
tags: [System]
summary: List registered background jobs
description: Local-only runtime administration. Returns each registered job and its latest run.
x-internal: true
responses:
"200":
description: Registered jobs
"500":
description: Failed to list jobs
/api/jobs/{id}/enable:
post:
tags: [System]
summary: Enable a background job
description: Local-only runtime administration. Enables the job and restarts its timer.
x-internal: true
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: Job enabled
"404":
description: Job not found
"500":
description: Failed to enable job
/api/jobs/{id}/disable:
post:
tags: [System]
summary: Disable a background job
description: Local-only runtime administration. Disables the job and stops its timer.
x-internal: true
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: Job disabled
"404":
description: Job not found
"500":
description: Failed to disable job
/api/jobs/{id}/run-now:
post:
tags: [System]
summary: Trigger a background job
description: >-
Local-only runtime administration. Starts the job, or waits for an in-flight
run before queueing the next one, subject to OMNIROUTE_RUNNOW_TIMEOUT_MS.
x-internal: true
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: Job trigger accepted
"404":
description: Job not found
"500":
description: Failed to trigger job
/api/jobs/{id}/runs:
get:
tags: [System]
summary: Read background-job run history
description: Local-only runtime administration. Returns newest-first run history for one job.
x-internal: true
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: Job run history
"404":
description: Job not found
"500":
description: Failed to load job runs
# ─── Resilience & Monitoring ────────────────────────────────────
/api/resilience:
@@ -5247,6 +5343,70 @@ paths:
"200":
description: Updated resilience configuration
/api/resilience/connections:
get:
tags: [System]
summary: Inspect connection resilience state
description: >-
Local-only operational view of per-connection cooldowns, provider circuit
breakers, model lockouts, and recent breaker transitions. Credential columns
are excluded by an explicit database whitelist.
x-internal: true
parameters:
- name: windowMs
in: query
schema:
type: integer
minimum: 0
maximum: 86400000
default: 3600000
- name: provider
in: query
schema:
type: string
minLength: 1
maxLength: 64
responses:
"200":
description: Connection, breaker, lockout, window, and degradation metadata
"400":
description: Invalid query parameters
"500":
description: Failed to collect resilience state
/api/telegram/update:
post:
tags: [System]
summary: Receive Telegram updates or Mini App messages
description: >-
Public Telegram integration endpoint. Bot updates are acknowledged after
reply dispatch is queued. Mini App requests must include Telegram-signed
initData, which is verified with TELEGRAM_BOT_TOKEN before chat proxying.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
properties:
initData:
type: string
message:
type: string
update_id:
type: integer
responses:
"200":
description: Update acknowledged or Mini App reply returned
"400":
description: Invalid JSON, request shape, or missing Mini App message
"401":
description: Invalid Mini App initData signature
"503":
description: Telegram integration is not configured
/api/resilience/reset:
post:
tags: [System]

View File

@@ -58,7 +58,7 @@ the natural shape is:
- **Public HTTPS required.** Telegram only delivers updates to an HTTPS
endpoint with a valid cert. Self-hosted OmniRoute behind Tailscale/ngrok
needs a public tunnel or Cloudflare Tunnel for the webhook path
(`TELEGRAM_WEBHOOK_URL`-style env). The dashboard can render the current
(a future webhook-URL setting). The dashboard can render the current
public origin (`OMNIROUTE_PUBLIC_BASE_URL`) but no webhook registration
helper exists.
- **Encryption gate.** `webhooks/route.ts:77` already refuses telegram
@@ -102,7 +102,7 @@ the natural shape is:
1. Add `grammy` or `telegraf` (or ~60 lines of hand-rolled HMAC + fetch).
2. Implement `src/lib/telegram/initData.ts``verifyInitData(initData, botToken)`.
3. Stand up a throwaway `POST /api/telegram/miniapp/webhook` route behind
`TELEGRAM_WEBHOOK_SECRET`; register via `setWebhook` once, locally.
a dedicated webhook secret; register via `setWebhook` once, locally.
### Phase 1 — Minimal chat slice (12 dev-days)

View File

@@ -0,0 +1,125 @@
---
title: "Providers — ChatGPT Web (session credentials via Cookie Editor)"
version: 3.8.50
lastUpdated: 2026-08-08
---
# Providers — ChatGPT Web (Plus/Pro session credentials)
`chatgpt-web` (alias `cgpt-web`, display name **ChatGPT Web (Plus/Pro)**) sends OpenAI-format chat requests through an authenticated `chatgpt.com` browser session. It authenticates with the `__Secure-next-auth.session-token` cookie — **no API key required**.
> **New to Web Cookie providers?**
>
> Read **`docs/getting-started/WEB-COOKIE-GUIDE.md`** for the general setup process, limitations, and troubleshooting before following this provider-specific guide.
---
## 1. What credential does OmniRoute need?
Defined in `src/shared/constants/providers/web-cookie.ts` + `src/shared/providers/webSessionCredentials.ts`:
| Field | Value |
| -------------------------- | ----------------------------------------------------------------------------- |
| Provider id | `chatgpt-web` |
| Credential name | `__Secure-next-auth.session-token` |
| Accepts full Cookie header | ✅ yes |
| Accepted storage keys | `cookie`, `sessionToken`, `session-token`, `__Secure-next-auth.session-token` |
Two paste formats both work:
- **Bare value** — just the token contents: `eyJhbGciOi...`
- **Full Cookie header** — `__Secure-next-auth.session-token=eyJhbGciOi...; cf_clearance=...` (preferred — carries rotation/anti-bot cookies the executor needs)
---
## 2. Copy the cookie header with Cookie Editor
Cookie Editor can copy the cookies for the active `chatgpt.com` tab as an HTTP header string.
Always compare the exported value with a live authenticated request as described in section 3.
### 2.1 Install and pin
1. Install **[Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm)** (Moustachauve) in Chrome/Edge, or the Firefox equivalent.
2. Pin it to the toolbar if you use it regularly.
### 2.2 Copy the credential
1. Go to **https://chatgpt.com** and make sure you're **signed in with the Plus/Pro account** you want OmniRoute to use.
2. Open a conversation and send at least one message (forces the session token to be live/refreshed).
3. Click the **Cookie Editor** icon to open its side panel for the active tab.
4. Find `__Secure-next-auth.session-token`. If it's split into chunks (`__Secure-next-auth.session-token.0`, `.1`, …), select **all** of them — OmniRoute's `nextAuthCookie.ts` merges rotated chunk families.
5. Click **Copy**, choose **Header string**, and copy the resulting `name=value; name=value` text.
> **If the token is missing:** confirm that you are signed in, send a message to refresh the session, and inspect the live request in section 3.
---
## 3. Verify the required data (before pasting)
The repo's `WEB-COOKIE-GUIDE.md` mandates a live-request check. Do it once per session:
1. With chatgpt.com open, press **F12****Network** tab.
2. Refresh the page, then send a chat message.
3. Click the conversation request (e.g. `/backend-api/conversation` or the SSE stream) → **Headers****Request Headers****Cookie**.
4. Confirm it contains `__Secure-next-auth.session-token=...`**not** just `cf_clearance` or `__cf_bm`.
The value you copied in step 2.3 must match what the live request sends. If they differ, re-copy from Cookie Editor.
---
## 4. Add / update the credential in OmniRoute
### Dashboard (typical user path)
1. Open the OmniRoute dashboard → **Providers****Add Provider**.
2. Search **ChatGPT Web (Plus/Pro)** (id `chatgpt-web`).
3. Paste the copied cookie header into the credential field.
4. Click **Test Connection**.
5. Save.
If requests later return 401 or 403, re-copy the header from a fresh live session. The executor merges `Set-Cookie` rotations while the connection is active, but it cannot recover a credential that is no longer accepted upstream.
### Bulk / session pools (many accounts)
For multiple ChatGPT sessions, use the bulk web-session import or session-pool endpoints:
- `POST /api/providers/bulk-web-session` — import many cookie credentials at once
- `GET /api/session-pools` + `/api/session-pools/[provider]` — pool rotation across accounts
Each credential blob must carry the `__Secure-next-auth.session-token` value under one of the accepted storage keys (`cookie`, `sessionToken`, `session-token`, or the cookie's exact name).
### Renewing when the session expires
Web sessions can stop working after sign-out or server-side rotation. Re-run steps 2.2 through 4 whenever requests start failing with 401/403.
---
## 5. Contributing updates
If you changed the credential contract (new storage key, new cookie name, changed hint) or are filling the docs gap, contribute it:
1. Update `src/shared/providers/webSessionCredentials.ts` (credential name / placeholder / storage keys) or `src/shared/constants/providers/web-cookie.ts` (`authHint`).
2. Update this guide (`docs/providers/CHATGPT_WEB.md`) and the provider table in `docs/getting-started/WEB-COOKIE-GUIDE.md`.
3. Update `.env.example` + `docs/reference/ENVIRONMENT.md` if you touched env vars, then run:
```bash
node scripts/check/check-env-doc-sync.mjs # must pass
```
4. Run the provider/unit tests:
```bash
npm run test:unit
# targeted: tests/unit/chatgpt-web.test.ts (stealth path)
```
5. Follow `CONTRIBUTING.md`, branch from the current active release tip, use a Conventional Commit message, and open the PR against that active release branch.
> ⚠️ **Never commit a real cookie value.** All examples above are placeholders. If a test fixture needs a token, use a fake `eyJhbGciOi...` string.
---
## Troubleshooting
| Symptom | Likely cause | Fix |
| -------------------------------- | -------------------------------------------- | --------------------------------------------------------- |
| Cookie not in Cookie Editor | Signed out / not HttpOnly-visible | Sign in; enable HttpOnly display in options |
| Token missing from live request | Request is not authenticated | Sign in and send a chat message first |
| 401 after Test Connection passed | Expired or rotated session | Re-copy from a fresh live request |
| Chunked token fails | Only one chunk pasted | Select all `__Secure-next-auth.session-token.*` chunks |

View File

@@ -43,6 +43,7 @@ lastUpdated: 2026-06-28
- [22. Debugging](#22-debugging)
- [23. GitHub Integration](#23-github-integration)
- [24. Skills Sandbox (v3.8.0+)](#24-skills-sandbox-v380)
- [27. Radar Feed (Self-Hosting)](#27-radar-feed-self-hosting)
- [Deployment Scenarios](#deployment-scenarios)
- [Audit: Removed / Dead Variables](#audit-removed--dead-variables)
@@ -189,19 +190,20 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. |
| `OMNIROUTE_CHAT_LARGE_BODY_BYTES` | `262144` (256 KB) | `src/shared/middleware/chatBodyAdmission.ts` | Actual request bodies at or above this threshold require an atomic process-local heavyweight admission lease before JSON parsing. |
| `OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES` | `52428800` (50 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Chat-route hard cap enforced against bytes read during bounded ingestion, including requests with missing, invalid, or dishonest `Content-Length`; excess receives `413`. |
| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | `1` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum heavyweight chat requests admitted concurrently in one process. When capacity is unavailable, OmniRoute waits up to `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` for a slot, then returns retryable `503` with `Retry-After`. |
| `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` | `5000` | `src/shared/middleware/chatBodyAdmission.ts` | How long a heavyweight chat request waits for an admission slot before the retryable `503`. A bounded wait serializes agent bursts (OpenCode, Claude Code, Cursor sub-requests) that would otherwise burn their client retry budget on immediate rejections; `0` restores the legacy immediate-reject behaviour. |
| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | `1` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum heavyweight chat requests admitted concurrently in one process. When capacity is unavailable, OmniRoute returns retryable `503` with `Retry-After`. |
| `OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT` | `200` | `src/shared/middleware/chatBodyAdmission.ts` | Message count that classifies a chat request as heavyweight even when its body is below the byte threshold. |
| `OMNIROUTE_CHAT_HEAVY_TOOL_COUNT` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Tool count that classifies a chat request as heavyweight even when its body is below the byte threshold. |
| `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. |
| `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `800` | `src/shared/middleware/chatBodyAdmission.ts` | Hard chat history cap. Requests above it receive structured compact-required `413` before compression, translation, or provider dispatch. |
| `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `0` (disabled) | `src/shared/middleware/chatBodyAdmission.ts` | Optional opt-in chat history cap. Disabled by default: a message count is deployment policy, not a universal property of a request, and capping here rejects conversations with a terminal `413` before the compression pipeline can make them servable. Heap growth is bounded by `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` and the heap-pressure shed. Set a positive value on memory-constrained deployments that need a hard ceiling; excess then receives structured compact-required `413`. |
| `OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES` | `67108864` (64 MB) | `open-sse/handlers/chatCore/nonStreamingResponseBody.ts` | Hard cap for a non-streaming upstream response buffered fully into memory. Past this the upstream reader is cancelled and the request fails fast instead of growing an unbounded string until the heap is exhausted. |
| `OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES` | `768` | `open-sse/handlers/chatCore/responseHeaders.ts` | Max wire bytes forwarded from upstream response headers. When the budget is exceeded, lower-priority headers (e.g., custom `x-codex-*`, `x-oai-request-id`) are dropped to stay within common reverse-proxy header limits. Set higher to forward more upstream metadata at the cost of larger response header size. |
| `CORS_ORIGIN` | _(unset)_ | `src/server/cors/origins.ts` | Legacy single-origin CORS allowlist. Prefer `CORS_ALLOWED_ORIGINS` for new deployments. CORS is only for cross-origin browser API clients; authenticated dashboard writes use same-origin requests plus session-bound CSRF protection instead. |
| `CORS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/cors/origins.ts` | Comma-separated CORS allowlist. No wildcard is sent unless `CORS_ALLOW_ALL=true` is explicitly configured. |
| `CORS_ALLOW_ALL` | `false` | `src/server/cors/origins.ts` | Development-only escape hatch to echo any browser `Origin`. Do not enable on shared or production deployments. |
| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. |
| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, 10.x.x.x, etc.). **REQUIRED for self-hosted providers** (LM Studio, Ollama, vLLM, Llamafile, Triton, SearXNG). When `false`, the dashboard rejects validation of local URLs. |
| `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN, private ranges) — scoped to the provider validation path. **Default `true`** (local-first); set `false` to enforce strict public-only blocking. Cloud-metadata endpoints (169.254.169.254, metadata.google.internal) stay blocked regardless. (#5066) |
| `AUDIO_REMOTE_PROVIDER_NODES` | `false` | `src/app/api/v1/_shared/audioProviderNodes.ts` | Let the `/v1/audio/*` routes (transcriptions, speech, translations) use an OpenAI-compatible provider node hosted outside localhost. Off by default — routing audio to a remote host changes egress identity and must be an explicit operator decision. Loopback/private nodes (localhost, 127.0.0.1, 172.16-31.x) are always allowed and unaffected. (#3963) |
### Hardening Checklist
@@ -265,6 +267,7 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
| `OMNIROUTE_PAYLOAD_RULES_PATH` | `./config/payloadRules.json` | `open-sse/services/payloadRules.ts` | Path to payload manipulation rules JSON file (per-model/protocol upstream tweaks). |
| `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | Reload interval (ms) for hot-reloading the payload rules file. Minimum `1000`. |
| `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` | `false` | `open-sse/services/model.ts` | Opt-in: route bare `claude-*` model IDs from Claude Code clients through the Claude Code OAuth account instead of requiring a provider prefix. Explicit provider prefixes still win. Also configurable via a dashboard toggle on the Claude provider page. |
| `COMBO_CONCURRENCY_PER_MODEL` | `3` | `open-sse/services/comboConfig.ts` | Per-model concurrency cap for round-robin combos (#9100). The round-robin combo semaphore was hard-capped at 3 concurrent requests per model with no override, serializing higher-concurrency traffic behind that cap. Validated to `>= 1`, clamped to `<= 32`. |
---
@@ -378,6 +381,14 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex,
| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. |
| `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. |
| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. |
| `CLI_DEVIN_AGENTIC_BIN` | `devin` | `open-sse/executors/devin-cli-agentic.ts` | Agentic bridge-only Devin CLI override. The executor accepts only the local ACP stdio upstream. |
| `DEVIN_AGENTIC_HOME` | _(required)_ | `open-sse/executors/devin-cli-agentic.ts` | Absolute isolated home for the agentic Devin subprocess; accepted bridge paths are `/home/bridge` and task-local `.sandbox` paths. |
| `DEVIN_AGENTIC_ACP_TIMEOUT_MS` | `120000` | `open-sse/executors/devin-cli-agentic.ts` | Maximum duration of one Devin ACP turn before the bridge terminates the child and returns an explicit timeout. |
| `DEVIN_BRIDGE_MODEL` | `devin-cli-agentic/swe-1-7` | `docker/devin-bridge/compose.yml` | Main Claude Code model alias for the isolated bridge. The live harness replaces the example with a model returned by the current Devin account. |
| `DEVIN_BRIDGE_SONNET_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Sonnet default. |
| `DEVIN_BRIDGE_OPUS_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Opus default. |
| `DEVIN_BRIDGE_HAIKU_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Haiku default. |
| `DEVIN_BRIDGE_SUBAGENT_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used for Claude Code subagents. |
| `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. |
| `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). |
| `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). |
@@ -415,7 +426,6 @@ detection above).
| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. |
| `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | Set to `1` to print retry/backoff diagnostics to stderr during CLI commands. |
| `OMNIROUTE_PLUGIN_PATH` | _(unset)_ | `bin/cli/plugins.mjs` | Custom directory for CLI plugin discovery (`omniroute-cmd-*` packages). Defaults to `~/.omniroute/plugins/` when unset. |
| `OMNIROUTE_PLUGINS_ALLOW_EXEC` | `0` | `src/lib/plugins/pluginWorker.ts` | Set to `1` to allow plugins to request the `exec` permission (spawn child processes from the worker sandbox). Local operator only. |
---
@@ -452,6 +462,7 @@ detection above).
| `COMPRESSION_PIPELINE_BREAKER_THRESHOLD` | `3` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Consecutive cross-request failures before an engine's breaker opens. |
| `COMPRESSION_PIPELINE_BREAKER_COOLDOWN_MS` | `30000` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Milliseconds an opened engine stays skipped before a half-open probe. |
| `COMPRESSION_CCR_RETRIEVAL_RAMP_FACTOR` | `2` | `open-sse/services/compression/engines/ccr/index.ts` | T08/H8 CCR retrieval-feedback ramp: each prior retrieval of a stored block raises its effective `minChars` linearly (frequently-retrieved content compresses less; `>=3` retrievals = never compressed). `1` disables the ramp (binary skip at the threshold only). |
| `COMPRESSION_CCR_DURABLE_STORE` | `true` | `open-sse/services/compression/engines/ccr/index.ts` | CCR durable block store (#9061). Backs the in-memory store with SQLite so a block survives LRU eviction, the TTL, a restart, or a retrieve landing on another instance. Set `false` to keep blocks in memory only. Blocks over 512KB and cloud runtimes stay memory-only regardless. |
| `COMPRESSION_PREFIX_FREEZE_ENABLED` | `false` | `open-sse/services/compression/prefixFreeze.ts` | T08/H5 usage-observed prefix freeze master switch. **Opt-in (default off)** — when on, a system prompt observed `>=` the threshold is treated as a stable cacheable prefix and preserved from compression even for providers the static cache heuristic misses (freeze only *preserves*, never mutates). |
| `COMPRESSION_PREFIX_FREEZE_THRESHOLD` | `3` | `open-sse/services/compression/prefixFreeze.ts` | Observations of a system prompt before it is treated as a frozen stable prefix. |
| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. |
@@ -507,8 +518,12 @@ Built-in credentials for **localhost development**. For remote deployments, regi
| `OMNIROUTE_QODER_WORKSPACE` | Qoder | Alias for `QODER_CLI_WORKSPACE`. |
| `QODER_CLI_CONFIG_DIR` | Qoder | Override the Qoder CLI config dir (isolated PAT session, avoids clobbering a browser login). |
| `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | Frontend `tk` token to send as `validated` on `/api/chat`. Required when Blackbox enforces token matching; otherwise OmniRoute falls back to a random UUID. See issue #2252. |
| `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. |
| `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. When the URL is OmniRoute's own `/v1`, the describe sub-request sends `x-omniroute-admission-bypass: internal` and authenticates with the resolved self-loop credential (`sk_omniroute` sentinel in local mode, or `OMNIROUTE_API_KEY` / `ROUTER_API_KEY`#1350) so `REQUIRE_API_KEY=true` deployments work. |
| `VISION_BRIDGE_API_KEY` | Vision Bridge guardrail | API key for the URL above. Overrides per-provider OpenAI / Google env vars for non-Anthropic vision-bridge calls. Anthropic models keep their dedicated Anthropic key path. Issue #2232. |
| `RAYCAST_BEARER_TOKEN` | Raycast Pro | Optional manual override for the Raycast access token (normally captured via macOS Auto-Import). No OAuth client_id/secret — reverse-engineered, local/personal use only. |
| `RAYCAST_DEVICE_ID` | Raycast Pro | Optional manual override for the Raycast device ID used to sign requests. |
| `RAYCAST_AID` | Raycast Pro | Optional manual override for the Raycast account/app ID; falls back to the device ID when unset. |
| `RAYCAST_SIG_SECRET` | Raycast Pro | Optional override for the request-signing HMAC secret. Defaults to a community-extracted value in `open-sse/services/raycast.ts`. |
> [!WARNING]
>
@@ -656,12 +671,13 @@ REQUEST_TIMEOUT_MS (global override)
| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). |
| `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. |
| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | Fallback used by `src/shared/utils/fetchTimeout.ts` when `FETCH_TIMEOUT_MS` is unset. |
| `OMNIROUTE_RELAY_FETCH_TIMEOUT_MS` | `25000` | Relay-specific fetch timeout in `open-sse/utils/proxyFetch.ts` (#9158). A hung relay must fail before the client/agent timeout (~30s) so callers see a relay-specific failure instead of a generic upstream timeout. Capped at `29000` so it always fires first. |
| `OMNIROUTE_RETRY_BACKOFF_MS` | `10` | Shared retry backoff for the direct/relay/proxy retry-once paths in `open-sse/utils/proxyFetch.ts` (#9158). `0` = retry immediately. |
| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`chatgptTlsClient.ts`). |
| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000` | Max wait for the first streamed byte from the ChatGPT TLS sidecar. |
| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding. |
| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_RUNNOW_TIMEOUT_MS` | `30000` | Timeout for the `/api/jobs/:id/run-now` endpoint. Bounds how long a run-now call waits for an in-flight job to finish before starting the queued run. See `src/app/api/jobs/[id]/run-now/route.ts`. |
| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000` (30s) | Max wait for the first streamed byte from the ChatGPT TLS sidecar (`chatgptTlsClient.ts`) before aborting a dead stream. Raise if upstream cold-starts exceed the window. |
| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`claudeTlsClient.ts`). |
| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). |
| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`grokTlsClient.ts`). |
@@ -670,6 +686,7 @@ REQUEST_TIMEOUT_MS (global override)
| `OMNIROUTE_NOTION_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
| `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. |
| `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. |
| `OMNIROUTE_LOGIN_BROWSER_PATH` | _(auto-detected)_ | Path to a system Chrome/Edge executable for the Adobe Firefly interactive browser sign-in (`adobeFireflyBrowserLogin.ts`); overrides per-OS auto-detection. |
Combo target attempts inherit the resolved upstream request timeout (`FETCH_TIMEOUT_MS`, or
`REQUEST_TIMEOUT_MS` when it supplies the fetch default). Set `targetTimeoutMs` in a combo,
@@ -717,8 +734,9 @@ The logging system writes to both stdout and rotated log files. All configuratio
| `CALL_LOG_RETENTION_DAYS` | `7` | Days to keep request/call log entries in the database. |
| `CALL_LOG_MAX_ENTRIES` | `10000` | Max call log entries in the in-memory buffer. |
| `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `call_logs` SQLite table before pruning. |
| `ENABLE_REQUEST_LOGS` | _(unset)_ | Force detailed request logging on or off, overriding the dashboard setting. |
| `MAX_PENDING_REQUEST_AGE_MS` | `3600000` (1 hour) | Max age for orphaned active request log entries before in-memory cleanup. |
| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `true` | Store stream chunks in pipeline artifacts when `call_log_pipeline_enabled=true`. |
| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `false` | Store stream chunks in pipeline artifacts when `call_log_pipeline_enabled=true`. Opt-in (`true`) — off by default to save disk. |
| `CALL_LOG_PIPELINE_MAX_SIZE_KB` | `512` | Max pipeline call log artifact size in KB when `call_log_pipeline_enabled=true`. |
| `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `proxy_logs` SQLite table before pruning. |
| `APP_LOG_ROTATION_CHECK_INTERVAL_MS` | `60000` (1 min) | How often `src/lib/logRotation.ts` re-checks the active log file size. |
@@ -726,7 +744,6 @@ The logging system writes to both stdout and rotated log files. All configuratio
| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `128` | Number of array items retained from the tail when truncating chat log payloads. |
| `CHAT_LOG_MAX_DEPTH` | `6` | Max nesting depth before chat log payloads are truncated. |
| `CHAT_LOG_MAX_OBJECT_KEYS` | `80` | Max object keys retained in chat log payloads (0 = unlimited). |
| `CHAT_LOG_MAX_BODY_KB` | `1024` | Max request/response body size before `truncateForLog()` summarizes it, in KB. |
| `CHAT_DEBUG_FILE` | `false` | When true, `serializeArtifactForStorage` skips size-based truncation. Debug only. |
---
@@ -763,9 +780,13 @@ Embedding layer, vector store and reranking knobs for the persistent memory subs
| `MEMORY_TRANSFORMERS_MODEL` | `Xenova/all-MiniLM-L6-v2` | HF repo id for the opt-in `@huggingface/transformers` local MiniLM pipeline (~23 MB int8, ~400 MB RAM). |
| `MEMORY_STATIC_MODEL` | `minishlab/potion-base-8M` | HF repo id for the static potion/Model2Vec lookup-table embedder. Downloaded lazily into the cache dir. |
| `MEMORY_STATIC_CACHE_DIR` | `<DATA_DIR>/embeddings` | Directory used to cache the static potion model files. Defaults under `DATA_DIR` when unset. |
| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). |
| `MEMORY_VEC_TOP_K` | `20` | Default top-K used by the `sqlite-vec` brute-force vector search inside `src/lib/memory/vectorStore.ts`. |
| `MEMORY_RRF_K` | `60` | Reciprocal Rank Fusion constant `k` for hybrid FTS5 + vector retrieval (sqlite-vec recipe). |
| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). |
| `NOTION_API_KEY` | _(unset)_ | API key for Notion backend (used by `genericBackend.ts` known backend preset). |
| `NOTION_API_URL` | `https://api.notion.com/v1`| Base URL for Notion API (can override for self-hosted Notion alternatives). |
| `OBSIDIAN_API_KEY` | _(unset)_ | API key for Obsidian Vault backend (used by `genericBackend.ts` known backend preset). |
| `OBSIDIAN_API_URL` | `http://localhost:27123` | Base URL for Obsidian Vault API (can override for remote vault). |
| `MEMORY_TYPED_DECAY_ENABLED` | `false` | TV6 typed memory decay master switch. **Opt-in (default off)** — the sweep **deletes** decayed memories. With it off, `access_count`/`last_accessed_at` are pure telemetry and nothing is ever deleted. |
| `MEMORY_TYPED_DECAY_EPISODIC_DAYS` | `30` | TTL (days) after which an unused `episodic` memory decays. `0` makes episodic immune too. Durable types (`factual`/`procedural`/`semantic`) are always immune. The decay clock re-bases on `last_accessed_at`. |
| `MEMORY_TYPED_DECAY_ACCESS_IMMUNITY` | `3` | A memory injected `>=` this many times becomes immune to decay regardless of type. `0` disables access immunity. |
@@ -832,6 +853,7 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov
| Variable | Default | Source File | Description |
| ----------------------------------- | ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MODELS_DEV_SYNC_ENABLED` | `false` | `src/lib/modelsDevSync.ts` | Opt-in switch for the models.dev capability sync. Set to anything non-empty it wins over the `modelsDevSyncEnabled` setting (Dashboard > Settings > AI) in either direction, so a deployment can pin the sync on or off without depending on database state surviving a rebuild; unset, it defers to that setting. On for `1`, `true`, `yes` or `on` in any casing; any other value is off. |
| `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. |
| `CONTEXT_WINDOW_RECONCILE_INTERVAL` | `86400` (24h) | `src/lib/contextWindowResolver.ts` | Interval (seconds) for the self-correcting context-window reconciler (5004): pins provider-declared windows from `/models` discovery as `auto:discovery` overrides when they diverge from the catalog. Set to `0` to disable. Reuses already-synced data (no new fetch); never overwrites `manual` overrides. |
@@ -847,6 +869,7 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov
| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. |
| `DESIGNER_WEB_POLL_TIMEOUT_MS` | `60000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | Max wait for microsoft-designer-web image generation jobs. |
| `DESIGNER_WEB_POLL_INTERVAL_MS` | `2000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | microsoft-designer-web job polling frequency. |
| `ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS` | `8000` | `open-sse/services/adobeFireflyUpscale.ts` | Base delay for the Adobe Firefly upscale submit-retry exponential backoff. |
| `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). |
| `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. |
| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. |
@@ -868,6 +891,10 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov
| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. |
| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. |
| `MUX_SERVICE_PORT` | `8322` | `src/lib/services/bootstrap.ts` | Override the port where the embedded Mux (coder/mux) agent-orchestration daemon listens (always 127.0.0.1). |
| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). |
| `DARIO_PORT` | `3456` | `open-sse/executors/dario.ts` | Dario embedded-service port. |
| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). |
| `DARIO_PORT` | `3456` | `open-sse/executors/dario.ts` | Dario embedded-service port. |
| `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). |
`ENABLE_CC_COMPATIBLE_PROVIDER` is only for third-party relays that accept Claude Code clients
@@ -949,7 +976,7 @@ changing them requires a code edit, not an env var:
| `CURSOR_AGENT_CLI_VERSION` | _(detect / pin)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Agent CLI build id (`YYYY.MM.DD-<hash>`) for `x-cursor-client-version: cli-…` on Agent Run. |
| `CURSOR_DATA_DIR` | _(probed)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Override Cursor Agent CLI data dir (`…/versions/<id>`); same var the official agent uses. |
| `CURSOR_TOKEN` | _(unset)_ | `scripts/ad-hoc/cursor-tap.cjs` | Direct Cursor bearer token used by developer tooling. |
| `OMNIROUTE_LOG_REQUEST_SHAPE` | enabled (`!== "0"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads. Set `"0"` to silence. |
| `OMNIROUTE_LOG_REQUEST_SHAPE` | disabled (opt-in via `"1"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads when `"1"` is set. Off by default to reduce log noise. |
| `DEBUG_RESPONSES_SSE_TO_JSON` | _(unset)_ | `open-sse/handlers/responseTranslator.ts` | Set `true` to log Responses API SSE→JSON translation details. |
| `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(unset)_ | E2E test harness | Set `true` to enable E2E test mode (relaxed auth, test hooks). |
@@ -1148,6 +1175,13 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(unset)_ | `src/lib/security/localEndpoints.ts` | Bearer token for `/api/local/*` callers that aren't on loopback (e.g. the desktop app). When set, requests from non-loopback IPs must carry `Authorization: Bearer <token>`. Required when `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` in non-loopback deployments. |
| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | Container name for the 1-click Redis launcher (`omniroute redis up`). Used by both the CLI and the `RedisLauncherPanel` GUI. |
| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | Host port for the 1-click Redis launcher. Bump if the host already binds 6379. The container's internal port stays 6379. |
| `OMNIROUTE_REDIS_BIND_HOST` | `127.0.0.1` | `bin/cli/commands/redis.mjs` | Host interface the 1-click Redis launcher publishes on. The launcher starts Redis WITHOUT a password, so binding `0.0.0.0` hands every host on your LAN an unauthenticated Redis — only widen this if you also set a password on the instance yourself. |
| `REDIS_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Redis sidecar on (#9286). The compose Redis runs without `requirepass`; app containers reach it over the compose network (`redis:6379`) — the published port exists only for host-side tooling. `0.0.0.0` exposes an unauthenticated Redis to the whole LAN. |
| `REDIS_PORT` | `6379` | `docker-compose.yml` | Host port for the compose Redis sidecar. |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | _(unset — mechanism disabled)_ | `src/lib/api/internalServiceAuth.ts` | Shared secret for identity-preserving internal REST hops (#9260): OmniRoute components calling other local OmniRoute routes send it as `x-omniroute-internal-service-token` so the original caller identity is preserved. Compared with `timingSafeEqual`. |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | _(unset)_ | `src/lib/api/internalServiceAuth.ts` | Secret-file variant of the internal service token: path to a file whose trimmed content is the token. Only consulted when the inline var is unset. |
| `OPENROUTER_PROVIDER_STATS_ENABLED` | `true` | `src/lib/catalog/openrouterProviderStats.ts` | Enrich the dashboard providers list with OpenRouter weekly ranking stats (#9324). On by default; set `false` to skip the background fetch entirely (non-blocking, never fatal). |
| `OPENROUTER_PROVIDER_STATS_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterProviderStats.ts` | Cache TTL for the OpenRouter provider-stats snapshot, in milliseconds. |
| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | Redis image used by the 1-click Redis launcher. Override to `redis:8-alpine` or a private registry mirror as needed. |
| `QDRANT_HOST` | `qdrant` | _(opt-in cluster profile)_ | Hostname of the Qdrant sidecar when `--profile memory` is active. Default points to the in-network qdrant service name; override for an external deployment. Only consumed when `qdrantEnabled` is `true` in code (`src/lib/memory/vectorStore.ts:108`). |
| `QDRANT_PORT` | `6333` | _(opt-in cluster profile)_ | REST port of the Qdrant sidecar. |
@@ -1173,6 +1207,17 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `OMNIROUTE_ROTATE_400_THRESHOLD` | `1` | `open-sse/services/rotationConfig.ts` | Number of `400` errors within `OMNIROUTE_ROTATE_400_WINDOW_SECONDS` required before the account is rotated (only consulted when `OMNIROUTE_ROTATE_ON_400=true`). |
| `OMNIROUTE_ROTATE_400_WINDOW_SECONDS` | `120` | `open-sse/services/rotationConfig.ts` | Sliding window (seconds) over which `400` errors are counted toward `OMNIROUTE_ROTATE_400_THRESHOLD`. |
### Claude Warmup Scheduler
Cron-driven warmup for opted-in Anthropic OAuth connections, so the 5-hour rate-limit window is opened by a trivial scheduled request instead of by the first real one (#8848). The scheduler is off unless `OMNIROUTE_WARMUP_ENABLED` is truthy **and** the connection is flagged in `settings.claudeWarmup.connections`; an empty connection list means nothing is warmed even with the env var on.
| Variable | Default | Source File | Description |
| ----------------------------- | -------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OMNIROUTE_WARMUP_ENABLED` | _(unset → off)_ | `src/lib/warmupScheduler.ts` | Master switch for the warmup scheduler. Accepts `1`/`true`/`yes`/`on` (case-insensitive, trimmed). Any other value, or unset, leaves the scheduler off. |
| `OMNIROUTE_WARMUP_CRON` | `0 7 * * *` | `src/lib/warmupScheduler.ts` | Five-field cron expression for the warmup tick, evaluated in `America/Los_Angeles` (Anthropic's reset timezone) regardless of the host clock. |
| `OMNIROUTE_WARMUP_CONCURRENCY` | `3` | `src/lib/warmupScheduler.ts` | How many connections are warmed in parallel per tick. Clamped to `1`-`10`; a non-numeric value falls back to `3`. |
| `OMNIROUTE_WARMUP_MODEL` | `claude-3-5-haiku-20241022` | `src/lib/warmupScheduler.ts` | Model used for the warmup request. Override only if the default is unavailable on your plan; pick the cheapest model that still opens the window. |
### Browser-Login VNC Sessions & Data-Dir Alias
Containerized Chromium+VNC used for interactive browser-login credential capture (`/api/vnc-session`), plus a legacy `DATA_DIR` alias. All optional — the VNC defaults target the bundled `omniroute-vnc-chromium:local` image and are only overridden for a custom container image, ports, or lifecycle tuning.
@@ -1237,6 +1282,25 @@ that should be able to run the docs translator.
---
## 27. Radar Feed (Self-Hosting)
Optional add-on gated by the RADAR_ENABLED feature flag (default off — a feature
flag toggled via Settings/DB, not an env var; see
[docs/frameworks/RADAR.md](../frameworks/RADAR.md#flag-radar_enabled-default-off)).
The four variables below are optional overrides used only to point the client at a
self-hosted or forked feed / supporter-key flow instead of the default OmniRoute
Radar service. See [docs/frameworks/RADAR.md](../frameworks/RADAR.md) for the full
module doc.
| Variable | Default | Source File | Description |
| -------------------------------- | --------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ |
| `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. |
| `RADAR_FEED_PUBKEY` | _(pinned default key)_ | `src/lib/radar/pinnedKeys.ts` | Ed25519 public key (base64-DER SPKI or PEM) used to verify feed signatures from a custom feed. |
| `RADAR_CONTRIBUTOR_CLAIM_URL` | `https://radar.omniroute.online/auth/github` | `src/lib/radar/links.ts` | URL the "I'm a contributor" dashboard button opens (GitHub OAuth supporter-key claim flow). |
| `RADAR_SUPPORTER_PLANS_URL` | `https://radar.omniroute.online/planos` | `src/lib/radar/links.ts` | URL the "Support the project" dashboard button opens (payment/plans page). |
---
## Audit: Removed / Dead Variables
The following variables appeared in previous versions of `.env.example` but have **no runtime references** in the current codebase. They have been removed:
@@ -1304,13 +1368,52 @@ Used by `src/lib/vncSession/manifest.ts` to configure Docker-based headless Chro
| `OMNIROUTE_VNC_HARVEST_MS` | `20000` | `src/lib/vncSession/manifest.ts` | Harvest/cleanup timeout (ms). |
| `VIBEPROXY_DATA_DIR` | _(unset)_ | `open-sse/services/notionThreadSessions.ts` | Directory for Notion thread session persistence. |
### Telegram Mini App
### Internal service auth
Used by `src/lib/telegram/*` and `src/app/api/telegram/update/route.ts` for the inbound bot webhook and Mini App chat proxy. All optional — the endpoint returns 503 when `TELEGRAM_BOT_TOKEN` is unset.
| Variable | Default | Description |
| --- | --- | --- |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | | Inline token for management-plane service-to-service authentication. |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | | Path to a file containing the internal service token (preferred in containers; overrides the inline variable). |
| Variable | Default | Source File | Description |
| ------------------------------ | -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------- |
| `TELEGRAM_BOT_TOKEN` | _(unset)_ | `src/lib/telegram/config.ts` | Bot token from @BotFather (`<numeric_id>:<secret>`). Enables the inbound webhook; doubles as the HMAC secret for Mini App `initData` verification. |
| `TELEGRAM_DEFAULT_MODEL` | `auto/chat` | `src/lib/telegram/chatProxy.ts` | Model used for Telegram chat replies. |
| `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override (proxies / self-hosted Bot API servers). |
| `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout (ms) for outbound Bot API calls (`sendMessage`/`setWebhook`). |
### OpenRouter provider stats
| Variable | Default | Description |
| --- | --- | --- |
| `OPENROUTER_PROVIDER_STATS_ENABLED` | `true` | Set to `false` to skip fetching OpenRouter per-provider stats for catalog enrichment. |
| `OPENROUTER_PROVIDER_STATS_TTL_MS` | `3600000` | Cache TTL (ms) for the fetched OpenRouter provider stats. |
### Embedded Redis binding
| Variable | Default | Description |
| --- | --- | --- |
| `REDIS_BIND_HOST` | `127.0.0.1` | Bind address for the embedded Redis service. |
| `REDIS_PORT` | `6379` | Port for the embedded Redis service. |
| `OMNIROUTE_REDIS_BIND_HOST` | | OmniRoute-scoped override for the embedded Redis bind address. |
---
## 24. Release v3.8.50 additions
These settings were introduced after the previous environment-contract snapshot.
| Variable | Default | Source File | Description |
| --- | --- | --- | --- |
| `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` | `5000` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum wait for a heavyweight chat admission slot before a retryable `503`; `0` restores immediate rejection. |
| `OMNIROUTE_RUNNOW_TIMEOUT_MS` | `30000` | `src/app/api/jobs/[id]/run-now/route.ts` | Bounds how long a run-now call waits for an in-flight job before starting the queued run. |
| `CHAT_LOG_MAX_BODY_KB` | `1024` | `src/lib/logEnv.ts` | Maximum request or response body size before log summarization, in KiB. |
| `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh through account-scoped Chrome CDP sessions; set `0` to disable. |
| `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR`; set `0` for memory-only state. |
| `ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS` | `12000` | `open-sse/services/adobeFireflySession.ts` | Minimum spacing between Adobe Firefly generate submissions. |
| `ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS` | `15000` | `open-sse/services/adobeFireflySession.ts` | Extra quiet period after every third successful Adobe submission. |
| `ADOBE_FIREFLY_CHROME_CDP_PORT` | `9334` | `open-sse/services/adobeFireflyChromeRuntime.ts` | CDP port for the account-scoped Chrome runtime. |
| `ADOBE_FIREFLY_CHROME_VISIBLE` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Set `1` to keep the Adobe renewal browser visible; the default parks a headed window off-screen. |
| `ADOBE_FIREFLY_CHROME_HEADLESS` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Debug-only true-headless mode; Adobe colligo normally rejects the resulting risk session. |
| `ADOBE_FIREFLY_CHROME_FORCE_RESTART` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Set `1` to restart the account-scoped Chrome runtime before renewal. |
| `ADOBE_FIREFLY_CHROME_PING` | automatic | `open-sse/services/adobeFireflyChromeRuntime.ts` | `1` forces, and `0` disables, the in-page generate probe used to prove the renewed ARP session. |
| `ADOBE_FIREFLY_LOGIN_WAIT_MS` | context-dependent | `open-sse/services/adobeFireflyChromeRuntime.ts` | Interactive-login wait budget: `0` on background renewal and `300000` on the explicit login flow unless overridden. |
| `ADOBE_FIREFLY_FORTER_WAIT_MS` | `45000` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Maximum wait for a fresh Forter token during session renewal. |
| `CHROME_PATH` | auto-detect | `open-sse/services/adobeFireflyChromeRuntime.ts` | Optional absolute Chrome executable used when platform auto-detection is insufficient. |
| `TELEGRAM_BOT_TOKEN` | _(unset)_ | `src/lib/telegram/config.ts` | BotFather token that enables the inbound webhook and signs Mini App `initData`. |
| `TELEGRAM_DEFAULT_MODEL` | `auto/chat` | `src/lib/telegram/chatProxy.ts` | Model used for Telegram chat replies. |
| `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override for proxies or self-hosted Bot API servers. |
| `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout in milliseconds for outbound Bot API calls. |

View File

@@ -1,13 +1,13 @@
---
title: "Guardrails"
version: 3.8.50
lastUpdated: 2026-08-07
lastUpdated: 2026-08-08
---
# Guardrails
> **Source of truth:** `src/lib/guardrails/`
> **Last updated:** 2026-08-07 — v3.8.50 (Modality Bridge PR-1: mode selector, task-aware prompt, describe cache, transparency header + stats)
> **Last updated:** 2026-08-08 — v3.8.50 (Modality Bridge PR-2: dashboard settings page, live stats, test action, and media-provider shortcuts)
Guardrails enforce safety, policy, and content transformations at the boundary
between OmniRoute and upstream providers. Each guardrail can inspect (and
@@ -134,6 +134,22 @@ swap is already visible in the response body's `model` field.
PR-3-reserved `audio`). Counters reset on process restart by design
(telemetry, not accounting).
#### Dashboard configuration
The dedicated dashboard page is
`/dashboard/settings/modality-bridge`. Its URL-addressable `Vision`, `Audio`,
and `Video` tabs preserve query parameters while switching the `tab` value.
The Vision tab is live: it exposes enablement, mode, model selection (including
the automatic default), task-aware prompting, advanced timeout/image/cache
limits, runtime counters, and a guarded sample request. Audio and Video are
explicit placeholders: Audio is reserved for PR-3, while Video remains tracked
in issue `#9760`.
The former Vision Bridge card under AI settings is a compatibility link to the
new page; it no longer owns a second copy of the form. Media Providers also
links Image-to-Text and Speech-to-Text workflows to the corresponding Modality
Bridge tabs without removing the existing Speech-to-Text playground.
**Self-loop admission bypass:** when the describe call routes through OmniRoute's
own `/v1` self-loop (non-standard provider model), the sub-request sends
`x-omniroute-admission-bypass: internal` and is authenticated with the resolved
@@ -355,9 +371,16 @@ Environment variables read by the built-in guardrails:
| `PII_RESPONSE_SANITIZATION` / `_MODE` | `pii-masker` (downstream) | Controls response-side masker behavior. |
The Vision Bridge reads runtime config from the DB-backed settings store
(`getSettings()`), not env vars: `visionBridgeEnabled`, `visionBridgeModel`,
`visionBridgePrompt`, `visionBridgeTimeout`, `visionBridgeMaxImages`. Defaults
live in `src/shared/constants/visionBridgeDefaults.ts`.
(`getSettings()`), not env vars. The primary keys are
`modalityBridgeVisionEnabled`, `modalityBridgeVisionMode`,
`modalityBridgeVisionModel`, `modalityBridgeVisionTaskAware`,
`modalityBridgeVisionPrompt`, `modalityBridgeVisionTimeout`,
`modalityBridgeVisionMaxImages`, `modalityBridgeCacheEnabled`,
`modalityBridgeCacheTtlMinutes`, and `modalityBridgeCacheMaxEntries`. The legacy
`visionBridge*` keys are accepted only as the documented one-cycle read
fallback; dashboard writes use the primary keys. Defaults and the fallback
resolver live in `src/shared/constants/modalityBridgeDefaults.ts`, with legacy
constants retained in `src/shared/constants/visionBridgeDefaults.ts`.
## Custom Guardrails

View File

@@ -247,6 +247,17 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record<string, AudioProvider> = {
format: "speechmatics",
models: [{ id: "enhanced", name: "Enhanced" }],
},
nanogpt: {
id: "nanogpt",
baseUrl: "https://nano-gpt.com/api/v1/audio/transcriptions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "whisper-1", name: "Whisper 1" },
{ id: "gpt-4o-transcription", name: "GPT-4o Transcription" },
],
},
};
/**
@@ -570,6 +581,17 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
{ id: "mimo-v2.5-tts-voiceclone", name: "MiMo V2.5 Voice Clone" },
],
},
nanogpt: {
id: "nanogpt",
baseUrl: "https://nano-gpt.com/api/v1/audio/speech",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "tts-1-hd", name: "TTS 1 HD" },
{ id: "tts-1", name: "TTS 1" },
],
},
};
/**

View File

@@ -1,4 +1,5 @@
import { getUpstreamTimeoutConfig } from "@/shared/utils/runtimeTimeouts";
import { resolvePublicCred } from "../utils/publicCreds.ts";
import type { LegacyProvider } from "./providerRegistry.ts";
import { loadProviderCredentials } from "./credentialLoader.ts";
import { generateLegacyProviders } from "./providerRegistry.ts";
@@ -18,6 +19,15 @@ export const FETCH_TIMEOUT_MS = upstreamTimeouts.fetchTimeoutMs;
// idle for this duration. Override with STREAM_IDLE_TIMEOUT_MS env var.
export const STREAM_IDLE_TIMEOUT_MS = upstreamTimeouts.streamIdleTimeoutMs;
// Grace period (ms) a client-disconnect finalization waits for the stream's own
// completion bookkeeping to land before persisting a 499. See #9653 — a client
// that closes right after reading a fully-completed SSE stream can otherwise
// race OmniRoute's own completion callback, resulting in a false 499 with zero
// token usage for a request that actually delivered its full response. Set
// STREAM_DISCONNECT_GRACE_PERIOD_MS=0 to disable and restore the old
// immediate-fail behavior.
export const STREAM_DISCONNECT_GRACE_PERIOD_MS = upstreamTimeouts.streamDisconnectGracePeriodMs;
// Timeout for the first non-ping SSE event. Inherits REQUEST_TIMEOUT_MS when
// set, unless STREAM_READINESS_TIMEOUT_MS is specified directly. This must stay
// conservative for large prompts and slow first-byte reasoning providers.
@@ -127,7 +137,7 @@ export const OAUTH_ENDPOINTS = {
openference: {
token: "https://openference.com/oauth/token",
auth: "https://openference.com/app/oauth/authorize",
clientId: "omniroute",
clientId: resolvePublicCred("openference_id"),
},
};

View File

@@ -409,6 +409,25 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
},
],
},
nanogpt: {
id: "nanogpt",
baseUrl: "https://nano-gpt.com/v1/embeddings",
authType: "apikey",
authHeader: "bearer",
models: [
{
id: "text-embedding-3-small",
name: "Text Embedding 3 Small",
dimensions: 1536,
},
{
id: "text-embedding-3-large",
name: "Text Embedding 3 Large",
dimensions: 3072,
},
],
},
};
const EMBEDDING_PROVIDER_ALIASES: Record<string, string> = {

View File

@@ -8,6 +8,7 @@ export const nanogptProvider: RegistryEntry = {
executor: "default",
baseUrl: "https://nano-gpt.com/api/v1/chat/completions",
modelsUrl: "https://nano-gpt.com/api/v1/models",
responsesBaseUrl: "https://nano-gpt.com/api/v1/responses",
authType: "apikey",
authHeader: "bearer",
models: CHAT_OPENAI_COMPAT_MODELS.nanogpt,

View File

@@ -1,4 +1,4 @@
import type { RegistryEntry } from "../../shared.ts";
import { resolvePublicCred, type RegistryEntry } from "../../shared.ts";
/**
* Openference — OpenAI-compatible AI inference gateway (https://openference.com/).
@@ -18,7 +18,7 @@ export const openferenceProvider: RegistryEntry = {
authHeader: "bearer",
passthroughModels: true,
oauth: {
clientIdDefault: "omniroute",
clientIdDefault: resolvePublicCred("openference_id"),
tokenUrl: "https://openference.com/oauth/token",
},
models: [{ id: "GLM-5.2", name: "GLM 5.2", contextLength: 850000 }],

View File

@@ -339,6 +339,15 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
format: "adobe-firefly-video",
models: toRegistryVideoModels(),
},
nanogpt: {
id: "nanogpt",
baseUrl: "https://nano-gpt.com/api/v1/video/generations",
authType: "apikey",
authHeader: "bearer",
format: "openai",
models: [{ id: "default", name: "NanoGPT Video" }],
},
};
/**

View File

@@ -2822,8 +2822,7 @@ export class ChatGptWebExecutor extends BaseExecutor {
const modelSlug = resolvedModel.slug;
const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(
(body || {}) as Record<string, unknown>,
messages as Array<{ role: string; content: unknown }>,
{ hardened: isThinkingCapableModel(model, modelSlug) }
messages as Array<{ role: string; content: unknown }>
);
if (!credentials.apiKey) {

View File

@@ -142,6 +142,8 @@ import {
getExplicitModelOutputCap,
resolveInputTokenCapForGate,
} from "@/lib/modelCapabilities.ts";
import { checkRequestCapabilityFit, deriveRequestCapabilityRequirements, buildCapabilityMismatchMessage } from "@/shared/constants/capabilities/capabilityFilter.ts";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts";
import { toPositiveInteger } from "../services/reasoningTokenBuffer.ts";
import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts";
import {
@@ -171,6 +173,7 @@ import {
ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE,
STREAM_RECOVERY,
DEFAULT_MAX_TOKENS,
STREAM_DISCONNECT_GRACE_PERIOD_MS,
} from "../config/constants.ts";
import { createRecoverableStream, makeContinuationBody } from "../services/streamRecovery.ts";
import {
@@ -2637,7 +2640,16 @@ export async function handleChatCore({
}
}
// === /Quota Share enforcement PRE-hook ===
if (isFeatureFlagEnabled("CAPABILITY_FILTER_ENABLED")) {
const fit = checkRequestCapabilityFit(getResolvedModelCapabilities({ provider, model: effectiveModel }),
deriveRequestCapabilityRequirements(body as Record<string, unknown>), provider);
if (!fit.compatible) {
const msg = buildCapabilityMismatchMessage(fit.terminalReason!, provider, effectiveModel);
log?.warn?.("CAPABILITY", msg);
trackPendingRequest(model, provider, connectionId, false);
return createErrorResult(400, msg, null, fit.terminalReason, "invalid_request_error");
}
}
// Get executor for this provider (with optional upstream proxy routing)
const executor = await resolveExecutorWithProxy(provider);
const getExecutionCredentials = () =>
@@ -4917,13 +4929,20 @@ export async function handleChatCore({
});
const handleStreamFailure = streamFailureFinalizers.handleStreamFailure;
onPipelineStreamError = streamFailureFinalizers.onPipelineStreamError;
onClientDisconnectFinalize = (event) =>
handleStreamFailure({
status: 499,
message: `Client disconnected: ${event.reason}`,
code: "client_disconnected",
type: "client_disconnected",
});
// #9653: gives a genuine, race-delayed completion a chance to land (see
// createClientDisconnectGraceHandler's doc comment) before persisting a false
// 499/0-tokens for a request that actually delivered its full response.
onClientDisconnectFinalize = streamFailure.createClientDisconnectGraceHandler({
isStreamCompletionRecorded: () => streamCompletionRecorded,
gracePeriodMs: STREAM_DISCONNECT_GRACE_PERIOD_MS,
finalize: (event) =>
handleStreamFailure({
status: 499,
message: `Client disconnected: ${event.reason}`,
code: "client_disconnected",
type: "client_disconnected",
}),
});
// For providers using Responses API format, translate stream back to openai (Chat Completions) format
// UNLESS client is Droid CLI which expects openai-responses format back

View File

@@ -1524,7 +1524,7 @@ async function handleFalAIImageGeneration({
}
const payload = await response.json();
const images = await normalizeProviderImagePayload(payload, body, log);
const images = await normalizeProviderImagePayload(payload, body, log, "b64_json");
return saveImageSuccessResult({
provider,
model,
@@ -1714,7 +1714,7 @@ async function handleStabilityAIImageGeneration({
payload = { image: buffer.toString("base64") };
}
const images = await normalizeProviderImagePayload(payload, body, log);
const images = await normalizeProviderImagePayload(payload, body, log, "b64_json");
return saveImageSuccessResult({
provider,
model,
@@ -1833,7 +1833,7 @@ async function handleBlackForestLabsImageGeneration({
})
: initialPayload;
const images = await normalizeProviderImagePayload(finalPayload, body, log);
const images = await normalizeProviderImagePayload(finalPayload, body, log, "url");
return saveImageSuccessResult({
provider,
model,
@@ -1908,7 +1908,7 @@ async function handleRecraftImageGeneration({
}
const payload = await response.json();
const images = await normalizeProviderImagePayload(payload, body, log);
const images = await normalizeProviderImagePayload(payload, body, log, "url");
return saveImageSuccessResult({
provider,
model,
@@ -2200,7 +2200,7 @@ function shouldIncludeStabilityMask(model) {
]).has(model);
}
async function normalizeProviderImagePayload(payload, body, log) {
async function normalizeProviderImagePayload(payload, body, log, defaultFormat) {
const candidates = [];
const pushCandidate = (value) => {
@@ -2226,7 +2226,7 @@ async function normalizeProviderImagePayload(payload, body, log) {
const normalized = [];
for (const candidate of candidates) {
const item = await normalizeProviderImageCandidate(candidate, body);
const item = await normalizeProviderImageCandidate(candidate, body, defaultFormat);
if (item) normalized.push(item);
}
@@ -2240,8 +2240,8 @@ async function normalizeProviderImagePayload(payload, body, log) {
return normalized;
}
async function normalizeProviderImageCandidate(candidate, body) {
const wantsBase64 = body?.response_format === "b64_json";
async function normalizeProviderImageCandidate(candidate, body, defaultFormat) {
const wantsBase64 = body?.response_format === "b64_json" || defaultFormat === "b64_json";
let url = null;
let b64 = null;

View File

@@ -15,14 +15,15 @@ import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGenerat
import {
AdobeFireflyError,
adobeFireflyGenerateImage,
adobeFireflyImageTimeoutMs,
resolveAdobeAccessToken,
resolveAdobeSourceImageReferences,
resolveAdobeSourceImageIds,
resolveAdobeImageModel,
} from "../../../services/adobeFireflyClient.ts";
import { getAdobeReferenceUploadLimit } from "../../../services/adobeFireflyModels.ts";
import { isAdobeFireflyUpscaleModel } from "../../../services/adobeFireflyUpscale.ts";
import { handleAdobeFireflyImageUpscale } from "../../imageUpscale/adobeFirefly.ts";
import { ensureAdobeFireflySession } from "../../../services/adobeFireflySession.ts";
function normalizePositiveNumber(value: unknown, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export async function handleAdobeFireflyImageGeneration({
model,
@@ -50,25 +51,22 @@ export async function handleAdobeFireflyImageGeneration({
images?: unknown;
[key: string]: unknown;
};
credentials: { apiKey?: string; accessToken?: string };
credentials: {
apiKey?: string;
accessToken?: string;
connectionId?: string;
providerSpecificData?: {
cookie?: unknown;
access_token?: unknown;
accessToken?: unknown;
browserSessionKey?: unknown;
} | null;
};
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
fetchImpl?: typeof fetch;
}) {
const startTime = Date.now();
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
// Topaz upscalers share adobe-firefly but use /v2/3p-images/upsample (no prompt).
if (isAdobeFireflyUpscaleModel(model)) {
return handleAdobeFireflyImageUpscale({
model,
provider,
body: body as Record<string, unknown>,
credentials,
log,
fetchImpl,
});
}
if (!prompt) {
return saveImageErrorResult({
provider,
@@ -80,7 +78,17 @@ export async function handleAdobeFireflyImageGeneration({
}
try {
const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl);
// Durable session: JWT + Cookie once → auto-rebuild ARP from forter/arkose,
// cache, optional Playwright warm-up. Submit path rotates ARP on 408.
const session = await ensureAdobeFireflySession({
credentials,
fetchImpl,
log,
});
const accessToken = session.accessToken;
const sessionCookie = session.cookie || undefined;
const arpSessionId = session.arpSessionId;
const timeoutMs = normalizePositiveNumber(body.timeout_ms, 180_000);
const seed =
typeof body.seed === "number"
? body.seed
@@ -88,44 +96,26 @@ export async function handleAdobeFireflyImageGeneration({
? Number(body.seed)
: undefined;
// Keep the raw credential blob for Cookie + sherlockToken (x-arp-session-id).
// JWT may be embedded in the same paste as cookies (HAR / multi-line).
const psd = (credentials as { providerSpecificData?: { cookie?: string } })
?.providerSpecificData;
const sessionCookie =
(typeof psd?.cookie === "string" && psd.cookie.trim()) ||
(typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) ||
(typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";")
? credentials.accessToken
: undefined);
// Cap uploads by model family (matches MediaViewModel GetSourceImageLimit).
const { id: resolvedId } = resolveAdobeImageModel(model);
const maxRefs = resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") ? 4 : 2;
const { spec } = resolveAdobeImageModel(model);
const references = await resolveAdobeSourceImageReferences({
const sourceImageIds = await resolveAdobeSourceImageIds({
accessToken,
body,
max: getAdobeReferenceUploadLimit(spec, "image"),
max: maxRefs,
sessionCookie,
arpSessionId,
prompt,
fetchImpl,
log,
});
const explicitTimeout =
typeof body.timeout_ms === "number"
? body.timeout_ms
: typeof body.timeout_ms === "string" && body.timeout_ms.trim()
? Number(body.timeout_ms)
: undefined;
const timeoutMs = adobeFireflyImageTimeoutMs({
timeoutMs: explicitTimeout,
refCount: references.length,
});
log?.info?.(
"IMAGE",
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
(references.length ? ` | refs: ${references.length}` : "") +
` | pollTimeoutMs=${timeoutMs}`
(sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") +
` | session=${session.source}`
);
const result = await adobeFireflyGenerateImage({
@@ -137,8 +127,11 @@ export async function handleAdobeFireflyImageGeneration({
quality: body.quality,
seed: Number.isFinite(seed as number) ? (seed as number) : undefined,
negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined,
references: references.length ? references : undefined,
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
sessionCookie,
arpSessionId,
sessionFingerprint: session.fingerprint,
sessionBrowserKey: session.browserSessionKey,
timeoutMs,
fetchImpl,
log,

View File

@@ -40,7 +40,12 @@ export async function handleResponsesCore({
const customToolNames = collectResponsesCustomToolNames(body?.tools, inputItems);
// Convert Responses API format to Chat Completions format
const convertedBody = convertResponsesApiFormat(body, credentials, modelInfo?.provider);
const convertedBody = convertResponsesApiFormat(
body,
credentials,
modelInfo?.provider,
modelInfo?.model
);
// Ensure stream is enabled
convertedBody.stream = true;

View File

@@ -9,11 +9,10 @@ import { sanitizeErrorMessage } from "../../utils/error.ts";
import {
AdobeFireflyError,
adobeFireflyGenerateVideo,
resolveAdobeAccessToken,
resolveAdobeSourceImageReferences,
resolveAdobeSourceImageIds,
resolveAdobeVideoModel,
} from "../../services/adobeFireflyClient.ts";
import { getAdobeReferenceUploadLimit } from "../../services/adobeFireflyModels.ts";
import { ensureAdobeFireflySession } from "../../services/adobeFireflySession.ts";
function normalizePositiveNumber(value: unknown, fallback: number): number {
const n = Number(value);
@@ -32,7 +31,17 @@ export async function handleAdobeFireflyVideoGeneration({
provider: string;
providerConfig?: { baseUrl?: string };
body: Record<string, unknown>;
credentials?: { apiKey?: string; accessToken?: string } | null;
credentials?: {
apiKey?: string;
accessToken?: string;
connectionId?: string;
providerSpecificData?: {
cookie?: unknown;
access_token?: unknown;
accessToken?: unknown;
browserSessionKey?: unknown;
} | null;
} | null;
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
fetchImpl?: typeof fetch;
}) {
@@ -47,7 +56,14 @@ export async function handleAdobeFireflyVideoGeneration({
}
try {
const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl);
const session = await ensureAdobeFireflySession({
credentials,
fetchImpl,
log,
});
const accessToken = session.accessToken;
const sessionCookie = session.cookie || undefined;
const arpSessionId = session.arpSessionId;
const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300_000);
const seed =
typeof body.seed === "number"
@@ -55,22 +71,16 @@ export async function handleAdobeFireflyVideoGeneration({
: typeof body.seed === "string" && String(body.seed).trim()
? Number(body.seed)
: undefined;
// Keep raw paste for Cookie + sherlockToken (x-arp-session-id).
const psd = (credentials as { providerSpecificData?: { cookie?: string } })
?.providerSpecificData;
const sessionCookie =
(typeof psd?.cookie === "string" && psd.cookie.trim()) ||
(typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) ||
(typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";")
? credentials.accessToken
: undefined);
const { spec } = resolveAdobeVideoModel(String(model));
const references = await resolveAdobeSourceImageReferences({
// Kling i2v / Veo ref / Sora frame: upload reference images first.
const { id: videoModelId } = resolveAdobeVideoModel(String(model));
const maxFrames = videoModelId.includes("kling") || videoModelId.includes("sora") ? 2 : 3;
const sourceImageIds = await resolveAdobeSourceImageIds({
accessToken,
body,
max: getAdobeReferenceUploadLimit(spec, "image"),
max: maxFrames,
sessionCookie,
arpSessionId,
prompt,
fetchImpl,
log,
@@ -79,7 +89,8 @@ export async function handleAdobeFireflyVideoGeneration({
log?.info?.(
"VIDEO",
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
(references.length ? ` | refs: ${references.length}` : "")
(sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "") +
` | session=${session.source}`
);
const result = await adobeFireflyGenerateVideo({
@@ -99,8 +110,11 @@ export async function handleAdobeFireflyVideoGeneration({
? body.negativePrompt
: undefined,
generateAudio: body.generate_audio !== false && body.generateAudio !== false,
references: references.length ? references : undefined,
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
sessionCookie,
arpSessionId,
sessionFingerprint: session.fingerprint,
sessionBrowserKey: session.browserSessionKey,
timeoutMs,
fetchImpl,
log,

View File

@@ -254,7 +254,7 @@ export async function handleVideoJobGeneration({
body: JSON.stringify(bodyForPreset),
log,
});
if (!submitResult.ok) {
if (submitResult.ok === false) {
return { success: false, status: submitResult.status, error: submitResult.error };
}
@@ -279,7 +279,7 @@ export async function handleVideoJobGeneration({
headers: buildJobHeaders(preset, credentials),
log,
});
if (!pollResult.ok) {
if (pollResult.ok === false) {
return { success: false, status: pollResult.status, error: pollResult.error };
}

View File

@@ -54,21 +54,8 @@ export function buildAccountSemaphoreKey({
return `${String(provider)}:${String(accountKey)}`;
}
/**
* Effective positive cap, or null when the semaphore is bypassed (unset/<=0).
*
* Narrowing companion of {@link isBypassed}: that one returns a plain boolean, so
* TypeScript cannot narrow `number | null` to `number` in its else-branch (a
* `x is null | undefined` predicate would be unsound — 0 bypasses too). Callers
* that need the VALUE after the guard go through here instead of casting.
*/
function resolveActiveCap(maxConcurrency?: number | null): number | null {
if (maxConcurrency == null || maxConcurrency <= 0) return null;
return maxConcurrency;
}
function isBypassed(maxConcurrency?: number | null): boolean {
return resolveActiveCap(maxConcurrency) === null;
return maxConcurrency == null || maxConcurrency <= 0;
}
function createNoopReleaseFn(): () => void {
@@ -205,8 +192,7 @@ export function acquire(
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
}: AcquireAccountSemaphoreOptions = {}
): Promise<() => void> {
const activeCap = resolveActiveCap(maxConcurrency);
if (activeCap === null) {
if (isBypassed(maxConcurrency)) {
return Promise.resolve(createNoopReleaseFn());
}
@@ -214,7 +200,9 @@ export function acquire(
return Promise.reject(makeAbortError(signal));
}
const gate = ensureGate(semaphoreKey, activeCap);
// isBypassed() above already excluded null/<=0 — ensureGate requires a plain
// number, but a boolean-returning helper isn't a type predicate TS can narrow on.
const gate = ensureGate(semaphoreKey, maxConcurrency as number);
clearCleanupTimer(gate);
if (gate.running < gate.maxConcurrency && !isBlocked(gate)) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
import { AdobeFireflyError } from "./adobeFireflyClient.ts";
import type { AdobeFireflyVideoModelSpec } from "./adobeFireflyClient.ts";
export interface AdobeSourceImageReference {
source: string;
usage?: string;
order?: number;
}
export function normalizeAdobeReferenceBlobs(
modelSpec: AdobeFireflyVideoModelSpec,
references: unknown
): Array<{ id: string; usage: string; order?: number }> {
if (!Array.isArray(references)) return [];
const maxReferences = modelSpec.referenceMode === "image" ? 3 : 2;
if (references.length > maxReferences) {
throw new AdobeFireflyError(
`Adobe Firefly model accepts at most ${maxReferences} ${
modelSpec.referenceMode === "image" ? "asset" : "frame"
} image references`,
400,
"bad_image"
);
}
return references.map((reference, index) => {
if (!reference || typeof reference !== "object") {
throw new AdobeFireflyError("Invalid Adobe Firefly reference image", 400, "bad_image");
}
const value = reference as Record<string, unknown>;
const id = typeof value.id === "string" ? value.id.trim() : "";
if (!id) {
throw new AdobeFireflyError("Adobe Firefly reference image id is required", 400, "bad_image");
}
const expectedUsage = modelSpec.referenceMode === "image" ? "asset" : "frame";
const usage = typeof value.usage === "string" ? value.usage.trim() : expectedUsage;
if (usage !== expectedUsage) {
throw new AdobeFireflyError(
`Adobe Firefly model does not support image references with usage '${usage}'`,
400,
"bad_image"
);
}
return expectedUsage === "frame" ? { id, usage, order: index + 1 } : { id, usage };
});
}
export function extractAdobeSourceImageReferences(
body: unknown,
max = 4
): AdobeSourceImageReference[] {
if (!body || typeof body !== "object") return [];
const inputs = (body as Record<string, unknown>).adobe_reference_inputs;
if (!Array.isArray(inputs)) return [];
const references: AdobeSourceImageReference[] = [];
for (const input of inputs) {
if (!input || typeof input !== "object") continue;
const value = input as Record<string, unknown>;
if (
value.type !== undefined &&
value.type !== "input_image" &&
value.type !== "image" &&
value.type !== "image_url"
) {
continue;
}
const imageUrl = value.image_url;
const source =
typeof value.source === "string"
? value.source.trim()
: typeof imageUrl === "string"
? imageUrl.trim()
: imageUrl &&
typeof imageUrl === "object" &&
typeof (imageUrl as Record<string, unknown>).url === "string"
? String((imageUrl as Record<string, unknown>).url).trim()
: typeof value.url === "string"
? value.url.trim()
: "";
if (!source || (!source.startsWith("data:image/") && !/^https?:\/\//i.test(source))) continue;
const usage =
typeof value.usage === "string" && value.usage.trim() ? value.usage.trim() : undefined;
const order =
typeof value.order === "number" && Number.isInteger(value.order) && value.order > 0
? value.order
: undefined;
references.push({ source, ...(usage ? { usage } : {}), ...(order ? { order } : {}) });
if (references.length >= max) break;
}
return references;
}

View File

@@ -0,0 +1,54 @@
const ADOBE_JWT_IN_TEXT_REGEX =
/eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/;
const ADOBE_JWT_IN_TEXT_GLOBAL_REGEX =
/eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/g;
const ADOBE_JWT_EXACT_REGEX =
/^eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}$/;
const FIREFLY_3P_HOST_SUFFIX = "firefly-3p.ff.adobe.io";
export function decodeAdobeJwtPayload(token: string): Record<string, unknown> | null {
try {
let raw = String(token || "")
.trim()
.replace(/^bearer\s+/i, "")
.trim();
const match = raw.match(ADOBE_JWT_IN_TEXT_REGEX);
if (match) raw = match[0];
const part = raw.split(".")[1];
if (!part) return null;
const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
const value: unknown = JSON.parse(json);
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
} catch {
return null;
}
}
export function findAllAdobeJwts(value: string): string[] {
return value.match(ADOBE_JWT_IN_TEXT_GLOBAL_REGEX) ?? [];
}
export function isExactAdobeJwt(value: string): boolean {
return ADOBE_JWT_EXACT_REGEX.test(value);
}
export function stripAdobeJwts(value: string, replacement = ""): string {
return value.replace(ADOBE_JWT_IN_TEXT_GLOBAL_REGEX, replacement);
}
function hostnameMatches(hostname: string, expected: string): boolean {
const normalized = hostname.toLowerCase().replace(/\.$/, "");
return normalized === expected || normalized.endsWith(`.${expected}`);
}
export function isAdobeFireflyApiUrl(rawUrl: string): boolean {
try {
return hostnameMatches(new URL(rawUrl).hostname, FIREFLY_3P_HOST_SUFFIX);
} catch {
return false;
}
}
export function isAdobeLoginCookieDomain(domain: string): boolean {
return hostnameMatches(domain.replace(/^\./, ""), "adobelogin.com");
}

File diff suppressed because it is too large Load Diff

View File

@@ -156,9 +156,10 @@ export function resolveAdobeCreativityLevel(opts: {
return clampLevel(normalizeExplicitCreativity(Number(explicit)));
}
const percent = typeof opts.creativityPercent === "number" && Number.isFinite(opts.creativityPercent)
? Math.max(0, Math.min(100, opts.creativityPercent))
: 0;
const percent =
typeof opts.creativityPercent === "number" && Number.isFinite(opts.creativityPercent)
? Math.max(0, Math.min(100, opts.creativityPercent))
: 0;
return clampLevel(percent / 100);
}
@@ -265,11 +266,7 @@ export async function adobeFireflyUpscaleImage(opts: {
const blobId = String(opts.blobId || "").trim();
if (!blobId) {
throw new AdobeFireflyError(
"Adobe Firefly upscale requires a source image",
400,
"bad_image"
);
throw new AdobeFireflyError("Adobe Firefly upscale requires a source image", 400, "bad_image");
}
const factor = normalizeFactor(opts.upsamplerFactor, spec.factors);

View File

@@ -39,7 +39,7 @@ export function preferAntigravityConnectionsWithStoredProject<T extends Record<s
): T[] {
if (!Array.isArray(connections) || connections.length === 0) return connections;
const hasStoredProject = (connection: T): boolean => {
if (typeof connection.projectId === "string" && connection.projectId) return true;
if (typeof connection.projectId === "string" && connection.projectId.trim()) return true;
let psd = connection.providerSpecificData;
if (typeof psd === "string") {
try {
@@ -48,12 +48,9 @@ export function preferAntigravityConnectionsWithStoredProject<T extends Record<s
return false;
}
}
return Boolean(
psd &&
typeof psd === "object" &&
typeof (psd as Record<string, unknown>).projectId === "string" &&
(psd as Record<string, unknown>).projectId
);
if (!psd || typeof psd !== "object") return false;
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;

View File

@@ -1,13 +1,39 @@
/**
* Re-export from `antigravityProjectPersist.ts` plus a connection-preference helper.
*
* The persistence layer for a runtime-discovered Antigravity projectId lives in
* the sibling file `antigravityProjectPersist.ts` (named by its core function).
* This module adds `preferAntigravityConnectionsWithStoredProject()`, used by the
* quota-strategy engine to give priority to connections whose projectId has
* already been discovered and persisted.
*/
import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts";
export { persistDiscoveredAntigravityProjectId };
export function preferAntigravityConnectionsWithStoredProject(
connections: Array<Record<string, unknown>>
): Array<Record<string, unknown>> {
return connections.filter(
(conn) => conn != null && typeof conn.projectId === "string" && conn.projectId.trim().length > 0
);
/**
* Prefer Antigravity connections with a discovered/stored `projectId` for
* reset-aware quota routing.
*
* This is a preference, not a hard requirement: when no candidate has a stored
* projectId, retain the full pool rather than making freshly-added accounts unusable.
*/
function hasStoredProjectId(connection: Record<string, unknown>): boolean {
if (typeof connection.projectId === "string" && connection.projectId.trim().length > 0) {
return true;
}
const providerSpecificData = connection.providerSpecificData;
if (providerSpecificData && typeof providerSpecificData === "object") {
const nested = (providerSpecificData as Record<string, unknown>).projectId;
if (typeof nested === "string" && nested.trim().length > 0) return true;
}
return false;
}
export function preferAntigravityConnectionsWithStoredProject<T extends Record<string, unknown>>(
connections: T[]
): T[] {
const withStoredProject = connections.filter(hasStoredProjectId);
return withStoredProject.length > 0 ? withStoredProject : connections;
}

View File

@@ -138,9 +138,7 @@ function normalizeRuntimeStep(
: {}),
weight,
label,
// `prompt` is a per-step pipeline input and only exists on a model step —
// #8894 widened the union with ComboProviderWildcardStep, which has no prompt.
prompt: (step.kind === "model" ? step.prompt : null) || null,
prompt: step.kind === "model" ? step.prompt || null : null,
} satisfies ResolvedComboTarget;
}
@@ -485,13 +483,6 @@ function estimateRequestInputTokens(body: Record<string, unknown>): number {
}
function valueContainsImagePart(value: unknown): boolean {
// Delegates to the unified media detector (open-sse/utils/mediaParts.ts) —
// single source of truth shared with the vision-bridge guardrail. The
// detector keeps this filter's legacy permissive matches (image-ish `type`
// in any casing, bare `image_url`/`input_image` keys, source.media_type
// image/*, bare data:image strings, recursion capped at depth 8) via
// "image_indicator" parts. containsMediaKind short-circuits on the first
// hit — this runs on every request, so no full-part collection here.
return containsMediaKind([{ content: [value] }], "image");
}
@@ -615,6 +606,10 @@ export type CompatFilterOptions = {
failOpen?: boolean;
};
export function hasHardCapabilityFailure(reasons: string[]): boolean {
return reasons.some((reason) => HARD_COMPAT_REASONS.has(reason));
}
/**
* Summarize a capability-filter exhaustion for a 400-class combo error (#8488).
* Returns null when the empty pool is not attributable to hard requirements.
@@ -718,9 +713,7 @@ export function filterTargetsByRequestCompatibility(
if (compatible.length === targets.length) return targets;
if (compatible.length === 0) {
const hardRejected = rejected.some((entry) =>
entry.reasons.some((r) => HARD_COMPAT_REASONS.has(r))
);
const hardRejected = rejected.some((entry) => hasHardCapabilityFailure(entry.reasons));
const failOpen = options?.failOpen === true;
log.debug?.(

View File

@@ -51,9 +51,9 @@ export function extractFusionPanelSpec(
panel.push(step.comboName);
return;
}
// #8894 widened ComboStep with ComboProviderWildcardStep, which carries a
// modelPattern instead of a model. getComboModelString() already resolves any
// step shape (and returns null for the ones with no concrete model id).
// Provider-wildcard steps have no concrete model to dispatch — fusion is a
// fixed-size panel of literal models/combo-refs, not a wildcard-expanding
// strategy (see file header). Skip rather than push an undefined model.
const modelStr = getComboModelString(step);
if (modelStr) panel.push(modelStr);
});

View File

@@ -89,9 +89,7 @@ async function getQuotaAwareConnectionsForTarget(
? (connections as Array<Record<string, unknown>>)
: [];
if (provider === "antigravity" || provider === "agy") {
activeConnections = preferAntigravityConnectionsWithStoredProject(
activeConnections
) as Array<Record<string, unknown>>;
activeConnections = preferAntigravityConnectionsWithStoredProject(activeConnections);
}
if (
!resetAwareConnectionCache.has(provider) &&

View File

@@ -221,6 +221,14 @@ const LITE_SCHEMA: EngineConfigField[] = [
label: "Preserve system prompt",
defaultValue: true,
},
{
key: "compressToolResults",
type: "boolean",
label: "Proactively truncate long tool results",
description:
"Truncates tool results over 2,000 characters during Lite compression. Emergency overflow protection may still trim content when the context exceeds the model budget.",
defaultValue: true,
},
];
function validateLiteConfig(config: Record<string, unknown>): EngineValidationResult {
@@ -231,6 +239,7 @@ function validateLiteConfig(config: Record<string, unknown>): EngineValidationRe
) {
errors.push("preserveSystemPrompt must be a boolean");
}
validateBoolean(config, "compressToolResults", errors);
return { valid: errors.length === 0, errors };
}
@@ -253,9 +262,17 @@ export const liteEngine: CompressionEngine = {
},
apply(body, options) {
const adapter = adaptBodyForCompression(body);
const stepCompressToolResults = options?.stepConfig?.compressToolResults;
const result = applyLiteCompression(adapter.body, {
...options,
preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false,
// buildStepOptions() already merges global config.lite with explicit step.config
// (step wins) into stepConfig, so consume that single effective value instead of
// AND-ing root and step values — an explicit step `true` must override a global `false`.
compressToolResults:
typeof stepCompressToolResults === "boolean"
? stepCompressToolResults
: (options?.config?.lite?.compressToolResults ?? true),
});
return adapter.adapted ? { ...result, body: adapter.restore(result.body) } : result;
},

View File

@@ -35,7 +35,6 @@
* - Only replace blocks ≥ minChars (default 600).
* - `stackable: true`, `stackPriority: 4` (runs just after session-dedup(3)).
*/
import crypto from "node:crypto";
import {
deleteAllCcrBlocks,
@@ -292,8 +291,10 @@ function rehydrateEntry(hash: string, principalId: string, now: number): CcrEntr
// Re-admit through the same budgets a fresh store would face. If the block no longer
// fits, it stays on disk and is served straight from the row instead of being cached.
const { principalId: owner, bytes } = entry;
if (enforcePrincipalBudget(owner, bytes) && enforceGlobalBudget(owner, bytes)) {
if (
enforcePrincipalBudget(entry.principalId, entry.bytes) &&
enforceGlobalBudget(entry.principalId, entry.bytes)
) {
const key = buildStoreKey(hash, principalId === ANON ? undefined : principalId);
ccrStore.set(key, entry);
ccrTotalBytes += entry.bytes;

View File

@@ -17,6 +17,7 @@ interface LiteCompressionOptions {
model?: string;
supportsVision?: boolean | null;
preserveSystemPrompt?: boolean;
compressToolResults?: boolean;
}
function trimTrailingHorizontalWhitespace(line: string): string {
@@ -253,9 +254,11 @@ export function applyLiteCompression(
current = r2.body;
if (r2.applied) techniquesApplied.push("system-dedup");
const r3 = compressToolResults(current);
current = r3.body;
if (r3.applied) techniquesApplied.push("tool-compress");
if (options?.compressToolResults !== false) {
const r3 = compressToolResults(current);
current = r3.body;
if (r3.applied) techniquesApplied.push("tool-compress");
}
const r4 = removeRedundantContent(current, options);
current = r4.body;

View File

@@ -14,6 +14,8 @@ export function resolveStepDetailConfig(
config: CompressionConfig | undefined
) {
switch (engine) {
case "lite":
return config?.lite ?? {};
case "headroom":
return config?.headroom ?? {};
case "session-dedup":

View File

@@ -349,6 +349,7 @@ function runCompression(
const result = applyLiteCompression(compressionBody, {
...options,
preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false,
...options?.config?.lite,
});
return adapter.adapted ? { ...result, body: adapter.restore(result.body) } : result;
}

View File

@@ -157,6 +157,12 @@ export interface LiveZoneConfig {
enabled: boolean;
}
/** Lite detail settings for proactive request-time transformations. */
export interface LiteConfig {
/** Truncate tool-result strings over 2,000 characters before provider dispatch. */
compressToolResults: boolean;
}
export interface CompressionPipelineStep {
engine: CompressionEngineId;
intensity?: CavemanIntensity | RtkIntensity;
@@ -218,6 +224,8 @@ export interface CompressionConfig {
languageConfig?: CompressionLanguageConfig;
aggressive?: AggressiveConfig;
ultra?: UltraConfig;
/** Lite proactive transformation detail settings. */
lite?: LiteConfig;
/** Headroom SmartCrusher detail settings (minRows gate). */
headroom?: HeadroomConfig;
/** Session Dedup detail settings (minBlockChars / fuzzy, #8388). */
@@ -395,6 +403,7 @@ export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = {
ultraEngine: "heuristic",
ultraSlmPrewarm: false,
liveZone: { enabled: false },
lite: { compressToolResults: true },
codexResponsesConfig: { ...DEFAULT_CODEX_RESPONSES_CONFIG },
};

View File

@@ -121,10 +121,6 @@ export function getFirecrawlBaseUrl(connection?: Record<string, unknown>): strin
export async function fetchFirecrawlQuota(
connectionId: string,
connection?: Record<string, unknown>
// FirecrawlQuota, not the base QuotaInfo: every return here is a full credit
// breakdown (remainingCredits / planCredits / extraCreditsInferred / overPlan),
// and the narrower annotation made the custom-base literal below an excess-
// property error. FirecrawlQuota extends QuotaInfo, so callers are unaffected.
): Promise<FirecrawlQuota | null> {
const cached = quotaCache.get(connectionId);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {

View File

@@ -64,6 +64,8 @@ const REASONING_REPLAY_MODEL_PATTERNS = [
];
const DEEPSEEK_V4_MODEL_PATTERN = /deepseek[-/]v4[-.](flash|pro)/i;
const K3_REASONING_REPLAY_MODEL_PATTERN = /(?:^|\/)(?:kimi-)?k3(?:$|-)/i;
const NATIVE_K27_REASONING_REPLAY_MODEL_PATTERN = /(?:^|\/)kimi-k2\.7-code(?:$|-)/i;
export function isDeepSeekReasoningModel(params: {
provider: string;
@@ -94,6 +96,14 @@ export function requiresReasoningReplay(params: {
if (normalizedInterleavedField === "reasoning_content") return true;
if (normalizedInterleavedField === "reasoning_details") return false;
if (K3_REASONING_REPLAY_MODEL_PATTERN.test(normalizedModel)) return true;
if (
(normalizedProvider === "moonshot" || normalizedProvider === "kimi") &&
NATIVE_K27_REASONING_REPLAY_MODEL_PATTERN.test(normalizedModel)
) {
return true;
}
// DeepSeek legacy reasoner family has an inverse contract: do not replay.
if (/deepseek-reasoner/i.test(normalizedModel) || /deepseek-r1/i.test(normalizedModel)) {
return false;

View File

@@ -84,6 +84,10 @@ export function hasValidContent(msg: ClaudeMessage): boolean {
return msg.content.some(
(block) =>
(block.type === "text" && block.text?.trim()) ||
(block.type === "thinking" && block.thinking?.trim()) ||
(block.type === "redacted_thinking" &&
typeof block.data === "string" &&
block.data.trim()) ||
block.type === "tool_use" ||
block.type === "tool_result" ||
// #7777: media-only user turns are real content — dropping them

View File

@@ -686,5 +686,38 @@ export function cleanJSONSchemaForAntigravity(schema: unknown): unknown {
addPlaceholders(cleaned);
// Phase 7: Recursive type:"object" injection for nested schemas (#9268).
// Gemini/Vertex requires every node with properties/required to have an explicit
// `type: "object"`. Some clients (e.g. Composio-exported tools) emit nested
// schemas with `properties` but no `type`, causing a Gemini 400. Follow the
// `removeUnsupportedKeywords()`/`addPlaceholders()` visitor pattern.
function injectObjectType(obj: unknown): void {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) {
for (const item of obj) {
injectObjectType(item);
}
return;
}
const record = obj as JsonRecord;
if (
!record.type &&
(record.properties !== undefined || record.required !== undefined)
) {
record.type = "object";
}
// Recurse into remaining values.
for (const value of Object.values(record)) {
if (value && typeof value === "object") {
injectObjectType(value);
}
}
}
injectObjectType(cleaned);
return cleaned;
}

View File

@@ -2,10 +2,16 @@
* Convert OpenAI Responses API format to standard chat completions format.
* Delegates to the canonical translator to avoid logic duplication.
*/
import { requiresReasoningReplay } from "../../services/reasoningCache.ts";
import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts";
import { toRecord } from "../request/openai-responses/helpers.ts";
export function convertResponsesApiFormat(body, credentials = null, provider = null) {
export function convertResponsesApiFormat(
body: Record<string, unknown>,
credentials: unknown = null,
provider: unknown = null,
model: unknown = null
): Record<string, unknown> {
const bodyModel = toRecord(body).model;
const requestedModel =
typeof bodyModel === "string" && bodyModel.trim().length > 0
@@ -13,5 +19,25 @@ export function convertResponsesApiFormat(body, credentials = null, provider = n
? bodyModel
: `${provider}/${bodyModel}`
: provider;
return openaiResponsesToOpenAIRequest(requestedModel, body, null, credentials);
const credentialRecord =
credentials && typeof credentials === "object" && !Array.isArray(credentials)
? (credentials as Record<string, unknown>)
: {};
const translationCredentials = requiresReasoningReplay({
provider: String(provider ?? ""),
model: String(model ?? ""),
allowLegacyFallback: false,
})
? { ...credentialRecord, _preserveReasoningContent: true }
: credentials;
const converted = openaiResponsesToOpenAIRequest(
requestedModel,
body,
null,
translationCredentials
);
if (!converted || typeof converted !== "object" || Array.isArray(converted)) {
throw new TypeError("Responses request conversion must produce an object");
}
return converted as Record<string, unknown>;
}

View File

@@ -13,7 +13,6 @@ import {
providerHonorsOpenAIFormatCacheControl,
resolveConnectionCacheOverride,
} from "../utils/cacheControlPolicy.ts";
import { requiresAuthenticReasoningContent } from "../utils/reasoningContentInjector.ts";
import { isInternalReasoningPlaceholder } from "../utils/reasoningPlaceholder.ts";
import {
coerceToolSchemas,
@@ -162,7 +161,11 @@ function isReasoningOnlyReplayTarget(provider: unknown, model: unknown): boolean
/(^|\/)deepseek/i.test(normalizedModel) ||
normalizedProvider === "xiaomi-mimo" ||
/(^|\/)mimo/i.test(normalizedModel) ||
requiresAuthenticReasoningContent(normalizedProvider, normalizedModel)
requiresReasoningReplay({
provider: normalizedProvider,
model: normalizedModel,
allowLegacyFallback: false,
})
);
}
@@ -233,6 +236,17 @@ export function translateRequest(
const connectionCacheOverride = resolveConnectionCacheOverride(
(credentials as { providerSpecificData?: unknown } | null)?.providerSpecificData
);
const normalizedProvider = String(provider ?? "");
const normalizedModel = String(model ?? "");
const isKimiCoding =
normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey";
const requiresExplicitReasoningReplay = requiresReasoningReplay({
provider: normalizedProvider,
model: normalizedModel,
allowLegacyFallback: false,
});
const preserveResponsesReasoning =
sourceFormat === FORMATS.OPENAI_RESPONSES && requiresExplicitReasoningReplay;
// Phase 2: Apply thinking budget control before normalization
result = applyThinkingBudget(result);
@@ -318,12 +332,16 @@ export function translateRequest(
options?.preserveCacheControl === true &&
providerHonorsOpenAIFormatCacheControl(provider, connectionCacheOverride);
const step1Credentials =
options?.copilotClient || hasTargetHint || preserveCacheControl
options?.copilotClient ||
hasTargetHint ||
preserveCacheControl ||
preserveResponsesReasoning
? {
...(credentials && typeof credentials === "object" ? credentials : {}),
...(options?.copilotClient ? { _copilotClient: true } : {}),
...(hasTargetHint ? { _targetFormat: targetFormat } : {}),
...(preserveCacheControl ? { _preserveCacheControl: true } : {}),
...(preserveResponsesReasoning ? { _preserveReasoningContent: true } : {}),
}
: credentials;
result = toOpenAI(model, result, stream, step1Credentials);
@@ -381,14 +399,6 @@ export function translateRequest(
// Resolve reasoning-replay status up-front: it gates both the reasoning_content
// strip in filterToOpenAIFormat below (#4849 must NOT strip client reasoning for
// replay providers) and the cache re-injection further down.
const normalizedProvider = String(provider ?? "");
const normalizedModel = String(model ?? "");
const isKimiCoding =
normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey";
const requiresAuthenticReasoning = requiresAuthenticReasoningContent(
normalizedProvider,
normalizedModel
);
const resolvedCapabilities = getResolvedModelCapabilities({
provider: normalizedProvider,
model: normalizedModel,
@@ -397,7 +407,10 @@ export function translateRequest(
provider: normalizedProvider,
model: normalizedModel,
thinkingEnabled: hasThinkingConfig(result),
supportsReasoning: supportsReasoning({ provider: normalizedProvider, model: normalizedModel }),
supportsReasoning: supportsReasoning({
provider: normalizedProvider,
model: normalizedModel,
}),
interleavedField: resolvedCapabilities?.interleavedField ?? null,
});
@@ -470,7 +483,7 @@ export function translateRequest(
if (
targetFormat === FORMATS.OPENAI &&
!requiresAuthenticReasoning &&
!requiresExplicitReasoningReplay &&
result.messages &&
Array.isArray(result.messages)
) {
@@ -495,7 +508,7 @@ export function translateRequest(
// isReasoner / normalizedProvider / normalizedModel / resolvedCapabilities were
// resolved up-front (before the OpenAI-format filter) so the #4849 reasoning strip
// could honor reasoning-replay providers.
if (isReasoner && !isKimiCoding && result.messages && Array.isArray(result.messages)) {
if (isReasoner && result.messages && Array.isArray(result.messages)) {
const canReplayReasoningOnly = isReasoningOnlyReplayTarget(normalizedProvider, normalizedModel);
for (const [messageIndex, msg] of result.messages.entries()) {
@@ -544,29 +557,51 @@ export function translateRequest(
// Has tool_use blocks but no thinking block yet.
// Reasoning models (Kimi K2, etc.) require a thinking block before tool_use
// on multi-turn or they regenerate the same tool call infinitely.
const hasThinkingBlock = msg.content.some(
const thinkingBlock = msg.content.find(
(b) => b?.type === "thinking" || b?.type === "redacted_thinking"
);
if (hasThinkingBlock) continue;
const hasNonEmptyClientThinking =
thinkingBlock?.type === "thinking" &&
typeof thinkingBlock.thinking === "string" &&
thinkingBlock.thinking.trim().length > 0;
if (thinkingBlock && (!isKimiCoding || hasNonEmptyClientThinking)) continue;
const toolUseBlocks = msg.content.filter((b) => b?.type === "tool_use");
const firstToolUseId = toolUseBlocks[0]?.id;
const firstToolUseIdx = msg.content.findIndex((b) => b?.type === "tool_use");
// Try reasoning cache first
// Client reasoning wins above. Otherwise try authentic replay before
// retaining Kimi Code's empty protocol marker as the final fallback.
if (firstToolUseId) {
const cached = lookupReasoning(firstToolUseId);
if (cached) {
msg.content.splice(firstToolUseIdx, 0, {
type: "thinking",
thinking: cached,
});
if (thinkingBlock) {
thinkingBlock.type = "thinking";
thinkingBlock.thinking = cached;
delete thinkingBlock.data;
delete thinkingBlock.signature;
} else {
msg.content.splice(firstToolUseIdx, 0, {
type: "thinking",
thinking: cached,
});
}
recordReplay();
continue;
}
}
if (requiresAuthenticReasoning) continue;
// Fallback: inject placeholder (must be non-empty for kimi-coding)
if (isKimiCoding) {
if (thinkingBlock) {
thinkingBlock.type = "thinking";
thinkingBlock.thinking = "";
delete thinkingBlock.data;
delete thinkingBlock.signature;
} else {
msg.content.splice(firstToolUseIdx, 0, { type: "thinking", thinking: "" });
}
continue;
}
if (requiresExplicitReasoningReplay) continue;
msg.content.splice(firstToolUseIdx, 0, {
type: "thinking",
thinking: NON_ANTHROPIC_THINKING_PLACEHOLDER,
@@ -603,7 +638,7 @@ export function translateRequest(
// Native Moonshot K3/K2.7 accepts only the real prior reasoning. If it
// was not supplied and the cache missed, leave it absent so upstream can
// enforce its contract instead of corrupting history with a placeholder.
if (requiresAuthenticReasoning) {
if (requiresExplicitReasoningReplay) {
if (msg.reasoning_content === "") delete msg.reasoning_content;
continue;
}
@@ -616,7 +651,7 @@ export function translateRequest(
// deepseek-v4-flash accepts an ABSENT reasoning_content field (the 400 is
// specific to empty-string, and even that is endpoint-dependent). Omit
// the field instead; providers that genuinely enforce the contract
// (kimi-coding, moonshot authentic-reasoning) have their own paths above.
// (kimi-coding, moonshot reasoning replay) have their own paths above.
if ((hasToolCalls || shouldReplayReasoningOnly) && !msg.reasoning_content) {
if (requiresReasoningContentPresence(normalizedProvider, normalizedModel)) {
msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER;
@@ -755,6 +790,7 @@ export function initState(sourceFormat) {
inThinking: false,
parseTextualReasoningTags: false,
funcArgsBuf: {},
funcArgsEscapeState: {},
funcNames: {},
funcCallIds: {},
funcArgsDone: {},

View File

@@ -73,6 +73,19 @@ function toolOutputContentToString(output: unknown): string {
return parts.join("\n");
}
function getReasoningSummaryText(item: JsonRecord): string {
if (!Array.isArray(item.summary)) return "";
return item.summary
.map((part) => toString(toRecord(part).text))
.filter((text) => text.length > 0)
.join("\n\n");
}
function appendReasoningContent(current: unknown, next: string): string {
const existing = typeof current === "string" ? current : "";
return existing ? `${existing}\n\n${next}` : next;
}
/**
* Convert OpenAI Responses API request to OpenAI Chat Completions format
*/
@@ -83,13 +96,13 @@ export function openaiResponsesToOpenAIRequest(
credentials: unknown
): unknown {
void stream;
void credentials;
const collapseToPlainString = requiresPlainStringContent(extractProviderHint(model));
const root = toRecord(body);
if (root.input === undefined) return body;
const credentialRecord = toRecord(credentials);
const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData);
const preserveReasoningContent = credentialRecord._preserveReasoningContent === true;
const rawInputItems = normalizeResponsesInputForChat(root.input);
// Tools may be declared at the Responses top level or in one or more
@@ -204,6 +217,7 @@ export function openaiResponsesToOpenAIRequest(
// Group items by conversation turn
let currentAssistantMsg: JsonRecord | null = null;
let pendingToolResults: JsonRecord[] = [];
let pendingReasoningContent = "";
// Upstream providers reject messages:[] with "400: at least one message is required".
// When the client sends input:[] (empty), inject a placeholder user message — mirrors
@@ -220,11 +234,20 @@ export function openaiResponsesToOpenAIRequest(
const itemType = toString(item.type) || (item.role ? "message" : "");
if (itemType === "message") {
const role = toString(item.role);
// Flush pending assistant message with tool calls
if (currentAssistantMsg) {
messages.push(currentAssistantMsg);
currentAssistantMsg = null;
}
if (role !== "assistant" && pendingReasoningContent) {
messages.push({
role: "assistant",
content: null,
reasoning_content: pendingReasoningContent,
});
pendingReasoningContent = "";
}
// Flush pending tool results
if (pendingToolResults.length > 0) {
@@ -269,7 +292,12 @@ export function openaiResponsesToOpenAIRequest(
})
: item.content;
messages.push({ role: toString(item.role), content });
const message: JsonRecord = { role, content };
if (role === "assistant" && pendingReasoningContent) {
message.reasoning_content = pendingReasoningContent;
pendingReasoningContent = "";
}
messages.push(message);
continue;
}
@@ -294,6 +322,10 @@ export function openaiResponsesToOpenAIRequest(
content: null,
tool_calls: [],
};
if (pendingReasoningContent) {
currentAssistantMsg.reasoning_content = pendingReasoningContent;
pendingReasoningContent = "";
}
}
const toolCalls = Array.isArray(currentAssistantMsg.tool_calls)
@@ -353,6 +385,10 @@ export function openaiResponsesToOpenAIRequest(
content: null,
tool_calls: [],
};
if (pendingReasoningContent) {
currentAssistantMsg.reasoning_content = pendingReasoningContent;
pendingReasoningContent = "";
}
}
const toolCalls = Array.isArray(currentAssistantMsg.tool_calls)
? currentAssistantMsg.tool_calls
@@ -401,7 +437,21 @@ export function openaiResponsesToOpenAIRequest(
}
if (itemType === "reasoning") {
// Skip reasoning items - they are display-only metadata
// Responses reasoning summaries are normally display metadata. Preserve them only
// when the routed upstream explicitly requires prior reasoning to continue a turn.
if (preserveReasoningContent) {
const reasoning = getReasoningSummaryText(item);
if (reasoning) {
if (currentAssistantMsg) {
currentAssistantMsg.reasoning_content = appendReasoningContent(
currentAssistantMsg.reasoning_content,
reasoning
);
} else {
pendingReasoningContent = appendReasoningContent(pendingReasoningContent, reasoning);
}
}
}
continue;
}
@@ -430,6 +480,13 @@ export function openaiResponsesToOpenAIRequest(
if (currentAssistantMsg) {
messages.push(currentAssistantMsg);
}
if (pendingReasoningContent) {
messages.push({
role: "assistant",
content: null,
reasoning_content: pendingReasoningContent,
});
}
if (pendingToolResults.length > 0) {
for (const toolResult of pendingToolResults) {
messages.push(toolResult);

View File

@@ -31,6 +31,19 @@ import {
// normalizeUpstreamFailure is re-exported for external importers (tests).
export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts";
/** Carries escapeJsonStringValues's scan state (whether we're inside a JSON
* string, and whether the fragment ended mid-escape-sequence) across calls
* for the SAME tool call — see escapeJsonStringValues's own doc comment for
* why this must persist across chunks rather than reset per call. */
interface JsonStringEscapeState {
inString: boolean;
pendingEscape: boolean;
}
function createJsonStringEscapeState(): JsonStringEscapeState {
return { inString: false, pendingEscape: false };
}
/**
* Escape control characters (newlines, tabs, carriage returns) that appear
* inside JSON string values, ensuring the resulting string is valid JSON.
@@ -38,18 +51,42 @@ export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts";
* newlines (0x0A) instead of \n escapes inside tool call argument JSON.
* Only escapes characters inside string contexts to avoid double-escaping
* already-proper JSON or corrupting structural newlines.
*
* `arguments` deltas arrive as arbitrary fragments of one continuous JSON
* string (OpenAI's Chat Completions streaming contract only guarantees each
* `tool_calls[].function.arguments` delta is the next slice, not that it
* starts/ends on a quote or escape boundary) — a large multi-line argument
* value routinely gets split mid-string. `escapeState` must therefore be the
* SAME object passed in on every call for a given tool call index, not a
* fresh `{inString: false}` each time: resetting per call made the
* in-string/out-of-string decision (and therefore whether a raw newline
* gets escaped) depend on where a chunk boundary happened to fall, which
* produced a real, reported bug — a single reassembled arguments string
* with a mix of real newlines and literal two-character `\n` sequences,
* breaking generated code (e.g. Python) that embeds multi-line content.
*/
function escapeJsonStringValues(json: string): string {
function escapeJsonStringValues(json: string, escapeState: JsonStringEscapeState): string {
let result = "";
let inString = false;
let { inString, pendingEscape } = escapeState;
for (let i = 0; i < json.length; i++) {
const ch = json[i];
// Inside a string, skip over escape sequences
// This char is the one immediately following a backslash from a
// previous iteration (possibly in a prior fragment) — it's already
// "consumed" by that escape sequence, pass it through untouched.
if (pendingEscape) {
result += ch;
pendingEscape = false;
continue;
}
// Inside a string, an unescaped backslash starts an escape sequence —
// the char AFTER it (next iteration, possibly in the next fragment)
// must not be reinterpreted as a quote/control-char in its own right.
if (inString && ch === "\\") {
result += ch + (json[i + 1] ?? "");
i++;
result += ch;
pendingEscape = true;
continue;
}
@@ -69,6 +106,8 @@ function escapeJsonStringValues(json: string): string {
result += ch;
}
escapeState.inString = inString;
escapeState.pendingEscape = pendingEscape;
return result;
}
@@ -482,6 +521,7 @@ function emitToolCall(state, emit, tc) {
delete state.funcArgsDone[tcIdx];
delete state.funcItemAdded[tcIdx];
delete state.funcItemDone[tcIdx];
delete state.funcArgsEscapeState?.[tcIdx];
}
if (funcName) state.funcNames[tcIdx] = funcName;
@@ -528,7 +568,14 @@ function emitToolCall(state, emit, tc) {
if (tc.function?.arguments) {
const refCallId = state.funcCallIds[tcIdx] || newCallId;
const existingArgs = state.funcArgsBuf[tcIdx] || "";
const sanitized = escapeJsonStringValues(tc.function.arguments);
if (!state.funcArgsEscapeState) state.funcArgsEscapeState = {};
if (!state.funcArgsEscapeState[tcIdx]) {
state.funcArgsEscapeState[tcIdx] = createJsonStringEscapeState();
}
const sanitized = escapeJsonStringValues(
tc.function.arguments,
state.funcArgsEscapeState[tcIdx]
);
const nextArgs = appendToolCallArgumentDelta(existingArgs, sanitized);
const emittedDelta = nextArgs.slice(existingArgs.length);
state.funcArgsBuf[tcIdx] = nextArgs;

View File

@@ -23,7 +23,6 @@
import crypto from "node:crypto";
import dns from "node:dns";
import { isIP } from "node:net";
import sharp from "sharp";
import {
parseAndValidatePublicUrl,
isPrivateHost,
@@ -31,6 +30,15 @@ import {
} from "@/shared/network/outboundUrlGuard";
import type { EncodedImage } from "./cursorAgentProtobuf.ts";
type SharpFactory = (typeof import("sharp"))["default"];
let sharpFactoryPromise: Promise<SharpFactory> | undefined;
function loadSharp(): Promise<SharpFactory> {
sharpFactoryPromise ??= import("sharp").then((module) => module.default);
return sharpFactoryPromise;
}
/** Final per-image byte cap after prep (composer-api / wire bound). */
export const MAX_CURSOR_IMAGE_BYTES = 1024 * 1024;
@@ -503,6 +511,7 @@ export async function prepareCursorImageForWire(input: {
mimeType: string;
detail?: string;
}): Promise<PreparedImage> {
const sharp = await loadSharp();
const mime = input.mimeType.toLowerCase();
const softMax = softMaxBytesForDetail(input.detail);
const qualities = jpegQualitiesForDetail(input.detail);

View File

@@ -177,6 +177,9 @@ const EMBEDDED_DEFAULTS = {
13, 92, 15, 89, 66, 91, 76, 70, 72, 29, 71, 70, 3, 65, 93, 84, 72, 23, 28, 87, 92, 88, 15, 95,
91, 22, 71, 87, 20, 66, 67, 86, 13, 81, 81, 21,
],
// Openference OAuth — public PKCE client id. The plaintext equals the first
// nine bytes of MASK, so its XOR-masked representation is nine zero bytes.
openference_id: [0, 0, 0, 0, 0, 0, 0, 0, 0],
// Trae Cloud IDE — public oauth client id
trae_id: [10, 3, 95, 6, 10, 22, 66, 3, 11, 90, 72, 31, 91, 2],
// Microsoft Designer web app — public ClientId header sent by the

View File

@@ -13,6 +13,8 @@
* that proxy to thinking-mode models.
*/
import { requiresReasoningReplay } from "../services/reasoningCache.ts";
const PLACEHOLDER = " ";
type JsonRecord = Record<string, unknown>;
@@ -30,24 +32,6 @@ const THINKING_MODEL_PATTERNS: RegExp[] = [
/\bmimo\b/i, // xiaomi-tokenplan mimo family (e.g. xiaomi-tokenplan/mimo-v2.5-pro)
];
const AUTHENTIC_REASONING_MODEL_PATTERN = /(?:^|\/)kimi-k(?:3|2\.7-code)(?:$|-)/i;
/**
* Native Moonshot K3/K2.7 replay must use the original reasoning content.
* A fabricated placeholder changes preserved-thinking history and is not a
* valid substitute when the client and reasoning cache both lack the field.
*/
export function requiresAuthenticReasoningContent(provider: unknown, model: unknown): boolean {
const normalizedProvider = String(provider ?? "")
.trim()
.toLowerCase();
const normalizedModel = String(model ?? "").trim();
return (
(normalizedProvider === "moonshot" || normalizedProvider === "kimi") &&
AUTHENTIC_REASONING_MODEL_PATTERN.test(normalizedModel)
);
}
export function isThinkingMessageModel(model: string | undefined | null): boolean {
if (!model || typeof model !== "string") return false;
return THINKING_MODEL_PATTERNS.some((re) => re.test(model));
@@ -62,7 +46,11 @@ export function shouldInjectReasoningContentPlaceholder(
.toLowerCase();
return (
(normalizedProvider === "moonshot" || normalizedProvider === "kimi") &&
!requiresAuthenticReasoningContent(normalizedProvider, model) &&
!requiresReasoningReplay({
provider: normalizedProvider,
model: String(model ?? ""),
allowLegacyFallback: false,
}) &&
isThinkingMessageModel(model)
);
}

View File

@@ -23,6 +23,7 @@ import {
hasActiveDeltaValue,
injectThinkingSignature,
} from "./streamHelpers.ts";
import { rejectEmptyChoicesStream, buildEmptyChoicesStreamError } from "./streamEmptyChoices.ts";
import { calculateCost } from "@/lib/usage/costCalculator";
import { buildOmniRouteSseMetadataComment } from "@/domain/omnirouteResponseMeta";
import { sseCommentsEnabled } from "./sseHeartbeat.ts";
@@ -725,6 +726,9 @@ export function createSSEStream(options: StreamOptions = {}) {
}
: null;
// Tracks whether any valuable chunk was forwarded; empty at flush => retryable 502 (#9268)
let forwardedValuableChunk = false;
// Track content length for usage estimation (both modes)
let totalContentLength = 0;
// Passthrough: accumulate content and reasoning separately for call log response body
@@ -995,6 +999,7 @@ export function createSSEStream(options: StreamOptions = {}) {
const output = formatSSE(itemSanitized, sourceFormat);
clientPayloadCollector.push(itemSanitized);
reqLogger?.appendConvertedChunk?.(output);
forwardedValuableChunk = true;
controller.enqueue(encoder.encode(output));
};
@@ -2619,6 +2624,27 @@ export function createSSEStream(options: StreamOptions = {}) {
return;
}
// #9268: reject a translate-mode stream that forwarded no valuable chunk
// (all-empty `choices: []`) instead of completing with an empty 200.
if (
mode === STREAM_MODE.TRANSLATE &&
rejectEmptyChoicesStream({
forwardedValuableChunk,
hasValidUsage: hasValidUsage(state?.usage),
providerPayloadCollector,
clientPayloadCollector,
targetFormat,
model,
usage: state?.usage,
onFailure,
onComplete,
clearPendingRequestFromStream,
})
) {
controller.error(markPendingRequestCleared(buildEmptyChoicesStreamError()));
return;
}
// Flush remaining events (only once at stream end)
const flushed = translateResponse(targetFormat, sourceFormat, null, state);

View File

@@ -0,0 +1,123 @@
/**
* Empty-stream rejection for the SSE transform (#9268).
*
* A streaming provider can complete a turn having forwarded nothing usable —
* every chunk carried an empty `choices: []` (no content, no tool_calls, no
* finish_reason, e.g. a Gemini turn where the model emitted nothing). The SSE
* transform drops those chunks silently, so without a guard the stream would
* terminate with a clean empty 200, which clients treat as a valid empty turn
* and retry to their cap with no error to stop on.
*
* The transform is the only place that knows a chunk was actually forwarded, so
* `createSSEStream` threads a `forwardedValuableChunk` boolean and the
* flush-time callbacks. All rejection logic lives here so the frozen
* `open-sse/utils/stream.ts` only carries the minimal call-site wiring.
*
* Mirrors the non-streaming `isEmptyContentResponse` behavior in
* `open-sse/handlers/chatCore.ts` (empty content → retryable 502), and the
* #8649 disconnect-aware wrapper's "Provider returned empty content" outcome.
*/
import { buildErrorBody } from "./error.ts";
import { buildStreamSummaryFromEvents } from "./streamPayloadCollector.ts";
type StructuredSSEEventLike = {
index: number;
timestamp?: string;
event?: string;
data: unknown;
};
type StructuredSSECollectorLike = {
getEvents: () => StructuredSSEEventLike[];
build: (summary?: unknown, opts?: { includeEvents?: boolean }) => unknown;
};
type EmptyChoicesRejectContext = {
/** True when any chunk with content/tool_calls/finish_reason was forwarded. */
forwardedValuableChunk: boolean;
/** Valid usage accumulated on the stream state (usage-only streams are fine). */
hasValidUsage: boolean;
/** Provider-side event collector (for the onComplete providerPayload summary). */
providerPayloadCollector: StructuredSSECollectorLike;
/** Client-side payload collector (for the onComplete clientPayload). */
clientPayloadCollector: StructuredSSECollectorLike;
targetFormat?: string;
model?: string | null;
usage?: unknown;
onFailure?: ((payload: {
status: number;
message: string;
code?: string;
type?: string;
}) => boolean | void | Promise<void>) | null;
onComplete?: ((payload: {
status: number;
usage: unknown;
responseBody?: unknown;
providerPayload?: unknown;
clientPayload?: unknown;
error?: string | null;
errorCode?: string | null;
}) => void) | null;
clearPendingRequestFromStream?: () => void;
};
/**
* Returns `true` when the empty-stream condition was detected and the caller
* must abort the stream (controller.error + early return); `false` when the
* stream legitimately forwarded content/usage and should complete normally.
*/
export function rejectEmptyChoicesStream(ctx: EmptyChoicesRejectContext): boolean {
if (ctx.forwardedValuableChunk || ctx.hasValidUsage) return false;
const error = new Error(
"Provider returned empty content — stream forwarded no valuable chunks"
) as Error & { statusCode: number; code: string };
error.statusCode = 502;
error.code = "empty_content";
if (ctx.onFailure) {
try {
ctx.onFailure({ status: 502, message: error.message, code: "empty_content" });
} catch {
// best-effort — must never break the stream error path
}
}
const errorBody = buildErrorBody(502, error.message);
if (ctx.onComplete) {
try {
ctx.onComplete({
status: 502,
usage: ctx.usage,
responseBody: errorBody,
error: error.message,
errorCode: "empty_content",
providerPayload: ctx.providerPayloadCollector.build(
buildStreamSummaryFromEvents(
ctx.providerPayloadCollector.getEvents(),
ctx.targetFormat,
ctx.model
),
{ includeEvents: false }
),
clientPayload: ctx.clientPayloadCollector.build(errorBody, { includeEvents: false }),
});
} catch {
// best-effort
}
}
ctx.clearPendingRequestFromStream?.();
return true;
}
/** The retryable error the caller should surface via controller.error. */
export function buildEmptyChoicesStreamError(): Error & { statusCode: number; code: string } {
const error = new Error(
"Provider returned empty content — stream forwarded no valuable chunks"
) as Error & { statusCode: number; code: string };
error.statusCode = 502;
error.code = "empty_content";
return error;
}

View File

@@ -29,6 +29,59 @@ export type PipelineStreamErrorHandler = (event: {
statusCode: number;
}) => boolean;
export type ClientDisconnectEvent = { reason: string; duration: number };
/**
* #9653: a client that closes its connection right after reading a fully-completed
* SSE stream can race the stream's own completion bookkeeping — the bytes already
* reached the client, but the transform stream's completion callback (which flips
* `isStreamCompletionRecorded()` to true) hasn't finished bubbling up yet when the
* disconnect handler fires. Persisting immediately in that case records a false
* 499 with zero token usage for a request that actually delivered its full response.
*
* This wraps a disconnect finalizer with a grace period: instead of finalizing
* immediately, poll `isStreamCompletionRecorded()` until it flips true (a real
* completion landed — nothing more to do) or the deadline passes (genuinely gone —
* finalize as a 499 same as before). Pass `gracePeriodMs <= 0` to disable and
* finalize immediately, matching the pre-#9653 behavior.
*/
export function createClientDisconnectGraceHandler({
isStreamCompletionRecorded,
gracePeriodMs,
finalize,
pollIntervalMs = 250,
setTimeoutFn = setTimeout,
}: {
isStreamCompletionRecorded: () => boolean;
gracePeriodMs: number;
finalize: (event: ClientDisconnectEvent) => unknown;
pollIntervalMs?: number;
setTimeoutFn?: (callback: () => void, ms: number) => unknown;
}): (event: ClientDisconnectEvent) => boolean {
return (event) => {
if (isStreamCompletionRecorded()) return true;
if (gracePeriodMs <= 0) {
finalize(event);
return true;
}
const deadline = Date.now() + gracePeriodMs;
const poll = () => {
if (isStreamCompletionRecorded()) return;
if (Date.now() >= deadline) {
finalize(event);
return;
}
setTimeoutFn(poll, pollIntervalMs);
};
setTimeoutFn(poll, pollIntervalMs);
// Claim "handled" immediately so the caller's own immediate-finalize fallback
// doesn't fire while the grace-period poll is still pending.
return true;
};
}
export function finalizeStreamRequestLog({
pendingRequestId,
model,
@@ -107,9 +160,7 @@ export function createStreamFailureFinalizers({
const message = failure.message || "Upstream stream error";
const code = failure.code || failure.type || String(status);
const classification =
failure.code || failure.type
? { code: failure.code, type: failure.type }
: undefined;
failure.code || failure.type ? { code: failure.code, type: failure.type } : undefined;
if (!isFailureCompletionRecorded()) {
const errorBody = buildErrorBody(status, message, undefined, classification);

228
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.8.49",
"version": "3.8.50",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.8.49",
"version": "3.8.50",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -81,7 +81,7 @@
"sqlite-vec": "^0.1.9",
"tailwind-merge": "^3.6.0",
"tsx": "^4.23.0",
"undici": "^8.3.0",
"undici": "^8.10.0",
"update-notifier": "^7.3.1",
"uuid": "^14.0.0",
"ws": "^8.18.0",
@@ -134,6 +134,7 @@
"lint-staged": "^17.0.8",
"lockfile-lint": "^5.0.0",
"node-loader": "^2.1.0",
"opencode-ai": "1.18.8",
"playwright-ctrf-json-reporter": "^0.0.29",
"prettier": "^3.8.3",
"promptfoo": "^0.121.18",
@@ -153,7 +154,7 @@
"@atjsh/llmlingua-2": "2.0.3",
"@huggingface/transformers": "3.5.2",
"@tensorflow/tfjs": "4.22.0",
"better-sqlite3": "^13.0.1",
"better-sqlite3": "^13.0.2",
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",
"tls-client-node": "^0.2.0",
@@ -13757,10 +13758,9 @@
}
},
"node_modules/better-sqlite3": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.1.tgz",
"integrity": "sha512-LYpmOXdkpQYf4wmlxkdzW01XGlOXNIbjLg45yNkh0FQ4814VbK9PdOFmhZpYbej+EZtR/i3FDdhEG98HqZdgnA==",
"hasInstallScript": true,
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -28809,6 +28809,205 @@
}
}
},
"node_modules/opencode-ai": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.8.tgz",
"integrity": "sha512-eZvYK0rIc/NUDQ+s3LsO9gyUU3MswsbNOLZz06iPwVhbg/2jF6bkTaroBgiIdFWKwUn5sj+kSMc4TBYxFkMrNQ==",
"cpu": [
"arm64",
"x64"
],
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"bin": {
"opencode": "bin/opencode.exe"
},
"optionalDependencies": {
"opencode-darwin-arm64": "1.18.8",
"opencode-darwin-x64": "1.18.8",
"opencode-darwin-x64-baseline": "1.18.8",
"opencode-linux-arm64": "1.18.8",
"opencode-linux-arm64-musl": "1.18.8",
"opencode-linux-x64": "1.18.8",
"opencode-linux-x64-baseline": "1.18.8",
"opencode-linux-x64-baseline-musl": "1.18.8",
"opencode-linux-x64-musl": "1.18.8",
"opencode-windows-arm64": "1.18.8",
"opencode-windows-x64": "1.18.8",
"opencode-windows-x64-baseline": "1.18.8"
}
},
"node_modules/opencode-darwin-arm64": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.8.tgz",
"integrity": "sha512-ZZCIEgTvHxOHk52Aeqhq59t/R0aqs29bPIgu45XE4rkgjmn/XCkTWalCPtyzJHipdcEbq/g0lqsE1OlJV0oNbA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/opencode-darwin-x64": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.8.tgz",
"integrity": "sha512-2EXRMJbRKnFPWI9oDU9tb7jDGmKiPmfjCLtwJMe3EF57h5wfcdEH9sP25bR3Og5NbE2M+PtMcJm0jMeHn2XoLQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/opencode-darwin-x64-baseline": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.8.tgz",
"integrity": "sha512-eLXa2tK9LRuZ5e20QG2k4dmWAA5xnLgJ1afRTSD0/ybE6CAeK02i8vFCnFFDaxuBo+gnq+yqO8AkqvN1m64V/Q==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/opencode-linux-arm64": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.8.tgz",
"integrity": "sha512-7kj3c9JEdryHgK+o8zE/N9KzTOdbiDn6KpY8dl+hM9n5Cnmxezx4IAlgJeC9QxpIx8Omop6CYuZ+17KfrKdKLw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-arm64-musl": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.8.tgz",
"integrity": "sha512-tww5TF/LIOv/GoTNyzGYgqDRhbJrhoMu8R+p5yD/SpnXPg3rcfYREw2wRy9yikyPU9sAQksuIIteTsyGerPjlA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.8.tgz",
"integrity": "sha512-Sm4fbQ9BdLI6hgN6FYYX8Nql+Sqe/2EKHJu3iWg0UYs93AXN4ROi0rvOmRbMk+ycYgOchb0hL6Ti2opxLx17sg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64-baseline": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.8.tgz",
"integrity": "sha512-egeEF4tk1rK9flIQjjeSVB9cR/X3zUti0pNAHW6ROJkNkj72z2C2FmjK1hZbfjtteCueMXPLptS23JROHGWL1w==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64-baseline-musl": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.8.tgz",
"integrity": "sha512-S+438BXs48gLeXX/ya4TSNytDy9mliU3sOAf6j9rfFjzGiF/S08LedemSAnHkr0riBtamik1aRPSmTjhQ0dOBg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64-musl": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.8.tgz",
"integrity": "sha512-c+E4Zsp0DYVcuqcDtgxw/4YcFLrVYWdGBR8x4CzpW48ga3RshaH+BlmUiy+GY0yr1x6UR+e2V3w4uzvzm/L9UQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-windows-arm64": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.8.tgz",
"integrity": "sha512-7NjdtEIiX28kmsKD9jHbFG4bbwBB5T4dAe2UwdnOqCBb2cl+ETV5eO6kbdC/xWxrgOghgZM1Wtw791T5pQPyag==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/opencode-windows-x64": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.8.tgz",
"integrity": "sha512-G+NEgEMvu/dEYshH5IaqHVTmsHVuGdORBvVmgphFiknT7q/NXPuoZCMtMIdfNlEFbu54BlzRDdJCR3Mqe98gUw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/opencode-windows-x64-baseline": {
"version": "1.18.8",
"resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.8.tgz",
"integrity": "sha512-IGbjFyWoSN9rdGUJX7TWkQ1Yl673Q3dDna54b5NtqeRcZ839p+Z47zzM5m883HKAxrdKCC6Z22HYuDcXLV0laA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
@@ -35019,9 +35218,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
@@ -36812,12 +37011,7 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
"version": "3.8.49",
"dependencies": {
"@toon-format/toon": "^4.1.0",
"safe-regex": "^2.1.1",
"smol-toml": "1.7.1"
}
"version": "3.8.50"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "omniroute",
"version": "3.8.49",
"description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
"version": "3.8.50",
"description": "Unified AI router with 291 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
"type": "module",
"bin": {
"omniroute": "bin/omniroute.mjs",
@@ -23,6 +23,7 @@
".env.example",
"scripts/build/postinstall.mjs",
"scripts/build/fixTlsClientNodeBinary.mjs",
"scripts/build/fixPlaywrightAndroid.mjs",
"bin/cli/runtime/",
"scripts/postinstall.mjs",
"scripts/build/postinstallSupport.mjs",
@@ -33,11 +34,15 @@
"scripts/dev/tls-options.mjs",
"scripts/check/check-supported-node-runtime.ts",
"scripts/dev/sync-env.mjs",
"scripts/build/assembleStandalone.mjs",
"scripts/build/backendOnlyPages.mjs",
"scripts/build/build-tproxy-native.mjs",
"scripts/build/native-binary-compat.mjs",
"scripts/build/build-next-isolated.mjs",
"scripts/build/runtime-env.mjs",
"README.md",
"LICENSE",
"!**/node_modules/**",
"!**/__tests__/**",
"!**/*.test.ts",
"!**/*.test.tsx",
@@ -110,6 +115,8 @@
"test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
"test:unit:ci:shard": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD \"tests/unit/serial/**/*.test.ts\"",
"test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
"test:scoped": "bash scripts/quality/test-scoped.sh",
"test:scoped:staged": "bash scripts/quality/test-scoped.sh --staged",
"test:unit:shard": "concurrently --kill-others-on-fail -n s1,s2 \"npm:test:unit:shard:1\" \"npm:test:unit:shard:2\"",
"test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=1/2 \"tests/unit/serial/**/*.test.ts\"",
"test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=2/2 \"tests/unit/serial/**/*.test.ts\"",
@@ -143,6 +150,7 @@
"check:node-runtime": "node --import tsx scripts/check/check-supported-node-runtime.ts",
"check:pack-artifact": "node --import tsx scripts/build/validate-pack-artifact.ts",
"check:pack-boot": "node scripts/check/check-pack-boot.mjs",
"check:install-upgrade": "node scripts/check/check-install-upgrade.mjs",
"check:pack-policy": "node --import tsx scripts/build/validate-pack-artifact.ts --policy-only",
"check:cli-i18n": "node scripts/check/check-cli-i18n.mjs",
"check:openapi-coverage": "node scripts/check/check-openapi-coverage.mjs",
@@ -161,6 +169,7 @@
"check:test-masking": "node scripts/check/check-test-masking.mjs",
"check:test-runner-api": "node scripts/check/check-test-runner-api.mjs",
"check:changelog-integrity": "node scripts/check/check-changelog-integrity.mjs",
"sweep:stale-fragments": "node scripts/release/sweep-stale-fragments.mjs",
"changelog:aggregate": "node scripts/release/aggregate-changelog.mjs",
"check:agent-skills-sync": "node --import tsx/esm scripts/skills/generate-agent-skills.mjs",
"check:build-scope": "node scripts/check/check-build-scope.mjs",
@@ -184,6 +193,7 @@
"check:bundle-size": "node scripts/check/check-bundle-size.mjs",
"check:circular-deps": "node scripts/check/check-circular-deps.mjs",
"check:mutation-ratchet": "node scripts/check/check-mutation-ratchet.mjs",
"check:rtl-ratchet": "node scripts/check/check-rtl-ratchet.mjs",
"check:licenses": "node scripts/check/check-licenses.mjs",
"check:pr-evidence": "node scripts/check/check-pr-evidence.mjs",
"check:vuln-ratchet": "node scripts/check/check-vuln-ratchet.mjs",
@@ -200,9 +210,11 @@
"typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json",
"typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json",
"check:dashboard-typecheck": "node scripts/check/check-dashboard-typecheck.mjs",
"check:open-sse-typecheck": "node scripts/check/check-open-sse-typecheck.mjs",
"backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts",
"env:sync": "node scripts/dev/sync-env.mjs",
"test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"",
"test:integration:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"",
"test:combo:matrix": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-matrix/*.test.ts\"",
"test:combo:live": "cross-env RUN_COMBO_LIVE=1 DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-live/*.live.test.ts\"",
"test:combo:live:vps": "node scripts/test/combo-live-vps.mjs",
@@ -232,6 +244,7 @@
"prepare": "husky",
"system-info": "node scripts/dev/system-info.mjs",
"build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs",
"postbuild": "node scripts/build/colocate-standalone.mjs",
"release:contributors": "node scripts/release/gen-contributors.mjs",
"release:uncovered": "node scripts/release/list-uncovered-commits.mjs",
"test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
@@ -306,7 +319,7 @@
"sqlite-vec": "^0.1.9",
"tailwind-merge": "^3.6.0",
"tsx": "^4.23.0",
"undici": "^8.3.0",
"undici": "^8.10.0",
"update-notifier": "^7.3.1",
"uuid": "^14.0.0",
"ws": "^8.18.0",
@@ -319,7 +332,7 @@
"@atjsh/llmlingua-2": "2.0.3",
"@huggingface/transformers": "3.5.2",
"@tensorflow/tfjs": "4.22.0",
"better-sqlite3": "^13.0.1",
"better-sqlite3": "^13.0.2",
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",
"tls-client-node": "^0.2.0",
@@ -365,6 +378,7 @@
"lint-staged": "^17.0.8",
"lockfile-lint": "^5.0.0",
"node-loader": "^2.1.0",
"opencode-ai": "1.18.8",
"playwright-ctrf-json-reporter": "^0.0.29",
"prettier": "^3.8.3",
"promptfoo": "^0.121.18",
@@ -396,9 +410,18 @@
"sharp"
]
},
"allowScripts": {
"better-sqlite3": true,
"esbuild": true,
"@swc/core": true,
"@parcel/watcher": true,
"keytar": true,
"protobufjs": true,
"unrs-resolver": true
},
"overrides": {
"fast-xml-parser": "^5.10.1",
"sharp": "^0.35.0",
"sharp": "^0.35.3",
"postcss": "^8.5.18",
"ip-address": "^10.3.1",
"qs": "^6.15.2",

View File

@@ -86,6 +86,11 @@ export const NATIVE_ASSET_ENTRIES = [
src: ["node_modules", "better-sqlite3", "build"],
dest: ["node_modules", "better-sqlite3", "build"],
},
{
label: "better-sqlite3 prebuilt native binaries",
src: ["node_modules", "better-sqlite3", "prebuilds"],
dest: ["node_modules", "better-sqlite3", "prebuilds"],
},
{
// onnxruntime-node's dist/binding.js dlopen()s a platform-specific
// libonnxruntime.so.1 shipped under bin/napi-v3/<platform>/<arch>/ — a

View File

@@ -49,6 +49,7 @@ export const INTENTIONALLY_INTERNAL = new Set([
"commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/*
"compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config)
"compressionDetailNormalizers", // db-internal: importado só por db/compression.ts (normalizeSessionDedupConfig/normalizeCcrConfig/buildDetailConfigDefaults/applyDetailConfigUpdate — normalizadores do detail-config split do compression.ts, #8404)
"connectionRuntimeState", // intentionally-internal: warmupScheduler sqlite/redis stores importam diretamente de @/lib/db/connectionRuntimeState (Rule #2)
"vacuumScheduler", // intentionally-internal: src/instrumentation-node.ts (dynamic import, lifecycle wiring per Rule #2)
"detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler)
"discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery

View File

@@ -42,12 +42,14 @@ export const KNOWN_DUPLICATE_VERSIONS = new Set([
// ---------------------------------------------------------------------------
// ALLOWLIST 2 — gaps de sequência CONHECIDOS.
// Fonte: auditoria do disco (src/lib/db/migrations/) — a sequência pula 026 e 055.
// Estes números nunca tiveram arquivo físico (slots legados que viraram outros
// números via RENAMED_MIGRATION_COMPATIBILITY em migrationRunner.ts). Congelados
// para que o gate bloqueie apenas NOVOS buracos inexplicados na sequência.
// Fonte: auditoria do disco (src/lib/db/migrations/). Além dos slots legados,
// 143145 estão reservados pelas migrations Radar que já existem na série
// empilhada. O job registry foi promovido de 139 para 146 pela tabela
// RENAMED_MIGRATION_COMPATIBILITY para não ocupar esses slots em trânsito.
// O stale-enforcement remove automaticamente cada reserva quando o arquivo
// correspondente aterrissar na release.
// ---------------------------------------------------------------------------
export const KNOWN_GAPS = new Set(["026", "055", "121"]); // 121: número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12)
export const KNOWN_GAPS = new Set(["026", "055", "121", "143", "144", "145"]); // 121: número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12)
function pad3(n) {
return String(n).padStart(3, "0");

View File

@@ -221,6 +221,17 @@ export const FULL_CI_SKIP = new Set(["check:pr-evidence", "check:codeql-ratchet"
// Gates that need a specific env to behave like CI (else they compare against the wrong base).
export const FULL_CI_ENV = { "check:test-masking": { GITHUB_BASE_REF: "main" } };
const FULL_CI_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
const FULL_CI_TIMEOUT_OVERRIDES_MS = {
// Measured at 19m38s on the loaded release-v3.8.50 devbox. The former generic
// 10m ceiling killed a green scan before it could report its result.
"check:test-masking": 30 * 60 * 1000,
};
export function fullCiTimeoutFor(gateId) {
return FULL_CI_TIMEOUT_OVERRIDES_MS[gateId] ?? FULL_CI_DEFAULT_TIMEOUT_MS;
}
/**
* Parse a ci.yml text and return the ordered, de-duplicated list of gate commands to run.
* Each entry: { id, job, args:["run", <script>, ...("--" + args)], env }.
@@ -272,7 +283,8 @@ export function extractCiGates(
* never an infinite block that the release captain mistakes for a hang and kills the pre-flight.
*/
export function classifyRunError(err, timeoutMs) {
if (err && err.killed && timeoutMs) {
const timedOut = err?.killed === true || err?.code === "ETIMEDOUT";
if (timedOut && timeoutMs) {
return {
code: 124,
out: `gate exceeded its ${Math.round(timeoutMs / 1000)}s ceiling and was killed — treat as a hung/failed gate (e.g. an unreleased DB handle in the unit suite); does NOT pass`,
@@ -696,7 +708,10 @@ async function main() {
for (const g of gates) {
// Skip a gate the curated pass already ran with the same id (avoid double-running lint).
if (already.has(g.id)) continue;
const { code, out } = run(npmCmd, g.args, { env: g.env, timeout: 10 * 60 * 1000 });
const { code, out } = run(npmCmd, g.args, {
env: g.env,
timeout: fullCiTimeoutFor(g.id),
});
saveGateLog(`fullci-${g.id.replace(/[^a-z0-9]+/gi, "-")}`, out);
record({
id: g.id,

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
import MediaProviderHeader from "../../components/MediaProviderHeader";
@@ -39,7 +40,10 @@ interface MediaProviderPageClientProps {
function renderPlayground(
kind: MediaKind,
providerId: string,
imageToTextCopy: { title: string; description: React.ReactNode }
bridgeCopy: {
imageToText: { title: string; description: React.ReactNode; cta: string };
sttCta: string;
}
) {
switch (kind) {
case "embedding":
@@ -49,7 +53,17 @@ function renderPlayground(
case "tts":
return <TtsExampleCard providerId={providerId} />;
case "stt":
return <SttExampleCard providerId={providerId} />;
return (
<div className="flex flex-col gap-3">
<SttExampleCard providerId={providerId} />
<Link
href="/dashboard/settings/modality-bridge?tab=audio"
className="text-xs text-primary hover:underline"
>
{bridgeCopy.sttCta}
</Link>
</div>
);
case "webSearch":
return <WebSearchExampleCard providerId={providerId} />;
case "webFetch":
@@ -66,9 +80,15 @@ function renderPlayground(
<div className="flex flex-col gap-2 border border-dashed border-border rounded-xl p-6">
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined text-[20px]">image_search</span>
<h3 className="text-sm font-medium">{imageToTextCopy.title}</h3>
<h3 className="text-sm font-medium">{bridgeCopy.imageToText.title}</h3>
</div>
<p className="text-xs text-text-muted">{imageToTextCopy.description}</p>
<p className="text-xs text-text-muted">{bridgeCopy.imageToText.description}</p>
<Link
href="/dashboard/settings/modality-bridge?tab=vision"
className="text-xs text-primary hover:underline"
>
{bridgeCopy.imageToText.cta}
</Link>
</div>
);
default:
@@ -197,10 +217,14 @@ export default function MediaProviderPageClient({
{/* Playground */}
{renderPlayground(activeKind, providerId, {
title: t("imageToText"),
description: t.rich("imageToTextComingSoon", {
code: (chunks) => <code className="font-mono bg-bg-subtle px-1 rounded">{chunks}</code>,
}),
imageToText: {
title: t("imageToText"),
description: t.rich("imageToTextBridgeAvailable", {
code: (chunks) => <code className="rounded bg-bg-subtle px-1 font-mono">{chunks}</code>,
}),
cta: t("imageToTextBridgeCta"),
},
sttCta: t("sttBridgeCta"),
})}
</div>
);

View File

@@ -59,6 +59,7 @@ import EmptyConnectionsPlaceholder from "./components/EmptyConnectionsPlaceholde
import UpstreamProxyCard from "./components/UpstreamProxyCard";
import SearchProviderCard from "./components/SearchProviderCard";
import NoAuthProviderControls from "./components/NoAuthProviderControls";
import AnonymousFallbackToggle from "./components/AnonymousFallbackToggle";
// providerText used by UpstreamProxyCard (Phase 1t.7)
export default function ProviderDetailPageClient() {
@@ -538,6 +539,12 @@ export default function ProviderDetailPageClient() {
}
/>
)}
{!isUpstreamProxyProvider && !isFreeNoAuth && (
<AnonymousFallbackToggle
providerId={providerId}
providerName={providerInfo?.name || providerId}
/>
)}
{!isUpstreamProxyProvider && !isFreeNoAuth && (
<Card>
<ProviderAccountRoutingCard

View File

@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import {
computeNoAuthFallbackDisabledProviders,
isNoAuthFallbackEnabled,
} from "../components/AnonymousFallbackToggle";
describe("AnonymousFallbackToggle list-update helpers", () => {
it("disabling adds the providerId exactly once and dedupes existing entries", () => {
const next = computeNoAuthFallbackDisabledProviders(
["openai", "openai", "opencode-go"],
"opencode-go",
"opencode",
true
);
expect(next).toEqual(["openai", "opencode-go"]);
expect(next.filter((id) => id === "opencode-go")).toHaveLength(1);
});
it("enabling removes both the providerId and its alias", () => {
const next = computeNoAuthFallbackDisabledProviders(
["openai", "opencode-go", "opencode"],
"opencode-go",
"opencode",
false
);
expect(next).toEqual(["openai"]);
});
it("enabling with only the alias present also removes it", () => {
const next = computeNoAuthFallbackDisabledProviders(
["opencode"],
"opencode-go",
"opencode",
false
);
expect(next).toEqual([]);
});
it("is enabled by default when the disabled list is absent", () => {
expect(isNoAuthFallbackEnabled("opencode-go", "opencode", undefined)).toBe(true);
});
it("is disabled when the providerId is in the list", () => {
expect(isNoAuthFallbackEnabled("opencode-go", "opencode", ["opencode-go"])).toBe(false);
});
it("is disabled when only the alias is in the list", () => {
expect(isNoAuthFallbackEnabled("opencode-go", "opencode", ["opencode"])).toBe(false);
});
it("is enabled when the list is present but does not contain the provider", () => {
expect(isNoAuthFallbackEnabled("opencode-go", "opencode", ["openai"])).toBe(true);
});
});

View File

@@ -0,0 +1,196 @@
"use client";
// Issue #8935 — per-provider opt-out for the synthetic anonymous (no-auth)
// credential fallback on API-key providers whose static definition declares
// anonymousFallback: true (opencode-go, opencode-zen, pollinations, kilocode).
// Default ON (fallback enabled) when the setting is absent, so existing
// behavior is preserved for everyone who does not opt out. True no-auth
// providers (NOAUTH_PROVIDERS / WEB_COOKIE_PROVIDERS) never see this control —
// their synthetic credential is the only credential path and is governed by
// blockedProviders instead.
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { getProviderAlias, getProviderById } from "@/shared/constants/providers";
import { useNotificationStore } from "@/store/notificationStore";
import { providerText } from "../providerPageHelpers";
export function computeNoAuthFallbackDisabledProviders(
current: string[],
providerId: string,
providerAlias: string | undefined,
disabling: boolean
): string[] {
const keysToRemove = new Set([providerId, providerAlias].filter(Boolean));
if (!disabling) {
return current.filter((item) => !keysToRemove.has(item));
}
return Array.from(new Set([...current.filter((item) => !keysToRemove.has(item)), providerId]));
}
export function isNoAuthFallbackEnabled(
providerId: string,
providerAlias: string | undefined,
disabledProviders: string[] | undefined
): boolean {
if (!Array.isArray(disabledProviders)) return true;
return (
!disabledProviders.includes(providerId) &&
!(typeof providerAlias === "string" && disabledProviders.includes(providerAlias))
);
}
interface AnonymousFallbackToggleProps {
providerId: string;
providerName: string;
}
export default function AnonymousFallbackToggle({
providerId,
providerName,
}: AnonymousFallbackToggleProps) {
const t = useTranslations("providers");
const notify = useNotificationStore();
const [disabledProviders, setDisabledProviders] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const providerDef = getProviderById(providerId) as { anonymousFallback?: boolean } | undefined;
const providerAlias = getProviderAlias(providerId);
const fallbackEnabled = isNoAuthFallbackEnabled(providerId, providerAlias, disabledProviders);
useEffect(() => {
let cancelled = false;
async function fetchDisabledProviders() {
try {
const response = await fetch("/api/settings", { cache: "no-store" });
if (!response.ok) return;
const data = await response.json();
if (!cancelled && Array.isArray(data.noAuthFallbackDisabledProviders)) {
setDisabledProviders(data.noAuthFallbackDisabledProviders);
}
} catch (error) {
console.error("Failed to fetch provider settings:", error);
}
}
void fetchDisabledProviders();
return () => {
cancelled = true;
};
}, []);
const handleToggle = useCallback(
async (nextEnabled: boolean) => {
const previous = disabledProviders;
const next = computeNoAuthFallbackDisabledProviders(
previous,
providerId,
providerAlias,
!nextEnabled
);
setDisabledProviders(next);
setSaving(true);
try {
const response = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ noAuthFallbackDisabledProviders: next }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(
data?.error?.message ||
data?.error ||
providerText(
t,
"anonymousFallbackUpdateFailed",
"Failed to update anonymous fallback setting"
)
);
}
setDisabledProviders(
Array.isArray(data.noAuthFallbackDisabledProviders)
? data.noAuthFallbackDisabledProviders
: next
);
notify.success(
nextEnabled
? providerText(
t,
"anonymousFallbackEnabled",
"Anonymous fallback enabled for {provider}",
{
provider: providerName,
}
)
: providerText(
t,
"anonymousFallbackDisabled",
"Anonymous fallback disabled for {provider} — exhausted connections will skip this provider",
{ provider: providerName }
)
);
} catch (error) {
setDisabledProviders(previous);
notify.error(
error instanceof Error
? error.message
: providerText(
t,
"anonymousFallbackUpdateFailed",
"Failed to update anonymous fallback setting"
)
);
} finally {
setSaving(false);
}
},
[disabledProviders, notify, providerAlias, providerId, providerName, t]
);
// Only API-key providers whose static definition opts into the anonymous
// fallback get this control; everything else self-hides.
if (providerDef?.anonymousFallback !== true) {
return null;
}
const title = providerText(t, "anonymousFallbackTitle", "Anonymous fallback");
return (
<Card>
<div className="flex items-center gap-3">
<div className="inline-flex shrink-0 items-center justify-center w-10 h-10 rounded-full bg-sky-500/10 text-sky-500">
<span className="material-symbols-outlined text-[20px]">key_off</span>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold">{title}</h2>
<p className="text-sm text-text-muted">
{providerText(
t,
"anonymousFallbackDesc",
"When all configured connections are exhausted (quota, credits, or expiry), temporarily use this provider's keyless tier. Turn off to skip this provider instead of sending anonymous requests — recommended when the keyless tier rejects them (401)."
)}
</p>
</div>
<button
type="button"
aria-pressed={fallbackEnabled}
aria-label={title}
disabled={saving}
onClick={() => handleToggle(!fallbackEnabled)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
fallbackEnabled ? "bg-sky-500" : "bg-black/[0.12] dark:bg-white/[0.15]"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
fallbackEnabled ? "translate-x-[26px]" : "translate-x-[3px]"
}`}
/>
</button>
</div>
</Card>
);
}

View File

@@ -111,16 +111,16 @@ export default function WebSessionCredentialGuide({
<li>
{providerText(
t,
"webSessionGuideStep2",
"Open the browser developer tools and inspect a request made by the web app."
"webSessionGuideStep2Fast",
"Fast path: install the Cookie Editor extension (chromewebstore.google.com → Cookie Editor), open it on the {provider} tab, find {credential} (select all numbered chunks if split), and click Export → Copy with the export format set to “Cookie header”.",
{ provider: providerName, credential: requirement.credentialName }
)}
</li>
<li>
{providerText(
t,
"webSessionGuideStep3",
"Copy the required credential from the provider's own domain. For cookies, copy only the Cookie header value and omit Cookie:.",
{ credential: requirement.credentialName }
"webSessionGuideStep3Manual",
"Manual path: open the browser developer tools (F12 → Network), refresh the page, open an authenticated request, and copy the Cookie header value from Request Headers — omit the Cookie: prefix."
)}
</li>
<li>

View File

@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import Badge from "@/shared/components/Badge";
import type { BreakerWithHistory } from "@/types/resilience";
interface BreakerTimelineProps {
breakers: BreakerWithHistory[];
onWindowChange: (ms: number) => void;
}
const WINDOWS = [
{ label: "1h", ms: 3600000 },
{ label: "6h", ms: 21600000 },
{ label: "24h", ms: 86400000 },
] as const;
export default function BreakerTimeline({ breakers, onWindowChange }: BreakerTimelineProps) {
const t = useTranslations("resilienceConnections");
const [selectedWindowMs, setSelectedWindowMs] = useState(3600000);
const stateLabel = (state: string) => {
const key = `timeline.state.${state.toLowerCase()}`;
const label = t(key);
return label === key ? state : label; // Fallback for unknown states
};
const reasonLabel = (reason?: string) => {
if (!reason) return "";
// Map known reasons; use prefix matching for parameterized reasons (e.g., "probe-failed (cycle 3)")
const knownReasons = [
"timeout-elapsed",
"success-recovery",
"manual-reset",
"probe-success",
"recovery",
"probe-failed",
];
const matched = knownReasons.find((r) => reason.startsWith(r));
if (matched) {
return t(`timeline.reason.${matched}`);
}
return reason; // Fallback: display raw reason string
};
// API already filters transitionHistory by windowMs; no client-side re-filter needed
const filtered = breakers.filter((b) => b.transitionHistory.length > 0);
// Current state: breakers that are OPEN/HALF_OPEN/DEGRADED even with no window transitions
const currentBreakers = breakers.filter(
(b) => b.state === "OPEN" || b.state === "HALF_OPEN" || b.state === "DEGRADED"
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
{currentBreakers.length > 0 && (
<div>
<h3>{t("timeline.currentState")}</h3>
{currentBreakers.map((b) => (
<span key={b.name} style={{ display: "inline-block", marginRight: "8px" }}>
<Badge
variant={
b.state === "OPEN"
? "error"
: b.state === "HALF_OPEN" || b.state === "DEGRADED"
? "warning"
: "success"
}
size="sm"
>
{b.name} ({b.state})
</Badge>
</span>
))}
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
<span style={{ fontWeight: 600 }}>{t("timeline.window")}</span>
{WINDOWS.map((w) => (
<button
key={w.ms}
type="button"
onClick={() => {
setSelectedWindowMs(w.ms);
onWindowChange(w.ms);
}}
style={{ fontWeight: selectedWindowMs === w.ms ? 700 : 400 }}
>
{t(`timeline.window${w.label}`)}
</button>
))}
</div>
{filtered.length === 0 ? (
<p style={{ color: "var(--color-text-muted)" }}>{t("timeline.empty")}</p>
) : (
filtered.map((b) => (
<div key={b.name}>
<Badge
variant={
b.state === "OPEN"
? "error"
: b.state === "HALF_OPEN"
? "warning"
: b.state === "DEGRADED"
? "warning"
: "success"
}
size="sm"
>
{b.name} ({stateLabel(b.state)})
</Badge>
<ul style={{ marginTop: "8px", paddingLeft: "20px" }}>
{b.transitionHistory.map((tr, i) => (
<li key={`${tr.timestamp}-${tr.from}-${tr.to}-${i}`}>
{new Date(tr.timestamp).toLocaleString()}: {stateLabel(tr.from)} {"->"}{" "}
{stateLabel(tr.to)}
{tr.reason ? ` (${reasonLabel(tr.reason)})` : ""}
</li>
))}
</ul>
</div>
))
)}
</div>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import Badge from "@/shared/components/Badge";
import type { ConnectionState } from "@/types/resilience";
import { formatRemaining } from "@/shared/utils/formatRemaining";
interface ConnectionDetailProps {
connection: ConnectionState | undefined; // undefined when connection deleted
receivedAt: number; // client fetch receive time (immune to clock skew)
onClose: () => void;
}
export default function ConnectionDetail({
connection,
receivedAt,
onClose,
}: ConnectionDetailProps) {
const t = useTranslations("resilienceConnections");
const [tick, setTick] = useState(0); // force re-render for live countdown
useEffect(() => {
if (!connection?.isCoolingDown) return;
// Reset tick baseline when the connection changes so the countdown restarts from
// the fresh cooldownRemainingMs. setTick(0) is a re-sync, not a cascading render.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTick(0);
const interval = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(interval);
}, [connection?.isCoolingDown, connection?.id]);
if (!connection) return null; // Guard: connection deleted while panel open
const elapsedMs = tick * 1000;
const adjustedCooldown = Math.max(0, connection.cooldownRemainingMs - elapsedMs);
return (
<div style={{ padding: "16px", borderRadius: "8px", border: "1px solid var(--color-border)" }}>
<h2>{t("detail.title")}</h2>
<div>
{t("detail.provider")}: {connection.provider}
</div>
<div>
{t("detail.id")}: {connection.id}
</div>
<div>
{t("detail.authType")}: {connection.authType}
</div>
<div>
{t("detail.priority")}: {connection.priority}
</div>
<div>
{t("detail.isActive")}: {connection.isActive ? t("detail.yes") : t("detail.no")}
</div>
<div>
{t("detail.errorCode")}: {connection.errorCode ?? t("detail.never")}
</div>
<div>
{t("detail.lastErrorAt")}: {connection.lastErrorAt ?? t("detail.never")}
</div>
<hr />
<h3>{t("detail.cooldown")}</h3>
<div>
{t("detail.rateLimitedUntil")}: {connection.rateLimitedUntil ?? t("detail.never")}
</div>
<div>
{t("detail.backoffLevel")}: {connection.backoffLevel}
</div>
<div>
{t("detail.remaining")}:{" "}
{connection.isCoolingDown ? formatRemaining(adjustedCooldown) : t("detail.never")}
</div>
<hr />
<h3>{t("detail.breaker")}</h3>
{connection.breaker ? (
<div>
<Badge
variant={
connection.breaker.state === "OPEN"
? "error"
: connection.breaker.state === "HALF_OPEN"
? "warning"
: connection.breaker.state === "DEGRADED"
? "warning"
: "success"
}
size="sm"
>
{connection.breaker.state}
</Badge>
<div>
{t("detail.failureCount")}: {connection.breaker.failureCount}
</div>
<div>
{t("detail.retryAfterMs")}: {connection.breaker.retryAfterMs}
</div>
<div>
{t("detail.lastFailureKind")}: {connection.breaker.lastFailureKind ?? t("detail.never")}
</div>
</div>
) : (
<div>{t("detail.never")}</div>
)}
<hr />
<h3>{t("detail.lockouts")}</h3>
{connection.lockouts.length > 0 ? (
<ul>
{connection.lockouts.map((l, i) => (
<li key={i}>
{l.model}: {l.reason} ({formatRemaining(Math.max(0, l.remainingMs - elapsedMs))})
</li>
))}
</ul>
) : (
<div>{t("detail.noLockouts")}</div>
)}
<button type="button" onClick={onClose}>
{t("detail.close")}
</button>
</div>
);
}

View File

@@ -0,0 +1,156 @@
"use client";
import { useState, useEffect, useMemo, memo } from "react";
import { useTranslations } from "next-intl";
import Badge from "@/shared/components/Badge";
import DataTable from "@/shared/components/DataTable";
import type { DataTableColumn, DataTableRow } from "@/shared/components/DataTable";
import type { ConnectionState } from "@/types/resilience";
import { formatRemaining } from "@/shared/utils/formatRemaining";
import ConnectionDetail from "./ConnectionDetail";
interface ConnectionsTableProps {
connections: ConnectionState[];
receivedAt: number; // client fetch receive time (immune to clock skew)
degraded: string[]; // meta.degraded from API (to show "Unknown" when breaker data absent)
}
// Module-scoped memoized countdown cell: hoisted to avoid remount on every poll
// (useMemo with receivedAt dependency would create new type each poll -> unmount/remount)
// Ponytail: elapsed derived from tick count (pure -- no Date.now() in render), self-corrects
// on each poll when receivedAt changes and the effect resets the tick baseline.
const CountdownCell = memo(function CountdownCell({
connection,
receivedAt,
}: {
connection: ConnectionState;
receivedAt: number;
}) {
const [tick, setTick] = useState(0); // force re-render for live countdown
useEffect(() => {
if (!connection.isCoolingDown) return;
// Reset tick baseline when new data arrives so the countdown restarts from the
// fresh cooldownRemainingMs. setTick(0) is a re-sync, not a cascading render.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTick(0);
const interval = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(interval);
}, [connection.isCoolingDown, receivedAt]);
if (!connection.isCoolingDown) return <span>-</span>;
const elapsedMs = tick * 1000;
return <span>{formatRemaining(Math.max(0, connection.cooldownRemainingMs - elapsedMs))}</span>;
});
export default function ConnectionsTable({
connections,
receivedAt,
degraded,
}: ConnectionsTableProps) {
const t = useTranslations("resilienceConnections");
const [selectedId, setSelectedId] = useState<string | null>(null);
// Derive the effective selected id during render (closes detail when connection disappears)
const effectiveSelectedId =
selectedId && connections.some((c) => c.id === selectedId) ? selectedId : null;
const columns: DataTableColumn[] = [
{ key: "status", label: t("table.status") },
{ key: "provider", label: t("table.provider") },
{ key: "id", label: t("table.connectionId") },
{ key: "authType", label: t("table.authType") },
{ key: "backoffLevel", label: t("table.backoffLevel") },
{ key: "cooldown", label: t("table.cooldown") },
{ key: "lastError", label: t("table.lastError") },
{ key: "lockouts", label: t("table.lockouts") },
];
return (
<>
<DataTable
columns={columns}
data={connections as unknown as DataTableRow[]}
selectedId={effectiveSelectedId ?? undefined}
onRowClick={(row) => setSelectedId(String(row.id))}
renderCell={(row: DataTableRow, col: DataTableColumn) => {
const r = row as unknown as ConnectionState;
switch (col.key) {
case "status":
switch (r.connectionStatus) {
case "cooling_down":
return (
<Badge variant="warning" size="sm">
{t("table.coolingDown")}
</Badge>
);
case "circuit_open":
return (
<Badge variant="error" size="sm">
{t("table.circuitOpen")}
</Badge>
);
case "terminal":
return (
<Badge variant="error" size="sm">
{t("table.terminal")}
</Badge>
);
case "healthy":
// When breaker data is absent (degraded source), show "Unknown" not "Healthy"
if (degraded.includes("circuitBreaker") && !r.breaker) {
return (
<Badge variant="info" size="sm">
{t("table.unknown")}
</Badge>
);
}
return r.breaker?.state === "HALF_OPEN" ? (
<Badge variant="warning" size="sm">
{t("table.recovering")}
</Badge>
) : r.breaker?.state === "DEGRADED" ? (
<Badge variant="warning" size="sm">
{t("table.degraded")}
</Badge>
) : (
<Badge variant="success" size="sm">
{t("table.healthy")}
</Badge>
);
default:
return (
<Badge variant="info" size="sm">
{r.connectionStatus}
</Badge>
);
}
case "id":
return <span>{r.id.length > 8 ? `${r.id.slice(0, 8)}...` : r.id}</span>;
case "cooldown":
return <CountdownCell connection={r} receivedAt={receivedAt} />;
{
/* memoized: isolates 1s tick to single cell */
}
case "lastError":
return <span>{r.lastErrorType ?? t("table.never")}</span>;
case "lockouts":
return <span>{r.lockouts.length}</span>;
case "provider":
return <span>{r.provider}</span>;
case "authType":
return <span>{r.authType}</span>;
case "backoffLevel":
return <span>{r.backoffLevel}</span>;
default:
return null;
}
}}
/>
{effectiveSelectedId && (
<ConnectionDetail
connection={connections.find((c) => c.id === effectiveSelectedId) ?? undefined}
receivedAt={receivedAt}
onClose={() => setSelectedId(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import type { ResilienceConnectionsResponse } from "@/types/resilience";
import EmptyState from "@/shared/components/EmptyState";
import ConnectionsTable from "./ConnectionsTable";
import BreakerTimeline from "./BreakerTimeline";
const POLL_INTERVAL_MS = 30000;
export default function ResilienceConnectionsClient() {
const t = useTranslations("resilienceConnections");
const [data, setData] = useState<ResilienceConnectionsResponse | null>(null);
const [windowMs, setWindowMs] = useState(3600000);
const [pollError, setPollError] = useState(false);
const [loading, setLoading] = useState(true);
const [stopReason, setStopReason] = useState<"none" | "local_only" | "not_found">("none");
const [retryCount, setRetryCount] = useState(0);
const stoppedRef = useRef(false);
const abortRef = useRef<AbortController | null>(null);
const fetchData = useCallback(async (ws: number) => {
if (stoppedRef.current) return; // permanent stop after 404/403
if (document.hidden) return; // pause on tab hidden
// Abort any in-flight request before starting a new one
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
// Timeout: prevent hanging request from killing the polling chain
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, 25000); // < 30s poll interval
try {
const res = await fetch(`/api/resilience/connections?windowMs=${ws}`, {
cache: "no-store",
signal: controller.signal,
});
if (controller.signal.aborted) return; // guard: window switch cancelled this request
if (res.status === 404 || res.status === 403) {
stoppedRef.current = true;
setStopReason(res.status === 403 ? "local_only" : "not_found"); // explicit reason for UI
setPollError(true);
setLoading(false); // exit loading so pollError banner is visible
return;
}
if (!res.ok) {
setPollError(true); // surface 5xx as error (not "No connections")
setLoading(false);
return;
}
const json = await res.json();
if (controller.signal.aborted) return; // guard against stale response
// Capture receive time for client-side countdown (immune to clock skew).
// Spread to avoid mutating the parsed JSON object.
setData({ ...json, receivedAt: Date.now() } as ResilienceConnectionsResponse);
setLoading(false);
setPollError(false); // clear error on successful fetch
} catch (err) {
if (err?.name === "AbortError") {
// works across browsers, undici, and test mocks
if (timedOut) {
// Timeout: surface as error (not user-initiated cancel)
setPollError(true);
setLoading(false);
}
return;
}
// Network error: surface as error state
setPollError(true);
setLoading(false);
console.warn(
"[ResilienceConnectionsClient] fetch error:",
err instanceof Error ? err.message : String(err)
);
} finally {
clearTimeout(timeoutId);
}
}, []);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollGenRef = useRef(0);
// Retry handler: reset stopped state, trigger effect restart (defined before use in errorBanner)
const handleRetry = () => {
stoppedRef.current = false;
setStopReason("none"); // reset reason on retry
setRetryCount((c) => c + 1); // triggers useEffect cleanup + restart
};
useEffect(() => {
if (stoppedRef.current) return;
const gen = ++pollGenRef.current; // generation counter: stale chains self-terminate
const poll = async () => {
await fetchData(windowMs);
if (gen !== pollGenRef.current) return; // stale chain: stop rescheduling
if (stoppedRef.current) return;
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL_MS);
};
poll();
return () => {
// pollGenRef is a mutable counter (not a DOM node); cleanup intentionally
// bumps it so the captured `gen` goes stale and in-flight chains self-terminate.
// eslint-disable-next-line react-hooks/exhaustive-deps
pollGenRef.current++; // invalidate any in-flight chain
abortRef.current?.abort();
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
};
}, [windowMs, fetchData, retryCount]); // retryCount restarts chain on retry
// Resume on tab visible
useEffect(() => {
const onVis = () => {
if (!document.hidden && !stoppedRef.current) fetchData(windowMs);
};
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, [windowMs, fetchData]);
// Cleanup abort on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
const errorBanner = pollError ? (
<div
style={{
padding: "12px",
borderRadius: "8px",
background: "rgba(239,68,68,0.1)",
color: "var(--color-error)",
}}
>
{stopReason === "local_only"
? t("pollErrorLocalOnly") // LOCAL_ONLY gate rejection - page only accessible from localhost/LAN
: stoppedRef.current
? t("pollErrorStopped")
: t("pollErrorTransient")}
{stopReason !== "local_only" && (
<button type="button" onClick={handleRetry} style={{ marginLeft: "8px" }}>
{t("retry")}
</button>
)}
</div>
) : null;
if (pollError && !data) return errorBanner;
if (loading && !data)
return (
<EmptyState icon="shield" title={t("loading.title")} description={t("loading.description")} />
);
if (!data)
return (
<EmptyState icon="shield" title={t("empty.title")} description={t("empty.description")} />
);
// Show degradation banner above empty state when sources failed
if (data.connections.length === 0 && data.meta.degraded.length === 0)
return (
<EmptyState icon="shield" title={t("empty.title")} description={t("empty.description")} />
);
return (
<>
{errorBanner}
<div
style={{ display: "flex", gap: "16px", fontSize: "12px", color: "var(--color-text-muted)" }}
>
<span>
{t("summary.total", { count: data.meta.totalConnections })}
{data.meta.countsCapped ? ` (${t("summary.capped")})` : ""}
</span>
<span>{t("summary.coolingDown", { count: data.meta.coolingDownCount })}</span>
<span>{t("summary.unhealthyBreakers", { count: data.meta.unhealthyBreakerCount })}</span>
</div>
{data.meta.degraded.length > 0 && (
<div
style={{
padding: "12px",
borderRadius: "8px",
background: "rgba(245,158,11,0.1)",
color: "var(--color-warning)",
}}
>
{t("degraded", {
sources: data.meta.degraded.map((s) => t(`degraded.source.${s}`)).join(", "),
})}
</div>
)}
<div style={{ fontSize: "11px", color: "var(--color-text-muted)" }}>{t("pollingNote")}</div>
<ConnectionsTable
connections={data.connections}
receivedAt={data.receivedAt ?? Date.now()}
degraded={data.meta.degraded}
/>
<BreakerTimeline breakers={data.breakers} onWindowChange={setWindowMs} />
</>
);
}

View File

@@ -0,0 +1,14 @@
import { getTranslations } from "next-intl/server";
import ResilienceConnectionsClient from "./components/ResilienceConnectionsClient";
export const dynamic = "force-dynamic";
export default async function ResilienceConnectionsPage() {
const t = await getTranslations("resilienceConnections");
return (
<div style={{ display: "flex", flexDirection: "column", gap: "24px" }}>
<h1>{t("title")}</h1>
<ResilienceConnectionsClient />
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Card } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { formatRemaining } from "@/shared/utils/formatRemaining";
type CooldownItem = {
provider: string;
@@ -13,13 +14,6 @@ type CooldownItem = {
unavailableSince: string;
};
function formatRemaining(ms: number): string {
const totalSec = Math.max(0, Math.ceil(ms / 1000));
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}m ${sec}s`;
}
export default function ModelCooldownsCard() {
const t = useTranslations("settings");
const notify = useNotificationStore();

View File

@@ -2,7 +2,7 @@
import { useTranslations } from "next-intl";
import ThinkingBudgetTab from "../components/ThinkingBudgetTab";
import VisionBridgeSettingsTab from "../components/VisionBridgeSettingsTab";
import ModalityBridgeMovedCard from "../components/ModalityBridgeMovedCard";
import SystemPromptTab from "../components/SystemPromptTab";
import ResponsesStatePolicyTab from "../components/ResponsesStatePolicyTab";
import CodexFastTierTab from "../components/CodexFastTierTab";
@@ -19,7 +19,7 @@ export default function SettingsAiPage() {
<div className="space-y-6">
<p className="text-sm text-text-muted">{t("aiSettingsIntro")}</p>
<ThinkingBudgetTab />
<VisionBridgeSettingsTab />
<ModalityBridgeMovedCard />
<SystemPromptTab />
<ResponsesStatePolicyTab />
<UsageTokenBufferTab />

View File

@@ -0,0 +1,32 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
// One-cycle migration notice. Settings → AI no longer renders or writes the
// legacy controls; the dedicated Modality Bridge page owns these settings.
export default function ModalityBridgeMovedCard() {
const t = useTranslations("settings");
return (
<section className="rounded-lg border border-border/70 bg-surface/40 p-4">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-[21px] text-fuchsia-500" aria-hidden="true">
image_search
</span>
<div className="min-w-0 flex-1">
<h4 className="text-base font-semibold text-text-main">
{t("modalityBridgeMovedTitle")}
</h4>
<p className="mt-1 text-sm text-text-muted">{t("modalityBridgeMovedBody")}</p>
</div>
</div>
<div className="mt-4 border-t border-border pt-3 text-sm">
<Link href="/dashboard/settings/modality-bridge" className="text-primary hover:underline">
{t("modalityBridgeMovedCta")}
</Link>
</div>
</section>
);
}

View File

@@ -1,180 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Card, Toggle } from "@/shared/components";
import { VISION_BRIDGE_DEFAULTS } from "@/shared/constants/visionBridgeDefaults";
type SettingsState = {
visionBridgeEnabled: boolean;
visionBridgeModel: string;
visionBridgePrompt: string;
visionBridgeTimeout: number;
visionBridgeMaxImages: number;
};
export default function VisionBridgeSettingsTab() {
const t = useTranslations("settings");
const [settings, setSettings] = useState<SettingsState>({
visionBridgeEnabled: VISION_BRIDGE_DEFAULTS.enabled,
visionBridgeModel: VISION_BRIDGE_DEFAULTS.model,
visionBridgePrompt: VISION_BRIDGE_DEFAULTS.prompt,
visionBridgeTimeout: VISION_BRIDGE_DEFAULTS.timeoutMs,
visionBridgeMaxImages: VISION_BRIDGE_DEFAULTS.maxImagesPerRequest,
});
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/settings")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!data) return;
setSettings({
visionBridgeEnabled: data.visionBridgeEnabled ?? VISION_BRIDGE_DEFAULTS.enabled,
visionBridgeModel: data.visionBridgeModel ?? VISION_BRIDGE_DEFAULTS.model,
visionBridgePrompt: data.visionBridgePrompt ?? VISION_BRIDGE_DEFAULTS.prompt,
visionBridgeTimeout: data.visionBridgeTimeout ?? VISION_BRIDGE_DEFAULTS.timeoutMs,
visionBridgeMaxImages:
data.visionBridgeMaxImages ?? VISION_BRIDGE_DEFAULTS.maxImagesPerRequest,
});
})
.finally(() => setLoading(false));
}, []);
const updateSetting = async (patch: Partial<SettingsState>) => {
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (res.ok) {
setSettings((prev) => ({ ...prev, ...patch }));
}
} catch (error) {
console.error("Failed to update Vision Bridge settings:", error);
}
};
return (
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-fuchsia-500/10 text-fuchsia-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
image_search
</span>
</div>
<div>
<h3 className="text-lg font-semibold">{t("visionBridge")}</h3>
<p className="text-sm text-text-muted">{t("visionBridgeDesc")}</p>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-medium">{t("visionBridgeEnabledLabel")}</p>
<p className="text-sm text-text-muted">{t("visionBridgeEnabledDesc")}</p>
</div>
<Toggle
checked={settings.visionBridgeEnabled}
onChange={(checked) => updateSetting({ visionBridgeEnabled: checked })}
disabled={loading}
/>
</div>
<div className="pt-4 border-t border-border space-y-4">
<div>
<label className="block text-sm font-medium mb-1">{t("visionBridgeModel")}</label>
<input
type="text"
value={settings.visionBridgeModel}
onChange={(e) =>
setSettings((prev) => ({ ...prev, visionBridgeModel: e.target.value }))
}
onBlur={() => updateSetting({ visionBridgeModel: settings.visionBridgeModel.trim() })}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
placeholder={t("visionBridgeModelPlaceholder")}
/>
<p className="text-xs text-text-muted mt-1">{t("visionBridgeModelHint")}</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("visionBridgePrompt")}</label>
<textarea
value={settings.visionBridgePrompt}
onChange={(e) =>
setSettings((prev) => ({ ...prev, visionBridgePrompt: e.target.value }))
}
onBlur={() =>
updateSetting({ visionBridgePrompt: settings.visionBridgePrompt.trim() })
}
className="min-h-[100px] w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
placeholder={t("visionBridgePromptPlaceholder")}
/>
<p className="text-xs text-text-muted mt-1">{t("visionBridgePromptHint")}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">{t("visionBridgeTimeoutMs")}</label>
<input
type="number"
min={1000}
max={300000}
value={settings.visionBridgeTimeout}
onChange={(e) =>
setSettings((prev) => ({
...prev,
visionBridgeTimeout: Number.parseInt(e.target.value, 10) || 0,
}))
}
onBlur={() =>
updateSetting({
visionBridgeTimeout: Math.min(
300000,
Math.max(
1000,
settings.visionBridgeTimeout || VISION_BRIDGE_DEFAULTS.timeoutMs
)
),
})
}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t("visionBridgeMaxImagesPerRequest")}
</label>
<input
type="number"
min={1}
max={20}
value={settings.visionBridgeMaxImages}
onChange={(e) =>
setSettings((prev) => ({
...prev,
visionBridgeMaxImages: Number.parseInt(e.target.value, 10) || 0,
}))
}
onBlur={() =>
updateSetting({
visionBridgeMaxImages: Math.min(
20,
Math.max(
1,
settings.visionBridgeMaxImages || VISION_BRIDGE_DEFAULTS.maxImagesPerRequest
)
),
})
}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
/>
</div>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { useTranslations } from "next-intl";
interface ModalityBridgeComingSoonTabProps {
bodyKey: string;
}
export default function ModalityBridgeComingSoonTab({ bodyKey }: ModalityBridgeComingSoonTabProps) {
const t = useTranslations("settings");
return (
<div className="flex flex-col gap-2 rounded-card border border-dashed border-border p-6">
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
hourglass_top
</span>
<p className="text-sm">{t(bodyKey)}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
type BridgeKind = "vision" | "audio";
interface BridgeStats {
bridged: number;
cacheHits: number;
failures: number;
lastUsedAt: string | null;
}
interface ModalityBridgeStatsRowProps {
kind: BridgeKind;
}
function parseStats(value: unknown): BridgeStats | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const lastUsedAt = record.lastUsedAt;
if (lastUsedAt !== null && typeof lastUsedAt !== "string") return null;
if (
typeof record.bridged !== "number" ||
typeof record.cacheHits !== "number" ||
typeof record.failures !== "number"
) {
return null;
}
return {
bridged: record.bridged,
cacheHits: record.cacheHits,
failures: record.failures,
lastUsedAt: typeof lastUsedAt === "string" ? lastUsedAt : null,
};
}
export default function ModalityBridgeStatsRow({ kind }: ModalityBridgeStatsRowProps) {
const t = useTranslations("settings");
const [stats, setStats] = useState<BridgeStats | null>(null);
useEffect(() => {
let cancelled = false;
fetch("/api/modality-bridge/stats")
.then((response) => (response.ok ? response.json() : Promise.reject(new Error("fetch"))))
.then((data: unknown) => {
if (cancelled || !data || typeof data !== "object") return;
setStats(parseStats((data as Record<string, unknown>)[kind]));
})
.catch(() => {
if (!cancelled) setStats(null);
});
return () => {
cancelled = true;
};
}, [kind]);
if (!stats) return null;
const lastUsed = stats.lastUsedAt
? new Date(stats.lastUsedAt).toLocaleString()
: t("modalityBridgeStatsNever");
return (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-text-muted" aria-live="polite">
<span>
{stats.bridged} {t("modalityBridgeStatsBridged")}
</span>
<span>
{stats.cacheHits} {t("modalityBridgeStatsCacheHits")}
</span>
<span>
{stats.failures} {t("modalityBridgeStatsFailures")}
</span>
<span>
{t("modalityBridgeStatsLastUsed")}: {lastUsed}
</span>
</div>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
const SAMPLE_INPUT = {
model: "modality-bridge/self-test",
messages: [
{
role: "user",
content: [
{ type: "text", text: "What is in this image?" },
{
type: "image_url",
image_url: {
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
},
},
],
},
],
};
const DISABLED_GUARDRAILS = ["pii-masker", "prompt-injection", "credential-masker"];
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function findVisionMeta(value: unknown): Record<string, unknown> | null {
const body = asRecord(value);
if (!Array.isArray(body?.results)) return null;
for (const entry of body.results) {
const result = asRecord(entry);
if (result?.guardrail === "vision-bridge") return asRecord(result.meta);
}
return null;
}
function readErrorMessage(value: unknown): string | null {
const body = asRecord(value);
const error = asRecord(body?.error);
return typeof error?.message === "string" ? error.message : null;
}
export default function ModalityBridgeTestButton() {
const t = useTranslations("settings");
const [running, setRunning] = useState(false);
const [result, setResult] = useState<string | null>(null);
const runTest = async () => {
setRunning(true);
setResult(null);
try {
const response = await fetch("/api/guardrails/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
input: SAMPLE_INPUT,
disabledGuardrails: DISABLED_GUARDRAILS,
}),
});
const body: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(readErrorMessage(body) ?? `HTTP ${response.status}`);
}
const meta = findVisionMeta(body);
if (meta?.rerouted === true) {
setResult(
t("modalityBridgeTestReroute", {
model: String(meta.toModel ?? "unknown"),
})
);
} else if (typeof meta?.imagesProcessed === "number" && meta.imagesProcessed >= 1) {
setResult(
t("modalityBridgeTestOk", {
count: meta.imagesProcessed,
model: String(meta.visionModel ?? "unknown"),
})
);
} else {
setResult(t("modalityBridgeTestNoop"));
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setResult(t("modalityBridgeTestError", { message }));
} finally {
setRunning(false);
}
};
return (
<div className="flex flex-col items-start gap-2">
<button
type="button"
className="rounded-control border border-border px-3 py-2 text-sm font-medium hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50"
disabled={running}
onClick={() => void runTest()}
>
{t(running ? "modalityBridgeTestRunning" : "modalityBridgeTestButton")}
</button>
{result && (
<p className="text-xs text-text-muted" role="status">
{result}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,303 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Card, ModelSelectField, Toggle } from "@/shared/components";
import {
MODALITY_BRIDGE_DEFAULTS,
resolveVisionBridgeRuntimeSettings,
type VisionBridgeMode,
} from "@/shared/constants/modalityBridgeDefaults";
import { VISION_BRIDGE_DEFAULTS } from "@/shared/constants/visionBridgeDefaults";
import ModalityBridgeStatsRow from "./ModalityBridgeStatsRow";
import ModalityBridgeTestButton from "./ModalityBridgeTestButton";
interface VisionState {
modalityBridgeVisionEnabled: boolean;
modalityBridgeVisionMode: VisionBridgeMode;
modalityBridgeVisionModel: string;
modalityBridgeVisionTaskAware: boolean;
modalityBridgeVisionPrompt: string;
modalityBridgeVisionTimeout: number;
modalityBridgeVisionMaxImages: number;
modalityBridgeCacheEnabled: boolean;
modalityBridgeCacheTtlMinutes: number;
modalityBridgeCacheMaxEntries: number;
}
function fromApi(data: Record<string, unknown>): VisionState {
const runtime = resolveVisionBridgeRuntimeSettings(data);
return {
modalityBridgeVisionEnabled: runtime.enabled,
modalityBridgeVisionMode: runtime.mode,
modalityBridgeVisionModel: runtime.model,
modalityBridgeVisionTaskAware: runtime.taskAware,
modalityBridgeVisionPrompt: runtime.prompt,
modalityBridgeVisionTimeout: runtime.timeoutMs,
modalityBridgeVisionMaxImages: runtime.maxImages,
modalityBridgeCacheEnabled: runtime.cacheEnabled,
modalityBridgeCacheTtlMinutes: runtime.cacheTtlMinutes,
modalityBridgeCacheMaxEntries: runtime.cacheMaxEntries,
};
}
function clampNumber(raw: string, min: number, max: number, fallback: number): number {
const parsed = Number.parseInt(raw, 10);
return Math.min(max, Math.max(min, Number.isFinite(parsed) ? parsed : fallback));
}
export default function ModalityBridgeVisionTab() {
const t = useTranslations("settings");
const [settings, setSettings] = useState<VisionState | null>(null);
useEffect(() => {
let cancelled = false;
fetch("/api/settings")
.then((response) => (response.ok ? response.json() : null))
.then((data: unknown) => {
if (cancelled) return;
setSettings(fromApi(asSettingsRecord(data)));
})
.catch(() => {
if (!cancelled) setSettings(fromApi({}));
});
return () => {
cancelled = true;
};
}, []);
const update = async (patch: Partial<VisionState>) => {
try {
const response = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (response.ok) {
setSettings((previous) => (previous ? { ...previous, ...patch } : previous));
}
} catch (error) {
console.error("Failed to update Modality Bridge settings:", error);
}
};
if (!settings) return null;
const setLocal = (patch: Partial<VisionState>) => {
setSettings((previous) => (previous ? { ...previous, ...patch } : previous));
};
const commitNumber = (
key:
| "modalityBridgeVisionTimeout"
| "modalityBridgeVisionMaxImages"
| "modalityBridgeCacheTtlMinutes"
| "modalityBridgeCacheMaxEntries",
raw: string,
min: number,
max: number,
fallback: number
) => {
const value = clampNumber(raw, min, max, fallback);
setLocal({ [key]: value });
void update({ [key]: value });
};
return (
<Card
title={t("modalityBridgeVisionTitle")}
subtitle={t("modalityBridgeVisionDesc")}
icon="image_search"
>
<div className="space-y-4">
<Toggle
checked={settings.modalityBridgeVisionEnabled}
onChange={(checked) => void update({ modalityBridgeVisionEnabled: checked })}
label={t("visionBridgeEnabledLabel")}
description={t("visionBridgeEnabledDesc")}
/>
<div>
<label className="text-sm font-medium" htmlFor="modality-bridge-mode">
{t("modalityBridgeMode")}
</label>
<select
id="modality-bridge-mode"
data-testid="modality-bridge-mode"
className="mt-1 w-full rounded-control border border-border bg-surface px-3 py-2 text-sm"
value={settings.modalityBridgeVisionMode}
onChange={(event) =>
void update({ modalityBridgeVisionMode: event.target.value as VisionBridgeMode })
}
>
<option value="auto">{t("modalityBridgeModeAuto")}</option>
<option value="describe">{t("modalityBridgeModeDescribe")}</option>
<option value="reroute">{t("modalityBridgeModeReroute")}</option>
</select>
<p className="mt-1 text-xs text-text-muted">
{settings.modalityBridgeVisionMode === "auto" && t("modalityBridgeModeAutoHint")}
{settings.modalityBridgeVisionMode === "describe" &&
t("modalityBridgeModeDescribeHint")}
{settings.modalityBridgeVisionMode === "reroute" && t("modalityBridgeModeRerouteHint")}
</p>
</div>
<ModelSelectField
label={t("modalityBridgeVisionModel")}
value={settings.modalityBridgeVisionModel}
placeholder={t("modalityBridgeVisionModelAuto")}
allowEmpty
onChange={(value) => void update({ modalityBridgeVisionModel: value })}
className="text-sm"
/>
<Toggle
checked={settings.modalityBridgeVisionTaskAware}
onChange={(checked) => void update({ modalityBridgeVisionTaskAware: checked })}
label={t("modalityBridgeTaskAware")}
description={t("modalityBridgeTaskAwareDesc")}
/>
<div>
<label className="block text-sm font-medium" htmlFor="modality-bridge-prompt">
{t("modalityBridgePrompt")}
</label>
<textarea
id="modality-bridge-prompt"
className="mt-1 min-h-[100px] w-full rounded-control border border-border bg-surface px-3 py-2 text-sm"
value={settings.modalityBridgeVisionPrompt}
onChange={(event) =>
setLocal({ modalityBridgeVisionPrompt: event.currentTarget.value })
}
onBlur={(event) => {
const value = event.currentTarget.value.trim();
setLocal({ modalityBridgeVisionPrompt: value });
void update({ modalityBridgeVisionPrompt: value });
}}
/>
</div>
<details className="rounded-control border border-border p-3">
<summary className="cursor-pointer text-sm font-medium">
{t("modalityBridgeAdvanced")}
</summary>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<NumberField
testId="modality-bridge-timeout"
label={t("modalityBridgeTimeoutMs")}
min={1000}
max={300000}
value={settings.modalityBridgeVisionTimeout}
onChange={(value) => setLocal({ modalityBridgeVisionTimeout: value })}
onBlur={(raw) =>
commitNumber(
"modalityBridgeVisionTimeout",
raw,
1000,
300000,
VISION_BRIDGE_DEFAULTS.timeoutMs
)
}
/>
<NumberField
testId="modality-bridge-max-images"
label={t("modalityBridgeMaxImages")}
min={1}
max={20}
value={settings.modalityBridgeVisionMaxImages}
onChange={(value) => setLocal({ modalityBridgeVisionMaxImages: value })}
onBlur={(raw) =>
commitNumber(
"modalityBridgeVisionMaxImages",
raw,
1,
20,
VISION_BRIDGE_DEFAULTS.maxImagesPerRequest
)
}
/>
<div className="md:col-span-2">
<Toggle
checked={settings.modalityBridgeCacheEnabled}
onChange={(checked) => void update({ modalityBridgeCacheEnabled: checked })}
label={t("modalityBridgeCacheEnabled")}
description={t("modalityBridgeCacheEnabledDesc")}
/>
</div>
<NumberField
testId="modality-bridge-cache-ttl"
label={t("modalityBridgeCacheTtlMinutes")}
min={1}
max={1440}
value={settings.modalityBridgeCacheTtlMinutes}
onChange={(value) => setLocal({ modalityBridgeCacheTtlMinutes: value })}
onBlur={(raw) =>
commitNumber(
"modalityBridgeCacheTtlMinutes",
raw,
1,
1440,
MODALITY_BRIDGE_DEFAULTS.cacheTtlMinutes
)
}
/>
<NumberField
testId="modality-bridge-cache-max-entries"
label={t("modalityBridgeCacheMaxEntries")}
min={10}
max={5000}
value={settings.modalityBridgeCacheMaxEntries}
onChange={(value) => setLocal({ modalityBridgeCacheMaxEntries: value })}
onBlur={(raw) =>
commitNumber(
"modalityBridgeCacheMaxEntries",
raw,
10,
5000,
MODALITY_BRIDGE_DEFAULTS.cacheMaxEntries
)
}
/>
</div>
</details>
<ModalityBridgeStatsRow kind="vision" />
<ModalityBridgeTestButton />
</div>
</Card>
);
}
function asSettingsRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
interface NumberFieldProps {
testId: string;
label: string;
min: number;
max: number;
value: number;
onChange: (value: number) => void;
onBlur: (raw: string) => void;
}
function NumberField({ testId, label, min, max, value, onChange, onBlur }: NumberFieldProps) {
return (
<label className="block text-sm font-medium">
{label}
<input
type="number"
data-testid={testId}
min={min}
max={max}
value={value}
onChange={(event) => onChange(Number.parseInt(event.currentTarget.value, 10) || 0)}
onBlur={(event) => onBlur(event.currentTarget.value)}
className="mt-1 w-full rounded-control border border-border bg-surface px-3 py-2 text-sm"
/>
</label>
);
}

View File

@@ -0,0 +1,81 @@
"use client";
import { Suspense, useMemo } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import ModalityBridgeComingSoonTab from "@/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeComingSoonTab";
import ModalityBridgeVisionTab from "@/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeVisionTab";
type TabId = "vision" | "audio" | "video";
const TABS: ReadonlyArray<{ id: TabId; labelKey: string }> = [
{ id: "vision", labelKey: "modalityBridgeVisionTab" },
{ id: "audio", labelKey: "modalityBridgeAudioTab" },
{ id: "video", labelKey: "modalityBridgeVideoTab" },
];
function ModalityBridgePageContent() {
const t = useTranslations("settings");
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const activeTab = useMemo<TabId>(() => {
const requested = searchParams.get("tab") as TabId | null;
return requested && TABS.some((tab) => tab.id === requested) ? requested : "vision";
}, [searchParams]);
const handleTabChange = (tab: TabId) => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", tab);
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
};
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">{t("modalityBridgeIntro")}</p>
<div
className="flex gap-1 overflow-x-auto border-b border-border"
role="tablist"
aria-label={t("modalityBridgeSubTabsAria")}
>
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls="modality-bridge-tabpanel"
onClick={() => handleTabChange(tab.id)}
className={`whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-text-muted hover:text-text"
}`}
>
{t(tab.labelKey)}
</button>
))}
</div>
<div id="modality-bridge-tabpanel" role="tabpanel">
{activeTab === "vision" && <ModalityBridgeVisionTab />}
{activeTab === "audio" && (
<ModalityBridgeComingSoonTab bodyKey="modalityBridgeAudioComingSoon" />
)}
{activeTab === "video" && (
<ModalityBridgeComingSoonTab bodyKey="modalityBridgeVideoComingSoon" />
)}
</div>
</div>
);
}
export default function ModalityBridgePage() {
return (
<Suspense fallback={null}>
<ModalityBridgePageContent />
</Suspense>
);
}

View File

@@ -8,6 +8,8 @@ const LEGACY_TAB_ROUTES: Record<string, string> = {
"feature-flags": "/dashboard/settings/feature-flags",
cache: "/dashboard/settings/cache",
general: "/dashboard/settings/general",
modalityBridge: "/dashboard/settings/modality-bridge",
"modality-bridge": "/dashboard/settings/modality-bridge",
resilience: "/dashboard/settings/resilience",
routing: "/dashboard/settings/routing",
security: "/dashboard/settings/security",

View File

@@ -20,7 +20,142 @@ function resolveProviderSlug(connection: Record<string, unknown> | null): string
return "";
}
// ─── POST: Start login flow ────────────────────────────────────────────────
function isAdobeFireflyProvider(
connection: { provider?: unknown } | null,
providerSlug: string
): boolean {
const raw = String(connection?.provider || "").trim();
return ADOBE_FIREFLY_SLUGS.has(raw) || ADOBE_FIREFLY_SLUGS.has(providerSlug);
}
/**
* Persist JWT + Cookie the way desktop clients (and generate) expect:
* multi-line api_key, plus camelCase providerSpecificData for updateProviderConnection.
*/
async function persistAdobeFireflyCredentials(
connectionId: string,
opts: {
accessToken?: string;
cookie?: string;
account?: string;
arpSessionId?: string;
}
): Promise<{
accessToken: string;
cookie: string;
credential: string;
account: string;
}> {
const accessToken = String(opts.accessToken || "").trim();
const cookie = String(opts.cookie || "").trim();
const account = String(opts.account || "").trim();
const credential =
accessToken && cookie
? `${accessToken}\n${cookie}`
: accessToken ||
cookie ||
JSON.stringify({
mode: "browser-profile",
account,
signedInAt: Date.now(),
});
const marker = {
mode: "browser-profile",
account,
signedInAt: Date.now(),
arpSessionId: String(opts.arpSessionId || ""),
};
try {
// camelCase only — updateProviderConnection / encryptConnectionFields read apiKey +
// providerSpecificData (snake_case keys are silently ignored and never persisted).
await updateProviderConnection(connectionId, {
apiKey: credential,
providerSpecificData: {
...marker,
cookie: cookie || credential,
access_token: accessToken || undefined,
},
});
} catch {
/* non-fatal — return credentials to the host app either way */
}
return { accessToken, cookie, credential, account };
}
function adobeFireflySuccessResponse(data: {
accessToken: string;
cookie: string;
credential: string;
account: string;
arpSessionId?: string;
via: "pure-cdp";
}): NextResponse {
return NextResponse.json({
success: true,
account: data.account || undefined,
accessToken: data.accessToken || undefined,
cookie: data.cookie || undefined,
arpSessionId: data.arpSessionId || undefined,
credential: data.credential,
credentials: {
access_token: data.accessToken || undefined,
cookie: data.cookie || undefined,
},
via: data.via,
persisted: true,
});
}
/**
* Adobe Firefly browser sign-in:
* pure system Chrome/Edge CDP only (packaged-safe, no Playwright/browser bundle).
*/
async function loginAdobeFirefly(
connectionId: string,
body: { timeout?: unknown; freshSession?: unknown }
): Promise<NextResponse> {
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
const freshSession = typeof body.freshSession === "boolean" ? body.freshSession : true;
// Pure system-browser CDP is the packaged-safe implementation. Do not open a second browser
// after failure: it creates ambiguous success/error races and the packaged runtime has no
// reliable Playwright browser bundle.
// startAdobeFireflyBrowserLogin always kills its Chrome tree in `finally` (no orphans).
try {
const { startAdobeFireflyBrowserLogin } =
await import("@omniroute/open-sse/services/adobeFireflyBrowserLogin.ts");
const pure = await startAdobeFireflyBrowserLogin(timeout, {
sessionKey: connectionId,
freshSession,
});
if (pure.success && pure.credentials?.accessToken) {
const persisted = await persistAdobeFireflyCredentials(connectionId, {
accessToken: pure.credentials.accessToken,
cookie: pure.credentials.cookie,
account: pure.account,
});
return adobeFireflySuccessResponse({
...persisted,
via: "pure-cdp",
});
}
return NextResponse.json(
{
success: false,
error: pure.error || "Adobe Firefly sign-in did not capture an authenticated IMS JWT.",
},
{ status: 400 }
);
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err);
return NextResponse.json({ success: false, error: msg }, { status: 400 });
}
}
// --- POST: Start login flow -------------------------------------------------
export async function POST(
req: NextRequest,
@@ -35,69 +170,35 @@ export async function POST(
return NextResponse.json({ success: false, error: "Provider not found" }, { status: 404 });
}
const body = await req.json().catch(() => ({}));
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
const body = (await req.json().catch(() => ({}))) as {
timeout?: unknown;
freshSession?: unknown;
};
const providerSlug = resolveProviderSlug(provider as Record<string, unknown>);
try {
// Adobe Firefly is special: the IMS JWT is only ever in the Authorization
// header of firefly-3p.ff.adobe.io XHRs (never cookies/localStorage), so
// the generic cookie-extraction service cannot capture it. Use a dedicated
// Playwright service that intercepts that request instead.
if (ADOBE_FIREFLY_SLUGS.has(providerSlug)) {
const { startAdobeFireflyBrowserLogin } =
await import("@omniroute/open-sse/services/adobeFireflyBrowserLogin.ts");
const fireflyResult = await startAdobeFireflyBrowserLogin(timeout);
if (fireflyResult.success && fireflyResult.credentials) {
const credentials = fireflyResult.credentials;
try {
// Store the JWT in apiKey (where resolveAdobeAccessToken looks first)
// and the cookie + access_token in providerSpecificData (camelCase —
// updateProviderConnection ignores snake_case keys).
const providerSpecificData: Record<string, string> = {};
if (credentials.accessToken) {
providerSpecificData.access_token = credentials.accessToken;
}
if (credentials.cookie) {
providerSpecificData.cookie = credentials.cookie;
}
await updateProviderConnection(id, {
apiKey: credentials.accessToken || "",
providerSpecificData,
});
return NextResponse.json({
success: true,
accessToken: credentials.accessToken || "",
cookie: credentials.cookie || "",
account: fireflyResult.account || "",
credentials: providerSpecificData,
persisted: true,
});
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err);
return NextResponse.json(
{ success: false, error: `Extracted but failed to persist: ${msg}` },
{ status: 500 }
);
}
}
// Adobe Firefly: dedicated JWT capture (never cookies/localStorage alone).
if (isAdobeFireflyProvider(provider as { provider?: unknown }, providerSlug)) {
try {
return await loginAdobeFirefly(id, body);
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err);
return NextResponse.json(
{ success: false, error: fireflyResult.error || "Adobe Firefly sign-in failed" },
{ status: 400 }
{ success: false, error: `Adobe Firefly sign-in error: ${msg}` },
{ status: 500 }
);
}
}
try {
// Generic web-cookie path: pass the provider SLUG (not the DB id) so
// TOKEN_EXTRACTION_CONFIGS can find the extraction config.
// Bug: the previous code passed `id` (connection UUID), so the lookup always
// missed and returned "No extraction config" without launching a browser.
const { inAppLoginService } = await import("@omniroute/open-sse/services/inAppLoginService.ts");
const result = await inAppLoginService.startLogin(providerSlug || id, { timeout });
const result = await inAppLoginService.startLogin(providerSlug || id, {
timeout: typeof body.timeout === "number" ? body.timeout : undefined,
});
// Persist credentials if extraction succeeded
if (result.success && result.credentials) {

View File

@@ -0,0 +1,246 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getRawProviderConnections, getProviderConnectionsCount } from "@/lib/db/providers";
import { getAllCircuitBreakerStatuses } from "@/shared/utils/circuitBreaker";
import { resolveProviderId } from "@/shared/constants/providers";
import { TERMINAL_CONNECTION_STATUSES } from "@/lib/quota/connectionRecovery";
import { sanitizeErrorMessage, buildErrorBody } from "@omniroute/open-sse/utils/error";
import {
getAllModelLockouts,
cooldownUntilMs,
type ModelLockoutInfo,
} from "@omniroute/open-sse/services/accountFallback";
import type {
ResilienceConnectionsResponse,
ConnectionState,
BreakerWithHistory,
} from "@/types/resilience";
// Explicit column whitelist -- getRawProviderConnections() DEFAULTS TO SELECT *,
// so passing columns is MANDATORY to avoid leaking api_key, access_token,
// refresh_token, id_token, email, scope, project_id, provider_specific_data, last_error.
const CONNECTION_COLUMNS: string[] = [
"id",
"provider",
"name",
"auth_type",
"priority",
"is_active",
"test_status",
"error_code",
"last_error_type",
"last_error_at",
"backoff_level",
"rate_limited_until",
"last_used_at",
];
const CONNECTION_LIMIT = 1000; // shared with UI cap indicator
const querySchema = z.object({
windowMs: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
z.coerce.number().int().min(0).max(86400000).default(3600000)
),
provider: z.string().trim().min(1).max(64).optional(),
});
function categorizeErrorCode(code: string | number): string {
const s = String(code).toLowerCase();
if (s.includes("rate") || s.includes("429") || s.includes("quota")) return "rate_limit";
if (s.includes("auth") || s.includes("401") || s.includes("403") || s.includes("key"))
return "auth";
if (s.includes("500") || s.includes("502") || s.includes("503") || s.includes("504"))
return "server";
if (s.includes("404") || s.includes("not_found") || s.includes("model")) return "not_found";
return "other";
}
function toConnectionState(
row: Record<string, unknown>,
breakersMap: Map<string, BreakerWithHistory>,
lockoutsMap: Map<string, ModelLockoutInfo[]>,
now: number // server timestamp captured before fetch (avoids drift)
): ConnectionState {
// getRawProviderConnections returns camelCase keys (via rowToCamel)
const provider = String(row.provider ?? "");
const breaker = breakersMap.get(resolveProviderId(provider)) ?? null;
const lockouts = lockoutsMap.get(String(row.id ?? "")) ?? [];
const testStatus = row.testStatus ? String(row.testStatus).trim().toLowerCase() : null; // normalize to match TERMINAL_CONNECTION_STATUSES
const rateLimitedUntil = row.rateLimitedUntil ? String(row.rateLimitedUntil) : null;
// cooldownUntilMs() handles both ISO strings and numeric epoch TEXT (#3954)
const rawCooldown = rateLimitedUntil ? cooldownUntilMs(rateLimitedUntil) - now : 0;
const cooldownRemainingMs = Number.isFinite(rawCooldown) ? Math.max(0, rawCooldown) : 0;
// Derive connection status for UI badge (terminal states take priority over cooldown)
let connectionStatus: ConnectionState["connectionStatus"] = "healthy";
if (testStatus && TERMINAL_CONNECTION_STATUSES.has(testStatus)) {
connectionStatus = "terminal"; // permanent unavailability takes priority
} else if (breaker?.state === "OPEN") {
connectionStatus = "circuit_open";
} else if (cooldownRemainingMs > 0) {
connectionStatus = "cooling_down";
}
return {
id: String(row.id ?? ""),
provider,
name: row.name != null && row.name !== "" ? String(row.name) : null,
authType: String(row.authType ?? ""),
priority: Number(row.priority ?? 0),
isActive: Boolean(row.isActive),
connectionStatus,
rateLimitedUntil,
backoffLevel: Number(row.backoffLevel ?? 0),
testStatus,
lastErrorType: row.lastErrorType ? String(row.lastErrorType) : null,
lastErrorAt: row.lastErrorAt ? String(row.lastErrorAt) : null,
errorCode: row.errorCode != null ? categorizeErrorCode(String(row.errorCode)) : null, // coarse category, not raw upstream code
lastUsedAt: row.lastUsedAt ? String(row.lastUsedAt) : null,
cooldownRemainingMs,
isCoolingDown: cooldownRemainingMs > 0,
breaker: breaker
? {
state: breaker.state,
failureCount: breaker.failureCount,
retryAfterMs: breaker.retryAfterMs,
lastFailureKind: breaker.lastFailureKind,
}
: null,
lockouts: lockouts.map((l) => ({
model: l.model,
reason: l.reason,
remainingMs: l.remainingMs,
})),
};
}
export async function GET(req: NextRequest) {
try {
const params = querySchema.safeParse(Object.fromEntries(new URL(req.url).searchParams));
if (!params.success) {
return NextResponse.json(
buildErrorBody(400, params.error.issues[0]?.message ?? "Invalid query parameters"),
{ status: 400 }
);
}
const { windowMs, provider } = params.data;
const degraded: string[] = [];
// Fetch all three sources independently (partial degradation)
let rawConnections: Record<string, unknown>[] = [];
try {
rawConnections = await getRawProviderConnections(
{ provider },
CONNECTION_LIMIT,
undefined,
CONNECTION_COLUMNS
);
} catch (err) {
degraded.push("database");
console.error("[API] resilience/connections database error:", err);
}
// NOTE: getAllCircuitBreakerStatuses() calls getStatus() internally. If a single
// getStatus() throws (e.g., onStateChange callback error), the entire function
// throws before reaching our loop. This is an accepted limitation - per-item
// fault tolerance is not possible with the current getAllCircuitBreakerStatuses()
// API. The outer try/catch handles this case.
let breakers: BreakerWithHistory[] = [];
try {
const allStatuses = getAllCircuitBreakerStatuses();
breakers = allStatuses.map((status) => ({
name: status.name,
state: status.state,
failureCount: status.failureCount,
retryAfterMs: status.retryAfterMs,
lastFailureKind: status.lastFailureKind,
transitionHistory: status.transitionHistory ?? [],
}));
} catch (err) {
degraded.push("circuitBreaker");
console.error("[API] resilience/connections breaker module error:", err);
}
// Capture window timestamps AFTER source fetches complete (includes lazy recovery transitions)
const now = Date.now();
const sinceMs = windowMs ? now - windowMs : 0;
// Apply window filter to all breakers.
// Both `now` and breaker transition timestamps come from the same Node.js process,
// so clock skew is negligible -- no future-buffer needed.
breakers = breakers.map((b) => ({
...b,
transitionHistory:
windowMs > 0
? b.transitionHistory.filter((tr) => tr.timestamp >= sinceMs && tr.timestamp <= now)
: b.transitionHistory,
}));
let lockouts: ModelLockoutInfo[] = [];
try {
lockouts = getAllModelLockouts();
} catch (err) {
degraded.push("modelLockouts");
console.error("[API] resilience/connections lockout module error:", err);
}
// Join all sources AFTER all fetches complete (so toConnectionState has full context)
// NOTE: If multiple breaker instances resolve to the same canonical provider (e.g., alias + canonical),
// the last one wins in the map. This is an accepted limitation -- connections typically have one
// active breaker per provider. The top-level breakers[] array preserves all instances.
const breakersMap = new Map(breakers.map((b) => [resolveProviderId(b.name), b]));
const lockoutsMap = new Map<string, ModelLockoutInfo[]>();
for (const l of lockouts) {
const arr = lockoutsMap.get(l.connectionId) ?? [];
arr.push(l);
lockoutsMap.set(l.connectionId, arr);
}
const connections = rawConnections.map((row) =>
toConnectionState(row, breakersMap, lockoutsMap, now)
);
// Total count (separate query; falls back to connections.length on failure)
let totalConnections = connections.length;
let countFailed = false;
try {
totalConnections = getProviderConnectionsCount({ provider });
} catch (err) {
// Non-critical: connections.length is acceptable fallback
console.error("[API] resilience/connections count error:", err);
degraded.push("count"); // surface count degradation for UI transparency
countFailed = true;
}
// When totalConnections > CONNECTION_LIMIT, counts reflect only the first LIMIT rows
const coolingDownCount = connections.filter((c) => c.isCoolingDown).length;
// Count all non-healthy breaker states (OPEN + HALF_OPEN + DEGRADED) for accurate summary
const unhealthyBreakerCount = connections.filter(
(c) =>
c.breaker?.state === "OPEN" ||
c.breaker?.state === "HALF_OPEN" ||
c.breaker?.state === "DEGRADED"
).length;
// Flag indicates counts may be incomplete due to LIMIT capping or count query failure.
// When countFailed=true, we returned a limited page and can't verify the true total,
// so treat as potentially capped to give the client an honest signal.
const countsCapped =
totalConnections > CONNECTION_LIMIT ||
(countFailed && connections.length === CONNECTION_LIMIT);
// Assemble window metadata (absolute timestamps)
const windowMeta = { sinceMs, untilMs: now, now: now };
// Assemble response (top-level fields, no `data` wrapper -- matches ResilienceConnectionsResponse)
const response: ResilienceConnectionsResponse = {
connections,
breakers,
window: windowMeta,
meta: { totalConnections, coolingDownCount, unhealthyBreakerCount, countsCapped, degraded },
};
return NextResponse.json(response);
} catch (err) {
console.error("[API] resilience/connections unexpected error:", err);
return NextResponse.json(buildErrorBody(500, sanitizeErrorMessage(err)), { status: 500 });
}
}

View File

@@ -21,6 +21,7 @@ import { extractChatMessage, sendTelegramMessage } from "@/lib/telegram/botApi";
import { getTelegramBotToken, isTelegramEnabled } from "@/lib/telegram/config";
import { verifyInitData, parseInitData } from "@/lib/telegram/initData";
import { proxyChat } from "@/lib/telegram/chatProxy";
import { formatTelegramGatewayError } from "@/lib/telegram/errorMessage";
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
/**
@@ -151,7 +152,7 @@ async function handleAndReply(chatId: number, text: string, messageId?: number):
try {
await sendTelegramMessage({
chat_id: chatId,
text: `⚠️ Gateway error: ${(err as Error)?.message || "unknown"}`,
text: formatTelegramGatewayError(err),
});
} catch {
// Nothing more we can do — the reply channel is down.

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