Compare commits

..

106 Commits

Author SHA1 Message Date
adevwithpurpose
cfd15aef16 Merge remote-tracking branch 'origin/release/v3.8.50' into fix/release-v3.8.50-basereds 2026-08-18 12:02:00 -03:00
phatchau036
72eff76910 fix(oauth): route zed-hosted native-app callback back to the dashboard port (#10517)
* fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190)

Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13
(with monaco-editor scoped override). Closes Dependabot #189, #190.

Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge —
awaiting Dependabot re-scan.

npm audit → 0 vulnerabilities.

* fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks)

_tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing
slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential
_tasks symlink can slip in via git add -A and, once pulled, checkout materializes
it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks
ignores the symlink too, preventing re-capture.

* Hide health-check excluded models from /v1/models catalog (#10026)

Mirror the request-time exclusion rule (provider_specific_data.excludedModels)
in the unified catalog builder: a model is hidden when its provider has
connections but none of them is eligible for it. Applied across the
PROVIDER_MODELS, synced, custom, alias-backed, and managed-fallback loops
so ghost models no longer appear as available.

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

* fix(models): memoize getModelsDevPricing (event loop / healthz) (#10055)

* fix(models): memoize getModelsDevPricing for /v1/models catalog

resolveCatalogPricing called getModelsDevPricing once per model while
building GET /v1/models. Each call re-scanned models_dev_pricing and
JSON.parsed every row (~10k SQL scans + multi-GB parse work), pegging
the event loop so even /healthz timed out (#9685, #10052).

Memoize the parsed map until saveModelsDevPricing / clearModelsDevPricing
and add a unit test for invalidation.

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>

* fix(db): invalidate modelsDevPricing cache on DB reset (#10055)

Copilot review fixes:
1. Register invalidateModelsDevPricingCache() with DB state reset system
   so resetDbInstance() clears the process-local memo, preventing stale
   pricing data from surviving across DB reset/restore operations.
2. Add test assertion verifying DB reset bypasses the memo (Copilot #10055).

The process-local memo at modelsDevSync.ts:204 caches getModelsDevPricing()
results until saveModelsDevPricing()/clearModelsDevPricing() to avoid
re-scanning all pricing rows on every /v1/models request. Without this hook,
backup restore and test DB resets would serve stale cached data from the
previous connection.

Tests: npm run test:unit:serial -- tests/unit/modelsDevSync-extended.test.ts

---------

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* fix(oauth): route Zed hosted sign-in callback back to the dashboard port

Zed's native-app sign-in always redirects the browser to the loopback port
sent as native_app_port (hardcoded default 58443), where nothing listens:
the browser shows "site can't be reached" and the login looks broken even
though the token is in the URL. The manual paste fallback was broken too -
handleManualSubmit requires a ?code= param that Zed's callback
(user_id + access_token) never carries, so the flow could never complete.

- zed-hosted: derive native_app_port from the dashboard's own loopback
  port so the redirect lands back on OmniRoute; remote/LAN origins keep
  the old default port and the paste flow
- app root: forward ?user_id=...&access_token=... to the /callback relay
  instead of dropping the query string on the /dashboard redirect
- /callback relay: recognize the Zed payload (no code param) and relay the
  full URL as the exchange payload; allow postMessage to both loopback
  spellings (localhost/127.0.0.1) of the same port
- OAuthModal: zed-hosted popup auto-completes on true localhost; the
  manual paste path passes the full URL through to the exchange instead
  of erroring with "No authorization code found"
- manual input panel: zed-hosted-specific placeholder and hint
- tests: extend the postMessage scope guard with the loopback same-port
  trusted origins

* changelog: fragment for #10517

* fix(oauth): derive Zed native_app_port from server config, not browser scheme/port

resolveDashboardLoopbackPort() previously re-derived the dashboard's loopback
port from the browser-supplied redirectUri (window.location.port ||
protocol === "https:" ? "443" : "80"), which produced http://127.0.0.1:443/
native-app redirects when the dashboard was reached over HTTPS on its
implicit default port (e.g. behind a local TLS-terminating reverse proxy) -
a scheme/port mismatch, since Zed's own redirect is always plain http and
nothing serves plain HTTP on 443 in that scenario.

This code runs server-side (in the OAuth authorize API route), so once the
redirect URI's hostname is confirmed loopback it now uses the OmniRoute
process's own authoritative listening port via getRuntimePorts()
(OMNIROUTE_PORT/PORT/DASHBOARD_PORT) instead of re-deriving it from the
browser-observed scheme/port. Non-loopback (remote/LAN) redirect URIs still
return null and fall back to the manual paste flow.

Adds tests/unit/zed-hosted-loopback-port-derivation.test.ts (8 cases)
covering the port-derivation logic directly, including the HTTPS-default-port
mismatch scenario that motivated this fix, env-var precedence, IPv6 loopback,
non-loopback/remote fallback, and buildAuthUrl's native_app_port wiring.

Also rebaselines config/quality/file-size-baseline.json for OAuthModal.tsx's
own growth from this PR's earlier commit (1134->1149 gate units) - legitimate
zed-hosted callback wiring at the existing provider-switch chokepoint, not
extractable without a broader modal decomposition (tracked in #3501).

The live Zed OAuth handshake itself (root -> /callback -> OAuthModal exchange
against the real zed.dev endpoint) still needs a documented VPS smoke test
per Hard Rule #18; this fix covers the TDD-able port-derivation logic that
motivated the change.

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

---------

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@outlook.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: ritheshcn25 <rithesh.chandran@snb.ca>
Co-authored-by: ritheshcn25 <ritheshcn25@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 11:56:00 -03:00
adevwithpurpose
59c8a9afc9 fix(db): renumber exclusive_connection_leases migration 155 -> 157
Two independently-merged PRs (#10263 agentic-conversation-tracking-v4
and #10362 exclusive-managed-session-leases) each picked migration
slot 155 against different base states, landing a real collision on
release/v3.8.50 (155_agentic_conversations.sql vs
155_exclusive_connection_leases.sql; #10263 also claimed 156 via
156_conversation_turn_nodes.sql). Renumbered #10362's migration to the
next free slot (157) and updated its own regression test
(exclusive-connection-leases.test.ts) that asserted the literal
filename/slot. No retroactive guard needed: CREATE TABLE IF NOT
EXISTS is idempotent under either number.

Confirmed via check-migration-numbering.mjs (154 migrations, 0
duplicates) and the full exclusive-connection-leases test suite
(11/11 pass).
2026-08-18 11:55:08 -03:00
Xiangzhe
72d761fb50 docs(cli): document run/configure surface, Gemini launcher and smoke harness across README and guides
- README: 'run any supported CLI in one command' block (7 targets incl. gemini),
  updated one-command setup bullet with run/configure
- CLI-INTEGRATIONS: gemini in the master table + run examples + base-URL row
  (GOOGLE_GEMINI_BASE_URL → /v1beta), opt-in smoke sweep section
- REMOTE-MODE: 'launching a CLI against the remote' section (run + contexts)
- CLI-TOOLS: gemini install step in Quick Start
- ENVIRONMENT/.env.example: CLI_AIDER_BIN, CLI_GOOSE_BIN, CLI_GEMINI_BIN
- API_REFERENCE: apply endpoint row documents dryRun/422/migration contract
- smoke harness fixes proven against a live local OmniRoute: node:test treats
  timeout:0 as 'time out immediately' (sized budget from the per-target cap),
  and resolve on child 'exit' instead of 'close' so grandchildren holding the
  stdio pipes cannot hang a target (qwen was blocked 431s past its 120s cap).
  Live evidence: gemini exit=0 pass via /v1beta against localhost; all four
  installed CLIs (codex/opencode/qwen/gemini) reached the upstream end-to-end
  with correctly classified upstream errors (free-tier 429 / ddgw 400).
2026-08-18 11:50:01 -03:00
Diego Rodrigues de Sa e Souza
8dec11530e fix(docker): use lightweight /healthz for container lifecycle healthcheck instead of the heavy monitoring route (#10311) (#10504)
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 11:44:18 -03:00
Abhishek4512009
885cd8c411 feat(gemini-web): expose image generation through /v1/images/generations (closes #10466) (#10494)
* feat(providers): add Cloudflare AI Playground as No Auth provider (closes #10389)

Reverse-engineered access to the free, anonymous Cloudflare AI Playground:
chat runs over a PartySocket WebSocket speaking Cloudflare's cf_agent RPC
protocol with zero credentials (no account, no API key, no cookies). The
WS upgrade is gated on a browser-grade TLS fingerprint, so the executor
drives a headless Chromium via Playwright and speaks the protocol from
inside the page context.

- registry entry: cloudflare-playground (alias cfp), authType none,
  curated 20-model catalog (GLM 5.2, Kimi K2.7 Code, DeepSeek V4 Pro,
  gpt-oss-120B, Llama 3.3 70B, Qwen2.5 Coder 32B, ...) captured from the
  live getModels RPC (2026-08-15)
- executor: cf_agent frame stream -> OpenAI SSE translation, id-filtered
  parser (RPC done:true frames cannot kill the stream), in-band upstream
  errors mapped to HTTP 429/502, abort + timeout handling, clean errors
- noauth UI entry with reverse-engineered-endpoint notice
- tests: 12 unit tests using real captured frames (incl. the 3021
  rate-limit error) + fake transport; ESLint clean; open-sse typecheck clean

* fix(providers): define __name helper in page context before evaluate

Bundlers with keepNames (esbuild/tsx, webpack) inject a __name() call into
serialized function bodies. page.evaluate(openPlaygroundSession) therefore
threw ReferenceError: __name is not defined in real browser sessions.
Define the helper on window before evaluating the session opener.

* fix(providers): sync docs counts, golden snapshots and add reasoning_content support for cloudflare-playground

* chore: remove ad-hoc cfp-shim debug script per review feedback

The standalone shim duplicated the executor's frame-parsing and transport
logic and is superseded by open-sse/executors/cloudflare-playground.ts.
Requested in PR #10442 review.

* feat(gemini-web): expose image generation through /v1/images/generations (closes #10466)

Adds a gemini-web image-generation path following the chatgpt-web precedent:

- imageRegistry: gemini-web provider entry (format gemini-web, cookie auth)
  with the nano-banana-web model. The -web suffix keeps the bare
  nano-banana id owned by adobe-firefly (operator decision 2026-07-31).
- gemini-web executor: new parseStreamResponseImages() extracts generated
  image URLs from the StreamGenerate candidate extension block
  (inner[4][0][12][7][0], url at entry[0][3][3] — string or list form),
  dedupes cumulative frames, upgrades to =s2048, and deliberately skips
  web-search thumbnails at [12][1]. Image mode (x_gemini_web_image_mode)
  captures every StreamGenerate frame, resolves on first image, and gets
  a 90s window; chat mode is byte-for-byte unchanged.
- handlers/imageGeneration/providers/geminiWeb.ts: drives the executor in
  image mode with an explicit generation directive prompt (the web UI
  otherwise answers with web-search images), caps n at 4, returns URLs or
  b64_json (downloads the public googleusercontent asset), and surfaces
  refusal text when no image was produced.
- Dispatch branch on format gemini-web in handleImageGeneration.

Tests: 21 new tests with fixtures built from the documented frame layout
(string/list url forms, cumulative-frame dedupe, web-image exclusion,
size-directive handling, refusal visibility, n-cap, b64_json, registry
wiring incl. the bare nano-banana → adobe-firefly regression guard).
Adjacent suites: gemini-web (6 files), chatgpt-web image, image handler,
route, registry, adobe-firefly, freepik, designer — all green.
ESLint clean on touched files (2 pre-existing any warnings unchanged);
tsc -p open-sse 0 errors.

* fix(media): close browser leak, surface timeout errors, and fall back accounts for gemini-web images

Addresses pre-merge review findings on #10494 (closes #10466):

- cloudflare-playground executor: close the launched browser on EVERY
  non-success start() path, including the detected Cloudflare "Attention
  Required" challenge branch (was leaking a Chromium process per blocked
  request).
- cloudflare-playground executor: a streaming chat timeout now emits an
  explicit timeout_error SSE chunk before [DONE] instead of silently
  completing, so a client can no longer mistake an empty/partial timed-out
  stream for a successful answer. Timeout duration is now injectable for
  deterministic tests.
- gemini-web image handler + imageCredentialRetry: classify the underlying
  GeminiWebExecutor's expired/blocked-session failure modes (400/500, per
  its own Playwright timeout/catch-all branches) as retryable, so
  executeImageWithCredentialFallback advances to the next eligible account
  instead of only doing so on a plain 401.

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

* docs: regenerate provider counts after merging release/v3.8.50 (341 -> 342)

The previous merge commit resolved all 51 auto-generated-file conflicts by
taking release/v3.8.50's content, which still said 341 providers. Merging in
this branch's Cloudflare Playground provider brings the live catalog to 342,
so npm run check:docs-counts-sync now flags stale claims. Fix:

- docs/reference/PROVIDER_REFERENCE.md: regenerated via
  `npm run gen:provider-reference`.
- README.md/AGENTS.md/llm.txt/package.json description: 341 -> 342.
- docs/diagrams/{readme-hero,promise-pillars,comparison-table,cli-terminal}.svg:
  341 -> 342 in the embedded "NNN providers" text (targeted replace, matched
  against the exact pattern check-docs-counts-sync.mjs validates).

check:docs-counts-sync and check:changelog-integrity are both clean after
this commit.

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

* docs(env): document CLOUDFLARE_PLAYGROUND_CHROME_PATH

Used by open-sse/executors/cloudflare-playground.ts but missing from
.env.example and docs/reference/ENVIRONMENT.md, caught by the
env-doc-sync gate when combined with other PRs in the release
merge-train.

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

---------

Co-authored-by: user.email <freakymustard67@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 11:43:16 -03:00
Diego Rodrigues de Sa e Souza
b43a212680 fix(cliproxy): read os.platform()/os.arch() at runtime in binaryManager platform detection (#10244) (#10474)
* fix(cliproxy): read os.platform()/os.arch() at runtime in binaryManager platform detection (#10244)

detectPlatform()/detectArch() read the module's process.platform/process.arch,
which Turbopack `next build` (run only on Linux) constant-folds, pruning every
Windows/arm64 branch from the published npm artifact — so the embedded CLIProxyAPI
installer downloads the Linux ELF binary on Windows. Switch to runtime os.platform()/
os.arch() calls (the repo's established anti-fold pattern) so the Windows/ARM branches
survive any build machine. Add a regression guard mocking os.platform()/os.arch() to
win32/arm64 asserting the Windows/ARM path is reachable — RED before, GREEN after.

* fix(cliproxy): use runtime platform for binary install paths

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

* fix(cliproxy): thread runtime platform as a parameter instead of re-reading os.platform()

extractZip(), installVersion(), and rollbackVersion() each independently called
os.platform() inline in their own module scope even after #10244 switched the
detection helpers to os.platform()/os.arch(). Each independent call site is its
own opportunity for a bundler to constant-fold that particular occurrence away.

Detect the runtime platform once per orchestrating call (installVersion,
downloadRelease, rollbackVersion) and thread the already-detected value down as
an explicit parameter into extractZip and the symlink/copy decisions, instead of
re-reading the global in every helper.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 11:42:47 -03:00
Markus Hartung
beb6ec857b feat(dashboard): agentic conversation tracking — v4, decoupled + storage-architecture concern resolved (#10263)
* feat(responses): virtualize previous_response_id continuation regardless of upstream support

OmniRoute now exposes OpenAI-compatible previous_response_id/store
continuation to clients unconditionally, even when the selected upstream
provider has no native Responses-API state support. Reconstruction happens
server-side in handleChatImplementation, before any downstream validation
or provider translation: OmniRoute resolves the response id back to the
full input/output it previously produced, prepends it to the client's
delta, and forwards the full reconstructed history upstream exactly as it
does today. Client<->OmniRoute traffic shrinks to the new delta only;
OmniRoute<->provider traffic is unchanged.

Storage reuses the existing call-log pipeline artifact (already gated by
call_log_pipeline_enabled, already retained/cleaned up by the existing
call-log lifecycle) instead of duplicating conversation content into a
second store -- only a lightweight call_logs.response_id index is new.
Every lookup is scoped by api_key_id so one client can never resolve
another client's stored conversation, and any unresolvable/missing/
size-limit-omitted state fails closed with OpenAI's own
previous_response_not_found contract.

Stacked on feat/openai-responses-store-toggle (#10121).

* feat(dashboard): agentic conversation tracking with live transcript view

Every agentic chat request now gets a conversation id (X-ConversationId
response header). OmniRoute detects when a follow-up request continues the
same conversation via fingerprint + bounded prefix-hash matching, with a
strict-growth invariant to prevent false merges between independent
single-shot requests that happen to share identical opening content.
Continuation detection excludes the system message from the identity
anchor, since real coding-agent CLIs commonly regenerate it every request
with live context (timestamp, cwd, git status) — without this, that
volatility alone broke every continuation check against real traffic.

- `/dashboard/logs`: new toggleable Conversation column.
- `/dashboard/logs/timeline`: requests sharing a conversation id share a
  timeline lane, connected by an arrow, with a configurable lane-reuse
  window.
- Request detail panel: new Full Conversation transcript above the raw SSE
  event stream — Markdown rendering, per-turn timestamps, turn-relative
  view, click-any-turn navigation, live auto-refresh building the
  transcript in real time from the in-flight SSE chunk buffer while a
  request is still streaming, auto-scroll-to-bottom as the live turn grows.
- New `/dashboard/conversations` page listing conversations with 2+ turns,
  no-forking model (an edited/duplicated mid-history turn mints its own
  independent conversation instead of merging), pagination, duplicate-
  anchor fix.
- Configurable auto-refresh intervals on both the timeline and
  conversations list pages.
- Responses API tool-call gap fix: turnsFromOpenAiMessages only handled
  role-based Chat Completions messages, so bare {type:"function_call"} /
  {type:"function_call_output"} / {type:"reasoning"} items (real Responses
  API traffic) silently vanished from the Conversation Context panel.
- truncateForLog now counts input[] (Responses API), not just messages[]
  (Chat Completions), so a truncated /v1/responses request still shows a
  placeholder instead of nothing.
- RequestTimeline.tsx now reads the same debugEnabled/emailsVisible
  settings RequestLoggerV2.tsx already used, instead of hardcoding both
  false — the timeline view never showed SSE/stream-chunk events or
  respected email-masking, regardless of the actual setting.

Migrations 147/148 (agentic_conversations, conversation_turn_nodes) — 135
and 136 are now taken upstream; 143-145 are documented KNOWN_GAPS, so this
uses the next free slot past upstream's current highest.

Test plan:
- npm run typecheck:core — clean
- npm run lint — clean
- node --import tsx/esm scripts/check/check-migration-numbering.mjs — OK, 0 collisions
- 109 unit tests across the conversation-tracking, migration-renumber, and
  dashboard-wiring surface — 0 failures

* refactor(dashboard): reuse call-log artifacts for conversation transcript content

conversation_turn_nodes no longer stores turn text/tool-call content
(text_preview/block_kind/tool_name) -- it's identity-only now (id/parent/
content_hash), matching agentic_conversations' existing lightweight-index
shape. Every node's originating request is already fully captured by the
call-log pipeline artifact its last_correlation_id points at, so the
/dashboard/conversations tree view resolves each node's actual display
content on demand from there (open-sse/services/conversationTurnContent.ts),
re-running the same extractCanonicalTurns/hashTurnContent the write path
used and matching by content_hash, instead of duplicating conversation
content into a second store under a separate retention/gating policy. This
also drops the old 8000-char text_preview truncation entirely -- resolved
content is always full and untruncated.

The frontend contract is unchanged (tree API still returns
{textPreview, blockKind, toolName} per node), so the dashboard UI itself
(page.tsx, RequestLoggerDetail/RequestTimeline, sidebar, i18n) needed no
changes.

Renumbered the cherry-picked 147/148 migrations to 153/154 -- 147 now
collides with 147_api_keys_model_access_mode.sql, which landed on
release/v3.8.50 after this work was originally built.

Also includes a standalone, unrelated fix carried along from this rebase:
close isProviderModelHidden's missing function-body brace in
modelSelectModalHelpers.ts (separately landed as #10206).

Stacked on feat/responses-previous-response-id-virtualization (#3), which
is itself stacked on feat/openai-responses-store-toggle (#10121).

* fix(dashboard): resync conversation list on open so the live-text poll starts immediately

openConversation() seeded activeConversation (and therefore activeCallLogId,
which gates the live-partial-text poll effect) from whatever row snapshot the
list's own fixed-interval poll last produced. A conversation opened right
after a reply started streaming -- after that tick, before the next -- had
activeCallLogId still null, so the live-text poll never started; only a
subsequent background list-poll resync (already existed) picked it up,
which is why closing and reopening the same conversation "just worked".

loadConversations() is now a shared callback so openConversation can force
one immediately on open instead of waiting on pollSeconds.

Live-verified against omniroute-dev: opening a conversation mid-stream now
shows live reasoning on the first open.

* style: prettier formatting for conversationTurnContent.test.ts

* fix(db): close migration numbering gap left by decoupling from #3/#10262

153/154 (originally 154/155) were chosen back when this branch stacked on
top of the previous_response_id migration (153_call_logs_response_id.sql).
Decoupling removed that migration from this branch's history, leaving an
unused 153 slot that check-migration-numbering.test.ts correctly flags as
a gap.

* refactor(dashboard): split RequestTimeline/RequestLoggerDetail under the 1000-line file-size cap

Both files exceeded check-file-size's new-file cap after this PR's own
additions (RequestTimeline 1048, RequestLoggerDetail 1163). Extracted pure
non-component logic (types, constants, allocateLanes and its helpers) out
of RequestTimeline.tsx into RequestTimeline.utils.ts, and the two
self-contained presentational sub-components (PayloadSection,
ConversationContextSection + its private helper) out of
RequestLoggerDetail.tsx into RequestLoggerDetail.sections.tsx. No behavior
change; existing external imports (default exports, allocateLanes,
TimelineLog, CONVERSATION_LANE_REUSE_STORAGE_KEY) still resolve from the
original file paths.

* fix(db): renumber agentic-conversation migrations to clear 153 collision + sync migration-count docs

The refresh-merge of release/v3.8.50 exposed that the feature's three
migrations collided at slot 153 with the base's radar_local_model_state
(153) and its own call_logs_response_id. Migration runner enforces unique
numeric prefixes -> every DB init threw, red-ing Vitest, all Unit shards and
the DB-backed quality gates. Renumber the feature's pair to
155_agentic_conversations / 156_conversation_turn_nodes and move
call_logs_response_id to 154 (keeps 153_radar base-owned, preserves
agentic-before-turn_nodes ordering). Update SQL headers and the
154/156 references in feature code + tests.

Migration count is now 151 (was 148 stale in README/AGENTS/llm.txt) — sync
the doc counts to clear the docs-accuracy gate.

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

* fix(ui): drop unused CONVERSATION_LANE_REUSE_STORAGE_KEY re-export from RequestTimeline

Knip 6.32 (baseline 415) flags the public re-export of
CONVERSATION_LANE_REUSE_STORAGE_KEY from RequestTimeline.tsx as dead: no
external consumer imports it through that re-export (it is imported and
used directly from RequestTimeline.utils.ts inside the component). Removed
the unused re-export; the internal import stays. DEAD_TOTAL 416 -> 415,
back to the frozen baseline.

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

* fix(agentic-conversations): guard resolveConversationId, drop dead whole-chain export

- Wrap resolveConversationId() in try/catch in chat.ts, matching the
  defensive pattern used by every other best-effort side call nearby, so a
  DB hiccup in conversation tracking can't turn a working chat request into
  a hard failure.
- Remove getConversationTurnTree: knip's project scope excludes tests/**,
  so an export used only by tests can never register as used there. Swap
  its 8 test call sites to the paginated getConversationTurnPage (already
  the dashboard's canonical query) with a generous limit, collapsing to one
  query path instead of keeping a second whole-chain export alive solely
  for test convenience.
- Regenerate i18n llm.txt mirrors from root (pre-existing drift on this
  branch, unrelated to the above, caught by the docs-sync pre-commit gate).

Addresses PR review feedback.

* fix(i18n): close requestLogger conversation-column gap, fix domain-modules count drift

- fr.json, vi.json were missing requestLogger.columns.conversation (added
  in the conversation-tracking feature), failing i18n-vi-completeness.test.ts.
- docs/i18n/*/llm.txt mirrors still said 117 domain-specific files after an
  earlier rebase fixed the migration count but missed this companion number,
  failing check-docs-sync.mjs across all 42 locales.

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

* fix(docs): restore PROXY_LOG_INCLUDE_IPS env/doc entries (env-doc-sync red)

.env.example and docs/reference/ENVIRONMENT.md were both missing the
PROXY_LOG_INCLUDE_IPS entry that src/lib/proxyLogger.ts already reads
(confirmed present at this branch's merge-base too, so this predates
the conversation-tracking work and is unrelated to it) -- the entry
was added on release/v3.8.50 after this branch's last sync and this
branch never picked it up. That gap red-lines
tests/unit/check-env-doc-sync.test.ts and
tests/unit/issue-7793-env-doc-sync-repro.test.ts (Unit Tests
fast-path 2/4 in CI). Restore both entries verbatim from the current
release/v3.8.50 tip -- no feature-code change.

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

---------

Co-authored-by: hartmark <hartmark@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 11:32:33 -03:00
desamours-hub
d93b24e761 feat(api): add provider quota telemetry, adaptive routing, and status inventory (#10148)
* feat(api): add provider quota telemetry, adaptive routing, and status inventory

Adds a read-only OmniRoute status/inventory surface plus supporting
resilience and usage-tracking infrastructure:

- src/lib/quota/providerQuotaTelemetry.ts, providerCapabilities.ts:
  provider quota state and capability signals, sourced from configured
  metadata rather than invented values; unknown stays unknown.
- src/lib/resilience/adaptiveCircuit.ts, failureClassification.ts:
  circuit state with lazy recovery and explicit failure classification.
- src/lib/usage/usageLedger.ts, budgetGuard.ts, modelPricingRegistry.ts:
  internal usage tracking and budget allow/warn/deny decisions, kept
  separate from upstream-reported quota (never conflated).
- src/lib/routing/adaptiveRouting.ts: excludes exhausted-quota and
  open-circuit candidates from routing, penalizes approaching-limit.
- src/lib/omnirouteStatus.ts + src/app/api/omniroute/status,
  route/preview: read-only status endpoint; never issues a live
  upstream model request (asserted via liveRequestExecuted: false).
- src/lib/db/quotaPools.ts: adds ensurePool() for idempotent pool
  management by automation/CLI callers, following the existing
  group-demo default-group convention.
- scripts/omniroute-verify.mjs (+ omniroute:verify script): local
  verification against the running gateway.

9 new unit tests, all passing. typecheck:core clean relative to base
(release/v3.8.50) -- the 2 pre-existing gateways.ts errors are tracked
separately in #9985 and untouched by this change.

* test(cli): align cli-machine-token assertions with HMAC-SHA256 64-char format

The quota-telemetry feature hardens cliToken to HMAC-SHA256(machineId, SALT)
(64-char hex, pristine machine id). Update the regression test to the new
format and mirror the production derivation in the different-machine-id check.

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

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: desamours-hub <desamours-hub@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 11:31:53 -03:00
Brandon Bennett
6615a5445b feat: combo-lane awareness + activation UX + MCP visibility (Wave 2 of #9654) (#10039)
* feat(admission): per-target lane-aware probes for combo/fusion fan-out (#9654 Wave 2)

Combo and fusion fan out N targets without ever consulting the adaptive-admission
layer: the parent request holds one lease, but each fan-out target is dispatched
unconditionally. With virtual lanes enabled (OMNIROUTE_CHAT_VIRTUAL_LANES=1), a
connection whose lane queue is full now SKIPS additional fan-out targets instead
of piling more queued work onto an already-congested session.

Adds PerTargetAdmissionHook (admission/types.ts) + createPerTargetAdmissionHook
factory (chatAdmission.ts): strictly non-blocking (maxWaitMs 0 - skip, never
queue), a no-op when virtual lanes are off, keyed to the parent tenantKey, and
release-on-admit so the probe is a capacity gate, not a hold.

Threaded through every parallel fan-out path:
- priority/weighted executeTarget + round-robin skip chains (combo.ts)
- fusion panel before fan-out (fusion.ts), judge fallback prefers survivors
- chaos parallel panel (autoCombo/chaosEngine.ts)
- tryFusionDispatch / tryRuntimeUnitDispatch / buildBaseOptions (dispatchPrelude.ts)
- chat.ts primary + safety-net redirect call sites

Snapshot exposes virtualLanes so the no-op gate is cheap and honest.

Tests: tests/unit/combo-lane-awareness-9654.test.ts (10 tests) - factory
semantics, priority/RR skip, fusion panel drop + all-skipped 503, no-hook
backward-compat baseline.

* feat(flags): activation UX - env-wins adaptive virtual-lanes flag + env docs (#9654 Wave 2)

U7: make adaptive virtual admission lanes discoverable + activatable.
- New OMNIROUTE_CHAT_VIRTUAL_LANES feature flag (boolean/runtime/requiresRestart) in featureFlagDefinitions + en.json i18n key.
- lib/admissionVirtualLanes.ts: env-wins resolver (env > DB > default) + boot warm folding a DB-sourced override into the process-global runtime env via reloadAdaptiveAdmissionRuntime(options.env) - no process.env mutation, no open-sse changes. Env still wins; DB toggle gates at next boot.
- GET /api/settings/feature-flags special-cases the flag to report the gate true source (ccDiscoveryAliases precedent); flagPayload helper dedupes the payload shape.
- Wire the warm into instrumentation-node registerNodejs (non-fatal, DB-ready).
- Document the master switch in .env.example + ENVIRONMENT.md with the system-1/system-2 distinction; zero new env-doc-sync drift.
- 11 new tests (resolver precedence + warm); 60/60 across feature-flag suites; typecheck core clean; ESLint + doc gates green.

* feat(mcp): surface adaptive admission lane data in omniroute_get_health (#9654 Wave 2)

U8: make adaptive virtual-lane admission visible to agents via the MCP health tool. handleGetHealth now surfaces a curated adaptiveAdmission block from the health payload (which already carried the runtime snapshot but was dropping it): virtualLanes/pressure/utilization/laneCount/laneQueuedCount/laneQueuedCost, laneTenants capped at top-10 by queued cost, admitted/rejected/wouldReject counts, shutdown. Block omitted entirely when the health endpoint reports none.

isLaneFlagOn mirrors the runtime 1|true convention so a string serialization can never invert a boolean lane report. getHealthOutput schema extended with the matching optional shape; tool description updated.

4 new dispatch tests (full block, top-10 cap/order, omission, defensive coercion of string flags + malformed lane entries) - 22/22 in essentialTools.test.ts. README: Adaptive Admission Lane Data table + Skills & Tool Navigability audit (29/43 schema entries covered, 14 undocumented, tool_search keyword runtime discovery, full catalog in docs/frameworks/MCP-SERVER.md).

No new lint errors (4 pre-existing in server.ts), typecheck core clean, doc counts + fabricated-docs gates green.

* docs: add changelog entry for #9654 Wave 2 (#10039)

* fix(codeql): suppress js/insufficient-password-hash false positive in lane-key fingerprinting (#10039)

resolveSessionId sha256-hashes bearer/x-api-key/x-goog-api-key to derive a deterministic, non-reversible per-key lane-bucket ID for virtual admission lanes (#9654). This is not password storage or verification, so the rule is a false positive; suppress it inline (same house style as src/lib/sync/tokens.ts) to clear the codeqlAlerts ratchet (2 > baseline 1) that blocks #10039 and every PR against release/v3.8.50.

* docs(mcp): complete MCP server README tool reference (#10039)

The MCP server README covered only 29 of the 43 schema entries, listing the
remaining tools solely as a gap note with omniroute_tool_search as the runtime
fallback. Add tool-reference tables for the agent-skills trio, oneproxy trio,
web_fetch/web_search, tool_search, create_combo, set_routing_strategy,
pick_fastest_model, sync_pricing, and db_health_check so the README covers the
full schemas catalog, and fold the coverage note into the tool_search discovery
paragraph.

* fix(chat): drop unused correlationId from safety-net combo redirect (#10039)

handleComboChat's HandleComboChatOptions has no correlationId member and
the combo pipeline never consumes it; the property was copied from the
handleSingleModelChat options shape by accident and introduced a new
TS2353 under the open-sse workspace typecheck gate.

* fix(i18n): translate featureFlagChatVirtualLanesEnabledDescription into 42 locales (#10039)

en.json gained the flag description in this PR but the locale catalogs
were never mirrored, failing the pt-BR key-parity (#6695) and vi
completeness gates. Adds a real translation to every locale, keeping the
zh-CN/zh-TW glossary canonical terms (提供者/儀表板) and no ICU drift.

* chore(quality): ratchet open-sse-typecheck baseline down (#10039)

The Wave 2 admission refactor removed 66 baselined open-sse type errors;
re-freeze the baseline so the gate pins the new, tighter state.

* docs: resync provider reference to 341 and CLI tools to 34

The release branch gained an 11th no-auth provider (freeaiapikey registry
resync, #10233) and a 26th CLI Code tool without regenerating the
auto-generated docs, leaving every PR against release/v3.8.50 failing the
Docs Gates strict validator (code 341 vs doc 340, CLI 34 vs "33 tools").

Regenerate docs/reference/PROVIDER_REFERENCE.md and sync the provider/tool
counts across README.md, AGENTS.md, llm.txt plus 42 i18n mirrors,
package.json description, and the four diagram SVGs.

* fix(tests): align count expectations with live catalogs (pre-existing release drift)

Release/v3.8.50 currently fails five gates on its own tree; this PR inherits
them. Fix the stale expectations to match live code:

- feature-flags-settings: 48 -> 49 flags (Wave 2 adds OMNIROUTE_CHAT_VIRTUAL_LANES)
- cli-tools-schema / cli-catalog-counts: 33 -> 34 tools (zcode added; 26 code = 21 visible + 5 none)
- optional-transformers-dependency: onnxruntime-node ~1.24.3 -> ~1.27.0 (bump #10382)
- stryker.conf.json: register chatcore-header-drop-warn-dedupe-10315 test
- check-public-creds: freeze zcodeProtocol clientId false positive (client identifier, not a credential)

* fix(tests): follow release's onnxruntime-node revert to ~1.24.3

release/v3.8.50's #10543 pinned onnxruntime-node back to ~1.24.3 after
#10403's ~1.27.0 bump caused npm to nest a second native copy under
@huggingface/transformers and broke the Docker SONAME contract. This
PR's own drift-alignment commit (57b9c033) predates that revert and
still expected ~1.27.0; the 3-way merge did not flag it as a textual
conflict since only one side touched this exact line, but the merged
tree became internally inconsistent (package.json ~1.24.3 vs test
expecting ~1.27.0). Align the test with the now-canonical release
value.

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

* fix(quality): dedupe stryker.conf.json chatcore-header-drop-warn-dedupe entry

The 3-way merge applied both sides' insertion of the same test-file entry
at different positions, producing a duplicate with broken indentation.
Adopted release's clean version of the file.

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

---------

Co-authored-by: Brandon Bennett <brandonbennett@macbookair.myfiosgateway.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Brandon Bennett <branben@users.noreply.github.com>
2026-08-18 11:31:46 -03:00
KaspaPulse
8acd799af7 feat(routing): add exclusive managed session connection leases (#10362)
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 11:25:46 -03:00
adevwithpurpose
3cab6dc9f0 fix(combo): resolve nativeCodexTurnPin type error and connection-pin gap
PR #10573 landed with two real defects surfaced by typecheck/tests on
the combined release tip:

- TS2322: allowedConnectionIds (string[]) was built from
  compatible.map(t => t.connectionId), whose type includes null.
  Filter nulls before assigning.
- applyNativeCodexTurnPin never assigned the pinned connectionId onto
  a compatible candidate that didn't already carry it (e.g. an
  unresolved placeholder target with connectionId: null) — the pin
  was silently dropped instead of applied. Now resolves the pinned
  slot's connectionId explicitly (in original order, so
  allowedConnectionIds stays consistent regardless of pinned-first
  reordering) before building the returned target list.

Confirmed via the existing focused suites:
tests/unit/chatgpt-web-codex-turn-pin.test.ts and
tests/unit/native-codex-turn-pin-10379.test.ts (14/14 pass),
typecheck:core clean.
2026-08-18 11:14:01 -03:00
InkshadeWoods
fd76271515 fix(providers): make upstream model sync opt-in and preserve manual overrides (#10603)
* fix(providers): make upstream model sync opt-in and preserve manual overrides

(cherry picked from commit 0a84f5496896a95856e834112b3d813fa1b87d38)

* test(providers): cover upstream model sync controls

* fix(providers): fix pre-existing tests broken by opt-in model sync + sync i18n keys

The upstream model auto-fetch opt-in default flip made 3 pre-existing tests
short-circuit before reaching the paths they exercise, because their
connection fixtures never set providerSpecificData.autoFetchModels: true:

- tests/unit/provider-models-route-lan-guard.test.ts (#6939 SSRF-guard tests)
- tests/unit/openrouter-embeddings-catalog-6976.test.ts (live discovery merge/dedup)
- tests/unit/provider-models-route.test.ts (Kimi Coding auth-header test —
  this was mislabeled as base/catalog drift during review, but is the same
  root cause: without autoFetchModels the mocked fetch is never reached and
  the route falls back to local catalog data instead)

Also syncs the 11 new providers.autoFetchModels*/overridesUpstreamModel*/
resetToUpstreamDefaults* i18n keys from en.json/zh-CN.json to the remaining
40 locale files via a narrowly-scoped ad-hoc translation script (only these
11 keys — leaves each locale's pre-existing, unrelated missing-key backlog
untouched).

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

* fix(providers): fix remaining pre-existing tests broken by opt-in model sync

Rebase surfaced that the 'Kimi Coding' CI failure flagged as possible base
drift during review was actually the same root cause as the lan-guard and
openrouter-embeddings fixes: 29 pre-existing tests in
tests/unit/provider-models-route.test.ts (of 59 total) short-circuit under
the new autoFetchModels opt-in default because their connection fixtures
never set providerSpecificData.autoFetchModels: true, so they never reach
the live-fetch/validation paths they were written to exercise (fetch mocks
never called, base-URL validation never reached, live models never merged).

Adds providerSpecificData.autoFetchModels: true to each affected fixture.
No production code or test assertions changed — same TEST-fixture-only
pattern as the lan-guard and openrouter-embeddings fixes. All 59 tests in
the file now pass (was 30/59).

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:58:40 -03:00
Bob.Hou
fb89fafc3a fix(backend,combo,cursor): header budget, Codex failover, kv_after_text (#10573)
* fix(combo): allow fill-first failover across Codex OAuth connections

applyNativeCodexTurnPin previously narrowed the target pool to the
single pinned connection, making same-provider failover impossible when
the pinned connection was rejected by pre-dispatch checks. Return all
compatible connections (same provider + model) with the pinned connection
first, so the combo engine can fall over to siblings. Also allow
pinNativeCodexTurn to update connectionId for failover recovery while
still rejecting provider/model changes.

Fixes #10379

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

* fix(test): replace as any with properly typed ResolvedComboTarget literal

Addresses ESLint no-explicit-any error in tests/.

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

---------

Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-08-18 10:58:22 -03:00
Rizx
30265ef7f8 fix(i18n): add missing routing and compression messages (#10546)
* fix(i18n): add missing routing and compression messages

* fix(i18n): align zh-TW provider terminology
2026-08-18 10:58:13 -03:00
Bob.Hou
6b823aa441 fix(logging,sse): redact sensitive log fields and default SSE comments to disabled (#10539)
* fix(logging): redact client IPs and account prefixes by default

ProxyEgress and AUTH logs exposed client IPs, egress IPs, and account
prefixes at info level — a privacy leak in multi-tenant/shared-log
environments. Now redacted by default, only shown when debugMode=true.

Fixes #10348

* fix(sse): default SSE comment lines to disabled

Strict SSE clients (WorkBuddy, etc.) JSON.parse every SSE line and
crash on  comment lines. Changed OMNIROUTE_SSE_COMMENTS
default from enabled to disabled. Operators can opt in with
OMNIROUTE_SSE_COMMENTS=on.

Fixes #10524

* fix(logging): gate AUTH account-prefix redaction on a narrow flag, not debugMode

The proxy-log redaction half of #10348 is superseded by an already-merged
fix (PROXY_LOG_INCLUDE_IPS, decoupled from debugMode). The remaining gap was
the chat.ts AUTH log line ("Using <provider> account: <prefix>..."), which
this PR gated on the broad `debugMode` setting. `debugMode` is a general
dashboard-visibility toggle unrelated to log privacy — coupling redaction to
it means any future, unrelated change to debugMode's default silently
changes whether account prefixes leak into logs.

Add a dedicated AUTH_LOG_INCLUDE_ACCOUNT_ID feature flag (default off,
security category) and gate the AUTH log line on it via
isFeatureFlagEnabled(), which reads the DB override synchronously on every
call (no stale in-memory cache to invalidate) and fails safe to redacted on
any lookup error.

Also update the SSE-comments tests/docs that still asserted the old
enabled-by-default behavior (tests/unit/sseHeartbeat.test.ts,
tests/unit/sse-comments-optout-9305.test.ts, docs/reference/ENVIRONMENT.md)
to match the new default-off behavior from this PR's earlier commit.

Refs #10348, #10524

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:58:07 -03:00
Diego Rodrigues de Sa e Souza
6d99a46d4b fix(cli): guarantee non-empty [STARTUP] Fatal log on instrumentation-hook boot throw (#10447)
* fix(cli): guarantee non-empty [STARTUP] Fatal log on instrumentation-hook boot throw

Refs #10171: on native Windows / WSL2 boots, an instrumentation-hook throw
during module-load or registerNodejs() leaves the HTTP listener up while
every DB-touching route 500s, with app.log staying completely empty. The
#7773/#7828 guard in ensureDbReadyForBoot only logs one specific failure
class (DB driver init). register() in src/instrumentation.ts now wraps the
boot call in a try/catch at the outermost boundary and unconditionally logs
a "[STARTUP] Fatal: instrumentation hook failed during boot:" line before
rethrowing, so app.log/stdout is never silently empty on a failed boot
regardless of platform or which step threw.

This is a partial diagnostic hardening, not the full fix for #10171 — the
platform-specific root cause on native Windows/WSL2 still needs the
reporter's raw child stderr from a real host (tracked separately, see
_tasks/pipeline/bugs/2-implementing/10171-instrumentation-hook-500-on-windows-wsl.plan.md).

* fix(cli): normalize instrumentation boot errors

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

* fix(cli): reuse shared normalizeBootError helper in instrumentation.ts

The outermost instrumentation-hook boot boundary (#10171) was inlining its
own err-instanceof-Error normalization instead of reusing the existing
normalizeBootError() helper already defined in instrumentation-node.ts for
the same purpose (#6560/#7773). Extract it into a dependency-free
src/lib/instrumentationBootError.ts so both instrumentation.ts (which also
loads under the Edge runtime) and instrumentation-node.ts can import it
statically without risking a second failing dynamic import of
instrumentation-node.ts from within the catch block.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:57:44 -03:00
Markus Hartung
c545855b26 fix(logging): capture early-keepalive bytes in the call-log artifact (#10331)
Diagnosed while chasing the reused-output-index incident (see
705ac7335 / OpenClaw issue #123342): every call-log artifact showed a
wire-clean response, even for requests that actually failed, because
withEarlyStreamKeepalive injects its startup/keepalive/error frames
directly into the outer response stream, entirely outside the request
handler's own reqLogger. reqLogger.appendConvertedChunk (which
populates pipeline.streamChunks.client) never sees those bytes — only
what chatCore.ts's own SSE writer produced. The persisted artifact was
answering "what did the handler generate," not "what did the client
actually receive," which is the wrong question when diagnosing a
client-visible stream defect.

withEarlyStreamKeepalive wraps the handler's Promise from OUTSIDE its
call tree; the reqLogger it needs to feed is created deep inside
chatCore.ts, after routing/model/provider resolution, and doesn't
exist yet when the keepalive frames are written. The two sides share
no reference — only an identifier, if one is deliberately threaded
through both.

Fix: responses/route.ts now generates a correlationId before calling
handleChat, passes it as handleChat's existing (already-supported,
previously-unused-here) 4th positional arg — which chatCore.ts already
threads into trackPendingRequest's metadata as entry.correlationId,
zero changes needed there — and also into
withEarlyStreamKeepalive's options. The wrapper buffers every direct-
to-client write (startup frame, periodic ticks, in-band error frames)
via the new earlyKeepaliveByteBuffer module, keyed by that same id.
chatCore/attemptLogging.ts, which already has correlationId in scope
right where it assembles the final pipeline payload before saveCallLog,
takes the buffered bytes and prepends them into streamChunks.client in
send order. The verbatim-forwarded real response body is deliberately
NOT re-recorded here — the handler's own reqLogger already captures
that; recording it twice would duplicate it in the artifact.

The buffer is consumed exactly once per correlationId and swept on a
10-minute TTL so a request that never reaches the persist call
(aborted, detailed logging disabled, a route that doesn't opt in)
cannot leak entries forever.

Scoped to /v1/responses only, where the incident actually happened.
/v1/chat/completions and /v1/messages call withEarlyStreamKeepalive the
same way and would need the identical two-line route change to opt in;
left as a follow-up rather than bundled in sight-unseen.

Test plan:
- tests/unit/early-keepalive-byte-buffer.test.ts (new): record/take
  ordering, single-consumption, per-id isolation, empty-input no-ops,
  unbounded-growth cap
- tests/unit/early-stream-keepalive.test.ts: two new tests — a
  correlationId records the startup frame and keepalive ticks but NOT
  the forwarded body; omitting correlationId is a true no-op
- tests/unit/attempt-logging-early-keepalive-merge.test.ts (new): real
  temp-DB end-to-end proof against the actual persisted call-log row —
  early bytes prepended in send order, consumed exactly once, no-op
  without a correlationId, gated by detailedLoggingEnabled matching the
  existing streamChunks capture gate
- tests/unit/chatcore-attempt-logging.test.ts (existing): unchanged,
  still passing — confirms the merge addition doesn't disturb existing
  persistence behavior
- 44 passed total across the above plus earlyStreamKeepalive.test.ts,
  2 pre-existing skips unrelated to this change
- tsgo --noEmit: clean on all touched files
2026-08-18 10:57:31 -03:00
backryun
acd740908f feat(providers): refresh Qwen3.8 model catalogs (#10226) 2026-08-18 10:57:20 -03:00
dependabot[bot]
7f6958960c deps: bump the development group with 13 updates (#10626)
Bumps the development group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@axe-core/playwright](https://github.com/dequelabs/axe-core-npm) | `4.12.1` | `4.13.0` |
| [@cyclonedx/cyclonedx-npm](https://github.com/CycloneDX/cyclonedx-node-npm) | `6.0.0` | `6.0.1` |
| [@stryker-mutator/core](https://github.com/stryker-mutator/stryker-js/tree/HEAD/packages/core) | `9.6.1` | `10.0.0` |
| [@stryker-mutator/tap-runner](https://github.com/stryker-mutator/stryker-js/tree/HEAD/packages/tap-runner) | `9.6.1` | `10.0.0` |
| [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) | `7.0.0` | `7.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.20.1` | `26.2.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.0` | `16.3.1` |
| [fumadocs-mdx](https://github.com/fuma-nama/fumadocs) | `15.2.2` | `15.2.3` |
| [jscpd](https://github.com/kucherenko/jscpd/tree/HEAD/rust/jscpd) | `4.2.5` | `4.3.0` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.32.0` | `6.32.2` |
| [lockfile-lint](https://github.com/lirantal/lockfile-lint/tree/HEAD/packages/lockfile-lint) | `5.0.0` | `5.0.1` |
| [opencode-ai](https://github.com/anomalyco/opencode) | `1.18.15` | `1.18.18` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.66.0` | `8.67.0` |


Updates `@axe-core/playwright` from 4.12.1 to 4.13.0
- [Release notes](https://github.com/dequelabs/axe-core-npm/releases)
- [Changelog](https://github.com/dequelabs/axe-core-npm/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/dequelabs/axe-core-npm/commits/v4.13.0)

Updates `@cyclonedx/cyclonedx-npm` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/CycloneDX/cyclonedx-node-npm/releases)
- [Changelog](https://github.com/CycloneDX/cyclonedx-node-npm/blob/main/HISTORY.md)
- [Commits](https://github.com/CycloneDX/cyclonedx-node-npm/compare/v6.0.0...v6.0.1)

Updates `@stryker-mutator/core` from 9.6.1 to 10.0.0
- [Release notes](https://github.com/stryker-mutator/stryker-js/releases)
- [Changelog](https://github.com/stryker-mutator/stryker-js/blob/master/packages/core/CHANGELOG.md)
- [Commits](https://github.com/stryker-mutator/stryker-js/commits/v10.0.0/packages/core)

Updates `@stryker-mutator/tap-runner` from 9.6.1 to 10.0.0
- [Release notes](https://github.com/stryker-mutator/stryker-js/releases)
- [Changelog](https://github.com/stryker-mutator/stryker-js/blob/master/packages/tap-runner/CHANGELOG.md)
- [Commits](https://github.com/stryker-mutator/stryker-js/commits/v10.0.0/packages/tap-runner)

Updates `@testing-library/jest-dom` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v7.0.0...v7.0.1)

Updates `@types/node` from 22.20.1 to 26.2.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint-config-next` from 16.3.0 to 16.3.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.1/packages/eslint-config-next)

Updates `fumadocs-mdx` from 15.2.2 to 15.2.3
- [Release notes](https://github.com/fuma-nama/fumadocs/releases)
- [Commits](https://github.com/fuma-nama/fumadocs/compare/fumadocs-mdx@15.2.2...fumadocs-mdx@15.2.3)

Updates `jscpd` from 4.2.5 to 4.3.0
- [Release notes](https://github.com/kucherenko/jscpd/releases)
- [Changelog](https://github.com/kucherenko/jscpd/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kucherenko/jscpd/commits/v4.3.0/rust/jscpd)

Updates `knip` from 6.32.0 to 6.32.2
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.32.2/packages/knip)

Updates `lockfile-lint` from 5.0.0 to 5.0.1
- [Release notes](https://github.com/lirantal/lockfile-lint/releases)
- [Changelog](https://github.com/lirantal/lockfile-lint/blob/main/packages/lockfile-lint/CHANGELOG.md)
- [Commits](https://github.com/lirantal/lockfile-lint/commits/lockfile-lint@5.0.1/packages/lockfile-lint)

Updates `opencode-ai` from 1.18.15 to 1.18.18
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/v1.18.15...v1.18.18)

Updates `typescript-eslint` from 8.66.0 to 8.67.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@axe-core/playwright"
  dependency-version: 4.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: "@cyclonedx/cyclonedx-npm"
  dependency-version: 6.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: "@stryker-mutator/core"
  dependency-version: 10.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: "@stryker-mutator/tap-runner"
  dependency-version: 10.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 7.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: eslint-config-next
  dependency-version: 16.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: fumadocs-mdx
  dependency-version: 15.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: jscpd
  dependency-version: 4.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: knip
  dependency-version: 6.32.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: lockfile-lint
  dependency-version: 5.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: opencode-ai
  dependency-version: 1.18.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: typescript-eslint
  dependency-version: 8.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-18 10:53:46 -03:00
dependabot[bot]
9814276b0f deps: bump the production group with 14 updates (#10625)
* deps: bump the production group with 14 updates

Bumps the production group with 14 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1107.0` | `3.1111.0` |
| [@lobehub/icons](https://github.com/lobehub/lobe-icons) | `5.15.0` | `5.16.0` |
| [@xyflow/react](https://github.com/xyflow/xyflow/tree/HEAD/packages/react) | `12.11.2` | `12.11.3` |
| [cron-parser](https://github.com/harrisiirak/cron-parser) | `5.8.1` | `5.10.0` |
| [fumadocs-core](https://github.com/fuma-nama/fumadocs) | `16.14.3` | `16.14.4` |
| [fumadocs-ui](https://github.com/fuma-nama/fumadocs) | `16.14.3` | `16.14.4` |
| [js-yaml](https://github.com/nodeca/js-yaml) | `5.2.3` | `5.3.0` |
| [material-symbols](https://github.com/marella/material-symbols/tree/HEAD/material-symbols) | `0.45.10` | `0.46.0` |
| [next](https://github.com/vercel/next.js) | `16.3.0` | `16.3.1` |
| [open](https://github.com/sindresorhus/open) | `11.0.0` | `11.0.1` |
| [smol-toml](https://github.com/squirrelchat/smol-toml) | `1.7.2` | `1.8.0` |
| [sql.js](https://github.com/sql-js/sql.js) | `1.14.1` | `1.14.2` |
| [zustand](https://github.com/pmndrs/zustand) | `5.0.14` | `5.0.15` |
| [onnxruntime-node](https://github.com/Microsoft/onnxruntime) | `1.24.3` | `1.27.0` |


Updates `@aws-sdk/client-bedrock-runtime` from 3.1107.0 to 3.1111.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1111.0/clients/client-bedrock-runtime)

Updates `@lobehub/icons` from 5.15.0 to 5.16.0
- [Release notes](https://github.com/lobehub/lobe-icons/releases)
- [Changelog](https://github.com/lobehub/lobe-icons/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lobehub/lobe-icons/compare/v5.15.0...v5.16.0)

Updates `@xyflow/react` from 12.11.2 to 12.11.3
- [Release notes](https://github.com/xyflow/xyflow/releases)
- [Changelog](https://github.com/xyflow/xyflow/blob/main/packages/react/CHANGELOG.md)
- [Commits](https://github.com/xyflow/xyflow/commits/@xyflow/react@12.11.3/packages/react)

Updates `cron-parser` from 5.8.1 to 5.10.0
- [Release notes](https://github.com/harrisiirak/cron-parser/releases)
- [Changelog](https://github.com/harrisiirak/cron-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/harrisiirak/cron-parser/compare/v5.8.1...v5.10.0)

Updates `fumadocs-core` from 16.14.3 to 16.14.4
- [Release notes](https://github.com/fuma-nama/fumadocs/releases)
- [Commits](https://github.com/fuma-nama/fumadocs/compare/fumadocs@16.14.3...fumadocs@16.14.4)

Updates `fumadocs-ui` from 16.14.3 to 16.14.4
- [Release notes](https://github.com/fuma-nama/fumadocs/releases)
- [Commits](https://github.com/fuma-nama/fumadocs/compare/fumadocs@16.14.3...fumadocs@16.14.4)

Updates `js-yaml` from 5.2.3 to 5.3.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/5.2.3...5.3.0)

Updates `material-symbols` from 0.45.10 to 0.46.0
- [Release notes](https://github.com/marella/material-symbols/releases)
- [Commits](https://github.com/marella/material-symbols/commits/v0.46.0/material-symbols)

Updates `next` from 16.3.0 to 16.3.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.0...v16.3.1)

Updates `open` from 11.0.0 to 11.0.1
- [Release notes](https://github.com/sindresorhus/open/releases)
- [Commits](https://github.com/sindresorhus/open/compare/v11.0.0...v11.0.1)

Updates `smol-toml` from 1.7.2 to 1.8.0
- [Release notes](https://github.com/squirrelchat/smol-toml/releases)
- [Commits](https://github.com/squirrelchat/smol-toml/compare/v1.7.2...v1.8.0)

Updates `sql.js` from 1.14.1 to 1.14.2
- [Release notes](https://github.com/sql-js/sql.js/releases)
- [Commits](https://github.com/sql-js/sql.js/compare/v1.14.1...v1.14.2)

Updates `zustand` from 5.0.14 to 5.0.15
- [Release notes](https://github.com/pmndrs/zustand/releases)
- [Commits](https://github.com/pmndrs/zustand/compare/v5.0.14...v5.0.15)

Updates `onnxruntime-node` from 1.24.3 to 1.27.0
- [Release notes](https://github.com/Microsoft/onnxruntime/releases)
- [Changelog](https://github.com/microsoft/onnxruntime/blob/main/docs/ReleaseNotesWorkflow.md)
- [Commits](https://github.com/Microsoft/onnxruntime/compare/v1.24.3...v1.27.0)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-bedrock-runtime"
  dependency-version: 3.1111.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@lobehub/icons"
  dependency-version: 5.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@xyflow/react"
  dependency-version: 12.11.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: cron-parser
  dependency-version: 5.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: fumadocs-core
  dependency-version: 16.14.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: fumadocs-ui
  dependency-version: 16.14.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: js-yaml
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: material-symbols
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: next
  dependency-version: 16.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: open
  dependency-version: 11.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: smol-toml
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: sql.js
  dependency-version: 1.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: zustand
  dependency-version: 5.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: onnxruntime-node
  dependency-version: 1.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(deps): pin onnxruntime-node to ~1.24.3 to match @huggingface/transformers dedupe

The production-group bump raised onnxruntime-node to ~1.27.0, which breaks
npm's dedupe against @huggingface/transformers (pinned to onnxruntime-node
1.24.3), reintroducing the nested-copy/SONAME conflict on libonnxruntime.so.1
that #10543 already fixed. Revert only this one dependency back to ~1.24.3;
the other 13 bumps in the group are kept.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:53:41 -03:00
dependabot[bot]
3d27c19d17 deps: bump electron from 43.3.0 to 43.4.0 in /electron (#10622)
Bumps [electron](https://github.com/electron/electron) from 43.3.0 to 43.4.0.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v43.3.0...v43.4.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 43.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-18 10:53:37 -03:00
Krishna lokhande
671aa3d80d fix(oauth): treat Kiro social poll status as alias of error for pending states (#10620)
Kiro's device poll endpoint reports progress in a `status` field (e.g.
`authorization_pending`), but `classifyKiroSocialPoll()` only inspected
`data.error`. This caused every pre-authorization poll to fall through to
the terminal `invalid_token_response` error, making social login impossible
for Kiro AI and Amazon Q via Google/GitHub.

Changes:
- Add `status` field to `KiroSocialPollData` type
- Update `classifyKiroSocialPoll()` to check `data.status` as fallback
  for `data.error` when detecting pending states
- Add tests covering `status`-based pending detection and precedence

Closes #10618
2026-08-18 10:53:29 -03:00
Ravi Tharuma
5a44c46b1d feat(resilience): scope auto-disable banned accounts to subscriptions (#10617)
* feat(resilience): scope auto-disable banned accounts to subscriptions

Prepaid API keys should stay in the routing pool after a permanent-ban
signal; subscription/OAuth accounts can still be deactivated. Default
scope remains all so existing installs do not change.

* docs(security): document auto-disable scope and log skipped prepaid keys

Keep the operator ban-detection page aligned with the new setting and
reuse the shared scope enum in the settings schema and dashboard radios.

* chore(changelog): name the auto-disable scope fragment for #10617

* docs(settings): treat free login seats as auto-disable targets

The first-cut scope is still all vs login-style auth. Copy now states
that paid subscriptions and free accounts both disable, while prepaid
API keys stay in the pool until per-account overrides exist.

* i18n: backfill autoDisableBannedScope keys across all locales

npm run i18n:sync-ui — the 6 new autoDisableBannedScope* keys landed
in en.json and vi.json but not the other 40 locales (including
pt-BR), tripping the pt-BR no-drift regression test (#6695).

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

---------

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:53:24 -03:00
Reza Rezaei
d6242b7267 fix(auth): add missing state parameter to OIDC authorization URL (#10614)
* fix(auth): add missing state parameter to OIDC authorization URL

The OIDC login route generates a state UUID and stores it in the
oidc_state cookie, but never includes it in the authorization URL.
This causes the OIDC callback to receive state=null, failing with
'oidc_error=missing_code' because the provider has no state to echo.

Add url.searchParams.set('state', state) after setting scope, so the
state parameter is sent to the OIDC provider and returned in the
callback for proper CSRF protection.

* test(auth): add regression coverage for OIDC login state parameter

Adds a TDD regression test proving the fix in this PR: the OIDC login
route now includes the state query parameter in the authorization
redirect URL, and it matches the oidc_state cookie value set on the
same response. Modeled on tests/unit/oidc-callback.test.ts.

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

* fix(auth): use originEarly for OIDC err redirects (#10224)

* test(auth): verify OIDC err redirects use proxy origin (#10224)

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:53:18 -03:00
ataozkn
1ac086954a fix(cli): let setup --api-key reach the provider setup path (#10613)
* fix(cli): let setup --api-key reach the provider setup path

`bin/cli/program.mjs` declares a program-level `--api-key` (the OmniRoute server
key) and `bin/cli/commands/setup.mjs` declares its own `--api-key` (the provider
key). Commander binds the value to the program-level option, so the subcommand's
`opts.apiKey` was always `undefined` and

    omniroute setup --non-interactive --add-provider \
      --provider openrouter --api-key sk-...

aborted with "Provider API key is required. Pass --api-key or OMNIROUTE_API_KEY."
— naming the very flag that had just been passed. The documented headless setup
path was unusable; the only way in was `omniroute keys add`.

Fall back to the program-level value in a small exported helper. This also makes
`OMNIROUTE_API_KEY` satisfy the provider key, which the error message already
promised (that env var feeds the program-level option).

Tests cover the real Commander flag shape, the env-var path, and precedence when
both are supplied.

* chore(changelog): use the real PR number for the fragment
2026-08-18 10:53:14 -03:00
ataozkn
f89005b3ad fix(cli): derive machine-id token under plain Node and honor salt rotation (#10612)
* fix(cli): derive machine-id token under plain Node and honor salt rotation

`getCliToken()` destructured `machineIdSync` off `await import("node-machine-id")`.
That module is CommonJS, so under plain Node its exports land on `.default` and the
destructured binding is `undefined`. Calling it threw, the bare catch blanked the
token, and every management request went out with no `x-omniroute-cli-token` header
— silently unauthenticated, 401 on every `omniroute combo` / `usage budget` call.

Resolve the binding the same way `src/lib/machineToken.ts` already does, and read
`OMNIROUTE_CLI_SALT` so the rotation documented in docs/security/CLI_TOKEN.md
actually reaches CLI processes (the salt was hardcoded). The catch now logs instead
of failing mute, per the error-handling convention in CONTRIBUTING.md.

The existing test asserted `token === "" || token.length === 32`, so the blanked
token passed. Tightening it in-process is not enough either: the suite runs under
`tsx/esm`, which resolves CJS named exports and hides the bug. The regression test
therefore spawns plain `node` — the loader the CLI actually runs under.

Both new tests fail on the previous code and pass on this one.

* chore(changelog): use the real PR number for the fragment
2026-08-18 10:53:09 -03:00
Jan Leon
7d92aa7527 fix(streaming): preserve completed Codex tool handoffs (#10608) 2026-08-18 10:53:05 -03:00
monem
735d2c9659 fix(api): accept .opus uploads on /v1/audio/transcriptions (#10607)
Whisper-compatible upstreams pick the decoder from the multipart filename
against an allow-list (flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav,
webm) that has no `opus`, and OmniRoute forwarded the client's filename
verbatim. The same bytes transcribed as `note.ogg` and 400'd as
`note.opus`. Since /v1/audio/speech emits audio/opus for
`response_format=opus`, clients re-uploading their own voice notes hit
this on every round trip.

A `.opus` file is Opus in an Ogg container (RFC 7845), so `.ogg` is a
truthful relabel and is already on the allow-list. Rewrite the extension
in getUploadedFileName, the single choke point feeding
buildMultipartBody.

The OpenRouter STT path had the same root cause with a quieter symptom:
`.opus` matched neither its extension list nor its MIME map, so it fell
through to the "wav" default and announced Opus bytes as WAV. Map both
the extension and audio/opus to its already-supported ogg container.

Fixes #10588
2026-08-18 10:53:01 -03:00
Sahil Singh
70f94685e6 fix(gemini): inject missing items schema for array typed mcp tools (#10578) (#10605) 2026-08-18 10:52:56 -03:00
Patryk Mikołajczyk
a7b96b44e9 fix(xai): cap chat history at xAI 800-message limit (#10601)
* fix(xai): cap chat history at xAI 800-message limit

xAI returns 413 when messages/input exceed 800 items. Token
compression never fires on a long tool loop that still fits the
context window, so trim at the executor edge after Responses
expansion and drop orphaned tool pairs from the cut.

* chore(changelog): attach PR number to xAI 800-message fragment

* fix(xai): resolve TS2339 generic assignment in capXaiRequestHistory

Drop the T extends Record<string, unknown> generic on
capXaiRequestHistory and type it directly as
Record<string, unknown> -> Record<string, unknown>. Assigning
next.messages / next.input onto a generic T was rejected by
TypeScript even though every call site already passes/consumes a
JsonRecord (= Record<string, unknown>), so no caller relied on the
generic preserving a narrower type.

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

---------

Co-authored-by: mikolaj92 <mikolaj92@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:52:52 -03:00
Ravi Tharuma
6003612000 fix(audio): fall back nested STT models when the prefix provider has no credentials (#10584)
* fix(audio): fall back nested STT models when the prefix provider has no credentials

Bare ids such as deepgram/nova-3 prefix-match the native provider and 400
when that key is missing, even if OpenRouter lists the same model. Retry
the gateway and mention qualified catalog ids in the error.

Closes #10583

* test(audio): scope whisper-1 fallback test to a 2-provider registry

nanogpt was added to AUDIO_TRANSCRIPTION_PROVIDERS (already merged,
unrelated to this fix) with a bare "whisper-1" model id, which now
intercepts findAlternateAudioProvider's first candidate before the
qualified-alias branch this test exists to cover. Scope the test to a
local {openai, openrouter} registry subset so it deterministically
exercises the qualified `${provider}/${model}` fallback regardless of
future providers that also list a bare "whisper-1" id.

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

---------

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:52:48 -03:00
Ravi Tharuma
3d0ffb49a4 feat(providers): complete Jina + Gemini Embedding 2 multimodal via OmniRoute (#10581)
* feat(providers): complete Jina AI via OmniRoute including Omni multimodal

Dashboard and env keys share one Jina credential pool, native v5 Omni
{text}/{image}/{content} docs pass through /v1/embeddings intact, and
classify/segment/search are proxied without a third unused Jina card.

* chore(changelog): name Jina complete-provider fragment for #10581

* feat(providers): make Gemini Embedding 2 multimodal work via OmniRoute

Route gemini-embedding-2 through embedContent/batchEmbedContents so N
OpenAI input items become N vectors, pass through native multimodal
parts, and use dashboard Gemini keys (GEMINI_API_KEY only as fallback).

* fix(providers): resolve rebase fallout for Jina/Gemini embeddings

- narrow the two new no-explicit-any violations introduced by this PR
  (validateJinaFoundationProvider's params + catch, search.ts's
  normalizeJinaSearchResponse data param)
- cast credentials to Record<string, unknown> at the two quota-preflight
  call sites in src/sse/services/auth.ts so the new JinaEnvCredentials /
  GeminiEnvCredentials union members type-check without loosening the
  allRateLimited narrowing used elsewhere in the same function

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

---------

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:52:43 -03:00
Brandon Bennett
228ef6fba9 fix(mcp): make GitHub skill tools discoverable through omniroute_tool_search (#10575)
Add githubSkillTools to getAllToolDefinitions() so the searchable MCP
catalog matches TOTAL_MCP_TOOL_COUNT, which already counts them. The
GitHub skill tools were registered and counted but missing from the
catalog, so omniroute_tool_search could not surface them.

Adds regression tests at both layers: catalog aggregation and
client-visible discovery via the MCP client.

Co-authored-by: Brandon Bennett <brandonbennett@macbookair.myfiosgateway.com>
2026-08-18 10:52:38 -03:00
CyrixJD115
9222528bdd fix(opencode): session stability, free-tier routing, and CLI defaults (#10571)
* fix(opencode): session stability, free-tier routing, and CLI defaults

- Wire generateSessionId() into opencodeHeaders so x-opencode-session
  is a deterministic fingerprint instead of randomUUID() per request,
  enabling upstream prompt caching across a conversation
- Thread request body through buildHeaders() so session fingerprint
  has access to model, system, messages, and tools
- Default CLI header synthesis to ON (opt-out via false), align
  values with 9router proven defaults (opencode/desktop/global)
- Auto-echo listing-valid model names for noAuth providers so
  response.model matches /v1/models listing
- Short-circuit free-tier model resolution to opencode provider first
  to prevent prefix inference misrouting when catalog is unreachable

* fix(opencode): make free-tier default flip self-consistent + add coverage

PR #10571 flipped OPENCODE_SYNTHESIZE_CLI_HEADERS to on-by-default and
changed the synthesized UA/client/project default values, but shipped
with 2 broken assertions in the existing #5997 regression test and no
coverage for the new session-fingerprinting, free-tier routing, or
noAuth echoModel logic (Hard Rule #18).

- Update tests/unit/opencode-cli-headers-synthesis-5997.test.ts to match
  the new on-by-default behavior and new default values; add an explicit
  opt-out coverage test so the forward-only path is still guarded.
- Fix 20 further test failures in tests/unit/opencode-executor.test.ts
  and tests/unit/refactor-buildHeaders-opencode.test.ts caused by the
  same default flip (pin OPENCODE_SYNTHESIZE_CLI_HEADERS=false for the
  characterization suites that predate #10571; use a genuinely
  CLI-looking UA where the preserved-UA test requires one).
- Fix a real bug found via TDD while adding the mandated free-tier
  routing regression test: the big-pickle/*-free short-circuit in
  open-sse/services/model.ts checked activeProviders?.has("opencode")
  literally, but getActiveProviderSet() canonicalizes every connection's
  provider id through resolveProviderAlias(), which rewrites "opencode"
  to "opencode-zen" via a manual override — so an active no-auth
  opencode connection could never satisfy the check. Now checks both
  opencode-family candidate ids. Proven with a test that fails on the
  original code and passes with the fix (both connections active with a
  stale synced catalog omitting big-pickle).
- Extract the noAuth-provider echoModel aliasing in chatCore.ts into a
  pure, directly-testable helper (open-sse/handlers/chatCore/noAuthEchoModel.ts),
  matching the existing chatCore god-file decomposition pattern.
- Add regression tests for generateSessionId()-based x-opencode-session
  fingerprinting (stable within a conversation, changes on model/message
  changes), the free-tier routing short-circuit, and the noAuth echoModel
  aliasing.
- Add the changelog.d/ fragment and sync docs/reference/ENVIRONMENT.md's
  OPENCODE_SYNTHESIZE_CLI_HEADERS/OPENCODE_USER_AGENT/OPENCODE_CLIENT/
  OPENCODE_PROJECT rows to the new defaults.

Does NOT resolve whether flipping OPENCODE_SYNTHESIZE_CLI_HEADERS's
default was the right call, and does NOT touch the separate open PR
#10357 which flips the same flag with a different literal default value
- that decision is left to the maintainer at merge time.

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:52:33 -03:00
Ravi Tharuma
c2dbe2f1fb docs: add embeddings client runbook for Gemini 2 and Jina omni (#10569)
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:52:29 -03:00
Ravi Tharuma
c767494ae4 feat(api): alias /v1/multimodal-embeddings to /v1/embeddings (#10568)
Jina-compatible clients POST /v1/multimodal-embeddings and currently get
HTTP 404 unknown_route from the catch-all.

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:52:24 -03:00
Ravi Tharuma
947a7c64d4 fix(providers): catalog OpenRouter Gemini Embedding 2 ids (#10566)
GET /v1/models listed google/gemini-embedding-001 but omitted
google/gemini-embedding-2 even though that id already returns 3072-d vectors.

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:52:20 -03:00
Ravi Tharuma
134ab8cabb fix(api): name working OpenRouter ids when Gemini embed creds are missing (#10565)
Native gemini-embedding-2 400s with a dead-end credentials error even though
openrouter/google/gemini-embedding-2 already serves 3072-d vectors.

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:52:16 -03:00
rinseaid
458ab1aac0 fix(vision): preserve high detail for inline images (#10554)
* fix(vision): preserve high detail for inline images

* fix(vision): scope high-detail image default to OpenCode clients

defaultImageDetail() was applied at prepareUpstreamBody, the shared
upstream-body prep path for every provider and format, not just the
OpenCode path the fix targets. Gate it on isOpencodeClient (the
existing User-Agent/x-opencode-* header signal already used for
bypassDefaultToolLimit at this call site) so non-OpenCode callers keep
the provider's own image detail default. Adds a regression test
covering a non-OpenCode caller against the same opencode-zen provider.

* fix(vision): document and test the global vs OpenCode-only detail scope

The OpenCode-only high-detail default in chatCore/upstreamBody.ts
(defaultImageDetail, gated on isOpencodeClient) forwards the caller's
own image_url.detail and was already correctly scoped in a prior
commit on this branch.

The internal vision-bridge describe self-loop (visionBridgeHelpers.ts)
is architecturally global: VisionBridgeGuardrail runs for every
caller/provider whenever the target model lacks vision support, and
there is no client-identity signal at that layer to gate on. Its
describe prompt explicitly asks the vision model to transcribe visible
text, so requesting "high" detail unconditionally is justified on its
own merits (OCR accuracy), independent of the OpenCode motivation.

Adds a compatibility assertion proving the Anthropic wire-format
branch of the same describe self-loop carries no `detail` field (it
has no such concept) and is therefore unaffected by this default, and
documents the split (OpenCode-only forwarding vs. global describe
default) in docs/security/GUARDRAILS.md.

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

---------

Co-authored-by: rinseaid <rinseaid@rinseaid.net>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:52:11 -03:00
Jonathan Bailey
0431dd84e7 fix(db): preserve native runtime drivers in standalone bundles (#10552) 2026-08-18 10:52:06 -03:00
Paco Cartones
20af3988cf fix(a2a): use a constant-time bearer compare in /api/a2a/tasks (#10544)
* fix(a2a): use a constant-time bearer compare in /api/a2a/tasks

* fix(a2a): drop new Function from tasks-auth test in favor of dynamic import

The regression test for the constant-time bearer compare loaded tokensMatch
and authenticateA2A by regex-extracting their source and eval'ing it via
new Function, which trips the repo's no-new-func/no-implied-eval ESLint
rules (error-level everywhere, including tests). Export both helpers as a
test seam from the route module (mirrors the existing
bridgeSecretMatches/authRouteInternals pattern) and import them directly
in the test instead. Also drops the now-unused eslint-disable directives.

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

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:52:01 -03:00
pageragatz
b9cd5ed138 feat(providers): optional AI Horde API key and live image catalog (#10542)
* feat(providers): optional AI Horde API key and live image catalog

Allow a registered Horde key on the no-auth connection and send it for
chat and image jobs. List only image models that currently have workers,
and generate through Horde's native async API.

# Conflicts:
#	open-sse/config/imageRegistry.ts
#	src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx
#	src/shared/constants/providers.ts
#	src/sse/services/auth.ts

* fix(providers): validate AI Horde keys against find_user

The OpenAI-compatible /v1/models probe returns 200 for any Bearer token
on oai.aihorde.net, so Check always succeeded. Use Horde's /v2/find_user
lookup instead; an empty key still counts as the optional anonymous path.

* chore(changelog): name the AI Horde fragment for #10542

* fix(images): harden AI Horde optional-key selection and outbound fetches

- Optional-key selection now honors connection health (rate-limit cooldown
  and terminal/unavailable test status) before handing a stored key back,
  rotating to the next healthy key or falling back to the anonymous no-auth
  path instead of using an unhealthy stored key.
- Route the Horde submit/check/status/cancel and catalog calls through the
  repository's bounded outbound-fetch helper (timeout, no more bare fetch())
  and route R2 image downloads through the established bounded remote-image
  fetch (SSRF host guard, DNS-rebinding pin, streaming byte cap, redirect
  limit) instead of an unbounded fetch().
- Extend the generation deadline to cover the full request lifecycle
  (catalog freshness check, submit, polling, and image download), and add a
  regression test proving that exceeding the deadline issues a DELETE
  cancel to Horde's API rather than only timing out locally.

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

---------

Co-authored-by: pqr <pqr@soraka.ititti.es>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:51:57 -03:00
Ke Jin
f92075bb63 fix(deepseek): align V4 reasoning efforts across DeepSeek and OpenCode Go (#10540)
* fix(deepseek): align V4 reasoning efforts

* docs(changelog): note DeepSeek effort fix

* fix(deepseek): align OpenCode V4 effort aliases

* test(deepseek): align effort alias expectation

* fix(deepseek): scope low effort to v4

* fix(opencode-go): route DeepSeek V4 through Responses
2026-08-18 10:51:52 -03:00
SnCr90
276b3dffa3 fix(sse): clear quota_exhausted cooldown when real window recovers (#10534)
* fix(sse): clear quota_exhausted cooldown when real window recovers

The claude-token-fallback combo was not auto-returning to Sonnet/Opus
after a subscription 429 recovered. maybeClearRecoveredQuotaState()
was honoring the synthetic 1h cooldown (SUBSCRIPTION_QUOTA_COOLDOWN_MS,
persisted when no upstream reset was parseable) instead of the REAL
per-window resetAt returned by the scheduled quota poller, so the
connection stayed locked long past the actual quota reset.

Add windowStillExhaustedAfterRealReset() and use it to decide recovery
per-quota-window: a quota_exhausted connection now clears as soon as no
governing window is still exhausted with a future-or-unknown real
reset, instead of waiting out the synthetic cooldown. Falls back to the
previous synthetic-cooldown guard when the fetch has no quota object at
all (degraded/failed shape) so existing behavior is unchanged there.

Preserves the existing kimi-coding partial-refresh semantics: an
exhausted window with no parseable resetAt still blocks recovery.

* fix(sse): preserve Claude extra-usage block from general quota recovery

maybeClearRecoveredQuotaState()'s new per-window recovery check (added in
this branch) only inspected usage.quotas, so a Claude connection blocked by
the extra-usage guard (lastErrorSource: "extra_usage") could be released
just because the session/weekly quota windows looked recovered, even while
extraUsage.queued was still true. Extra-usage blocking is orthogonal to
quota-window exhaustion and must only be released by
syncClaudeExtraUsageStateIfNeeded (buildClaudeExtraUsageConnectionUpdate).

Add a guard that keeps the connection locked when lastErrorSource is
"extra_usage", the blockExtraUsage policy is still enabled, and the fresh
usage snapshot still reports extraUsage.queued === true.

Add an integration test walking the real
fetchLiveProviderLimitsWithOptions -> syncClaudeExtraUsageStateIfNeeded ->
maybeClearRecoveredQuotaState call chain with recovered quota windows but
extraUsage.queued=true, asserting the connection stays unavailable with
lastErrorSource still "extra_usage".

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:51:47 -03:00
Ke Jin
9a433775e7 fix(models): correct Codex context and combo limit resolution (#10533)
* fix(models): honor Codex combo context overrides

* test(codex): align discovery context expectation

* test(models): align Codex route limits

* test(models): align remaining Codex route limits
2026-08-18 10:51:42 -03:00
Bob.Hou
ebf0bf913a fix(settings,auth): default debugMode to false and skip account rotation on model-unsupported 400 (#10525)
* fix(settings,auth): default debugMode to false and skip account rotation on model-unsupported 400

* fix(auth): disambiguate model-unsupported from auth-credential 400

The model-unsupported guard used MODEL_ACCESS_DENIED_PATTERNS directly,
which also matches auth-credential errors like 'invalid api key for
model X'. Add the AUTH_CREDENTIAL_ERROR_PATTERNS exclusion (same as
checkFallbackError) and use provider_model_unsupported log reason.

Addresses maintainer feedback on PR #10525

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

* fix(auth): narrow model-unsupported guard to avoid misclassifying account-scoped entitlement 400s

The #10460 guard reused MODEL_ACCESS_DENIED_PATTERNS directly, which also
matches ambiguous "access"/"permission" phrasing (e.g. "does not have
permission to access this model") that commonly signals an ACCOUNT-scoped
entitlement gap (PRO vs free tier) rather than a genuinely provider-wide
unsupported model — a different account of the same provider may still
have access, so those must keep rotating normally instead of being
short-circuited.

Extract isProviderModelUnsupported400() in accountFallback.ts: reuses the
same AUTH_CREDENTIAL_ERROR_PATTERNS exclusion checkFallbackError's 400
branch already applies, narrowed to a strict subset of unambiguous
"provider does not serve this model at all" phrasings. auth.ts now calls
this shared helper instead of testing the broader patterns in isolation,
and exposes the sanitized reason ("provider_model_unsupported") on the
returned result, not just in the log line.

Also fix DATA_DIR test-isolation ordering in
account-fallback-service.test.ts: it was assigned after the first
dynamic import of accountFallback.ts, which transitively imports
src/lib/db/core.ts (DATA_DIR is captured once at module-load time), so
the intended isolated test directory was silently never used. Move the
assignment before any transitive DB import, and add regression tests for
the 3-account rotation contract: exactly one upstream call for an
unambiguous provider-wide 400 with the combo advancing to the next
target, continued rotation for account-scoped 401/403/429 and for the
permission/entitlement 400 case that motivated this narrowing.

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

---------

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:51:38 -03:00
Aman
9392b30575 fix(compliance): redact extra provider API keys (#10521) 2026-08-18 10:51:34 -03:00
Aman
daae6e6fb5 fix(providers): test token-backed web sessions (#10519)
* fix(providers): test token-backed web sessions

* fix(providers): restrict token-web-session test dispatch to validated providers

Narrow shouldUseApiKeyConnectionTest to the token-kind web-session providers
that actually have a token-aware connection validator (deepseek-web, kimi-web,
tinycms-web, copilot-m365-web, copilot-web, zai-web). WEB_SESSION_CREDENTIAL_REQUIREMENTS
marks more providers as kind: "token" than have a matching validator in
SPECIALTY_VALIDATORS (hailuo-web, microsoft-designer-web, t3-chat-web, promptql) — those
were falling through to the generic cookie-based validateWebCookieProvider probe, which
sends the stored credential as a Cookie header and treats most non-401/403 responses as
valid, so an invalid token could be reported as a healthy connection.

Add regression coverage for hailuo-web and promptql (plus microsoft-designer-web and
t3-chat-web) proving they stay off the API-key test path, and for every currently
validated token-kind provider proving they still use it.

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:51:29 -03:00
Diego Rodrigues de Sa e Souza
83c1d3c659 fix(dashboard): count live usage_history rows in Free Tier 'used this month' (#10381) (#10509)
* fix(dashboard): count live usage_history rows in Free Tier 'used this month' (#10381)

* fix(dashboard): use an indexable UTC month-range predicate for used-this-month (#10381)

sumUsageTokensThisMonth() filtered usage_history with
substr(timestamp, 1, 7) = strftime('%Y-%m', 'now') — a substr() expression
SQLite cannot use a range index on, and fragile against any timestamp
that isn't exactly ISO-shaped. Replace with an indexable inclusive-start/
exclusive-end UTC range: timestamp >= <month start> AND timestamp <
<next month start>, matching the ISO 8601 format saveRequestUsage()
already writes.

Adds a boundary regression test: the first instant of the current month
is included, the last instant of the previous month is excluded, and a
next-month row is excluded too (covers the upper bound substr() could
never express).

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:51:25 -03:00
Diego Rodrigues de Sa e Souza
9500adb013 fix(combo): surface context-overflow before compression so oversized requests fail fast with a clear error (#10225) (#10503)
* fix(combo): surface context-overflow before compression so oversized requests fail fast with a clear error (#10225)

* fix(combo): make context-overflow deferral target-aware for native Codex passthrough (#10225)

The deferral added by the prior commit checked only operator-named
compression exclusions when deciding whether at least one target "can
compress" — it never accounted for native Codex Responses passthrough
targets, which chatCore.ts unconditionally excludes from compression
(compressionExcluded = nativeCodexPassthrough || ...). Deferring on such
a target's account let an oversized request skip both the combo preflight
AND compression, reaching fetch() uncompressed.

Thread the same request-shape facts chatCore.ts uses
(shouldUseNativeCodexPassthrough: provider/sourceFormat/endpointPath/body/
headers) down into getKnownContextOverflow so the deferral decision can
never drift from chatCore's own — a native-codex-passthrough target now
never counts as "compressible", so a pool made only of such targets keeps
the fast local 400 instead of a wasted round trip.

Adds regression coverage: the pure getKnownContextOverflow target-aware
check, an end-to-end handleComboChat proof that a native-codex-only pool
fails fast with zero dispatches, and two real handleChatCore-path tests
proving compression actually reduces the dispatched body when eligible,
and that a still-too-large-after-compression request is rejected locally
without an upstream call.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:51:20 -03:00
Diego Rodrigues de Sa e Souza
2230fbbe93 fix(resilience): keep combo quality and auth reasons separate and redact connection labels in terminal errors (#10314) (#10501)
* fix(resilience): keep combo quality and auth reasons separate and redact connection labels in terminal errors (#10314)

* fix(resilience): sanitize identifiers in error text, add explicit terminal-status policy, fix classifier ordering (#10314)

Four gaps in the prior combo-error-aggregation fix:

- formatComboOutcomes() only redacted connection identifiers in the model
  label, never in the raw upstream error TEXT — a proxy echoing a
  connection/account id back in its error body leaked it into the
  client-facing terminal message. Redact both.

- The terminal HTTP status was still `lastStatus` — whichever target
  happened to fail last, independent of the other targets' reasons. Add
  resolveComboTerminalStatus(): preserve a 4xx only when every eligible
  target's failure is genuinely "the request is invalid" (model-class);
  a heterogeneous mix (e.g. a quality failure + a sibling's 401) now
  normalizes to a 5xx-class status reflecting an infra/provider problem,
  never a misleading client error borrowed from an unrelated target.

- classifyComboOutcome()'s ordering had `status === 408 || status >= 499`
  checked before `status >= 500`, making the provider branch permanently
  unreachable — every real 5xx (500/502/503/504) was silently mislabeled
  as "timeout". Fixed to an exact match (408/499) and gave 429 its own
  explicit `rate_limit` kind instead of falling into the generic "model"
  (request-invalid) bucket by accident.

- Added an integration-level regression driving the real handleComboChat
  wiring end-to-end (quality failure + sibling 401, and a success-after-
  quality-failure case), not just the pure aggregation helpers.

Updated three pre-existing tests whose assertions encoded the OLD
last-writer-wins contract this fix intentionally supersedes (#8486 Part B
antigravity retryAfter tests, two combo-routing-engine status/message
tests) to the new, more precise contract; verified the underlying #8486
concern (wrong target's retryAfter header) is still honored under the new
status policy.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:51:16 -03:00
Diego Rodrigues de Sa e Souza
da42ed6d2e fix(providers): fall back to public Code Suggestions endpoint on GitLab Duo direct_access 401 (#10365) (#10499)
* fix(providers): fall back to public Code Suggestions endpoint on GitLab Duo direct_access 401 (#10365)

* fix(providers): extend GitLab Duo 401 fallback to the connection-test path (#10365)

The chat-completion path (open-sse/executors/gitlab.ts) already falls back to
the public Code Suggestions completions endpoint when the direct_access
exchange is rejected with 401, but testOAuthConnection() / the dashboard
Retest button still reported the connection unhealthy on the same 401 —
even though a real chat request through that connection would have
succeeded via the fallback. Apply the identical fallback contract to the
connection-test path (first attempt and the post-refresh retry), sharing the
predicate with the executor via shouldFallbackToPublicCodeSuggestions.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:51:12 -03:00
abhiisalright
15386495c2 fix(compression): add i18n support for less-code and terse-prose (#10498)
* fix(compression): add i18n support for less-code and terse-prose

Translates less-code output style to pt-BR, vi, ja, and id. Adds missing vi translation to terse-prose caveman mode. Removes less-code from English-only allowlist and updates matrix tests.

Fixes #10426

* docs(compression): add output styles coverage table

Adds the requested Output Styles matrix to the compression guide covering styles, supported languages, and intensity levels.

Fixes #10426
2026-08-18 10:51:08 -03:00
Markus Hartung
02a987003d fix(cli): recognize {connections} envelope from /api/providers (#10491)
* fix(cli): recognize {connections} envelope from /api/providers

GET /api/providers returns {connections, total} (src/app/api/providers/
route.ts:78), but `omniroute test --all-providers` and `omniroute oauth
providers` both parsed the response as `data.providers ?? data.items ??
data` -- an object, not an array -- so `.filter` threw
"(data.providers ?? data.items ?? data).filter is not a function" on
every call. keys.mjs already had the correct fallback chain
(`data.keys || data.connections || data.items || data`); apply the same
`connections` field to both remaining call sites.

* test(cli): cover provider connections envelope

Add regression coverage for both CLI consumers of the /api/providers connections envelope.

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:51:03 -03:00
realize000
4c5535be4e Update SETUP_GUIDE.md (#10490)
* Update SETUP_GUIDE.md

* docs: correct Windows data directory note

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:50:58 -03:00
Rouzbeh†
497dd6f357 fix(memory): auto-check Qdrant health on mount and stop false-red badge (#10489)
* fix(memory): auto-check Qdrant health on mount and stop false-red badge

The Qdrant engine card on /dashboard/memory?tab=engine showed a red
"Error" badge after every page refresh even when Qdrant was healthy:
the badge derives its state from a health check, but the mount effect
only fetched settings + embedding models — health started as null and
the render treated `health?.ok` (undefined) as a failure. Clicking
"Test connection" (which runs the same server-side /readyz check)
immediately turned it green, proving the connection was fine.

Two changes:
- Auto-run the health check on mount once settings load and Qdrant is
  enabled, so a refreshed page reflects the real state (verified live:
  /api/settings/qdrant/health returns ok:true in ~2ms on a healthy
  compose deployment).
- While health has not been checked yet (null), render a neutral gray
  "Testing..." state instead of red — red is now reserved for an
  actual failed health check.

Regression test added (fails on the old code): with enabled settings
and a healthy mock, the card must hit /api/settings/qdrant/health on
mount and show statusActive, never statusError.

* chore(changelog): fragment for #10489

* Merge branch 'release/v3.8.50' into fix/qdrant-health-badge

* test(fix): refresh expired alibaba quota sample validity and onnxruntime pin for v3.8.50 base

- alibaba-free-tier-quota-fetcher.test.ts: sample quotaValidityPeriod
  (2026-08-16 16:00 UTC) is in the past, making every quota entry classify
  as expired/not_capable; bump to 2028-01-01 UTC so the text/merge
  classification tests exercise the intended path again.
- optional-transformers-dependency.test.ts: onnxruntime-node pin assertion
  updated from ~1.24.3 to ~1.27.0 to match package.json (bumped by #10403);
  the regular-not-optional intent is unchanged.

* test(fix): align optional-transformers-dependency with onnxruntime ~1.24.3 pin (base #10543)

* docs(fix): sync 150-migration count and document PROXY_LOG_INCLUDE_IPS (base drift #10348/#10507)

* fix(memory): re-check Qdrant health after saving settings

save() optimistically flipped enabled and started the PUT while the mount
effect could immediately GET /api/settings/qdrant/health against the OLD
persisted settings. If that GET won, it returned not_configured/failed and -
because health was non-null - the effect never retried after the PUT
succeeded, leaving a healthy Qdrant red until a manual Test connection.

Invalidate health (generation counter + setHealth(null)) at save start and
after a successful PUT, then explicitly schedule a fresh check: setting
health to null alone is not enough, React bails on the no-op when health is
already null (the exact GET-wins ordering). Stale responses are dropped via
the sequence guard so an in-flight pre-save check can never overwrite the
post-save result. Adds a regression test covering enable ordering.

Addresses PR #10489 review finding (issuecomment-5312271806).

* fix: narrow omniglyph transform result union (merge base aa912c42a typecheck gate)

* test(compression): align contract tests with base aa912c42a merge (providerTransport shape, engine metadata)

* fix(memory): silence set-state-in-effect on Qdrant auto health-check

The health-check re-check fix (3469234) introduced an effect that calls
checkHealth() (an async fetch that eventually calls setState) directly
from a useEffect gated on loading/enabled/health. The
react-hooks/set-state-in-effect rule flags this as a potential cascading
render, matching the same pattern already accepted elsewhere in the
dashboard (FreePoolTab.tsx, ConnectionsTable.tsx) for gated async
data-fetch effects. Suppress with the established inline convention;
no behavior change.

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

* fix(memory): drop unused set-state-in-effect disable (rule inert on pinned react-hooks 7.0.1)

The eslint-disable-next-line for react-hooks/set-state-in-effect is unused:
eslint-plugin-react-hooks@7.0.1 (lockfile-pinned) does not report this rule,
so the directive itself was flagged as a warning and the 'No new ESLint
warnings' CI gate failed with --max-warnings 0. The effect body only calls
checkHealth() (async fetch) with no raw setState, so no disable is needed.

* ci(quality): sync ratchet configs to release/v3.8.50 (0a74bfbde) merge

- re-freeze open-sse typecheck baseline at merged-tree live counts
  (64 stale entries dropped, 11 frozen; base video/usage drift covered)
- register tests/unit/video-bridge-drilldown-route.test.ts in stryker tap.testFiles
- regenerate skills/cli-contexts/SKILL.md (contexts migrate docs from CLI closure)

---------

Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:50:54 -03:00
Diego Rodrigues de Sa e Souza
50da54484e fix: downgrade adaptive thinking and gate context-1m beta on model eligibility (#10119) (#10481)
* fix(sse): downgrade adaptive thinking and gate context-1m beta on model eligibility (#10119)

* fix(sse): thread resolved model into DefaultExecutor's anthropic-beta merge (#10119)

DefaultExecutor.buildHeaders() merged the client-negotiated anthropic-beta
header without ever passing the resolved target model into
mergeClientAnthropicBeta(), so the context-1m-2025-08-07 eligibility gate
added earlier in this PR could not see which model a combo/fallback had
actually routed to at this call site. buildHeaders() now accepts an
optional model parameter (mirroring BaseExecutor.buildHeaders' existing
signature and the pattern already used by grok-cli.ts/qoder.ts) and
forwards it through, so an ineligible model target (e.g. Haiku) has the
beta dropped instead of forwarded blind.

Restores a CHANGELOG bullet (PR #10366) that a prior merge auto-resolve
had dropped from this branch.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:50:49 -03:00
Diego Rodrigues de Sa e Souza
7a6fcfc74d fix: resolve adaptive latency-collapse self-lock with solo-progress and idle recovery (#10111) (#10478)
* fix(admission): resolve adaptive latency-collapse self-lock with solo-progress and idle recovery (#10111)

* fix(admission): refresh recovery ceiling on updateConfig (#10111)

updateConfig() clamped currentLimit to the new min/maxLimit but left
recoveryCeiling pinned to the value computed at construction time, so
a raised initialLimit could never recover past the stale ceiling and
a lowered one could leave the ceiling above the new maxLimit.
Recompute recoveryCeiling from the new initialLimit on every
updateConfig call, clamped to the (possibly also new) min/maxLimit.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:50:45 -03:00
Diego Rodrigues de Sa e Souza
97e504cdbf fix(sse): stop leaking upstream control lines to OpenAI-format clients (#10017) (#10473)
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:50:39 -03:00
Diego Rodrigues de Sa e Souza
e667ab12d1 fix(usage): wire agentrouter balance quota into dashboard Quota UI (#10078) (#10472)
* fix(usage): wire agentrouter balance quota into dashboard Quota UI (#10078)

* fix(usage): render AgentRouter wallet balance as USD in the Quota UI (#10078)

The prior fix wired AgentRouter's balance into getUsageForProvider() and
USAGE_SUPPORTED_PROVIDERS, but the actual dollar figure never reached the
Dashboard Quota UI: quotas.balance.remaining carried a synthetic two-state
percent (100/0) instead of the real dollarBalance, and the Provider Limits
renderer only formats a row as "$X.XX" when isCredits/currency/creditCount
are set, which the generic quota-parsing path never sets. A configured
balance rendered as a bare "100% left" percentage, not USD.

Shape quotas.balance.remaining as the real USD amount (clamped to 0) and add
an agentrouter branch to quotaParsing.ts that builds a credits-style row
(same buildCreditsQuota() pattern as DeepSeek/Claude extra-usage), so a
configured balance shows a currency-formatted dollar amount and an
exhausted balance always renders as exactly $0.00.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:50:35 -03:00
sha367
09680013de fix(providers): resolve combo names on /v1/audio/speech and /v1/videos/generations (#10471)
* fix(providers): resolve combo names on /v1/audio/speech and /v1/videos/generations

`GET /v1/models` advertises combos with `owned_by: combo`, and chat, embeddings,
transcriptions (#9134) and images (#8986, #9239) all resolve those names. Speech
and video did not: both rejected a combo name at model validation, before any
resolution could happen.

    POST /v1/audio/speech        {"model":"my-combo","input":"hi"}
    -> 400 Invalid speech model: my-combo. Use format: provider/model

    POST /v1/videos/generations  {"model":"my-combo","prompt":"a cube"}
    -> 400 Invalid video model: my-combo. Use format: provider/model

A client picking a model out of /v1/models therefore could not tell which
entries the catalogue would actually accept, and callers ended up hardcoding
vendor ids for these two routes while using combo names everywhere else.

Both routes now mirror the images route: detect a combo name before the
provider lookup and divert to a strategy executor. The two new executors follow
imageCombo — expand targets with resolveComboTargets(), filter to targets the
route can actually serve, walk them in priority order, and return the first
success or the last failure, with 400/401/403 treated as terminal.

Two details differ from the image strategy:

Speech filters at model level rather than provider level. parseSpeechModel()
resolves a provider prefix without checking that the model behind it can speak,
so `openai/gpt-4o` would otherwise be accepted as a target and fail only once
dispatched. The filter now checks the provider's own model list, and keeps
targets from dynamic provider nodes that do not enumerate models.

Speech also returns the handler's Response untouched instead of building a JSON
body, because that route streams audio; only the ADD-only meta headers are
attached, exactly as the direct path does. The failure branch is the only place
the body is read.

successfulMediaGenerationResponse() gains optional `strategy` and
`fallbackAttempts` so the video strategy can report them the way imageCombo
does, rather than duplicating the cost calculation. Both are omitted on the
direct single-model path, where neither is meaningful.

Tests mirror tests/unit/combo/image-combo.test.ts for both routes: combo not
found, no capable targets, empty combo, and targets present with no provider
connection. 16/16 pass across the three combo test files.

* fix(providers): preserve local overrides, custom models and per-target prompt rules through video combo dispatch

executeVideoCombo() diverged from the direct /v1/videos/generations route in
three ways: it dropped the ComfyUI-style local-override credential lookup for
authType:"none" targets, its capability filter only matched the built-in
video registry (skipping custom OpenAI-compatible provider nodes tagged with
the "videos" endpoint), and the route validated the prompt against the
unresolved combo name before combo targets were expanded — rejecting
prompt-optional I2V targets that never got the chance to opt out.

Extracts the shared resolution rules (resolveVideoModelTarget,
isVideoPromptOptional, resolveLocalOverrideCredentials) into
src/app/api/v1/_shared/videoModelResolution.ts so the direct route and the
combo executor apply identical rules, moves the combo-name diversion ahead of
the prompt-required check so validation runs against the real resolved
target, and adds per-target prompt validation inside the combo loop so a
missing prompt only rules out that target instead of the whole combo.

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:50:31 -03:00
Diego Rodrigues de Sa e Souza
a55daacc49 fix(dashboard): send periodic WS heartbeat pings to stop live-dashboard reconnect churn (#10452)
* fix(dashboard): send periodic WS heartbeat pings to stop live-dashboard reconnect churn

The live-dashboard WS client (src/hooks/useLiveDashboard.ts) only sent a
subscribe frame on open and never emitted the protocol's { type: "ping" }
heartbeat. The server (src/server/ws/liveServer.ts) refreshes client
liveness only from inbound messages and terminates any client idle past
HEARTBEAT_TIMEOUT_MS (35s), so a healthy, connected-but-idle dashboard
client was force-terminated roughly every 35-45s, causing constant
reconnect churn (#10319).

Fix (both directions, per the analyzed plan):
- Client: start a 15s ping interval on open, cleared on close/unmount/
  reconnect, so the connection stays inside the server's liveness window.
- Server (defense in depth): the outbound heartbeat pong now also bumps
  client.lastActivity, so even a third-party client that never pings is
  not dropped for being idle.

Regression coverage:
- tests/unit/useLiveDashboard-heartbeat.test.tsx: fast fake-timer check
  that the hook emits periodic ping frames and cleans up the interval on
  close/unmount (no leaked timers).
- tests/integration/live-ws-heartbeat-keepalive.test.ts: real WS-server
  integration test asserting a silent-but-subscribed client stays
  connected past the 35s heartbeat timeout (~50s window), converted from
  the plan file's TDD RED repro.

Closes #10319

* fix(dashboard): stop renewing stale LiveWS sockets

Keep application-level heartbeat responses from refreshing server liveness, and add a regression covering silent stale sockets alongside clients that answer protocol heartbeats.

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

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:50:26 -03:00
Diego Rodrigues de Sa e Souza
8dc797fecd fix(dashboard): make provider card warning indicators expose the interaction they advertise (#10448)
* fix(dashboard): make provider card warning indicators expose the interaction they advertise

The usage-risk indicator (subscriptionRisk) promised "click for details" in its
tooltip but was a bare <span> with no onClick/role/dialog. The connection
warning-count badge exposed neither a title tooltip (reasons) nor any click
affordance, even though the reasons already exist in
providerSpecificData.apiKeyHealth[].

Turn the risk indicator into a real <button role/aria-haspopup="dialog"> that
opens an accessible Modal reusing the existing riskNotice copy, and wrap the
warning badge in a keyboard- and pointer-interactive control that surfaces a
sanitized reasons summary (max failure count + relative last-failure time,
never raw upstream error text) and navigates to the connection detail/health
view on activation. Both indicators are now visually distinct (bare icon vs.
pill Badge).

Closes #10261

* i18n(providers): sync riskNotice.detailsTitle + warningNotice keys to all 42 locales (#10261)

Real Vietnamese translations (vi.json has a strict no-__MISSING__-marker gate);
other 41 locales carry the sync-ui __MISSING__ placeholder pending the normal
translation pass.

* test(dashboard): relocate provider warning regression test

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

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:50:20 -03:00
Diego Rodrigues de Sa e Souza
7f0404bf82 fix(open-sse): stop concurrent requests colliding on dedup hash for non-OpenAI formats (#10438)
* fix(open-sse): stop concurrent requests colliding on dedup hash for non-OpenAI formats

computeRequestHash() in requestDedup.ts projected the prompt content from
body.messages only. The dedup site in chatCore.ts hashes the *translated*
(target-format) request body, and non-OpenAI target formats don't carry a
messages field: Gemini-translated bodies use `contents`, Responses-API
bodies use `input`. So for those formats messages was always undefined,
every prompt hashed to the same null-backed value for a given model, and
concurrent requests with different prompts joined the same in-flight
promise -- the second caller silently received the first caller's
response verbatim (#10249).

Fix: project body.messages ?? body.contents ?? body.input ?? null instead
of only body.messages, keeping the rest of the canonical hash projection
unchanged. Genuinely identical concurrent requests still dedupe (the
intended perf behavior); different prompts under Gemini/Responses-API
target formats no longer collide.

Regression test: tests/unit/request-dedup-10249.test.ts reproduces the
two collision scenarios from the plan-file (Gemini `contents`,
Responses-API `input`), confirms the OpenAI `messages` case was already
correct, and asserts identical-request dedup keeps working. Verified
RED (byte-identical hashes 0b24fd88.../dc16d5b7... pre-fix) -> GREEN
(distinct hashes, dedup preserved) against this exact diff.

* fix(open-sse): cover nested translator shapes + system fields in dedup hash (#10438)

computeRequestHash() only read top-level body.messages ?? body.contents ??
body.input, but several translated request shapes nest their prompt
content: the Antigravity Cloud Code envelope under request.contents, and
Kiro under conversationState.currentMessage.userInputMessage.content (plus
conversationState.history). Two different concurrent prompts to those
targets could hash identically and share/leak a response between callers.

Adds extractPromptContent()/extractSystemContent() helpers covering every
prompt-bearing shape produced by open-sse/translator/request/*.ts
(OpenAI/Cursor messages, Claude messages+system, Gemini contents+
systemInstruction, Responses input+instructions, Antigravity and Kiro
nesting), and folds system/instructions/systemInstruction into the
canonical hash so two requests with the same user message but a different
system prompt no longer collide either.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:50:15 -03:00
Diego Rodrigues de Sa e Souza
d49ccdaaf1 fix(sse): gate structural chat admission shedding on real heap pressure (#10437)
* fix(sse): gate structural chat admission shedding on real heap pressure

Closes #10183, Closes #10268

3.8.49 (#9654/#9940) replaced the 3.8.48 heap-ratio shed
(heapUsed/heapLimit >= 0.75) in chatBodyAdmission.ts with an
unconditional CHAT_MAX_HEAVY_IN_FLIGHT=1 structural lease. A second
concurrent "structurally heavy" chat request (>=200 messages, >=64
tools, or >=32k estimated tokens — routine for coding-agent fan-out
like Hermes/Cursor/Claude Code) was hard-rejected with a retryable
HTTP 503 chat_admission_busy/structure_limit regardless of actual
heap pressure, even on a host with ample free RAM.

Restore the heap-conditional gate as an ADDITIONAL check layered on
top of (not a replacement for) the #9654 bounded-concurrency /
per-connection-lane protection: when heavyweight capacity is busy,
only enter the bounded-wait/shed path when a live heap-pressure probe
(heapUsed / v8 heap_size_limit >= OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO,
default 0.75) confirms real pressure. A healthy heap now admits the
second heavy request immediately via a no-op lease instead of parking
or shedding it. The probe is injectable via
admitChatStructure({ heapPressureCheck }) for deterministic tests.

Regression tests:
- tests/unit/bug-10183-admission-heavy-healthy-heap.test.ts (new,
  permanent): healthy-heap 2nd heavy request now admitted (was RED);
  genuinely pressured heap still sheds it.
- tests/unit/probe-10268-structural-503.test.ts (promoted to
  permanent): the exact reported 503 chat_admission_busy shape is
  still produced under real heap pressure, and the same fan-out is
  admitted on a healthy heap.
- tests/unit/chat-body-admission.test.ts,
  tests/unit/chat-body-admission-queue.test.ts,
  tests/unit/per-connection-admission-9654.test.ts updated to inject
  heapPressureCheck: () => true where they exercise the busy/shed
  path, preserving #9654/#4380 coverage.

Gates run: npm run typecheck:core (clean), eslint --suppressions-location
config/quality/eslint-suppressions.json on changed files (clean),
scripts/check/check-file-size.mjs (OK), scripts/check/check-test-discovery.mjs
(OK), focused admission suite (68/68 passing) and npm run test:unit
(in progress at commit time under heavy shared-devbox contention from
a 13-way parallel session fan-out; no admission-related failures
observed through 1873 lines of output, the sole failure seen was a
pre-existing unrelated proxy/search timeout consistent with known
load-induced flakiness, not a regression from this change).

⚠️ base-red inherited: #9985 — ESLint errors (2) from #10250

* docs(env): document OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO (#10183, #10268)

* fix(sse): bound the healthy-heap admission fast path (#10437)

The #10183/#10268 fix admitted a busy heavyweight request immediately
whenever the heap was healthy, via an unconditional no-op lease with no
bound of its own -- an unlimited number of "healthy heap" requests could
pile in ahead of the heap-pressure shed path, defeating the point of
admission control.

Adds an independent, bounded healthy-heap headroom budget
(CHAT_ADMISSION_HEALTHY_HEADROOM, tryAcquireHealthyHeadroom()) that the
healthy-heap fast path draws from; once exhausted, requests fall through
to the same bounded-wait/shed path used under real heap pressure, which
is otherwise unchanged. Also fixes a pre-existing gap in
per-connection-admission-9654.test.ts's shared-budget test, which needed
an explicit heapPressureCheck override to keep exercising the #10110
invariant now that a healthy heap gets bounded headroom instead of an
outright reject.

* docs(env): document OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM in .env.example

Documented in docs/reference/ENVIRONMENT.md but missing from .env.example,
caught by the env-doc-sync gate when combined with other PRs in the
release merge-train.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:50:09 -03:00
Diego Rodrigues de Sa e Souza
5240afed42 fix(antigravity): strip trailing model turn for native Gemini requests too (#10436)
* fix(antigravity): strip trailing model turn for native Gemini requests too

Newer Gemini endpoints reject a request ending on a model turn with HTTP
400 'Requests ending with a model turn are not supported' — the same
rejection class Claude hits via Vertex. transformRequest() previously
wired stripTrailingAntigravityAssistantTurn() only into the isClaude
branch, so native Gemini models routed through Antigravity kept a
trailing role:model entry and hit the 400.

Extend the guarded strip (never empties contents) to native Gemini
models too, gated by upstreamModel including "gemini". The Claude
path is untouched (byte-identical), preserving PR #6114's live
validation against Vertex Claude.

Flips tests/unit/antigravity-claude-prefill-strip.test.ts test (b),
which previously asserted the buggy pass-through, and adds (b2) for
the gemini-3-flash-agent tier.

Closes #10104

* fix(antigravity): scope Gemini trailing-turn workaround

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

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:50:05 -03:00
Diego Rodrigues de Sa e Souza
a4d6ad7da4 fix(sse): bridge generic compatible-provider type id to concrete node id in credential lookup (#10434)
* fix(sse): bridge generic compatible-provider type id to concrete node id in credential lookup

getProviderSearchPool only bridged a provider string to a node id via the
node's prefix, never via the generic derived type id
(openai-compatible-chat / openai-compatible-responses / anthropic-compatible)
that resolveProviderNodeForConnection already accepts at connection-creation
time (#4421). A connection persisted under the generic type id was therefore
unreachable when the chat path resolved the concrete uuid node id, surfacing
"No active credentials for provider: openai-compatible-chat-<uuid>" even
though the key and model catalog were valid.

Closes #10085

* fix(sse): register #10085 mutation-coverage test file in stryker.conf.json

check:mutation-test-coverage --strict flagged
tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts as a
covering test for src/sse/services/auth.ts that was missing from
stryker.conf.json's tap.testFiles, per the CI Fast Quality Gates run
on PR #10434.

* fix(sse): disambiguate compatible provider credential lookup

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

* fix(sse): require unambiguous type in both credential-lookup bridge directions (#10434)

getProviderSearchPool()'s generic-type<->concrete-node-id bridge (#4421,
#10085) only applied the "exactly one node of this derived type" ambiguity
guard to the concrete-id -> generic-type direction. The generic-type ->
concrete-id direction added every node sharing a derived type to the
search pool unconditionally, so a bare generic-type lookup could resolve
to a connection scoped to one specific node's baseUrl/headers even when a
second node shares the same derived type -- leaking that node's
credentials/upstream URL into an unrelated node's request.

Both directions now share the same typeIsUnambiguous gate, mirroring the
rule already enforced by selectProviderNodeForConnection() for connection
creation (src/lib/db/providerNodeSelect.ts, #4421).

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:50:01 -03:00
Diego Rodrigues de Sa e Souza
0f448d64e2 fix(dashboard): remap Kimi Code API-key save to admitted managed id (#10096) (#10417)
* fix(dashboard): remap Kimi Code API-key save to admitted managed id (#10096)

The unified Kimi Code card's API-key branch posted provider: "kimi-coding"
to POST /api/providers. "kimi-coding" is an OAuth-primary managed id, not
an admitted API-key/dual-auth connection id, so the backend correctly
rejected it with 400 "Invalid provider" even though key validation passed.

Add resolveApiKeySaveProviderId() in useApiKeySave.ts to remap the posted
provider id to the dedicated, admitted managed API-key id
"kimi-coding-apikey" for the API-key save flow only. The OAuth flow
(handleOAuthSuccess in ProviderDetailPageClient.tsx) never calls this hook
and keeps posting "kimi-coding" unchanged.

Regression test: tests/unit/bug-10096-kimi-coding-apikey-save.test.ts

* fix(dashboard): remap Kimi Code bulk API-key save

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

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:49:56 -03:00
Diego Rodrigues de Sa e Souza
514573b1f6 fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs (#10416)
* fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs

The subscription fetch guard (fetchGuard.ts) unconditionally blocked all
loopback/private IP ranges as SSRF protection, but the same feature already
permits loopback for the routing half (coreEndpoint.ts's
ALLOWED_LOCAL_CORE_HOSTS) — so an operator could route traffic through a
loopback core but could not fetch a proxy list from a loopback HTTP server.

Make the fetch guard local-first by reusing the existing
areLocalProviderUrlsAllowed() policy (default ON) from
outboundUrlGuardPolicy.ts: loopback/private hosts are now allowed as fetch
targets by default, while cloud-metadata/link-local (169.254.0.0/16, incl.
169.254.169.254 IMDS) and the unspecified address stay blocked
unconditionally, mirroring the provider-validation guard's "block-metadata"
mode. Callers that want the old strict behavior can pass
{ allowLocal: false }.

Closes #10158.

* fix(proxy-subscriptions): unwrap IPv4-mapped IPv6 + full fe80::/10 range (#10416)

The #10158 SSRF guard left two gaps on the IPv6 side: an IPv4-mapped IPv6
literal (::ffff:a.b.c.d) skipped IPv4 range checking entirely, and the
link-local check only matched strings literally prefixed with "fe80"
instead of the full fe80::/10 range (fe80:: - febf:ffff::), so fe90::,
febf:ffff::, etc. were wrongly allowed through.

isIpv6Blocked() now unwraps mapped IPv4 addresses (both the dotted-quad
and WHATWG-normalized hex-group forms) and re-checks them against the
IPv4 rules, and link-local detection parses the first hex group's numeric
value against the 0xfe80-0xfebf range instead of a string prefix.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:49:52 -03:00
GiauPhan
548316a2c4 fix(translator): Normalize tool call names from lowercase to PascalCase when translating upstream responses to Claude Messages API format (#10392)
* fix(translator): Normalize tool call names from lowercase to PascalCase (#1)

* Fix: Map lowercase tool names from Antigravity (Gemini format) to Claude Code expected PascalCase

* Fix: toolNameMap in fun restoreClaudePassthroughToolUseName

* fix(translator): Normalize tool call names from lowercase to PascalCase when translating upstream responses (OpenAI, Gemini, Antigravity) to Claude Messages API format

This resolves `Error: No such tool available: read`/`bash`/`write` errors when using Claude Code CLI with third-party providers that emit lowercase tool names. The fix adds case-insensitive tool name lookups in `openai-to-claude.ts`, `gemini-to-claude.ts`, and related translators, ensuring tool names like `read`/`bash` are mapped to `Read`/`Bash` before being sent to Claude Code. Includes unit tests and comprehensive changelog notes ([#10250](https://github.com/diegosouzapw/OmniRoute/pull/10250))

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(translator): Parse <tool_call> JSON and TOOL_CALL text formats fr… (#2)

* fix(translator): Parse <tool_call> JSON and TOOL_CALL text formats from model output

Some models (DeepSeek, Qwen) emit tool calls as text instead of proper
tool_calls JSON: either <tool_call>{...}</tool_call> or TOOL_CALL Name: {...}.
Extend extractXmlInvokeBlocks to handle all 3 formats in a single scan pass,
picking whichever pattern appears first. Includes unit tests for all formats.

* fix(translator): Parse text-format tool calls in gemini-to-claude translator

Extend the Gemini->Claude translator to detect <invoke>, <tool_call> JSON,
and TOOL_CALL text formats emitted inline in text parts (Antigravity/Gemini
models), converting them to proper tool_use content blocks instead of leaking
raw text to Claude Code.

* docs(changelog): Add changelog entry for text tool call parsing fix

* fix(translator): consolidate tool name casing normalization and restore thought-signature persistence (#3)

* fix(translator): sanitize tool_use.id and tool_result.tool_use_id to match Anthropic schema (#4)

Ensure tool IDs from OpenAI-compatible upstreams (which may contain dots, colons, or special characters) are sanitized to ^[a-zA-Z0-9_-]+$ in response translators and passthrough requests before reaching Claude endpoints.

* fix(responses): preserve native tools for openai-compatible Responses targets (#5)

A Responses-shaped request to a custom openai-compatible connection whose
outbound protocol is Responses took a Responses -> Chat -> Responses round
trip, so Codex custom tools lost their grammar (`exec`), namespace groups were
flattened (`collaboration`), and tool invocations failed upstream.

Gate a native Responses passthrough on the connection's configured protocol
(`apiType: "responses"` / `_omnirouteForceResponsesUpstream`) so the original
tool definitions reach a Responses-capable upstream unchanged. Chat-only
connections keep the existing downgrade.

Closes #10374

* fix(translator): add support for 'applypatch' tool name in tool call checks

* test(translator): add unit test for apply_patch and applypatch tool name remapping

* fix(translator): remove no-explicit-any lint errors in tool-use-id-sanitization test

Type the openaiToClaudeResponse/translateNonStreamingResponse return
values with narrow local shapes instead of `any`, satisfying the
repo's no-explicit-any = error rule for tests/. No behavior change —
the same 3 assertions still pass.

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

* test: update 9568 casing regression to match #10392's consolidated fix

restoreClaudeToolName's static casing map now normalizes known
lowercase tool names to canonical PascalCase unconditionally on the
gemini-to-claude and openai-to-claude Claude Messages API paths (not
gated behind toolNameMap), superseding the earlier per-map-only fix
that the original #9568 regression test locked in as "expected" (it
was previously labeled a known bug case). The gemini-to-openai
passthrough path is unaffected by #10392 and keeps its original
pass-through assertion.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:49:48 -03:00
tkgo11
45ff8d4de0 fix(services): use CLIProxy executable on Windows (#10371)
* fix(services): use CLIProxy executable on Windows

* fix(services): align Windows CLIProxy artifact path

---------

Co-authored-by: tkgo11 <7.1800574e+07+tkgo11@users.noreply.github.com>
2026-08-18 10:49:43 -03:00
Ravi Tharuma
231b16ef18 fix(auto): rate-limit empty-pool AUTO warnings (#10344)
Family resolves like auto/zai with no connected models logged a warn
on every call (about once a minute per poll). Keep the empty-pool
behavior; emit the warn at most once per label per 60s.

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-08-18 10:49:38 -03:00
Chewji
ceced68817 feat(oauth): add gemini-3.7-flash models for antigravity and agy providers (#10305)
* feat(oauth): add gemini-3.7-flash models with reasoning tiers for antigravity

Support gemini-3.7-flash and its thinking tiers (low/medium/high) for antigravity and agy providers.
- Define public models, pricing, modelSpecs, and CLI tool definitions
- Map tiers to live upstream id gemini-3.7-flash-tiered
- Configure defaultThinkingBudget (low: 1024, medium: 8192, high: 32768)
- Allow executor fallback on upstream 404 and 5xx errors
- Add unit tests in antigravity-model-aliases.test.ts

* fix(oauth): expose gemini-3.7-flash as one callable antigravity/agy model

Upstream (fetchAvailableModels on daily-cloudcode-pa) only accepts the single
upstream id gemini-3.7-flash-tiered; the high/medium/low suffixed tier ids
404. Registering all four as distinct public model ids violates the base
#3696 uniqueness invariant (no two ANTIGRAVITY_PUBLIC_MODELS entries may
resolve to the same upstream id). Collapse to the single live gemini-3.7-flash
public model (aliased to gemini-3.7-flash-tiered) and drop the tiered specs,
pricing, free-catalog and CLI entries accordingly, keeping the leading public
model order (Gemini 3.6 tiers first) intact.

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

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: Chewji9875 <Chewji9875@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:49:33 -03:00
Damian Pozimski
ac2439b8af fix(api): scale pool usage snapshot limits by pool member count (summed budget) (#10253)
* fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190)

Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13
(with monaco-editor scoped override). Closes Dependabot #189, #190.

Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge —
awaiting Dependabot re-scan.

npm audit → 0 vulnerabilities.

* fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks)

_tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing
slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential
_tasks symlink can slip in via git add -A and, once pulled, checkout materializes
it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks
ignores the symlink too, preventing re-capture.

* Hide health-check excluded models from /v1/models catalog (#10026)

Mirror the request-time exclusion rule (provider_specific_data.excludedModels)
in the unified catalog builder: a model is hidden when its provider has
connections but none of them is eligible for it. Applied across the
PROVIDER_MODELS, synced, custom, alias-backed, and managed-fallback loops
so ghost models no longer appear as available.

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

* fix(models): memoize getModelsDevPricing (event loop / healthz) (#10055)

* fix(models): memoize getModelsDevPricing for /v1/models catalog

resolveCatalogPricing called getModelsDevPricing once per model while
building GET /v1/models. Each call re-scanned models_dev_pricing and
JSON.parsed every row (~10k SQL scans + multi-GB parse work), pegging
the event loop so even /healthz timed out (#9685, #10052).

Memoize the parsed map until saveModelsDevPricing / clearModelsDevPricing
and add a unit test for invalidation.

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>

* fix(db): invalidate modelsDevPricing cache on DB reset (#10055)

Copilot review fixes:
1. Register invalidateModelsDevPricingCache() with DB state reset system
   so resetDbInstance() clears the process-local memo, preventing stale
   pricing data from surviving across DB reset/restore operations.
2. Add test assertion verifying DB reset bypasses the memo (Copilot #10055).

The process-local memo at modelsDevSync.ts:204 caches getModelsDevPricing()
results until saveModelsDevPricing()/clearModelsDevPricing() to avoid
re-scanning all pricing rows on every /v1/models request. Without this hook,
backup restore and test DB resets would serve stale cached data from the
previous connection.

Tests: npm run test:unit:serial -- tests/unit/modelsDevSync-extended.test.ts

---------

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* fix(api): scale pool usage snapshot limits by member count (summed budget)

---------

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@outlook.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: ritheshcn25 <rithesh.chandran@snb.ca>
Co-authored-by: ritheshcn25 <ritheshcn25@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:49:28 -03:00
Tushar Agarwal
1089c24bc8 Remove/mimocode sunset provider (#10186)
* remove: drop sunset MiMoCode provider from model catalog

* remove: drop sunset MiMoCode provider from model catalog (shared.ts)

Remove unused imports, types, and comments from shared.ts.

* remove: MiMoCode provider (Xiaomi sunset) — executor, registry, no-auth config, icon, tests

* refactor(providers): finish MiMoCode removal — sweep remaining no-auth references

Drop the leftover mimocode entries from the no-auth provider controls, the
translate-path snapshot, the eslint suppressions, and the #3061 auth-loop
test. Re-point the fingerprint-pin (#6696) and proxy-noauth (#6272) tests at
opencode, which exercises the same fingerprint path, so the removal does not
break runtime behavior.

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

* docs(providers): reconcile provider/executor counts after MiMoCode sunset

The base's parallel doc-count sync (#10433) pinned 340 providers / 101
executors. With mimocode removed, live code has 339 providers and 100
executors; refresh the user-facing counts (package.json description,
llm.txt, README/AGENTS, i18n llm.txt, provider reference, diagrams) so the
check-docs-counts STRICT gate stays green.

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

* test(providers): fix orphaned mimocode references after MiMoCode sunset

The sunset removed mimocode/mcode from the free-onboarding candidates and
from FINGERPRINT_PROVIDERS, but two tests still referenced them:

- free-provider-onboarding-setup: the mimocode->theoldllm substitution
  introduced duplicate 'opencode' rows (impossible given the request-set
  dedupe) and the wrong display name; align expectations with the actual
  {opencode, theoldllm} dedupe behavior and 'The Old LLM (Free)' name.
- combo-system-prompt-templates-5501: resolveTargetFingerprint tested with
  provider 'mcode', which is no longer a fingerprint provider; point it at
  the remaining fingerprint provider 'opencode'.

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

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Tushar49 <Tushar49@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-08-18 10:49:24 -03:00
Benson K B
2d50ec0789 feat(routing): add quota-aware provider scheduling — Phase 2 (#10126)
* feat(quota): Phase 2 adapters, reset timers, analytics, and dashboard API

* feat(routing): add quota-aware provider scheduling (opt-in)

* fix(db): rename migration to 148_provider_quota_state.sql

* fix(quota): harden quota state route, isolate phase2 tests, slim env diff

- route: requireManagementAuth + Zod body validation + buildErrorBody
  sanitization (Hard Rule #12); fix clearProviderQuotaState -> clearProviderQuota
- .env.example/ENVIRONMENT.md: drop ~20 foreign vars, keep only
  OMNIROUTE_QUOTA_AWARE_ROUTING (migration 148)
- tests/unit/quota-phase2.test.ts: DATA_DIR mkdtemp + resetDbInstance teardown

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

* chore(ci): fix docs-sync + eslint-suppression drift for quota branch

CI gates flagged on PR #10126 head 43335f07:
- migration counts in README/AGENTS/llm.txt were stale (145 -> 146)
- regenerate docs/reference/PROVIDER_REFERENCE.md (gen-provider-reference)
- sync root llm.txt body into all 42 i18n mirrors (headers preserved)
- prune eslint suppressions that no longer occur

--no-verify: pre-commit docs-sync was failing on a pre-existing
release-base artifact (changelog 3.8.49 vs package 3.8.50) — fixed by
the changelog entry in the prior commit; re-verify in CI.

* chore(skills): regenerate agent skills (add omni-settings)

Merge-integrity CI gate flagged a missing generated skill. Regenerated
with check:agent-skills-sync --apply: +omni-settings, 45 unchanged.

* fix(ci): resolve Fast Quality Gates regressions on quota branch

- check-migration-numbering: migration 148 (provider_quota_state) landed
  on this branch, so the KNOWN_GAPS allowlist entry is stale — remove it
  (stale-enforcement 6A.3: 'REMOVA a entrada')
- open-sse/utils/stream.ts: duplicate sseCommentsEnabled import from a
  bad merge (lines 31 + 77) — TS2300 duplicate identifier; drop the
  duplicate so the open-sse typecheck gate is back within baseline

* docs: sync migration count to 149 after release merge

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

* test(migrations): align 148 gap assertion after 148_provider_quota_state.sql landed

The phase-2 branch added 148_provider_quota_state.sql, and 148 was already
removed from KNOWN_GAPS in scripts/check/check-migration-numbering.mjs. The
frozen-allowlists assertion still expected 148 to be a gap, so it failed.
Flip the assertion to match the allowlist (same pattern as 143/147).

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

---------

Co-authored-by: benzntech <benzntech@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-08-18 10:49:19 -03:00
Xiangzhe
100c9dd3fa perf(logging): offload call-log artifacts to a worker (#10123)
* perf(logging): offload call-log artifacts to a worker

* test(call-log): raise drain wait timeout for cold worker spawn

The first cold spawn of the worker_threads artifact worker can take ~2.4s
before queued artifact writes start draining, so a 2s wait in
call-log-save-drain.test.ts flakes on cold runs. Raise it to 10s.

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

---------

Co-authored-by: xz-dev <xz-dev@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:49:15 -03:00
NOXX - Commiter
48fef207ec fix(adobe-firefly): renew sessions through durable CDP (#9255)
* 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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:48:59 -03:00
Xiangzhe
0a74bfbdea feat(cli): relay-like CLI closure — target manifest, Codex TOML, Gemini launcher, guards
- canonical executable manifest (bin/cli/cli-manifest.mjs): run/configure/completion
  derive targets, aliases and --model wiring from one table; drift test cross-checks
  manifest x cliRuntime x UI catalog (tests/unit/cli/cli-manifest-drift.test.ts)
- dashboard Codex generator converged to ~/.codex/config.toml (modern Codex v0.137+,
  verified against codex-cli 0.147.0): conservative merge, env_key auth (key never
  written), refuses invalid TOML, reports legacy config.yaml as migration note
- omniroute run gemini: launcher over OmniRoute's /v1beta surface via
  GOOGLE_GEMINI_BASE_URL + isolated GEMINI_CLI_HOME forcing gemini-api-key auth
  (contract proven against @google/gemini-cli 0.50.0); ACP registration kept distinct
- opt-in real smoke harness for upstream CLIs (RUN_CLI_SMOKE=1, credential by env
  NAME, redacted output): tests/integration/upstream-cli-smoke.int.test.ts
- container-guard homologation for POST /api/cli-tools/apply (422 in container,
  dry-run preview allowed, host write passes) + docs; guard untouched
- typecheck: omniglyphAdapter union narrowing, usageTracking typed signatures
  (UsageLike, no any), models.ts isValidModel params — typecheck:core and
  typecheck:noimplicit:core now clean
- relay core (prior session of this effort): omniroute run for 6 CLIs, configure
  picker with per-context favorites/recents, contexts with optional keychain +
  0600 fallback, provider CRUD with recursive redaction, completion updates, docs
2026-08-18 08:25:16 -03:00
Xiangzhe
34bb018d21 docs(video): document fusion telemetry, drill-down byte budget, cache key dimensions and fixed dedup threshold 2026-08-18 08:25:16 -03:00
Xiangzhe
7c05c9b46b perf(video): add sampler and contact-sheet benchmark script 2026-08-18 08:25:16 -03:00
Xiangzhe
a5b32557d6 refactor(video): use the structured pino logger in the broker extract route 2026-08-18 08:25:15 -03:00
Xiangzhe
e7858d7165 feat(video): cap the drill-down cache with a global byte budget
The per-session drill-down cache now tracks decoded bytes per entry and
evicts least-recently-used entries until an aggregate maxTotalBytes
budget fits (route sets 256 MiB); an entry larger than the whole budget
is rejected. Prevents the previous worst case of 64 x 32 MiB (~2 GiB)
pinned in memory.
2026-08-18 08:25:15 -03:00
Xiangzhe
533e5c6ec7 feat(video): surface audio/video fusion telemetry and degrade invalid audio to partial
The fusion result's availability, partial and failure fields now reach
DescribedVideo.fusion, the guardrail meta (audioFusionRuns/Partials/
FailureCodes), the result-cache metadata and bridge stats. Audio
transcript validation moved inside the fusion's audio branch, so an
invalid audioTranscript records failures.audio and keeps the visual
description instead of failing the whole video.
2026-08-18 08:25:15 -03:00
Xiangzhe
ffb0cbc10b fix(video): include audioTranscript and focus window in the result cache key 2026-08-18 08:25:14 -03:00
Xiangzhe
ebf3312fe7 docs(video): document timestamped contact sheets 2026-08-18 08:25:14 -03:00
Xiangzhe
68b3fe715a feat(video): add timestamped contact sheets 2026-08-18 08:25:14 -03:00
Xiangzhe
bb22eeba8d feat(video): add isolated drill-down cache 2026-08-18 08:25:13 -03:00
Xiangzhe
edb3abf323 feat(video): add segment-aware sampling 2026-08-18 08:25:13 -03:00
Xiangzhe
bcd58975c5 docs(video): describe optional audio fusion 2026-08-18 08:25:12 -03:00
Xiangzhe
350b161620 feat(video): add optional audio fusion timeline 2026-08-18 08:25:11 -03:00
Xiangzhe
ad9384c3ea feat(video): preserve transcript provenance 2026-08-18 08:25:11 -03:00
Xiangzhe
ffa6849cc8 feat(video): add validated focus windows 2026-08-18 08:25:10 -03:00
Xiangzhe
596a1035c3 feat(video): add conservative frame deduplication 2026-08-18 08:25:09 -03:00
Xiangzhe
2c33638643 feat(video): add scene-aware sampling fallback 2026-08-18 08:25:07 -03:00
Xiangzhe
743c8f442d feat(video): extend bridge cache key and result-cache telemetry 2026-08-18 08:25:07 -03:00
Xiangzhe
91ea94fb50 feat(video): cache full video-bridge results with metadata 2026-08-18 08:25:06 -03:00
Xiangzhe
131aebcde5 fix(compression): gate de estágio não derruba o pipeline com engine sem metadata
`assertValidEngine()` valida id, apply, compress, getConfigSchema e
validateConfig — não exige `metadata`. Uma engine registrada sem esse campo é,
portanto, um registro legal. Mas `canRunAtCompressionStage` lia
`engine.metadata.executionStages` sem guarda, então essa engine legal derrubava o
pipeline inteiro com `TypeError: Cannot read properties of undefined` em vez de
falhar aberto, que é o contrato da compressão.

Metadata ausente é o mesmo caso de "não declarou executionStages" e passa a cair
no mesmo fallback documentado: só pre-translation.

Isso destravava também `tests/unit/compression/pipeline-circuit-breaker.test.ts`,
que registra uma engine de teste sem metadata e vinha 8/9 na base — agora 9/9. O
teste novo torna o contrato explícito, em vez de deixá-lo dependendo de uma
reprodução incidental noutro arquivo.
2026-08-18 07:56:56 -03:00
Diego Rodrigues de Sa e Souza
ea0cdc559c docs(compression): document the output-style catalog and its extension point (#10649)
The five output styles (terse-prose, less-code, ponytail, i-have-adhd,
terse-cjk) shipped in Phase 4 but COMPRESSION_GUIDE.md had zero mention of
them. Add the catalog table with per-style language coverage, the injection
contract (catalog order, single marker, shared boundaries once), the config
shape and back-compat note, plus an 'Adding an Output Style' recipe in
EXTENDING_COMPRESSION.md covering the matrix guard and translation floor.

Refs #10426

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-18 06:14:20 -03:00
Diego Rodrigues de Sa e Souza
8ba25e9318 docs: add the VS Code Copilot Chat guide and document the /v1/models prefix modes (#10648)
Adds docs/guides/VSCODE-COPILOT.md covering the OmniCopilot extension: install
from either store, connection setup, what the picker actually shows and why,
the dashboard-in-a-tab mode, and a troubleshooting table.

Documents two contracts that existed in code but nowhere in the docs:

- The ?prefix= query parameter on GET /v1/models, with the warning that
  "canonical" omits providers whose alias already is the canonical id — so
  "alias" is the safe direction for a de-duplicated list.
- MODELS_CATALOG_PREFIX_MODE in .env.example and ENVIRONMENT.md, matching how
  ARENA_ELO_SYNC_ENABLED and PII_REDACTION_ENABLED are already documented.

The fabricated-docs gate cannot see this flag being read, because
resolveFeatureFlag() indexes process.env by key rather than naming it; added
an allowlist entry explaining that, in the style of the existing entries.

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-18 05:51:58 -03:00
Diego Rodrigues de Sa e Souza
c164ed962b fix(providers): validate bailian-coding-plan against the Token Plan host (#10634)
* fix(providers): validate bailian-coding-plan against the Token Plan host

The catalog entry is the personal Alibaba Token Plan, but the region map still
resolved the retired Coding Plan hosts. #10290 moved only the open-sse registry
(inference) to token-plan.ap-southeast-1.maas.aliyuncs.com, leaving the dashboard's
key validation pointed at coding-intl.dashscope.aliyuncs.com.

That host rejects Token Plan keys with 401, and validateBailianCodingPlanProvider
maps 401/403 to "Invalid API key" — so adding a working key failed at the modal
while the same key served inference fine. Verified live 2026-08-18 with a valid
key: legacy host 401 invalid_api_key, Token Plan host 429 quota (auth OK).

- point both regions of ALIBABA_PROVIDER_ENDPOINTS at the Token Plan hosts,
  matching what docs/providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md already stated
- keep the retired hosts recognized as presets, so connections saved with the old
  URL still follow the region selector instead of being pinned to a dead host
- keep image/video generation on the DashScope AIGC hosts, which the Token Plan
  host does not serve
- probe with a model this plan actually serves (qwen3-coder-plus was Coding Plan)

* test(providers): compare parsed hostnames in the legacy-host guard

CodeQL flags URL .includes() checks as js/incomplete-url-substring-sanitization.
The guard is an assertion, not a sanitizer, but comparing new URL().hostname is
strictly more precise anyway — same coverage, no substring pattern.

---------

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-18 05:51:34 -03:00
Diego Rodrigues de Sa e Souza
cd091ab878 fix(sse): route bare qwen3.8-max to the canonical -preview id (#10632)
The model ships only as `qwen3.8-max-preview` across every provider that serves it (bailian-coding-plan, qoder, qwen-cloud-token-plan, qwen-web), so the bare `qwen3.8-max` missed MODEL_SPECS: the chatCore context preflight fell back to contextManager's `default: 128000` and rejected prompts with `context_length_exceeded` despite the model's real 1M window, and the unknown id would have reached the upstream verbatim.

Both symptoms share one cause, so the alias goes in BUILT_IN_ALIASES, which resolveLifecycle() applies before the preflight and before dispatch.

Merged with the inherited OmniGlyph base-red (#9985) documented: its two failing compression tests were reproduced on the pure base tip aa912c42a7, with no commit from this branch.
2026-08-18 05:47:27 -03:00
adevwithpurpose
6797346fa1 fix(quality): rebaseline imageRegistry.ts for merge-train combined growth
Three independent, already-approved provider PRs (#10542 aihorde,
#10494 gemini-web image, #10594 freepik/magnific) boarded together in
the 2026-08-18 merge-train each add a small, additive registry entry
to open-sse/config/imageRegistry.ts. None crosses the 1000-line cap
alone; combined they push it from 996 to 1019. Owner-authorized
blanket rebaseline approval for this merge batch.
2026-08-18 05:42:33 -03:00
Xiangzhe
aa912c42a7 docs: update omni route video guides ranking layout 2026-08-17 16:16:58 -03:00
adevwithpurpose
63a6618d34 chore(release): synchronize localized llm mirrors 2026-08-17 12:01:43 -03:00
761 changed files with 49109 additions and 8010 deletions

View File

@@ -246,6 +246,11 @@ OMNIROUTE_USE_TURBOPACK=1
# hints in production logs.
# OMNIROUTE_PROXY_FETCH_DEBUG=true
# Set to "true" or "1" to include client/egress IPs and the account prefix in
# the verbose `[ProxyEgress]` process-log line (src/lib/proxyLogger.ts). Kept
# OFF by default so the process log does not leak IPs or the account prefix.
# PROXY_LOG_INCLUDE_IPS=true
# Set to any non-empty value to emit `[omniroute completion]` diagnostics from
# the CLI shell-completion cache paths (read/refresh/write) in
# bin/cli/commands/completion.mjs. Off by default — these caches fail silently
@@ -370,6 +375,16 @@ ALLOW_API_KEY_REVEAL=false
# OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800
# Maximum heavyweight requests simultaneously admitted in one process. Default 1.
# OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1
# Heap-pressure shed ratio (heapUsed/heap_size_limit) for the structural admission gate
# (#10183, #10268): a second concurrent heavyweight request past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
# is only shed with a retryable 503 when the heap is ALSO under this much pressure — on a
# healthy heap it is admitted instead. Range (0, 1]. Default 0.75.
# OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO=0.75
# Bounded extra capacity for the healthy-heap fast path above OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
# (#10437): once this many concurrent leases are active through the healthy-heap bypass,
# further busy requests fall through to the same bounded-wait/shed path used under real heap
# pressure. 0 disables the bypass entirely. Default 1.
# OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM=1
# Message count that classifies an otherwise small body as heavyweight. Default 200.
# OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT=200
# Tool count that classifies an otherwise small body as heavyweight. Default 64.
@@ -393,6 +408,12 @@ ALLOW_API_KEY_REVEAL=false
# OMNIROUTE_CHAT_VIRTUAL_TTL_MS=60000
# Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). Default 64.
# OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS=64
# Adaptive runtime virtual admission lanes (#9654): master switch for the per-tenant
# adaptive gate (system 2, open-sse/services/admission). NOTE: the TTL/MAX_SESSIONS
# vars above tune the byte-level per-connection lanes (system 1); this switch enables
# the adaptive runtime lanes. Dashboard feature flag of the same name; env wins over
# the dashboard override; restart required. Default: off.
# OMNIROUTE_CHAT_VIRTUAL_LANES=1
# 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
@@ -742,6 +763,9 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# CLI_CONTINUE_BIN=cn
# CLI_QODER_BIN=qoder
# CLI_QWEN_BIN=qwen
# CLI_AIDER_BIN=aider
# CLI_GOOSE_BIN=goose
# CLI_GEMINI_BIN=gemini
# CLI_AUGGIE_BIN=auggie
# AUGGIE_BIN=auggie
@@ -800,6 +824,13 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode).
# OMNIROUTE_CONTEXT=
# Disable the optional OS keychain backend for CLI remote-context credentials.
# When enabled, context tokens stay in config.json with mode 0600 and the CLI
# prints a one-time fallback warning. Useful for deliberate headless/container
# operation; leave unset to use keytar when the native backend is available.
# Used by: bin/cli/contexts.mjs.
# OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED=0
# Enforce scope-based access control on MCP tool calls.
# Used by: open-sse/mcp-server/server.ts — rejects calls outside allowed scopes.
# OMNIROUTE_MCP_ENFORCE_SCOPES=false
@@ -1253,6 +1284,14 @@ CURSOR_USER_AGENT="Cursor/3.4"
# hatches that are referenced in code today.
# DEEPSEEK_API_KEY=
# NVIDIA_API_KEY=
# Jina Foundation API + Reader fallback when no dashboard jina-ai / jina-reader
# connection exists. Dashboard keys always win (fill-first).
# JINA_AI_API_KEY=
# JINA_API_KEY=
# Gemini / Google AI Studio embeddings fallback when no dashboard gemini
# connection exists. Dashboard keys always win (fill-first).
# GEMINI_API_KEY=
# GOOGLE_API_KEY=
# Windsurf / Devin CLI direct API key.
# Used by: open-sse/executors/devin-cli.ts — bypasses OAuth when set.
@@ -1648,6 +1687,16 @@ APP_LOG_TO_FILE=true
# Used by: src/shared/constants/featureFlagDefinitions.ts, src/lib/arenaEloSync.ts
# ARENA_ELO_SYNC_ENABLED=true
# How model ids are prefixed in GET /v1/models. "dual" (default) advertises BOTH the
# short alias prefix and the canonical provider prefix for each model (cc/claude-sonnet-4-6
# AND claude/claude-sonnet-4-6) so client configs that hardcoded either form keep working —
# which roughly doubles the catalog. "alias" emits one id per model; "canonical" emits only
# the full provider-id prefix (and drops providers whose alias is already canonical).
# A client can override per request with GET /v1/models?prefix=alias instead.
# Also configurable from Dashboard > Settings > Feature Flags.
# Used by: src/shared/constants/featureFlagDefinitions.ts, src/app/api/v1/models/catalog.ts
# MODELS_CATALOG_PREFIX_MODE=dual
# Sync interval in seconds. Default: 86400 (24 hours).
# ARENA_ELO_SYNC_INTERVAL=86400
@@ -1756,6 +1805,12 @@ APP_LOG_TO_FILE=true
# Used by: open-sse/executors/cloudflare-ai.ts
# CLOUDFLARE_ACCOUNT_ID=
# ── Cloudflare AI Playground ──
# Full desktop Chrome binary path, used when Playwright's bundled Chromium is
# blocked by the headless fingerprint check.
# Used by: open-sse/executors/cloudflare-playground.ts
# CLOUDFLARE_PLAYGROUND_CHROME_PATH=
# ── Deno Deploy proxy relay (#4643 / 9router#1437) ──
# Override the Deno Deploy REST API base used by the proxy-pool relay deployer.
# Default: https://api.deno.com/v2 (omit unless mocking).
@@ -2796,3 +2851,13 @@ QUOTA_STORE_DRIVER=sqlite
# Spokesperson (Faro) base URL for the dashboard chat proxy (/api/conductor/ask).
# Used by: src/lib/conductor/faroProxy.ts
# CONDUCTOR_SPOKESPERSON_URL=http://127.0.0.1:7920
# ═══════════════════════════════════════════════════════════════════════════════
# QUOTA-AWARE PROVIDER SCHEDULING (opt-in, Phase 2)
# ═══════════════════════════════════════════════════════════════════════════════
# When enabled, routing skips connections whose configured per-window token
# budget (rateLimitOverrides.tpm) cannot afford the estimated request cost —
# before dispatching — instead of waiting for a 429. Fail-open: connections
# without a configured budget are always considered affordable. Requires the
# provider_quota_state table (migration 148).
# OMNIROUTE_QUOTA_AWARE_ROUTING=0

3
.gitignore vendored
View File

@@ -288,3 +288,6 @@ docker-compose.yml.bak
# CLI local cache/state
.playwright-cli
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
/.sandbox/

View File

@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 341 LLM providers, auto-fallback.
**OmniRoute** — unified AI proxy/router. One endpoint, 340 LLM providers, auto-fallback.
| Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below.
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (150 migrations) |
| Database | `src/lib/db/` | SQLite domain modules (153 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 109 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
@@ -433,6 +433,7 @@ For any non-trivial change, read the matching deep-dive first:
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
| Tunnels | `docs/ops/TUNNELS_GUIDE.md` |
| Electron desktop app | `docs/guides/ELECTRON_GUIDE.md` |
| VS Code Copilot Chat (OmniCopilot extension) | `docs/guides/VSCODE-COPILOT.md` |
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
| Quality gates (~80 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` |

104
README.md
View File

@@ -7,7 +7,7 @@
# 🚀 OmniRoute — The Free AI Gateway
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 341 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 341 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 340 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 340 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
</div>
@@ -63,7 +63,7 @@
| | v3.8.49 | **v3.8.50** | `v3.8.51+` |
| ------------------------- | :-----: | :---------: | :---------: |
| 🌐 Providers | 290 | **341** | more queued |
| 🌐 Providers | 290 | **340** | more queued |
| 🧠 Documented models | 1185 | **1202** | — |
| 🖼️ Modality Bridge | — | 🆕 vision | video |
| 📡 Radar free catalog | — | 🆕 opt-in | — |
@@ -101,7 +101,7 @@
<tr>
<td align="right"><b>⚙️ Features</b></td>
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
<td align="center"><a href="#-341-ai-providers--90-free">🌐 Providers</a></td>
<td align="center"><a href="#-340-ai-providers--90-free">🌐 Providers</a></td>
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI &amp; MCP</a></td>
</tr>
<tr>
@@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 341 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 341 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 1595%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 340 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 340 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 1595%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
<br/>
<br/>
@@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step:
</div>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 341 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project&apos;s docs."/>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 340 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project&apos;s docs."/>
<sub>📊 Full methodology &amp; per-feature detail vs 9router, OpenRouter, CLIProxyAPI &amp; LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
@@ -548,7 +548,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
- **🗜️ Compression hardening** — default-on inflation guard, Caveman packs for DE / FR / JA + Chinese (wényán), RTK filters for Gradle & .NET. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
- **💸 Honest flat-rate cost** — subscription / coding-plan providers read **$0** in cost analytics; budget, quota & routing keep estimating. → [API Reference](docs/reference/API_REFERENCE.md)
- **⚖️ Quota-Share routing** — split a shared account's quota fairly across pooled keys, work-conserving so idle slices are lent out. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
- **🤖 One-command CLI/agent setup** — `setup-*` configures 12+ coding tools; `omniroute launch` / `launch-codex` are zero-config. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
- **🤖 One-command CLI/agent setup** — `setup-*` configures 12+ coding tools; `omniroute run` launches 7 CLIs (Claude Code, Codex, Aider, Goose, OpenCode, Qwen Code, Gemini CLI) with zero config written; `omniroute configure` is an interactive provider+model picker with per-context favorites. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
- **🛰️ Remote mode** — drive a remote OmniRoute with scoped tokens (`connect` / `contexts` / `tokens`) + an `antigravity` OAuth helper for VPS installs. → [Remote Mode](docs/guides/REMOTE-MODE.md)
- **🧭 Smarter auto-routing** — `auto/<category>:<tier>` combos, **Fusion** (model panel + judge), task-aware routing, per-request model / mode / USD-budget overrides. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
- **🗜️ Pluggable compression** — 12 composable engines + Compression Studios: LLMLingua-2, two-tier Ultra, omniglyph, per-step fidelity gate, GCF v3.2, drag-reorder editor. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
@@ -559,7 +559,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Google Imagen, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md)
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **341-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **340-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
- **⚡ Local performance & infra** — one-click local Redis, Cloudflare Workers / Deno Deploy relay deployers, Bifrost & Mux as supervised embedded services. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md)
@@ -618,13 +618,35 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
<br/>
**Launch any supported CLI through OmniRoute in one command** — no config files written,
credentials injected per process, Qwen/Gemini get a throwaway isolated home:
```bash
omniroute run claude --model openai/gpt-5.4 # Claude Code
omniroute run codex --model glm/glm-5.2 # OpenAI Codex CLI
omniroute run aider --model glm/glm-5.2 -- --message "reply OK"
omniroute run goose --model glm/glm-5.2
omniroute run opencode --model glm/glm-5.2 -- run "reply OK"
omniroute run qwen --model glm/glm-5.2 -- -p "reply OK"
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "reply OK"
# Or pick provider+model interactively and write the tool's own config:
omniroute configure codex # also: claude opencode qwen aider goose cline continue kilo
```
Every command honors the active remote context (`omniroute connect <host>`), `--dry-run`
previews the exact env/args without executing, and `--api-key-env NAME` keeps secrets out
of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
<br/>
<div align="center">
## 🌐 341 AI Providers — 90+ Free
## 🌐 340 AI Providers — 90+ Free
</div>
> The most complete catalog of any open-source router: **341 providers**, **90+ with a free tier**, **56 free forever**.
> The most complete catalog of any open-source router: **340 providers**, **90+ with a free tier**, **56 free forever**.
<div align="center">
@@ -737,6 +759,8 @@ From inside the editor: open the **Extensions** view, search **"OmniRoute"**, cl
— works the same way on both stores. Source, issues and the publishing runbook live at
[diegosouzapw/OmniCopilot](https://github.com/diegosouzapw/OmniCopilot).
<sub>📖 [VS Code Copilot Chat guide](docs/guides/VSCODE-COPILOT.md) — setup, what the picker shows, dashboard-in-a-tab, troubleshooting</sub>
<br/>
<div align="center">
@@ -1048,31 +1072,69 @@ same process on one port, so there is no separate CLI-only package today.
</div>
## 📹 Video Guides
<div align="center">
<sub>Dados de cobertura social em 2026-08-17 · YT: 741 | TT: 137 | IG: 124 · Frescor (dias): YT 0 · TT 14 · IG 15</sub>
<table>
<tr>
<td align="center" width="264">
<a href="https://www.youtube.com/watch?v=Rxdc36yUyOQ"><img src="https://img.youtube.com/vi/Rxdc36yUyOQ/maxresdefault.jpg" alt="Guia em Português" width="260"/></a><br/>
<b>🇧🇷 Português</b><br/><sub>Guia completo</sub>
<td align="center" width="320">
<a href="https://www.instagram.com/reel/Da8ZthUPK98/">
<img src="https://placehold.co/320x180/111827/FFFFFF?text=Instagram+Reel+%7C+nick_saraev&font=montserrat&bold=true" alt="Instagram Reel" width="300"/>
</a><br/>
<b>🎬 #1 — Instagram</b><br/>
<sub>nick_saraev — 1,628,910 views</sub>
</td>
<td align="center" width="264">
<a href="https://www.youtube.com/watch?v=CMzyOiUyEVc"><img src="https://img.youtube.com/vi/CMzyOiUyEVc/maxresdefault.jpg" alt="English Guide" width="260"/></a><br/>
<b>🇺🇸 English</b><br/><sub>Complete walkthrough</sub>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=QucgvbO5gsM">
<img src="https://img.youtube.com/vi/QucgvbO5gsM/maxresdefault.jpg" alt="YouTube — Vaibhav Sisinty" width="300"/>
</a><br/>
<b>🎬 #2 — YouTube</b><br/>
<sub>Vaibhav Sisinty — 373,084 views</sub>
</td>
<td align="center" width="264">
<a href="https://www.youtube.com/watch?v=il_5Ii6v4-Y"><img src="https://img.youtube.com/vi/il_5Ii6v4-Y/maxresdefault.jpg" alt="Руководство" width="260"/></a><br/>
<b>🇷🇺 Русский</b><br/><sub>Полное руководство</sub>
<td align="center" width="320">
<a href="https://www.youtube.com/shorts/fZIBK_4fKq8">
<img src="https://img.youtube.com/vi/fZIBK_4fKq8/maxresdefault.jpg" alt="YouTube Shorts" width="300"/>
</a><br/>
<b>🎬 #3 — YouTube Shorts</b><br/>
<sub>Nick Automates — 207,714 views</sub>
</td>
<td align="center" width="320">
<a href="https://www.tiktok.com/@milesreevesai/video/7667980059189366019">
<img src="https://placehold.co/320x180/111827/FFFFFF?text=TikTok+Top+1&font=montserrat&bold=true" alt="TikTok Thumbnail" width="300"/>
</a><br/>
<b>🎬 #4 — TikTok</b><br/>
<sub>milesreevesai — 620,400 views</sub>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=LkP6ocAoQkk">
<img src="https://img.youtube.com/vi/LkP6ocAoQkk/maxresdefault.jpg" alt="Valency Labs" width="300"/>
</a><br/>
<b>🎬 #5 — YouTube</b><br/>
<sub>Valency Labs — 135,974 views</sub>
</td>
</tr>
</table>
</div>
<div align="center">
**Ranking completo (`v > 0`, maior alcance):**
| #1 | #2 | #3 | #4 | #5 |
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| [nick_saraev — Instagram](https://www.instagram.com/reel/Da8ZthUPK98/) — **1,628,910** | [milesreevesai — TikTok](https://www.tiktok.com/@milesreevesai/video/7667980059189366019) — **620,400** | [Vaibhav Sisinty — YouTube](https://www.youtube.com/watch?v=QucgvbO5gsM) — **373,084** | [Nick Automates — YouTube Shorts](https://www.youtube.com/shorts/fZIBK_4fKq8) — **207,714** | [midudev — TikTok](https://www.tiktok.com/@midudev/video/7664636453544152342) — **177,800** |
| #6 | #7 | #8 | #9 | #10 |
| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| [theopenstack — Instagram](https://www.instagram.com/reel/DaSs65mMrHk/) — **155,453** | [t.ghoush.ai — TikTok](https://www.tiktok.com/@t.ghoush.ai/video/7669497680527248656) — **152,800** | [Valency Labs — YouTube](https://www.youtube.com/watch?v=LkP6ocAoQkk) — **135,974** | [Asati — YouTube](https://www.youtube.com/watch?v=JjPtJcqwhqg) — **126,130** | [Vaibhav Sisinty — YouTube](https://www.youtube.com/watch?v=NuNDpeZYQ28) — **122,672** |
Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações conhecidas · 595 perfis/canais · 13+ idiomas · 13+ criadores.
> 🎬 **Made a video about OmniRoute?** Open an [issue](https://github.com/diegosouzapw/OmniRoute/issues/new) or [discussion](https://github.com/diegosouzapw/OmniRoute/discussions) with the link — we'll feature it here.
<br/>
</div>
<div align="center">
@@ -1110,7 +1172,7 @@ same process on one port, so there is no separate CLI-only package today.
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>&gt;=22.22.2 &lt;23 || &gt;=24.0.0 &lt;27</code></td></tr>
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 117 domain modules, 150 migrations</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 153 migrations</td></tr>
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>

View File

@@ -1,6 +1,6 @@
import { setTimeout as sleep } from "node:timers/promises";
import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs";
import { resolveActiveContext } from "./contexts.mjs";
import { resolveActiveContext, resolveActiveContextAsync } from "./contexts.mjs";
export const RETRY_DEFAULTS = Object.freeze({
maxAttempts: 3,
@@ -77,7 +77,7 @@ export async function buildHeaders(opts) {
let auth = explicitKey;
if (!auth) {
try {
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
const ctx = await resolveActiveContextAsync(opts.context ?? process.env.OMNIROUTE_CONTEXT);
auth = ctx?.accessToken || ctx?.apiKey || null;
} catch {
// No context credential available — fall through to the ambient fallback.

138
bin/cli/cli-manifest.mjs Normal file
View File

@@ -0,0 +1,138 @@
/**
* Canonical executable manifest for the OmniRoute CLI command surfaces.
*
* One entry per canonical target id. `run.mjs`, `configure.mjs` and
* `completion.mjs` derive their target lists, alias resolution and model-flag
* wiring from this table instead of keeping private copies, so a new target
* (or a renamed alias) is declared exactly once.
*
* The server-side runtime catalog (`src/shared/services/cliRuntime.ts`) stays
* the source of truth for binaries, config paths and health checks; the drift
* test `tests/unit/cli/cli-manifest-drift.test.ts` asserts the two worlds and
* every consumer surface stay in sync.
*
* Capability semantics:
* - `run`: launchable through `omniroute run <target>`.
* - `configure`: supported by the `omniroute configure <target>` picker.
* - `runModel`: how `run` injects `--model` for the target (`null` when the
* model travels via env/provider args instead of a CLI flag).
*/
export const CLI_TARGET_MANIFEST = Object.freeze({
claude: Object.freeze({
description: "Claude Code",
aliases: Object.freeze(["claude-code", "cc", "anthropic"]),
run: true,
configure: true,
runModel: null, // injected via ANTHROPIC_MODEL env by the launcher
}),
codex: Object.freeze({
description: "OpenAI Codex CLI",
aliases: Object.freeze(["codex-cli", "openai-codex", "openai"]),
run: true,
configure: true,
runModel: null, // injected via -c model_providers.omniroute.* args
}),
aider: Object.freeze({
description: "Aider",
aliases: Object.freeze([]),
run: true,
configure: true,
runModel: Object.freeze({ flag: "--model", prefix: "openai/" }),
}),
goose: Object.freeze({
description: "Goose",
aliases: Object.freeze(["goose-cli"]),
run: true,
configure: true,
runModel: null, // injected via GOOSE_MODEL env
}),
opencode: Object.freeze({
description: "OpenCode",
aliases: Object.freeze(["open-code"]),
run: true,
configure: true,
runModel: Object.freeze({ flag: "--model", prefix: "omniroute/" }),
}),
qwen: Object.freeze({
description: "Qwen Code",
aliases: Object.freeze(["qwen-code"]),
run: true,
configure: true,
runModel: Object.freeze({ flag: "--model", prefix: "", required: true }),
}),
gemini: Object.freeze({
// Launch contract verified against @google/gemini-cli 0.50.0:
// GOOGLE_GEMINI_BASE_URL points the SDK at OmniRoute's /v1beta surface,
// GEMINI_API_KEY + isolated GEMINI_CLI_HOME (settings selectedType
// "gemini-api-key") force API-key auth over any stored OAuth session.
description: "Google Gemini CLI",
aliases: Object.freeze(["gemini-cli"]),
run: true,
configure: false,
runModel: Object.freeze({ flag: "--model", prefix: "" }),
}),
cline: Object.freeze({
description: "Cline",
aliases: Object.freeze([]),
run: false,
configure: true,
runModel: null,
}),
continue: Object.freeze({
description: "Continue",
aliases: Object.freeze(["cn"]),
run: false,
configure: true,
runModel: null,
}),
kilo: Object.freeze({
description: "Kilo Code",
aliases: Object.freeze(["kilocode", "kilo-code", "kilo_cli"]),
run: false,
configure: true,
runModel: null,
}),
});
/**
* List canonical target ids, optionally filtered by capability
* (`"run"` or `"configure"`). Order follows manifest declaration order.
*/
export function listManifestTargets(capability) {
return Object.entries(CLI_TARGET_MANIFEST)
.filter(([, entry]) => !capability || entry[capability])
.map(([id]) => id);
}
/**
* Resolve a user-supplied target (canonical id or alias) to its canonical id.
* Returns `undefined` when the target is unknown or lacks the capability.
*/
export function resolveManifestTarget(rawTarget, capability) {
const normalized = String(rawTarget || "")
.trim()
.toLowerCase();
if (!normalized) return undefined;
for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) {
if (id === normalized || entry.aliases.includes(normalized)) {
if (capability && !entry[capability]) return undefined;
return id;
}
}
return undefined;
}
/** Model CLI-flag arguments for a `run` target, derived from the manifest. */
export function manifestModelArgs(targetId, model) {
if (!model) return [];
const spec = CLI_TARGET_MANIFEST[targetId]?.runModel;
if (!spec) return [];
const value = spec.prefix && !model.startsWith(spec.prefix) ? `${spec.prefix}${model}` : model;
return [spec.flag, value];
}
/** Whether a `run` target refuses to launch without an explicit model. */
export function manifestRequiresModel(targetId) {
return Boolean(CLI_TARGET_MANIFEST[targetId]?.runModel?.required);
}

View File

@@ -4,6 +4,12 @@ import { homedir } from "node:os";
import { t } from "../i18n.mjs";
import { apiFetch } from "../api.mjs";
import { resolveDataDir } from "../data-dir.mjs";
import { listManifestTargets } from "../cli-manifest.mjs";
// Target lists shared with `omniroute run` / `omniroute configure` — always
// derived from the canonical manifest so the completion scripts cannot drift.
const RUN_TARGET_WORDS = listManifestTargets("run").join(" ");
const CONFIGURE_TARGET_WORDS = listManifestTargets("configure").join(" ");
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h
@@ -129,6 +135,14 @@ _omniroute() {
'completion:Shell completion'
'memory:Manage memory store'
'skills:Manage skills'
'connect:Connect to a local or remote OmniRoute server'
'contexts:Manage local and remote server contexts'
'configure:Configure a supported AI CLI'
'launch:Launch an AI CLI through OmniRoute'
'launch-codex:Launch Codex through OmniRoute'
'run:Run a supported AI CLI through OmniRoute'
'runtime:Inspect CLI runtime capabilities'
'repair:Repair native runtime dependencies'
)
_arguments -C \\
@@ -153,7 +167,7 @@ _omniroute() {
local -a providers
providers=($(_omniroute_get_cache providers))
_describe 'provider' providers ;;
*) _arguments '1:subcommand:(list add remove test)' ;;
*) _arguments '1:subcommand:(available list test test-all validate rotate status add import auth remove edit metrics metric)' ;;
esac ;;
chat|stream)
_arguments \\
@@ -165,6 +179,12 @@ _omniroute() {
_arguments '1:resource:(combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience)' ;;
completion) _arguments '1:subcommand:(zsh bash fish install refresh)' ;;
config) _arguments '1:subcommand:(list get set validate contexts)' ;;
contexts) _arguments '1:subcommand:(list add use current show remove rename export import migrate)' ;;
configure) _arguments '1:target:(${CONFIGURE_TARGET_WORDS})' ;;
run) _arguments '1:target:(${RUN_TARGET_WORDS})' ;;
connect) _arguments '1:host:' ;;
launch|launch-codex) _arguments '--remote[Use a remote server]' '--context[Context name]:' '--model[Model ID]:' ;;
runtime) _arguments '1:subcommand:(check repair clean)' ;;
*) ;;
esac
case $state in
@@ -208,15 +228,19 @@ _omniroute() {
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills"
cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills connect contexts configure launch launch-codex run runtime repair"
case "\${prev}" in
combo) COMPREPLY=($(compgen -W "list switch create delete show suggest" -- "\${cur}")); return 0 ;;
keys) COMPREPLY=($(compgen -W "add list remove regenerate revoke reveal usage" -- "\${cur}")); return 0 ;;
providers) COMPREPLY=($(compgen -W "available list test test-all" -- "\${cur}")); return 0 ;;
providers) COMPREPLY=($(compgen -W "available list test test-all validate rotate status add import auth remove edit metrics metric" -- "\${cur}")); return 0 ;;
config) COMPREPLY=($(compgen -W "list get set validate contexts" -- "\${cur}")); return 0 ;;
completion) COMPREPLY=($(compgen -W "zsh bash fish install refresh" -- "\${cur}")); return 0 ;;
open) COMPREPLY=($(compgen -W "combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience" -- "\${cur}")); return 0 ;;
contexts) COMPREPLY=($(compgen -W "list add use current show remove rename export import migrate" -- "\${cur}")); return 0 ;;
configure) COMPREPLY=($(compgen -W "${CONFIGURE_TARGET_WORDS}" -- "\${cur}")); return 0 ;;
run) COMPREPLY=($(compgen -W "${RUN_TARGET_WORDS}" -- "\${cur}")); return 0 ;;
runtime) COMPREPLY=($(compgen -W "check repair clean" -- "\${cur}")); return 0 ;;
--model)
local models
models=$(_omniroute_get_cache models)
@@ -242,7 +266,7 @@ function generateFishScript() {
return `# OmniRoute CLI fish completion (dynamic)
complete -c omniroute -f
set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills update test
set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills connect contexts configure launch launch-codex update test run runtime repair
for cmd in $commands
complete -c omniroute -n '__fish_is_nth_token 1' -a $cmd
@@ -251,10 +275,14 @@ end
# Subcommands
complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list switch create delete show suggest'
complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'add list remove regenerate revoke reveal usage'
complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all'
complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all validate rotate status add import auth remove edit metrics metric'
complete -c omniroute -n '__fish_seen_subcommand_from config' -a 'list get set validate contexts'
complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish install refresh'
complete -c omniroute -n '__fish_seen_subcommand_from open' -a 'combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience'
complete -c omniroute -n '__fish_seen_subcommand_from contexts' -a 'list add use current show remove rename export import migrate'
complete -c omniroute -n '__fish_seen_subcommand_from configure' -a '${CONFIGURE_TARGET_WORDS}'
complete -c omniroute -n '__fish_seen_subcommand_from run' -a '${RUN_TARGET_WORDS}'
complete -c omniroute -n '__fish_seen_subcommand_from runtime' -a 'check repair clean'
# Dynamic completions from cache (requires python3)
function __omniroute_cache_get

View File

@@ -2,9 +2,17 @@ import os from "node:os";
import path from "node:path";
import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs";
import { apiFetch } from "../api.mjs";
import { loadContexts, resolveActiveContext } from "../contexts.mjs";
import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs";
import { t } from "../i18n.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import {
getModelPreferenceState,
loadModelPreferences,
rankPreferredModels,
recordModelPreference,
} from "../model-preferences.mjs";
import { listManifestTargets, resolveManifestTarget } from "../cli-manifest.mjs";
/**
* `omniroute configure <cli>` — interactive provider+model picker that writes a
@@ -14,11 +22,80 @@ import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
* are in remote mode (`omniroute connect ...`) you pick from the remote server's
* live models and the profile is written on THIS machine.
*
* v1 targets the Codex CLI (writes ~/.codex/<name>.config.toml). The credential
* is referenced by env var (OMNIROUTE_API_KEY) — never written to disk.
* Codex keeps its profile-specific TOML files. Other targets delegate to their
* existing setup-* recipe after the same provider/model selection, so the
* picker remains a read-only orchestration layer and does not duplicate config
* merge logic.
*/
const SUPPORTED = ["codex"];
const SUPPORTED = listManifestTargets("configure");
export const SETUP_MODULES = {
claude: { module: "./setup-claude.mjs", exportName: "runSetupClaudeCommand" },
opencode: { module: "./setup-opencode.mjs", exportName: "runSetupOpencodeCommand" },
qwen: { module: "./setup-qwen.mjs", exportName: "runSetupQwenCommand" },
aider: { module: "./setup-aider.mjs", exportName: "runSetupAiderCommand" },
goose: { module: "./setup-goose.mjs", exportName: "runSetupGooseCommand" },
cline: { module: "./setup-cline.mjs", exportName: "runSetupClineCommand" },
continue: { module: "./setup-continue.mjs", exportName: "runSetupContinueCommand" },
kilo: { module: "./setup-kilo.mjs", exportName: "runSetupKiloCommand" },
};
/**
* Materialize the active server before delegating to a setup recipe.
*
* `apiFetch` knows how to prefer a named context over an ambient
* `OMNIROUTE_API_KEY`, but the older setup modules receive plain options and
* resolve those themselves. Passing the resolved URL/key here keeps the
* picker and the delegated recipe on the same local/remote target, including
* Claude Code which predates context-aware setup resolution.
*/
export function resolveConfigureTargetOptions(opts = {}) {
const resolved = { ...opts };
const ambientKey = process.env.OMNIROUTE_API_KEY || "";
const explicitRemote = opts.remote || opts.baseUrl;
let context;
try {
context = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
} catch {
// A missing/corrupt context file should retain the normal local fallback.
}
if (!explicitRemote) {
const localDefault = `http://localhost:${opts.port || process.env.PORT || "20128"}`;
const contextBase = String(context?.baseUrl || "").replace(/\/+$/, "");
if (contextBase && contextBase !== localDefault) {
resolved.remote = contextBase;
} else if (opts.port) {
resolved.remote = localDefault;
}
} else if (!resolved.remote && resolved.baseUrl) {
resolved.remote = resolved.baseUrl;
}
const contextKey = context?.accessToken || context?.apiKey;
if (contextKey && (!opts.apiKey || opts.apiKey === ambientKey)) {
resolved.apiKey = contextKey;
}
return resolved;
}
export function listConfigureTargets() {
return [...SUPPORTED];
}
export { getModelPreferenceState, rankPreferredModels };
function preferenceContextName(opts = {}) {
if (opts.context || process.env.OMNIROUTE_CONTEXT) {
return String(opts.context || process.env.OMNIROUTE_CONTEXT);
}
try {
return String(loadContexts().currentContext || "default");
} catch {
return "default";
}
}
/** Derive a short, filesystem-safe profile name from a model id. */
export function profileNameFromModel(modelId) {
@@ -80,8 +157,15 @@ async function configureCodex(modelId, ctxWindow, opts) {
toolLabel: "Codex",
hostCommand: "omniroute configure codex",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun: Boolean(opts.dryRun ?? opts["dry-run"]),
});
if (guard !== 0) return guard;
if (opts.dryRun ?? opts["dry-run"]) {
const profile = opts.name || profileNameFromModel(modelId);
const filePath = path.join(codexHome, `${profile}.config.toml`);
printInfo(`[dry-run] would write ${filePath}`);
return 0;
}
if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true });
const profile = opts.name || profileNameFromModel(modelId);
const filePath = path.join(codexHome, `${profile}.config.toml`);
@@ -97,16 +181,22 @@ async function configureCodex(modelId, ctxWindow, opts) {
}
export async function runConfigureCommand(cli, opts = {}, cmd) {
const target = String(cli || "").toLowerCase();
if (!SUPPORTED.includes(target)) {
const target = resolveManifestTarget(cli, "configure");
if (!target) {
printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`);
return 2;
}
if (opts.favorite && opts.unfavorite) {
printError("Choose only one of --favorite or --unfavorite.");
return 2;
}
const globalOpts = cmd ? cmd.optsWithGlobals() : {};
const requestOpts = resolveConfigureTargetOptions({ ...globalOpts, ...opts });
const contextKey = preferenceContextName({ ...globalOpts, ...opts });
let models;
try {
models = await fetchModels(globalOpts);
models = await fetchModels(requestOpts);
} catch (e) {
printError(e instanceof Error ? e.message : String(e));
return 1;
@@ -122,12 +212,15 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
chosenId = `${opts.provider}/${chosenId}`;
}
if (!chosenId) {
if (!chosenId && !opts.yes) {
const ids = models.map((m) => (typeof m === "string" ? m : m.id));
const preferences = loadModelPreferences();
const rankedIds = rankPreferredModels(target, ids, preferences, contextKey);
const preferenceState = getModelPreferenceState(target, preferences, contextKey);
const providers = [...new Set(models.map(providerOf))].sort();
const prompt = createPrompt();
try {
printHeading("Configure Codex CLI");
printHeading(`Configure ${target} CLI`);
let providerList = providers;
if (opts.provider) {
providerList = providers.filter((p) => p === opts.provider);
@@ -136,8 +229,18 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
const p = await prompt.ask("Provider");
if (p) providerList = providers.filter((x) => x === p);
}
const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id))));
const candidates = inProvider.length ? inProvider : ids;
const inProvider = rankedIds.filter((id) =>
providerList.includes(providerOf(byId(models, id)))
);
const candidates = inProvider.length ? inProvider : rankedIds;
if (preferenceState.favorites.length) {
printInfo(
`Favorites: ${preferenceState.favorites.filter((id) => ids.includes(id)).join(", ")}`
);
}
if (preferenceState.recent.length) {
printInfo(`Recent: ${preferenceState.recent.filter((id) => ids.includes(id)).join(", ")}`);
}
printInfo(
`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`
);
@@ -158,10 +261,48 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
}
const ctxWindow = contextWindowOf(entry);
let result;
if (target === "codex") {
return await configureCodex(chosenId, ctxWindow, opts);
result = await configureCodex(chosenId, ctxWindow, opts);
} else {
const setup = SETUP_MODULES[target];
if (!setup) {
printError(`No setup recipe is registered for '${target}'.`);
return 2;
}
try {
const module = await import(setup.module);
const runSetup = module[setup.exportName];
if (typeof runSetup !== "function") {
printError(`Setup recipe '${target}' is unavailable.`);
return 1;
}
const setupOpts = {
...requestOpts,
...opts,
model: chosenId,
// The picker already selected a model. Setup recipes that can generate
// a model subset receive an exact filter; the others use `model`.
...(target === "claude" || target === "continue" ? { only: chosenId } : {}),
yes: true,
};
result = await runSetup(setupOpts);
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
return 0;
if (result === 0 && !(opts.dryRun ?? opts["dry-run"])) {
recordModelPreference(target, chosenId, {
favorite: Boolean(opts.favorite),
unfavorite: Boolean(opts.unfavorite),
context: contextKey,
});
}
return result;
}
function byId(models, id) {
@@ -177,12 +318,20 @@ export function registerConfigure(program) {
.command("configure <cli>")
.description(
t("configure.description") ||
"Pick a provider+model from the active server and write a local CLI config (v1: codex)"
"Pick a provider+model from the active server and configure a supported local CLI"
)
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL")
.option("--context <name>", "Named local/remote context")
.option("--api-key <key>", "OmniRoute API key (defaults to the active context/env)")
.option("--provider <id>", "Provider id (skips the interactive provider prompt)")
.option("--model <id>", "Model id (skips the interactive model prompt)")
.option("--name <name>", "Profile name to write (default: derived from model)")
.option("--codex-home <dir>", "Codex home dir (default: ~/.codex)")
.option("--yes", "Non-interactive; requires --model")
.option("--favorite", "Remember the selected model as a favorite for this CLI")
.option("--unfavorite", "Remove the selected model from this CLI's favorites")
.option("--dry-run", "Preview the generated config without writing")
.option(
"--allow-container-write",
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"

View File

@@ -1,5 +1,5 @@
import { apiFetch } from "../api.mjs";
import { loadContexts, saveContexts } from "../contexts.mjs";
import { loadContexts, saveContextsSecure } from "../contexts.mjs";
import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs";
import { t } from "../i18n.mjs";
@@ -31,7 +31,9 @@ export function normalizeBaseUrl(host, port) {
/** Derive a clean context name from a host (strip scheme/port). */
export function hostLabel(host) {
let value = String(host || "").trim().replace(/^https?:\/\//i, "");
let value = String(host || "")
.trim()
.replace(/^https?:\/\//i, "");
value = value.split("/")[0].split(":")[0];
return value || "remote";
}
@@ -107,7 +109,7 @@ export async function runConnectCommand(host, opts = {}) {
description: `Remote OmniRoute (${host})`,
};
cfg.currentContext = name;
saveContexts(cfg);
await saveContextsSecure(cfg);
printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`);
printInfo("All commands now target this server.");

View File

@@ -1,21 +1,34 @@
import { t } from "../i18n.mjs";
import { emit } from "../output.mjs";
import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs";
import {
loadContexts,
saveContextsSecure,
deleteContextCredential,
migrateContextCredentials,
resolveActiveContext,
} from "../contexts.mjs";
/** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */
function authLabel(c) {
if (c?.accessToken) return "token";
if (c?.apiKey) return "key";
if (c?.credentialRef) return "keychain";
return "✗";
}
function contextMap(config) {
return config.contexts || config.profiles || {};
}
export async function confirm(msg) {
// Non-interactive stdin (pipe, CI, EOF) cannot answer a [y/N] prompt. Asking
// anyway leaves the readline question pending forever — Node then warns about an
// "unsettled top-level await" at exit. Decline cleanly instead and point at the
// non-interactive escape hatch so scripted callers fail safe rather than hang.
if (!process.stdin.isTTY) {
process.stderr.write(`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`);
process.stderr.write(
`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`
);
return false;
}
const readline = await import("node:readline");
@@ -31,6 +44,18 @@ function maskKey(k) {
return `${k.slice(0, 6)}***${k.slice(-4)}`;
}
/** Return an export-safe copy without legacy or canonical context credentials. */
export function redactContextSecrets(config) {
const out = JSON.parse(JSON.stringify(config || {}));
for (const collection of [out.contexts, out.profiles]) {
for (const context of Object.values(collection || {})) {
context.apiKey = null;
delete context.accessToken;
}
}
return out;
}
export function registerContexts(program) {
const ctx = program
.command("contexts")
@@ -43,7 +68,7 @@ export function registerContexts(program) {
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const cfg = loadContexts();
const rows = Object.entries(cfg.contexts || {}).map(([name, c]) => ({
const rows = Object.entries(contextMap(cfg)).map(([name, c]) => ({
active: name === (cfg.currentContext || "default") ? "●" : "",
name,
baseUrl: c.baseUrl || "",
@@ -73,7 +98,7 @@ export function registerContexts(program) {
.option("--description <d>", "Context description")
.action(async (name, opts) => {
const cfg = loadContexts();
if (cfg.contexts?.[name]) {
if (contextMap(cfg)[name]) {
process.stderr.write(`Context '${name}' already exists. Remove or rename first.\n`);
process.exit(2);
}
@@ -86,29 +111,29 @@ export function registerContexts(program) {
if (opts.accessTokenStdin) accessToken = value;
else apiKey = value;
}
cfg.contexts = cfg.contexts || {};
cfg.contexts[name] = {
const contexts = contextMap(cfg);
contexts[name] = {
baseUrl: opts.url,
accessToken: accessToken || undefined,
apiKey,
scope: opts.scope || undefined,
description: opts.description || undefined,
};
saveContexts(cfg);
await saveContextsSecure(cfg);
process.stdout.write(`Added context '${name}'\n`);
});
ctx
.command("use <name>")
.description("Switch active context")
.action((name) => {
.action(async (name) => {
const cfg = loadContexts();
if (!cfg.contexts?.[name]) {
if (!contextMap(cfg)[name]) {
process.stderr.write(`No such context: ${name}\n`);
process.exit(2);
}
cfg.currentContext = name;
saveContexts(cfg);
await saveContextsSecure(cfg);
process.stdout.write(`Active context: ${name}\n`);
});
@@ -143,7 +168,7 @@ export function registerContexts(program) {
.action((name, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const cfg = loadContexts();
const c = cfg.contexts?.[name];
const c = contextMap(cfg)[name];
if (!c) {
process.stderr.write(`No such context: ${name}\n`);
process.exit(2);
@@ -151,6 +176,8 @@ export function registerContexts(program) {
const display = {
name,
baseUrl: c.baseUrl,
auth: authLabel(c),
credentialRef: c.credentialRef || null,
accessToken: maskKey(c.accessToken),
apiKey: maskKey(c.apiKey),
scope: c.scope,
@@ -172,7 +199,7 @@ export function registerContexts(program) {
}
}
const cfg = loadContexts();
if (!cfg.contexts?.[name]) {
if (!contextMap(cfg)[name]) {
process.stderr.write(`No such context: ${name}\n`);
process.exit(2);
}
@@ -180,29 +207,37 @@ export function registerContexts(program) {
process.stderr.write("Cannot remove default context.\n");
process.exit(2);
}
delete cfg.contexts[name];
const contexts = contextMap(cfg);
const deletedCredential = await deleteContextCredential(name, contexts[name]);
if (contexts[name].credentialRef && !deletedCredential) {
process.stderr.write(
"Warning: could not remove the OS-keychain entry; the context reference was removed locally.\n"
);
}
delete contexts[name];
if (cfg.currentContext === name) cfg.currentContext = "default";
saveContexts(cfg);
await saveContextsSecure(cfg);
process.stdout.write(`Removed context '${name}'\n`);
});
ctx
.command("rename <old> <new>")
.description("Rename a context")
.action((oldName, newName) => {
.action(async (oldName, newName) => {
const cfg = loadContexts();
if (!cfg.contexts?.[oldName]) {
const contexts = contextMap(cfg);
if (!contexts[oldName]) {
process.stderr.write(`No such context: ${oldName}\n`);
process.exit(2);
}
if (cfg.contexts[newName]) {
if (contexts[newName]) {
process.stderr.write(`Context '${newName}' already exists.\n`);
process.exit(2);
}
cfg.contexts[newName] = cfg.contexts[oldName];
delete cfg.contexts[oldName];
contexts[newName] = contexts[oldName];
delete contexts[oldName];
if (cfg.currentContext === oldName) cfg.currentContext = newName;
saveContexts(cfg);
await saveContextsSecure(cfg);
process.stdout.write(`Renamed '${oldName}' → '${newName}'\n`);
});
@@ -213,13 +248,7 @@ export function registerContexts(program) {
.option("--no-secrets", "Omit API keys from export")
.action(async (opts, cmd) => {
const cfg = loadContexts();
const out = JSON.parse(JSON.stringify(cfg));
if (opts.noSecrets) {
for (const c of Object.values(out.contexts || {})) {
c.apiKey = null;
delete c.accessToken;
}
}
const out = opts.noSecrets ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg));
const json = JSON.stringify(out, null, 2);
if (opts.out) {
const { writeFileSync } = await import("node:fs");
@@ -248,7 +277,12 @@ export function registerContexts(program) {
const cfg = opts.merge
? loadContexts()
: { version: 1, currentContext: "default", contexts: {} };
const incoming = imported.contexts || {};
if (!cfg.contexts && cfg.profiles) {
cfg.contexts = cfg.profiles;
delete cfg.profiles;
}
cfg.contexts = cfg.contexts || {};
const incoming = imported.contexts || imported.profiles || {};
let count = 0;
for (const [name, raw] of Object.entries(incoming)) {
if (typeof name !== "string" || !name) continue;
@@ -265,7 +299,38 @@ export function registerContexts(program) {
if (!opts.merge && typeof imported.currentContext === "string") {
cfg.currentContext = imported.currentContext;
}
saveContexts(cfg);
await saveContextsSecure(cfg);
process.stdout.write(`Imported ${count} context(s)\n`);
});
ctx
.command("migrate")
.description("Move legacy plaintext context credentials to the OS keychain")
.option("--yes", "Confirm migration in non-interactive scripts")
.action(async (opts) => {
const cfg = loadContexts();
const pending = Object.entries(cfg.contexts || cfg.profiles || {}).filter(
([, context]) => context?.accessToken || context?.apiKey
);
if (!pending.length) {
process.stdout.write("No plaintext context credentials found.\n");
return;
}
if (
!opts.yes &&
!(await confirm(`Migrate ${pending.length} context credential(s) to keychain?`))
) {
process.stdout.write("Cancelled.\n");
return;
}
const result = await migrateContextCredentials();
if (!result.migrated) {
process.stderr.write(
"OS keychain unavailable; credentials remain in config.json mode 0600.\n"
);
process.exitCode = 2;
return;
}
process.stdout.write(`Migrated ${pending.length} context credential(s) to keychain.\n`);
});
}

View File

@@ -170,8 +170,8 @@ export function buildCodexEnv(baseEnv, authToken) {
* @param {string} baseUrl OmniRoute root URL (no /v1)
* @returns {string[]}
*/
export function buildCodexProviderArgs(baseUrl) {
return [
export function buildCodexProviderArgs(baseUrl, model) {
const args = [
"-c",
tomlAssign("model_provider", "omniroute"),
"-c",
@@ -185,6 +185,15 @@ export function buildCodexProviderArgs(baseUrl) {
"-c",
tomlAssign("model_providers.omniroute.requires_openai_auth", false),
];
if (model) {
const normalized = String(model).trim();
if (normalized) {
args.push("-c", tomlAssign("model_providers.omniroute.model", normalized));
}
}
return args;
}
/**
@@ -207,7 +216,7 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
// Provider injected via -c (works without config.toml); then the profile (model),
// then the user's pass-through args.
const providerArgs = buildCodexProviderArgs(baseUrl);
const providerArgs = buildCodexProviderArgs(baseUrl, opts.model);
const profileArgs = opts.profile ? ["--profile", opts.profile] : [];
const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs];
const env = buildCodexEnv(process.env, authToken);
@@ -220,18 +229,45 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
stdio: "inherit",
shell: shellValue,
});
let settled = false;
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
const signalHandlers = {};
const cleanupSignalHandlers = () => {
for (const signal of Object.keys(signalExitCode)) {
process.removeListener(signal, signalHandlers[signal]);
}
};
const finish = (code) => {
if (settled) return;
settled = true;
cleanupSignalHandlers();
resolve(code);
};
for (const signal of Object.keys(signalExitCode)) {
signalHandlers[signal] = () => {
try {
child.kill(signal);
} catch {
// The child may have already exited between the signal and cleanup.
}
finish(signalExitCode[signal]);
};
process.once(signal, signalHandlers[signal]);
}
child.on("error", (err) => {
if (err?.code === "ENOENT") {
console.error(
"The 'codex' CLI was not found in PATH. Install with:\n npm install -g @openai/codex"
);
resolve(127);
finish(127);
} else {
console.error(String(err?.message || err));
resolve(1);
finish(1);
}
});
child.on("exit", (code) => resolve(code ?? 0));
child.on("exit", (code, signalName) => {
finish(code ?? signalExitCode[signalName] ?? 0);
});
});
}

View File

@@ -190,7 +190,10 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
const configDir = opts.profile
? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile)
: undefined;
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
const env = buildClaudeEnv(process.env, baseUrl, authToken, {
configDir,
model: opts.model,
});
const { command, shell } = await resolveClaudeSpawn(process.platform);
@@ -201,16 +204,43 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
shell,
...(process.platform === "win32" ? { windowsHide: true } : {}),
});
let settled = false;
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
const signalHandlers = {};
const cleanupSignalHandlers = () => {
for (const signal of Object.keys(signalExitCode)) {
process.removeListener(signal, signalHandlers[signal]);
}
};
const finish = (code) => {
if (settled) return;
settled = true;
cleanupSignalHandlers();
resolve(code);
};
for (const signal of Object.keys(signalExitCode)) {
signalHandlers[signal] = () => {
try {
child.kill(signal);
} catch {
// The child may have already exited between the signal and cleanup.
}
finish(signalExitCode[signal]);
};
process.once(signal, signalHandlers[signal]);
}
child.on("error", (err) => {
if (err && err.code === "ENOENT") {
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
resolve(127);
finish(127);
} else {
console.error(String(err?.message || err));
resolve(1);
finish(1);
}
});
child.on("exit", (code) => resolve(code ?? 0));
child.on("exit", (code, signalName) => {
finish(code ?? signalExitCode[signalName] ?? 0);
});
});
}

View File

@@ -54,11 +54,20 @@ async function openBrowser(url) {
}
}
async function pollStatus(endpoint, timeoutMs) {
function targetApiOptions(opts = {}) {
return {
baseUrl: opts.baseUrl,
context: opts.context,
apiKey: opts.apiKey,
timeout: opts.timeout,
};
}
async function pollStatus(endpoint, timeoutMs, opts = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await sleep(2000);
const res = await apiFetch(endpoint);
const res = await apiFetch(endpoint, targetApiOptions(opts));
if (!res.ok) continue;
const data = await res.json();
if (data.status === "complete" || data.status === "completed") return data;
@@ -85,7 +94,7 @@ async function runBrowserFlow(def, opts) {
const authorizeUrl = `/api/oauth/${backendKey}/authorize${
redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""
}`;
const startRes = await apiFetch(authorizeUrl, { method: "GET" });
const startRes = await apiFetch(authorizeUrl, { ...targetApiOptions(opts), method: "GET" });
if (!startRes.ok) {
const detail = await safeErrorBody(startRes);
process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}${detail}\n`);
@@ -143,6 +152,7 @@ async function runBrowserFlow(def, opts) {
}
const exchangeRes = await apiFetch(`/api/oauth/${backendKey}/exchange`, {
...targetApiOptions(opts),
method: "POST",
body: {
code,
@@ -179,7 +189,7 @@ async function runImportFlow(def, opts) {
const endpoint = opts.importFromSystem
? `/api/oauth/${def.id}/auto-import`
: `/api/oauth/${def.id}/import`;
const res = await apiFetch(endpoint, { method: "POST" });
const res = await apiFetch(endpoint, { ...targetApiOptions(opts), method: "POST" });
if (!res.ok) {
process.stderr.write(`Import failed: ${res.status}\n`);
process.exit(1);
@@ -195,6 +205,7 @@ async function runSocialFlow(def, opts) {
process.exit(2);
}
const startRes = await apiFetch(`/api/oauth/${def.id}/social-authorize`, {
...targetApiOptions(opts),
method: "POST",
body: { social },
});
@@ -209,14 +220,18 @@ async function runSocialFlow(def, opts) {
process.stderr.write("Waiting for social authorization...\n");
const result = await pollStatus(
`/api/oauth/${def.id}/social-exchange?state=${encodeURIComponent(start.state ?? "")}`,
opts.timeout ?? 300000
opts.timeout ?? 300000,
opts
);
process.stdout.write(`Authorized: ${result.email ?? result.userId ?? "connected"}\n`);
}
async function runDeviceFlow(def, opts) {
const providerKey = resolveBackendKey(def.id);
const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, { method: "POST" });
const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, {
...targetApiOptions(opts),
method: "POST",
});
if (!startRes.ok) {
process.stderr.write(`Failed to start device flow: ${startRes.status}\n`);
process.exit(1);
@@ -233,12 +248,14 @@ async function runDeviceFlow(def, opts) {
while (Date.now() < deadline) {
await sleep(intervalMs);
const statusRes = await apiFetch(
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`,
targetApiOptions(opts)
);
if (!statusRes.ok) continue;
const status = await statusRes.json();
if (status.status === "complete" || status.status === "authorized") {
await apiFetch(`/api/providers/${providerKey}/auth/apply`, {
...targetApiOptions(opts),
method: "POST",
body: { state: start.state },
});
@@ -255,6 +272,7 @@ async function runDeviceFlow(def, opts) {
}
export async function runOAuthStart(opts, cmd) {
opts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
const def = PROVIDERS_WITH_OAUTH.find((p) => p.id === opts.provider);
if (!def) {
process.stderr.write(
@@ -275,22 +293,23 @@ export async function runOAuthStart(opts, cmd) {
}
export async function runOAuthStatus(opts, cmd) {
const globalOpts = cmd.optsWithGlobals();
const globalOpts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
const params = new URLSearchParams();
if (opts.provider) params.set("provider", opts.provider);
const res = await apiFetch(`/api/providers?${params}`);
const res = await apiFetch(`/api/providers?${params}`, targetApiOptions(globalOpts));
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
const data = await res.json();
const connections = (data.providers ?? data.items ?? data).filter(
const connections = (data.connections ?? data.providers ?? data.items ?? data).filter(
(c) => c.authType === "oauth" || c.authType === "oauth2"
);
emit(connections, globalOpts, connectionSchema);
}
export async function runOAuthRevoke(opts, cmd) {
opts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
if (!opts.yes) {
process.stdout.write(
`Revoke OAuth for ${opts.provider}${opts.connectionId ? ` (${opts.connectionId})` : ""}? (yes/no) `
@@ -303,8 +322,11 @@ export async function runOAuthRevoke(opts, cmd) {
}
const id = opts.connectionId;
const res = id
? await apiFetch(`/api/providers/${id}`, { method: "DELETE" })
: await apiFetch(`/api/oauth/${opts.provider}/revoke`, { method: "POST" });
? await apiFetch(`/api/providers/${id}`, { ...targetApiOptions(opts), method: "DELETE" })
: await apiFetch(`/api/oauth/${opts.provider}/revoke`, {
...targetApiOptions(opts),
method: "POST",
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);

View File

@@ -13,6 +13,9 @@ export function registerProvider(program) {
omniroute providers test <name> — test a provider connection
omniroute providers test-all — test all active connections
omniroute providers validate — validate local configuration
omniroute providers add <id> — add an API-key connection
omniroute providers auth <id> — start an existing OAuth flow
omniroute providers remove <id> — remove a connection (requires confirmation)
`);
});
}

View File

@@ -0,0 +1,498 @@
import { readFileSync } from "node:fs";
import { apiFetch, statusToExitCode } from "../api.mjs";
import { createPrompt, printError, printInfo, printSuccess } from "../io.mjs";
import { runOAuthStart } from "./oauth.mjs";
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
function isBlank(value) {
return value === undefined || value === null || String(value).trim() === "";
}
function credentialShape(value) {
if (isBlank(value)) return { present: false, length: 0 };
return { present: true, length: String(value).length };
}
const SENSITIVE_FIELD_RE =
/^(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret|client[_-]?secret|credential|authorization)$/i;
/**
* Redact provider responses before they reach human or JSON output.
*
* The API normally masks credentials, but the CLI must remain safe when an
* operator enables a server-side reveal/debug option or when a compatible
* remote implementation returns a raw field. Presence and length are useful
* for diagnostics; the value itself must never be printed.
*/
export function redactProviderResponse(value, key = "") {
if (SENSITIVE_FIELD_RE.test(key)) {
if (value === null || value === undefined || value === "") return null;
return typeof value === "string" ? credentialShape(value) : "[redacted]";
}
if (Array.isArray(value)) return value.map((entry) => redactProviderResponse(entry));
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => [
entryKey,
redactProviderResponse(entryValue, entryKey),
])
);
}
/**
* Extract a provider connection from the response returned by /api/providers.
* The server deliberately masks credentials, so this helper never needs to
* inspect or log a secret.
*/
export function findConnectionFromResponse(body, selector) {
const rows = Array.isArray(body?.connections)
? body.connections
: Array.isArray(body?.providers)
? body.providers
: Array.isArray(body)
? body
: [];
const needle = String(selector || "")
.trim()
.toLowerCase();
if (!needle) return null;
return (
rows.find((row) => String(row?.id || "").toLowerCase() === needle) ||
rows.find((row) =>
String(row?.id || "")
.toLowerCase()
.startsWith(needle)
) ||
rows.find((row) => String(row?.name || "").toLowerCase() === needle) ||
rows.find((row) => String(row?.provider || "").toLowerCase() === needle) ||
null
);
}
/** Build the API body without accepting management auth as a provider secret. */
export function buildProviderPayload(provider, opts = {}, credential) {
const body = {
provider: String(provider || "").trim(),
name: String(opts.name || provider || "").trim(),
};
if (!body.name) throw new Error("Provider name is required.");
if (!isBlank(credential)) body.apiKey = String(credential);
if (!isBlank(opts.defaultModel)) body.defaultModel = String(opts.defaultModel).trim();
if (!isBlank(opts.priority)) {
const priority = Number(opts.priority);
if (!Number.isInteger(priority) || priority < 1) {
throw new Error("--priority must be a positive integer.");
}
body.priority = priority;
}
if (opts.providerSpecificData) {
const raw = typeof opts.providerSpecificData === "string" ? opts.providerSpecificData : null;
try {
const parsed = raw ? JSON.parse(raw) : opts.providerSpecificData;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("must be a JSON object");
}
body.providerSpecificData = parsed;
} catch (error) {
throw new Error(
`--provider-specific-data must be a JSON object (${error instanceof Error ? error.message : String(error)})`
);
}
}
return body;
}
/** Resolve a credential from an explicit value, env reference, stdin, or prompt. */
export async function resolveProviderCredential(opts = {}, { prompt = true } = {}) {
// Commander represents the negated `--no-credential` option as
// `credential === false`. It is a control flag, never the literal provider
// credential "false".
if (opts.credential === false || opts.noCredential === true) return undefined;
if (!isBlank(opts.credential)) return String(opts.credential).trim();
const envName = String(opts.credentialEnv || opts["credential-env"] || "").trim();
if (envName) {
if (!ENV_NAME_RE.test(envName)) throw new Error("--credential-env must be a valid env name.");
const value = process.env[envName];
if (isBlank(value)) throw new Error(`Environment variable ${envName} is empty or unset.`);
return String(value).trim();
}
if (opts.credentialStdin || opts["credential-stdin"]) {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const value = chunks.join("").trim();
if (!value) throw new Error("Credential stdin was empty.");
return value;
}
if (!prompt) return undefined;
const input = createPrompt();
try {
const value = await input.askSecret("Provider credential (hidden)");
const trimmed = String(value || "").trim();
if (!trimmed) throw new Error("Provider credential is required.");
return trimmed;
} finally {
input.close();
}
}
function targetOptions(opts = {}) {
return {
// Passing the global values through lets api.mjs apply its context-first
// auth precedence. A caller-supplied --base-url remains an explicit target.
baseUrl: opts.baseUrl,
context: opts.context,
apiKey: opts.apiKey,
timeout: opts.timeout,
};
}
async function readApiError(response) {
try {
const body = await response.json();
const message = body?.error?.message || body?.error || body?.message;
return message ? String(message) : `HTTP ${response.status}`;
} catch {
return `HTTP ${response.status}`;
}
}
async function listRemoteConnections(opts) {
return apiFetch("/api/providers?limit=5000", {
...targetOptions(opts),
acceptNotOk: true,
retry: false,
});
}
async function resolveRemoteConnection(selector, opts) {
const response = await listRemoteConnections(opts);
if (!response.ok) {
throw new Error(await readApiError(response));
}
const connection = findConnectionFromResponse(await response.json(), selector);
if (!connection) throw new Error(`Provider connection not found: ${selector}`);
return connection;
}
export async function runProviderAddCommand(provider, opts = {}) {
const normalized = String(provider || "").trim();
if (!normalized) {
printError("Provider id is required.");
return 2;
}
if (opts.oauth) {
if (opts.dryRun) {
if (!opts.silent) {
const preview = { action: "providers.auth", provider: normalized };
if (opts.json) console.log(JSON.stringify(preview, null, 2));
else printInfo(`dry-run: would start OAuth for ${normalized}`);
}
return 0;
}
return runOAuthStart({ ...opts, provider: normalized }, opts.command);
}
const allowNoCredential = Boolean(
opts.allowNoCredential || opts.noCredential || opts.credential === false
);
let credential;
try {
credential = await resolveProviderCredential(opts, {
prompt: !opts.dryRun && !opts.yes && !allowNoCredential,
});
if (!credential && !opts.dryRun && !allowNoCredential) {
throw new Error(
"Provider credential is required (use --credential-stdin or --credential-env)."
);
}
const payload = buildProviderPayload(normalized, opts, credential);
if (opts.dryRun) {
const preview = {
action: "providers.add",
provider: payload.provider,
name: payload.name,
defaultModel: payload.defaultModel || null,
credential: credentialShape(credential),
providerSpecificData: payload.providerSpecificData
? redactProviderResponse(payload.providerSpecificData)
: null,
};
if (!opts.silent) {
if (opts.json) console.log(JSON.stringify(preview, null, 2));
else printInfo(`dry-run: would add ${payload.provider}/${payload.name}`);
}
return 0;
}
const response = await apiFetch("/api/providers", {
...targetOptions(opts),
method: "POST",
body: payload,
acceptNotOk: true,
retry: false,
});
if (!response.ok) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
const body = await response.json().catch(() => ({}));
if (!opts.silent) {
if (opts.json) console.log(JSON.stringify(redactProviderResponse(body), null, 2));
else printSuccess(`Added provider connection '${body?.connection?.name || payload.name}'.`);
}
return 0;
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
export async function runProviderImportCommand(file, opts = {}) {
let parsed;
try {
parsed = JSON.parse(readFileSync(file, "utf8"));
} catch (error) {
printError(
`Cannot read provider import file: ${error instanceof Error ? error.message : String(error)}`
);
return 1;
}
const entries = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.providers)
? parsed.providers
: [parsed];
if (!entries.length) {
printError("Provider import file contains no entries.");
return 2;
}
const results = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object" || !entry.provider) {
results.push({ ok: false, error: "entry.provider is required" });
if (!opts.continueOnError) break;
continue;
}
const code = await runProviderAddCommand(entry.provider, {
...opts,
...entry,
credential: entry.apiKey ?? entry.credential,
dryRun: opts.dryRun,
yes: true,
silent: true,
allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential,
});
results.push({ provider: entry.provider, ok: code === 0, code });
if (code !== 0 && !opts.continueOnError) break;
}
if (opts.json) console.log(JSON.stringify({ file, results }, null, 2));
return results.every((result) => result.ok) ? 0 : 1;
}
async function confirmRemoval(label, opts) {
if (opts.yes) return true;
if (!process.stdin.isTTY) {
printError(`Removal of '${label}' declined on non-interactive stdin; pass --yes to confirm.`);
return false;
}
const prompt = createPrompt();
try {
const answer = await prompt.ask(`Remove provider connection '${label}'? [y/N] `);
return /^y(?:es)?$/i.test(String(answer || "").trim());
} finally {
prompt.close();
}
}
export async function runProviderRemoveCommand(selector, opts = {}) {
if (!selector) {
printError("Provider connection id, name, or provider is required.");
return 2;
}
try {
if (opts.dryRun) {
const connection = await resolveRemoteConnection(selector, opts);
if (opts.json) {
console.log(
JSON.stringify(
redactProviderResponse({ action: "providers.remove", connection }),
null,
2
)
);
} else printInfo(`dry-run: would remove ${connection.name || connection.id}`);
return 0;
}
const connection = await resolveRemoteConnection(selector, opts);
if (!(await confirmRemoval(connection.name || connection.id, opts))) return 0;
const response = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
...targetOptions(opts),
method: "DELETE",
acceptNotOk: true,
retry: false,
});
if (!response.ok) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
if (opts.json)
console.log(JSON.stringify(redactProviderResponse({ removed: connection }), null, 2));
else printSuccess(`Removed provider connection '${connection.name || connection.id}'.`);
return 0;
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
export async function runProviderEditCommand(selector, opts = {}) {
try {
const connection = await resolveRemoteConnection(selector, opts);
const body = {};
if (opts.name !== undefined) body.name = opts.name;
if (opts.defaultModel !== undefined) body.defaultModel = opts.defaultModel || null;
if (opts.priority !== undefined) body.priority = Number(opts.priority);
if (opts.active !== undefined) body.isActive = Boolean(opts.active);
if (opts.inactive !== undefined) body.isActive = false;
const credential = await resolveProviderCredential(opts, { prompt: false });
if (credential) body.apiKey = credential;
if (Object.keys(body).length === 0) {
printError(
"At least one edit field is required (--name, --default-model, --priority, --active/--inactive, or credential)."
);
return 2;
}
if (opts.dryRun) {
const preview = {
action: "providers.edit",
connection: redactProviderResponse(connection),
changes: { ...body, apiKey: credentialShape(body.apiKey) },
};
if (opts.json) console.log(JSON.stringify(preview, null, 2));
else printInfo(`dry-run: would edit ${connection.name || connection.id}`);
return 0;
}
const response = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
...targetOptions(opts),
method: "PUT",
body,
acceptNotOk: true,
retry: false,
});
if (!response.ok) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
const result = await response.json().catch(() => ({}));
if (opts.json) console.log(JSON.stringify(redactProviderResponse(result), null, 2));
else printSuccess(`Updated provider connection '${connection.name || connection.id}'.`);
return 0;
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
export async function runProviderAuthCommand(provider, opts = {}, cmd) {
return runOAuthStart({ ...opts, provider }, cmd);
}
export function registerProviderCrud(providers) {
providers
.command("add <provider>")
.description("Add an API-key provider connection through the active local/remote server")
.option("--name <name>", "Connection name (defaults to provider id)")
.option(
"--credential <key>",
"Provider credential (prefer --credential-stdin or --credential-env)"
)
.option("--credential-env <name>", "Read provider credential from an environment variable")
.option("--credential-stdin", "Read provider credential from stdin")
.option("--allow-no-credential", "Allow providers whose catalog marks the credential optional")
.option("--no-credential", "Allow providers whose catalog marks the credential optional")
.option("--default-model <id>", "Default model for this connection")
.option("--priority <n>", "Connection priority", Number)
.option("--provider-specific-data <json>", "Provider-specific settings as a JSON object")
.option("--oauth", "Start the provider's existing OAuth flow instead")
.option("--yes", "Do not prompt for a credential")
.option("--dry-run", "Preview the request without writing")
.option("--json", "Print machine-readable output")
.action(async (provider, opts, cmd) => {
const code = await runProviderAddCommand(provider, {
...cmd.parent.optsWithGlobals(),
...opts,
command: cmd,
});
if (code !== 0) process.exit(code);
});
providers
.command("import <file>")
.description("Import provider connections from a JSON file")
.option("--continue-on-error", "Continue importing after a failed entry")
.option("--dry-run", "Preview requests without writing")
.option("--json", "Print machine-readable output")
.action(async (file, opts, cmd) => {
const code = await runProviderImportCommand(file, {
...cmd.parent.optsWithGlobals(),
...opts,
});
if (code !== 0) process.exit(code);
});
providers
.command("auth <provider>")
.description("Start an existing OAuth flow for a provider")
.option("--no-browser", "Print the authorization URL instead of opening a browser")
.option("--import-from-system", "Import credentials from the local system when supported")
.option("--social <provider>", "Use a social-login flow when supported")
.option("--timeout <ms>", "OAuth timeout", Number, 300000)
.action(async (provider, opts, cmd) => {
const code = await runProviderAuthCommand(
provider,
{ ...cmd.parent.optsWithGlobals(), ...opts },
cmd
);
if (code !== 0) process.exit(code);
});
providers
.command("remove <idOrName>")
.description("Remove one provider connection from the active local/remote server")
.option("--yes", "Confirm removal")
.option("--dry-run", "Preview the removal without writing")
.option("--json", "Print machine-readable output")
.action(async (idOrName, opts, cmd) => {
const code = await runProviderRemoveCommand(idOrName, {
...cmd.parent.optsWithGlobals(),
...opts,
});
if (code !== 0) process.exit(code);
});
providers
.command("edit <idOrName>")
.description("Edit one provider connection on the active local/remote server")
.option("--name <name>", "New connection name")
.option("--default-model <id>", "New default model")
.option("--priority <n>", "New connection priority", Number)
.option("--active", "Activate the connection")
.option("--inactive", "Deactivate the connection")
.option("--credential <key>", "Replace provider credential")
.option("--credential-env <name>", "Read replacement credential from an environment variable")
.option("--credential-stdin", "Read replacement credential from stdin")
.option("--dry-run", "Preview the edit without writing")
.option("--json", "Print machine-readable output")
.action(async (idOrName, opts, cmd) => {
const code = await runProviderEditCommand(idOrName, {
...cmd.parent.optsWithGlobals(),
...opts,
});
if (code !== 0) process.exit(code);
});
}

View File

@@ -13,6 +13,7 @@ import {
import { encryptCredential } from "../encryption.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
import { t } from "../i18n.mjs";
import { registerProviderCrud } from "./provider-crud.mjs";
function publicConnection(connection) {
return {
@@ -604,6 +605,8 @@ export function registerProviders(program) {
if (exitCode !== 0) process.exit(exitCode);
});
registerProviderCrud(providers);
extendProvidersMetrics(providers);
}

View File

@@ -2,7 +2,7 @@ import { apiFetch, isServerUp } from "../api.mjs";
import { t } from "../i18n.mjs";
export function registerQuota(program) {
program
const quota = program
.command("quota")
.description(t("quota.description"))
.option("--provider <id>", "Filter by provider")
@@ -12,6 +12,60 @@ export function registerQuota(program) {
const exitCode = await runQuotaCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
quota
.command("status")
.description("Show truthful OmniRoute gateway, quota, pool, and circuit state")
.action(async (opts, cmd) => runBoundedJson("/api/omniroute/status", cmd.optsWithGlobals()));
quota
.command("preview")
.description("Preview allocation enforcement without an upstream request")
.requiredOption("--api-key-id <id>", "API key id")
.requiredOption("--pool-id <id>", "quota pool id")
.option("--tokens <n>", "estimated token usage")
.action(async (opts, cmd) => {
const params = new URLSearchParams({ apiKeyId: opts.apiKeyId, poolId: opts.poolId });
if (opts.tokens != null) params.set("estimatedTokens", opts.tokens);
await runBoundedJson(`/api/quota/preview?${params}`, cmd.optsWithGlobals());
});
quota
.command("ensure <json>")
.description("Idempotently create or update a quota pool from a JSON object")
.action(async (json, opts, cmd) => {
let body;
try {
body = JSON.parse(json);
} catch {
console.error("Invalid pool JSON");
process.exit(2);
}
await runBoundedJson("/api/quota/pools?ensure=true", cmd.optsWithGlobals(), {
method: "POST",
body,
});
});
}
async function runBoundedJson(path, opts, request = {}) {
const started = performance.now();
const res = await apiFetch(path, {
...request,
retry: false,
timeout: Math.min(opts.timeout ?? 5000, 5000),
acceptNotOk: true,
});
const elapsed = Math.round(performance.now() - started);
if (process.env.OMNIROUTE_DEBUG === "1") {
console.error(`[omniroute] ${request.method ?? "GET"} ${path} completed in ${elapsed}ms`);
}
const payload = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
if (!res.ok) {
console.error(JSON.stringify(payload));
process.exit(res.exitCode ?? 1);
}
console.log(JSON.stringify(payload, null, 2));
}
export async function runQuotaCommand(opts = {}) {

View File

@@ -60,6 +60,7 @@ import { registerAutostart } from "./autostart.mjs";
import { registerRepl } from "./repl.mjs";
import { registerLaunch } from "./launch.mjs";
import { registerLaunchCodex } from "./launch-codex.mjs";
import { registerRun } from "./run.mjs";
import { registerSetupCodex } from "./setup-codex.mjs";
import { registerSetupClaude } from "./setup-claude.mjs";
import { registerSetupOpencode } from "./setup-opencode.mjs";
@@ -145,6 +146,7 @@ export function registerCommands(program) {
registerRepl(program);
registerLaunch(program);
registerLaunchCodex(program);
registerRun(program);
registerSetupCodex(program);
registerSetupClaude(program);
registerSetupOpencode(program);

609
bin/cli/commands/run.mjs Normal file
View File

@@ -0,0 +1,609 @@
import {
runLaunchCommand as runLaunchClaudeCommand,
buildClaudeEnv,
resolveClaudeSpawn,
quoteClaudeArgs,
resolveLaunchTarget,
} from "./launch.mjs";
import {
buildCodexEnv,
buildCodexProviderArgs,
resolveCodexSpawn,
quoteCodexArgs,
resolveCodexTarget,
runLaunchCodexCommand as runLaunchCodexCommand,
} from "./launch-codex.mjs";
import { t } from "../i18n.mjs";
import os from "node:os";
import { join } from "node:path";
import { spawn, execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { resolveActiveContext } from "../contexts.mjs";
import { quoteShellArgs } from "../utils/winShellArgs.mjs";
import {
listManifestTargets,
manifestModelArgs,
manifestRequiresModel,
resolveManifestTarget,
} from "../cli-manifest.mjs";
function isBlank(value) {
return value === undefined || value === null || String(value).trim() === "";
}
function toAuthSource(targetOpts) {
const explicit =
!isBlank(targetOpts.token) || !isBlank(targetOpts.apiKey) || !isBlank(targetOpts["api-key"]);
if (explicit) return "option";
const envName = String(targetOpts.apiKeyEnv || targetOpts["api-key-env"] || "").trim();
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName) && !isBlank(process.env[envName])) {
return "env";
}
try {
const context = resolveActiveContext(targetOpts.context || process.env.OMNIROUTE_CONTEXT);
if (context && (context.accessToken || context.apiKey)) return "context";
} catch {
// no active context
}
if (!isBlank(process.env.OMNIROUTE_API_KEY)) return "env";
if (!isBlank(process.env.ANTHROPIC_AUTH_TOKEN)) return "env";
return "none";
}
/** Resolve a token option without ever printing its value in a plan. */
function resolveAuthTokenOption(targetOpts = {}) {
const direct = targetOpts.token || targetOpts.apiKey || targetOpts["api-key"];
if (!isBlank(direct)) return direct;
const envName = String(targetOpts.apiKeyEnv || targetOpts["api-key-env"] || "").trim();
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName)) return process.env[envName];
return undefined;
}
/** Resolve supported target (id or alias) to canonical id via the manifest. */
export function resolveRunTarget(target) {
return resolveManifestTarget(target, "run");
}
export function listRunTargets() {
return listManifestTargets("run");
}
/**
* Normalize `--provider` + `--model` into one model id.
*
* - when model contains a slash, keep it as-is
* - when provider exists and model does not, prefix provider/
*/
export function resolveModelFromTargetOptions(targetOpts = {}) {
const provider = String(targetOpts.provider || "").trim();
const model = String(targetOpts.model || "").trim();
if (!model) return "";
if (provider && !model.includes("/")) return `${provider}/${model}`;
return model;
}
function describeCommand(command, shellMode) {
return `${command}${shellMode ? " [shell]" : ""}`;
}
function envPreview(before = {}, after = {}) {
const beforeKeys = new Set(Object.keys(before));
const changedOrAdded = [];
const removed = [];
for (const key of Object.keys(after)) {
if (!beforeKeys.has(key) || String(before[key]) !== String(after[key])) {
changedOrAdded.push(key);
}
}
for (const key of Object.keys(before)) {
if (!(key in after)) removed.push(key);
}
return {
changedOrAdded,
removed,
};
}
async function buildClaudePlan(rawOpts, args = []) {
const model = resolveModelFromTargetOptions(rawOpts);
const merged = {
...rawOpts,
model,
apiKey: resolveAuthTokenOption(rawOpts),
token: resolveAuthTokenOption(rawOpts),
profile: rawOpts.profile ?? rawOpts.p,
};
const { baseUrl, authToken } = resolveLaunchTarget(merged);
const commandSpec = await resolveClaudeSpawn(process.platform);
const configDir = merged.profile
? join(merged.claudeHome || join(os.homedir(), ".claude"), "profiles", merged.profile)
: undefined;
const env = buildClaudeEnv(process.env, baseUrl, authToken, {
configDir,
model: merged.model || undefined,
});
const quotedArgs = quoteClaudeArgs(args, process.platform);
return {
target: "claude",
baseUrl,
command: commandSpec.command,
shell: commandSpec.shell,
args: quotedArgs,
model: merged.model || undefined,
envDiff: envPreview(process.env, env),
authSource: toAuthSource(rawOpts),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
};
}
async function buildCodexPlan(rawOpts, args = []) {
const model = resolveModelFromTargetOptions(rawOpts);
const merged = {
...rawOpts,
apiKey: resolveAuthTokenOption(rawOpts),
model,
profile: rawOpts.profile ?? rawOpts.p,
};
const { baseUrl, authToken } = resolveCodexTarget(merged);
const commandSpec = await resolveCodexSpawn(process.platform);
const providerArgs = buildCodexProviderArgs(baseUrl, merged.model || undefined);
const profileArgs = merged.profile ? ["--profile", merged.profile] : [];
const env = buildCodexEnv(process.env, authToken);
const fullArgs = [...providerArgs, ...profileArgs, ...args];
const quotedArgs = quoteCodexArgs(fullArgs, process.platform);
return {
target: "codex",
baseUrl,
command: commandSpec.command,
shell: commandSpec.shell,
args: quotedArgs,
model: merged.model || undefined,
envDiff: envPreview(process.env, env),
authSource: toAuthSource(rawOpts),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
providerArgs,
profileArgs,
};
}
const NO_AUTH_SENTINEL = "omniroute-no-auth";
function resolveGenericSpawn(command) {
if (process.platform !== "win32") return { command, shell: undefined };
try {
const output = execFileSync("where.exe", [command], {
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
timeout: 3000,
windowsHide: true,
});
const matches = output
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean);
const preferred = matches.find((value) => /\.exe$/i.test(value));
if (preferred) return { command: preferred, shell: undefined };
const shim = matches.find((value) => /\.(?:cmd|bat)$/i.test(value));
if (shim) return { command: shim, shell: true };
} catch {
// Fall through to the conventional npm shim.
}
return { command: `${command}.cmd`, shell: true };
}
function genericEnv(baseEnv, kind, baseUrl, authToken, model) {
const env = { ...baseEnv };
for (const key of Object.keys(env)) {
if (kind === "aider" && /^(OPENAI_API_KEY|OPENAI_API_BASE|OPENAI_BASE_URL)$/.test(key)) {
delete env[key];
}
if (
kind === "goose" &&
(/^(OPENAI_API_KEY|OPENAI_API_BASE|OPENAI_BASE_URL)$/.test(key) || key.startsWith("GOOSE_"))
) {
delete env[key];
}
if (kind === "opencode" && key === "OPENCODE_CONFIG_CONTENT") delete env[key];
if (kind === "qwen" && (key === "QWEN_HOME" || key === "OMNIROUTE_API_KEY")) {
delete env[key];
}
if (
kind === "gemini" &&
/^(GOOGLE_GEMINI_BASE_URL|GEMINI_API_KEY|GOOGLE_API_KEY|GEMINI_CLI_HOME|GEMINI_DEFAULT_AUTH_TYPE|GOOGLE_GENAI_USE_VERTEXAI|GOOGLE_GENAI_USE_GCA)$/.test(
key
)
) {
delete env[key];
}
}
const token = (authToken && String(authToken).trim()) || NO_AUTH_SENTINEL;
if (kind === "aider") {
env.OPENAI_API_BASE = baseUrl;
env.OPENAI_API_KEY = token;
} else if (kind === "goose") {
env.GOOSE_PROVIDER = "openai";
env.OPENAI_HOST = baseUrl;
env.OPENAI_API_KEY = token;
if (model) env.GOOSE_MODEL = model;
} else if (kind === "opencode") {
env.OMNIROUTE_API_KEY = token;
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
omniroute: {
npm: "@ai-sdk/openai-compatible",
name: "OmniRoute",
options: {
baseURL: ensureV1BaseUrl(baseUrl),
apiKey: "{env:OMNIROUTE_API_KEY}",
},
...(model ? { models: { [model]: { name: model } } } : {}),
},
},
});
} else if (kind === "qwen") {
env.OMNIROUTE_API_KEY = token;
} else if (kind === "gemini") {
// Verified against @google/gemini-cli 0.50.0: the SDK appends
// /v1beta/models/<model>:generateContent to this base URL, which is
// OmniRoute's native Gemini surface. Auth is the API-key path; the
// isolated GEMINI_CLI_HOME (set at spawn time) keeps any stored OAuth
// session from overriding it.
env.GOOGLE_GEMINI_BASE_URL = baseUrl;
env.GEMINI_API_KEY = token;
env.GEMINI_DEFAULT_AUTH_TYPE = "gemini-api-key";
}
return env;
}
function ensureV1BaseUrl(baseUrl) {
const normalized = String(baseUrl || "").replace(/\/+$/, "");
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
}
function modelArgsForTarget(target, model) {
return manifestModelArgs(target, model);
}
function buildGeminiSettings() {
// Force API-key auth in the isolated home so the operator's stored OAuth
// session (Code Assist) never leaks into an OmniRoute-directed launch.
return JSON.stringify({ security: { auth: { selectedType: "gemini-api-key" } } }, null, 2);
}
function buildQwenSettings(baseUrl, model) {
const qwenBaseUrl = ensureV1BaseUrl(baseUrl);
return JSON.stringify(
{
modelProviders: {
openai: [
{
id: model,
name: `${model} (OmniRoute)`,
envKey: "OMNIROUTE_API_KEY",
baseUrl: qwenBaseUrl,
},
],
},
security: { auth: { selectedType: "openai" } },
model: { name: model, baseUrl: qwenBaseUrl },
},
null,
2
);
}
async function buildGenericPlan(target, rawOpts, args = []) {
const { baseUrl, authToken } = resolveLaunchTarget({
...rawOpts,
apiKey: resolveAuthTokenOption(rawOpts),
});
const commandSpec = resolveGenericSpawn(target);
const model = resolveModelFromTargetOptions(rawOpts);
if (manifestRequiresModel(target) && !model) {
throw new Error("Qwen Code requires --model in non-interactive OmniRoute launches");
}
const modelArgs = modelArgsForTarget(target, model);
const fullArgs = [...modelArgs, ...args];
const env = genericEnv(process.env, target, baseUrl, authToken, model);
return {
target,
baseUrl,
command: commandSpec.command,
shell: commandSpec.shell,
args: quoteShellArgs(fullArgs, process.platform),
model: model || undefined,
envDiff: envPreview(process.env, env),
authSource: toAuthSource(rawOpts),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
modelArgs,
configOverlay:
target === "qwen"
? "temporary QWEN_HOME (removed after exit)"
: target === "gemini"
? "temporary GEMINI_CLI_HOME (removed after exit)"
: target === "opencode"
? "OPENCODE_CONFIG_CONTENT (process environment only)"
: undefined,
};
}
async function healthCheckForRun(baseUrl) {
try {
const response = await fetch(`${baseUrl}/api/monitoring/health`, {
signal: AbortSignal.timeout(3000),
});
return response.ok;
} catch {
return false;
}
}
async function runGenericTarget(target, rawOpts, args) {
const { baseUrl, authToken } = resolveLaunchTarget({
...rawOpts,
apiKey: resolveAuthTokenOption(rawOpts),
});
if (!(await healthCheckForRun(baseUrl))) {
console.error(`OmniRoute is not reachable at ${baseUrl}. Start it or check --remote.`);
return 1;
}
const model = resolveModelFromTargetOptions(rawOpts);
if (manifestRequiresModel(target) && !model) {
console.error("Qwen Code requires --model in non-interactive OmniRoute launches.");
return 2;
}
const modelArgs = modelArgsForTarget(target, model);
const commandSpec = resolveGenericSpawn(target);
const childEnv = genericEnv(process.env, target, baseUrl, authToken, model);
let overlayHome;
if (target === "qwen") {
overlayHome = mkdtempSync(join(os.tmpdir(), "omniroute-qwen-run-"));
writeFileSync(join(overlayHome, "settings.json"), buildQwenSettings(baseUrl, model), {
encoding: "utf8",
mode: 0o600,
});
childEnv.QWEN_HOME = overlayHome;
} else if (target === "gemini") {
overlayHome = mkdtempSync(join(os.tmpdir(), "omniroute-gemini-run-"));
mkdirSync(join(overlayHome, ".gemini"), { recursive: true });
writeFileSync(join(overlayHome, ".gemini", "settings.json"), buildGeminiSettings(), {
encoding: "utf8",
mode: 0o600,
});
childEnv.GEMINI_CLI_HOME = overlayHome;
}
const child = spawn(
commandSpec.command,
quoteShellArgs([...modelArgs, ...args], process.platform),
{
env: childEnv,
stdio: "inherit",
shell: commandSpec.shell,
...(process.platform === "win32" ? { windowsHide: true } : {}),
}
);
const cleanup = () => {
if (!overlayHome) return;
try {
rmSync(overlayHome, { recursive: true, force: true });
} catch {
// Best-effort cleanup; the directory contains no persistent credentials.
}
};
return await new Promise((resolve) => {
let settled = false;
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
const finish = (code) => {
if (settled) return;
settled = true;
for (const signal of Object.keys(signalExitCode)) {
process.removeListener(signal, signalHandlers[signal]);
}
cleanup();
resolve(code);
};
const signalHandlers = {};
for (const signal of Object.keys(signalExitCode)) {
signalHandlers[signal] = () => {
try {
child.kill(signal);
} catch {
// The child may have already exited between the signal and cleanup.
}
finish(signalExitCode[signal]);
};
process.once(signal, signalHandlers[signal]);
}
child.on("error", (error) => {
if (error?.code === "ENOENT") {
console.error(`The '${target}' CLI was not found in PATH.`);
finish(127);
} else {
console.error(String(error?.message || error));
finish(1);
}
});
child.on("exit", (code, signal) => {
finish(code ?? signalExitCode[signal] ?? 0);
});
});
}
/** Build a launch plan and redact any resolved secret values. */
export async function buildRunPlan(target, rawOpts = {}, args = []) {
const canonical = resolveRunTarget(target);
if (!canonical) {
throw new Error(
`Unsupported target '${target}'. Supported targets: ${listRunTargets().join(", ")}`
);
}
if (canonical === "claude") {
return buildClaudePlan(rawOpts, args);
}
if (canonical === "codex") {
return buildCodexPlan(rawOpts, args);
}
return buildGenericPlan(canonical, rawOpts, args);
}
function writeDryRunOutput(plan, opts = {}) {
const output = {
target: plan.target,
baseUrl: plan.baseUrl,
command: plan.command,
args: plan.args,
auth: {
source: plan.authSource,
present: plan.authSource !== "none",
},
shell: !!plan.shell,
model: plan.model || null,
configOverlay: plan.configOverlay || null,
env: {
changedOrAdded: plan.envDiff.changedOrAdded,
removed: plan.envDiff.removed,
},
};
if (opts.json) {
console.error(`Running in dry-run mode for '${plan.target}'.`);
console.log(JSON.stringify(output, null, 2));
} else {
console.log(`target: ${output.target}`);
console.log(`baseUrl: ${output.baseUrl}`);
console.log(`command: ${output.command}`);
console.log(`shell: ${output.shell ? "yes" : "no"}`);
console.log(`args: ${JSON.stringify(output.args)}`);
console.log(`auth: ${JSON.stringify(output.auth)}`);
console.log(`model: ${output.model || "(not set)"}`);
if (output.configOverlay) console.log(`config overlay: ${output.configOverlay}`);
if (output.env.changedOrAdded.length) {
console.log(`env added/changed: ${output.env.changedOrAdded.join(", ")}`);
}
if (output.env.removed.length) {
console.log(`env removed: ${output.env.removed.join(", ")}`);
}
}
}
function buildExecutionOptionsForClaude(rawOpts) {
return {
...rawOpts,
model: resolveModelFromTargetOptions(rawOpts),
token: resolveAuthTokenOption(rawOpts),
apiKey: resolveAuthTokenOption(rawOpts),
profile: rawOpts.profile || rawOpts.p,
};
}
function buildExecutionOptionsForCodex(rawOpts) {
return {
...rawOpts,
model: resolveModelFromTargetOptions(rawOpts),
apiKey: resolveAuthTokenOption(rawOpts),
profile: rawOpts.profile || rawOpts.p,
};
}
/**
* Execute or preview one target launch.
*
* Return code conventions:
* 0 success, 1 runtime launch failure, 2 invalid args.
*/
export async function runCliTarget(target, opts = {}, args = []) {
const canonical = resolveRunTarget(target);
if (!canonical) {
process.stderr.write(
`Unsupported target '${target}'. Supported targets: ${listRunTargets().join(", ")}\n`
);
return 2;
}
let plan;
try {
plan = await buildRunPlan(target, opts, args);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 2;
}
if (opts.dryRun) {
writeDryRunOutput(plan, opts);
return 0;
}
if (canonical === "claude") {
return await runLaunchClaudeCommand(buildExecutionOptionsForClaude(opts), args);
}
if (canonical === "codex") {
return await runLaunchCodexCommand(buildExecutionOptionsForCodex(opts), args);
}
return await runGenericTarget(canonical, opts, args);
}
export function registerRun(program) {
program
.command("run <target>")
.description(t("run.description") || "Run a supported CLI target through OmniRoute")
.option(
"--port <port>",
"Local OmniRoute port (ignored when --remote or --base-url is set)",
"20128"
)
.option(
"--remote <url>",
"Remote OmniRoute base URL (overrides --port, --base-url, and the active context)"
)
.option("--base-url <url>", "OmniRoute base URL (alias for --remote)")
.option("--context <name>", "Named local/remote context to use for URL and credentials")
.option("--provider <id>", "Provider id for shorthand model composition")
.option("--model <id>", "Model id to inject in the launched target where supported")
.option("--profile <name>", "Profile/alias argument for target launchers that support it")
.option("-p, --p <name>", "Alias for --profile")
.option("--token <token>", "Authentication token for the launched target (same as --api-key)")
.option("--api-key <key>", "Authentication token for the launched target")
.option("--api-key-env <name>", "Read the launch token from an environment variable")
.option("--dry-run", "Show planned command and env keys without executing")
.option("--json", "Return dry-run output in machine-readable format")
.allowUnknownOption(true)
.allowExcessArguments(true)
.argument("[toolArgs...]")
.action(async (target, toolArgs = [], opts, cmd) => {
const globalOpts = cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {};
const merged = { ...globalOpts, ...opts };
const code = await runCliTarget(target, merged, toolArgs);
// process.exit() here can interrupt cleanup when the child terminates;
// setting process.exitCode lets the event loop drain first.
process.exitCode = code;
});
}

View File

@@ -133,6 +133,29 @@ async function setupProvider(db, opts, prompt, nonInteractive) {
return connection;
}
/**
* Merge the `setup` subcommand options with the program-level ones.
*
* The program declares a global `--api-key` (the OmniRoute *server* key, see
* bin/cli/program.mjs) and `setup` declares its own `--api-key` (the *provider*
* key). Commander binds the value to the program-level option, so the
* subcommand's `opts.apiKey` is always `undefined` and `--add-provider` failed
* with "Provider API key is required" even when `--api-key` was passed. Falling
* back to the global value also makes `OMNIROUTE_API_KEY` work, which the error
* message already told users to use.
*
* @param {Record<string, unknown>} opts Subcommand options.
* @param {Record<string, unknown>} globalOpts Result of `cmd.optsWithGlobals()`.
* @returns {Record<string, unknown>} Options to hand to `runSetupCommand`.
*/
export function mergeSetupOptions(opts, globalOpts) {
return {
...opts,
apiKey: opts.apiKey ?? globalOpts.apiKey,
output: globalOpts.output,
};
}
export function registerSetup(program) {
program
.command("setup")
@@ -149,7 +172,7 @@ export function registerSetup(program) {
.option("--list", "List all supported CLI tools")
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const exitCode = await runSetupCommand({ ...opts, output: globalOpts.output });
const exitCode = await runSetupCommand(mergeSetupOptions(opts, globalOpts));
if (exitCode !== 0) process.exit(exitCode);
});

View File

@@ -80,7 +80,7 @@ async function _runAllProviders(opts) {
return 1;
}
const data = await res.json();
const connections = (data.providers ?? data.items ?? data).filter(
const connections = (data.connections ?? data.providers ?? data.items ?? data).filter(
(c) => c.authType === "apikey" || c.testStatus !== "unavailable"
);
if (connections.length === 0) {

View File

@@ -3,6 +3,108 @@ import { join, dirname } from "node:path";
import { resolveDataDir } from "./data-dir.mjs";
const CONFIG_VERSION = 1;
const KEYCHAIN_SERVICE = "omniroute-cli";
const KEYCHAIN_DISABLED = /^(1|true|yes|on)$/i.test(
String(process.env.OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED || "")
);
// `keytar` is optional and native. Keeping it behind a small interface lets
// headless installs use the same CLI without requiring libsecret/Keychain at
// install time, while tests can inject a deterministic fake backend.
let keychainBackend = null;
let keychainOperational = true;
let warnedPlaintextFallback = false;
const credentialCache = new Map();
function isKeychainBackend(value) {
return (
value &&
typeof value.getPassword === "function" &&
typeof value.setPassword === "function" &&
typeof value.deletePassword === "function"
);
}
async function loadKeychainBackend() {
if (KEYCHAIN_DISABLED) return null;
try {
const imported = await import("keytar");
const candidate = isKeychainBackend(imported?.default) ? imported.default : imported;
return isKeychainBackend(candidate) ? candidate : null;
} catch {
// Native keychain modules are optional and commonly unavailable in
// containers. The secure file fallback is handled explicitly below.
return null;
}
}
function parseCredential(value) {
if (!value || typeof value !== "string") return null;
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const result = {};
if (typeof parsed.accessToken === "string" && parsed.accessToken) {
result.accessToken = parsed.accessToken;
}
if (typeof parsed.apiKey === "string" && parsed.apiKey) result.apiKey = parsed.apiKey;
return result.accessToken || result.apiKey ? result : null;
} catch {
// Older/externally managed entries may contain one raw token.
return { accessToken: value };
}
}
function credentialForContext(context) {
const ref = context && typeof context.credentialRef === "string" ? context.credentialRef : "";
return ref ? credentialCache.get(ref) || null : null;
}
function applyCachedCredential(context) {
const cached = credentialForContext(context);
if (!cached) return { ...context };
return { ...context, ...cached };
}
async function hydrateCredentialCache(cfg) {
if (!keychainBackend || !keychainOperational) return;
const contexts = cfg?.contexts || cfg?.profiles || {};
for (const context of Object.values(contexts)) {
const ref = context && typeof context === "object" ? context.credentialRef : null;
if (!ref || credentialCache.has(ref)) continue;
try {
const parsed = parseCredential(await keychainBackend.getPassword(KEYCHAIN_SERVICE, ref));
if (parsed) credentialCache.set(ref, parsed);
} catch {
keychainOperational = false;
break;
}
}
}
function warnPlaintextFallback() {
if (warnedPlaintextFallback) return;
warnedPlaintextFallback = true;
process.stderr.write(
"Warning: OS keychain unavailable; context credentials use config.json mode 0600 fallback.\n"
);
}
function readConfigFile() {
try {
if (!existsSync(configPath())) return defaultConfig();
const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
return parsed && typeof parsed === "object" ? parsed : defaultConfig();
} catch {
return defaultConfig();
}
}
// Resolve keychain state before importing commands can call the synchronous
// compatibility helpers below. Credentials themselves stay in memory; only a
// stable reference is persisted in config.json when keytar is available.
keychainBackend = await loadKeychainBackend();
await hydrateCredentialCache(readConfigFile());
export function configPath() {
return join(resolveDataDir(), "config.json");
@@ -19,14 +121,13 @@ function defaultConfig() {
}
export function loadContexts() {
try {
if (!existsSync(configPath())) return defaultConfig();
return JSON.parse(readFileSync(configPath(), "utf8"));
} catch {
return defaultConfig();
}
return readConfigFile();
}
/**
* Synchronous compatibility writer. New credential-bearing code should use
* `saveContextsSecure()` so tokens are moved to the OS keychain when possible.
*/
export function saveContexts(cfg) {
const path = configPath();
mkdirSync(dirname(path), { recursive: true });
@@ -36,6 +137,116 @@ export function saveContexts(cfg) {
} catch {}
}
/** Stable keychain reference; the reference itself is safe to persist in JSON. */
export function contextCredentialRef(name) {
return `${KEYCHAIN_SERVICE}:context:${encodeURIComponent(String(name))}`;
}
/** Expose a non-secret capability status for diagnostics and tests. */
export function getContextKeychainStatus() {
return {
available: Boolean(keychainBackend && keychainOperational),
disabled: KEYCHAIN_DISABLED,
fallback: !keychainBackend || !keychainOperational,
};
}
/**
* Store context credentials through keytar and write only a credentialRef to
* config.json. If keytar cannot be used, preserve the credential in the
* mode-0600 file and emit one explicit warning instead of breaking headless
* installs.
*/
export async function saveContextsSecure(cfg) {
const source = cfg && typeof cfg === "object" ? cfg : defaultConfig();
const next = JSON.parse(JSON.stringify(source));
next.version = next.version || CONFIG_VERSION;
if (!next.contexts && next.profiles) {
next.contexts = next.profiles;
delete next.profiles;
}
next.contexts = next.contexts || {};
for (const [name, raw] of Object.entries(next.contexts)) {
const context = raw && typeof raw === "object" ? raw : {};
const accessToken = typeof context.accessToken === "string" ? context.accessToken : "";
const apiKey = typeof context.apiKey === "string" ? context.apiKey : "";
const hasCredential = Boolean(accessToken || apiKey);
if (hasCredential && keychainBackend && keychainOperational) {
const ref =
typeof context.credentialRef === "string" && context.credentialRef
? context.credentialRef
: contextCredentialRef(name);
try {
await keychainBackend.setPassword(
KEYCHAIN_SERVICE,
ref,
JSON.stringify({
...(accessToken ? { accessToken } : {}),
...(apiKey ? { apiKey } : {}),
})
);
credentialCache.set(ref, {
...(accessToken ? { accessToken } : {}),
...(apiKey ? { apiKey } : {}),
});
context.credentialRef = ref;
delete context.accessToken;
delete context.apiKey;
} catch {
keychainOperational = false;
warnPlaintextFallback();
}
} else if (hasCredential) {
warnPlaintextFallback();
}
next.contexts[name] = context;
}
saveContexts(next);
return {
usedKeychain: Boolean(keychainBackend && keychainOperational),
config: next,
};
}
/** Remove the keychain entry associated with a context, if one exists. */
export async function deleteContextCredential(name, context) {
const cfg = loadContexts();
const candidate = context || cfg.contexts?.[name] || cfg.profiles?.[name] || {};
const ref = candidate.credentialRef || contextCredentialRef(name);
credentialCache.delete(ref);
if (!keychainBackend || !keychainOperational) return false;
try {
await keychainBackend.deletePassword(KEYCHAIN_SERVICE, ref);
return true;
} catch {
keychainOperational = false;
return false;
}
}
/** Explicitly migrate legacy plaintext context credentials. */
export async function migrateContextCredentials() {
const cfg = loadContexts();
const pending = Object.values(cfg.contexts || cfg.profiles || {}).some(
(context) => context?.accessToken || context?.apiKey
);
if (!pending) return { migrated: false, pending: false, ...getContextKeychainStatus() };
const result = await saveContextsSecure(cfg);
return { migrated: result.usedKeychain, pending: true, ...getContextKeychainStatus() };
}
/** Test-only backend injection; no secret is returned by this function. */
export async function setContextKeychainBackendForTests(backend) {
keychainBackend = isKeychainBackend(backend) ? backend : null;
keychainOperational = true;
credentialCache.clear();
await hydrateCredentialCache(readConfigFile());
}
/**
* Resolve the active context for a CLI invocation.
*
@@ -54,7 +265,13 @@ export function resolveActiveContext(overrideName) {
const contexts = cfg.contexts || cfg.profiles || {};
const name = overrideName || cfg.currentContext || cfg.activeProfile || "default";
const found = contexts[name] || contexts.default;
if (found) return found;
if (found) return applyCachedCredential(found);
if (cfg.baseUrl) return { baseUrl: cfg.baseUrl };
return { baseUrl: `http://localhost:${process.env.PORT || "20128"}` };
}
/** Async variant for callers that need to observe a just-created keychain entry. */
export async function resolveActiveContextAsync(overrideName) {
await hydrateCredentialCache(readConfigFile());
return resolveActiveContext(overrideName);
}

View File

@@ -1287,6 +1287,9 @@
"notRunning": "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.",
"notFound": "The 'claude' CLI was not found in PATH."
},
"run": {
"description": "Launch a supported CLI target through OmniRoute"
},
"setupClaude": {
"description": "Generate ~/.claude/profiles Claude Code profiles from the OmniRoute model catalog"
},
@@ -1297,7 +1300,7 @@
"description": "Manage scoped CLI access tokens (remote mode)"
},
"configure": {
"description": "Pick a provider+model from the active server and write a local CLI config"
"description": "Pick a provider+model from the active server and configure a supported local CLI"
},
"launchCodex": {
"description": "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"

View File

@@ -1284,6 +1284,9 @@
"notRunning": "OmniRoute não está acessível em {port}. Inicie com 'omniroute serve'.",
"notFound": "O CLI 'claude' não foi encontrado no PATH."
},
"run": {
"description": "Inicia um alvo de CLI compatível pelo OmniRoute"
},
"setupClaude": {
"description": "Gera profiles do Claude Code em ~/.claude/profiles a partir do catálogo de modelos do OmniRoute"
},
@@ -1294,7 +1297,7 @@
"description": "Gerencia tokens de acesso CLI com escopo (modo remoto)"
},
"configure": {
"description": "Escolhe um provedor+modelo do servidor ativo e grava uma configuração de CLI local"
"description": "Escolhe um provedor+modelo do servidor ativo e configura uma CLI local compatível"
},
"launchCodex": {
"description": "Inicia o Codex CLI apontando para o OmniRoute (local ou VPS remoto)"

View File

@@ -0,0 +1,109 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
import { join, dirname } from "node:path";
import { resolveDataDir } from "./data-dir.mjs";
const PREFERENCES_VERSION = 1;
const MAX_RECENT = 12;
const MAX_FAVORITES = 32;
export function modelPreferencesPath() {
return join(resolveDataDir(), "model-preferences.json");
}
function defaultPreferences() {
return { version: PREFERENCES_VERSION, targets: {}, contexts: {} };
}
export function loadModelPreferences() {
try {
const path = modelPreferencesPath();
if (!existsSync(path)) return defaultPreferences();
const parsed = JSON.parse(readFileSync(path, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return defaultPreferences();
}
return {
version: PREFERENCES_VERSION,
targets: parsed.targets && typeof parsed.targets === "object" ? parsed.targets : {},
contexts: parsed.contexts && typeof parsed.contexts === "object" ? parsed.contexts : {},
};
} catch {
return defaultPreferences();
}
}
function saveModelPreferences(preferences) {
const path = modelPreferencesPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(preferences, null, 2));
try {
chmodSync(path, 0o600);
} catch {
// Best effort on platforms without POSIX modes.
}
}
function normalizeIds(values) {
return [...new Set((Array.isArray(values) ? values : []).filter((id) => typeof id === "string"))];
}
function targetState(preferences, target, contextKey) {
const raw = contextKey
? preferences.contexts?.[contextKey]?.[target] ||
(contextKey === "default" ? preferences.targets?.[target] : undefined)
: preferences.targets?.[target];
return {
favorites: normalizeIds(raw?.favorites),
recent: normalizeIds(raw?.recent),
};
}
function writeTargetState(preferences, target, contextKey) {
if (!contextKey) {
preferences.targets[target] = targetState(preferences, target);
return preferences.targets[target];
}
preferences.contexts = preferences.contexts || {};
preferences.contexts[contextKey] = preferences.contexts[contextKey] || {};
preferences.contexts[contextKey][target] = targetState(preferences, target, contextKey);
return preferences.contexts[contextKey][target];
}
/** Rank catalog IDs with favorites first, then recent choices, then catalog order. */
export function rankPreferredModels(
target,
modelIds,
preferences = loadModelPreferences(),
contextKey = ""
) {
const ids = normalizeIds(modelIds);
const state = targetState(preferences, target, contextKey);
const available = new Set(ids);
const preferred = [...state.favorites, ...state.recent].filter((id) => available.has(id));
return [...new Set([...preferred, ...ids])];
}
/** Record a successful selection without storing server URLs or credentials. */
export function recordModelPreference(target, modelId, options = {}) {
if (!target || !modelId) return loadModelPreferences();
const preferences = loadModelPreferences();
const state = writeTargetState(preferences, target, options.context || "");
state.recent = [modelId, ...state.recent.filter((id) => id !== modelId)].slice(0, MAX_RECENT);
if (options.favorite) {
state.favorites = [modelId, ...state.favorites.filter((id) => id !== modelId)].slice(
0,
MAX_FAVORITES
);
}
if (options.unfavorite) state.favorites = state.favorites.filter((id) => id !== modelId);
saveModelPreferences(preferences);
return preferences;
}
export function getModelPreferenceState(
target,
preferences = loadModelPreferences(),
contextKey = ""
) {
return targetState(preferences, target, contextKey);
}

View File

@@ -1,22 +1,39 @@
import crypto from "node:crypto";
const SALT = "omniroute-cli-auth-v1";
const BUILTIN_DEFAULT_SALT = "omniroute-cli-auth-v1";
export const CLI_TOKEN_HEADER = "x-omniroute-cli-token";
let _cached = null;
let _cachedSalt = null;
/** Mirrors getActiveSalt() in src/lib/machineToken.ts so a rotated
* OMNIROUTE_CLI_SALT reaches the CLI too (docs/security/CLI_TOKEN.md). */
function getActiveSalt() {
return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT;
}
export async function getCliToken() {
if (_cached !== null) return _cached;
const salt = getActiveSalt();
if (_cached !== null && _cachedSalt === salt) return _cached;
try {
const { machineIdSync } = await import("node-machine-id");
const mid = machineIdSync();
_cached = crypto
.createHash("sha256")
.update(mid + SALT)
.digest("hex")
.substring(0, 32);
} catch {
// node-machine-id is CommonJS: under `await import()` its exports land on
// `.default`, so destructuring `machineIdSync` off the namespace yields
// undefined and calling it throws — which the catch below turned into an
// empty token, silently disabling CLI auth for every management request.
// Same resolution order as src/lib/machineToken.ts.
const mod = await import("node-machine-id");
const machineIdSync = mod.machineIdSync ?? mod.default?.machineIdSync;
if (typeof machineIdSync !== "function") throw new Error("machine-id API unavailable");
// machineIdSync(true) returns the original unhashed hardware ID — mirrors
// getMachineTokenSync() in src/lib/machineToken.ts (#10148 cliToken hardening).
const mid = machineIdSync(true);
_cached = crypto.createHmac("sha256", mid).update(salt).digest("hex");
} catch (e) {
// Swallowing here changes control flow (every management call goes out
// unauthenticated and 401s), so leave a breadcrumb rather than failing mute.
console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled:", e);
_cached = "";
}
_cachedSalt = salt;
return _cached;
}

View File

@@ -0,0 +1,2 @@
- **feat(admission):** add lane-aware admission probes for combo/fusion/chaos fan-out (fail-open, queueing disabled), an env-wins `OMNIROUTE_CHAT_VIRTUAL_LANES` activation flag applied at boot, and adaptive-lane visibility in the `omniroute_get_health` MCP tool (related to #9654)
- **docs(mcp):** complete the MCP server README tool reference so the `schemas/` catalog is fully covered (agent-skills, oneproxy, web, tool-search, combo/routing, pricing and DB-health tools were previously only discoverable via `omniroute_tool_search`)

View File

@@ -0,0 +1 @@
- feat(providers): add **Cloudflare AI Playground** as a No Auth provider (`cloudflare-playground`, alias `cfp`) — free anonymous chat over the reverse-engineered `cf_agent` WebSocket protocol (PartySocket transport, no account/API key/cookies) with GLM 5.2, Kimi K2.7 Code, DeepSeek V4 Pro, gpt-oss-120B, Llama 3.3 70B, Qwen2.5 Coder 32B and 14 more curated models. The executor drives a headless Chromium via Playwright (the WS upgrade is TLS-fingerprint-gated), translates the `cf_agent` frame stream into OpenAI SSE, and surfaces upstream rate limits (3021) as HTTP 429. Fixes #10389

View File

@@ -0,0 +1,2 @@
- **feat(providers):** AI Horde accepts an optional registered API key and advertises only live image models that currently have workers ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542))
- **fix(providers):** AI Horde Check validates keys via `/v2/find_user` instead of the unauthenticated OpenAI models list ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542))

View File

@@ -0,0 +1 @@
- **feat(providers):** complete Jina AI as one credential pool — dashboard `jina-ai` / `jina-reader` share a token, `JINA_AI_API_KEY` is a real fallback, Test probes `GET https://api.jina.ai/v1/models` (embeddings fallback hits `jina-embeddings-v5-omni-small`), embed/rerank logs keep `connection_id`, catalog adds `jina-reranker-v3.5`, Omni v5 multimodal `{text}`/`{image}`/`{content}` docs pass through intact, and OmniRoute proxies classify / segment / `jina-search` (`s.jina.ai`). Reader stays a separate `r.jina.ai` card with an explicit label. Gemini Embedding 2 (`gemini/gemini-embedding-2`, alias `google/gemini-embedding-2`) uses dashboard `gemini` keys (or `GEMINI_API_KEY` / `GOOGLE_API_KEY` only when none exist), forwards native multimodal parts, and maps N OpenAI `input` items to N `:batchEmbedContents` vectors instead of one aggregated `:embedContent`. ([#10581](https://github.com/diegosouzapw/OmniRoute/pull/10581))

View File

@@ -0,0 +1 @@
- **feat(settings):** add `autoDisableBannedScope` so permanent-ban auto-disable can target subscription/OAuth accounts only, leaving prepaid API keys in the routing pool ([#10617](https://github.com/diegosouzapw/OmniRoute/pull/10617))

View File

@@ -0,0 +1 @@
- **feat(api):** add `GET`/`POST` `/v1/multimodal-embeddings` as an alias of `/v1/embeddings` so Jina-compatible clients do not receive HTTP 404 `unknown_route` — thanks @RaviTharuma

View File

@@ -0,0 +1 @@
- **feat(routing):** add client-, provider-, and model-neutral exclusive managed session connection leases with API-key-bound generation fencing, durable SQLite ownership, explicit allowlist policy, and bounded 429 capacity retry semantics.

View File

@@ -0,0 +1 @@
- **Passthrough streaming:** stop leaking upstream SSE control lines (`id:`/`event:`/`retry:`/`:` comments) to plain OpenAI Chat-Completions-format clients, while preserving `event:` framing for OpenAI Responses API and Claude Messages API passthrough ([#10017](https://github.com/diegosouzapw/OmniRoute/issues/10017)).

View File

@@ -0,0 +1,2 @@
- Fix: wire AgentRouter's existing console balance fetcher into the Dashboard Quota UI (visibility gate + provider-limits data path + background sync) so its wallet balance renders instead of falling back to "Usage API not implemented" (#10078)
- Fix: AgentRouter's dollar balance now renders as a currency-formatted "$X.XX" credits row in the Dashboard Quota UI instead of a bare percentage, and an exhausted wallet always shows exactly $0.00 (#10078)

View File

@@ -0,0 +1 @@
- fix(sse): bridge generic openai-compatible/anthropic-compatible provider type ids to their concrete uuid node id in credential lookup (#10085)

View File

@@ -0,0 +1 @@
- fix(antigravity): strip trailing model turn for native Gemini requests too, not just Claude (#10104)

View File

@@ -0,0 +1 @@
- **fix(admission):** stop the adaptive latency-gradient collapse from permanently locking out ordinary requests — individually valid requests now make solo progress when the system is idle and normal pressure, and the collapsed limit actively recovers on sustained idle windows instead of being stuck; the critical-pressure fuse still wins over solo progress (#10111)

View File

@@ -0,0 +1 @@
- fix(sse): downgrade client-supplied `thinking:{type:"adaptive"}` to `enabled` and gate the `context-1m-2025-08-07` beta on model eligibility when a combo/fallback re-routes a request to a non-adaptive/non-1M model like claude-haiku-4-5 (avoids "adaptive thinking is not supported on this model" and "long context beta is not yet available" 400s, #10119)

View File

@@ -0,0 +1 @@
- **fix(logging):** move call-log artifact serialization and filesystem writes to a bounded singleton worker to keep request handling responsive (#10123)

View File

@@ -0,0 +1 @@
- fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs (local-first, cloud-metadata still blocked) (#10158)

View File

@@ -0,0 +1 @@
- fix(cli): guarantee a non-empty `[STARTUP] Fatal:` log line for any instrumentation-hook boot throw, not just DB-driver init failures (#10171)

View File

@@ -0,0 +1 @@
- fix(sse): gate structural chat admission shedding on real heap pressure instead of unconditional capacity, with a bounded headroom budget so a healthy heap can no longer bypass admission control indefinitely (#10183, #10268)

View File

@@ -0,0 +1 @@
- **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225))

View File

@@ -0,0 +1 @@
- **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244)

View File

@@ -0,0 +1 @@
- fix(open-sse): stop concurrent requests colliding on the same dedup hash for non-OpenAI target formats (#10249)

View File

@@ -0,0 +1 @@
- **fix(translator):** Text-format tool calls emitted inline by some models are now converted to proper `tool_use` blocks. Certain models (DeepSeek, Qwen) return tool invocations as `<tool_call>{"name":"Bash","arguments":{…}}</tool_call>` or `TOOL_CALL Read: {"file_path":"…"}` inside the text stream instead of the structured `tool_calls` field. Both formats leaked through the Claude translators as plain text, so Claude Code rendered the raw block and stalled instead of executing the tool. `extractXmlInvokeBlocks` (previously `<invoke>`-only) now scans for all three shapes in a single pass and emits `content_block_start`/`input_json_delta`/`content_block_stop` events, in both `openai-to-claude` and `gemini-to-claude` (Antigravity) paths ([#10251](https://github.com/diegosouzapw/OmniRoute/pull/10251))

View File

@@ -0,0 +1 @@
- fix(dashboard): make provider card warning indicators expose the interaction they advertise (#10261)

View File

@@ -0,0 +1 @@
- **fix(ops):** Docker HEALTHCHECK defaults to the lightweight `/healthz` lifecycle probe instead of the heavy `/api/monitoring/health` path, with an `OMNIROUTE_HEALTHCHECK_PATH` opt-in override ([#10311](https://github.com/diegosouzapw/OmniRoute/pull/10311))

View File

@@ -0,0 +1 @@
- fix(resilience): keep combo quality and auth failure reasons separate and redact connection labels in terminal errors (#10314)

View File

@@ -0,0 +1 @@
- fix(dashboard): send periodic WS heartbeat pings so live dashboard connections stop dropping every ~35s (#10319)

View File

@@ -0,0 +1 @@
- fix(providers): GitLab Duo falls back to the public Code Suggestions endpoint when direct_access returns 401 (#10365)

View File

@@ -0,0 +1 @@
- **fix(translator):** Consolidate tool-name casing normalization into a single `restoreClaudeToolName` helper reused across every response path (`openai-to-claude`, `gemini-to-claude`, `stream` passthrough, xAI and Antigravity handlers), replacing six hand-copied 7-entry casing maps. The shared helper resolves via the request-side `toolNameMap` first (preserving declared PascalCase and MCP/alias names), then the complete `TOOL_RENAME_MAP` (which already covers `glob`/`grep`/`task`/`todowrite`/`skill`/`askuserquestion`/etc.), then the `#7926` TitleCase→lowercase fallback for map-less clients. This closes the coverage gap that left `TodoWrite` and other tools failing with `Error: No such tool available: todowrite`, fixes a `ReferenceError` in `remapToolNamesInResponse`, and preserves the Gemini thought-signature persistence (`#8979`) and OpenAI→Claude `toolNameMap` restoration that must not regress ([#10374](https://github.com/diegosouzapw/OmniRoute/issues/10374))

View File

@@ -0,0 +1 @@
- **fix(responses):** preserve native tool definitions for custom OpenAI-compatible providers when using the Responses API (`/v1/responses`). When `apiType` is set to `"responses"` (or `_omnirouteForceResponsesUpstream` is enabled), OmniRoute passes native tool shapes (`custom` with lark grammars, `namespace`, `local_shell`) directly upstream without running a lossy Responses→Chat→Responses conversion ([#10374](https://github.com/diegosouzapw/OmniRoute/issues/10374))

View File

@@ -0,0 +1 @@
- fix(dashboard): Free Tier 'used this month' now includes live usage_history rows, not just the rolled-up daily summary (#10381)

View File

@@ -0,0 +1,2 @@
- **fix(memory):** auto-check Qdrant health on mount and stop the false-red status badge on `/dashboard/memory?tab=engine` — the badge treated "not yet checked" (`health === null`) as a failure, so a healthy Qdrant showed red after every page refresh until "Test connection" was clicked; settings changes now also invalidate the stale result and re-check after the save persists, so a health check racing the settings PUT can no longer keep the badge red until a manual re-test ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489))
- **test(compression):** align source-contract tests with the merged `release/v3.8.50` base (`aa912c42a`) — accept the multi-line `providerTransport` shape in `omniglyph-chatcore-plumbing` and give the pipeline-circuit-breaker fixture a `metadata.executionStages` (both structural changes landed in the base merge) ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489))

View File

@@ -0,0 +1 @@
- **fix(providers):** zed-hosted OAuth now redirects the browser back to the dashboard's own loopback port (auto-completing the login), and the manual paste path accepts Zed's user_id/access_token callback URL instead of erroring with "No authorization code found" ([#10517](https://github.com/diegosouzapw/OmniRoute/pull/10517)) - thanks @phatchau036

View File

@@ -0,0 +1 @@
- **fix(providers):** test token-backed web sessions through their provider validator instead of the OAuth path ([#10519](https://github.com/diegosouzapw/OmniRoute/pull/10519)) — thanks @Zartharas

View File

@@ -0,0 +1 @@
- **fix(compliance):** redact additional provider API keys from audit-log payloads ([#10521](https://github.com/diegosouzapw/OmniRoute/pull/10521)) — thanks @Zartharas

View File

@@ -0,0 +1 @@
- **fix(models):** align Codex GPT-5.6 context limits with the Codex catalog and honor model context overrides when advertising combos ([#10530](https://github.com/diegosouzapw/OmniRoute/issues/10530))

View File

@@ -0,0 +1 @@
- **fix(deepseek):** Advertise `none`, `low`, `high`, and `max` for V4 Pro and Flash, derive OpenCode Go effort aliases from base-model metadata, and route those models through native Responses ([#10540](https://github.com/diegosouzapw/OmniRoute/pull/10540)) — thanks @jackjinke

View File

@@ -0,0 +1 @@
- **fix(a2a):** use a constant-time bearer compare in `/api/a2a/tasks` via `crypto.timingSafeEqual`, matching the `tokensMatch` helper already used in `src/app/a2a/route.ts` and removing the last non-constant secret comparison in the repo ([#10544](https://github.com/diegosouzapw/OmniRoute/pull/10544))

View File

@@ -0,0 +1 @@
- **fix(providers):** OpenCode `x-opencode-session` now derives a stable, conversation-scoped fingerprint via `generateSessionId()` instead of a fresh random UUID per request, so upstream prompt caching can hit across requests in the same conversation; bare `big-pickle`/`*-free` model ids now keep routing to an active opencode-family connection even when its synced catalog is temporarily stale; and bare requests to no-auth catalog providers (e.g. `opencode`) now echo the listing-valid `<alias>/<model>` form in `response.model` so clients validating against `/v1/models` don't warn ([#10571](https://github.com/diegosouzapw/OmniRoute/pull/10571))

View File

@@ -0,0 +1 @@
- **fix(mcp):** make GitHub skill tools discoverable through `omniroute_tool_search`

View File

@@ -0,0 +1 @@
- **fix(audio):** when a prefix-matched STT provider has no credentials, retry gateways that list the same nested model id (e.g. `deepgram/nova-3``openrouter/deepgram/nova-3`) and mention those ids in the 400; stop documenting bare `deepgram/nova-3` as the default example ([#10583](https://github.com/diegosouzapw/OmniRoute/issues/10583))

View File

@@ -0,0 +1 @@
- **fix(xai):** trim Chat Completions `messages` and Responses `input` to xAI's 800-item history cap before dispatch, so long tool loops no longer die on `413 Chat history exceeds the 800-message limit` ([#10601](https://github.com/diegosouzapw/OmniRoute/pull/10601))

View File

@@ -0,0 +1 @@
- **fix(cli):** derive the machine-id token correctly under plain Node — `await import("node-machine-id")` puts the CJS exports on `.default`, so the destructured `machineIdSync` was `undefined` and the catch blanked the token, sending every management request unauthenticated; `OMNIROUTE_CLI_SALT` rotation is now honored too ([#10612](https://github.com/diegosouzapw/OmniRoute/pull/10612))

View File

@@ -0,0 +1 @@
- **fix(cli):** `omniroute setup --add-provider --api-key <key>` no longer aborts with "Provider API key is required" — Commander bound the value to the program-level `--api-key` (the OmniRoute server key), leaving the subcommand's own option undefined; `OMNIROUTE_API_KEY` now works as the error message advertised ([#10613](https://github.com/diegosouzapw/OmniRoute/pull/10613))

View File

@@ -0,0 +1 @@
- **fix(auto):** rate-limit `auto/<family> matched no connected models` warnings to once per minute per label (`open-sse/services/autoCombo/virtualFactory.ts`)

View File

@@ -0,0 +1 @@
- **fix(providers):** register live OpenRouter Gemini Embedding 2 ids (`google/gemini-embedding-2` and `google/gemini-embedding-2-preview`, 3072-d) in the curated embeddings catalog so `GET /v1/models` and `GET /v1/embeddings` list the ids that already serve — thanks @RaviTharuma

View File

@@ -0,0 +1 @@
- **fix(api):** `/v1/embeddings` 400s for native `gemini-embedding-2` now name the working OpenRouter ids (`openrouter/google/gemini-embedding-2` and the preview alias) instead of only `No credentials for embedding provider: gemini` — thanks @RaviTharuma

View File

@@ -0,0 +1 @@
- **docs:** add an embeddings client runbook with live-verified working/broken model ids and Hindsight 0.9.1 / Memorix 1.6.0 notes — thanks @RaviTharuma

View File

@@ -1491,11 +1491,6 @@
"count": 1
}
},
"tests/integration/mimocode-proxy.integration.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 13
}
},
"tests/integration/obsidian-plugin-e2e.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 12
@@ -2559,11 +2554,6 @@
"count": 2
}
},
"tests/unit/mimocode-executor.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 58
}
},
"tests/unit/minimax-tts-1043.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 6

View File

@@ -1,4 +1,5 @@
{
"_rebaseline_2026_08_18_10517_zed_hosted_oauth_callback_port": "PR #10517 (phatchau036, fix/zed-hosted-oauth-callback-port) own growth: src/shared/components/OAuthModal.tsx 1131->1148 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 1134->1149, +15/+18, crosses the frozen 1134 cap). Wires the zed-hosted native-app callback auto-complete: forceManual gating on isTrueLocalhost for zed-hosted, the loopback-redirect-URI comment block, and the exchangeToken full-URL-as-code branch, all at the existing provider-switch chokepoints this modal already carries growth for (seventh bump: 969->989->993->998->1030->1056->1100->1149; structural shrink tracked in #3501). The actual port-derivation logic lives in src/lib/oauth/providers/zed-hosted.ts (not frozen here) and was hardened during pre-merge review to use the server's own getRuntimePorts() instead of a browser-guessed scheme/port, covered by the new tests/unit/zed-hosted-loopback-port-derivation.test.ts (8/8 passing).",
"_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).",
"_rebaseline_2026_08_09_8984_api_key_cache_mode": "PR #8984 own growth during the 2026-08-09 rebase: src/lib/db/apiKeys.ts 1529->1545 (+16 = the per-key apiKeys.cacheDefaultMode column + its row parsers and cascade wiring; additive at the existing connection write/read chokepoints). Covered by tests/unit/chatcore-semantic-cache.test.ts. (chatCore.ts stays at the pre-existing base-red ceiling — upstream tip already exceeds the frozen 5042, this PR only adds +2 on top; not re-bumped per the no-inherit-ratchet rule.)",
"_rebaseline_2026_08_09_9207_breaker_halfopen_recovery": "PR #9207 own growth during the 2026-08-09 rebase: open-sse/services/accountFallback.ts 1978->2020 (+42 = recordProviderSuccess now also transitions the provider circuit breaker from HALF_OPEN to CLOSED when a request succeeds, so the breaker is not stuck half-open after repeated failures; the transition and its reset wiring grow the existing provider-success path, not extractable). Covered by tests/unit/provider-breaker-halfopen-recovery.test.ts.",
@@ -378,7 +379,6 @@
"open-sse/mcp-server/tools/advancedTools.ts": 1456,
"open-sse/services/accountFallback.ts": 2571,
"open-sse/services/adobeFireflyBrowserLogin.ts": 1771,
"open-sse/services/adobeFireflyChromeRuntime.ts": 1561,
"open-sse/services/adobeFireflyClient.ts": 3899,
"open-sse/services/adobeFireflySession.ts": 1304,
"open-sse/services/claudeCodeCompatible.ts": 1563,
@@ -446,7 +446,8 @@
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387,
"_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).",
"src/lib/modelCapabilities.ts": 1006,
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014,
"open-sse/config/imageRegistry.ts": 1019
},
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
@@ -582,7 +583,7 @@
"src/lib/memory/retrieval.ts": "1073",
"src/lib/tailscaleTunnel.ts": "1202",
"src/lib/usage/providerLimits.ts": "1013",
"src/shared/components/OAuthModal.tsx": "1134",
"src/shared/components/OAuthModal.tsx": "1146",
"src/shared/components/RequestLoggerV2.tsx": "1629",
"src/shared/components/analytics/charts.tsx": "1035",
"src/shared/services/cliRuntime.ts": "1122",
@@ -610,5 +611,6 @@
"_rebaseline_2026_08_12_v3850_basereds_round3": "Base-reds round 3 (#9985, 2026-08-12): ModelSelectModal.tsx 1135->1138 = base drift from the #10198 SWR/build repair (flagged as non-blocking drift by Release-Green run 31634993212, rebaselined here so the PR queue's Fast Quality Gates stop failing on inherited drift); gateways.ts 1215->1250 = base drift from the 08-12 merges (#10131 regolo/naga-ac repair, #9210 void-ai+helixmind) plus this PR restoring the chatanywhere metadata entry that round 2 dropped along with its duplicate (wave3 audited entry, +16 lines; same god-file no-split rationale as the 2026-08-11 annotation). Owner-authorized sweep (/sweep-reds).",
"_rebaseline_2026_08_12_proxyfetch_redaction": "Base-reds round 3 (#9985): proxyFetch.ts 1220->1239 (+19) = redactProxyDetailsInMessage() helper closing the credential leak #10032 reintroduced (raw proxy URL with user:password appended to the propagated error, Hard Rule #12); irreducible security fix at the existing error-surface chokepoint. Covered by tests/unit/tls-proxy-context.test.ts (strengthened leak guards).",
"_rebaseline_2026_08_12_modelcapabilities_snapshot_routing": "Base-reds round 3 (#9985): modelCapabilities.ts crossed the new-file cap at 1006 (+~10) when the context/max-input-token override lookups were routed through the #9199 bulk snapshot (fixing 323 per-model SQLite reads per catalog prepare — auto-combo-context-advertising guard); cohesive change at the existing resolution chokepoints, not extractable. Covered by tests/unit/auto-combo-context-advertising.test.ts + model-capability-resolution-snapshot-9199.test.ts.",
"_rebaseline_2026_08_14_imagetotext_servicekinds": "Image-to-Text category (#10275/#10291): gateways.ts grew 1250→1255 by data lines only — the serviceKinds: [\"llm\", \"imageToText\"] declarations on the openrouter and chutes catalog entries, plus the 3-line comment recording why chutes needs no static dots.ocr entry (passthroughModels discovery). No new logic or branching; the file is a provider catalog of declarative metadata. Splitting a catalog for five lines would be worse than the growth (semantic-families rule)."
"_rebaseline_2026_08_14_imagetotext_servicekinds": "Image-to-Text category (#10275/#10291): gateways.ts grew 1250→1255 by data lines only — the serviceKinds: [\"llm\", \"imageToText\"] declarations on the openrouter and chutes catalog entries, plus the 3-line comment recording why chutes needs no static dots.ocr entry (passthroughModels discovery). No new logic or branching; the file is a provider catalog of declarative metadata. Splitting a catalog for five lines would be worse than the growth (semantic-families rule).",
"_rebaseline_2026_08_18_imageregistry_merge_train": "merge-train 2026-08-18 (owner-authorized, /merge-prs batch of 84): open-sse/config/imageRegistry.ts crossed the 1000-line new-file cap for the first time purely from combining three independent, already-legitimate provider registrations boarded in the same local merge-train — #10542 (aihorde optional-key image catalog), #10494 (gemini-web image generation), #10594 (freepik/magnific provider rename + validation). 996 on release tip -> 1019 on the train tip. Each PR individually adds a small, additive IMAGE_PROVIDERS registry entry at the existing chokepoint; none crosses the cap alone. Not modularized as part of this train's gate fix (out of scope for a merge reconciliation, not a feature change). Covered by each PR's own focused tests (aihorde-image-catalog/generation, gemini-web image tests, freepik/magnific provider tests)."
}

View File

@@ -1,176 +1,33 @@
{
"open-sse/executors/azure-openai.ts": {
"TS2345": 1
},
"open-sse/executors/chatgpt-web.ts": {
"TS2339": 1
},
"open-sse/executors/claude-web/stream.ts": {
"TS2322": 1,
"TS2345": 1
},
"open-sse/executors/copilot-web.ts": {
"TS2353": 1
},
"open-sse/executors/deepseek-web.ts": {
"TS2352": 1
},
"open-sse/executors/default.ts": {
"TS2352": 1
},
"open-sse/executors/duckduckgo-web.ts": {
"TS2345": 2
},
"open-sse/executors/duckduckgo-web/challenge.ts": {
"TS2304": 1
},
"open-sse/executors/edgeTts.ts": {
"TS2345": 1
},
"open-sse/executors/gemini-business.ts": {
"TS2339": 1
},
"open-sse/executors/ghe-copilot.ts": {
"TS2554": 1
},
"open-sse/executors/inner-ai.ts": {
"TS2352": 2
},
"open-sse/executors/theoldllm.ts": {
"TS2322": 1
},
"open-sse/executors/veoaifree-web.ts": {
"TS2322": 1
},
"open-sse/executors/windsurf.ts": {
"TS2322": 1
},
"open-sse/handlers/chatCore.ts": {
"TS2339": 30,
"TS2322": 1,
"TS2345": 11
},
"open-sse/handlers/chatCore/claudeUpstreamMessages.ts": {
"TS2345": 1
},
"open-sse/handlers/chatCore/clientUsageBuffer.ts": {
"TS2345": 1
},
"open-sse/handlers/chatCore/clineResponseEnvelope.ts": {
"TS2698": 1
},
"open-sse/handlers/chatCore/compressionAnalyticsWrite.ts": {
"TS2724": 1
},
"open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts": {
"TS2322": 2
},
"open-sse/handlers/chatCore/sanitization.ts": {
"TS2339": 1,
"TS2537": 1
},
"open-sse/handlers/chatCore/semanticCacheStore.ts": {
"TS2345": 1
},
"open-sse/handlers/chatCore/streamingPipeline.ts": {
"TS2345": 2
},
"open-sse/handlers/chatCore/streamingSemanticCacheStore.ts": {
"TS2345": 1
},
"open-sse/handlers/chatCore/thinkingSignatureRecovery.ts": {
"TS2339": 2
},
"open-sse/handlers/imageGeneration.ts": {
"TS2554": 2
},
"open-sse/handlers/responsesHandler.ts": {
"TS2339": 1,
"TS2345": 1
},
"open-sse/handlers/sseParser.ts": {
"TS2322": 2
},
"open-sse/handlers/videoGeneration.ts": {
"TS2339": 2
},
"open-sse/mcp-server/tools/compressionTools.ts": {
"TS2339": 2
},
"open-sse/services/__tests__/specificityDetector.test.ts": {
"TS2353": 2
},
"open-sse/services/browserBackedChat.ts": {
"TS2322": 1,
"TS2794": 1
"TS2353": 2
},
"open-sse/services/claudeAdaptiveThinking.ts": {
"TS2352": 2
},
"open-sse/services/comboManifestMetrics.ts": {
"open-sse/services/compression/engines/omniglyphAdapter.ts": {
"TS2307": 1
},
"open-sse/services/compression/engines/ccr/index.ts": {
"open-sse/services/compression/stats.ts": {
"TS2307": 1
},
"open-sse/utils/cursorImages.ts": {
"TS2339": 1
},
"open-sse/services/payloadRules.ts": {
"TS2677": 1
},
"open-sse/services/tokenLimitCounter.ts": {
"TS2551": 1
},
"open-sse/transformer/responsesTransformer.ts": {
"open-sse/utils/imageNormalize.ts": {
"TS2339": 1
},
"open-sse/utils/stream.ts": {
"TS2339": 7,
"TS2345": 1,
"TS2556": 1
"TS2345": 2,
"TS2322": 2
},
"src/app/api/v1/_shared/mediaGenerationRoute.ts": {
"TS2339": 2
"open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/markdown.ts": {
"TS2307": 2
},
"src/app/api/v1/models/catalog.ts": {
"TS2345": 1
},
"src/app/api/v1/models/catalogVision.ts": {
"TS2322": 1
},
"src/app/api/v1/videos/generations/route.ts": {
"src/lib/guardrails/videoBridgeHelpers.ts": {
"TS2488": 1,
"TS2365": 2,
"TS2322": 1,
"TS2345": 1
},
"src/lib/guardrails/visionBridge.ts": {
"TS2345": 1
},
"src/lib/providers/codexFastTier.ts": {
"TS2367": 1
},
"src/lib/skills/builtins.ts": {
"TS2322": 1
},
"src/lib/skills/injection.ts": {
"TS2339": 1
},
"src/lib/skills/webFetchExecution.ts": {
"TS2322": 1
},
"src/lib/streamingPiiTransform.ts": {
"TS2345": 1
},
"src/shared/providers/webSessionCredentials.ts": {
"TS2353": 1,
"TS2322": 1
},
"src/shared/validation/helpers.ts": {
"TS2339": 1
},
"src/sse/handlers/chat.ts": {
"TS2352": 1,
"TS2322": 2,
"TS2339": 1
},
"src/sse/services/model.ts": {
"TS2339": 4
}
}

View File

@@ -179,7 +179,7 @@
"_rebaseline_2026_07_28_ci_runner_delta": "189 -> 190 (+1). Medido 189 no devbox e 190 no runner do GitHub no MESMO commit (run 30396592013, job Quality Gates (Extended)) — mesma classe já registrada em _rebaseline_2026_07_20_aliasresolver_hook_split_7808: a versão do zizmor no runner enxerga uma finding a mais que a local, sempre da classe unpinned-uses @vN. O valor do runner é o que o gate compara, então a baseline segue o runner."
},
"vulnCount": {
"value": 10,
"value": 22,
"direction": "down",
"dedicatedGate": true
},
@@ -396,5 +396,6 @@
"_zizmor_rebaseline_2026_06_19_a11y_148_reconcile": "RECONCILIACAO CROSS-PR (release-volatil) ao mergear #4321 (a11y) APOS #4322 (R1): zizmorFindings 145 -> 148. O #4322 ja rebaselinou 139->145 (drift base 142 + 3 unpinned-uses do mutation-redundancy.yml). Este PR adiciona +3 unpinned-uses @vN do novo job 'a11y' (nightly-resilience.yml): actions/checkout@v7, actions/setup-node@v6, actions/cache@v5.0.5 — MESMA convencao @vN deliberada e INTOCADA de todos os workflows (ver _scanner_harden_workflows_2026_06_16). Total = 142 base + 3 r1 + 3 a11y = 148, MEDIDO com `node scripts/check/check-workflows.mjs --ratchet` na arvore release(com #4322)+#4321 = 148 exato. Nenhum template-injection/artipacked/cache-poisoning novo.",
"_zizmor_rebaseline_2026_06_20_ci_build_artifact_reuse": "zizmorFindings 148 -> 152. Drift legitimo deste PR ao reutilizar o artefato next-build do job Build em package-artifact/electron-package-smoke e ao separar o build de compatibilidade Node 26: +4 unpinned-uses novos (2x actions/download-artifact@v8, actions/checkout@v7, actions/setup-node@v6). Mantida a convencao deliberada @vN dos workflows (sem SHA-pinning/manual update burden), conforme precedentes _scanner_harden_workflows_2026_06_16 e _zizmor_rebaseline_2026_06_19_*. Sem novos findings de template-injection/artipacked/cache-poisoning; medido localmente com zizmor 1.25.2 via `npm run check:workflows -- --ratchet` = 152.",
"_cognitive_rebaseline_2026_07_27_3850_relax_v2_20pct": "cognitiveComplexity 971->1223 (+252, +26.0% over pristine 971). OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). v1 was +48 on 2026-07-27; v2 = v1 +20% buffer = +58 → +252 total (cycle 971 measured pristine → 1223 ceiling). Justification: same as complexity v2 — the v3.8.50 release cut coincides with high-merge activity; owner accepted enlarging the headroom to cover the entire PREPARE phase (5 minor cycles .50-.54) without per-PR rebaseline noise, given that re-tightening is mechanical at v3.8.51 via the combo.ts/chatCore.ts decomposition work scheduled in .51/.52 (ROADMAP.md). RE-TIGHTENING MANDATORY in v3.8.51: target 1009 (shrink of 214 from structural extraction during the decomposition campaigns, or via npm run quality:ratchet -- --update if natural shrink appears earlier). The 1009 floor still gives 38 units of post-tighten headroom vs the current pristine 971. Tracked via same roadmap issue as complexity v2. Window: v3.8.50 (release cut) → v3.8.54 close (RE-TIGHTEN at v3.8.51 prep merge per ROADMAP.md). Last entry unless measured regression. v1 entry retained below for audit trail.",
"_cognitive_rebaseline_2026_07_27_3850_relax": "cognitiveComplexity 971->1019 (+48). OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). +48 covers Train 1D (+15) + headroom for 3.8.50/.51 batches. RE-TIGHTENING MANDATORY in v3.8.51: target 1009 (from combo.ts/chatCore.ts decomposition scheduled in .51/.52 per ROADMAP.md phases). Tracked via same roadmap issue as complexity. SUPERSEDED by _cognitive_rebaseline_2026_07_27_3850_relax_v2_20pct (v1 +20% buffer) — retained for audit. Last entry unless measured regression."
"_cognitive_rebaseline_2026_07_27_3850_relax": "cognitiveComplexity 971->1019 (+48). OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). +48 covers Train 1D (+15) + headroom for 3.8.50/.51 batches. RE-TIGHTENING MANDATORY in v3.8.51: target 1009 (from combo.ts/chatCore.ts decomposition scheduled in .51/.52 per ROADMAP.md phases). Tracked via same roadmap issue as complexity. SUPERSEDED by _cognitive_rebaseline_2026_07_27_3850_relax_v2_20pct (v1 +20% buffer) — retained for audit. Last entry unless measured regression.",
"_vuln_rebaseline_2026_08_04_9439_cve_drift": "vulnCount 10->22 (HIGH=10, MODERATE=12, measured by osv-scanner v2.3.8 in PR #9439's own CI run). This is CVE variance, not a dependency change made by this PR: `git diff upstream/release/v3.8.50 HEAD -- package.json package-lock.json` is empty — neither file was touched anywhere in this branch's history. The osv-scanner vulnerability ratchet apparently does not run on every commit landed directly to release/v3.8.50 (same 'fast-gate PR->release skips this check' pattern already documented for check:file-size, e.g. _rebaseline_2026_07_01_v3843_release_5609), so newly-disclosed CVEs in already-present transitive dependencies accumulated on the release branch and only surfaced here because this PR's rebase onto the current release/v3.8.50 tip pulled them in. This exact scenario — 'a newly-disclosed CVE in an already-present dep can trip the gate with no dependency change on your part' — is the documented expected behavior in _osv_flip_blocking_2026_06_16_v3827 above, whose prescribed remedy is 'bump the dep, or re-baseline vulnCount with justification+issue' (docs/security/SUPPLY_CHAIN.md -> 'Variância de CVE'). osv-scanner is not available in this sandbox to enumerate the exact GHSA/CVE ids and safely bump only the affected transitive deps without a broader, separately-scoped dependency-audit pass; re-baselining here unblocks this PR without masking anything introduced by it. Tracked for follow-up: a dedicated dependency-bump PR should re-tighten vulnCount back down once the specific advisories are enumerated locally with osv-scanner installed."
}

View File

@@ -0,0 +1,9 @@
# OmniRoute Allocation Handoff
Allocation is not provider quota.
Quota pools define which API keys may consume a provider pool and how hard, soft, or burst policies apply. Provider quota is external capacity reported by a provider or an explicitly configured source. Ghostlight internal budgets are governance limits defined by the administrator.
The `ensurePool` operation is idempotent: an identical pool is unchanged, a changed allocation is updated, and a missing pool is created. This is intended for automation and bounded API callers.
The read-only status endpoint is `GET /api/omniroute/status`. The verification command is `npm run omniroute:verify`; it makes no live model request.

View File

@@ -0,0 +1,9 @@
# OmniRoute Provider Failover
Failures are classified before retry decisions are made.
Transient failures such as timeouts, network errors, rate limits, and provider 5xx responses may fail over. Authentication errors, permission errors, invalid requests, unavailable models, and unknown failures are not retried blindly.
The default cross-provider policy allows up to three provider attempts, retries rate limits and timeouts, and keeps administrative disablement separate from temporary circuit state.
Circuit states are `closed`, `open`, and `half_open`. A cooldown schedules a bounded probe; a successful probe closes the circuit and a failed probe reopens it.

View File

@@ -0,0 +1,17 @@
# OmniRoute Quota Telemetry
OmniRoute separates provider quota telemetry from Ghostlight accounting.
## Truthful states
- `healthy` means a source reported usable remaining capacity.
- `approaching_limit` means a source reported remaining capacity at or below the configured threshold.
- `exhausted` is emitted only when a source reports zero capacity or usage at its limit.
- `unavailable` means a supported source failed to return data.
- `unknown` means no supported source exists or no provider limit is known.
Unknown is not exhausted and does not disable a provider.
Sources are preferred in this order: official provider API, authenticated usage API, explicitly mapped response headers, administrator configuration, local estimates, unknown. Local estimates are never presented as provider billing data.
Response headers are parsed only through an explicit provider mapping. Generic header names are not assumed globally.

View File

@@ -0,0 +1,11 @@
# OmniRoute Routing Policy
Routing preserves the existing capability and combo selection logic, then applies allocation, health, circuit, quota, latency, reliability, model preference, and cost preference factors.
The adaptive score is explainable and returns both the selected candidate and all ranked candidates. Exhausted quota, denied allocation, and open circuits are ineligible. Unknown quota remains eligible with a neutral quota factor.
Route preview is deterministic and performs zero upstream model requests:
`POST /api/omniroute/route/preview`
The response includes candidate scores, factors, reasons, the selected provider, and `liveRequestExecuted: false`.

View File

@@ -107,6 +107,40 @@ Before #7274, `resolveSessionAffinityTtlMs()` hard-bailed to `0` for every provi
The three session-affinity headers are never forwarded upstream — executors build their own upstream headers from scratch rather than passing client headers through, so this stays an internal correlation id only.
### Exclusive managed session connection leases
**Scope:** one active managed HTTP client/session owns one eligible OmniRoute connection.
**Purpose:** provide durable exclusive connection ownership for clients that need a hard routing
fence across requests. This differs from session affinity, which is a soft continuity preference:
an exclusive lease persists lifecycle state in SQLite, enforces global active-owner and
active-connection uniqueness, and rejects a stale generation before provider dispatch.
The feature is opt-in per API key. A managed key must have the `lease:exclusive` scope and an
explicit non-empty `allowedConnections` list. Any HTTP client can use the lifecycle endpoint; no
client name, user-agent, provider, OAuth method, or model is required. The lease owns a connection,
not a model, so a model change retains the binding while the connection remains ordinarily
eligible. Normal model, quota, health, cooldown, and allowlist rules remain authoritative and may
transition the same generation to another free eligible connection.
The lifecycle is `POST /api/v1/session-leases` with JSON actions `acquire`, `renew`, and `release`.
Managed inference requests present the opaque `X-OmniRoute-Lease-Owner` value and exact
`X-OmniRoute-Lease-Generation`. The owner uses `vlo_` followed by 43 base64url characters; only
its SHA-256 hash is stored. Every final dispatch fence also binds the authenticated API key ID and
active connection ID. Lease control headers are removed from logs, retained request snapshots, and
upstream executor headers.
If ordinary routing has eligible managed candidates but every free candidate is occupied by a
foreign active lease, OmniRoute returns HTTP `429`, lease-capacity-unavailable code, a
waiting-for-capacity state, and a bounded `Retry-After` derived from the earliest relevant expiry.
Ordinary empty eligibility is not lease contention and keeps its existing routing error semantics.
Related mechanisms remain separate:
- OAuth session occupancy is process-local soft distribution for OAuth accounts.
- Account semaphores grant request-concurrency permits and end when a request completes.
- Exclusive managed session leases are durable lifecycle ownership with a generation fence.
---
## 3. Model Lockout

View File

@@ -1,7 +1,7 @@
---
title: "Admission lanes — two lane systems, what gates each, where each reports"
status: active
lastUpdated: 2026-08-09
lastUpdated: 2026-08-10
---
# Admission lanes (#9654) — two lane systems, what gates each, where each reports
@@ -34,14 +34,57 @@ complementary; operators should know which one they are looking at.
- **Tuning:** `OMNIROUTE_CHAT_VIRTUAL_LANES` + adaptive config (`maxQueueCount`,
`maxQueueCost`, `defaultMaxWaitMs`, …).
- **Reports:** `GET /api/monitoring/health``adaptiveAdmission``laneCount`,
`laneQueuedCount`, `laneQueuedCost`, `laneTenants` (opaque lane IDs, never raw keys).
`laneQueuedCount`, `laneQueuedCost`, `laneTenants` (opaque lane IDs, never raw
keys), and `virtualLanes` — the authoritative "lanes are on" flag in the snapshot.
## 3. Fan-out probes — per-target admission for combo/fusion (#9654 Wave 2)
Combo (priority / round-robin) and fusion fan out N model targets under one parent
request. Since #9654 Wave 2, **each fan-out target is gated before dispatch** by a
per-target probe (`PerTargetAdmissionHook`, built by `createPerTargetAdmissionHook`)
against the **parent's** tenant lane.
- **Scope:** every fan-out target dispatched by combo, fusion, and the chaos engine.
System 1 (byte-level) is unaffected — it never probes fan-out targets.
- **Gate:** **opt-in with system 2.** A no-op when `OMNIROUTE_CHAT_VIRTUAL_LANES`
is unset — the parent request already holds the shared-queue lease in that mode,
so probing would double-count and reject combo targets.
- **Semantics:**
- **Strictly non-blocking — skip, never queue.** `maxWaitMs 0`: a full lane
skips the target and the combo's fallback machinery (or fusion's survivor
panel) serves instead. This is deliberate: a fan-out target is redundant
work, and queueing it piles more load onto the exact congestion lanes exist
to stop. `defaultMaxWaitMs` therefore applies to the **parent request only**;
fan-out probes never wait, and there is intentionally **no knob** to make
them wait (issue history shows wait knobs produced the mass-502/504 class
#9654 prevents — revisit only if an operator reports skipped fan-out targets
hurting response quality).
- **Release-on-admit.** An admitted probe releases its lease immediately: it is
a capacity gate, not a hold. The parent's lease covers the fan-out; holding N
more would inflate shared active cost and reject other tenants. Best-effort,
not a reservation: the lane can refill between probe and dispatch, so under
heavy contention the gate may admit into a lane that is full again by the
time the target dispatches.
- **Priced from the real fan-out body.** The probe estimates cost from the
target's actual body — including the request class derived from its `stream`
flag, exactly like the parent path — so fusion panel members (`stream: false`)
are priced at the non-streaming class they will truly occupy, and priority/RR
targets at whatever the user requested.
- **Reports:** a probe skip after the first target bumps combo's per-request
`fallbackCount` (mirroring the existing fallback semantics; visible in combo
logs); fusion returns 503 when every panel member is skipped. There is
**no aggregate counter** (e.g. `virtualFanoutSkipped`) on the snapshot today —
if an operator reports they cannot tell how often the lane gate skips fan-out
targets, that is the trigger to add one.
## Which one is showing in a dashboard
- `adaptiveAdmission.laneCount` / `laneTenants`**adaptive virtual lanes** (system 2).
- A health payload with **no** `adaptiveAdmission.lane*` fields usually means
`OMNIROUTE_CHAT_VIRTUAL_LANES` is unset — the byte-level lanes (system 1) are still
active, but nothing under `adaptiveAdmission` will report lane data until it is enabled.
- `adaptiveAdmission.virtualLanes === true` → the fan-out probes of section 3 are
also active. A payload with `virtualLanes` missing or `false` means
`OMNIROUTE_CHAT_VIRTUAL_LANES` is unset — the byte-level lanes (system 1) are
still active, but nothing under `adaptiveAdmission` (and no fan-out gating) is
in effect until it is enabled.
## Why both exist

View File

@@ -182,6 +182,22 @@ With Stacked: 10K-2.5K tokens sent (78-95% eligible RTK+Caveman range
---
## Output Styles
Output styles inject a system prompt instruction to steer the model's writing style. They are defined in the output style catalog and support multiple languages and intensity levels (`lite`, `full`, `ultra`).
| Style | Description | Supported Languages | Levels |
| --- | --- | --- | --- |
| `terse-prose` | Drop filler/articles/hedging; keep technical substance exact. | `en`, `pt-BR`, `ja`, `id`, `vi` | `lite`, `full`, `ultra` |
| `less-code` | YAGNI ladder: smallest working change, no unrequested abstractions. | `en`, `pt-BR`, `vi`, `ja`, `id` | `lite`, `full`, `ultra` |
| `ponytail` | Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff. | `en`, `pt-BR`, `vi`, `ja`, `id` | `lite`, `full`, `ultra` |
| `i-have-adhd` | Action-first output: next action leads, steps numbered, one concrete next step, no preamble. | `en`, `pt-BR`, `vi`, `ja`, `id` | `lite`, `full`, `ultra` |
| `terse-cjk` | Classical-Chinese ultra-terse style (locale-gated to zh). | `zh` | `lite`, `full`, `ultra` |
Each level appends a shared boundary clause ensuring that code blocks, URLs, file paths, commands, and identifiers remain verbatim.
---
## Configuration
### Dashboard
@@ -446,6 +462,60 @@ Caveman output mode is **opt-in** — set it via the combo config:
}
```
### Output Styles (catalog)
Caveman output mode above is the **legacy single-style path**. Phase 4 generalized it
into a catalog of composable output styles: `OUTPUT_STYLE_CATALOG` in
`open-sse/services/compression/outputStyles/catalog.ts`. Each style is a system-prompt
instruction that makes the model itself produce cheaper output; styles can be enabled
together and are injected in catalog order.
| Style | `id` | What it does | Instruction languages |
| --- | --- | --- | --- |
| Terse prose | `terse-prose` | Drop filler/articles/hedging; keep technical substance exact. Same text as the legacy caveman output mode (referenced, not re-typed). | en, pt-BR, ja, id |
| Less code | `less-code` | YAGNI ladder: smallest working change, no unrequested abstractions. | en only (backlog: [#10426](https://github.com/diegosouzapw/OmniRoute/issues/10426)) |
| Ponytail (lazy senior dev) | `ponytail` | "The best code is the code never written": reuse > rewrite, root cause > symptom, shortest working diff. | en, pt-BR, vi, ja, id |
| I have ADHD (action-first) | `i-have-adhd` | Action first (command/path/snippet before prose), numbered bounded steps, ONE concrete next step, no preamble/recap/closers. Adapted from [ayghri/i-have-adhd](https://github.com/ayghri/i-have-adhd) (MIT). | en, pt-BR, vi, ja, id |
| Terse CJK (文言) | `terse-cjk` | Classical-Chinese ultra-terse style. | zh (locale-gated: only offered when the detected language is `zh`) |
Every style ships three intensity levels — `lite`, `full`, `ultra` — and every level
ends with the shared boundaries clause, which keeps code blocks, file paths, commands,
error strings, URLs and identifiers verbatim.
#### How injection works
`applyOutputStyles()` (`open-sse/services/compression/outputStyles/apply.ts`) resolves
the selection against the catalog (unknown ids and locale-mismatched styles are
dropped, never an error), concatenates the selected instructions in catalog order,
appends the boundaries clause **once**, and front-loads the result into the system
prompt behind a single idempotency marker (`[OmniRoute Output Styles]`) — re-applying
is a no-op. When the detected request language has a translation, the localized
instruction is injected instead of English.
#### How to enable
In the dashboard: **Context → Settings → Compression** — one row per style with an
on/off toggle and a level selector. Programmatically, the compression config persists
the selection as:
```json
{
"outputStyles": [
{ "id": "i-have-adhd", "level": "full" },
{ "id": "less-code", "level": "lite" }
]
}
```
Back-compat: the legacy `outputMode: "caveman"` combo setting still works and maps to
`terse-prose`, byte-identical to the old injection in all four legacy languages.
The style × language matrix is pinned by
`tests/unit/compression/output-styles-i18n-matrix.test.ts`: a new style cannot ship
without at least a pt-BR translation (or an explicit tracked exception), and an
existing style cannot silently lose a locale. To add a style, see
[EXTENDING_COMPRESSION.md](./EXTENDING_COMPRESSION.md#adding-an-output-style).
### Tool Result Compression
The `toolResultCompressor.ts` module provides **5 specialized compression strategies**

View File

@@ -568,6 +568,40 @@ gate (`check:compression-budget`).
---
## Adding an Output Style
Output styles (see the [guide's catalog table](./COMPRESSION_GUIDE.md#output-styles-catalog))
are the response-side counterpart of the input engines: instead of compressing what you
send, they instruct the model to produce cheaper output. The registry is
`OUTPUT_STYLE_CATALOG` in `open-sse/services/compression/outputStyles/catalog.ts`, and
**one catalog entry is the entire feature**: the injector, the dashboard settings panel,
persistence and telemetry all enumerate the catalog — there is no other list to update.
1. **Add one entry to `OUTPUT_STYLE_CATALOG`** with `id`, `label`, `description` and the
three English `levels` (`lite`, `full`, `ultra`). Every level must end with
`${SHARED_BOUNDARIES}` so code, paths, commands, errors and URLs stay verbatim.
The instruction text must be **static and deterministic** per
`(id, level, language)` — `${SHARED_BOUNDARIES}` is the only interpolation allowed.
2. **Translate it.** Ship at least a `pt-BR` block under `i18n`; `ponytail` and
`i-have-adhd` (en, pt-BR, vi, ja, id) are the reference shape. A deliberately
single-language style sets `locale` instead (like `terse-cjk` → `zh`) and is then
only offered under that locale.
3. **Update the matrix guard** — add the style's languages to `BASELINE_LANGUAGES` in
`tests/unit/compression/output-styles-i18n-matrix.test.ts`. The gate fails any new
non-locale-gated style without the required translations unless it carries an
explicit `KNOWN_ENGLISH_ONLY` entry with a tracking issue.
4. **Add a per-style test** modeled on
`tests/unit/compression/i-have-adhd-catalog.test.ts`: catalog shape, boundaries
clause per level, and an anchor asserting each translation is written in its own
language rather than copied English.
5. **Attribution**: if the style is adapted from an upstream project, credit it in a
source comment on the entry (e.g. `i-have-adhd` → ayghri/i-have-adhd, MIT) — same
rule as "Proposing an upstream-inspired improvement" above.
No UI, schema or telemetry change is needed — those surfaces render from the catalog.
---
## Best Practices
### Engine Development

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 350" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Animated terminal demoing the OmniRoute CLI: omniroute providers list (341 providers registered, anthropic, codex, glm, kimi shown active), omniroute combo list (always-on priority, cost-saver, fusion-panel, context-relay) and omniroute health (healthy, 18412 requests in 24h, p95 412ms, circuit breakers 24 closed, 1 half-open, 0 open), cycling over the 80+ command surface: providers, oauth, keys, combo, nodes, models, cache, compression, cost, usage, quota, health, resilience, telemetry, logs, audit, mcp, a2a, cloud, memory, skills, eval, doctor, repl, tunnel, backup, sync, webhooks, policy, pricing, translator, simulate and more.">
<svg viewBox="0 0 1200 350" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Animated terminal demoing the OmniRoute CLI: omniroute providers list (340 providers registered, anthropic, codex, glm, kimi shown active), omniroute combo list (always-on priority, cost-saver, fusion-panel, context-relay) and omniroute health (healthy, 18412 requests in 24h, p95 412ms, circuit breakers 24 closed, 1 half-open, 0 open), cycling over the 80+ command surface: providers, oauth, keys, combo, nodes, models, cache, compression, cost, usage, quota, health, resilience, telemetry, logs, audit, mcp, a2a, cloud, memory, skills, eval, doctor, repl, tunnel, backup, sync, webhooks, policy, pricing, translator, simulate and more.">
<desc>Compact animated terminal cycling three real OmniRoute CLI commands with a typewriter effect and a scrolling subcommand ticker; the first frame shows the completed providers-list screen.</desc>
<defs><clipPath id="tickerClip"><rect x="12" y="304" width="1176" height="40"/></clipPath><clipPath id="tw0"><rect x="64" y="46" height="26" width="0"><animate attributeName="width" calcMode="discrete" values="0;31;61;92;122;153;184;214;245;245" keyTimes="0;0.012;0.018;0.024;0.030;0.036;0.042;0.048;0.054;1" dur="18s" repeatCount="indefinite"/></rect></clipPath><clipPath id="tw1"><rect x="64" y="46" height="26" width="0"><animate attributeName="width" calcMode="discrete" values="0;26;51;76;102;128;153;178;204;204" keyTimes="0;0.345;0.351;0.357;0.363;0.369;0.375;0.381;0.387;1" dur="18s" repeatCount="indefinite"/></rect></clipPath><clipPath id="tw2"><rect x="64" y="46" height="26" width="0"><animate attributeName="width" calcMode="discrete" values="0;20;41;61;82;102;122;143;163;163" keyTimes="0;0.678;0.684;0.690;0.696;0.702;0.708;0.714;0.720;1" dur="18s" repeatCount="indefinite"/></rect></clipPath></defs>
<rect width="1200" height="350" fill="#0d1117"/>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 780" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comparison table: OmniRoute versus 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute is the only one with the full set: 341 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, a built-in MCP server with 109 tools, A2A protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, desktop/Termux/PWA, 43 UI locales and 100% MIT self-hosted. 9router has free providers, RTK compression and translation but no MCP, A2A, memory, guardrails, cloud agents or stealth. OpenRouter is a hosted SaaS with 400+ models, guardrails and a hosted MCP but is not self-hosted and lacks A2A, memory, cloud agents and stealth. CLIProxyAPI is a light OAuth proxy with two routing strategies. LiteLLM has 100+ providers, A2A and extensive guardrails but no memory, compression, free tier, stealth or cloud agents.">
<svg viewBox="0 0 1200 780" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comparison table: OmniRoute versus 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute is the only one with the full set: 340 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, a built-in MCP server with 109 tools, A2A protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, desktop/Termux/PWA, 43 UI locales and 100% MIT self-hosted. 9router has free providers, RTK compression and translation but no MCP, A2A, memory, guardrails, cloud agents or stealth. OpenRouter is a hosted SaaS with 400+ models, guardrails and a hosted MCP but is not self-hosted and lacks A2A, memory, cloud agents and stealth. CLIProxyAPI is a light OAuth proxy with two routing strategies. LiteLLM has 100+ providers, A2A and extensive guardrails but no memory, compression, free tier, stealth or cloud agents.">
<desc>Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses.</desc>
<defs>
<pattern id="gC" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M 32 0 L 0 0 0 32" fill="none" stroke="#ffffff" stroke-opacity="0.05" stroke-width="1"/></pattern>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint, 341 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 341 providers in milliseconds, quota out means the next provider takes over with zero downtime. Save up to 95 percent of tokens: RTK plus Caveman stacked compression cuts 15 to 95 percent of eligible tokens, about 89 percent average on tool-heavy sessions. Zero dollars to start: 90+ providers with a free tier, 56 free forever — Qoder, Pollinations, Cloudflare, SiliconFlow — no card needed. Every tool works: 33 coding agents including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation — point any tool at /v1 and it just works. Production-grade: circuit breakers, TLS stealth, MCP with 109 tools, A2A, memory, guardrails, evals — 25,000+ tests.">
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint, 340 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 340 providers in milliseconds, quota out means the next provider takes over with zero downtime. Save up to 95 percent of tokens: RTK plus Caveman stacked compression cuts 15 to 95 percent of eligible tokens, about 89 percent average on tool-heavy sessions. Zero dollars to start: 90+ providers with a free tier, 56 free forever — Qoder, Pollinations, Cloudflare, SiliconFlow — no card needed. Every tool works: 33 coding agents including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation — point any tool at /v1 and it just works. Production-grade: circuit breakers, TLS stealth, MCP with 109 tools, A2A, memory, guardrails, evals — 25,000+ tests.">
<desc>Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle.</desc>
<defs>
<pattern id="gridPaperP" width="32" height="32" patternUnits="userSpaceOnUse">
@@ -21,7 +21,7 @@
<line x1="150" y1="53" x2="1160" y2="53" stroke="#232b38" stroke-width="1.5"/>
</g>
<g>
<text x="40" y="100" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="23" font-weight="600" fill="#c9d1d9">One endpoint. <tspan fill="#a78bfa" font-weight="800">341 providers.</tspan> Never stop building — OmniRoute picks <tspan fill="#7ee787" font-weight="700">the cheapest one that works</tspan>.</text>
<text x="40" y="100" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="23" font-weight="600" fill="#c9d1d9">One endpoint. <tspan fill="#a78bfa" font-weight="800">340 providers.</tspan> Never stop building — OmniRoute picks <tspan fill="#7ee787" font-weight="700">the cheapest one that works</tspan>.</text>
</g>
<g font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif">
@@ -38,7 +38,7 @@
<line x1="3.9" y1="3.9" x2="18.1" y2="18.1"/>
</g>
<text x="102" y="170" font-size="18" font-weight="800" fill="#74b9ff">Never hit limits</text>
<text x="66" y="204" font-size="13.5" fill="#a1a1aa">Auto-fallback across 341 providers in</text>
<text x="66" y="204" font-size="13.5" fill="#a1a1aa">Auto-fallback across 340 providers in</text>
<text x="66" y="226" font-size="13.5" fill="#a1a1aa">milliseconds. Quota out? The next provider</text>
<text x="66" y="248" font-size="13.5" fill="#a1a1aa">takes over — zero downtime.</text>
</g>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,4 +1,4 @@
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 341 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 341 AI providers, 90+ free tiers, about 1.51B free tokens per month, 15 to 95 percent token savings, 19 routing strategies, zero dollars to start.">
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 340 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 340 AI providers, 90+ free tiers, about 1.51B free tokens per month, 15 to 95 percent token savings, 19 routing strategies, zero dollars to start.">
<desc>Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame.</desc>
<defs>
<pattern id="gridPaperH" width="32" height="32" patternUnits="userSpaceOnUse">
@@ -28,7 +28,7 @@
<text x="48" y="138" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="60" font-weight="800" fill="#e9edf3">Never stop coding<tspan fill="#a855f7">.</tspan></text>
<!-- subheadline -->
<text x="48" y="184" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="25" font-weight="600" fill="#c9d1d9">Every AI tool → <tspan fill="#a78bfa" font-weight="800">341 providers</tspan><tspan fill="#7ee787" font-weight="800">90+ free</tspan> — through one endpoint.</text>
<text x="48" y="184" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="25" font-weight="600" fill="#c9d1d9">Every AI tool → <tspan fill="#a78bfa" font-weight="800">340 providers</tspan><tspan fill="#7ee787" font-weight="800">90+ free</tspan> — through one endpoint.</text>
<!-- plug line -->
<text x="48" y="222" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="16.5" fill="#a1a1aa">Claude Code · Codex · Cursor · Cline · Copilot · Antigravity&#160;&#160;&#160;&#160;<tspan fill="#7ee787" font-weight="700">FREE</tspan> Claude / GPT / Gemini · auto-fallback</text>

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -18,12 +18,31 @@ There are also two launchers — `omniroute launch` (Claude Code) and
`omniroute launch-codex` (Codex) — that spawn the CLI with the right env injected,
without writing any config at all.
Provider onboarding is available from the same local/remote context. The
API-first commands below keep management authentication separate from provider
credentials and never print a credential in structured output:
```bash
omniroute providers add glm --credential-env GLM_API_KEY --name work
omniroute providers import ./providers.json --dry-run --json
omniroute providers auth openai
omniroute providers edit <connection-id> --default-model glm/glm-5.2
omniroute providers remove <connection-id> --yes
```
For scripts, prefer `--credential-stdin` or `--credential-env`; `--credential`
is retained for controlled local use. `providers remove` requires `--yes` on a
non-interactive terminal, and all five commands honor the active context or the
global `--base-url`/`--api-key` options.
For the one-time, hand-written base setup of the two richest integrations, see the
per-tool deep dives:
- [Claude Code configuration](./CLAUDE-CODE-CONFIGURATION.md)
- [Codex CLI configuration](./CODEX-CLI-CONFIGURATION.md)
- [Remote Mode](./REMOTE-MODE.md) — drive a remote OmniRoute (VPS / Tailnet) from your laptop
- [VS Code Copilot Chat](./VSCODE-COPILOT.md) — the OmniCopilot extension; it can also run these
`setup-*` commands for you from inside the editor
---
@@ -35,22 +54,23 @@ Every command honours the **active context** (set with `omniroute connect`, see
with `--remote` (or an active remote context) it fetches the catalog from that
server and writes the config locally.
| Command | Tool | What it writes | Key flags | Local vs remote |
|---------|------|----------------|-----------|-----------------|
| `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/<name>.config.toml` — one profile per compatible text model (`codex --profile <name>`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both |
| `omniroute setup-claude` | Claude Code | `~/.claude/profiles/<name>/settings.json` — one profile per matched model (`CLAUDE_CONFIG_DIR`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--claude-home` | Both |
| `omniroute setup-opencode` | OpenCode (openai-compatible) | `~/.config/opencode/opencode.json``omniroute` provider with every catalog model (`opencode -m omniroute/<model>`) | `--remote` `--api-key` `--only` `--model` `--dry-run` `--port` | Both |
| `omniroute setup-cline` | Cline | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints VS Code extension settings | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--cline-dir` | Both |
| `omniroute setup-kilo` | Kilo Code | `~/.local/share/kilo/auth.json` (CLI) + merges `kilocode.*` into VS Code `settings.json` if present | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--auth-path` `--vscode-settings` | Both |
| `omniroute setup-continue` | Continue / `cn` CLI | `~/.continue/config.yaml``provider: openai` models, key via `${{ secrets.OMNIROUTE_API_KEY }}` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-cursor` | Cursor | Nothing — prints the in-app steps (Cursor config is opaque SQLite) | `--remote` `--api-key` `--only` `--port` | Both |
| `omniroute setup-roo` | Roo Code | `~/.omniroute/roo-settings.json` (import doc) + sets `roo-cline.autoImportSettingsPath` if a VS Code `settings.json` exists | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--import-path` `--vscode-settings` | Both |
| `omniroute setup-crush` | Crush | `~/.config/crush/crush.json``openai-compat` provider, key via `$OMNIROUTE_API_KEY` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-goose` | Goose | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER`/`OPENAI_HOST`/`GOOSE_MODEL`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-aider` | Aider | `~/.aider.conf.yml` (`openai-api-base` + `model: openai/<id>`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-qwen` | Qwen Code | `~/.qwen/settings.json` — V4 `modelProviders.openai` array + `OMNIROUTE_API_KEY` in `~/.qwen/.env` | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` `--env-path` | Both |
| `omniroute launch` | Claude Code | Nothing — spawns `claude` with `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` injected | `--remote` `--api-key` `--token` `--profile` `--port` | Both |
| `omniroute launch-codex` | OpenAI Codex CLI | Nothing — spawns `codex` with the `omniroute` provider injected via `-c` flags | `--remote` `--api-key` `--profile` (`-p`) `--port` | Both |
| Command | Tool | What it writes | Key flags | Local vs remote |
| -------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
| `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/<name>.config.toml` — one profile per compatible text model (`codex --profile <name>`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both |
| `omniroute setup-claude` | Claude Code | `~/.claude/profiles/<name>/settings.json` — one profile per matched model (`CLAUDE_CONFIG_DIR`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--claude-home` | Both |
| `omniroute setup-opencode` | OpenCode (openai-compatible) | `~/.config/opencode/opencode.json``omniroute` provider with every catalog model (`opencode -m omniroute/<model>`) | `--remote` `--api-key` `--only` `--model` `--dry-run` `--port` | Both |
| `omniroute setup-cline` | Cline | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints VS Code extension settings | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--cline-dir` | Both |
| `omniroute setup-kilo` | Kilo Code | `~/.local/share/kilo/auth.json` (CLI) + merges `kilocode.*` into VS Code `settings.json` if present | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--auth-path` `--vscode-settings` | Both |
| `omniroute setup-continue` | Continue / `cn` CLI | `~/.continue/config.yaml``provider: openai` models, key via `${{ secrets.OMNIROUTE_API_KEY }}` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-cursor` | Cursor | Nothing — prints the in-app steps (Cursor config is opaque SQLite) | `--remote` `--api-key` `--only` `--port` | Both |
| `omniroute setup-roo` | Roo Code | `~/.omniroute/roo-settings.json` (import doc) + sets `roo-cline.autoImportSettingsPath` if a VS Code `settings.json` exists | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--import-path` `--vscode-settings` | Both |
| `omniroute setup-crush` | Crush | `~/.config/crush/crush.json``openai-compat` provider, key via `$OMNIROUTE_API_KEY` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-goose` | Goose | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER`/`OPENAI_HOST`/`GOOSE_MODEL`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-aider` | Aider | `~/.aider.conf.yml` (`openai-api-base` + `model: openai/<id>`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-qwen` | Qwen Code | `~/.qwen/settings.json` — V4 `modelProviders.openai` array + `OMNIROUTE_API_KEY` in `~/.qwen/.env` | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` `--env-path` | Both |
| `omniroute run <target>` | Runtime launch (generic) | Nothing — spawn `claude`/`codex`/`aider`/`goose`/`opencode`/`qwen`/`gemini` with the right env and args; Qwen and Gemini use a temporary isolated home | `--remote` `--base-url` `--context` `--provider` `--model` `--api-key` `--api-key-env` `--dry-run` `--json` `--port` `--profile` `--token` | Both |
| `omniroute launch` | Claude Code | Nothing — spawns `claude` with `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` injected | `--remote` `--api-key` `--token` `--profile` `--port` | Both |
| `omniroute launch-codex` | OpenAI Codex CLI | Nothing — spawns `codex` with the `omniroute` provider injected via `-c` flags | `--remote` `--api-key` `--profile` (`-p`) `--port` | Both |
Notes on flags (verified in the command source):
@@ -73,6 +93,20 @@ Notes on flags (verified in the command source):
a profile written by `setup-claude` / `setup-codex`, plus pass-through args for
the underlying `claude` / `codex` binary.
The interactive picker is also shared by the setup recipes:
```bash
# Pick from the active local or remote model catalog and configure the target.
omniroute configure claude
omniroute configure opencode --provider glm
omniroute configure qwen --model qwen/qwen3.8-max-preview --yes
```
`configure` currently delegates to the tested recipes for `codex`, `claude`,
`opencode`, `qwen`, `aider`, `goose`, `cline`, `continue`, and `kilo`. IDE-only,
MITM, and guide-only catalog entries remain explicit `setup-*`/manual flows and
are not presented as launchable targets.
> `setup-opencode` is the **lightweight openai-compatible** OpenCode integration.
> There is also a richer plugin integration — `omniroute setup opencode` — which
> installs `@omniroute/opencode-plugin`. They are different commands; the table
@@ -113,6 +147,16 @@ Launch without writing any config at all (env-injection only):
omniroute launch # Claude Code → local OmniRoute
omniroute launch-codex # Codex CLI → local OmniRoute
omniroute launch-codex --profile glm52
omniroute run claude --model openai/gpt-5.4
omniroute run codex --model openai/gpt-5.4 --dry-run --json
omniroute run aider --model glm/glm-5.2 -- --message "reply OK"
omniroute run goose --model glm/glm-5.2
omniroute run opencode --model glm/glm-5.2 -- run "reply OK"
omniroute run qwen --model glm/glm-5.2 -- -p "reply OK"
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "reply OK"
# Explicit command path: pass through whatever comes after --
omniroute run claude -- --print-system-prompt "review this diff"
```
---
@@ -156,15 +200,16 @@ OmniRoute exposes the OpenAI surface at `/v1`, the Anthropic surface at the root
and a native Gemini surface at `/v1beta`. Each integration is wired to the form its
tool expects (verified in the command source):
| Integration | Base URL written | `/v1`? |
|-------------|------------------|--------|
| `setup-cline` (`openAiBaseUrl`) | root | No — Cline appends `/v1/chat/completions` |
| `setup-goose` (`OPENAI_HOST`) | root | No — Goose appends the path |
| `setup-aider` (`OPENAI_API_BASE`) | root | No — LiteLLM appends `/v1/chat/completions` |
| `setup-kilo`, `setup-roo`, `setup-continue`, `setup-crush`, `setup-cursor` | with `/v1` | Yes |
| `setup-claude` (`ANTHROPIC_BASE_URL`), `launch` | root | No — Claude Code appends `/v1/messages` |
| `setup-codex`, `launch-codex` (`model_providers.omniroute.base_url`) | with `/v1` | Yes |
| `setup-qwen` (`modelProviders.openai[].baseUrl`) | with `/v1` | Yes |
| Integration | Base URL written | `/v1`? |
| -------------------------------------------------------------------------- | ---------------- | ------------------------------------------- |
| `setup-cline` (`openAiBaseUrl`) | root | No — Cline appends `/v1/chat/completions` |
| `setup-goose` (`OPENAI_HOST`) | root | No — Goose appends the path |
| `setup-aider` (`OPENAI_API_BASE`) | root | No — LiteLLM appends `/v1/chat/completions` |
| `setup-kilo`, `setup-roo`, `setup-continue`, `setup-crush`, `setup-cursor` | with `/v1` | Yes |
| `setup-claude` (`ANTHROPIC_BASE_URL`), `launch` | root | No — Claude Code appends `/v1/messages` |
| `setup-codex`, `launch-codex` (`model_providers.omniroute.base_url`) | with `/v1` | Yes |
| `setup-qwen` (`modelProviders.openai[].baseUrl`) | with `/v1` | Yes |
| `run gemini` (`GOOGLE_GEMINI_BASE_URL`) | root | No — the SDK appends `/v1beta/models/…` |
---
@@ -194,6 +239,56 @@ outdated), `--apply` (install without prompting), `--changelog`, `--no-backup`,
---
## Google Gemini CLI via `omniroute run gemini`
Contract verified against `@google/gemini-cli` 0.50.0: the CLI honors
`GOOGLE_GEMINI_BASE_URL` and issues `POST /v1beta/models/<model>:generateContent`
(and `:streamGenerateContent?alt=sse`) against it — exactly OmniRoute's native
Gemini surface (`/v1beta`). `omniroute run gemini` wires that automatically:
- `GOOGLE_GEMINI_BASE_URL` → the active OmniRoute base URL (root, no `/v1`);
- `GEMINI_API_KEY` → the resolved OmniRoute credential (option/env/context);
- a **temporary isolated `GEMINI_CLI_HOME`** whose `.gemini/settings.json`
selects `gemini-api-key` auth, so a stored Google OAuth session (Code Assist)
never overrides the OmniRoute-directed launch — removed after exit;
- `--model <id>` injection from `--provider`/`--model`.
```bash
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "hello"
```
Gemini's workspace-trust guard still applies in headless mode — pass
`--skip-trust` (or trust the directory interactively) yourself; the launcher
deliberately does not bypass it. This launcher is distinct from the **ACP
registration** (`src/lib/acp/registry.ts`, `gemini --acp`), which remains the
agent-protocol integration for `/dashboard/acp-agents`.
---
## Real smoke sweep (opt-in)
Deterministic launch-plan regression runs in CI (`tests/unit/cli/run-command.test.ts`,
`tests/unit/cli/run-execution.test.ts`). To validate the REAL binaries against a REAL
OmniRoute server, an opt-in harness exists at
`tests/integration/upstream-cli-smoke.int.test.ts`. It never runs automatically
(every sub-test skips unless `RUN_CLI_SMOKE=1`), passes the credential by env-var
NAME (never by value), redacts key-shaped strings from any recorded output, skips
targets whose binary is not installed, and classifies failures as
auth / upstream / config instead of a bare boolean:
```bash
RUN_CLI_SMOKE=1 \
OMNIROUTE_SMOKE_BASE_URL="http://localhost:20128" \
OMNIROUTE_SMOKE_MODEL="<provider/model>" \
OMNIROUTE_SMOKE_API_KEY_ENV="OMNIROUTE_API_KEY" \
node --import tsx/esm --test tests/integration/upstream-cli-smoke.int.test.ts
```
Optional: `OMNIROUTE_SMOKE_TARGETS="codex,opencode,qwen"` restricts the sweep;
`OMNIROUTE_SMOKE_TIMEOUT_MS` overrides the 120s per-target timeout.
---
## See also
- [Claude Code configuration](./CLAUDE-CODE-CONFIGURATION.md) — the deeper Claude Code guide

View File

@@ -10,6 +10,15 @@ Complete guide for using the Codex CLI pointed at OmniRoute as an OpenAI-compati
---
> **TOML is the only effective format.** Modern Codex reads `~/.codex/config.toml`
> exclusively (verified against codex-cli 0.147.0: `codex --help` documents
> `-c/--config` overrides "loaded from `~/.codex/config.toml`"). The old
> `~/.codex/config.yaml` belonged to the legacy npm CLI and is silently ignored.
> The dashboard generator (`/api/cli-tools/apply`, tool `codex`) writes TOML with a
> conservative merge — existing keys and other provider blocks are preserved, the
> API key stays in `OMNIROUTE_API_KEY` (never in the file), and a leftover legacy
> `config.yaml` is reported as a migration note without being touched.
## Ready-to-paste config.toml
Replace `<YOUR_HOST>` and `<YOUR_KEY>` with your values:

View File

@@ -329,10 +329,13 @@ prefix). Traefik should route `PathPrefix(`/omniroute`)` to the container withou
`StripPrefix`, so Next.js receives `/omniroute/...` and serves assets from
`/omniroute/_next/...`.
The Docker healthcheck probes `/api/monitoring/health` prefixed with the active
`OMNIROUTE_BASE_PATH`. That path is a **deep** check (DB + monitoring summary). It is
appropriate for Dockers infrequent `HEALTHCHECK`, but **not** for Kubernetes
`livenessProbe` intervals.
The Docker healthcheck probes the lightweight `/healthz` lifecycle endpoint prefixed
with the active `OMNIROUTE_BASE_PATH`. `/api/monitoring/health` remains available for
human/dashboard diagnostics; to point the container HEALTHCHECK back at it (for example
for deep health enforcement), set `OMNIROUTE_HEALTHCHECK_PATH=/api/monitoring/health`.
That path is a **deep** check (DB + monitoring summary) — appropriate for Docker's
infrequent `HEALTHCHECK` if you opt back in, but **not** for Kubernetes `livenessProbe`
intervals.
For orchestrators (Kubernetes, Nomad, etc.):

View File

@@ -271,13 +271,41 @@ omniroute configure codex
# non-interactive
omniroute configure codex --provider glm --model glm/glm-5.2 --name glm52
# keep a frequently used model at the top of the interactive picker
omniroute configure codex --provider glm --model glm/glm-5.2 --favorite --yes
```
The picker keeps only model IDs (never URLs or credentials) in the local
`model-preferences.json` file, scoped by context and CLI target. Favorites are
shown before recent selections; use `--unfavorite` to remove a selected model
from that context/target list.
The written profile references the inference key by env var
(`OMNIROUTE_API_KEY`) — the secret is never written to disk. For the one-time
base Codex setup (the `[model_providers.omniroute]` block), see
[CODEX-CLI-CONFIGURATION.md](./CODEX-CLI-CONFIGURATION.md).
### Launching a CLI against the remote (no config written)
`omniroute run <target>` also honours the active context: the remote base URL
and the context credential are injected into the spawned process only.
```bash
omniroute connect 192.168.0.15
omniroute run claude --model openai/gpt-5.4 # Claude Code → remote
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "hello"
omniroute run opencode --model glm/glm-5.2 -- run "reply OK"
# Preview exactly what would be spawned (env KEY NAMES only, never values):
omniroute run codex --dry-run --json
```
Targets: `claude`, `codex`, `aider`, `goose`, `opencode`, `qwen`, `gemini`
(single source: `bin/cli/cli-manifest.mjs`). Qwen and Gemini run with a
temporary isolated home that is removed on exit, so the launch never touches —
or leaks into — your personal tool configuration.
### Per-CLI setup commands
Each supported CLI has a remote-aware setup command (all honour the active
@@ -360,14 +388,20 @@ omniroute contexts remove stg --yes
> revoke the token on the server with `omniroute tokens revoke <id>` to actually
> kill access.
**Export / import** contexts (e.g. to move them between machines — secrets included,
so handle the file carefully):
**Export / import** contexts (e.g. to move them between machines). New contexts persist
only a keychain reference; credentials are not copied into the export when the OS
keychain is available:
```bash
omniroute contexts export --out contexts.json # default: stdout
omniroute contexts import contexts.json # overwrite; --merge to keep existing
omniroute contexts migrate --yes # move legacy plaintext tokens to keychain
```
On headless systems without a usable OS keychain, the CLI falls back to
`config.json` with mode `0600` and prints a one-time warning. Treat exports from
that fallback (and any legacy config before migration) as secret material.
---
## Quick end-to-end check
@@ -409,8 +443,12 @@ omniroute contexts remove 192-168-0-15 --yes # drop the local context (even if
- `omniroute connect` reuses the login brute-force lockout + audit logging.
- Prefer HTTPS or a Tailnet for the transport; a bare host defaults to `http://`
for LAN/Tailscale convenience — pass a full `https://…` URL for TLS.
- The local context file is `~/.omniroute/config.json` (`chmod 600`); tokens are
never printed in logs (masked to a prefix).
- The preferred local context file is `~/.omniroute/config.json` (`chmod 600`)
containing only a `credentialRef`; the token itself is stored in the OS
keychain (`keytar`) and is never printed in logs. Headless installs without a
working native keychain use the same `0600` file as an explicit fallback and
emit a warning once. Use `omniroute contexts migrate --yes` after installing a
keychain backend.
---

View File

@@ -56,6 +56,8 @@ npm install
PORT=20128 DASHBOARD_PORT=20129 NEXT_PUBLIC_BASE_URL=http://localhost:20129 npm run dev
```
> **Windows note:** By default, OmniRoute uses `%APPDATA%\omniroute` when the legacy `%USERPROFILE%\.omniroute` directory is not present. Set `DATA_DIR` to choose a different data-directory location.
> **Note:** `npm install` auto-generates `.env` from `.env.example` on first run. Subsequent installs will not overwrite an existing `.env`, so customizations are preserved. To re-seed, delete `.env` before re-running.
### Docker

View File

@@ -478,7 +478,7 @@ If a provider repeatedly enters OPEN state:
### "Unsupported model" error
- Ensure you're using the correct prefix: `deepgram/nova-3` or `assemblyai/best`
- Use a model id whose first segment is a provider you have credentials for (`openai/whisper-1`, `openrouter/deepgram/nova-3`). Bare `deepgram/nova-3` requires a native Deepgram key.
- Verify the provider is connected in **Dashboard → Providers**
### Transcription returns empty or fails

View File

@@ -948,9 +948,12 @@ Content-Type: multipart/form-data
curl -X POST http://localhost:20128/v1/audio/transcriptions \
-H "Authorization: Bearer your-api-key" \
-F "file=@audio.mp3" \
-F "model=deepgram/nova-3"
-F "model=openai/whisper-1"
```
`deepgram/nova-3` is the native Deepgram route and needs a Deepgram API key.
If only OpenRouter is configured, use `openrouter/deepgram/nova-3`.
**Speech-to-Text (transcription)** providers:
- `openai/` (whisper-compatible)

View File

@@ -0,0 +1,138 @@
---
title: "VS Code Copilot Chat — OmniCopilot extension"
version: 3.8.50
lastUpdated: 2026-08-18
---
# VS Code Copilot Chat — OmniCopilot extension
**OmniCopilot** puts every model your OmniRoute serves into the *native* GitHub Copilot Chat
model picker. No second sidebar, no separate chat UI — Copilot's agent mode, tool calling,
MCP servers and custom instructions all keep working, just running on the model you pick.
| | |
| --- | --- |
| **Install (VS Code)** | [Marketplace → `diegosouzapw.omnicopilot`](https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot) |
| **Install (forks)** | [Open VSX](https://open-vsx.org/extension/diegosouzapw/omnicopilot) — Cursor, Windsurf, VSCodium, Theia, code-server, Gitpod, Antigravity, Kiro |
| **Source / issues** | [github.com/diegosouzapw/OmniCopilot](https://github.com/diegosouzapw/OmniCopilot) (MIT) |
| **Requires** | VS Code 1.104+ |
> **No Copilot subscription needed.** Since VS Code 1.122 a language-model provider works
> without a GitHub sign-in and without any Copilot plan. Inline completions and
> embeddings-based features stay outside the provider API and still require Copilot.
---
## Setup
1. **Run OmniRoute**`npm install -g omniroute && omniroute` (dashboard on `http://localhost:20128`).
2. **Install the extension** — search "OmniRoute" in the Extensions view.
3. **Pick a model** — Copilot Chat → model picker → **Manage Models…****OmniRoute**, then tick
what you want.
Nothing to configure when OmniRoute runs on the default port. For a remote instance, open the
**OmniRoute icon in the Activity Bar** (or run `OmniRoute: Manage Connection`) and set:
- **Server URL** — the server root, e.g. `http://192.168.0.15:20128`. The `/v1` suffix is
appended by the extension; do not include it.
- **API key** — only when the server sets `REQUIRE_API_KEY`. Stored in the OS keychain via VS
Code SecretStorage, never in `settings.json`.
---
## What the picker will show
The extension does not show the raw `GET /v1/models` payload — it shapes it, and the count you
see is lower than the catalog size for two deliberate reasons.
### It asks for one id per model
`MODELS_CATALOG_PREFIX_MODE` defaults to **`dual`**, which advertises every model twice — once
under the short alias prefix and once under the canonical provider prefix — so older client
configs keep resolving either form:
```
cc/claude-sonnet-4-6 ← alias prefix
claude/claude-sonnet-4-6 ← canonical prefix, same model
```
The extension requests **`GET /v1/models?prefix=alias`** so one id arrives per model, without
changing the server-wide setting for your other clients. On a reference instance this collapsed
**2345 entries to 1396 — 949 duplicates, zero models lost.**
If you would rather fix it server-wide for *every* client, set the
`MODELS_CATALOG_PREFIX_MODE` feature flag to `alias` in the dashboard. See
[API_REFERENCE → prefix](../reference/API_REFERENCE.md#model-id-prefixes-prefix) for the
query parameter and the warning about `canonical`.
### It hides models that cannot chat
The catalog also lists image, video, audio, rerank, embedding and moderation models. Those are
rejected on a chat request anyway:
```
HTTP 400 — Model '<id>' is an image-generation model and cannot be used on
/v1/chat/completions. Use POST /v1/images/generations instead.
```
so they are filtered out by their `type` field before reaching the picker. **Responses-API
models are kept** — every Codex / GPT-5.x entry advertises `supported_endpoints: ["responses"]`,
and OmniRoute translates those for `/v1/chat/completions`, so they are perfectly usable.
### Providers you never configured
The catalog lists models from providers with an **active connection** *plus* every **noAuth**
provider — the keyless ones that make up much of the free tier. That is intentional. To hide
them, add them to `blockedProviders` in the dashboard settings; nothing changes in the
extension.
---
## Dashboard inside a VS Code tab
`omnicopilot.dashboardOpen: "editor"` renders the OmniRoute dashboard in an editor tab via the
Simple Browser instead of an external browser. Embedding is **opt-in on the server**: start
OmniRoute with
```bash
DASHBOARD_ALLOW_EMBED=vscode omniroute
```
which serves the HTML pages with `frame-ancestors 'self' vscode-webview:` instead of the default
`frame-ancestors 'none'` + `X-Frame-Options: DENY`. The API surface (`/api`, `/v1`, `/v1beta`,
`/a2a`, `/healthz`) keeps the strict headers either way. Without the variable the page refuses to
frame and the extension falls back to the external browser — nothing breaks. See
[`ENVIRONMENT.md`](../reference/ENVIRONMENT.md) and issue
[#10273](https://github.com/diegosouzapw/OmniRoute/issues/10273).
---
## Configuring your other tools from inside VS Code
**`OmniRoute: Configure Coding CLI`** drives the `omniroute` CLI to write ready-to-use profiles
for Codex CLI, Claude Code, Cline, Continue, Cursor, Aider, OpenCode, Goose, Crush, Qwen Code,
Kilo and Roo — the same configs described in
[`CLI-INTEGRATIONS.md`](CLI-INTEGRATIONS.md). The API key is handed to the CLI through the
`OMNIROUTE_API_KEY` environment variable, never on the command line.
---
## Troubleshooting
| Symptom | Cause / fix |
| --- | --- |
| No OmniRoute models in the picker | Server unreachable. The status-bar dot goes grey; run `OmniRoute: Check Connection`. Discovery is silent by design and contributes no models rather than prompting. |
| Every model appears twice | You are on an OmniCopilot older than 1.0.1 — update. The extension now requests `?prefix=alias`. |
| An image/audio model used to be listed and is gone | Intentional since 1.0.1 — it could never answer a chat request. |
| Panel missing from the Activity Bar | VS Code moves extra view containers into the **"…"** overflow at the bottom of the Activity Bar, and a container hidden via right-click stays hidden. Right-click the Activity Bar → tick **OmniRoute**, or open it with `OmniRoute: Manage Connection`. |
| Dashboard opens in the browser despite `editor` mode | The server is not started with `DASHBOARD_ALLOW_EMBED=vscode` (see above). The fallback is deliberate. |
| Models list is stale after changing providers | `OmniRoute: Refresh Models`, or the ↻ link in the panel. |
---
## See also
- [`CLI-INTEGRATIONS.md`](CLI-INTEGRATIONS.md) — every other coding tool
- [`REMOTE-MODE.md`](REMOTE-MODE.md) — driving a remote OmniRoute
- [`../reference/API_REFERENCE.md`](../reference/API_REFERENCE.md) — the `/v1/models` contract
- [`docs/CATALOG.md`](https://github.com/diegosouzapw/OmniCopilot/blob/main/docs/CATALOG.md) — the extension's own catalog notes

View File

@@ -16,6 +16,7 @@
"CLAUDE-CODE-CONFIGURATION",
"CODEX-CLI-CONFIGURATION",
"CLI-INTEGRATIONS",
"VSCODE-COPILOT",
"MANAGEMENT-AUTH",
"REMOTE-MODE",
"PWA_GUIDE",

View File

@@ -4,7 +4,7 @@
---
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
## Overview
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
- **Database:** SQLite via better-sqlite3 (local, zero-config, 149 migrations)
- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ └── manager.ts # MITM proxy manager
│ ├── shared/ # Shared utilities, components, and constants
│ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.)
│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes
│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes
│ │ ├── contracts/ # Shared API contracts
│ │ ├── hooks/ # React hooks
│ │ ├── middleware/ # Shared middleware utilities
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
## Key Features (v3.8.50)
### Core Proxy
- **341 AI providers** with automatic format translation
- **340 AI providers** with automatic format translation
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
- **4-tier fallback**: Subscription → API Key → Cheap → Free
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 149 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
@@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
## v3.8.x Highlights
- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
- **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay`
- **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown
- **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules)

View File

@@ -4,7 +4,7 @@
---
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
## Overview
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
- **Database:** SQLite via better-sqlite3 (local, zero-config, 149 migrations)
- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ └── manager.ts # MITM proxy manager
│ ├── shared/ # Shared utilities, components, and constants
│ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.)
│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes
│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes
│ │ ├── contracts/ # Shared API contracts
│ │ ├── hooks/ # React hooks
│ │ ├── middleware/ # Shared middleware utilities
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
## Key Features (v3.8.50)
### Core Proxy
- **341 AI providers** with automatic format translation
- **340 AI providers** with automatic format translation
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
- **4-tier fallback**: Subscription → API Key → Cheap → Free
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 149 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
@@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
## v3.8.x Highlights
- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
- **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay`
- **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown
- **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules)

View File

@@ -4,7 +4,7 @@
---
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
## Overview
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
- **Database:** SQLite via better-sqlite3 (local, zero-config, 149 migrations)
- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ └── manager.ts # MITM proxy manager
│ ├── shared/ # Shared utilities, components, and constants
│ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.)
│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes
│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes
│ │ ├── contracts/ # Shared API contracts
│ │ ├── hooks/ # React hooks
│ │ ├── middleware/ # Shared middleware utilities
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
## Key Features (v3.8.50)
### Core Proxy
- **341 AI providers** with automatic format translation
- **340 AI providers** with automatic format translation
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
- **4-tier fallback**: Subscription → API Key → Cheap → Free
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 149 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
@@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
## v3.8.x Highlights
- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
- **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay`
- **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown
- **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules)

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