Compare commits

..

147 Commits

Author SHA1 Message Date
Xiangzhe
9ced57bc08 refactor(sse): route executor lookup through ExecutorRegistry (R0.3)
Adds open-sse/executors/registry.ts (Map-based registry mirroring
translator/registry.ts): the built-in table in executors/index.ts stays
declarative, every entry is registered at module load, and
getExecutor()/hasSpecializedExecutor() resolve through the registry.
DefaultExecutor fallback, its memoization, and the cloud-agent (#6699) /
search-provider (#10274) guards are unchanged.

Also fixes a latent lookup leak: the old object-literal lookup treated
Object.prototype names (constructor, toString, ...) as specialized
executors; the Map registry resolves them to the DefaultExecutor
fallback like any unknown provider.

Parity proof: executor-map golden (137 entries, byte-identical
before/after), check:known-symbols green, 1018 tests across the 65
executor test files green. Docs: OPEN_SSE_ARCHITECTURE factory section
corrected (it claimed generation from providerRegistry).

Refs #3501
2026-08-18 00:36:21 -03:00
Xiangzhe
a459dfd221 test(sse): golden characterization of the executor map before the R0.3 registry refactor
Freezes the 137-entry provider-id → executor mapping (class, provider
identity, backing PROVIDERS config), the no-shared-instances invariant,
and the getExecutor() dispatch rules (memoized DefaultExecutor fallback,
cloud-agent guard #6699, search-provider guard #10274) as stable JSON
snapshots. The upcoming ExecutorRegistry must keep both snapshots
byte-identical.
2026-08-18 00:16:46 -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
adevwithpurpose
fb2585530d chore(release): sync v3.8.50 base quality docs 2026-08-17 11:52:26 -03:00
Diego Rodrigues de Sa e Souza
dc32732b2a fix(dashboard): media playground cards stop sending masked API key as Bearer (#10449)
The 9 media *ExampleCard components under media-providers/components used
the masked value from useApiKey() (sk-xxxx****yyyy) as an Authorization:
Bearer header, which the gateway always rejects (AUTH_002) once
REQUIRE_API_KEY is enabled. Mirror the LlmChatCard fix (#3503): authenticate
via the dashboard session (credentials: "same-origin") and forward the
selected key's id via x-omniroute-playground-key-id instead of its secret.
buildCurl now keeps the <your-api-key> placeholder instead of the masked
value.

Adds tests/unit/bug-9935-masked-bearer.test.ts as the permanent regression
guard (asserts none of the 9 cards embed apiKey as a raw Bearer token).

Refs #9935

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-17 11:12:25 -03:00
Ravi Tharuma
611466b419 fix(api): hash API keys in the v1 models catalog cache key
Validated in local merge-train-equivalent focused gate on release/v3.8.50 tip 9081b57146: catalog fingerprint regression + existing catalog-cache callers, 8 tests passed.
2026-08-17 09:50:52 -03:00
Diego Rodrigues de Sa e Souza
9081b57146 fix(release): align onnxruntime dependency contract test 2026-08-17 08:47:32 -03:00
Rouzbeh†
7f5275ed6b fix(usage): surface Gemini cachedContentTokenCount as cached_tokens (#10465)
* fix(usage): read Gemini usageMetadata out of the antigravity response envelope

Port decolua/9router#59d858b: antigravity/gemini-cli wrap non-streaming
payloads in { response: {...} }, so extractUsageFromResponse only saw the
top-level usageMetadata and every non-streaming antigravity request logged
zero usage (IN 0 | OUT 0) and zeroed usage-dashboard rows. Top-level
metadata keeps priority; OpenAI/Claude branches untouched.

* chore(changelog): fragment for #10430 antigravity usage envelope

* fix(usage): surface Gemini cachedContentTokenCount as cached_tokens

Review follow-up on #10430: the Gemini branch of extractUsageFromResponse
ignored cachedContentTokenCount, so non-streaming cache-hit tokens never
reached the cached_tokens field the OpenAI/Claude/Responses branches
already populate (and the streaming path surfaces at usageTracking.ts:684).

Adds cached_tokens: usageMetadata.cachedContentTokenCount || 0, updates
the three Gemini assertions (envelope fixture already carried
cachedContentTokenCount: 7), and adds a dedicated regression test.

* chore(changelog): fragment for #10465 Gemini cached_tokens surfacing

* 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.

---------

Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
2026-08-17 08:28:03 -03:00
Diego Rodrigues de Sa e Souza
6c50137eeb fix(combo): actionable recovery hint for the all_targets_skipped terminal reason (#9303) (#10510)
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-17 08:25:41 -03:00
Diego Rodrigues de Sa e Souza
8ee778fabb fix(backend): redact client IPs and account prefixes from default proxy logs (#10348) (#10507)
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-17 08:25:17 -03:00
Diego Rodrigues de Sa e Souza
db0b4a1955 fix(startup): read platform at runtime via os.platform() so Windows Tailscale branches survive bundle DCE (#10293) (#10500)
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-17 08:24:51 -03:00
Rouzbeh†
e3bca29bbc fix(docker): real image tags (bifrost/cliproxyapi) + complete OMNIROUTE_BASE_PATH runtime patcher (#10482)
* fix(docker): real image tags + complete OMNIROUTE_BASE_PATH runtime patcher

Three docker issues fixed:

1. Images that do not exist:
   - bifrost: ghcr.io/maximhq/bifrost:1.5.21 never existed (1.5.x tops at
     v1.5.16, all tags carry the v prefix) -> ghcr.io/maximhq/bifrost:v1.6.11
   - cliproxyapi: ghcr.io/router-for-me/* is not publicly pullable (403);
     the official prebuilt image is docker.io/eceasy/cli-proxy-api, where
     the pinned v6.9.7 exists -> docker.io/eceasy/cli-proxy-api:v6.9.7
   - Verified still-current: redis:8.6.5-alpine (already on Redis 8 since
     #9065; ioredis 5.10 is RESP2/3-compatible, no modules used) and
     qdrant:v1.12.4 -- both exist, unchanged.

2. OMNIROUTE_BASE_PATH ignored on prebuilt images (root cause):
   Next 16 (webpack and Turbopack) app-router renders SSR asset URLs from
   assetPrefix ALONE; basePath only affects routing. The runtime patcher
   (ensure-docker-base-path) rewrote basePath literals only, so a prebuilt
   root-path image patched to /omniroute served the page but every
   /_next/static shell reference stayed unprefixed (404 behind a subpath
   proxy), the RSC flight-payload chunk refs came from client-reference
   manifests baked with unprefixed paths, and the Turbopack client process
   shim ships an empty env object so the client never learns the subpath.
   Extended patch-standalone-base-path.mjs to also rewrite:
   - assetPrefix literals (mirrors the subpath for SSR asset URLs)
   - the NEXT_PUBLIC_OMNIROUTE_BASE_PATH env mirror in the inline config
   - the client process.env shim (.env={}) with the two basePath keys
   - every baked "/_next/static URL (manifests, media imports, .html pages)
   next.config.mjs now mirrors basePath into assetPrefix so REBUILT images
   bake prefixed assets too. E2E-verified on the published main-web image:
   HTML under /omniroute now has 16/16 prefixed JS srcs and 82/82 prefixed
   flight refs (was 13/9 + ~150 unprefixed), prefixed assets return 200.

* chore(changelog): fragment for #10482 (docker images + basepath patcher)

* chore(changelog): bullet-form fragment for #10482

* Merge branch 'release/v3.8.50' into fix/docker-compose-images-and-basepath

* 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.

---------

Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
2026-08-17 08:24:27 -03:00
Rouzbeh†
6ff2e7b2c2 fix(antigravity): heal empty-projectId accounts via retryable auto-onboarding (#10424)
* fix(antigravity): heal empty-projectId accounts via retryable auto-onboarding

Accounts with an empty Cloud Code projectId get a permanent 422 "Missing
Google projectId" when loadCodeAssist returns no project. The 3.8.50
bootstrap attempts to CREATE the project via onboardUser, but a single failed
attempt (transient network/upstream error) was memoized forever in
onboardAttemptedCache: every later request in the process skipped onboarding
and 422'd, even though a retry would succeed.

Replace the permanent per-token Set with a failure-backoff map: failed onboard
attempts are retried after a 5-minute backoff (bounded, self-healing), the
in-flight lock still dedupes concurrent calls, and success clears the failure
marker and memoizes the project as before. Accounts that CAN be onboarded now
heal automatically on a later request or token refresh — no user action.

Tests: the existing "does not retry" case is now framed as the backoff window;
a new case proves the account heals (retries onboarding and recovers the
project) once the backoff expires.

* chore(changelog): fragment for #10424 antigravity project autocreate

* feat(antigravity): BYOP fast-fail + manual GCP project-id override

Port decolua/9router#2934 + VansRouter 802a859:
- tryOnboardUser now returns a three-way status; a 200 onboardUser response
  WITHOUT cloudaicompanionProject means Google deprecated automatic project
  creation for standard-tier (personal) accounts (BYOP). Such accounts are
  cached permanently (no pointless ~18s re-onboard) and the executor fails
  fast with 403 GCP_PROJECT_REQUIRED + actionable 'enter your project id'
  message instead of the generic 422 or a delayed 429.
- Transient onboard failures keep the existing 5-min backoff heal.
- Manual project-id override: the EditConnectionModal now stamps
  providerSpecificData.isProjectIdManual when the operator enters a project
  id, and tokenRefresh skips auto-discovery for flagged accounts so the
  manual value is never overwritten.

* chore(changelog): cover BYOP fast-fail + manual override in #10424 fragment

* test(antigravity): expect fast 403 GCP_PROJECT_REQUIRED when loadCodeAssist finds no project (#10424)

Google now marks accounts without an onboarded project as BYOP (automatic
project creation deprecated for standard-tier accounts, #2934). The PR's
BYOP fast-fail path returns 403 gcp_project_required instead of the old
generic 422 missing_project_id; align the #2334 executor test with that
contract so CI unit-test shard 2/4 passes.

* fix(antigravity): persist isProjectIdManual, fix BYOP citation, dodge refresh-retry

Review follow-up on #10424:

1. EditConnectionModal: isProjectIdManual was set on
   updates.providerSpecificData right after the project-id field, then the
   OAuth path (Antigravity is always OAuth) rebuilt providerSpecificData from
   connection.providerSpecificData before the request went out, discarding the
   flag — tokenRefresh.ts was guarding a field never actually persisted. The
   flag now lands in the single surviving antigravity merge, with a jsdom
   regression test (modeled on edit-connection-modal-openai-store-toggle).

2. The '#2934' citation for the Google BYOP claim pointed at an unrelated
   closed issue. Swapped for the real tracking issue #8491 (empty Google
   projectId -> 422 class) across bootstrap/executor/test comments.

3. BYOP fast-fail now returns 422 instead of 403: chatCore's generic
   401/403 -> refresh-and-retry path was hitting Google's OAuth token
   endpoint on every request from an affected account (pointless — refreshing
   cannot create a GCP project), and 422 matches the sibling
   missing_project_id error the client already maps to an action-needed
   prompt.

Also: eslint-disable-next-line for the pre-existing
react-hooks/set-state-in-effect baseline noise in the modal (repo
convention, same pattern as 11 other dashboard files).

* chore(ci): drop unused eslint-disable in EditConnectionModal form hydration

The react-hooks/set-state-in-effect disable added in the previous commit is
unused under the repo's pinned eslint-plugin-react-hooks (7.0.1) — the rule
does not fire on this line at that version, so the unused directive tripped
the whole-repo 'No new ESLint warnings' gate (max-warnings 0). Verified with
the lockfile-pinned plugin: lint:json is clean (0 errors, 0 warnings).

* fix(build): bound and retry the opencode-plugin npm install in prepublish

The plugin's node_modules is gitignored, so every fresh CI checkout runs a
full npm install inside @omniroute/opencode-plugin during build:cli. npm's
unbounded fetch retries turn a stalled registry CDN connection (the recurring
onnxruntime-class ETIMEDOUT flake) into a 20-30 minute hang — the DAST
'Build CLI bundle' step has been cancelled at the 30m cap repeatedly.

- Bound npm fetch: --fetch-timeout 60s, 2 retries with capped backoff — a
  stalled connection now fails fast instead of hanging the job.
- Retry the install up to 3 times with a 10s pause between attempts, so
  transient CDN failures recover in-build.

Net effect: the step either completes (network OK) or fails quickly with a
clear error (network down) — it can no longer eat the whole job budget.

* ci(quality): use the npm-ci-retry action on every install step

Fast Quality Gates failed on the recurring onnxruntime-node postinstall
ETIMEDOUT (Microsoft CDN 150.171.x.x) - the same transient flake that has
hit Vitest and dast-smoke today. Only the Build job used the retry action;
the other five jobs (Docs, Fast Quality Gates, Vitest, Unit Tests,
changelog) still ran a bare install and die on any CDN hiccup. Use the
existing retry action (3 attempts, exponential backoff) on every install
step for consistency.

* Merge branch 'release/v3.8.50' into fix/antigravity-project-autocreate

* 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): widen modelsDevSync lastSync wait from 200ms default to 2000ms

The truthy-spellings loop asserted each enabled case completes its first
fetch within waitFor's 200ms default timeout, which trips under CI runner
load (observed on PR 10424 shard 2/4). Match the file's other lastSync
waits (2000ms) so the sync-completion assertion is load-tolerant.

---------

Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
2026-08-17 08:23:41 -03:00
blarovse
24ef1dc3d4 Sanitize test fixtures, add developer .env guidance, and add gitleaks… (#10411)
* Sanitize test fixtures, add developer .env guidance, and add gitleaks workflow

- Replace realistic-looking AWS keys and PEM fixtures in unit tests with synthetic placeholders to avoid false positives from secret scanners.
- Add docs/DEVELOPER-ENVIRONMENT.md describing postinstall .env behavior and remediation guidance.
- Add .github/workflows/gitleaks.yml to run gitleaks on pull requests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add gitleaks baseline and CI baseline support; update ignore and PR body\n\n- Copy gitleaks-local.json -> gitleaks-baseline.json\n- Add --baseline-path to workflow\n- Allowlist baseline in .gitleaks.toml\n- Ignore gitleaks-local.json\n- Add PR_BODY.md with scan summary\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore(security): fix gitleaks config, drop redundant baseline/CI, clean doc artifacts

- Fix the malformed .gitleaks.toml [[rules]] block: an inline [rules.allowlist]
  with only paths (no regex/path at rule level) made gitleaks refuse to load the
  config (`FTL Failed to load config ... both |regex| and |path| are empty`),
  turning the project's blocking check-secrets ratchet into a hard failure.
  Verified: check-secrets config now loads and exits 0.
- Reconcile with the existing gitleaks gate: remove the redundant
  .github/workflows/gitleaks.yml and root gitleaks-baseline.json (a second,
  differently-scoped scanning mechanism + an unreviewed 430-finding blanket
  baseline) — the project already runs scripts/check/check-secrets.mjs as a
  blocking ratchet in ci.yml/quality.yml and its .gitleaks.toml policy is to fix
  real findings, not blanket-allowlist them.
- Remove the stray PR_BODY.md automation artifact from the repo root.
- Fix the duplicated <div align="center"> tag in README.md.

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

---------

Co-authored-by: OmniRoute Bot <noreply@omniroute.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: blarovse <312250233+blarovse@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-17 08:23:10 -03:00
Ravi Tharuma
722748f7c1 fix(sse): keep Codex quota headers under the forwarding budget (#10306)
* 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(sse): keep Codex quota headers under the forwarding budget

The 768-byte cap plus priority-3 for any name that does not contain
"ratelimit" dropped x-codex-*-used-percent / reset / credits on every
stream. x-codex-turn-state (314 bytes) ate the budget. Raise the cap,
treat Codex quota headers as rate-limit priority, and do not forward
turn-state.

---------

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 <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-17 08:22:42 -03:00
Markus Hartung
0f402a84a4 feat(responses): virtualize previous_response_id continuation regardless of upstream support (#10262)
* 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).

* fix(db): re-export responsesContinuationStore from the localDb barrel

check-db-rules requires every db/ module to be re-exported (or explicitly
allowlisted as intentionally-internal) for discoverability. Missed this
when the module was first added.

* fix(db): renumber previous_response_id index migration to 154

The migration was numbered 153, but release/v3.8.50 already carries
153_radar_local_model_state.sql. The emngrating runner's collision guard
throws on two live .sql files sharing a numeric prefix, so the refreshed
merge would fail DB startup. Renumber to the next free slot (154).

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

* docs(db): sync migration count to 149 across llm.txt mirrors

The responses-continuation store adds one migration, so the docs'
migration count is now 149 (was 148). Update README/AGENTS/llm.txt and
regenerate the i18n llm.txt mirrors to keep check:docs-all green.

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

* fix(responses-continuation): respect preserve mode, drop dead export

- Un-export ResponsesContinuationState: it's never imported outside
  responsesContinuationStore.ts, its own defining file. Fixes the
  check:dead-code regression (410 > baseline 409).
- Scope the previous_response_id virtualization interception in chat.ts to
  skip entirely when responsesPreviousResponseIdMode=preserve. The
  interception ran unconditionally before target/connection selection,
  ahead of applyResponsesPreviousResponseIdPolicy (chatCore.ts) -- the
  existing per-target enforcement point for this setting -- so "preserve"
  (the explicit, connection-independent contract for "let the upstream
  resolve previous_response_id natively") was silently unreachable: the
  field was already deleted and replaced with locally-reconstructed input
  by the time that policy ran. This also broke Codex's own executor, which
  relies on an untouched previous_response_id to delegate history
  resolution upstream (see stripOrphanedCodexFunctionCallOutputs in
  codex.ts). "auto" and "strip" modes are unaffected -- virtualization is
  a strict improvement over their old "drop the field, hope the client
  resent everything" behavior.
- Add a regression test exercising the actual chat.ts handler (not just
  the policy helper in isolation): confirms mode=preserve now proceeds to
  normal routing instead of the virtualization's previous_response_not_found
  rejection, and that default/auto mode's existing virtualization behavior
  is unchanged. Verified the test fails for the right reason against
  pre-fix chat.ts.

Addresses PR review feedback.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: hartmark <hartmark@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-08-17 08:22:17 -03:00
Benson K B
14afdcb923 fix(routing): fallback to default model alias seeds when unmapped in database (#10124)
* fix(routing): fallback to default model alias seeds when unmapped in database

* fix(routing): rename seed-fallback resolver; hermetic 401 regression test

Maintainer review (PR #10124):
1. Rename resolveModelAlias -> resolveModelAliasWithSeedFallback (and
   resolveModelAliasOnBody -> resolveModelAliasWithSeedFallbackOnBody) to
   avoid the export collision with the sync resolveModelAlias in
   open-sse/services/modelDeprecation.ts and
   src/shared/constants/modelSpecs.ts.
2. Regression test now reproduces the 401: alias unmapped in the (empty,
   DATA_DIR-isolated) modelAliases namespace but present in the static seed
   resolves to the seed target instead of passing through unmapped.
3. Test isolates DATA_DIR (temp dir + resetDbInstance) instead of reading
   the operator's live DB.

* fix(models): add outputTokenLimit to CustomModelEntry

Fixes the open-sse typecheck gate regression: catalog.ts reads
model.outputTokenLimit (for max_output_tokens in custom model metadata)
but CustomModelEntry only declared inputTokenLimit — TS2551. The
field exists in the runtime model data and is already consumed; the
interface just never declared it.
2026-08-17 08:21:53 -03:00
Nick Sullivan
b6d2b4a41c Compression telemetry retention has never deleted a row (same unit bug as #9625) (#10559)
* fix(db): align compression_run_telemetry cleanup cutoff with millisecond column

cleanupCompressionRunTelemetry() computed its cutoff in epoch seconds while
insertCompressionRunTelemetryRow() stamps the timestamp column with Date.now()
(epoch milliseconds). A millisecond timestamp is ~1000x larger than a seconds
cutoff, so DELETE WHERE timestamp < cutoff never matched an old row and the
retention sweep added by #6848 to bound storage.sqlite growth was inert.

This is the same defect as domain_cost_history (#9625), whose fix corrected
cleanupDomainCostHistory() ~90 lines earlier in this file and missed this
sibling call site. The stale docstring asserting a unix-epoch column is
corrected too.

The repro test seeds through the real writer to establish the stored unit, so
it also fails if the producer format diverges from the consumer again.

* docs(changelog): add fragment for the telemetry retention unit fix
2026-08-17 08:10:13 -03:00
Nick Sullivan
a87c9236ff Database settings page returns HTTP 500 when SQLite lacks the optional dbstat table (#10558)
* fix(db): tolerate a SQLite build without the dbstat virtual table

getDatabaseStats() queried `dbstat` once per table with no guard. `dbstat` is
compile-time optional (ENABLE_DBSTAT_VTAB) and is absent from sql.js/WASM
builds, so on those runtimes the query throws and the error propagates out of
getDatabaseStats().

Every caller dies with it. Most visibly, GET and PATCH /api/settings/database
return HTTP 500, which makes the entire database settings page unusable — users
cannot read or change page size, cache size, or vacuum settings.

The function already anticipated missing virtual-table modules: the COUNT(*)
lookup a few lines above swallows "no such module:" errors. The dbstat query
simply sat outside that guard.

Probe dbstat once per call and skip the per-table size lookups when it is
unavailable, reporting size 0. Database-level figures (total size, page count,
cache size) come from pragmas and stay accurate; only per-table byte sizes are
lost, which is the correct trade against a hard 500.

Unrelated failures (I/O errors, corruption) still propagate.

Both spellings are handled: sql.js reports "no such module: dbstat" while
better-sqlite3 can surface "no such table: dbstat".

* test(db): cover prefixed driver errors and dbstat edge cases

Review follow-up on the previous commit.

The guard is deliberately unanchored because real drivers stringify errors
with their class name attached ("SqliteError: no such table: dbstat",
"RuntimeError: ..."). Nothing pinned that, so anchoring the regex would have
passed the suite while silently breaking every real driver. Add a case for the
prefixed form; it fails if a caret is introduced.

Also cover three shapes the fake previously could not express:
- a database with no user tables, which is what a fresh install hits first
- SUM(pgsize) returning NULL for a table occupying no pages
- dbstat answering the probe but failing on a later table, which documents
  that a mid-iteration fault still propagates rather than being mistaken for
  an absent module

Correct the source comment: the two error spellings track the SQLite build,
not the driver package, so the earlier attribution to better-sqlite3 was
wrong.

* docs(changelog): add fragment for the dbstat availability guard

Registers the new test with Stryker alongside the sibling db suites and adds
the changelog fragment for this fix.

---------

Co-authored-by: Nick Sullivan <nick@technick.ai>
2026-08-17 08:09:47 -03:00
Yahoo
2098ba3848 fix(cli): ignore non-Windows HOSTNAME when binding server (#10557)
* fix(cli): ignore non-Windows HOSTNAME when binding server

* chore(changelog): assign PR number
2026-08-17 08:09:19 -03:00
Rizx
b8b78f7a69 fix(providers): remove invalid CodeBuddy CN glm-4.7 and add hy3 (0.0x… (#10356)
* 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(providers): remove invalid CodeBuddy CN glm-4.7 and add hy3 (0.0x credit)

Swap the GLM-4.7 model for the Hunyuan hy3 model in the codebuddy-cn
registry, keep the catalog at 15 models, and update the matching provider
test expectations. Regenerated the auto-generated provider reference doc.

---------

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-17 08:03:16 -03:00
Ravi Tharuma
fbc67f1338 fix(models): honor MODELS_DEV_SYNC_ENABLED=0 over dashboard settings (#10299)
* 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(models): honor MODELS_DEV_SYNC_ENABLED=0 over dashboard settings

The file header already advertised this env var but nothing read it.
When catalog/compression pin the event loop, the dashboard (same process)
cannot turn models.dev sync off. Let 0/false/off win over sqlite so an
operator can recover with env + restart. Skip getModelsDevPricing SQL
scans while the kill switch is set.

* fix(models): restore prettier formatting after base merge

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

* test(models): cover env kill switch during live settings updates

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 <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-17 08:02:47 -03:00
Gi99lin
b1a2ff6887 feat(proxy): non-destructive auto-disable mode for the proxy health scheduler (#10342)
* feat(proxy): add non-destructive auto-disable mode for the proxy health scheduler

PROXY_AUTO_REMOVE was the only opt-in action the background proxy health
scheduler could take on a consistently failing proxy, and it deletes the row.
For a manually-maintained proxy chain (multi-proxy pool/rotation, #6365) that
is too destructive just to exclude a temporarily-dead member.

Add PROXY_AUTO_DISABLE as a sibling flag: at the same consecutive-failure
threshold it soft-disables the proxy (status "dead") instead of removing it.
"dead" is already one of the statuses the pool/rotation alive-filter excludes,
so a disabled proxy drops out of the active chain immediately with no other
code changes. The scheduler keeps probing dead proxies on its normal interval,
and the existing recovery branch (previously autoRemove-only) re-activates it
automatically once it starts answering again.

decision.ts's decideProxyHealthAction() gets an optional `autoDisable` input
(defaults to false, so existing callers are unaffected) and a "dead" status
value; scheduler.ts wires the new PROXY_AUTO_DISABLE env flag through. If both
flags are set, auto-remove wins. getProxyHealthStats() now also surfaces the
registry `status` so operators can see when a proxy was auto-disabled, and
ProxyStatusBadge now treats the full "not alive" status set (not just the
literal string "inactive") as inactive in the dashboard.

* test(proxy): assert registry status in getProxyHealthStats output

The non-destructive auto-disable change added the live registry status to the
stats object returned by getProxyHealthStats. Align the pre-existing
db-proxies-crud assertion with the intended output shape.

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

* fix(proxy): preserve auto-disabled status in dashboard edits

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

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: Gi99lin <Gi99lin@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-17 08:02:16 -03:00
stanley
4540d303d7 fix(oauth): send required CLI headers in claude-auth import bootstrap call (#10144)
* fix(oauth): send required CLI headers in claude-auth import bootstrap call

enrichWithBootstrap() in claudeAuthImport.ts was missing the
User-Agent and anthropic-beta headers that the two other callers of
the same /api/claude_cli/bootstrap endpoint (claudeIdentity.ts and
src/lib/oauth/providers/claude.ts) always send. Without them,
Anthropic doesn't recognize the request as coming from a CLI client
and the bootstrap call fails, silently returning a null identity
(accountUUID/organizationUUID/organizationType all null).

createConnectionFromAuthFile()'s identity-verification refusal then
gets bypassed via overwriteExisting: true (the only way imports
currently succeed, since first attempts fail with
identity_unverified because of this same bug), so every imported
Claude connection ends up with unverified identity.

Downstream, resolveAccountUUID() in claudeIdentity.ts falls back to
a hash-derived fake UUID when providerSpecificData.accountUUID is
null. That fake UUID is shape-valid but was never associated with
the real account by Anthropic, so requests carrying it get
classified as unrecognized third-party traffic and routed to the
separate extra-usage pool instead of the account's plan limits --
producing an intermittent (~50% observed) 400:
"Third-party apps now draw from your extra usage, not your plan
limits." on an otherwise perfectly valid, imported subscription
token.

Fixes the header mismatch so bootstrap succeeds and imported
connections get a real, Anthropic-recognized account identity from
the start, same as connections created via the native OAuth flow.

Fixes #10143

* fix(oauth): persist cliUserID device identity on claude-auth import

createConnectionFromAuthFile() in claudeAuthImport.ts never set
providerSpecificData.cliUserID, unlike the native OAuth setup flow in
src/lib/oauth/providers/claude.ts which always mints one. cliUserID is
read by resolveCliUserID() (open-sse/executors/claudeIdentity.ts) as
the request's device_id; when absent it falls back to a lazy-random
device id regenerated fresh every process restart (in-memory Map,
process-lifetime only), so every restart of an imported connection
presents as a brand-new device to Anthropic for the same account --
a second, independent contributor (alongside Part 1's bootstrap
header fix in this same PR) to the intermittent third-party-usage 400
on valid imported subscription tokens.

- "create new connection" branch: always mint a fresh cliUserID.
- "update existing connection" branch: preserve any already-persisted
  cliUserID from existing.providerSpecificData (don't rotate a working
  device identity on re-import); only mint a fresh one if absent.

Adds changelog.d/fixes/10144-claude-import-cli-user-id.md per
CONTRIBUTING.md.

Fixes #10143

* test(oauth): cover claude-auth import bootstrap headers + cliUserID persistence

Adds tests/unit/claudeAuthImport-bootstrap-headers-10144.test.ts (Rule #18
regression guard for #10143):

1. enrichWithBootstrap() sends the required CLI headers on the
   /api/claude_cli/bootstrap call — a claude-cli User-Agent (now sourced
   from CLAUDE_CODE_CLIENT_VERSION, matching the two working call-sites)
   and anthropic-beta: oauth-2025-04-20 — and still falls back to null
   identity fields on non-OK upstream responses.
2. createConnectionFromAuthFile() mints a 64-hex cliUserID device
   identity on create, preserves an already-persisted cliUserID on
   overwrite re-import (no rotation), and mints a fresh one when the
   existing connection has none.

Also aligns the hardcoded claude-cli/1.0.0 User-Agent in the import
bootstrap with the version constant the two working call-sites
(claudeIdentity.ts, oauth/providers/claude.ts) already use.

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

* refactor(oauth): source claude-auth import UA from canonical constant (#10144 review nit)

Addresses the hardcoded-version nit from review: the bootstrap User-Agent was
re-typed as `claude-cli/${CLAUDE_CODE_CLIENT_VERSION}` instead of importing
getClaudeCodeUserAgent() — the single source of truth the two working
call-sites (claudeIdentity.ts, oauth/providers/claude.ts) use.

- claudeAuthImport.ts: use getClaudeCodeUserAgent("cli") for the bootstrap call
- test: import the same canonical helper instead of a local copy of the pinned
  version, and assert the outbound UA byte-for-byte against it, so a future
  version bump can't silently desync the wire identity.

Verified: node --import tsx/esm --test on the new test file -> 5/5 pass;
sibling claudeAuthImport.test.ts -> pass; eslint on both changed files ->
no new findings (only the pre-existing @/lib/localDb barrel-import restriction
on an untouched import line).

* test(oauth): exercise claude auth import implementation

Replace copied helper tests with real implementation coverage for bootstrap headers and persistent cliUserID behavior.

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: stanleytejakusuma <stanleytejakusuma@users.noreply.github.com>
2026-08-17 08:01:45 -03:00
Dave Cox
dcfbc24625 fix(deps): pin onnxruntime-node to the exact version @huggingface/transformers requires (#10543)
`@huggingface/transformers` 4.2.0 hard-pins `onnxruntime-node` to "1.24.3".
The production-group bump in #10403 raised the root range from "~1.24.3" to
"~1.27.0", so npm stopped deduping and nested a second copy under
`node_modules/@huggingface/transformers/node_modules/onnxruntime-node`.

Both copies ship a native `libonnxruntime.so.1` under the SAME SONAME, so
glibc binds whichever is dlopen()ed first and the other addon dies. The
Dockerfile post-build verification imports `@huggingface/transformers` and
`onnxruntime-node` in one process, so `docker build` has failed on every
commit since #10403:

  Error: .../transformers/node_modules/onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime.so.1:
  version `VERS_1.27.0' not found (required by .../onnxruntime-node/bin/napi-v6/linux/x64/onnxruntime_binding.node)

Restore the root range to "~1.24.3" so a single hoisted copy is resolved
again. Copying the nested native binaries into the standalone bundle is NOT
a workaround: it makes both `.so` files present, which is precisely what
triggers the SONAME clash above (verified against a real image build).

Regression guard: tests/unit/onnxruntime-single-copy.test.ts asserts the
lockfile resolves exactly one onnxruntime-node and that it matches the
version transformers pins. Confirmed failing on the pre-fix lockfile
(two copies, 1.27.0 vs 1.24.3) and passing after.

Validated with a full `docker build --target runner-base`: the post-build
verification step now passes (#19 DONE 156.9s) and the image boots healthy
(/api/monitoring/health 200, migrations 134-148 applied).
2026-08-17 07:59:52 -03:00
Diego Rodrigues de Sa e Souza
8dec2ad472 fix(resilience): mark embed connection terminal on hard upstream failure so dead accounts are not re-hit (#10347) (#10506)
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-17 07:06:00 -03:00
Diego Rodrigues de Sa e Souza
31b02ff85f fix(responses): keep stream-aware TextDecoder across SSE transform chunks (#10223) (#10495)
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-17 07:05:23 -03:00
Ravi Tharuma
48e5cf7fe4 fix(sse): do not ZWJ-obfuscate the substring hermes in user text (#10488)
Keep the #8350 Hermes system-prompt drops, but remove hermes from the
factory obfuscate_words list so hostnames and CLI mentions stay intact.

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
2026-08-17 07:04:48 -03:00
Chewji
8bd0b840f6 fix(antigravity): unblock Gemini and Claude reasoning capabilities (#10376)
* fix(antigravity): unblock Gemini and Claude reasoning capabilities

* fix(antigravity): align two unit tests with unblocked Gemini/Claude reasoning

The PR unblocks Antigravity Gemini/Claude reasoning (removed from
REASONING_UNSUPPORTED_PATTERNS, mirroring model-capabilities-registry.test.ts).
models-catalog-combo-metadata and services-branch-hardening still asserted the
pre-PR deny contract; align them to the new verified behavior. No production
code changed.

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

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: Chewji9875 <Chewji9875@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-08-17 07:04:07 -03:00
Benson K B
3e8a8f71cc fix(providers): add PATCH handler to provider connection route (CLI rotate 405) (#10366)
* fix(providers): add PATCH handler to provider connection route

The OpenAPI spec and the CLI (omniroute providers rotate, generated
api-commands) both use PATCH /api/providers/[id], but the route only
implemented PUT — PATCH requests returned 405 and key rotation via the
CLI silently failed while reporting success (the DB-write fallback only
catches thrown exceptions, not non-OK HTTP responses).

Add a PATCH handler delegating to the PUT handler: both apply the same
partial-update schema, so the semantics are identical.

Regression test proves the PATCH export exists and delegates into the
shared auth path; verified to fail without the fix.

* docs(changelog): note PATCH provider route fix (PR #10366)

* fix(providers): make PATCH delegation test environment-robust

The 'PATCH delegates to PUT' assertion hardcoded a 401, which only holds
when management auth is enforced (dev). In the CI unit-test env auth is not
required, so the flow falls through to 'Connection not found' (404) for an
unknown id — the test failed on the status code while the PATCH->PUT
delegation itself is correct. Assert on delegation equivalence instead:
PATCH must never 405 (the regression) and must return the same status as
PUT for the same input.

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

* test(providers): use fresh Request per handler in PATCH delegation test

The same Request was passed to both PATCH and PUT — PUT consumes the
body via request.json(), so the second call got an empty body (400
validation) vs the first (404 not-found): a false status mismatch on
bases where management auth is bypassed in the test env (release
v3.8.50). Fresh Request per invocation makes identical inputs produce
identical statuses.

---------

Co-authored-by: benzntech <benzntech@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-08-17 07:02:48 -03:00
Sahil Singh
8ff3a1dda3 fix(mcp): dynamically generate web search provider enum from registry (#10209)
* fix(mcp): dynamically generate web search provider enum from registry

* test(mcp): add contract test for dynamic web search provider enum

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

* fix(mcp): restore search.ts eslint suppression, type builder maps, fix firecrawl searchType arg

The enum-dynamic refactor dropped search.ts's no-explicit-any suppression
while a new Record<string,any> map re-introduced anys, and the response
normalizer map swapped the firecrawl searchType argument with query. Type
both maps explicitly, restore the base suppression (33 pre-existing anys),
and pass searchType (not query) to normalizeFirecrawlSearchResponse.

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: sadSanta-07 <sadSanta-07@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-08-17 07:01:46 -03:00
Xiangzhe
faeca3bbac fix(providers): scope model target formats to providers (#10072)
Co-authored-by: xz-dev <xz-dev@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-17 07:01:09 -03:00
Xiangzhe
b082d0735b fix(api-manager): allow empty combo restrictions (#10066)
* fix(api-manager): allow empty combo restrictions

Represent unrestricted Combo access explicitly as combo/* so an empty Allowed Combos list can deny every Combo without affecting direct model routes. Preserve existing keys through migration 149 and cover Dashboard, policy, routing-target, and migration behavior.

* docs: sync migration count to 149 after api-key combo-access migration

Merging release/v3.8.50 forward landed 149_api_key_combo_access.sql,
bumping the real migration count from 148 to 149. Updates README.md,
AGENTS.md, llm.txt (root + all 42 i18n mirrors, exact-copy requirement)
so the strict docs-counts-sync gate matches the live count again.

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

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: xz-dev <xz-dev@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-08-17 07:00:05 -03:00
Aman
2723698fe2 fix(providers): update token-backed web sessions (#10518) 2026-08-17 05:50:21 -03:00
Diego Rodrigues de Sa e Souza
bdc30ca4dd fix(providers): strip uniqueItems from Gemini tool schemas to avoid upstream 400 (#9617) (#10511)
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-17 05:50:09 -03:00
Diego Rodrigues de Sa e Souza
9bfdc15cbc fix(providers): emit Cursor kv_after_text before tool calls instead of truncating them (#10215) (#10502)
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-17 05:49:58 -03:00
Bob.Hou
33e0fea8b0 fix(sse): flag OpenAI streams that close with content but no terminal marker (#10475)
Issue #10443: when the upstream kills an SSE stream mid-generation
(antigravity/Gemini does this under its own rate enforcement), OmniRoute
closed the stream silently for OpenAI-format clients - HTTP 200, a few
content chunks, no finish_reason. The client sees a truncated turn.

resolveSilentCloseReason() only flagged that shape for Claude clients
(#7699). Extend it to OpenAI chat completions guarded on sawContent(),
and teach hasClientTerminalSseMarker() that a non-null finish_reason
chunk is a terminal marker (some providers omit data: [DONE]). Every
known OpenAI-producing path ends with one of the two, so content
forwarded without either is an upstream drop and now surfaces the
in-band 502 error chunk + [DONE] instead of a silent close.

TDD: tests/unit/silent-sse-close-openai-10443.test.ts - core case RED
before / GREEN after, plus guard cases for finish_reason-only close,
[DONE] close, empty-content (#8649 verdict preserved), and literal
finish_reason text inside model content (JSON escaping keeps the raw
bytes from matching the unescaped-field regex).

Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-08-17 05:49:46 -03:00
Diego Rodrigues de Sa e Souza
b17dfa4a14 fix(sse): mark gemini-3.5-flash as thinking-capable (#10450)
The base gemini-3.5-flash entry spread the shared GEMINI_35_FLASH_MODEL_SPEC
constant, which has supportsThinking:false because it is also spread into
several Antigravity flash-tier aliases that reject client-supplied thinking
params. That made the reasoning-routing policy resolve reasoning_effort as
"unsupported" for the base Google AI Studio model, producing a spurious
pre-provider HTTP 400 even though the model supports reasoning (it has an
effort-tier alias gemini-3.5-flash-high).

Set supportsThinking:true as an explicit override on the base
gemini-3.5-flash entry only, leaving the shared spec and the Antigravity
tier aliases unchanged.

Closes #10286

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-17 05:49:16 -03:00
Diego Rodrigues de Sa e Souza
5ca747f6a5 fix(sse): exclude search providers from credential-health scheduler sweep (#10435)
* fix(sse): exclude search providers from credential-health scheduler sweep

The credential-health scheduler's sweep() tested every active connection
every 5 minutes with no exclusion for search providers. For providers in
SEARCH_VALIDATOR_CONFIGS (tavily-search, exa-search, serper-search,
brave-search, google-pse-search, linkup-search, searchapi-search,
youcom-search), "validation" fires a real billed upstream query
(e.g. POST api.tavily.com/search), so the periodic sweep silently burned
quota with no user-initiated search.

Exclude connections whose provider id is registered in
SEARCH_VALIDATOR_CONFIGS from the sweep's connection-selection filter.
Non-search API-key/OAuth connections remain monitored (#9180, #9289
regressions verified green).

Closes #9970

* fix(docs): drop backticks around SEARCH_VALIDATOR_CONFIGS in ENVIRONMENT.md

The env/docs sync gate (check-env-doc-sync.mjs) treats any backtick-wrapped
SHOUTY_NAME as an env var reference. SEARCH_VALIDATOR_CONFIGS is a code
export, not an env var, so wrapping it in backticks made the #9970 doc note
trip the env/docs contract check (docMissingEnv). Drop the backticks so the
gate stops classifying it as an undocumented env var.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-17 05:48:48 -03:00
adevwithpurpose
be364d2c70 fix(release): align agent skills catalog tests
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-17 05:18:57 -03:00
adevwithpurpose
810c6b9843 fix(release): clear v3.8.50 base quality reds 2026-08-17 04:58:39 -03:00
Diego Rodrigues de Sa e Souza
e646fe84c7 feat(dashboard): VS Code Copilot Chat home banner, remove Provider Quota home card (#10520)
* feat(dashboard): add VS Code Copilot Chat home banner, remove Provider Quota home card

Announce the OmniCopilot extension right below the Kimi sponsor banner on the
dashboard home page (same size/shape, dismissible, no version gate). Also
removes the "pin Provider Quota to home" card and its now-dead settings
toggle — the widget itself, its auto-refresh setting (shared with the
standalone /dashboard/quota page), and its component tests are untouched.

* fix(dashboard): remove now-dead homeWidgets.ts (dead-code gate)

Deleting the AppearanceTab pin-to-home toggle left this file's sole export,
PIN_PROVIDER_QUOTA_TO_HOME_KEY, with zero remaining consumers, which regressed
the dead-code ratchet from 415 to 416. Removing the file restores the exact
baseline count (415).

---------

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-16 03:29:37 -03:00
backryun
c6c134300b perf(electron): ship optional ML/browser deps as installable packs (#10382)
Stage 7 of issue #10321 moves the optional ML and browser automation dependency closures out of the desktop bundle into checksummed, versioned packs installed on demand through the omniroute packs command.

- scripts/build/optionalPackStaging.mjs stages pack members under .build/optional-packs, creates release tarballs, and emits optional-packs.index.json with per-member SHA-256 checksums.
- scripts/packs provides manifest, install, remove, and verification helpers plus the packs CLI commands.
- Runtime lookup includes installed pack node_modules directories, while LLMLingua and browser executors continue to degrade gracefully when packs are absent.

The measured darwin-arm64 staging closure was about 534 MB of the 929 MB standalone node_modules tree (57%).
2026-08-16 02:20:59 -03:00
backryun
2162289f0a perf(electron): verify better-sqlite3 v13 Node-API prebuilds instead of source rebuild (#10367)
better-sqlite3 v13 ships Node-API prebuilds for every packaged platform
(darwin/linux/linuxmusl/win32 x x64/arm64) inside the npm tarball, so the
Electron-ABI node-gyp source rebuild in prepare-electron-standalone.mjs is
obsolete. Replace it with a fail-fast prebuild verification that mirrors
better-sqlite3 lib/binding.js selection, and strip build/deps/src so the
packaged loader can only resolve the prebuild.

Verified locally on darwin-arm64: the same darwin-arm64.node prebuild loads
under both Node 24 (NODE_MODULE_VERSION 137) and Electron 43.3.0 under
ELECTRON_RUN_AS_NODE (148); DB create/migrate/read/write/close/reopen pass
in both runtimes and cross-runtime on each other's database files.

Issue #10321 Stage 6.
2026-08-16 02:20:53 -03:00
Brandon Bennett
6d9336088c fix(chat-body-admission): process-wide budget (#10110) (#10322)
* fix(chat-body-admission): process-wide budget (#10110)

Remove per-session admission lanes that multiplied the documented
"in one process" heavy/bytes bound by up to 64. All requests now admit
against ONE process-global ChatAdmissionController so the bound holds
against fake-credential sharding.

Per-request session identity survives only as a fairness scheduling key:
waiters are grouped per key and served round-robin (#9654) against the
shared budget — one connection's burst cannot starve others.

- src/shared/middleware/chatBodyAdmission.ts: delete lane map + LRU/TTL
  eviction; ChatAdmissionController is now the global budget with per-key
  FIFO queues + round-robin dispatchFair(). PerConnectionAdmissionController
  returns the same shared controller for every session. resolveSessionId
  stays as a scheduling key with honest re-scoping docs. snapshot() emits
  process-wide aggregates.
- tests/unit/chat-body-admission-aggregate-10110.test.ts: new U6 suite — 6
  deterministic tests (LRU-no-mint, TTL-no-mint, shared byte budget,
  16 MiB config, same-session recreation, round-robin fairness). RED on
  release/v3.8.50, GREEN post-fix.
- tests/unit/per-connection-admission-9654.test.ts: rewrite the tests that
  encoded the defect (per-session isolation) to assert the global-budget
  contract.
- docs/reference/ENVIRONMENT.md: OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES
  documented as process-wide; VIRTUAL_TTL_MS/VIRTUAL_MAX_SESSIONS deprecated.

* docs(changelog): add #10322 fragment for process-wide admission budget

* ci: retrigger checks after transient npm ci network failure in shard 3/4 (ETIMEDOUT)

---------

Co-authored-by: Brandon Bennett <brandonbennett@macbookair.myfiosgateway.com>
2026-08-16 00:46:13 -03:00
Jan Leon
e5e1358693 fix(antigravity): discover live chat models dynamically (#10422)
* fix(antigravity): discover Gemini 3.7 Flash models

* fix(antigravity): discover live chat models dynamically

* fix(antigravity): keep provider limits sanitizer strict
2026-08-16 00:42:59 -03:00
dependabot[bot]
8bd0e7b6bf deps: bump the production group across 1 directory with 21 updates (#10403)
Bumps the production group with 20 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1096.0` | `3.1107.0` |
| [@toon-format/toon](https://github.com/toon-format/toon) | `4.1.0` | `4.1.1` |
| [axios](https://github.com/axios/axios) | `1.18.1` | `1.19.0` |
| [cron-parser](https://github.com/harrisiirak/cron-parser) | `5.7.0` | `5.8.1` |
| [csv-stringify](https://github.com/adaltas/node-csv/tree/HEAD/packages/csv-stringify) | `6.8.1` | `6.8.3` |
| [fumadocs-core](https://github.com/fuma-nama/fumadocs) | `16.13.0` | `16.14.3` |
| [fumadocs-ui](https://github.com/fuma-nama/fumadocs) | `16.13.0` | `16.14.3` |
| [jose](https://github.com/panva/jose) | `6.2.4` | `6.2.8` |
| [js-yaml](https://github.com/nodeca/js-yaml) | `5.2.2` | `5.2.3` |
| [marked](https://github.com/markedjs/marked) | `18.0.7` | `18.0.9` |
| [material-symbols](https://github.com/marella/material-symbols/tree/HEAD/material-symbols) | `0.45.9` | `0.45.10` |
| [next](https://github.com/vercel/next.js) | `16.2.12` | `16.3.0` |
| [next-intl](https://github.com/amannn/next-intl) | `4.13.4` | `4.13.6` |
| [playwright](https://github.com/microsoft/playwright) | `1.61.1` | `1.62.1` |
| [smol-toml](https://github.com/squirrelchat/smol-toml) | `1.7.1` | `1.7.2` |
| [tsx](https://github.com/privatenumber/tsx) | `4.23.1` | `4.23.12` |
| [turndown](https://github.com/mixmark-io/turndown) | `7.2.0` | `7.2.4` |
| [ws](https://github.com/websockets/ws) | `8.21.1` | `8.21.3` |
| [onnxruntime-node](https://github.com/Microsoft/onnxruntime) | `1.24.3` | `1.27.0` |
| [wreq-js](https://github.com/sqdshguy/wreq-js) | `2.3.1` | `3.0.0` |



Updates `@aws-sdk/client-bedrock-runtime` from 3.1096.0 to 3.1107.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.1107.0/clients/client-bedrock-runtime)

Updates `@toon-format/toon` from 4.1.0 to 4.1.1
- [Release notes](https://github.com/toon-format/toon/releases)
- [Commits](https://github.com/toon-format/toon/compare/v4.1.0...v4.1.1)

Updates `axios` from 1.18.1 to 1.19.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.18.1...v1.19.0)

Updates `cron-parser` from 5.7.0 to 5.8.1
- [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.7.0...v5.8.1)

Updates `csv-stringify` from 6.8.1 to 6.8.3
- [Changelog](https://github.com/adaltas/node-csv/blob/master/packages/csv-stringify/CHANGELOG.md)
- [Commits](https://github.com/adaltas/node-csv/commits/csv-stringify@6.8.3/packages/csv-stringify)

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

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

Updates `jose` from 6.2.4 to 6.2.8
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.4...v6.2.8)

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

Updates `lucide-react` from 1.27.0 to 1.31.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.31.0/packages/lucide-react)

Updates `marked` from 18.0.7 to 18.0.9
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.7...v18.0.9)

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

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

Updates `next-intl` from 4.13.4 to 4.13.6
- [Release notes](https://github.com/amannn/next-intl/releases)
- [Changelog](https://github.com/amannn/next-intl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amannn/next-intl/compare/v4.13.4...v4.13.6)

Updates `playwright` from 1.61.1 to 1.62.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.61.1...v1.62.1)

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

Updates `tsx` from 4.23.1 to 4.23.12
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.1...v4.23.12)

Updates `turndown` from 7.2.0 to 7.2.4
- [Release notes](https://github.com/mixmark-io/turndown/releases)
- [Commits](https://github.com/mixmark-io/turndown/compare/v7.2.0...v7.2.4)

Updates `ws` from 8.21.1 to 8.21.3
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.21.1...8.21.3)

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)

Updates `wreq-js` from 2.3.1 to 3.0.0
- [Release notes](https://github.com/sqdshguy/wreq-js/releases)
- [Commits](https://github.com/sqdshguy/wreq-js/compare/v2.3.1...v3.0.0)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-bedrock-runtime"
  dependency-version: 3.1107.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@toon-format/toon"
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: axios
  dependency-version: 1.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: cron-parser
  dependency-version: 5.8.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: csv-stringify
  dependency-version: 6.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: fumadocs-core
  dependency-version: 16.14.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: fumadocs-ui
  dependency-version: 16.14.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: jose
  dependency-version: 6.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: js-yaml
  dependency-version: 5.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: lucide-react
  dependency-version: 1.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: marked
  dependency-version: 18.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: material-symbols
  dependency-version: 0.45.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: next
  dependency-version: 16.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: next-intl
  dependency-version: 4.13.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: playwright
  dependency-version: 1.62.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: smol-toml
  dependency-version: 1.7.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: tsx
  dependency-version: 4.23.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: turndown
  dependency-version: 7.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: ws
  dependency-version: 8.21.3
  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
- dependency-name: wreq-js
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
...

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-16 00:42:53 -03:00
backryun
6b85413b87 perf(electron): build the Next standalone once and hydrate natives per leg (#10321 stage 8) (#10390)
The desktop release matrix ran the full Next.js standalone build on all four legs (windows, macos-intel, macos-arm64, linux), duplicating the platform-neutral majority of that work four times and re-exposing every leg to the hosted-runner RAM class of failure that took the linux leg out of v3.8.49.

- scripts/build/standaloneTarball.mjs: deterministic, dependency-free tar.gz writer/reader (uid/gid/mtime pinned, sorted entries, symlink + exec-bit preservation; GNU-tar interop covered by tests).
- scripts/build/standaloneManifest.mjs: byte-level manifest of .build/next (sha256 + size + symlink target per entry, plus the archive's own digest) catching artifact-transfer corruption before extraction and re-verifying the restored tree byte-for-byte, smuggling included.
- scripts/build/standaloneBundle.mjs: pack / restore / hydrate CLI over the two modules above.
- scripts/build/hydrateNativeDeps.mjs: swaps install-machine-forked native optionals (@img/sharp-*, @ngrok/ngrok-*, fsevents) from the leg's own npm ci into the restored tree, then verifies the bundled-native closure (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime with its documented darwin-x64 exemption) services the leg's platform/arch before packaging starts.
- .github/workflows/electron-release.yml: new web-build job builds the standalone once on ubuntu with webpack and uploads the bundle; legs download, restore, and hydrate it, skipping the per-leg build. The legacy per-leg build remains as a rollback path via the ELECTRON_SHARED_STANDALONE workflow_dispatch input, and legs fail closed if web-build ran and failed.

Regression tests cover archive roundtrip, byte determinism, manifest tamper/smuggle detection, forked-native swaps, and native-closure serviceability.
2026-08-16 00:42:48 -03:00
Diego Rodrigues de Sa e Souza
e1739fc71d fix(security): sanitize test regex and annotate CodeQL hash false-positives (#10380)
* fix(security): sanitize test regex and annotate CodeQL hash false-positives

tests/unit/early-sse-route-intent.test.ts built a RegExp from a hardcoded
string but only escaped `?`/`.`, missing `\` — js/incomplete-sanitization
(#816). Not exploitable (fixed literal input) but the escaping was
genuinely incomplete; now escapes backslash too.

reasoningCache.ts::buildAssistantMessageCacheKey and codexIdentity.ts's two
UUID derivation helpers hash a cache-scope/account-seed with SHA-256 to
produce a lookup key / deterministic ID — not a stored, verified password.
CodeQL's js/insufficient-password-hash overfires on any hash of a
secret-like variable, the same false-positive class already annotated at
src/lib/db/apiKeys.ts:624. Added matching lgtm/nosemgrep annotations and
inline rationale so the intent is clear to reviewers and future scans.

Refs #815 #816 #817 #818

* fix(security): keep only the regex sanitization; drop non-functional CodeQL annotations

The lgtm[]/nosemgrep: comments in codexIdentity.ts and reasoningCache.ts use
formats GitHub Actions CodeQL does not honor, and shifting those sha256 lines
re-attributed the already-dismissed base alerts to this PR as two new CodeQL
findings. Revert those two annotation-only files to base so the existing
dismissals apply; retain the real fix (escaping backslash in the test regex),
which resolves the open js/incomplete-sanitization alert.

---------

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-16 00:42:42 -03:00
Ravi Tharuma
4c7b902257 fix(ops): Docker HEALTHCHECK probes /healthz not deep monitoring (#10307)
* 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(ops): Docker HEALTHCHECK probes /healthz not deep monitoring

/api/monitoring/health does a SQLite ping and more. When the event loop
is busy the official image HEALTHCHECK (5s timeout) marks the container
Unhealthy and orchestrators restart the only replica mid-session.

* fix(ops): keep healthcheck PR scoped to the /healthz probe

Drop the stray catalog ghost-model exclusion that leaked into this branch
from main (already covered upstream). Restore catalog.ts to the release
version so the PR contains only the Docker HEALTHCHECK /healthz fix, its
tests, and the changelog entry.

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 <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-16 00:42:36 -03:00
Ravi Tharuma
326d0e81cb docs(ops): k8s probe recommendations (TCP liveness, HTTP /healthz readiness) (#10297)
* docs(ops): recommend TCP liveness and HTTP /healthz readiness for k8s

Stock Docker HEALTHCHECK hits /api/monitoring/health (deep). Orchestrators
should not use that path for kubelet liveness. Document /healthz vs deep
health, note same-process event-loop limits, and link related issues.

* docs: add changelog fragment for #10297

---------

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-08-16 00:42:31 -03:00
Paco Cartones
d010a9979f fix(providers): repoint freeaiapikey to its live API host and resync its catalog (#10233)
* fix(providers): point freeaiapikey at the api. host it moved to

Every /v1 route on the freeaiapikey.com apex host answers HTTP 410 with
type "endpoint_moved", and the body names its own replacement:

  "This API endpoint has moved. Please update your base_url to
   https://api.freeaiapikey.com/v1 - the old endpoint on freeaiapikey.com
   no longer works."

Probed 2026-08-13 with paired controls so a network fault could not be
read as an upstream verdict:

  GET https://freeaiapikey.com/v1/models                -> 410
  GET https://freeaiapikey.com/v1/chat/completions      -> 410
  GET https://api.freeaiapikey.com/v1/models            -> 200
  GET https://api.freeaiapikey.com/v1/chat/completions  -> 405 (POST-only)
  GET https://api.openai.com/v1/models                  -> 401 (control: reachable)
  GET https://<nonexistent-domain>/v1/models            -> 000 (control: unreachable)

Every request through this provider therefore fails today. Repoint baseUrl
and modelsUrl at the host upstream names.

* fix(providers): resync the freeaiapikey catalog with its live model list

GET https://api.freeaiapikey.com/v1/models (200, probed 2026-08-13) serves 10
models. The registry declared 7, four of which upstream does not serve at all:
openai/gpt-5, openai/gpt-5.2-codex, Alibaba/qwen3.5, Alibaba/qwen3-vl:235b.
Seven live models were missing: openai/gpt-5.4, openai/gpt-5.5,
openai/gpt-5.6-sol, anthropic/claude-opus-4.7, anthropic/claude-opus-4.8,
anthropic/claude-sonnet-5, anthropic/claude-opus-5.

The four phantom ids are selectable in the dashboard and can only ever fail
upstream; the seven real ones are unreachable through the static catalog.

On context windows: the /v1/models response carries only id/object/created/
owned_by, so upstream publishes no window at all. The models added here
therefore declare no contextLength and inherit the entry's existing
defaultContextLength (128000) instead of a fabricated number. The two
pre-existing contextLength values are left untouched for the same reason -
this sweep neither confirms nor refutes them, and rewriting them would be
guesswork in the other direction.

* chore(changelog): name the fragment after the real PR number

* chore(changelog): substitute the PRNUM placeholder in the fragment body

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-16 00:42:25 -03:00
Chewji
be6f18b849 fix(account-fallback): classify 'insufficient credits' as credits-exhausted (#10116)
* fix(account-fallback): classify 'insufficient credits' as credits-exhausted

Command Code returns 400 'You have insufficient credits to make this
request...' when an account's billing credits run out. The phrase was
missing from CREDITS_EXHAUSTED_SIGNALS, so the error stayed unclassified
(errorType=null) and the connection was never marked credits_exhausted —
getProviderCredentials kept re-selecting the same dead account on every
request instead of rotating to a healthy one.

Add 'insufficient credits'/'insufficient credit' to the signal list
(already used by antigravity429Engine.ts) so the error classifies as
QUOTA_EXHAUSTED and the account is skipped on subsequent selections.

* fix(account-fallback): harden insufficient-credit matching and preserve chatanywhere

Add the common 'insufficient credit balance' variation to
CREDITS_EXHAUSTED_SIGNALS alongside the Command Code 'insufficient
credits'/'insufficient credit' signals, and restore the consolidated
ChatAnywhere gateway entry that the stale snapshot removal would have
deleted when merging into release/v3.8.50.

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

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-16 00:42:20 -03:00
SB Yoon
d46e8d72c9 feat(cli): refuse ephemeral container auto-config writes (#10057)
* feat(cli): refuse ephemeral container auto-config writes

Detect containerized OmniRoute and block CLI/API config writes into
throwaway homes unless a bind mount or explicit opt-in is present, and
honor compose host-profile CLI_CONFIG_HOME mounts outside the container home.

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

* chore(changelog): name fragment for #10057

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: yansigit <yansigit@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-08-16 00:42:14 -03:00
Alex
20fcb8d205 fix(affinity): evict the sticky session pin on a combo per-model timeout (#10016)
A combo target that stalls past comboTargetTimeoutMs is aborted by
buildTargetTimeoutRunner, which swallows the resulting rejection behind its
synthetic 524. Nothing marks the account unavailable — correctly, since a stall
is not a quota/auth failure — so the #6219 eviction on the generic
markAccountUnavailable -> shouldFallback path in chat.ts never ran. The session
pin therefore survived its full TTL and every following request in that session
was handed straight back to the account that had just stalled.

Seen in production on combo "coding" [priority]: one codex account pinned for a
30-minute TTL, four consecutive requests, four 120s timeouts, "all targets
exhausted" each time, while four sibling codex accounts stayed healthy and
unused.

Classify the abort reason (new dependency-free leaf comboAbortReasons.ts) and
evict the connection-matched pin. Only a genuine per-model timeout evicts: a
client disconnect or a hedge cancellation says nothing about account health, so
those keep the pin and its prompt-cache locality. Eviction is best-effort and
never breaks the dispatch path.

The dispatch itself moves into a new seam, chatDispatch.ts, which merges the
per-model abort signal into the outgoing request, runs executeChatWithBreaker,
and owns the eviction on both the rejection and failed-result paths. Keeping
that logic out of the frozen god-file leaves chat.ts one line SHORTER than
before (1844 -> 1843).

Co-authored-by: alexey.nazarov@softmg.ru <alexey.nazarov@softmg.ru>
Co-authored-by: fenix007 <fenix007@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-08-16 00:42:09 -03:00
SHANMUGAPRIYAN
579cae32b1 fix(sse): buffer '<think' partial so a split open tag cannot leak into content (#10441)
containsOrMayEndWithThinkOpenTag missed the 6-char partial '<think', so an
open tag arriving as '<think' + '>' across SSE deltas leaked into content
instead of being parsed as reasoning. Derive every proper prefix from
THINK_OPEN itself so the lookahead list can never drift out of sync with
the tag again. Covered by new unit tests for the partial-suffix lookahead
and the split-delta buffering path.
2026-08-16 00:16:36 -03:00
Rouzbeh†
df226e55f4 fix(usage): read Gemini usageMetadata out of the antigravity response envelope (#10430)
* fix(usage): read Gemini usageMetadata out of the antigravity response envelope

Port decolua/9router#59d858b: antigravity/gemini-cli wrap non-streaming
payloads in { response: {...} }, so extractUsageFromResponse only saw the
top-level usageMetadata and every non-streaming antigravity request logged
zero usage (IN 0 | OUT 0) and zeroed usage-dashboard rows. Top-level
metadata keeps priority; OpenAI/Claude branches untouched.

* chore(changelog): fragment for #10430 antigravity usage envelope

* ci: re-run dast-smoke (Build CLI bundle runner timeout flake)

---------

Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
2026-08-16 00:16:32 -03:00
Rouzbeh†
e44a409aa9 fix(antigravity): classify geo-blocked egress, exclude account, real connection probe (#10420)
* fix(antigravity): classify geo-blocked egress, exclude account, real connection probe

Google refuses the Cloud Code model API from unsupported egress locations
with 400 FAILED_PRECONDITION "User location is not supported for the API
use." Previously this surfaced as a cryptic "Antigravity upstream error
(400)", never excluded the account, and the dashboard connection test stayed
green because it only probed the (non-geo-restricted) OAuth userinfo endpoint.

- errorClassifier: new GEO_BLOCKED type + isGeoBlockedError detection
  (400/403 + location-not-supported wording); non-terminal classification.
- chatCore fallback: GEO_BLOCKED marks the connection and caches a 24h
  rate-limit-until exclusion so routing moves to other accounts instead of
  re-selecting the same one; never bans/expires the account.
- auth: GEO_BLOCKED joins the non-terminal group (no banned/expired state).
- antigravityUpstreamError: geo refusals carry an actionable message (egress
  location vs account problem, proxy-in-supported-region guidance).
- connection test: antigravity/agy now probe the REAL streamGenerateContent
  surface (buildProbe), so a green tick means the model path actually works
  and a geo-blocked egress shows red with a clear diagnosis.

* chore(changelog): fragment for #10420 antigravity geo-block resilience

* chore(pr): drop prettier-version drift noise, keep only real hunks

The earlier format pass (local prettier differs from the repo's pinned
version) rewrapped unrelated lines in chatCore.ts and the provider test
route. Restore the base formatting and re-apply only the GEO_BLOCKED
fallback branch and the buildProbe connection-test changes.

* fix(antigravity): strip competing-agent system prompts (429 RESOURCE_EXHAUSTED)

Port decolua/9router b566b20, generalized: Antigravity flags system prompts
advertising competing agents ('You are a Claude agent, built on Anthropic's
Claude Agent SDK.' — Zed, Claude Code, etc.) and answers with a 429 quota
error. sanitizeAntigravityGeminiRequest now strips known competitor identity
sentences from systemInstruction.parts before dispatch; surrounding
instruction text is untouched and non-matching prompts pass through without
allocation.

* chore(changelog): cover competitive prompt strip in #10420 fragment

* fix(antigravity): scope GEO_BLOCKED classification to Google AI surfaces

Address reviewer feedback: classifyProviderError is shared across every
provider, so a lookalike 'not available in your region' body from an
unrelated upstream must not receive the egress-fixable 24h exclusion
treatment. Gate GEO_BLOCKED behind isGeoBlockEligibleProvider, which
matches the surfaces that actually emit Google's regional-availability
refusal: Cloud Code / Gemini Code Assist (antigravity, agy, cloudcode*),
the Gemini Developer API (gemini, gemini-cli, vertex), plus a
registry-driven fallback on executor/format. Non-Google providers fall
through to their existing 400/403 classification (typically null for an
unclassified 400), so a permanent block still follows its own path.

* ci: re-run quality gates

Trigger a fresh CI run for the PR: the previous run's 'Vitest (fast-path)'
job failed in 'npm ci' because the onnxruntime-node postinstall could not
download its binary from the Microsoft CDN (connect ETIMEDOUT
150.171.109.118:443). No tests ran; no code changed in this commit.

* fix(antigravity): guard provider before registry lookup in geo-block gate

isGeoBlockEligibleProvider passes the raw provider (string | null | undefined)
to getRegistryEntry(provider: string), failing typecheck:core and the
ts7-diagnostics ratchet (TS2345 at errorClassifier.ts:166). Add an explicit
null guard; runtime behavior is unchanged — a falsy provider already resolved
to !entry -> false.

* ci: re-run quality gates (vitest npm ci onnxruntime CDN flake)

---------

Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
2026-08-16 00:16:27 -03:00
Rouzbeh†
b75f7dde93 fix(guardrails): reroute zero-vision combos through the vision bridge (#10415)
* fix(guardrails): reroute zero-vision combos through the vision bridge

Named combos whose model targets all lack vision support are never
reroute-eligible: the bridge only attempts the describe path, and when
describing cannot run or fails the raw images stay in the payload and the
request dies in the combo capability filter with capability_mismatch.

getComboVisionBridgeDecision now returns a "no-vision" verdict for combos
with zero vision-capable targets, and preCall treats it as reroute-eligible
with the same credential guards as single text-only models, falling back to
describe only when no usable reroute target exists.

* chore(changelog): fragment for #10415 vision bridge combo reroute

* fix(guardrails): extend allNull stub fallback to no-vision combos

Reviewer follow-up (#10415): the allNull stub-text fallback at the end of
preCall only fired for comboVisionBridgeDecision === 'process'. In the
compound-failure case for a zero-vision combo — reroute target without
usable credentials AND every describe call failing — raw images were
preserved and the original capability_mismatch recurred, because a
no-vision combo has no target that can consume images.

Include 'no-vision' in the guard: stub text is strictly better than raw
bytes no combo target can consume. Adds a double-failure unit test.

* ci: re-run dast-smoke (Build CLI bundle runner timeout flake)

* fix(build): bound and retry the opencode-plugin npm install in prepublish

The plugin's node_modules is gitignored, so every fresh CI checkout runs a
full npm install inside @omniroute/opencode-plugin during build:cli. npm's
unbounded fetch retries turn a stalled registry CDN connection (the recurring
onnxruntime-class ETIMEDOUT flake) into a 20-30 minute hang — the DAST
'Build CLI bundle' step has been cancelled at the 30m cap repeatedly.

- Bound npm fetch: --fetch-timeout 60s, 2 retries with capped backoff — a
  stalled connection now fails fast instead of hanging the job.
- Retry the install up to 3 times with a 10s pause between attempts, so
  transient CDN failures recover in-build.

Net effect: the step either completes (network OK) or fails quickly with a
clear error (network down) — it can no longer eat the whole job budget.

* ci(dast): use existing npm-ci-retry action instead of bare npm ci

dast-smoke died at 'Run npm ci' with connect ETIMEDOUT to the
onnxruntime-node binary CDN (Microsoft 150.171.x.x) — the same
transient CDN flake class that has hit Vitest/Quality Gates before.
quality.yml already wraps npm ci in ./.github/actions/npm-ci-retry
(3 attempts, exponential backoff); dast-smoke was the one workflow
still using a bare install. Use the existing action for consistency.

* ci(quality): use the npm-ci-retry action on every install step

Fast Quality Gates failed on the recurring onnxruntime-node postinstall
ETIMEDOUT (Microsoft CDN 150.171.x.x) - the same transient flake that has
hit Vitest and dast-smoke today. Only the Build job used the retry action;
the other five jobs (Docs, Fast Quality Gates, Vitest, Unit Tests,
changelog) still ran a bare install and die on any CDN hiccup. Use the
existing retry action (3 attempts, exponential backoff) on every install
step for consistency.

---------

Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
2026-08-16 00:16:23 -03:00
dependabot[bot]
3c8432791e chore(deps): bump github/codeql-action/init from 4.37.4 to 4.37.6 (#10407)
Bumps [github/codeql-action/init](https://github.com/github/codeql-action) from 4.37.4 to 4.37.6.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](f205ea1c33...5595ccaf91)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-16 00:16:18 -03:00
dependabot[bot]
142bd5019f chore(deps): bump github/codeql-action/analyze from 4.37.4 to 4.37.6 (#10406)
Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.37.4 to 4.37.6.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](f205ea1c33...5595ccaf91)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-16 00:16:14 -03:00
dependabot[bot]
d9e24d84d8 chore(deps): bump github/codeql-action from 4.37.4 to 4.37.6 (#10405)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.4 to 4.37.6.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.37.4...v4.37.6)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-16 00:16:09 -03:00
Dizzle
94cf4c402a fix(executors): rotate to the next account on network throws when the account has a dedicated proxy (#10402)
OpencodeExecutor and MimocodeExecutor rotated to the next account only on
HTTP 429. A network exception (timeout, connection refused/reset) on one
account instead propagated out of execute() and failed the whole request,
even when other accounts remained available.

Both executors now rotate on a network exception only when the failed
account has its own dedicated proxy (account.proxy !== null) — a dead
proxy is genuinely account-scoped, so rotating away from it is safe.
Accounts sharing the default egress (no proxy configured) trigger the
same cooldown and are skipped for the rest of the request once the shared
egress is known down, but a later account with its own dedicated proxy is
still tried normally — a throw on a proxy-less account no longer strands
a proxied account further in the rotation. This behavior is gated behind
NETWORK_ROTATION_SHARED_EGRESS_GUARD (Feature Flag, default on); disabled,
it reproduces the immediate-propagation behavior this fix started from.

The shared rotation mechanics (pickAccount/markCooldown/markSuccess) are
extracted into executors/accountRotation.ts, used by both executors —
they had independently implemented the same round-robin+cooldown
skeleton. This also fixes an identical, pre-existing bug in
MimocodeExecutor that predates this PR: its catch block called
markCooldown unconditionally on any throw, with no proxy check and no
warn log (a silent exception swallow on a path that influences the
result).

The cooldown formula for both the proxy and shared-egress cases reuses
the repo's already-established "transient, not clearly attributable"
constants (errorConfig.ts TRANSIENT_COOLDOWN_MS/COOLDOWN_MS.transientMax,
already used by accountFallback.ts for network-error classification)
instead of introducing a separate value.

MimocodeExecutor's network-error 502 body also now goes through
buildErrorBody()/sanitizeErrorMessage() instead of embedding the raw
caught error message directly (Hard Rule #12), matching the sanitization
already used on its #2101 malformed-request path.

Validated by TDD (Hard Rule #18): tests/unit/account-rotation.test.ts
covers the shared module directly; opencode-proxy-rotation-4954.test.ts
and mimocode-executor.test.ts cover the proxy-configured rotation path,
the mixed-fleet case, the shared-egress single-network-call case, and the
NETWORK_ROTATION_SHARED_EGRESS_GUARD-disabled legacy path, for each
executor. tsc, lint, and the provider golden-path gates
(check:provider-consistency, check:provider-assets,
provider-translate-path-golden.test.ts) are clean on all touched files.

Co-authored-by: Max <maxmad64@gmail.com>
2026-08-16 00:16:04 -03:00
Jacky Lam
6e97fbf340 fix(sse): dedupe header-budget drop warns by drop-set fingerprint (#10397)
* fix(sse): dedupe header-budget drop warns by drop-set fingerprint

The 768-byte forwarded-header budget drop path emitted a full warn (with
up to 20 dropped entries) on every SSE response whose headers exceeded the
budget. The dropped set is usually identical across responses from the same
upstream, so the repeats carried no new information — under Desktop
multi-stream use this buried real errors and added event-loop serialization
work.

Warn once per unique drop fingerprint (sorted dropped-header names, capped
at 1000 fingerprints) per process, then log at debug level.

Fixes #10315

* changelog: fragment for #10397
2026-08-16 00:16:00 -03:00
azzaouiomar19-sketch
201c234b96 fix(chat): guard search providers from OpenAI fallback (#10394)
Co-authored-by: DarkAngel <48388675+DarkEsteves@users.noreply.github.com>
2026-08-16 00:15:55 -03:00
Jacky Lam
149049ca4a fix(db): default debugMode to false in getSettings() defaults (#10372)
* fix(db): default debugMode to false in getSettings() defaults

Fresh installs (or installs missing the persisted debugMode key) ran in
debug mode, contradicting the documented opt-in toggle and flooding new
production installs with debug-level logs. Flip the default to false;
installs that persisted debugMode=true keep it — only the missing-key
path changes, no migration needed.

Fixes #10312

* changelog: fragment for #10372
2026-08-16 00:15:51 -03:00
tkgo11
b19e9772bc fix(monitoring): canonicalize provider aliases in health matrix (#10370)
* fix(monitoring): canonicalize provider aliases in health matrix

* fix(monitoring): canonicalize aliases in health autopilot

---------

Co-authored-by: tkgo11 <7.1800574e+07+tkgo11@users.noreply.github.com>
2026-08-16 00:15:46 -03:00
backryun
684ea70fb3 perf(electron): prune authoring docs from packages (#10359) 2026-08-16 00:15:42 -03:00
Markus Hartung
4b76d3b76f fix(sse): close the synthetic keepalive reasoning item + harden output_index allocation (#10330)
* fix(sse): close the synthetic keepalive reasoning item's output_item

RESPONSES_STARTUP_THINKING_FRAME (the /v1/responses early-keepalive
placeholder for slow-starting reasoning models) opened a synthetic
"rs_keepalive" reasoning item at output_index 0 and closed its nested
summary part (response.reasoning_summary_part.done), but never sent
response.output_item.done to close the item itself. The comment
claimed it was "closed within this one frame" — that was true for the
part, not the item.

Since this placeholder has no real upstream counterpart (the real
response starts an independent response.created lifecycle later and
never touches it), nothing else ever closes it. A client tracking open
items by output_index (as the Responses API spec requires — this is
exactly what OpenClaw's parser does) sees index 0 still open when the
real response's own output_item.added later reuses that same index,
and throws a collision.

Live incident (2026-08-13, reliably reproducing by 2026-08-14): traced
via a live tcpdump capture on the OmniRoute-dev container's network
namespace, correlated against the OpenClaw gateway journal and 10
separate real request/response pairs (all wire-clean on the response
side, ruling out provider corruption). The failing request's own
outbound payload confirmed a replayed reasoning item without
encrypted_content feeding a continuation call; the response wire bytes
for that exact exchange showed rs_keepalive's output_item.added at
index 0, then response.created/response.in_progress arriving *after*
it, then a second output_item.added reusing index 0 for the real
reasoning item — never preceded by an output_item.done for
rs_keepalive. Reported upstream as OpenClaw issue #123342 before the
OmniRoute-side root cause was found.

Fix: emit response.output_item.done for the synthetic item, matching
its already-buffered summary text, right after the summary part closes
and before the frame ends.

Test plan:
- tests/unit/early-stream-keepalive.test.ts: updated the frame-shape
  test to assert the full 5-event closed sequence (added the missing
  output_item.done and its field assertions); confirmed it fails
  against pre-fix code (only 4 events) and passes after
- node --test tests/unit/early-stream-keepalive.test.ts,
  tests/unit/earlyStreamKeepalive.test.ts,
  tests/unit/keepalive-cleanup-8140.test.ts,
  tests/unit/chat-body-admission.test.ts: 58 passed, 2 pre-existing
  skips unrelated to this change (Node test runner
  ReadableStream-error-simulation limitation)
- tsgo --noEmit: clean on both touched files

* fix(sse): allocate the keepalive output_index from a stack, not a literal

Follow-up to 03f8345ac. That commit patched the specific symptom (added
the missing response.output_item.done). This commit fixes the class:
RESPONSES_STARTUP_THINKING_FRAME hardcoded output_index: 0 as a literal
across five hand-written events, which is exactly how the missing-close
bug happened in the first place — nothing enforced that every open got
a matching close, so it silently didn't for months.

ResponsesOutputIndexStack (open-sse/utils/responsesOutputIndexStack.ts)
makes that structural: open() allocates the next sequential index,
close() must name the index being closed and throws if it doesn't match
the stack's top, and assertAllClosed() throws if anything is still open.
The keepalive frame now calls assertAllClosed() at module load, so a
future regression of this exact shape fails at import/boot time instead
of shipping a malformed stream to production and surfacing days later
as a live incident.

Also adds tests/helpers/assertResponsesOutputIndexLifecycle.ts: a
reusable version of the same invariant for replaying a full SSE event
sequence (not just checking one frame's own shape), mirroring what a
real client's output-index tracker enforces. Existing coverage for this
bug class (responses-reasoning-close-before-message-466.test.ts) only
asserted it by hand for one specific emitter (the real translator); nothing
generic existed for a hand-rolled synthetic frame like this keepalive to
be checked against, which is why its own test could pass while the actual
downstream contract still failed. Wired into
early-stream-keepalive.test.ts, including a test that concatenates the
keepalive frame with a plausible real subsequent response and asserts no
collision — the scenario that actually reproduced live, not just the
frame's own internal shape.

Test plan:
- tests/unit/responses-output-index-stack.test.ts (new): open/close/
  assertAllClosed behavior, including the exact mismatch and
  never-closed shapes this incident hit
- tests/unit/early-stream-keepalive.test.ts: existing frame-shape test
  plus new collision-simulation test, both passing
- node --test across responses-output-index-stack, early-stream-keepalive,
  earlyStreamKeepalive, keepalive-cleanup-8140, chat-body-admission:
  65 passed, 2 pre-existing skips unrelated to this change
- tsgo --noEmit: clean on all touched files

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-16 00:15:38 -03:00
Aman
0b347eaea1 fix(providers): validate Z.ai web auth semantics (#10329) 2026-08-16 00:14:55 -03:00
backryun
47f53f37ea ci(electron): streamline release dependency setup (#10325) 2026-08-16 00:14:50 -03:00
backryun
757b195540 perf(electron): bound lightweight readiness polling (#10324) 2026-08-16 00:14:45 -03:00
Anudeep Adiraju
cb51facf12 fix(docker): prefix cache mount ids with Railway service scope (#10288)
* fix(docker): prefix cache mount ids with Railway service scope

Railway's Dockerfile builder rejects --mount=type=cache ids that lack
the s/<service-id>- prefix (dockerfile invalid, caught at syntax
validation before any build step runs). Prefix all 7 cache mount ids
(apt-cache, apt-lists x4 RUN blocks, npm-cache x2, next-cache x1) with
the omni-route service id.

* fix(sse): remove duplicate sseCommentsEnabled import in stream.ts

Turbopack rejected the file with 'the name sseCommentsEnabled is
defined multiple times' — imported once at the top of the file and
again lower down from the same module. Broke every production build
(Docker/Railway) at the release/v3.8.50 tip, independent of the cache
mount fix in this branch. Validated by a full Docker build on Railway
completing past this step.
2026-08-16 00:14:40 -03:00
Harkaran Brar
710e43eb97 fix(sse): answer tiny-budget reasoning probes with a truncated 200 (#10281) (#10284)
* fix(sse): answer tiny-budget reasoning probes with a truncated 200 (#10281)

Claude Code's /model capability check sends max_tokens: 1. Reasoning
models burn the whole probe on thinking, and some upstreams (e.g.
api.cline.bot for deepseek-v4-flash) answer the empty outcome with a
5xx "empty response content" instead of a truncated 200. The relayed
failure also marked the connection unavailable and poisoned
fallback/cooldown bookkeeping for what is only a probe.

Detect tiny-budget reasoning probes in the non-streaming providerFailure
path and synthesize a valid truncated response (200, empty content,
finish_reason "length") — the same semantics errorClassifier.ts already
grants to length-truncated empty 200s. Probes no longer poison
connection health. Refs #10281.

* chore(changelog): add fragment for reasoning-probe truncated-200 fix (#10284)
2026-08-16 00:14:35 -03:00
Dizzle
b67d9ef353 fix(db): publish the sql.js database atomically instead of rewriting it in place (#10278)
sql.js has no incremental write path, so persist() rewrites the whole image on
every save. Going through fs.writeFileSync(filePath, ...) opened the destination
with O_TRUNC, leaving the on-disk database 0 bytes and then partial for the whole
write -- a window that scales with database size and recurs on every save.

Unlike better-sqlite3 / node:sqlite, that window is not covered by SQLite's
locking protocol, so it is visible to every other process reading the same file:
a backup job, a metrics exporter, an operator running sqlite3. Those readers get
SQLITE_CORRUPT ("database disk image is malformed") while PRAGMA
integrity_check passes moments later, which makes the failure look random and
blames the reader.

Now: temp file in the same directory, fsync, rename() over the destination.
rename is atomic on POSIX and on Windows for a same-volume replace, so a reader
sees either the previous image or the new one, never a truncated one. It also
closes a total-loss window: a crash mid-write used to leave the real database
truncated, and now only leaves a stale temp file behind.

The regression guard asserts the property that separates the two implementations
without racing a timer: a reader that opened the file before a save still reads a
complete, valid image afterwards, and the published file sits on a new inode.
It fails on the previous implementation and passes on this one.

Co-authored-by: Max <maxmad64@gmail.com>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-16 00:14:30 -03:00
Aman
462f4fc9da fix(providers): preserve connection test status codes (#10272) 2026-08-16 00:14:26 -03:00
Paco Cartones
dd4a33d1d8 fix(providers): make the monsterapi deprecation from #8676 actually apply (#10234)
* fix(providers): make the monsterapi deprecation from #8676 actually apply

#8676 marked MonsterAPI deprecated after its domain stopped resolving, but
wrote the flag as `isDeprecated`. Nothing reads that key. The field the
codebase consumes is `deprecated`:

  src/shared/validation/providerSchema.ts   declares `deprecated`
  ProviderCard.tsx                          strikethrough + block icon + reason
  ProviderTestSlideOver.tsx                 warning
  providerOnboardingCatalog.ts              Boolean(provider.deprecated), sorts last
  ProviderOnboardingWizard.tsx              deprecated badge
  scripts/docs/gen-provider-reference.ts    gates the DEPRECATED note

Zod object schemas ignore undeclared keys, so `isDeprecated` never failed
validation - it was dropped silently. The deprecation therefore had no effect
anywhere, and tests/unit/8676-monsterapi-deprecation.test.ts asserted the same
unread key, so it stayed green while guarding nothing.

The committed docs/reference/PROVIDER_REFERENCE.md is the visible proof: the
generator renders predibase (which uses `deprecated`) with a DEPRECATED note,
while monsterapi still advertised "Get API key at monsterapi.ai" - a domain
that does not resolve (probed 2026-08-13: api.monsterapi.ai and monsterapi.ai
both 000, against api.openai.com 401 as a reachability control).

Rename the key, repair the regression test to assert the consumed field and to
reject the undeclared one, and refresh the generated reference row.

* fix(providers): name the changelog fragment for PR #10234

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

---------

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-16 00:14:20 -03:00
backryun
5a7487a60a refactor(providers): unify xAI authentication entry point (#10201)
Present xAI API-key and OAuth connections through one dashboard card while preserving the distinct backend IDs required for refresh and quota handling.

Co-locate both registry entries and include canonical and legacy connection IDs in provider fetch and batch-test flows.
2026-08-16 00:13:46 -03:00
backryun
5239728d6f feat(providers): add Grok 4.6 and refresh DeepSeek V4 (#10195) 2026-08-16 00:13:41 -03:00
Bezrabotnyi
595d04dad9 feat(providers): add local ZCode ACP backend (#10184)
* feat(providers): add local ZCode ACP backend

* test(snapshots): regenerate translate-path golden for zcode provider

The new local ZCode ACP backend (zcode://app-server/stdio) was added to the
provider catalog but the translate-path golden snapshot was not regenerated,
so the combined suite (provider-translate-path-golden.test.ts) failed on the
merged tip. Regenerate the snapshot to include the zcode translate-path entry.

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

* docs(env): document ZCODE_* vars for the local zcode provider

Registers the 11 ZCODE_* env vars read by the zcode executor (.env.example
+ docs/reference/ENVIRONMENT.md) so the env-doc-sync gate stays green.

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

* test(autoCombo): include zcode in the glm-family provider set

#10184's local zcode backend advertises the full GLM_SHARED_MODELS
line-up (registry/zcode, authType none) — same documented case as auggie
and devin-cli-agentic. Update auto/glm provider-set assertion to include
it.

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

---------

Co-authored-by: roomhacker <roomhacker@bezrabotnyi.com>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-16 00:13:36 -03:00
Diego Rodrigues de Sa e Souza
aa5b77eb6e docs: add OmniCopilot (VS Code Copilot Chat) to platform table and links (#10512)
Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-15 21:46:22 -03:00
Xiangzhe
1a4a55cfc0 fix(.gitignore): add /output/ directory to ignore list 2026-08-15 18:48:12 -03:00
Diego Rodrigues de Sa e Souza
dc562d93ca Merge pull request #10497 from diegosouzapw/fix/9760-video-caption-self-loop
fix(video-bridge): route captions through provider connections
2026-08-15 18:04:03 -03:00
Diego Rodrigues de Sa e Souza
b1d710d45b fix(video-bridge): route captions through provider connections 2026-08-15 17:44:47 -03:00
Diego Rodrigues de Sa e Souza
382b2fba26 Merge pull request #10493 from diegosouzapw/fix/9760-video-runtime-ui-status
fix(video-bridge): restore runtime extraction and remote status
2026-08-15 16:34:54 -03:00
Diego Rodrigues de Sa e Souza
782e480061 fix(video-bridge): let fetch size broker bodies 2026-08-15 16:18:43 -03:00
Diego Rodrigues de Sa e Souza
e315082887 fix(video-bridge): clarify remote runtime status 2026-08-15 15:57:25 -03:00
Aron Lee
972c4594b6 fix(services): fall back to ss and netstat when lsof is absent (#10459)
resolvePortPid shelled out to lsof alone. On a host without it, spawn
raises ENOENT, the error handler turned that into null, and the caller
could not tell 'nothing holds this port' from 'I have no way to look' -
so a service adopted on a supervisor restart kept pid: null forever,
silently, which is the regression the adopt-branch test guards against.

Probes lsof, then ss, then netstat, sharing one deadline so the whole
lookup still costs at most PID_RESOLVE_TIMEOUT_MS. Output parsing for
each is a pure exported function so the formats are unit-testable
without the binary being installed.

netstat cannot filter by port, so its parser matches the local-address
column rather than scanning the line, keeping a foreign address that
ends in the same number from being read as a listener.
2026-08-15 15:27:42 -03:00
Diego Rodrigues de Sa e Souza
5379493bed feat: add Video Bridge frame sampling (#10483)
Implements the secure, opt-in Video Bridge for issue #9760, including bounded FFmpeg frame extraction, capability-aware routing, telemetry, settings UI, localization, documentation, and regression coverage.
2026-08-15 14:23:29 -03:00
Diego Rodrigues de Sa e Souza
282c087c27 fix(radar): separate feature availability from opt-in (#10487)
Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-15 14:13:26 -03:00
killmonger2317-coder
d33e62af9c fix(sse): let :free OpenRouter models bypass connection-wide credits_exhausted lock (#10445)
* fix(sse): let :free OpenRouter models bypass connection-wide credits_exhausted lock

A 402 from one paid OpenRouter model correctly locks the whole connection
as credits_exhausted for an hour (intentional, per #6842), but that lock
was also blocking every :free model on the same connection even though
OpenRouter bills free models separately from account credits.

Reconstructed clean against release/v3.8.50 by the maintainer: the author's
original branch predated a large auth.ts import refactor; the same delta was
re-applied onto the current tip and the TDD test still passes.

TDD: tests/unit/openrouter-free-model-credits-exhausted.test.ts
reproduces the bug (fails before the fix, passes after) and covers the
three guard cases above.

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

* test(mutation): register openrouter-free-model-credits-exhausted in stryker tap.testFiles

The new unit test covers src/sse/services/auth.ts, which is one of the 31
stryker-mutated modules — per check-mutation-test-coverage every covering
test must be listed in tap.testFiles or its mutant kills stop counting.
Registered the file so the blocking mutation-test-coverage gate passes.

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

---------

Co-authored-by: killmonger2317-coder <282069920+killmonger2317-coder@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-15 13:52:46 -03:00
Benson K B
f466ea91c9 fix(kilocode): strip unsupported response_format for DeepSeek V4 Flash (400 regression) (#10458)
* fix(kilocode): strip unsupported response_format for DeepSeek (400 regression)

kilocode's DeepSeek V4 Flash rejects ANY response_format — both
json_schema AND json_object 400 with 'Invalid input: response_format'
(verified live 2026-08-15 via the Hindsight fact-extraction path on
kilocode/deepseek/deepseek-v4-flash). The default executor's
applyJsonSchemaFallback only covered openai-compatible-* providers and
only downgraded json_schema -> json_object, so kilocode forwarded the
unsupported format raw. Same bug class as the opencode fix #9992.

For kilocode: strip response_format entirely and inject the schema (or a
plain 'valid JSON only' instruction for json_object) into the system
prompt. openai-compatible-* keeps the existing json_schema downgrade and
json_object passthrough (they accept both).

Regression tests: kilocode json_schema is stripped + schema-injected;
kilocode json_object is stripped + JSON-only instruction; both verified
to fail without the fix (sabotage: 2 fail). All 49 executor-default-base
tests pass.

* fix(kilocode): drop as-any casts in new tests to clear the frozen ESLint baseline

The file's frozen no-explicit-any baseline is count 42; the new kilocode
strip tests added 3 net-new 'as any' casts, tripping the --max-warnings 0
lint-guard. Replace them with typed assertions that carry the same checks.

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

---------

Co-authored-by: benzntech <benzntech@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-08-15 13:52:11 -03:00
Bob.Hou
8ff7f7daf0 fix(sse): relocate directive-only messages off messages[0] (#10457)
The upstream Messages API rejects directive-style messages (empty content
array with a message-level output_config) when they sit at messages[0] —
the initial system prompt position — while accepting the form at any other
position. Measured in production: 122x 400 on the offical-claude combo in
one hour.

The mid-conversation-system passthrough (official provider + 1M-context
beta models) keeps system-role messages inside messages[], so a directive
that arrived first went upstream unchanged. relocateDirectiveOnlyMessages()
moves the whole leading run of empty system messages: directive-only ones
past the first real turn, plain empties dropped. extractSystemRoleMessages()
now folds a directive's output_config into the top-level parameter instead
of silently discarding it.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-08-15 13:51:40 -03:00
Hernan Javier Ardila Sanchez
e168b2347e fix(combo): restrict auto combo pools to user-visible models (#10456)
Auto combos (virtual auto/* pools via virtualFactory and pure-auto named
combos via expandAutoComboCandidatePool) expanded their candidate pool from
the provider's STATIC registry catalog, which can include models the operator
never synced or approved (e.g. openrouter/auto). The visibility filter
(getHiddenModelsByProvider) only caught models explicitly flagged isHidden,
so catalog-only models passed through and got routed upstream.

Build the credentialed pool from the models the user actually has available
(synced + custom non-hidden), falling back to the static catalog only when
the operator has no synced/custom models for that provider. Applies to every
provider uniformly (openai, kilocode, openrouter, ...), with per-connection
scoping for synced models. Provider wildcards (providerWildcard.ts) already
used the active synced catalog as the authoritative source.

Regression coverage: tests/unit/combo-auto-pool-visible-only.test.ts

Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
2026-08-15 13:51:34 -03:00
backryun
4adf50dbcb fix(ci): clean up Windows packaged smoke process trees (#10453) 2026-08-15 13:51:11 -03:00
backryun
370c1b9ae7 test(build): resolve standalone fixture paths from file URLs (#10451) 2026-08-15 13:50:46 -03:00
Diego Rodrigues de Sa e Souza
ee221d870c fix(radar): bypass stale flag-off responses (#10464)
Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-15 08:15:41 -03:00
Reza Rezaei
774127be3f feat(providers): add tencent-aistudio-web cookie provider (tasw) (#10174)
* feat(providers): add tencent-aistudio-web cookie provider (tasw)

* fix(sse): remove orphaned DevinDesktopExecutor import from executor index

The "devin-desktop" executor key is unused (devin-desktop provider config
resolves to executor "devin-cli"); the imported ./devin-desktop.ts file
was never present, so executors/index.ts failed to load (ERR_MODULE_NOT_FOUND)
and broke every unit test that imports the executor registry (e.g.
tests/unit/deepseek-web.test.ts). Stale base sync carried this into the branch.
Remove the dead import/registration/export.

* fix(providers): restore DevinDesktopExecutor registration in executor index

The previous commit removed the devin-desktop executor import/registration/
export from open-sse/executors/index.ts, but the devin-desktop provider
registry still resolves executor "devin-desktop" and
tests/unit/devin-providers.test.ts asserts hasSpecializedExecutor("devin-desktop")
is true. The removal broke 6 tests in that file. Restore the three lines so
the live Devin Desktop executor keeps serving the provider.

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

* fix(providers): correct tencent-aistudio-web wrapper shape + provider count sync

Return {response,url,headers,transformedBody} instead of a raw fetch Response
(the executor contract every other executor in this file follows) and
re-wrap the upstream body so it uses the local Response constructor, not the
undici-patched one from globalThis.fetch.

Regenerate docs/reference/PROVIDER_REFERENCE.md and sync the 339->340
provider-count claims (README, AGENTS.md, llm.txt + 42 i18n mirrors,
package.json, promise-pillars/comparison-table/cli-terminal SVGs) that this
PR's new provider invalidated.

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

* docs(providers): sync readme-hero.svg provider count claim (339->340)

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

* fix(providers): register tencent-aistudio-web web-session credential metadata + golden

Add the WEB_SESSION_CREDENTIAL_REQUIREMENTS entry for tencent-aistudio-web
(cookie-based, matching the executor's raw Cookie-header credential) and
regenerate the translate-path golden snapshot to include the new provider —
both were failing CI unit tests that enumerate every registered provider.

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

* fix(providers): align tencent-aistudio-web test with the wrapper-shape contract

The test asserted res.status/res.json() directly against executor.execute()'s
return value, matching the pre-fix (broken) raw-Response shape. Update it to
read res.response.status/res.response.json() — the {response,url,headers,
transformedBody} contract every executor in this codebase follows.

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

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: MeRezaRezaei <MeRezaRezaei@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-15 05:15:17 -03:00
Diego Rodrigues de Sa e Souza
1287b6a75d feat(ops): canary deploy with provenance gate, real smoke and rollback anchor (#10446)
Deploying the internal gateway was a manual build/pack/scp/npm-i/pm2-restart sequence with no record of what landed and no proof it served traffic. On 2026-08-14 that shipped a package built from a branch predating #10373: the process came up, health said 'healthy', and every request returned 502 until a human hit it.

scripts/ops/deployCanary.ts holds the policy as pure functions — refuse an artifact that is not traceable to the release line (reusing #10427), and grade the deploy on health PLUS at least one real completion. Zero probes fails: 'no probe ran' must never read as 'everything is fine', which is exactly how a broken egress path hides behind a green health check. Remote steps are argv arrays, never shell strings (Hard Rule #13), ordered so the rollback anchor is captured before the install overwrites it.

scripts/ops/deploy-canary.mjs performs the side effects, supports --dry-run, and prints the rollback command when the smoke fails.

Closes #10429
2026-08-15 03:22:09 -03:00
Diego Rodrigues de Sa e Souza
a36fbdcc8d fix(build): verify artifact provenance and expose buildSha on health (#10444)
The packaged artifact stamped dist/BUILD_SHA but nothing verified the SHA belonged to the release line, so a tarball built from a feature branch installed and served traffic indistinguishably from a release build. That is how the internal gateway ended up running a build that predated #10373 and answered every request with 502 'Executor result must contain a Response' — identifying it required SSH plus grepping the compiled chunks.

scripts/build/buildProvenance.ts classifies a build SHA against the release ref (pure functions, injected git probe). A missing SHA fails even with the canary override: an unidentifiable artifact cannot be vouched for. validate-pack-artifact enforces it on real packs (skipped under --policy-only, which runs without a build); OMNIROUTE_ALLOW_CANARY_BUILD=1 records a deliberate off-release-line build instead of failing it. /api/monitoring/health now exposes system.buildSha — absent when unknown, never fabricated.

Closes #10427
2026-08-15 02:44:52 -03:00
Diego Rodrigues de Sa e Souza
2a04b2415a fix(db): keep test runs off the operator's real DATA_DIR (#10432)
Any process that opened the DB without setting DATA_DIR resolved to ~/.omniroute/storage.sqlite — the operator's live database, provider credentials included. tests/_setup/isolateDataDir.ts only covers the npm scripts; the documented single-file test command and ad-hoc probes bypassed it (one did exactly that during #10334).

resolveWritableDataDir now redirects a test-context process with no DATA_DIR to a throwaway temp dir, stable per process. Redirect rather than throw, so the documented single-file command keeps working; OMNIROUTE_ALLOW_DEFAULT_DATA_DIR=1 opts back in and records the intent.

Closes #10428
2026-08-15 01:51:01 -03:00
Diego Rodrigues de Sa e Souza
50cb187511 Merge PR #10418: integrate OmniRoute Radar cumulative stack
Integrates the reviewed F2-F7 and Radar hardening stack into release/v3.8.50 after exact-head CI and freeze gates.
2026-08-14 23:10:45 -03:00
Xiangzhe
6972332f81 Merge remote-tracking branch 'origin/release/v3.8.50' into fix/radar-oss-cumulative-integration
# Conflicts:
#	README.md
#	docs/diagrams/promise-pillars.svg
2026-08-14 22:28:36 -03:00
Diego Rodrigues de Sa e Souza
17bfe15f04 docs: correct drifted counts, reconcile the v3.8.50 changelog and gate the free-forever number (#10433)
Audit of every numeric claim in the README, AGENTS.md and the README SVGs against
live code, plus a changelog/credit reconciliation over the full v3.8.50 cycle.

Corrected numbers (all measured, not estimated):

- Provider circuit breaker thresholds were scaled up in code for 500+ connection
  deployments (`providerFailureThreshold`: OAuth 3 -> 10, API key 5 -> 15) but the
  docs still published the pre-scale values. Fixed in AGENTS.md (now a table that
  also separates the provider-level threshold from the per-connection one and lists
  the provider cooldowns), in the README alt text and inside resilience-layers.svg
  (visible label and aria-label).
- "40+ free forever" was unsourced. Measured from the free-tier catalog as every
  provider whose free access renews or needs no key (recurring-monthly, -daily,
  -uncapped, -credit, keyless; one-time signup credits and discontinued pools
  excluded): 56. Updated in the README and promise-pillars.svg.
- Cycle-evolution table: v3.8.49 shipped 290 providers, not 291, and the model row
  compared the v3.8.49 free-tier catalog (516) against today's full catalog. Both
  columns now use the same metric - distinct documented models, 1185 -> 1202.
- Tech-stack row: 95 domain modules -> 117.

The free-forever count is now enforced by check:docs-counts so it cannot drift
again; it is derived from freeType in the live catalog, like every other gated
number.

Changelog reconciliation (`scripts/release/list-uncovered-commits.mjs`):
uncovered cycle commits drop from 149 to 62. 75 user-facing commits gained a bullet
with author attribution, the 45 ref-less direct pushes and 29 chore/ci/test/docs
commits were consolidated into rollup bullets, and the contributors table grew from
147 to 161 rows - 14 contributors who had landed work with no credit at all
(including @amartinawi, @pacocartones and @excessivechaos) are now credited.
The remaining 62 carry no PR/issue ref, which is the ceiling of ref-based coverage.

CHANGELOG.md and its i18n mirrors are added to .prettierignore: check:changelog-
integrity compares base bullets as exact strings, and Prettier normalizes markdown
emphasis inside them (*from* -> _from_), so any PR that staged the changelog turned
the merge-integrity job red. scripts/release/* is the changelog's formatter of
record, the same precedent already used for ENVIRONMENT.md and PROVIDER_REFERENCE.md.

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-14 22:25:29 -03:00
Xiangzhe
27a10da398 Merge remote-tracking branch 'origin/release/v3.8.50' into fix/radar-oss-cumulative-integration 2026-08-14 22:23:58 -03:00
Xiangzhe
b40a9ea8fe fix(radar): close final client trust boundaries 2026-08-14 22:19:50 -03:00
mrcram2021
f093a3516f docs: add troubleshooting guide for rate-limiting on free providers (429/400/401) (#10112)
* docs: add rate limiting guide for free providers (429/400/401)

Community-reported troubleshooting for auto-discovered issues when
rotating through free/no-auth providers (opencode, felo-web, auggie).
Documents the verified env-var combo that eliminates
intermittent 429/400/401 failures in cron/agent automation:
OMNIROUTE_ROTATE_ON_400=true,
OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=4,
OMNIROUTE_STRUCTURE_LIMIT=off

Includes root-cause breakdown (provider quota vs passthrough 401 vs
concurrency amplification), verification steps via /monitoring/health,
and escalation for hard quota exhaustion.

* docs(providers): fix fabricated env var and breaker states in rate-limit guide

Replace OMNIROUTE_STRUCTURE_LIMIT (does not exist in the codebase) with
OMNIROUTE_CHAT_ADMISSION_QUEUE_MS and document the real rate-limit knobs
(RATE_LIMIT_MAX_WAIT_MS / RATE_LIMIT_MAX_QUEUE_DEPTH / RATE_LIMIT_AUTO_ENABLE).
Correct the circuit breaker states to the actual enum (CLOSED/DEGRADED/OPEN/HALF_OPEN)
and point the health-check note at circuitBreakers.providerBreakers[].state.

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

---------

Co-authored-by: Bruno <bruno@nousresearch.com>
Co-authored-by: mrcram2021 <mrcram2021@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-14 22:02:54 -03:00
Diego Rodrigues de Sa e Souza
4a53a53277 fix(db): prune pre-migration backups so db_backups stops growing unbounded (#10423)
* fix(db): prune pre-migration backups so db_backups stops growing unbounded

createPreMigrationBackup() wrote a VACUUM INTO snapshot on every migration run
and never pruned. On a long-lived instance db_backups/ reached 48.999 files /
204 GB against a 5,3 MB live database; a second devbox showed the same shape
(5.711 files / 24 GB).

The retention policy already existed in cleanupDbBackups() but nothing on the
migration path reached it — its only callers are backup.ts and the
/api/db-backups route, neither of which runs during a migration.

migrationRunner.ts cannot import backup.ts: core.ts imports migrationRunner.ts
and backup.ts imports core.ts, so that edge would close a cycle. The policy
therefore moves to a new core-free module, backupRetention.ts, which both call
sites share — cleanupDbBackups() now delegates to it rather than duplicating it.

At the migration call site the operator's maxFiles/retentionDays are read
through the adapter already open for the run; going through getDbInstance()
would re-enter database initialization. Pruning never throws, so housekeeping
cannot fail a migration.

Closes #10421

* chore(db): declare backupRetention as an intentionally-internal db module

check:db-rules requires every src/lib/db/ module to be either re-exported by
localDb.ts or listed in INTENTIONALLY_INTERNAL. backupRetention.ts is a shared
primitive consumed only by db/backup.ts and db/migrationRunner.ts — the same
category as the migrationRunner entry — so it belongs in the allowlist rather
than in the public re-export surface.

* test(db): include backupRetention in the audited INTENTIONALLY_INTERNAL list

check-db-rules-classification.test.ts freezes the exact membership of
INTENTIONALLY_INTERNAL, so adding the 40th entry has to be reflected there too
— the gate script and this test pin the same contract from opposite sides.

---------

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-14 21:27:16 -03:00
Diego Rodrigues de Sa e Souza
4b914ad297 feat(sse): bring i-have-adhd to vi/ja/id parity and guard the style × language matrix (#10425)
i-have-adhd shipped in #10271 with en + pt-BR only; it now covers vi/ja/id as well,
matching ponytail. Every level keeps the SHARED_BOUNDARIES clause.

Adds a per-MATRIX guard (output-styles-i18n-matrix.test.ts). Every existing test is
per-style, which is how less-code stayed English-only since the 9router port without
anyone noticing. The guard fails on: a new style without pt-BR, a style losing a
translation it had, a translation missing an intensity level, a translation that dropped
the boundaries clause, and a stale KNOWN_ENGLISH_ONLY entry.

Proven by mutation, not just by passing: dropping less-code from the allowlist and
renaming the vi key both turned it red with the expected messages.

less-code stays English-only as declared debt (KNOWN_ENGLISH_ONLY + comment); the
remaining coverage work is tracked in #10426.
2026-08-14 21:18:38 -03:00
Xiangzhe
d2d2598a0d Merge remote-tracking branch 'origin/release/v3.8.50' into fix/radar-oss-cumulative-integration 2026-08-14 21:00:01 -03:00
Diego Rodrigues de Sa e Souza
e05ac345da feat(sse): honor provider-rule lock scope for agentrouter (connection vs model) (#10419)
Makes the ProviderErrorRule `scope` field real at the persistence layer, exclusively for agentrouter (owner decision; every other provider keeps byte-identical behavior).

checkFallbackError now surfaces `ruleScope` behind the HONORS_RULE_LOCK_SCOPE_PROVIDERS allowlist, and the agentrouter 403 path consults the rules before the generic apikey-FORBIDDEN early-return. markAccountUnavailable honors scope "connection" with a temporary connection cooldown instead of a per-model lockout — guarded so a permanent state can never be downgraded to a transient retry loop — and combo now skips the exhausted account within the same request, which also stops force-reusing the just-cooled connection via allowRateLimitedConnection.

Documented in RESILIENCE_GUIDE §7 with the honest limits (disableCooling connections keep per-model behavior; the 6h model-access cooldown is clamped by mlSettings.maxCooldownMs, 30min by default; same-request skip needs targets carrying their own connectionId).

Closes #10334
2026-08-14 20:52:53 -03:00
Xiangzhe
dfcf6086a2 fix(radar): close cumulative quality regressions 2026-08-14 19:47:07 -03:00
Xiangzhe
6233ca6642 test(radar): seed guided setup E2E timing 2026-08-14 19:08:50 -03:00
Xiangzhe
45a4358b61 docs: sync measured Radar integration counts 2026-08-14 18:54:08 -03:00
Xiangzhe
6cad6314b5 Merge remote-tracking branch 'origin/release/v3.8.50' into fix/radar-oss-cumulative-integration 2026-08-14 18:48:20 -03:00
Diego Rodrigues de Sa e Souza
7bb3bc7e32 fix(ci): pin Build (advisory) to a hosted runner with memory provisioning (#10408)
* fix(ci): pin Build (advisory) to a hosted runner with memory provisioning

`Build (advisory)` has been reporting a permanent red on every PR while
producing no usable signal at all.

Measured over the last 25 quality.yml runs (2026-08-14): not one instance of
the job reached a conclusion. Every sample was either queued on the
self-hosted pool — 2 runners, omniroute-113-6/7, both permanently busy; one
job sat queued for over 2 hours and was still unclaimed — or, when it did land
on a runner, killed mid-build by this workflow's own cancel-in-progress
concurrency. All 6 sampled "failures" are exit 143 / "The runner has received
a shutdown signal" at ~3.5 min into `npm run build`. Zero OOM, zero build
errors. The job was consuming a runner the real gates compete for while
telling every PR author it was broken.

Gap 19 deliberately left USE_VPS_RUNNER governing build-like jobs, on the
premise that the build needs the .113's RAM. That premise no longer holds:
`Fast Production Build` (build.yml) runs `build:release` — a superset of this
job's `npm run build`, plus the CLI bundle — on plain ubuntu-latest and passed
24 of its last 25 runs in ~15 min. The difference is memory PROVISIONING, not
the machine: a 10 GB swapfile plus a 12 GB V8 heap. Swap is the part that
matters, because --max-old-space-size bounds only V8's JS heap and never
Turbopack's native Rust allocation (#6409).

Pins the job to ubuntu-latest and mirrors both settings from build.yml.
USE_VPS_RUNNER keeps its other consumers (ci.yml Build, nightly-release-green,
npm-publish), so the variable stays meaningful. Fork safety is strictly
improved: no PR can reach the LAN runner through this job any more.

check:workflows --ratchet: 186 zizmor findings, baseline 190, no regression.
prettier + YAML parse: clean.

* fix(ci): scope Build (advisory) to fork PRs

Follow-up to the hosted-runner pin in this same PR, after measuring what the
job is actually for.

build.yml's `Fast Production Build` triggers on `push: branches: ["**"]` and
runs `build:release` — a superset of this job's `npm run build`, plus the CLI
bundle. For an own-origin branch that push fires here, so the tree was being
built twice per PR. A fork contributor pushes to THEIR repo, so build.yml
never runs in this repo and this job is their only pre-merge build signal.

That could have argued for deleting the job, except the traffic says
otherwise: 72 of the last 100 PRs into release/** come from forks. The fork
case is the majority, not the exception. So the job earns its place — it just
should not duplicate build.yml for the own-origin 28%. Added the fork filter
to the existing `if`.

Also corrects the reliability claim in the previous commit message. Over a
wider window the job is not literally never-green: across 2026-08-13/14 it
reached `success` on roughly 10-15% of runs (13/138 on 08-14, 7/53 sampled on
08-13). Chronically unreliable, not permanently dead — the conclusion and the
fix are unchanged.

The #7307 guard in tests/unit/build/check-workflows.test.ts pinned the old
self-hosted expression, so it is realigned here: it now asserts the hosted
pin, the absence of self-hosted/USE_VPS_RUNNER in the job's DIRECTIVES (the
comment legitimately explains why the pool was abandoned, so the scan strips
comments), both memory settings, and the fork filter. Mutation-validated —
restoring self-hosted, dropping the swapfile, or flipping the fork filter each
turns it red.

check-workflows.test.ts: 32 pass, 0 fail.
check:workflows --ratchet: 186 findings, baseline 190, no regression.

---------

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-14 17:27:45 -03:00
Diego Rodrigues de Sa e Souza
8d1a59771a fix(providers): refresh the translate-path golden for the bailian Token Plan endpoint (#10410)
#10290 moved bailian-coding-plan from the Coding Plan host to the documented
Token Plan one, but the provider/translate-path golden still pinned
coding-intl.dashscope.aliyuncs.com, so tests/unit/provider-translate-path-golden.test.ts
fails on the release tip.

Regenerates the snapshot (UPDATE_GOLDEN=1) — the diff is exactly the two
bailian-coding-plan URLs, every other provider byte-identical — and fixes the
same stale host in the endpoint matrix of
docs/providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md.

This golden covers every provider's resolved URL, which is why neither the
focused tests nor typecheck caught the change: only the unit shard runs it.

Refs #9603

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-14 16:52:00 -03:00
Xiangzhe
bac3b4eb37 Merge remote-tracking branch 'origin/release/v3.8.50' into fix/radar-oss-cumulative-integration 2026-08-14 15:44:57 -03:00
Xiangzhe
7580feb1bf Merge remote-tracking branch 'origin/release/v3.8.50' into fix/radar-oss-cumulative-integration 2026-08-14 12:22:42 -03:00
Xiangzhe
be04c7aecb fix(radar): reconcile cumulative release integration 2026-08-14 12:21:43 -03:00
Xiangzhe
834e53b95b docs(radar): explain supporter key recovery 2026-08-14 11:02:37 -03:00
Xiangzhe
da148eddb7 docs(radar): document end-to-end activation 2026-08-14 11:02:36 -03:00
backryun
3784c35b29 fix(radar): validate aggregate sync body 2026-08-14 11:02:35 -03:00
backryun
74f146a7d4 feat(radar): surface verified model metadata 2026-08-14 11:02:34 -03:00
backryun
2256057f4a feat(radar): add owner-only admin link 2026-08-14 11:02:29 -03:00
backryun
b314b1b112 test(radar): move D12 guard out of OSS 2026-08-14 11:01:49 -03:00
backryun
b334d2d05a feat(radar): complete guided provider setup 2026-08-14 11:01:48 -03:00
diegosouzapw
baa68bfc06 docs(changelog): add Radar launch news fragment 2026-08-14 11:01:26 -03:00
diegosouzapw
d0aff219f5 feat(radar): prepare launch news surface 2026-08-14 11:01:25 -03:00
diegosouzapw
6f48ac4bfe docs(changelog): add Radar Intel fragment 2026-08-14 11:00:13 -03:00
diegosouzapw
e80ac605bc docs(radar): document Intel and CLI contract 2026-08-14 11:00:13 -03:00
diegosouzapw
1a55b20963 feat(cli): add Radar status and sync commands 2026-08-14 11:00:13 -03:00
diegosouzapw
a45076fa2a feat(radar): recognize supporters and add Intel UI 2026-08-14 11:00:12 -03:00
diegosouzapw
7859c843de feat(radar): sync signed Intel insights 2026-08-14 10:59:42 -03:00
diegosouzapw
b8a6557449 docs(changelog): record Radar supporter offers 2026-08-14 10:59:41 -03:00
diegosouzapw
fa7bf4df7b docs(radar): format offers route table 2026-08-14 10:59:41 -03:00
diegosouzapw
f806740a2f feat(radar): add supporter offers dashboard 2026-08-14 10:59:37 -03:00
diegosouzapw
e2e589c79c feat(radar): sync signed supporter offers 2026-08-14 10:58:11 -03:00
diegosouzapw
b11d55332b docs(changelog): record Radar guided combos 2026-08-14 10:58:11 -03:00
diegosouzapw
ee6695acad refactor(mcp): modularize Radar catalog tool 2026-08-14 10:58:10 -03:00
diegosouzapw
b6bba896b3 docs(radar): document guided combos and MCP 2026-08-14 10:58:09 -03:00
diegosouzapw
ea95cc2937 feat(mcp): expose Radar catalog tool 2026-08-14 10:52:20 -03:00
diegosouzapw
a801abc8e9 feat(radar): add guided combos page 2026-08-14 10:52:14 -03:00
diegosouzapw
86caa94baa feat(radar): build guided combo suggestions 2026-08-14 10:48:50 -03:00
diegosouzapw
2ebab9c686 docs(changelog): add Radar local state entry 2026-08-14 10:48:50 -03:00
diegosouzapw
340edbc997 feat(radar): persist local catalog state 2026-08-14 10:48:48 -03:00
718 changed files with 41800 additions and 5237 deletions

View File

@@ -45,6 +45,26 @@ INITIAL_PASSWORD=CHANGEME
# executor's on-disk thread-sticky session cache. Leave unset to rely on DATA_DIR. # executor's on-disk thread-sticky session cache. Leave unset to rely on DATA_DIR.
# OMNIROUTE_DATA_DIR=/var/lib/omniroute # OMNIROUTE_DATA_DIR=/var/lib/omniroute
# Escape hatch for the test-context DATA_DIR guard (#10428). A test run that never
# chose a DATA_DIR is redirected to a throwaway temp dir so it cannot open the
# operator's real database. Set to 1 only for a deliberate run against the real
# DATA_DIR — never for CI. Used by: src/lib/dataPaths.ts
# OMNIROUTE_ALLOW_DEFAULT_DATA_DIR=1
# Build provenance (#10427). OMNIROUTE_BUILD_SHA lets a container inject the artifact's git
# SHA when the dist/BUILD_SHA sentinel is absent; it is also what `npm run build:release`
# stamps. OMNIROUTE_RELEASE_REF is the ref the pack gate checks ancestry against, and
# OMNIROUTE_ALLOW_CANARY_BUILD=1
# API key the canary-deploy smoke uses when the target gateway requires auth (#10429).
# Used by: scripts/ops/deploy-canary.mjs — sent as `Authorization: Bearer` on the
# /v1/chat/completions probe. Never needed by the server itself.
# OMNIROUTE_SMOKE_API_KEY=sk-... records a deliberate off-release-line build instead of
# failing it. Used by: scripts/build/buildProvenance.ts, src/lib/monitoring/buildSha.ts
# OMNIROUTE_BUILD_SHA=abc1234
# OMNIROUTE_RELEASE_REF=origin/main
# OMNIROUTE_ALLOW_CANARY_BUILD=1
# Encryption key for SQLite database encryption at rest. # Encryption key for SQLite database encryption at rest.
# Used by: src/lib/db/encryption.ts — encrypts the entire SQLite database. # Used by: src/lib/db/encryption.ts — encrypts the entire SQLite database.
# Generate: openssl rand -hex 32 | Leave empty to disable DB encryption. # Generate: openssl rand -hex 32 | Leave empty to disable DB encryption.
@@ -694,6 +714,16 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Allow OmniRoute to write CLI config files (token refresh, etc.). # Allow OmniRoute to write CLI config files (token refresh, etc.).
# CLI_ALLOW_CONFIG_WRITES=true # CLI_ALLOW_CONFIG_WRITES=true
# Force container detection on (1/true) or off (0/false). Leave unset for auto-detect
# via /.dockerenv, /run/.containerenv, cgroup markers, or KUBERNETES_SERVICE_HOST.
# Used by: src/shared/utils/containerEnv.ts — gates ephemeral-home CLI config writes.
# OMNIROUTE_CONTAINER=1
# Allow CLI-tool config writes into an unmounted container path anyway (default off).
# Prefer host-side `omniroute configure` / Remote Mode, or a bind-mounted CLI_CONFIG_HOME.
# CLI equivalent: --allow-container-write. Used by: src/shared/utils/containerEnv.ts
# OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true
# Auto-sync CLI profile files after provider model discovery changes. OPT-IN, default OFF for # Auto-sync CLI profile files after provider model discovery changes. OPT-IN, default OFF for
# both. When enabled, writes only the tool's profile files (~/.codex/*.config.toml or # both. When enabled, writes only the tool's profile files (~/.codex/*.config.toml or
# ~/.claude/profiles/<name>/settings.json); never changes the active/default config. Both also # ~/.claude/profiles/<name>/settings.json); never changes the active/default config. Both also
@@ -715,6 +745,21 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# CLI_AUGGIE_BIN=auggie # CLI_AUGGIE_BIN=auggie
# AUGGIE_BIN=auggie # AUGGIE_BIN=auggie
# ── ZCode (Z.ai GLM coding-plan CLI) local provider ──
# The local "zcode" provider talks to the authenticated ZCode app-server over a
# custom framed stdio protocol. Overrides below tune that stdio lifecycle.
# ZCODE_BIN=zcode
# ZCODE_ARGS=["--some-flag"]
# ZCODE_CWD=
# ZCODE_PROVIDER_ID=builtin:zai-coding-plan
# ZCODE_SERVER_RUNTIME_ROOT=~/.zcode/server
# ZCODE_SERVER_NODE=~/.zcode/server/node
# ZCODE_SERVER_ENTRY=~/.zcode/server/zcode-server.cjs
# ZCODE_STARTUP_TIMEOUT_MS=10000
# ZCODE_RPC_TIMEOUT_MS=30000
# ZCODE_TURN_TIMEOUT_MS=120000
# ZCODE_POLL_INTERVAL_MS=250
# Override the Hermes Agent home directory (where OmniRoute reads/writes the # Override the Hermes Agent home directory (where OmniRoute reads/writes the
# Hermes CLI config). Matches the env var the Hermes PowerShell installer sets # Hermes CLI config). Matches the env var the Hermes PowerShell installer sets
# on Windows (%LOCALAPPDATA%\hermes); defaults to ~/.hermes when unset. # on Windows (%LOCALAPPDATA%\hermes); defaults to ~/.hermes when unset.
@@ -1499,6 +1544,10 @@ APP_LOG_TO_FILE=true
# Default: 100000 # Default: 100000
# PROXY_LOGS_TABLE_MAX_ROWS=100000 # PROXY_LOGS_TABLE_MAX_ROWS=100000
# Include client/egress IPs and account prefixes in [ProxyEgress] console logs.
# Default: false (the dashboard/database proxy-log records retain full details).
# PROXY_LOG_INCLUDE_IPS=false
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# 17. MEMORY OPTIMIZATION (Low-RAM / Docker) # 17. MEMORY OPTIMIZATION (Low-RAM / Docker)
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@@ -1849,6 +1898,13 @@ APP_LOG_TO_FILE=true
# PROXY_AUTO_REMOVE=false # PROXY_AUTO_REMOVE=false
# Consecutive failures before an auto-remove fires. Default: 3. # Consecutive failures before an auto-remove fires. Default: 3.
# PROXY_AUTO_REMOVE_AFTER=3 # PROXY_AUTO_REMOVE_AFTER=3
# Set "true" to let the scheduler auto-disable (status "dead") proxies after
# repeated failures instead of deleting them. Non-destructive alternative to
# PROXY_AUTO_REMOVE — the row stays in the registry, drops out of pool/rotation
# resolution immediately, and is automatically re-activated once it starts
# answering probes again. Shares the PROXY_AUTO_REMOVE_AFTER threshold above.
# If both PROXY_AUTO_REMOVE and PROXY_AUTO_DISABLE are "true", auto-remove wins.
# PROXY_AUTO_DISABLE=false
# Let automated reachability probes (the scheduler + the "Test All" button) WRITE # Let automated reachability probes (the scheduler + the "Test All" button) WRITE
# a proxy's status. Default "false": probes are read-only and never deactivate a # a proxy's status. Default "false": probes are read-only and never deactivate a
# proxy — only the operator sets active/inactive (a flaky probe must not strand an # proxy — only the operator sets active/inactive (a flaky probe must not strand an
@@ -2353,6 +2409,12 @@ APP_LOG_TO_FILE=true
# intended to be published as `omniroute-secure`. See SECURITY.md. # intended to be published as `omniroute-secure`. See SECURITY.md.
# OMNIROUTE_BUILD_PROFILE=full # OMNIROUTE_BUILD_PROFILE=full
# Skip emitting `.tar.gz` tarballs during optional-pack staging for the Electron
# standalone tree (pack directories + optional-packs.index.json are still produced).
# Used by the desktop release workflow to trim artifact upload size.
# Default (when unset): 1 (tarballs emitted). Set to 0 to disable.
# OMNIROUTE_OPTIONAL_PACK_TAR=1
# Electron smoke harness (used by scripts/dev/smoke-electron-packaged.mjs). # Electron smoke harness (used by scripts/dev/smoke-electron-packaged.mjs).
# ELECTRON_SMOKE_URL=http://127.0.0.1:20128/login # ELECTRON_SMOKE_URL=http://127.0.0.1:20128/login
# ELECTRON_SMOKE_TIMEOUT_MS=45000 # ELECTRON_SMOKE_TIMEOUT_MS=45000
@@ -2652,9 +2714,9 @@ QUOTA_STORE_DRIVER=sqlite
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# Optional add-on (feature flag RADAR_ENABLED, default off — see feature flag # Optional add-on (feature flag RADAR_ENABLED, default off — see feature flag
# settings, not an env var) that overlays a signed, freshly-curated free-model # settings, not an env var) that overlays a signed, freshly-curated free-model
# catalog on top of the release baseline. All four variables below are optional # catalog on top of the release baseline. The first four variables below are
# and only needed to point the client at a self-hosted/forked feed or # optional overrides for a self-hosted/forked feed or supporter-key flow. The
# supporter-key flow instead of the default OmniRoute Radar service. Used by: # fifth is an optional, default-free link to the owner's private operations panel. Used by:
# src/lib/radar/sync.ts, src/lib/radar/pinnedKeys.ts, src/lib/radar/links.ts. # src/lib/radar/sync.ts, src/lib/radar/pinnedKeys.ts, src/lib/radar/links.ts.
# Base URL of the Radar feed service. Overrides the built-in default so forks # Base URL of the Radar feed service. Overrides the built-in default so forks
@@ -2675,6 +2737,11 @@ QUOTA_STORE_DRIVER=sqlite
# page). No pricing/value lives in this repo — only the link. # page). No pricing/value lives in this repo — only the link.
# RADAR_SUPPORTER_PLANS_URL=https://radar.omniroute.online/planos # RADAR_SUPPORTER_PLANS_URL=https://radar.omniroute.online/planos
# Owner-only link to the private Radar operations panel. There is deliberately
# no default: when unset or invalid, no "Radar Admin" navigation item exists.
# Use HTTPS for a tunnel/tailnet URL, or HTTP only for an SSH loopback forward.
# RADAR_ADMIN_URL=http://127.0.0.1:9351
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# 27. RELEASE v3.8.50 ADDITIONS # 27. RELEASE v3.8.50 ADDITIONS
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════

View File

@@ -18,7 +18,7 @@ Vitest, the 60% coverage gate, and the production build all run in CI on this PR
- [ ] `npm run lint` - [ ] `npm run lint`
- [ ] Reconciled with the current active release base; focused checks rerun afterward - [ ] Reconciled with the current active release base; focused checks rerun afterward
- [ ] Production-code changes include a new or updated automated test in this PR - [ ] Production-code changes include a new or updated automated test in this PR
- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below - SonarQube is temporarily opt-in while the private project has no quota; it is not a PR gate.
## Tests Added Or Updated ## Tests Added Or Updated

View File

@@ -697,11 +697,12 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 30 timeout-minutes: 30
needs: build needs: build
# WS1.5 (v3.8.49 plan): the Electron rebuild/spawn path previously executed for # WS1.5 (v3.8.49 plan): the Electron native-module path previously executed for
# the FIRST time on the release tag — the v3.8.48 Windows bug (npx.cmd spawned # the FIRST time on the release tag — the v3.8.48 Windows bug (npx.cmd spawned
# without shell, CVE-2024-27980 behavior change) could only surface at release. # without shell, CVE-2024-27980 behavior change) could only surface at release.
# windows-latest runs prepare:bundle (the ABI rebuild + spawn plan) per release # windows-latest runs prepare:bundle (better-sqlite3 prebuild verification since
# PR; ubuntu keeps the full pack + headless smoke. # v13 — the node-gyp rebuild is gone) per release PR; ubuntu keeps the full
# pack + headless smoke.
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -738,7 +739,7 @@ jobs:
# precedent): its first-ever real run (2026-07-15, run 29457533565) died in # precedent): its first-ever real run (2026-07-15, run 29457533565) died in
# 0.7s with the error swallowed by pwsh — bash shell captures stderr and # 0.7s with the error swallowed by pwsh — bash shell captures stderr and
# continue-on-error keeps the heavy gate green while we harden it (#7336). # continue-on-error keeps the heavy gate green while we harden it (#7336).
- name: Prepare Electron standalone (Windows ABI rebuild + spawn path) - name: Prepare Electron standalone (Windows prebuild verification)
if: runner.os == 'Windows' if: runner.os == 'Windows'
working-directory: electron working-directory: electron
continue-on-error: true continue-on-error: true
@@ -998,7 +999,10 @@ jobs:
name: SonarQube name: SonarQube
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test-coverage needs: test-coverage
if: ${{ !cancelled() && needs.test-coverage.result == 'success' }} # Temporarily opt-in: the private project currently has no Sonar quota.
# Re-enable without another code change by setting the repository Actions
# variable SONARQUBE_ENABLED=true after quota/project access is restored.
if: ${{ vars.SONARQUBE_ENABLED == 'true' && !cancelled() && needs.test-coverage.result == 'success' }}
env: env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
@@ -1397,7 +1401,7 @@ jobs:
echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| i18n Glossary (zh-CN, ko) | $(status '${{ needs.i18n-glossary-zhcn.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| i18n Glossary (zh-CN, ko) | $(status '${{ needs.i18n-glossary-zhcn.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| SonarQube (opt-in; disabled without SONARQUBE_ENABLED=true) | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY"
echo "## 🏗️ Build" >> "$GITHUB_STEP_SUMMARY" echo "## 🏗️ Build" >> "$GITHUB_STEP_SUMMARY"

View File

@@ -22,10 +22,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with: with:
languages: javascript-typescript languages: javascript-typescript
queries: security-extended queries: security-extended
- uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with: with:
category: "/language:javascript-typescript" category: "/language:javascript-typescript"

View File

@@ -37,7 +37,7 @@ jobs:
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm
- run: npm ci - uses: ./.github/actions/npm-ci-retry
- name: Build CLI bundle - name: Build CLI bundle
env: env:
OMNIROUTE_BUILD_BACKEND_ONLY: "1" OMNIROUTE_BUILD_BACKEND_ONLY: "1"

View File

@@ -372,7 +372,7 @@ jobs:
- name: Upload Trivy SARIF to Security tab - name: Upload Trivy SARIF to Security tab
if: needs.prepare.outputs.version != 'main' if: needs.prepare.outputs.version != 'main'
continue-on-error: true continue-on-error: true
uses: github/codeql-action/upload-sarif@v4.37.4 uses: github/codeql-action/upload-sarif@v4.37.6
with: with:
sarif_file: trivy-results.sarif sarif_file: trivy-results.sarif
category: trivy-image category: trivy-image

View File

@@ -55,9 +55,75 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "✓ Valid version: $VERSION" echo "✓ Valid version: $VERSION"
web-build:
name: Build shared Next standalone
needs: validate
# Stage 8 (issue #10321): the four desktop legs used to each run the full
# `npm run build` (Next standalone) — ~111 runner-minutes per release just to
# produce the same platform-independent bundle four times. This job builds it
# once on ubuntu; every leg then restores the byte-verified archive and
# re-forks its native optionals (scripts/build/standaloneBundle.mjs).
#
# Rollback lever: set the repo variable ELECTRON_SHARED_STANDALONE=disabled.
# This job then skips, every leg falls back to building its own web bundle
# (the legacy step below), and the pipeline behaves exactly like pre-Stage 8 —
# no revert needed.
if: ${{ !cancelled() && needs.validate.result == 'success' && vars.ELECTRON_SHARED_STANDALONE != 'disabled' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Install dependencies
run: npm ci
env:
NPM_CONFIG_LEGACY_PEER_DEPS: true
- name: Build Next.js standalone
# webpack, not Turbopack, for the same hosted-runner RAM reason as the
# linux leg (see the long comment on the fallback step in `build`).
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
NODE_OPTIONS: "--max_old_space_size=6144"
OMNIROUTE_USE_TURBOPACK: "0"
run: npm run build
- name: Pack standalone bundle
# Deterministic tar.gz + byte-level manifest; the manifest embeds the
# archive's own sha256 so artifact-transfer corruption is caught before
# extraction, and every entry is re-verified after extraction.
run: node scripts/build/standaloneBundle.mjs pack --out web-bundle.tar.gz
- name: Upload shared web bundle
uses: actions/upload-artifact@v7
with:
name: web-standalone-bundle
# compression-level 0: the payload is already a deterministic tar.gz;
# re-zipping would only burn runner CPU without shrinking it further.
compression-level: 0
# Legs consume this within minutes; no reason to retain it like the
# installer artifacts (default 90d).
retention-days: 3
path: |
web-bundle.tar.gz
web-bundle.tar.gz.manifest.json
build: build:
name: Build Electron (${{ matrix.platform }}) name: Build Electron (${{ matrix.platform }})
needs: validate needs: [validate, web-build]
# `web-build` is skipped when ELECTRON_SHARED_STANDALONE=disabled (rollback
# mode); legs then run the legacy per-leg web build below. If it ran and
# failed, fail closed: legs cannot package without the bundle, and silently
# falling back to four per-leg builds would hide exactly the regression the
# shared job exists to surface.
if: ${{ !cancelled() && needs.validate.result == 'success' && (needs.web-build.result == 'success' || needs.web-build.result == 'skipped') }}
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
permissions: permissions:
contents: write # electron-builder may publish artifacts with GH_TOKEN contents: write # electron-builder may publish artifacts with GH_TOKEN
@@ -69,19 +135,27 @@ jobs:
runner: windows-latest runner: windows-latest
target: win target: win
ext: .exe ext: .exe
os: win32
arch: x64
- platform: macos-intel - platform: macos-intel
runner: macos-15-intel runner: macos-15-intel
target: mac-x64 target: mac-x64
ext: .dmg ext: .dmg
os: darwin
arch: x64
- platform: macos-arm64 - platform: macos-arm64
runner: macos-latest runner: macos-latest
target: mac-arm64 target: mac-arm64
ext: -arm64.dmg ext: -arm64.dmg
os: darwin
arch: arm64
- platform: linux - platform: linux
runner: ubuntu-latest runner: ubuntu-latest
target: linux target: linux
ext: .AppImage ext: .AppImage
deb_ext: .deb deb_ext: .deb
os: linux
arch: x64,arm64
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
@@ -93,14 +167,6 @@ jobs:
node-version: 24 node-version: 24
cache: npm cache: npm
- name: Cache node_modules
uses: actions/cache@v6.1.0
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
env: env:
@@ -116,7 +182,11 @@ jobs:
mkdir -p "$RUNNER_TEMP/home" mkdir -p "$RUNNER_TEMP/home"
echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV" echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV"
- name: Build Next.js standalone - name: Build Next.js standalone (legacy per-leg fallback)
# Stage 8: only runs in rollback mode (ELECTRON_SHARED_STANDALONE=disabled)
# or when the shared web-build job was skipped. Otherwise the leg restores
# the shared bundle from the `web-build` job below.
if: needs.web-build.result == 'skipped'
env: env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
NODE_OPTIONS: "--max_old_space_size=6144" NODE_OPTIONS: "--max_old_space_size=6144"
@@ -134,6 +204,30 @@ jobs:
OMNIROUTE_USE_TURBOPACK: ${{ matrix.platform == 'linux' && '0' || '1' }} OMNIROUTE_USE_TURBOPACK: ${{ matrix.platform == 'linux' && '0' || '1' }}
run: npm run build run: npm run build
- name: Download shared web bundle
# Stage 8: inverse of the fallback step above — runs exactly when the
# shared `web-build` job produced the bundle.
if: needs.web-build.result == 'success'
uses: actions/download-artifact@v8
with:
name: web-standalone-bundle
- name: Restore + hydrate shared web bundle
if: needs.web-build.result == 'success'
shell: bash
# restore: verify the archive's sha256 against the manifest, extract, then
# re-verify every entry (existence + size + content hash + symlink
# targets, and no unlisted files) byte-for-byte.
# hydrate: the bundle was built on ubuntu, so install-machine-forked native
# optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*,
# fsevents) carry linux forks. Replace them with the forks this
# leg's own `npm ci` resolved, then assert every bundled native
# (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime)
# can service this leg's platform/arch before packaging starts.
run: |
node scripts/build/standaloneBundle.mjs restore --archive web-bundle.tar.gz
node scripts/build/standaloneBundle.mjs hydrate --platform ${{ matrix.os }} --arch ${{ matrix.arch }}
- name: Sync version in electron/package.json - name: Sync version in electron/package.json
shell: bash shell: bash
env: env:
@@ -158,7 +252,7 @@ jobs:
- name: Install Electron dependencies - name: Install Electron dependencies
working-directory: electron working-directory: electron
run: npm install --no-audit --no-fund run: npm ci --no-audit --no-fund
- name: Build Electron for ${{ matrix.platform }} - name: Build Electron for ${{ matrix.platform }}
working-directory: electron working-directory: electron

View File

@@ -60,13 +60,49 @@ jobs:
build: build:
name: Build (advisory) name: Build (advisory)
needs: changes needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }} # FORK PRs ONLY. build.yml's `Fast Production Build` triggers on `push: branches: ["**"]`
# Dynamic runner — same fork-safe rule as ci.yml / fast-gates. # and runs `build:release` — a superset of this job — so for an own-origin branch this job
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} # was building the same tree twice. A fork contributor pushes to THEIR repo, so that push
# never fires here, and this is the only pre-merge build signal they get. Measured
# 2026-08-14: 72 of the last 100 PRs into release/** came from forks, so the fork case is
# the majority of the traffic, not the exception — this job earns its place, it just should
# not duplicate build.yml for the own-origin 28%.
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true' && github.event.pull_request.head.repo.full_name != github.repository) }}
# PINNED to hosted — this was the last job in THIS workflow still on the USE_VPS_RUNNER
# switch (ci.yml's Build, nightly-release-green and npm-publish keep it, so the variable
# stays meaningful), and with USE_VPS_RUNNER=true it produced NO signal at all here.
# Measured 2026-08-14 over the last 25
# quality.yml runs: not one Build (advisory) reached a conclusion. Every sample was either
# queued on the self-hosted pool (2 runners, `omniroute-113-6/7`, both permanently busy — one
# job sat queued 2h+ and was still unclaimed) or, when it did land, killed mid-build by this
# workflow's own `cancel-in-progress` concurrency. 6/6 sampled "failures" are exit 143 /
# "The runner has received a shutdown signal" at ~3.5 min into `npm run build` — zero OOM,
# zero build errors. So the job burned a scarce runner that the gates actually need while
# reporting a permanent red on every PR.
#
# Gap 19 left USE_VPS_RUNNER governing build-like jobs on the premise that "the build needs
# the .113's RAM". That premise no longer holds: `Fast Production Build` (build.yml) runs
# `build:release` — a SUPERSET of this job's `npm run build`, plus the CLI bundle — on plain
# ubuntu-latest and passed 24/25 of its last runs in ~15 min. What it has and this job did
# not is memory PROVISIONING: a 10 GB swapfile plus a 12 GB V8 heap. That matters because
# --max-old-space-size only bounds V8's JS heap, never Turbopack's native (Rust) allocation
# (#6409) — swap is what absorbs the native peak. Both are mirrored below.
runs-on: ubuntu-latest
# #7307: advisory for the first week of release-PR runs; remove # #7307: advisory for the first week of release-PR runs; remove
# continue-on-error after the production-build signal is stable. # continue-on-error after the production-build signal is stable.
continue-on-error: true continue-on-error: true
steps: steps:
# Mirrors build.yml: Turbopack's native peak is not bounded by --max-old-space-size, so
# the hosted runner needs swap headroom before the build starts.
- name: Expand virtual memory (10 GB swap)
run: |
sudo swapoff -a || true
sudo rm -f /mnt/swapfile /swapfile
sudo fallocate -l 10G /mnt/swapfile || sudo dd if=/dev/zero of=/mnt/swapfile bs=1M count=10240
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
free -h
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
persist-credentials: false persist-credentials: false
@@ -79,6 +115,10 @@ jobs:
- run: npm run build - run: npm run build
env: env:
OMNIROUTE_USE_TURBOPACK: "1" OMNIROUTE_USE_TURBOPACK: "1"
# Same heap build.yml proves sufficient. build-next-isolated.mjs defaults to 8192 and
# honours OMNIROUTE_BUILD_MEMORY_MB; NODE_OPTIONS is set for parity with build.yml.
NODE_OPTIONS: "--max-old-space-size=12288"
OMNIROUTE_BUILD_MEMORY_MB: "12288"
# No artifact upload here: the PR-to-release quality workflow has no # No artifact upload here: the PR-to-release quality workflow has no
# downstream package/e2e jobs that consume the Next.js build output. # downstream package/e2e jobs that consume the Next.js build output.
@@ -97,7 +137,7 @@ jobs:
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- run: npm ci - uses: ./.github/actions/npm-ci-retry
# One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently). # One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently).
- run: npm run check:api-docs-refs - run: npm run check:api-docs-refs
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict) - name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
@@ -141,7 +181,7 @@ jobs:
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- run: npm ci - uses: ./.github/actions/npm-ci-retry
- name: Restore ESLint file cache - name: Restore ESLint file cache
uses: actions/cache@v6 uses: actions/cache@v6
with: with:
@@ -390,7 +430,7 @@ jobs:
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- run: npm ci - uses: ./.github/actions/npm-ci-retry
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR, # WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR,
# which is where flaky-detection volume actually comes from (ci.yml's heavy # which is where flaky-detection volume actually comes from (ci.yml's heavy
# jobs only run on the release PR). Advisory upload, own-origin only. # jobs only run on the release PR). Advisory upload, own-origin only.
@@ -436,7 +476,7 @@ jobs:
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- run: npm ci - uses: ./.github/actions/npm-ci-retry
# QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do # QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do
# comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes # comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes
# silenciosamente não rodavam no fast path) e o setupPolyfill não era importado. # silenciosamente não rodavam no fast path) e o setupPolyfill não era importado.
@@ -476,7 +516,7 @@ jobs:
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- run: npm ci - uses: ./.github/actions/npm-ci-retry
- name: Restore ESLint file cache - name: Restore ESLint file cache
uses: actions/cache@v6 uses: actions/cache@v6
with: with:
@@ -543,7 +583,7 @@ jobs:
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- run: npm ci - uses: ./.github/actions/npm-ci-retry
- name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result) - name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result)
run: npm run check:changelog-integrity run: npm run check:changelog-integrity
- name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo) - name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo)

3
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# project-specific directories # project-specific directories
/output/
.slim/deepwork/ .slim/deepwork/
.omnivscodeagent/ .omnivscodeagent/
omnirouteCloud/ omnirouteCloud/
@@ -69,6 +70,8 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
# Local gitleaks artifacts (do not commit)
gitleaks-local.json
!.env.example !.env.example
!.env.homolog.example !.env.homolog.example
!.env.devin-bridge.example !.env.devin-bridge.example

View File

@@ -14,3 +14,11 @@ open-sse/config/freeModelCatalog.data.ts
# Prettier reformats the frontmatter (blank line after ---), which makes the gate # Prettier reformats the frontmatter (blank line after ---), which makes the gate
# fail on any skill that happens to pass through lint-staged. # fail on any skill that happens to pass through lint-staged.
skills/*/SKILL.md skills/*/SKILL.md
# check:changelog-integrity compares release bullets against the base as exact
# strings. Prettier normalizes markdown emphasis inside them (*from* -> _from_)
# and re-wraps table rows, so any PR that stages CHANGELOG.md would "lose" base
# bullets and turn the merge-integrity job red. The changelog is generated and
# reconciled by scripts/release/*, which are its formatter of record.
CHANGELOG.md
docs/i18n/*/CHANGELOG.md

View File

@@ -46,22 +46,22 @@ Repository map and Reference Documentation sections below.
## Project at a Glance ## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 339 LLM providers, auto-fallback. **OmniRoute** — unified AI proxy/router. One endpoint, 341 LLM providers, auto-fallback.
| Layer | Location | Purpose | | Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| API Routes | `src/app/api/v1/` | Next.js App Router — entry points | | API Routes | `src/app/api/v1/` | Next.js App Router — entry points |
| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) | | Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) |
| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch | | Executors | `open-sse/executors/` | Provider-specific HTTP dispatch |
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | | Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | | Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | | Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (145 migrations) | | Database | `src/lib/db/` | SQLite domain modules (150 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | | Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 105 tools (43 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 31 scopes | | 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 | | A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
| Skills | `src/lib/skills/` | Extensible skill framework | | Skills | `src/lib/skills/` | Extensible skill framework |
| Memory | `src/lib/memory/` | Persistent conversational memory | | Memory | `src/lib/memory/` | Persistent conversational memory |
Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point). Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point).
@@ -118,11 +118,18 @@ upstream/service level, so one unhealthy provider does not slow down every reque
- `HALF_OPEN`: reset timeout has elapsed; allow a probe request. Success closes the - `HALF_OPEN`: reset timeout has elapsed; allow a probe request. Success closes the
breaker, failure opens it again. breaker, failure opens it again.
**Defaults** (`open-sse/config/constants.ts`): **Defaults** (`open-sse/config/constants.ts``PROVIDER_PROFILES`). Two thresholds live side by
side — do not confuse them:
- OAuth providers: threshold `3`, reset timeout `60s`. | Profile | `providerFailureThreshold` (whole provider) | `providerCooldownMs` | `circuitBreakerThreshold` (one connection) | `circuitBreakerReset` |
- API-key providers: threshold `5`, reset timeout `30s`. | ------- | ------------------------------------------: | -------------------: | -----------------------------------------: | --------------------: |
- Local providers: threshold `2`, reset timeout `15s`. | OAuth | `10` | `5min` | `8` | `60s` |
| API key | `15` | `10min` | `12` | `30s` |
| Local | `2` | `1min` | `2` | `15s` |
The provider-level thresholds were scaled up for deployments with 500+ connections (OAuth was
`3`, API key was `5`); every default is overridable through the `OMNIROUTE_PROVIDER_BREAKER_*`
and `OMNIROUTE_CIRCUIT_BREAKER_*` env vars.
Only provider-level failure statuses should trip the provider breaker: Only provider-level failure statuses should trip the provider breaker:
@@ -679,7 +686,7 @@ the stale-enforcement added in Fase 6A.3.
causa-raiz de DOIS wipes (2026-08-08 e 2026-08-10: `git reset --hard` materializou o causa-raiz de DOIS wipes (2026-08-08 e 2026-08-10: `git reset --hard` materializou o
symlink rastreado por cima do diretório real e o git apagou todo o conteúdo ignorado sem symlink rastreado por cima do diretório real e o git apagou todo o conteúdo ignorado sem
aviso); (c) após qualquer escrita relevante, `git -C _tasks add -A && git -C _tasks commit aviso); (c) após qualquer escrita relevante, `git -C _tasks add -A && git -C _tasks commit
&& git -C _tasks push` — o push frequente é o backup real; (d) repetir esta proibição && git -C _tasks push` — o push frequente é o backup real; (d) repetir esta proibição
VERBATIM no prompt de todo subagente que toque git; (e) se `_tasks` aparecer como symlink VERBATIM no prompt de todo subagente que toque git; (e) se `_tasks` aparecer como symlink
quebrado, NÃO commitar nada — restaurar do remote e avisar o operador. O gate quebrado, NÃO commitar nada — restaurar do remote e avisar o operador. O gate
`check:tracked-artifacts` (pre-commit + CI) bloqueia `_tasks` rastreado em qualquer forma. `check:tracked-artifacts` (pre-commit + CI) bloqueia `_tasks` rastreado em qualquer forma.

View File

@@ -148,9 +148,26 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
- **feat(admission) — direct pushes:** adaptive overload/pressure controls with shared admission wired across the LLM routes, plus mutation-test registration for the capability-filter suite - **feat(admission) — direct pushes:** adaptive overload/pressure controls with shared admission wired across the LLM routes, plus mutation-test registration for the capability-filter suite
- **feat(agentrouter) — direct pushes:** support Claude and Codex protocols — infer the protocol from the client endpoint and honor the alternate protocol through the chat pipeline - **feat(agentrouter) — direct pushes:** support Claude and Codex protocols — infer the protocol from the client endpoint and honor the alternate protocol through the chat pipeline
- **feat(providers) — direct pushes:** ChatGPT Web session credential guide with a Cookie Editor fast-path (canonical chromewebstore install link) and web-session fast-path test coverage - **feat(providers) — direct pushes:** ChatGPT Web session credential guide with a Cookie Editor fast-path (canonical chromewebstore install link) and web-session fast-path test coverage
- **feat(sse):** honor provider-rule lock scope for agentrouter (connection vs model) ([#10419](https://github.com/diegosouzapw/OmniRoute/pull/10419))
- **feat(ocr):** Vertex AI DeepSeek-OCR provider ([#10398](https://github.com/diegosouzapw/OmniRoute/pull/10398))
- **feat(providers):** derive imageToText from the OCR registry + chutes dots.ocr seed ([#10400](https://github.com/diegosouzapw/OmniRoute/pull/10400))
- **feat(ocr):** multi-provider /v1/ocr with transformation layer (Azure Document Intelligence) ([#10283](https://github.com/diegosouzapw/OmniRoute/pull/10283))
- **feat(providers):** declare imageToText serviceKind on major vision providers ([#10275](https://github.com/diegosouzapw/OmniRoute/pull/10275))
- **feat(bridge):** native-vision skip guard + configurable describe output cap ([#10289](https://github.com/diegosouzapw/OmniRoute/pull/10289))
- **feat(bridge):** normalize images to 2048px long edge before vision describe self-call ([#10287](https://github.com/diegosouzapw/OmniRoute/pull/10287))
- **feat(sse):** restate agentrouter quota 403/400 as retryable 429 with provider-scoped error rules ([#10335](https://github.com/diegosouzapw/OmniRoute/pull/10335))
- **feat(sse):** add i-have-adhd output style to compression catalog ([#10271](https://github.com/diegosouzapw/OmniRoute/pull/10271))
- **feat(codex):** add OAuth fingerprint convergence modes ([#10243](https://github.com/diegosouzapw/OmniRoute/pull/10243)) — thanks @xz-dev
- **feat(i18n):** complete Portuguese (PT-PT) translation ([#10250](https://github.com/diegosouzapw/OmniRoute/pull/10250)) — thanks @DarkEsteves
- **feat(providers):** publish Poolside's probed Laguna Preview catalog ([#10216](https://github.com/diegosouzapw/OmniRoute/pull/10216)) — thanks @pacocartones
- **feat(crof):** advertise reasoning effort tiers incl. max from live discovery and registry ([#10062](https://github.com/diegosouzapw/OmniRoute/pull/10062)) — thanks @excessivechaos
- **feat(open-sse):** expose provider-level circuit breaker thresholds via env vars (#10040) ([#10046](https://github.com/diegosouzapw/OmniRoute/pull/10046)) — thanks @tiangao88
- **feat(dashboard):** Kimi 15% first-top-up campaign — dedicated tracked link + discount-first banner copy ([#10240](https://github.com/diegosouzapw/OmniRoute/pull/10240))
- **feat(providers):** integrate audited free-tier gateways ([#9210](https://github.com/diegosouzapw/OmniRoute/pull/9210))
### 🐛 Bug Fixes ### 🐛 Bug Fixes
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
- **executors**: fix internal timeout misclassified as client disconnect (499) for 7 niche executors — pass TimeoutError reason to controller.abort() (#8197 side-finding) - **executors**: fix internal timeout misclassified as client disconnect (499) for 7 niche executors — pass TimeoutError reason to controller.abort() (#8197 side-finding)
- test(combo): guard auto/best-free never leaks the combo name as a model (#7754) - test(combo): guard auto/best-free never leaks the combo name as a model (#7754)
- fix(vision-bridge): describe-model no longer returns unreachable "openai/gpt-4o-mini" when every vision-capable provider is unreachable on the instance — returns null instead and surfaces a clear error (#8430) - fix(vision-bridge): describe-model no longer returns unreachable "openai/gpt-4o-mini" when every vision-capable provider is unreachable on the instance — returns null instead and surfaces a clear error (#8430)
@@ -553,6 +570,63 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
- **fix(i18n) — direct pushes:** restore/unescape HTML entities in UI strings, translate capability-filter messages, complete web-session guide translations and Vietnamese parity - **fix(i18n) — direct pushes:** restore/unescape HTML entities in UI strings, translate capability-filter messages, complete web-session guide translations and Vietnamese parity
- **fix(providers) — direct pushes:** repair the DeepAI registry import + executor - **fix(providers) — direct pushes:** repair the DeepAI registry import + executor
- **fix(deps) — direct pushes:** CVE-driven bumps (nanoid, dompurify, mermaid, js-yaml + transitive deps for 26 Dependabot alerts) and retained isolated-build runtime dependencies in the pack - **fix(deps) — direct pushes:** CVE-driven bumps (nanoid, dompurify, mermaid, js-yaml + transitive deps for 26 Dependabot alerts) and retained isolated-build runtime dependencies in the pack
- **fix(ci):** pin Build (advisory) to a hosted runner with memory provisioning ([#10408](https://github.com/diegosouzapw/OmniRoute/pull/10408))
- **fix(providers):** refresh the translate-path golden for the bailian Token Plan endpoint ([#10410](https://github.com/diegosouzapw/OmniRoute/pull/10410))
- **fix(sse):** surface Qwen/Alibaba personal Token Plan quota in dashboard and preflight ([#10290](https://github.com/diegosouzapw/OmniRoute/pull/10290))
- **fix(deps):** pin next to an exact version so a fresh upstream release cannot break installs ([#10340](https://github.com/diegosouzapw/OmniRoute/pull/10340))
- **fix(types):** restore custom model output limit contract ([#10339](https://github.com/diegosouzapw/OmniRoute/pull/10339)) — thanks @backryun
- **fix(sse):** stop the executor-contract guard from hot-looping the router ([#10373](https://github.com/diegosouzapw/OmniRoute/pull/10373))
- **fix(types):** validate nonstreaming JSON contracts ([#10258](https://github.com/diegosouzapw/OmniRoute/pull/10258)) — thanks @backryun
- **fix(types):** narrow refresh token rotation inputs ([#10257](https://github.com/diegosouzapw/OmniRoute/pull/10257)) — thanks @backryun
- **fix(types):** normalize executor result contracts ([#10256](https://github.com/diegosouzapw/OmniRoute/pull/10256)) — thanks @backryun
- **fix(types):** align Responses stream options ([#10255](https://github.com/diegosouzapw/OmniRoute/pull/10255)) — thanks @backryun
- **fix(types):** narrow combo credential preflight ([#10254](https://github.com/diegosouzapw/OmniRoute/pull/10254)) — thanks @backryun
- **fix(compression):** cap countTextTokens at 50k chars and strip base64 data URIs ([#10118](https://github.com/diegosouzapw/OmniRoute/pull/10118)) — thanks @adevwithpurpose
- **fix(ci):** clear base-reds on release/v3.8.50 (round 4) ([#10260](https://github.com/diegosouzapw/OmniRoute/pull/10260))
- **fix(sse):** extract perplexity-web answers from workflow_block ([#10259](https://github.com/diegosouzapw/OmniRoute/pull/10259)) — thanks @jeyhunfaslanov
- **fix(mcp):** persist and re-attach Gemini thoughtSignature on the direct Claude<->Gemini path ([#9448](https://github.com/diegosouzapw/OmniRoute/pull/9448)) — thanks @Sam280903
- **fix(opencode-plugin):** respect log level for lifecycle output (#8982) ([#9316](https://github.com/diegosouzapw/OmniRoute/pull/9316)) — thanks @xiaoyaner0201
- **fix(providers):** raise default provider probe timeout from 5s to 8s ([#9283](https://github.com/diegosouzapw/OmniRoute/pull/9283)) — thanks @Sam280903
- **fix(opencode-plugin):** stop warning when an auto combo replaces its expected /v1/models twin (#8983) ([#9042](https://github.com/diegosouzapw/OmniRoute/pull/9042)) — thanks @xiaoyaner0201
- **fix(opencode):** force CLI User-Agent when CLI identity synthesis is enabled ([#10222](https://github.com/diegosouzapw/OmniRoute/pull/10222)) — thanks @adevwithpurpose
- **fix(deepseek-web):** classify business auth rejection as 401 ([#10218](https://github.com/diegosouzapw/OmniRoute/pull/10218)) — thanks @Zartharas
- **fix(combo):** make failoverBeforeRetry actually skip the same-model retry ([#10217](https://github.com/diegosouzapw/OmniRoute/pull/10217)) — thanks @hartmark
- **fix(responses):** preserve case-insensitive combo names before Codex rewrite ([#10177](https://github.com/diegosouzapw/OmniRoute/pull/10177)) — thanks @ddarkr
- **fix(discovery):** parse reasoning tiers nested under metadata.reasoning.supported_efforts ([#10138](https://github.com/diegosouzapw/OmniRoute/pull/10138)) — thanks @excessivechaos
- **fix(combo):** isolate session stickiness by combo ([#10137](https://github.com/diegosouzapw/OmniRoute/pull/10137)) — thanks @hydraxman
- **fix(combo):** default chaos SSE to comment-only for OpenAI-compatible clients ([#10128](https://github.com/diegosouzapw/OmniRoute/pull/10128)) — thanks @herjarsa
- **fix(kimi):** normalize MFJS tool schemas ([#10079](https://github.com/diegosouzapw/OmniRoute/pull/10079)) — thanks @xz-dev
- **fix(mcp):** move pack validation out of unit suite ([#10065](https://github.com/diegosouzapw/OmniRoute/pull/10065)) — thanks @yansigit
- **fix(zed-hosted):** send the provider wire values cloud.zed.dev accepts ([#10051](https://github.com/diegosouzapw/OmniRoute/pull/10051)) — thanks @ARC345
- **fix(ci):** repair and wire the two live-server E2E suites ([#10050](https://github.com/diegosouzapw/OmniRoute/pull/10050)) — thanks @ARC345
- **fix(reasoning):** preserve and replay assistant turns ([#10045](https://github.com/diegosouzapw/OmniRoute/pull/10045)) — thanks @jackjinke
- **fix(types):** tighten chatCore helper contracts ([#10175](https://github.com/diegosouzapw/OmniRoute/pull/10175)) — thanks @backryun
- **fix(cli):** read the full provider catalog instead of the 6-entry fallback ([#10097](https://github.com/diegosouzapw/OmniRoute/pull/10097)) — thanks @amartinawi
- **fix(cli):** stop swallowing non-2xx responses into benign-looking results ([#10092](https://github.com/diegosouzapw/OmniRoute/pull/10092)) — thanks @amartinawi
- **fix(cli):** openapi endpoints/paths/validate accept the served catalog shape ([#10091](https://github.com/diegosouzapw/OmniRoute/pull/10091)) — thanks @amartinawi
- **fix(cli):** doctor detects prebuilt better-sqlite3 binaries ([#10090](https://github.com/diegosouzapw/OmniRoute/pull/10090)) — thanks @amartinawi
- **fix(providers):** kilo-gateway authType should be optional, not apikey ([#10086](https://github.com/diegosouzapw/OmniRoute/pull/10086)) — thanks @TengSivtean
- **fix(cli):** strip inline comments when parsing .env values ([#10101](https://github.com/diegosouzapw/OmniRoute/pull/10101)) — thanks @amartinawi
- **fix(logging):** document CHAT_LOG_MAX_BODY_KB, capture messageCount for Responses API bodies ([#10038](https://github.com/diegosouzapw/OmniRoute/pull/10038)) — thanks @hartmark
- **fix(dashboard):** expose OpenAI Responses store toggle for non-Codex connections ([#10121](https://github.com/diegosouzapw/OmniRoute/pull/10121)) — thanks @hartmark
- **fix(combo):** clear LKGP pin when its target fails, not only set it on success ([#10034](https://github.com/diegosouzapw/OmniRoute/pull/10034)) — thanks @hartmark
- **fix(sse):** provider-response summary format bugs (dashboard Provider Response panel) ([#10037](https://github.com/diegosouzapw/OmniRoute/pull/10037)) — thanks @hartmark
- **fix(responses-api):** tool call after reasoning collided on the same output_index ([#10025](https://github.com/diegosouzapw/OmniRoute/pull/10025)) — thanks @hartmark
- **fix(responses-api):** explicit function-tool declaration must win over apply_patch-is-custom fallback ([#10041](https://github.com/diegosouzapw/OmniRoute/pull/10041)) — thanks @hartmark
- **fix(kimi):** recupera limite temporario sem bloquear conta ([#10058](https://github.com/diegosouzapw/OmniRoute/pull/10058)) — thanks @bortolidiego
- **fix(translator):** preserve Responses custom tools for OpenAI-compatible providers ([#10114](https://github.com/diegosouzapw/OmniRoute/pull/10114)) — thanks @mtb-ninja
- **fix(providers):** xai-oauth chat→responses body + missing breaker import (#10165) ([#10170](https://github.com/diegosouzapw/OmniRoute/pull/10170)) — thanks @nordz0r
- **fix(ollama-cloud):** map xhigh reasoning effort to max ([#10160](https://github.com/diegosouzapw/OmniRoute/pull/10160)) — thanks @Chewji9875
- **fix(translator):** strip Codex encrypted tool-schema key for Gemini/Antigravity ([#10053](https://github.com/diegosouzapw/OmniRoute/pull/10053)) — thanks @XDayonline
- **fix(combo):** preserve OpenCode Free oc/ prefix for connections ([#10180](https://github.com/diegosouzapw/OmniRoute/pull/10180)) — thanks @AStupidBear
- **fix(sse):** apply free-tier filter to auto/best-free on chat path ([#10199](https://github.com/diegosouzapw/OmniRoute/pull/10199)) — thanks @ggdayup
- **fix(providers):** default missing cache_control.ttl to 1h on the native Claude OAuth path ([#10221](https://github.com/diegosouzapw/OmniRoute/pull/10221)) — thanks @jeff-alves
- **fix(ci):** clear base-reds on release/v3.8.50 (round 3) ([#10213](https://github.com/diegosouzapw/OmniRoute/pull/10213))
- **fix(security):** correct XML double-unescape and non-CSPRNG nonce from CodeQL sweep ([#10154](https://github.com/diegosouzapw/OmniRoute/pull/10154))
- **fix(docker):** eliminate npm-bundled CVEs from the published image ([#10182](https://github.com/diegosouzapw/OmniRoute/pull/10182))
- **fix(security):** resolve open CodeQL alerts ([#10188](https://github.com/diegosouzapw/OmniRoute/pull/10188))
- **fix(dashboard):** retarget Kimi promo CTA to the API platform aff link ([#10200](https://github.com/diegosouzapw/OmniRoute/pull/10200))
- **fix(build):** repair broken production build, red lint gate and SWR crash ([#10198](https://github.com/diegosouzapw/OmniRoute/pull/10198))
### 📝 Maintenance ### 📝 Maintenance
@@ -698,6 +772,10 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
- **deps (rollup):** dependency bumps and lockfile maintenance across the cycle — Dependabot groups and manual CVE-driven bumps ([#9081](https://github.com/diegosouzapw/OmniRoute/pull/9081), [#9082](https://github.com/diegosouzapw/OmniRoute/pull/9082), [#9427](https://github.com/diegosouzapw/OmniRoute/pull/9427), [#9458](https://github.com/diegosouzapw/OmniRoute/pull/9458), [#9459](https://github.com/diegosouzapw/OmniRoute/pull/9459), [#9461](https://github.com/diegosouzapw/OmniRoute/pull/9461), [#9462](https://github.com/diegosouzapw/OmniRoute/pull/9462), [#9472](https://github.com/diegosouzapw/OmniRoute/pull/9472)) - **deps (rollup):** dependency bumps and lockfile maintenance across the cycle — Dependabot groups and manual CVE-driven bumps ([#9081](https://github.com/diegosouzapw/OmniRoute/pull/9081), [#9082](https://github.com/diegosouzapw/OmniRoute/pull/9082), [#9427](https://github.com/diegosouzapw/OmniRoute/pull/9427), [#9458](https://github.com/diegosouzapw/OmniRoute/pull/9458), [#9459](https://github.com/diegosouzapw/OmniRoute/pull/9459), [#9461](https://github.com/diegosouzapw/OmniRoute/pull/9461), [#9462](https://github.com/diegosouzapw/OmniRoute/pull/9462), [#9472](https://github.com/diegosouzapw/OmniRoute/pull/9472))
- **docs/chore (rollup):** documentation, refactoring and repository-hygiene upkeep across the cycle ([#8954](https://github.com/diegosouzapw/OmniRoute/pull/8954), [#8991](https://github.com/diegosouzapw/OmniRoute/pull/8991), [#9059](https://github.com/diegosouzapw/OmniRoute/pull/9059), [#9194](https://github.com/diegosouzapw/OmniRoute/pull/9194), [#9258](https://github.com/diegosouzapw/OmniRoute/pull/9258), [#9508](https://github.com/diegosouzapw/OmniRoute/pull/9508)) - **docs/chore (rollup):** documentation, refactoring and repository-hygiene upkeep across the cycle ([#8954](https://github.com/diegosouzapw/OmniRoute/pull/8954), [#8991](https://github.com/diegosouzapw/OmniRoute/pull/8991), [#9059](https://github.com/diegosouzapw/OmniRoute/pull/9059), [#9194](https://github.com/diegosouzapw/OmniRoute/pull/9194), [#9258](https://github.com/diegosouzapw/OmniRoute/pull/9258), [#9508](https://github.com/diegosouzapw/OmniRoute/pull/9508))
- **main-branch plumbing (rollup):** work that landed on `main` between cycles and was carried into this one — the Mergify merge-queue migration and tuning (#7168, #7179, #7216, #7220, #7225), npm-publish unblock via dynamic runner + CI build reuse (#8941), CodeQL-driven e2e mock hardening (#7559), hermetic self-ref guard (#6634, #7341), coverage-baseline tightening (#7347), Dependabot alert resolutions via npm overrides (#8067, #8070), README flag/doc-link polish (#8317), and the v3.8.49 release plumbing itself (#7076) - **main-branch plumbing (rollup):** work that landed on `main` between cycles and was carried into this one — the Mergify merge-queue migration and tuning (#7168, #7179, #7216, #7220, #7225), npm-publish unblock via dynamic runner + CI build reuse (#8941), CodeQL-driven e2e mock hardening (#7559), hermetic self-ref guard (#6634, #7341), coverage-baseline tightening (#7347), Dependabot alert resolutions via npm overrides (#8067, #8070), README flag/doc-link polish (#8317), and the v3.8.49 release plumbing itself (#7076)
- **deps:** bump the development group across 1 directory with 22 updates ([#10043](https://github.com/diegosouzapw/OmniRoute/pull/10043)) — thanks @app/dependabot
- **deps:** bump electron from 43.2.0 to 43.3.0 in /electron ([#10042](https://github.com/diegosouzapw/OmniRoute/pull/10042)) — thanks @app/dependabot
- **maint(release):** 45 direct pushes to the release branch with no PR ref — base-red and quality-gate repairs, i18n string completion and stream/type fixes (quality ×6, i18n ×5, deps ×3, agentrouter ×3, providers ×2, release ×2, security ×2, logging ×2)
- **maint(repo):** 29 chore/ci/test/docs commits rolled up — quality baselines, mutation registration, CI re-triggers, doc restructure and repo hygiene (#10187, #10189, #10190, #10193, #10196, #10203, #10204, #10205, #10207, #10210, #10236, #10318)
### 🙌 Contributors ### 🙌 Contributors
@@ -706,7 +784,7 @@ Thanks to everyone whose work landed in v3.8.50:
| Contributor | PRs / Issues | | Contributor | PRs / Issues |
| --- | --- | | --- | --- |
| [@AbdullahFageeh](https://github.com/AbdullahFageeh) | #9087 | | [@AbdullahFageeh](https://github.com/AbdullahFageeh) | #9087 |
| [@adevwithpurpose](https://github.com/adevwithpurpose) | #9790 | | [@adevwithpurpose](https://github.com/adevwithpurpose) | #9790, #10118, #10222 |
| [@adrianojiu](https://github.com/adrianojiu) | #8438 | | [@adrianojiu](https://github.com/adrianojiu) | #8438 |
| [@agisota](https://github.com/agisota) | #9837 | | [@agisota](https://github.com/agisota) | #9837 |
| [@AgnesRiber](https://github.com/AgnesRiber) | #9718, #9976 | | [@AgnesRiber](https://github.com/AgnesRiber) | #9718, #9976 |
@@ -714,21 +792,24 @@ Thanks to everyone whose work landed in v3.8.50:
| [@AIB1TAL0S](https://github.com/AIB1TAL0S) | #9284 | | [@AIB1TAL0S](https://github.com/AIB1TAL0S) | #9284 |
| [@AlanSyue](https://github.com/AlanSyue) | direct commit / report | | [@AlanSyue](https://github.com/AlanSyue) | direct commit / report |
| [@alex-jordan547](https://github.com/alex-jordan547) | #9235, #9245, #9813 | | [@alex-jordan547](https://github.com/alex-jordan547) | #9235, #9245, #9813 |
| [@amartinawi](https://github.com/amartinawi) | #10090, #10091, #10092, #10097, #10101 |
| [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #8888, #8889, #8890, #8891, #8892, #8893, #8894, #8895 | | [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #8888, #8889, #8890, #8891, #8892, #8893, #8894, #8895 |
| [@AnhLead](https://github.com/AnhLead) | #9722 | | [@AnhLead](https://github.com/AnhLead) | #9722 |
| [@aniketshukla1](https://github.com/aniketshukla1) | #9148 | | [@aniketshukla1](https://github.com/aniketshukla1) | #9148 |
| [@Anjielon](https://github.com/Anjielon) | #8776 | | [@Anjielon](https://github.com/Anjielon) | #8776 |
| [@apoapostolov](https://github.com/apoapostolov) | #8916 | | [@apoapostolov](https://github.com/apoapostolov) | #8916 |
| [@ARC345](https://github.com/ARC345) | #9628 | | [@ARC345](https://github.com/ARC345) | #9628, #10050, #10051 |
| [@artickc](https://github.com/artickc) | #8571, #8578, #8791, #8843, #8870, #8927, #8974, #9097, #9549 | | [@artickc](https://github.com/artickc) | #8571, #8578, #8791, #8843, #8870, #8927, #8974, #9097, #9549 |
| [@Arul-](https://github.com/Arul-) | #9761 | | [@Arul-](https://github.com/Arul-) | #9761 |
| [@AStupidBear](https://github.com/AStupidBear) | #10180 |
| [@b1nhm1nh](https://github.com/b1nhm1nh) | direct commit / report | | [@b1nhm1nh](https://github.com/b1nhm1nh) | direct commit / report |
| [@backryun](https://github.com/backryun) | #8228, #8451, #8627, #8809, #8818, #9084, #9086, #9090, #9091, #9092, #9093, #9114, #9119, #9120, #9122, #9135, #9136, #9137, #9138, #9139, #9141, #9561, #9562, #9563, #9564, #9565, #9566, #9742, #9747, #9748, #9751, #9753, #9755, #9791, #9792, #9793, #9795, #9796, #9797, #9798, #9920, #9972, #9973, #9974, #9975, #9977, #9978, #9979, #9984, #9986, #9987, #9988, #9989, #9990, #9998, #10087, #10088, #10134, #10178 | | [@backryun](https://github.com/backryun) | #8228, #8451, #8627, #8809, #8818, #9084, #9086, #9090, #9091, #9092, #9093, #9114, #9119, #9120, #9122, #9135, #9136, #9137, #9138, #9139, #9141, #9561, #9562, #9563, #9564, #9565, #9566, #9742, #9747, #9748, #9751, #9753, #9755, #9791, #9792, #9793, #9795, #9796, #9797, #9798, #9920, #9972, #9973, #9974, #9975, #9977, #9978, #9979, #9984, #9986, #9987, #9988, #9989, #9990, #9998, #10087, #10088, #10134, #10178, #10175, #10254, #10255, #10256, #10257, #10258, #10339 |
| [@Benson-mk](https://github.com/Benson-mk) | #8369 | | [@Benson-mk](https://github.com/Benson-mk) | #8369 |
| [@benzntech](https://github.com/benzntech) | #9810, #9812, #9939 | | [@benzntech](https://github.com/benzntech) | #9810, #9812, #9939 |
| [@Bl0ck154](https://github.com/Bl0ck154) | #9231 | | [@Bl0ck154](https://github.com/Bl0ck154) | #9231 |
| [@bortolidiego](https://github.com/bortolidiego) | #10058 |
| [@branben](https://github.com/branben) | #9940 | | [@branben](https://github.com/branben) | #9940 |
| [@Chewji9875](https://github.com/Chewji9875) | #9257, #9420, #9821, #9994 | | [@Chewji9875](https://github.com/Chewji9875) | #9257, #9420, #9821, #9994, #10160 |
| [@chirag127](https://github.com/chirag127) | #6674 | | [@chirag127](https://github.com/chirag127) | #6674 |
| [@chloeassistant](https://github.com/chloeassistant) | #9675, #9746 | | [@chloeassistant](https://github.com/chloeassistant) | #9675, #9746 |
| [@configurowebmax](https://github.com/configurowebmax) | #8877 | | [@configurowebmax](https://github.com/configurowebmax) | #8877 |
@@ -736,7 +817,9 @@ Thanks to everyone whose work landed in v3.8.50:
| [@costaeder](https://github.com/costaeder) | #8626, #8629, #8630 | | [@costaeder](https://github.com/costaeder) | #8626, #8629, #8630 |
| [@csoftware-arigpt](https://github.com/csoftware-arigpt) | #3440 | | [@csoftware-arigpt](https://github.com/csoftware-arigpt) | #3440 |
| [@DaDecky](https://github.com/DaDecky) | direct commit / report | | [@DaDecky](https://github.com/DaDecky) | direct commit / report |
| [@ddarkr](https://github.com/ddarkr) | #9035, #9036 | | [@DarkEsteves](https://github.com/DarkEsteves) | #10250 |
| [@ddarkr](https://github.com/ddarkr) | #9035, #9036, #10177 |
| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer |
| [@Dingding-leo](https://github.com/Dingding-leo) | #7987, #8640, #8678, #8704, #8774, #8790, #8808, #8817 | | [@Dingding-leo](https://github.com/Dingding-leo) | #7987, #8640, #8678, #8704, #8774, #8790, #8808, #8817 |
| [@DinonowDev](https://github.com/DinonowDev) | #8804 | | [@DinonowDev](https://github.com/DinonowDev) | #8804 |
| [@Dragost](https://github.com/Dragost) | #8339 | | [@Dragost](https://github.com/Dragost) | #8339 |
@@ -745,26 +828,31 @@ Thanks to everyone whose work landed in v3.8.50:
| [@engmarcosjr](https://github.com/engmarcosjr) | #9993 | | [@engmarcosjr](https://github.com/engmarcosjr) | #9993 |
| [@epsilonode](https://github.com/epsilonode) | #8871 | | [@epsilonode](https://github.com/epsilonode) | #8871 |
| [@ervareza](https://github.com/ervareza) | direct commit / report | | [@ervareza](https://github.com/ervareza) | direct commit / report |
| [@excessivechaos](https://github.com/excessivechaos) | #10062, #10138 |
| [@fajarhide](https://github.com/fajarhide) | #9191, #9198 | | [@fajarhide](https://github.com/fajarhide) | #9191, #9198 |
| [@fenix007](https://github.com/fenix007) | #9618 | | [@fenix007](https://github.com/fenix007) | #9618 |
| [@Gecky2102](https://github.com/Gecky2102) | #9280 | | [@Gecky2102](https://github.com/Gecky2102) | #9280 |
| [@ggdayup](https://github.com/ggdayup) | #10199 |
| [@Gioxaa](https://github.com/Gioxaa) | #9162, #9171 | | [@Gioxaa](https://github.com/Gioxaa) | #9162, #9171 |
| [@HaoNgo232](https://github.com/HaoNgo232) | direct commit / report | | [@HaoNgo232](https://github.com/HaoNgo232) | direct commit / report |
| [@Hariprajwal](https://github.com/Hariprajwal) | #9922 | | [@Hariprajwal](https://github.com/Hariprajwal) | #9922 |
| [@hartmark](https://github.com/hartmark) | #9635, #9704, #9711, #9712, #9727, #9734, #9735, #9738, #9741, #9744, #9745, #9822 | | [@hartmark](https://github.com/hartmark) | #9635, #9704, #9711, #9712, #9727, #9734, #9735, #9738, #9741, #9744, #9745, #9822, #10025, #10034, #10037, #10038, #10041, #10121, #10217 |
| [@Hdiaktoros](https://github.com/Hdiaktoros) | #8930 | | [@Hdiaktoros](https://github.com/Hdiaktoros) | #8930 |
| [@HectorBernstorff](https://github.com/HectorBernstorff) | direct commit / report | | [@HectorBernstorff](https://github.com/HectorBernstorff) | direct commit / report |
| [@HellFiveOsborn](https://github.com/HellFiveOsborn) | #9248 | | [@HellFiveOsborn](https://github.com/HellFiveOsborn) | #9248 |
| [@herjarsa](https://github.com/herjarsa) | #9714, #9816, #9937, #9946 | | [@herjarsa](https://github.com/herjarsa) | #9714, #9816, #9937, #9946, #10128 |
| [@horacecar](https://github.com/horacecar) | #7679 | | [@horacecar](https://github.com/horacecar) | #7679 |
| [@HouMinXi](https://github.com/HouMinXi) | #8886, #8904, #8905, #8976, #8984, #9079, #9106, #9207, #9242, #9328, #9340, #9342, #9351, #9365, #9380, #9381, #9392, #9449, #9482, #9483, #9509, #9510, #9572, #9631, #9634, #9695, #9929 | | [@HouMinXi](https://github.com/HouMinXi) | #8886, #8904, #8905, #8976, #8984, #9079, #9106, #9207, #9242, #9328, #9340, #9342, #9351, #9365, #9380, #9381, #9392, #9449, #9482, #9483, #9509, #9510, #9572, #9631, #9634, #9695, #9929 |
| [@hppsc1215](https://github.com/hppsc1215) | #8970 | | [@hppsc1215](https://github.com/hppsc1215) | #8970 |
| [@hydraxman](https://github.com/hydraxman) | #10137 |
| [@Iammilansoni](https://github.com/Iammilansoni) | #9353, #9397 | | [@Iammilansoni](https://github.com/Iammilansoni) | #9353, #9397 |
| [@ikelvingo](https://github.com/ikelvingo) | #8591, #8872, #9053 | | [@ikelvingo](https://github.com/ikelvingo) | #8591, #8872, #9053 |
| [@infinit-X](https://github.com/infinit-X) | #9095 | | [@infinit-X](https://github.com/infinit-X) | #9095 |
| [@isaaclb98](https://github.com/isaaclb98) | #9730 | | [@isaaclb98](https://github.com/isaaclb98) | #9730 |
| [@jackjinke](https://github.com/jackjinke) | #9556, #9601, #10005 | | [@jackjinke](https://github.com/jackjinke) | #9556, #9601, #10005, #10045 |
| [@jax-novita](https://github.com/jax-novita) | #8913 | | [@jax-novita](https://github.com/jax-novita) | #8913 |
| [@jeff-alves](https://github.com/jeff-alves) | #10221 |
| [@jeyhunfaslanov](https://github.com/jeyhunfaslanov) | #10259 |
| [@jhordanjw123](https://github.com/jhordanjw123) | #8736 | | [@jhordanjw123](https://github.com/jhordanjw123) | #8736 |
| [@jktan0504](https://github.com/jktan0504) | #9025 | | [@jktan0504](https://github.com/jktan0504) | #9025 |
| [@joachimBrindeau](https://github.com/joachimBrindeau) | #9200 | | [@joachimBrindeau](https://github.com/joachimBrindeau) | #9200 |
@@ -795,12 +883,15 @@ Thanks to everyone whose work landed in v3.8.50:
| [@MohitRawat017](https://github.com/MohitRawat017) | #8718, #8772, #9605 | | [@MohitRawat017](https://github.com/MohitRawat017) | #8718, #8772, #9605 |
| [@Momen4444](https://github.com/Momen4444) | #9612 | | [@Momen4444](https://github.com/Momen4444) | #9612 |
| [@MrShitFox](https://github.com/MrShitFox) | #9826 | | [@MrShitFox](https://github.com/MrShitFox) | #9826 |
| [@mtb-ninja](https://github.com/mtb-ninja) | #10114 |
| [@MumuTW](https://github.com/MumuTW) | #8839 | | [@MumuTW](https://github.com/MumuTW) | #8839 |
| [@mvanhorn](https://github.com/mvanhorn) | #9542 | | [@mvanhorn](https://github.com/mvanhorn) | #9542 |
| [@Mynacol](https://github.com/Mynacol) | #9733 | | [@Mynacol](https://github.com/Mynacol) | #9733 |
| [@nguyenha935](https://github.com/nguyenha935) | #9044, #9215 | | [@nguyenha935](https://github.com/nguyenha935) | #9044, #9215 |
| [@nordz0r](https://github.com/nordz0r) | #10170 |
| [@nosolosoft](https://github.com/nosolosoft) | #8900 | | [@nosolosoft](https://github.com/nosolosoft) | #8900 |
| [@oyi77](https://github.com/oyi77) | #8299, #8752, #9158, #9818 | | [@oyi77](https://github.com/oyi77) | #8299, #8752, #9158, #9818 |
| [@pacocartones](https://github.com/pacocartones) | #10216 |
| [@PixmaNts](https://github.com/PixmaNts) | #9432 | | [@PixmaNts](https://github.com/PixmaNts) | #9432 |
| [@pizzav-xyz](https://github.com/pizzav-xyz) | #9077 | | [@pizzav-xyz](https://github.com/pizzav-xyz) | #9077 |
| [@Poid-ZA](https://github.com/Poid-ZA) | #9467 | | [@Poid-ZA](https://github.com/Poid-ZA) | #9467 |
@@ -817,7 +908,7 @@ Thanks to everyone whose work landed in v3.8.50:
| [@ryanngit](https://github.com/ryanngit) | direct commit / report | | [@ryanngit](https://github.com/ryanngit) | direct commit / report |
| [@sadSanta-07](https://github.com/sadSanta-07) | #9938 | | [@sadSanta-07](https://github.com/sadSanta-07) | #9938 |
| [@SalyyS1](https://github.com/SalyyS1) | direct commit / report | | [@SalyyS1](https://github.com/SalyyS1) | direct commit / report |
| [@Sam280903](https://github.com/Sam280903) | #9274, #9278, #9281 | | [@Sam280903](https://github.com/Sam280903) | #9274, #9278, #9281, #9283, #9448 |
| [@seakleangnhak](https://github.com/seakleangnhak) | direct commit / report | | [@seakleangnhak](https://github.com/seakleangnhak) | direct commit / report |
| [@seanford](https://github.com/seanford) | #8523 | | [@seanford](https://github.com/seanford) | #8523 |
| [@SemonCat](https://github.com/SemonCat) | direct commit / report | | [@SemonCat](https://github.com/SemonCat) | direct commit / report |
@@ -831,27 +922,28 @@ Thanks to everyone whose work landed in v3.8.50:
| [@tald26](https://github.com/tald26) | #9959 | | [@tald26](https://github.com/tald26) | #9959 |
| [@taltas](https://github.com/taltas) | direct commit / report | | [@taltas](https://github.com/taltas) | direct commit / report |
| [@TechNickAI](https://github.com/TechNickAI) | #9251 | | [@TechNickAI](https://github.com/TechNickAI) | #9251 |
| [@TengSivtean](https://github.com/TengSivtean) | #10000, #10002 | | [@TengSivtean](https://github.com/TengSivtean) | #10000, #10002, #10086 |
| [@TheFrenchGhosty](https://github.com/TheFrenchGhosty) | #9326 | | [@TheFrenchGhosty](https://github.com/TheFrenchGhosty) | #9326 |
| [@tiangao88](https://github.com/tiangao88) | #10046 |
| [@tuxmonteiro](https://github.com/tuxmonteiro) | #9065 | | [@tuxmonteiro](https://github.com/tuxmonteiro) | #9065 |
| [@vinogradovnet](https://github.com/vinogradovnet) | #9581 | | [@vinogradovnet](https://github.com/vinogradovnet) | #9581 |
| [@VXNCXNX](https://github.com/VXNCXNX) | #9111, #9783 | | [@VXNCXNX](https://github.com/VXNCXNX) | #9111, #9783 |
| [@wgordon17](https://github.com/wgordon17) | #8909, #9233, #9441, #9619 | | [@wgordon17](https://github.com/wgordon17) | #8909, #9233, #9441, #9619 |
| [@Witroch4](https://github.com/Witroch4) | #8713 | | [@Witroch4](https://github.com/Witroch4) | #8713 |
| [@witt3rd](https://github.com/witt3rd) | #9962, #9963 | | [@witt3rd](https://github.com/witt3rd) | #9962, #9963 |
| [@xiaoyaner0201](https://github.com/xiaoyaner0201) | #8757, #8869, #8876, #8883, #8906, #8931, #9021, #9027, #9452 | | [@XDayonline](https://github.com/XDayonline) | #10053 |
| [@xz-dev](https://github.com/xz-dev) | #8908, #9199, #9205, #9262, #9290, #9313, #9555, #9569, #9629, #9788, #9983 | | [@xiaoyaner0201](https://github.com/xiaoyaner0201) | #8757, #8869, #8876, #8883, #8906, #8931, #9021, #9027, #9452, #9042, #9316 |
| [@yansigit](https://github.com/yansigit) | #9834, #9911, #9917, #9921 | | [@xz-dev](https://github.com/xz-dev) | #8908, #9199, #9205, #9262, #9290, #9313, #9555, #9569, #9629, #9788, #9983, #10079, #10243 |
| [@yansigit](https://github.com/yansigit) | #9834, #9911, #9917, #9921, #10065 |
| [@yidecode](https://github.com/yidecode) | direct commit / report | | [@yidecode](https://github.com/yidecode) | direct commit / report |
| [@yulinlina](https://github.com/yulinlina) | #10013 | | [@yulinlina](https://github.com/yulinlina) | #10013 |
| [@yutuknown](https://github.com/yutuknown) | #8999 | | [@yutuknown](https://github.com/yutuknown) | #8999 |
| [@zabrodschiipavel-sketch](https://github.com/zabrodschiipavel-sketch) | #9312 | | [@zabrodschiipavel-sketch](https://github.com/zabrodschiipavel-sketch) | #9312 |
| [@Zartharas](https://github.com/Zartharas) | #9161, #9164, #9181, #9182, #9184, #9185, #9186, #9189, #9294, #9825, #9833, #9936, #9965, #9992 | | [@Zartharas](https://github.com/Zartharas) | #9161, #9164, #9181, #9182, #9184, #9185, #9186, #9189, #9294, #9825, #9833, #9936, #9965, #9992, #10218 |
| [@Zenlyte](https://github.com/Zenlyte) | #9005 | | [@Zenlyte](https://github.com/Zenlyte) | #9005 |
| [@zhiru](https://github.com/zhiru) | #9099, #9101 | | [@zhiru](https://github.com/zhiru) | #9099, #9101 |
| [@ziuus](https://github.com/ziuus) | #8912 | | [@ziuus](https://github.com/ziuus) | #8912 |
| [@zuckdorsey](https://github.com/zuckdorsey) | #9723 | | [@zuckdorsey](https://github.com/zuckdorsey) | #9723 |
| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer |
--- ---

View File

@@ -8,8 +8,8 @@ WORKDIR /app
# that already have a fix published in trixie. CVEs without an upstream fix yet # that already have a fix published in trixie. CVEs without an upstream fix yet
# (local-only TOCTOU, etc.) remain until the distro patches them and the image # (local-only TOCTOU, etc.) remain until the distro patches them and the image
# is rebuilt; none are reachable from the proxy's request surface at runtime. # is rebuilt; none are reachable from the proxy's request surface at runtime.
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \ --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \ apt-get update \
&& apt-get upgrade -y \ && apt-get upgrade -y \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \ && apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
@@ -61,8 +61,8 @@ FROM base AS builder
# Build tools for native module compilation # Build tools for native module compilation
# apt-get update needed here because base's rm -rf clears the shared cache # apt-get update needed here because base's rm -rf clears the shared cache
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \ --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \ apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \ && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -108,7 +108,7 @@ RUN test -f package-lock.json \
# in production (TlsClientUnavailableError, #7802). Run it explicitly here so # in production (TlsClientUnavailableError, #7802). Run it explicitly here so
# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a # a broken/rate-limited fetch fails the BUILD loudly instead of shipping a
# broken image. # broken image.
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \ npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
&& (cd node_modules/better-sqlite3 \ && (cd node_modules/better-sqlite3 \
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \ && node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
@@ -158,7 +158,7 @@ ARG OMNIROUTE_BUILD_MEMORY_MB=4096
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}" ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
COPY . ./ COPY . ./
RUN --mount=type=cache,id=next-cache,target=/app/.build/next/cache \ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \
mkdir -p /app/data \ mkdir -p /app/data \
&& npm run build \ && npm run build \
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', '@tensorflow/tfjs', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);" && node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', '@tensorflow/tfjs', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
@@ -262,8 +262,8 @@ COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
# browsers land under /home/node which persists across image layers and is # browsers land under /home/node which persists across image layers and is
# accessible to the non-root runtime user. # accessible to the non-root runtime user.
ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \ --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \ apt-get update \
&& node node_modules/playwright/cli.js install chromium --with-deps \ && node node_modules/playwright/cli.js install chromium --with-deps \
&& chown -R node:node /home/node/.cache \ && chown -R node:node /home/node/.cache \
@@ -284,15 +284,15 @@ COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright-
COPY --from=builder /app/node_modules/playwright ./node_modules/playwright COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
# Install system dependencies required by openclaw (git+ssh references). # Install system dependencies required by openclaw (git+ssh references).
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \ --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \ apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \ && apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/" && git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
# Install CLI tools globally. Separate layer from apt for better cache reuse. # Install CLI tools globally. Separate layer from apt for better cache reuse.
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
USER node USER node

139
README.md
View File

@@ -7,7 +7,7 @@
# 🚀 OmniRoute — The Free AI Gateway # 🚀 OmniRoute — The Free AI Gateway
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 339 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. 339 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 → 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."/>
</div> </div>
@@ -63,10 +63,10 @@
| | v3.8.49 | **v3.8.50** | `v3.8.51+` | | | v3.8.49 | **v3.8.50** | `v3.8.51+` |
| ------------------------- | :-----: | :---------: | :---------: | | ------------------------- | :-----: | :---------: | :---------: |
| 🌐 Providers | 291 | **339** | more queued | | 🌐 Providers | 290 | **341** | more queued |
| 🧠 Documented models | 500+ | **1200+** | — | | 🧠 Documented models | 1185 | **1202** | — |
| 🖼️ Modality Bridge | — | 🆕 vision | video | | 🖼️ Modality Bridge | — | 🆕 vision | video |
| 📡 Radar free catalog | — | — | 🔭 next | | 📡 Radar free catalog | — | 🆕 opt-in | — |
| ⚖️ Quota-aware scheduling | — | — | 🔭 next | | ⚖️ Quota-aware scheduling | — | — | 🔭 next |
| 📊 Quota telemetry | — | — | 🔭 next | | 📊 Quota telemetry | — | — | 🔭 next |
@@ -101,7 +101,7 @@
<tr> <tr>
<td align="right"><b>⚙️ Features</b></td> <td align="right"><b>⚙️ Features</b></td>
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td> <td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
<td align="center"><a href="#-339-ai-providers--90-free">🌐 Providers</a></td> <td align="center"><a href="#-341-ai-providers--90-free">🌐 Providers</a></td>
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI &amp; MCP</a></td> <td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI &amp; MCP</a></td>
</tr> </tr>
<tr> <tr>
@@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \
</div> </div>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 339 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 339 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, 40+ 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 105 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/> <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)."/>
<br/> <br/>
<br/> <br/>
@@ -449,7 +449,7 @@ All **19** strategies — mix & match per combo step:
### 🧱 Resilience is built in (3 independent layers) ### 🧱 Resilience is built in (3 independent layers)
<img src="./docs/diagrams/resilience-layers.svg" width="100%" alt="OmniRoute resilience — 3 independent self-healing layers, the right layer for the right failure. Layer 1 provider circuit breaker (whole provider): trips only on 408/5xx, thresholds OAuth 3× / API-key 5× / local 2×, resets 60s/30s/15s into a HALF-OPEN probe, lazy recovery; while OPEN the combo reroutes to the next provider. Layer 2 connection cooldown (one key/account): base 5s OAuth / 3s API-key, exponential ×2 backoff with anti-thundering-herd guard, 429 honors Retry-After, success clears all error state; one cooling key is skipped while sibling keys keep serving. Layer 3 model lockout (one model): per-model 429, local 404 or mode denials lock just that model — never the whole connection. Terminal states (banned, expired, credits exhausted) are for the operator, not cooldowns."/> <img src="./docs/diagrams/resilience-layers.svg" width="100%" alt="OmniRoute resilience — 3 independent self-healing layers, the right layer for the right failure. Layer 1 provider circuit breaker (whole provider): trips only on 408/5xx, thresholds OAuth 10× / API-key 15× / local 2×, resets 60s/30s/15s into a HALF-OPEN probe, lazy recovery; while OPEN the combo reroutes to the next provider. Layer 2 connection cooldown (one key/account): base 5s OAuth / 3s API-key, exponential ×2 backoff with anti-thundering-herd guard, 429 honors Retry-After, success clears all error state; one cooling key is skipped while sibling keys keep serving. Layer 3 model lockout (one model): per-model 429, local 404 or mode denials lock just that model — never the whole connection. Terminal states (banned, expired, credits exhausted) are for the operator, not cooldowns."/>
<sub>📖 [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) · [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)</sub> <sub>📖 [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) · [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)</sub>
@@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step:
</div> </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: 339 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 105 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: 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."/>
<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> <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>
@@ -513,6 +513,27 @@ Pix copia-e-cola:
<br/> <br/>
<p><strong>Developer notes:</strong> The project may generate a local <code>.env</code> file during npm install/postinstall for developer convenience. This file is intentionally ignored via <code>.gitignore</code> (see <code>.gitignore</code>) and must never be committed — if accidentally committed, rotate any exposed secrets and remove the file from history. See <a href="docs/DEVELOPER-ENVIRONMENT.md">docs/DEVELOPER-ENVIRONMENT.md</a> for guidance on managing local environment files and secrets.</p>
## 📡 OmniRoute Radar
The main free-tier headline remains **~1.53B tokens/month** from the documented,
pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
month to **~2.15B**. Radar is an optional, signed catalog overlay for people who want fresher
free-model availability between OmniRoute releases; the community catalog and every existing free
feature remain free.
Supporters can receive the live catalog and additional provider opportunities. Its separate,
mutable ceiling is **approximately 3B tokens/month at most**, depending on provider availability.
That ceiling is not a guarantee: providers can change quotas, eligibility, models, or regions at
any time.
Radar is opt-in and GET-only. The OmniRoute client does not upload prompts, traffic, provider
configuration, usage telemetry, or local announcement-dismiss state. Learn about eligibility and
the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute.online/planos)**.
<br/>
<div align="center"> <div align="center">
## ✨ What's New ## ✨ What's New
@@ -538,7 +559,7 @@ Pix copia-e-cola:
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md) - **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Google Imagen, Segmind, EdgeTTS. → [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) - **🌍 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 **339-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 **341-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) - **📡 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) - **⚡ 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)
@@ -591,7 +612,7 @@ Pix copia-e-cola:
<b> also works with</b> · Kiro · Command Code · Antigravity · Windsurf · AMP · <b>any OpenAI-compatible tool</b> <b> also works with</b> · Kiro · Command Code · Antigravity · Windsurf · AMP · <b>any OpenAI-compatible tool</b>
</div> </div>
<sub>📖 Per-tool setup for all 33 tools (25 CLI Code's + 8 CLI Agents) → [`docs/reference/CLI-TOOLS.md`](docs/reference/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub> <sub>📖 Per-tool setup for all 34 tools (26 CLI Code's + 8 CLI Agents) → [`docs/reference/CLI-TOOLS.md`](docs/reference/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub>
</div> </div>
@@ -599,11 +620,11 @@ Pix copia-e-cola:
<div align="center"> <div align="center">
## 🌐 339 AI Providers — 90+ Free ## 🌐 341 AI Providers — 90+ Free
</div> </div>
> The most complete catalog of any open-source router: **339 providers**, **90+ with a free tier**, **40+ free forever**. > The most complete catalog of any open-source router: **341 providers**, **90+ with a free tier**, **56 free forever**.
<div align="center"> <div align="center">
@@ -683,6 +704,7 @@ Pix copia-e-cola:
<tr><td align="left" nowrap>📱 <b>Android (Termux)</b></td><td align="left" nowrap><code>pkg install nodejs && npx -y omniroute</code></td><td align="left">Runs <b>on your phone</b>, 24/7, no root</td></tr> <tr><td align="left" nowrap>📱 <b>Android (Termux)</b></td><td align="left" nowrap><code>pkg install nodejs && npx -y omniroute</code></td><td align="left">Runs <b>on your phone</b>, 24/7, no root</td></tr>
<tr><td align="left" nowrap>📲 <b>PWA</b></td><td align="left" nowrap>"Add to Home Screen"</td><td align="left">Fullscreen, offline, installable from browser</td></tr> <tr><td align="left" nowrap>📲 <b>PWA</b></td><td align="left" nowrap>"Add to Home Screen"</td><td align="left">Fullscreen, offline, installable from browser</td></tr>
<tr><td align="left" nowrap>🧩 <b>OpenCode plugin</b></td><td align="left" nowrap><code>@omniroute/opencode-provider</code></td><td align="left">Native OpenCode integration</td></tr> <tr><td align="left" nowrap>🧩 <b>OpenCode plugin</b></td><td align="left" nowrap><code>@omniroute/opencode-provider</code></td><td align="left">Native OpenCode integration</td></tr>
<tr><td align="left" nowrap>🤖 <b>VS Code Copilot Chat</b></td><td align="left" nowrap>install <b>OmniCopilot</b> extension</td><td align="left">Every OmniRoute model in the native Copilot Chat picker — stable &amp; Insiders</td></tr>
<tr><td align="left" nowrap>🛠️ <b>From source</b></td><td align="left" nowrap><code>npm install && npm run dev</code></td><td align="left">Hack on it, contribute</td></tr> <tr><td align="left" nowrap>🛠️ <b>From source</b></td><td align="left" nowrap><code>npm install && npm run dev</code></td><td align="left">Hack on it, contribute</td></tr>
</table> </table>
@@ -692,6 +714,33 @@ Pix copia-e-cola:
<div align="center"> <div align="center">
### 🧩 New: OmniRoute inside VS Code's native Copilot Chat
</div>
> No new sidebar, no new chat UI — every model OmniRoute serves shows up right in the
> **Copilot Chat model picker you already use**. Since VS Code 1.122, provider models work
> without a GitHub sign-in or a Copilot subscription — agent mode, tool calling and vision, for
> free.
Install the **[OmniCopilot](https://github.com/diegosouzapw/OmniCopilot)** extension, point it
at your OmniRoute server (defaults to `localhost:20128`), then open Copilot Chat → model picker
**Manage Models…****OmniRoute**.
<table>
<tr><th align="left">Store</th><th align="left">Link</th><th align="left">Works with</th></tr>
<tr><td align="left" nowrap>🧩 <b>VS Code Marketplace</b></td><td align="left"><a href="https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot">Install →</a></td><td align="left">VS Code — stable &amp; Insiders</td></tr>
<tr><td align="left" nowrap>🔓 <b>Open VSX Registry</b></td><td align="left"><a href="https://open-vsx.org/extension/diegosouzapw/omnicopilot">Install →</a></td><td align="left">Cursor, Windsurf, VSCodium, Theia, code-server, Gitpod, Antigravity, Kiro…</td></tr>
</table>
From inside the editor: open the **Extensions** view, search **"OmniRoute"**, click **Install**
— works the same way on both stores. Source, issues and the publishing runbook live at
[diegosouzapw/OmniCopilot](https://github.com/diegosouzapw/OmniCopilot).
<br/>
<div align="center">
## 🔒 Private & Local-First ## 🔒 Private & Local-First
</div> </div>
@@ -748,7 +797,7 @@ Expose OmniRoute over **MCP**, **A2A**, a **REST API**, **webhooks** or a **remo
<table> <table>
<tr><th align="left">Interface</th><th align="left">Endpoint / command</th><th align="left">Use it for</th></tr> <tr><th align="left">Interface</th><th align="left">Endpoint / command</th><th align="left">Use it for</th></tr>
<tr><td align="left" nowrap>🧰 <b>MCP (stdio)</b></td><td align="left" nowrap><code>omniroute --mcp</code></td><td align="left">Plug into Claude Desktop, Cursor, any MCP client</td></tr> <tr><td align="left" nowrap>🧰 <b>MCP (stdio)</b></td><td align="left" nowrap><code>omniroute --mcp</code></td><td align="left">Plug into Claude Desktop, Cursor, any MCP client</td></tr>
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>105 tools</b>, 31 scopes, full audit trail</td></tr> <tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>109 tools</b>, 33 scopes, full audit trail</td></tr>
<tr><td align="left" nowrap>📡 <b>MCP (SSE)</b></td><td align="left" nowrap><code>/api/mcp/sse</code></td><td align="left">Streaming MCP transport</td></tr> <tr><td align="left" nowrap>📡 <b>MCP (SSE)</b></td><td align="left" nowrap><code>/api/mcp/sse</code></td><td align="left">Streaming MCP transport</td></tr>
<tr><td align="left" nowrap>🤝 <b>A2A</b></td><td align="left" nowrap><code>/.well-known/agent.json</code></td><td align="left">Agent-to-agent, <b>JSON-RPC 2.0</b> + SSE, 6 skills</td></tr> <tr><td align="left" nowrap>🤝 <b>A2A</b></td><td align="left" nowrap><code>/.well-known/agent.json</code></td><td align="left">Agent-to-agent, <b>JSON-RPC 2.0</b> + SSE, 6 skills</td></tr>
<tr><td align="left" nowrap>🌐 <b>REST API</b></td><td align="left" nowrap><code>/v1/*</code></td><td align="left">OpenAI-compatible — chat, embeddings, images, audio, OCR</td></tr> <tr><td align="left" nowrap>🌐 <b>REST API</b></td><td align="left" nowrap><code>/v1/*</code></td><td align="left">OpenAI-compatible — chat, embeddings, images, audio, OCR</td></tr>
@@ -999,31 +1048,69 @@ same process on one port, so there is no separate CLI-only package today.
</div> </div>
## 📹 Video Guides
<div align="center"> <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> <table>
<tr> <tr>
<td align="center" width="264"> <td align="center" width="320">
<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/> <a href="https://www.instagram.com/reel/Da8ZthUPK98/">
<b>🇧🇷 Português</b><br/><sub>Guia completo</sub> <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>
<td align="center" width="264"> <td align="center" width="320">
<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/> <a href="https://www.youtube.com/watch?v=QucgvbO5gsM">
<b>🇺🇸 English</b><br/><sub>Complete walkthrough</sub> <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>
<td align="center" width="264"> <td align="center" width="320">
<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/> <a href="https://www.youtube.com/shorts/fZIBK_4fKq8">
<b>🇷🇺 Русский</b><br/><sub>Полное руководство</sub> <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> </td>
</tr> </tr>
</table> </table>
</div> </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. > 🎬 **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/> <br/>
</div>
<div align="center"> <div align="center">
@@ -1061,7 +1148,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>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>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>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) — 95 domain modules, 145 migrations</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>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</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>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> <tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
@@ -1135,7 +1222,7 @@ same process on one port, so there is no separate CLI-only package today.
<tr><th align="left">Document</th><th align="left">Description</th></tr> <tr><th align="left">Document</th><th align="left">Description</th></tr>
<tr><td nowrap><b><a href="docs/reference/API_REFERENCE.md">API Reference</a></b></td><td>All endpoints with examples</td></tr> <tr><td nowrap><b><a href="docs/reference/API_REFERENCE.md">API Reference</a></b></td><td>All endpoints with examples</td></tr>
<tr><td nowrap><b><a href="docs/openapi.yaml">OpenAPI Spec</a></b></td><td>OpenAPI 3.0 specification</td></tr> <tr><td nowrap><b><a href="docs/openapi.yaml">OpenAPI Spec</a></b></td><td>OpenAPI 3.0 specification</td></tr>
<tr><td nowrap><b><a href="open-sse/mcp-server/README.md">MCP Server</a></b></td><td>105 MCP tools, IDE configs, Python/TS/Go clients</td></tr> <tr><td nowrap><b><a href="open-sse/mcp-server/README.md">MCP Server</a></b></td><td>109 MCP tools, IDE configs, Python/TS/Go clients</td></tr>
<tr><td nowrap><b><a href="docs/frameworks/MCP-SERVER.md">MCP Server Guide</a></b></td><td>MCP installation, transports, and tool reference</td></tr> <tr><td nowrap><b><a href="docs/frameworks/MCP-SERVER.md">MCP Server Guide</a></b></td><td>MCP installation, transports, and tool reference</td></tr>
<tr><td nowrap><b><a href="src/lib/a2a/README.md">A2A Server</a></b></td><td>JSON-RPC 2.0 protocol, skills, streaming, task mgmt</td></tr> <tr><td nowrap><b><a href="src/lib/a2a/README.md">A2A Server</a></b></td><td>JSON-RPC 2.0 protocol, skills, streaming, task mgmt</td></tr>
<tr><td nowrap><b><a href="docs/frameworks/A2A-SERVER.md">A2A Server Guide</a></b></td><td>A2A agent card, tasks, skills, and streaming</td></tr> <tr><td nowrap><b><a href="docs/frameworks/A2A-SERVER.md">A2A Server Guide</a></b></td><td>A2A agent card, tasks, skills, and streaming</td></tr>

View File

@@ -208,7 +208,7 @@ _omniroute() {
COMPREPLY=() COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}" cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}" 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 run"
case "\${prev}" in case "\${prev}" in
combo) COMPREPLY=($(compgen -W "list switch create delete show suggest" -- "\${cur}")); return 0 ;; combo) COMPREPLY=($(compgen -W "list switch create delete show suggest" -- "\${cur}")); return 0 ;;
@@ -242,7 +242,7 @@ function generateFishScript() {
return `# OmniRoute CLI fish completion (dynamic) return `# OmniRoute CLI fish completion (dynamic)
complete -c omniroute -f 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 update test run
for cmd in $commands for cmd in $commands
complete -c omniroute -n '__fish_is_nth_token 1' -a $cmd complete -c omniroute -n '__fish_is_nth_token 1' -a $cmd

View File

@@ -5,6 +5,7 @@ import fs from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { resolveDataDir } from "../data-dir.mjs"; import { resolveDataDir } from "../data-dir.mjs";
import { registerContexts } from "./contexts.mjs"; import { registerContexts } from "./contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function ensureBackup(configPath) { function ensureBackup(configPath) {
if (!fs.existsSync(configPath)) return; if (!fs.existsSync(configPath)) return;
@@ -87,6 +88,13 @@ async function runConfigSetCommand(toolId, opts = {}) {
return 1; return 1;
} }
const guard = await guardHostConfigTarget(result.configPath, {
toolLabel: toolId,
hostCommand: `omniroute config set ${toolId}`,
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
});
if (guard !== 0) return guard;
const nonInteractive = opts.nonInteractive || opts.yes; const nonInteractive = opts.nonInteractive || opts.yes;
if (!nonInteractive) { if (!nonInteractive) {
@@ -271,6 +279,10 @@ export function registerConfig(program) {
.option("--model <model>", "Model identifier (where applicable)") .option("--model <model>", "Model identifier (where applicable)")
.option("--non-interactive", "Do not prompt for confirmation") .option("--non-interactive", "Do not prompt for confirmation")
.option("--yes", "Skip confirmation prompt") .option("--yes", "Skip confirmation prompt")
.option(
"--allow-container-write",
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"
)
.action(async (tool, opts, cmd) => { .action(async (tool, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals(); const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runConfigSetCommand(tool, { const exitCode = await runConfigSetCommand(tool, {
@@ -306,6 +318,10 @@ export function registerConfig(program) {
.option("--model <model>", "Model identifier") .option("--model <model>", "Model identifier")
.option("--non-interactive", "Do not prompt for confirmation") .option("--non-interactive", "Do not prompt for confirmation")
.option("--yes", "Skip confirmation prompt") .option("--yes", "Skip confirmation prompt")
.option(
"--allow-container-write",
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"
)
.action(async (opts, cmd) => { .action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals(); const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runConfigSetCommand("opencode", { const exitCode = await runConfigSetCommand("opencode", {

View File

@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs";
import { apiFetch } from "../api.mjs"; import { apiFetch } from "../api.mjs";
import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs"; import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
/** /**
* `omniroute configure <cli>` — interactive provider+model picker that writes a * `omniroute configure <cli>` — interactive provider+model picker that writes a
@@ -75,6 +76,12 @@ function buildCodexProfile(modelId, ctx) {
async function configureCodex(modelId, ctxWindow, opts) { async function configureCodex(modelId, ctxWindow, opts) {
const codexHome = opts.codexHome || path.join(os.homedir(), ".codex"); const codexHome = opts.codexHome || path.join(os.homedir(), ".codex");
const guard = await guardHostConfigTarget(codexHome, {
toolLabel: "Codex",
hostCommand: "omniroute configure codex",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
});
if (guard !== 0) return guard;
if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true }); if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true });
const profile = opts.name || profileNameFromModel(modelId); const profile = opts.name || profileNameFromModel(modelId);
const filePath = path.join(codexHome, `${profile}.config.toml`); const filePath = path.join(codexHome, `${profile}.config.toml`);
@@ -86,6 +93,7 @@ async function configureCodex(modelId, ctxWindow, opts) {
printInfo(`Use it: codex --profile ${profile}`); printInfo(`Use it: codex --profile ${profile}`);
printInfo("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block"); printInfo("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block");
printInfo(" (run the Codex setup once — see docs/guides/CODEX-CLI-CONFIGURATION.md)."); printInfo(" (run the Codex setup once — see docs/guides/CODEX-CLI-CONFIGURATION.md).");
return 0;
} }
export async function runConfigureCommand(cli, opts = {}, cmd) { export async function runConfigureCommand(cli, opts = {}, cmd) {
@@ -130,7 +138,9 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
} }
const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id)))); const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id))));
const candidates = inProvider.length ? inProvider : ids; const candidates = inProvider.length ? inProvider : ids;
printInfo(`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`); printInfo(
`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`
);
chosenId = await prompt.ask("Model id"); chosenId = await prompt.ask("Model id");
} finally { } finally {
prompt.close(); prompt.close();
@@ -149,7 +159,7 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
const ctxWindow = contextWindowOf(entry); const ctxWindow = contextWindowOf(entry);
if (target === "codex") { if (target === "codex") {
await configureCodex(chosenId, ctxWindow, opts); return await configureCodex(chosenId, ctxWindow, opts);
} }
return 0; return 0;
} }
@@ -173,6 +183,10 @@ export function registerConfigure(program) {
.option("--model <id>", "Model id (skips the interactive model prompt)") .option("--model <id>", "Model id (skips the interactive model prompt)")
.option("--name <name>", "Profile name to write (default: derived from model)") .option("--name <name>", "Profile name to write (default: derived from model)")
.option("--codex-home <dir>", "Codex home dir (default: ~/.codex)") .option("--codex-home <dir>", "Codex home dir (default: ~/.codex)")
.option(
"--allow-container-write",
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"
)
.action(async (cli, opts, cmd) => { .action(async (cli, opts, cmd) => {
const code = await runConfigureCommand(cli, opts, cmd); const code = await runConfigureCommand(cli, opts, cmd);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -170,8 +170,8 @@ export function buildCodexEnv(baseEnv, authToken) {
* @param {string} baseUrl OmniRoute root URL (no /v1) * @param {string} baseUrl OmniRoute root URL (no /v1)
* @returns {string[]} * @returns {string[]}
*/ */
export function buildCodexProviderArgs(baseUrl) { export function buildCodexProviderArgs(baseUrl, model) {
return [ const args = [
"-c", "-c",
tomlAssign("model_provider", "omniroute"), tomlAssign("model_provider", "omniroute"),
"-c", "-c",
@@ -185,6 +185,15 @@ export function buildCodexProviderArgs(baseUrl) {
"-c", "-c",
tomlAssign("model_providers.omniroute.requires_openai_auth", false), 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), // Provider injected via -c (works without config.toml); then the profile (model),
// then the user's pass-through args. // 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 profileArgs = opts.profile ? ["--profile", opts.profile] : [];
const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs]; const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs];
const env = buildCodexEnv(process.env, authToken); const env = buildCodexEnv(process.env, authToken);

View File

@@ -190,7 +190,10 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
const configDir = opts.profile const configDir = opts.profile
? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile) ? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile)
: undefined; : 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); const { command, shell } = await resolveClaudeSpawn(process.platform);

166
bin/cli/commands/packs.mjs Normal file
View File

@@ -0,0 +1,166 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { t } from "../i18n.mjs";
import { resolveDataDir } from "../data-dir.mjs";
import {
EXIT_CODES,
emit,
exitWith,
printError,
printInfo,
printSuccess,
printWarning,
} from "../output.mjs";
import { findPack } from "../../../scripts/packs/optionalPackManifest.mjs";
import {
findPackIndexFile,
installPack,
listPackStates,
packState,
packsRoot,
readPackIndex,
removePack,
} from "../../../scripts/packs/optionalPackInstaller.mjs";
const CLI_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
/**
* Locate + parse the bundle-shipped `optional-packs.index.json`.
* Search order: explicit --source dir, then walking up from the CLI module
* (bundle installs keep the index at the bundle root), then cwd.
*/
function loadIndex(sourceDir) {
const indexFile = findPackIndexFile([sourceDir, CLI_DIR, process.cwd()]);
if (!indexFile) return { indexFile: null, index: null };
return { indexFile, index: readPackIndex(indexFile) };
}
function stateRow(state, dataDir) {
return {
pack: state.name,
packVersion: state.packVersion,
installed: state.installed ? "yes" : "no",
verified: state.verified === null ? "-" : state.verified ? "ok" : "FAILED",
members: state.members.length,
installDir: path.join(packsRoot(dataDir), state.name),
errors: state.errors ?? [],
};
}
const STATE_SCHEMA = [
{ key: "pack", header: "pack" },
{ key: "packVersion", header: "packVersion" },
{ key: "installed", header: "installed" },
{ key: "verified", header: "verified" },
{ key: "members", header: "members" },
];
async function run(action) {
try {
await action();
} catch (err) {
exitWith(EXIT_CODES.ERROR, err instanceof Error ? err.message : String(err));
}
}
export function registerPacks(program) {
const packs = program.command("packs").description(t("packs.description"));
packs
.command("list")
.description(t("packs.listDescription"))
.option("--source <dir>", t("packs.sourceOpt"))
.action(async (opts) => {
await run(async () => {
const dataDir = resolveDataDir();
const { index } = loadIndex(opts.source);
emit(
(await listPackStates({ dataDir, index })).map((s) => stateRow(s, dataDir)),
opts,
STATE_SCHEMA
);
if (!index) printWarning(t("packs.warnNoIndex"));
});
});
packs
.command("install <name>")
.description(t("packs.installDescription"))
.option("--source <dir>", t("packs.sourceOpt"))
.action(async (name, opts) => {
await run(async () => {
if (!findPack(name)) exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
const { indexFile, index } = loadIndex(opts.source);
if (!index) exitWith(EXIT_CODES.ERROR, t("packs.errNoIndex"));
const dataDir = resolveDataDir();
// The payload (tarball or extracted pack dir) lives next to the index
// unless the caller pointed elsewhere via --source.
await installPack(name, {
dataDir,
index,
sourceDir: opts.source || path.dirname(indexFile),
log: (msg) => printInfo(msg.replace(/^\[optional-packs\]\s*/, "")),
});
const installDir = path.join(packsRoot(dataDir), name);
printSuccess(t("packs.installed", { name, dir: installDir }));
printInfo(t("packs.restartHint"));
emit({ pack: name, installed: "yes", verified: "ok", installDir }, opts, STATE_SCHEMA);
});
});
packs
.command("verify [name]")
.description(t("packs.verifyDescription"))
.option("--source <dir>", t("packs.sourceOpt"))
.action(async (name, opts) => {
await run(async () => {
if (name && !findPack(name))
exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
const { index } = loadIndex(opts.source);
if (!index) exitWith(EXIT_CODES.ERROR, t("packs.errNoIndex"));
const dataDir = resolveDataDir();
const states = name
? [await packState(name, { dataDir, index })]
: await listPackStates({ dataDir, index });
emit(
states.map((s) => stateRow(s, dataDir)),
opts,
STATE_SCHEMA
);
const broken = states.filter((s) => s.installed && s.verified !== true);
if (broken.length > 0) {
for (const state of broken) {
for (const error of state.errors ?? []) printError(`${state.name}: ${error}`);
}
exitWith(EXIT_CODES.ERROR, t("packs.verifyFailed", { count: broken.length }));
}
if (!states.some((s) => s.installed)) {
printInfo(t("packs.noneInstalled"));
return;
}
printSuccess(t("packs.verifyOk"));
});
});
packs
.command("remove <name>")
.description(t("packs.removeDescription"))
.action(async (name, opts) => {
await run(async () => {
if (!findPack(name)) exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
const dataDir = resolveDataDir();
const removed = removePack(name, {
dataDir,
log: (msg) => printInfo(msg.replace(/^\[optional-packs\]\s*/, "")),
});
if (removed) {
printSuccess(t("packs.removed", { name }));
printInfo(t("packs.restartHint"));
} else {
printInfo(t("packs.notInstalled", { name }));
}
emit({ pack: name, installed: removed ? "no" : "no" }, opts, STATE_SCHEMA);
});
});
}

View File

@@ -0,0 +1,76 @@
import { apiFetch } from "../api.mjs";
import { t } from "../i18n.mjs";
import { emit } from "../output.mjs";
const statusSchema = [
{ key: "feed", header: "Feed" },
{ key: "available", header: "Available" },
{ key: "version", header: "Version" },
{ key: "tier", header: "Tier" },
{ key: "fetchedAt", header: "Fetched" },
];
const syncSchema = [
{ key: "feed", header: "Feed" },
{ key: "status", header: "Status" },
{ key: "version", header: "Version" },
{ key: "reason", header: "Reason" },
];
function exitCodeFor(response) {
return Number.isInteger(response.exitCode) ? response.exitCode : response.status === 401 ? 4 : 1;
}
export async function runRadarStatusCommand(opts = {}) {
const response = await apiFetch("/api/radar/status", { acceptNotOk: true });
if (!response.ok) return exitCodeFor(response);
const data = await response.json();
if (opts.output === "json") {
emit(data, opts);
return 0;
}
const rows = Object.entries(data.feeds ?? {}).map(([feed, value]) => ({
feed,
...(value && typeof value === "object" ? value : { available: false }),
}));
emit(rows, opts, statusSchema);
return 0;
}
export async function runRadarSyncCommand(opts = {}) {
const response = await apiFetch("/api/radar/sync-all", {
method: "POST",
body: {},
acceptNotOk: true,
});
if (!response.ok) return exitCodeFor(response);
const data = await response.json();
if (opts.output === "json") {
emit(data, opts);
return 0;
}
const rows = Object.entries(data).map(([feed, value]) => ({
feed,
...(value && typeof value === "object" ? value : { status: "error" }),
}));
emit(rows, opts, syncSchema);
return 0;
}
export function registerRadar(program) {
const radar = program.command("radar").description(t("radar.description"));
radar
.command("status")
.description(t("radar.status"))
.action(async (_opts, command) => {
const code = await runRadarStatusCommand(command.optsWithGlobals());
if (code !== 0) process.exitCode = code;
});
radar
.command("sync")
.description(t("radar.sync"))
.action(async (_opts, command) => {
const code = await runRadarSyncCommand(command.optsWithGlobals());
if (code !== 0) process.exitCode = code;
});
}

View File

@@ -60,6 +60,7 @@ import { registerAutostart } from "./autostart.mjs";
import { registerRepl } from "./repl.mjs"; import { registerRepl } from "./repl.mjs";
import { registerLaunch } from "./launch.mjs"; import { registerLaunch } from "./launch.mjs";
import { registerLaunchCodex } from "./launch-codex.mjs"; import { registerLaunchCodex } from "./launch-codex.mjs";
import { registerRun } from "./run.mjs";
import { registerSetupCodex } from "./setup-codex.mjs"; import { registerSetupCodex } from "./setup-codex.mjs";
import { registerSetupClaude } from "./setup-claude.mjs"; import { registerSetupClaude } from "./setup-claude.mjs";
import { registerSetupOpencode } from "./setup-opencode.mjs"; import { registerSetupOpencode } from "./setup-opencode.mjs";
@@ -78,6 +79,8 @@ import { registerTokens } from "./tokens.mjs";
import { registerConfigure } from "./configure.mjs"; import { registerConfigure } from "./configure.mjs";
import { registerApiCommands } from "../api-commands/registry.mjs"; import { registerApiCommands } from "../api-commands/registry.mjs";
import { registerPlugin } from "./plugin.mjs"; import { registerPlugin } from "./plugin.mjs";
import { registerRadar } from "./radar.mjs";
import { registerPacks } from "./packs.mjs";
export function registerCommands(program) { export function registerCommands(program) {
registerMemory(program); registerMemory(program);
@@ -143,6 +146,7 @@ export function registerCommands(program) {
registerRepl(program); registerRepl(program);
registerLaunch(program); registerLaunch(program);
registerLaunchCodex(program); registerLaunchCodex(program);
registerRun(program);
registerSetupCodex(program); registerSetupCodex(program);
registerSetupClaude(program); registerSetupClaude(program);
registerSetupOpencode(program); registerSetupOpencode(program);
@@ -161,4 +165,6 @@ export function registerCommands(program) {
registerConfigure(program); registerConfigure(program);
registerApiCommands(program); registerApiCommands(program);
registerPlugin(program); registerPlugin(program);
registerRadar(program);
registerPacks(program);
} }

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

@@ -0,0 +1,316 @@
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 { resolveActiveContext } from "../contexts.mjs";
const RUN_TARGETS = {
claude: {
aliases: ["claude", "claude-code", "cc"],
description: "Claude Code",
},
codex: {
aliases: ["codex", "openai-codex", "openai"],
description: "OpenAI Codex CLI",
},
};
/** @type {Record<string,string>} */
const RUN_TARGET_ALIAS_TO_CANONICAL = {
claude: "claude",
"claude-code": "claude",
cc: "claude",
codex: "codex",
"openai-codex": "codex",
openai: "codex",
};
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";
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 supported target to canonical id. */
export function resolveRunTarget(target) {
const raw = String(target || "")
.trim()
.toLowerCase();
return RUN_TARGET_ALIAS_TO_CANONICAL[raw];
}
export function listRunTargets() {
return Object.keys(RUN_TARGETS);
}
/**
* 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: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
token: rawOpts.token || rawOpts.apiKey || rawOpts["api-key"],
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(merged),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
};
}
async function buildCodexPlan(rawOpts, args = []) {
const model = resolveModelFromTargetOptions(rawOpts);
const merged = {
...rawOpts,
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
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(merged),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
providerArgs,
profileArgs,
};
}
/** 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");
}
if (canonical === "claude") {
return buildClaudePlan(rawOpts, args);
}
return buildCodexPlan(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,
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.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: rawOpts.token || rawOpts.apiKey || rawOpts["api-key"],
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
profile: rawOpts.profile || rawOpts.p,
};
}
function buildExecutionOptionsForCodex(rawOpts) {
return {
...rawOpts,
model: resolveModelFromTargetOptions(rawOpts),
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
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: ${Object.keys(RUN_TARGETS).join(", ")}\n`
);
return 2;
}
const plan = await buildRunPlan(target, opts, args);
if (opts.dryRun) {
writeDryRunOutput(plan, opts);
return 0;
}
if (canonical === "claude") {
return await runLaunchClaudeCommand(buildExecutionOptionsForClaude(opts), args);
}
return await runLaunchCodexCommand(buildExecutionOptionsForCodex(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("--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("--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

@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { platform, totalmem, hostname as osHostname } from "node:os"; import { platform, totalmem } from "node:os";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs"; import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs"; import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
@@ -12,6 +12,7 @@ import {
isFatalInstrumentationHookFailure, isFatalInstrumentationHookFailure,
formatAndroidInstrumentationFailureHint, formatAndroidInstrumentationFailureHint,
} from "../utils/ensureAndroidCacheDir.mjs"; } from "../utils/ensureAndroidCacheDir.mjs";
import { resolveServerHost } from "../utils/serverHost.mjs";
import { import {
resolveMaxOldSpaceMb, resolveMaxOldSpaceMb,
calibrateHeapFallbackMb, calibrateHeapFallbackMb,
@@ -207,16 +208,10 @@ export async function runServe(opts = {}) {
PORT: String(dashboardPort), PORT: String(dashboardPort),
DASHBOARD_PORT: String(dashboardPort), DASHBOARD_PORT: String(dashboardPort),
API_PORT: String(apiPort), API_PORT: String(apiPort),
// #6194: POSIX shells (bash/zsh) auto-set HOSTNAME to the machine name — the // #10492: HOSTNAME is standard shell state on Unix-like systems, not an
// .env loader (first-wins) can never override it. Ignore HOSTNAME when it // OmniRoute bind setting. The resolver only keeps its legacy meaning on
// matches the OS-reported hostname (the auto-set signature). OMNIROUTE_SERVER_HOST // Windows; OMNIROUTE_SERVER_HOST is the cross-platform explicit setting.
// takes precedence; legacy HOSTNAME values that don't match os.hostname() are HOSTNAME: resolveServerHost(),
// still honoured for backward compatibility (e.g. Windows CMD/PowerShell users
// who set HOSTNAME in .env where it is NOT auto-set).
HOSTNAME:
process.env.OMNIROUTE_SERVER_HOST ||
(process.env.HOSTNAME !== osHostname() ? process.env.HOSTNAME : undefined) ||
"0.0.0.0",
NODE_ENV: "production", NODE_ENV: "production",
// #5238: preserve a user-set NODE_OPTIONS (incl. their own // #5238: preserve a user-set NODE_OPTIONS (incl. their own
// `--max-old-space-size=…`) instead of clobbering it with the calibrated // `--max-old-space-size=…`) instead of clobbering it with the calibrated

View File

@@ -13,6 +13,7 @@ import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function stripToRoot(url) { function stripToRoot(url) {
const s = String(url || "").replace(/\/+$/, ""); const s = String(url || "").replace(/\/+$/, "");
@@ -25,7 +26,9 @@ export function resolveAiderTarget(opts = {}) {
if (opts.remote) root = stripToRoot(opts.remote); if (opts.remote) root = stripToRoot(opts.remote);
else { else {
try { try {
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl); root = stripToRoot(
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
);
} catch { } catch {
/* none */ /* none */
} }
@@ -78,7 +81,7 @@ async function fetchModelIds(apiBase, apiKey) {
const res = await fetch(`${apiBase}/v1/models`, { headers, signal: AbortSignal.timeout(8000) }); const res = await fetch(`${apiBase}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return []; if (!res.ok) return [];
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch { } catch {
return []; return [];
@@ -88,7 +91,16 @@ async function fetchModelIds(apiBase, apiKey) {
export async function runSetupAiderCommand(opts = {}) { export async function runSetupAiderCommand(opts = {}) {
const { apiBase, apiKey } = resolveAiderTarget(opts); const { apiBase, apiKey } = resolveAiderTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".aider.conf.yml"); const configPath =
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".aider.conf.yml");
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "Aider",
hostCommand: "omniroute setup-aider",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printHeading("OmniRoute → Aider (openai-compatible via LiteLLM)"); printHeading("OmniRoute → Aider (openai-compatible via LiteLLM)");
printInfo(`OPENAI_API_BASE: ${apiBase} (no /v1 — LiteLLM appends it)`); printInfo(`OPENAI_API_BASE: ${apiBase} (no /v1 — LiteLLM appends it)`);
@@ -107,7 +119,9 @@ export async function runSetupAiderCommand(opts = {}) {
} }
} }
if (!model) { if (!model) {
printError("A model is required. Pass --model <id> (the openai/ prefix is added automatically)."); printError(
"A model is required. Pass --model <id> (the openai/ prefix is added automatically)."
);
return 2; return 2;
} }
@@ -139,6 +153,10 @@ export function registerSetupAider(program) {
.option("--config-path <path>", ".aider.conf.yml path (default: ~/.aider.conf.yml)") .option("--config-path <path>", ".aider.conf.yml path (default: ~/.aider.conf.yml)")
.option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupAiderCommand(opts); const code = await runSetupAiderCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -20,6 +20,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import { import {
categoriseModel, categoriseModel,
isCodexCompatibleTextModel, isCodexCompatibleTextModel,
@@ -147,6 +148,14 @@ export async function runSetupClaudeCommand(opts = {}) {
printHeading("OmniRoute → Claude Code profile generator"); printHeading("OmniRoute → Claude Code profile generator");
printInfo(`Connecting to ${baseUrl}`); printInfo(`Connecting to ${baseUrl}`);
const guard = await guardHostConfigTarget(profilesRoot, {
toolLabel: "Claude Code",
hostCommand: "omniroute setup-claude",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
// ── Fetch model catalog ─────────────────────────────────────────────────── // ── Fetch model catalog ───────────────────────────────────────────────────
let models; let models;
try { try {
@@ -220,6 +229,10 @@ export function registerSetupClaude(program) {
"Comma-separated substrings — only matching model IDs (e.g. glm,kimi)" "Comma-separated substrings — only matching model IDs (e.g. glm,kimi)"
) )
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const exitCode = await runSetupClaudeCommand(opts); const exitCode = await runSetupClaudeCommand(opts);
if (exitCode !== 0) process.exit(exitCode); if (exitCode !== 0) process.exit(exitCode);

View File

@@ -16,6 +16,7 @@ import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function stripToRoot(url) { function stripToRoot(url) {
let s = String(url || "").replace(/\/+$/, ""); let s = String(url || "").replace(/\/+$/, "");
@@ -28,11 +29,14 @@ export function resolveClineTarget(opts = {}) {
if (opts.remote) baseUrl = stripToRoot(opts.remote); if (opts.remote) baseUrl = stripToRoot(opts.remote);
else { else {
try { try {
baseUrl = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl); baseUrl = stripToRoot(
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
);
} catch { } catch {
/* none */ /* none */
} }
if (!baseUrl) baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`; if (!baseUrl)
baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
} }
let apiKey = opts.apiKey ?? opts["api-key"]; let apiKey = opts.apiKey ?? opts["api-key"];
if (!apiKey) { if (!apiKey) {
@@ -81,7 +85,7 @@ async function fetchModelIds(baseUrl, apiKey) {
const res = await fetch(`${baseUrl}/v1/models`, { headers, signal: AbortSignal.timeout(8000) }); const res = await fetch(`${baseUrl}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return []; if (!res.ok) return [];
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch { } catch {
return []; return [];
@@ -93,6 +97,14 @@ export async function runSetupClineCommand(opts = {}) {
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const clineDir = opts.clineDir ?? opts["cline-dir"] ?? join(os.homedir(), ".cline", "data"); const clineDir = opts.clineDir ?? opts["cline-dir"] ?? join(os.homedir(), ".cline", "data");
const guard = await guardHostConfigTarget(clineDir, {
toolLabel: "Cline",
hostCommand: "omniroute setup-cline",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printHeading("OmniRoute → Cline (OpenAI-compatible)"); printHeading("OmniRoute → Cline (OpenAI-compatible)");
printInfo(`Server: ${baseUrl}`); printInfo(`Server: ${baseUrl}`);
@@ -122,7 +134,18 @@ export async function runSetupClineCommand(opts = {}) {
if (dryRun) { if (dryRun) {
console.log(`\n── [dry-run] ${gsPath} ──`); console.log(`\n── [dry-run] ${gsPath} ──`);
console.log(JSON.stringify({ actModeApiProvider: globalState.actModeApiProvider, planModeApiProvider: globalState.planModeApiProvider, openAiBaseUrl: globalState.openAiBaseUrl, openAiModelId: globalState.openAiModelId }, null, 2)); console.log(
JSON.stringify(
{
actModeApiProvider: globalState.actModeApiProvider,
planModeApiProvider: globalState.planModeApiProvider,
openAiBaseUrl: globalState.openAiBaseUrl,
openAiModelId: globalState.openAiModelId,
},
null,
2
)
);
console.log(`\n── [dry-run] ${secPath} ── (openAiApiKey: ${apiKey ? "set" : "sk_omniroute"})`); console.log(`\n── [dry-run] ${secPath} ── (openAiApiKey: ${apiKey ? "set" : "sk_omniroute"})`);
} else { } else {
if (!existsSync(clineDir)) mkdirSync(clineDir, { recursive: true }); if (!existsSync(clineDir)) mkdirSync(clineDir, { recursive: true });
@@ -133,7 +156,9 @@ export async function runSetupClineCommand(opts = {}) {
} }
// The VS Code extension uses opaque globalStorage — can't be file-written. // The VS Code extension uses opaque globalStorage — can't be file-written.
printInfo("\nFor the Cline VS Code extension, set these in its Settings → API (OpenAI Compatible):"); printInfo(
"\nFor the Cline VS Code extension, set these in its Settings → API (OpenAI Compatible):"
);
printInfo(` Base URL: ${baseUrl} (NOT /v1 — Cline appends it)`); printInfo(` Base URL: ${baseUrl} (NOT /v1 — Cline appends it)`);
printInfo(` API Key: <your OMNIROUTE_API_KEY>`); printInfo(` API Key: <your OMNIROUTE_API_KEY>`);
printInfo(` Model: ${model}`); printInfo(` Model: ${model}`);
@@ -153,6 +178,10 @@ export function registerSetupCline(program) {
.option("--cline-dir <dir>", "Cline data dir (default: ~/.cline/data)") .option("--cline-dir <dir>", "Cline data dir (default: ~/.cline/data)")
.option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupClineCommand(opts); const code = await runSetupClineCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -16,6 +16,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
// ── Model categorisation ────────────────────────────────────────────────────── // ── Model categorisation ──────────────────────────────────────────────────────
@@ -306,6 +307,14 @@ export async function runSetupCodexCommand(opts = {}) {
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null; const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
printHeading(`OmniRoute → Codex CLI profile generator`); printHeading(`OmniRoute → Codex CLI profile generator`);
const guard = await guardHostConfigTarget(codexHome, {
toolLabel: "Codex",
hostCommand: "omniroute setup-codex",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printInfo(`Connecting to ${baseUrl}`); printInfo(`Connecting to ${baseUrl}`);
// ── Fetch model catalog ─────────────────────────────────────────────────── // ── Fetch model catalog ───────────────────────────────────────────────────
@@ -380,6 +389,10 @@ export function registerSetupCodex(program) {
"Comma-separated substrings — only generate profiles for matching model IDs (e.g. glm,kimi)" "Comma-separated substrings — only generate profiles for matching model IDs (e.g. glm,kimi)"
) )
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const exitCode = await runSetupCodexCommand(opts); const exitCode = await runSetupCodexCommand(opts);
if (exitCode !== 0) process.exit(exitCode); if (exitCode !== 0) process.exit(exitCode);

View File

@@ -14,6 +14,7 @@ import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { categoriseModel } from "./setup-codex.mjs"; import { categoriseModel } from "./setup-codex.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
const SECRET_REF = "${{ secrets.OMNIROUTE_API_KEY }}"; const SECRET_REF = "${{ secrets.OMNIROUTE_API_KEY }}";
@@ -92,7 +93,7 @@ async function fetchModelIds(apiBase, apiKey) {
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch (e) { } catch (e) {
throw new Error(`Could not fetch models: ${e.message}`); throw new Error(`Could not fetch models: ${e.message}`);
@@ -102,8 +103,22 @@ async function fetchModelIds(apiBase, apiKey) {
export async function runSetupContinueCommand(opts = {}) { export async function runSetupContinueCommand(opts = {}) {
const { apiBase, apiKey } = resolveContinueTarget(opts); const { apiBase, apiKey } = resolveContinueTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null; const only = opts.only
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml"); ? opts.only
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
const configPath =
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml");
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "Continue",
hostCommand: "omniroute setup-continue",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printHeading("OmniRoute → Continue (config.yaml)"); printHeading("OmniRoute → Continue (config.yaml)");
printInfo(`apiBase: ${apiBase}`); printInfo(`apiBase: ${apiBase}`);
@@ -150,7 +165,7 @@ export async function runSetupContinueCommand(opts = {}) {
printInfo("\nProvide the key (config.yaml references it, not stores it):"); printInfo("\nProvide the key (config.yaml references it, not stores it):");
printInfo(" cn CLI: export OMNIROUTE_API_KEY=... (read from your shell)"); printInfo(" cn CLI: export OMNIROUTE_API_KEY=... (read from your shell)");
printInfo(" IDE: echo 'OMNIROUTE_API_KEY=...' >> ~/.continue/.env"); printInfo(" IDE: echo 'OMNIROUTE_API_KEY=...' >> ~/.continue/.env");
printInfo("Run: cn -p \"reply OK\""); printInfo('Run: cn -p "reply OK"');
return 0; return 0;
} }
@@ -166,6 +181,10 @@ export function registerSetupContinue(program) {
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs") .option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
.option("--config-path <path>", "config.yaml path (default: ~/.continue/config.yaml)") .option("--config-path <path>", "config.yaml path (default: ~/.continue/config.yaml)")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupContinueCommand(opts); const code = await runSetupContinueCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -13,6 +13,7 @@ import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { categoriseModel } from "./setup-codex.mjs"; import { categoriseModel } from "./setup-codex.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
const API_KEY_REF = "$OMNIROUTE_API_KEY"; const API_KEY_REF = "$OMNIROUTE_API_KEY";
@@ -87,15 +88,29 @@ async function fetchModelIds(baseUrl, apiKey) {
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} }
export async function runSetupCrushCommand(opts = {}) { export async function runSetupCrushCommand(opts = {}) {
const { baseUrl, apiKey } = resolveCrushTarget(opts); const { baseUrl, apiKey } = resolveCrushTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null; const only = opts.only
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "crush", "crush.json"); ? opts.only
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
const configPath =
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "crush", "crush.json");
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "Crush",
hostCommand: "omniroute setup-crush",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printHeading("OmniRoute → Crush (openai-compat)"); printHeading("OmniRoute → Crush (openai-compat)");
printInfo(`base_url: ${baseUrl}`); printInfo(`base_url: ${baseUrl}`);
@@ -120,13 +135,17 @@ export async function runSetupCrushCommand(opts = {}) {
if (dryRun) { if (dryRun) {
console.log("\n" + (out.length > 3500 ? out.slice(0, 3500) + "\n… (truncated)" : out)); console.log("\n" + (out.length > 3500 ? out.slice(0, 3500) + "\n… (truncated)" : out));
printInfo(`[dry-run] ${provider.models.length} model(s) under providers.omniroute → ${configPath}`); printInfo(
`[dry-run] ${provider.models.length} model(s) under providers.omniroute → ${configPath}`
);
return 0; return 0;
} }
mkdirSync(join(configPath, ".."), { recursive: true }); mkdirSync(join(configPath, ".."), { recursive: true });
writeFileSync(configPath, out, "utf8"); writeFileSync(configPath, out, "utf8");
printSuccess(`Wrote ${configPath} (${provider.models.length} models under providers.omniroute)`); printSuccess(`Wrote ${configPath} (${provider.models.length} models under providers.omniroute)`);
printInfo("Provide the key (config references $OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=..."); printInfo(
"Provide the key (config references $OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=..."
);
printInfo("Then run: crush"); printInfo("Then run: crush");
return 0; return 0;
} }
@@ -141,6 +160,10 @@ export function registerSetupCrush(program) {
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs") .option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
.option("--config-path <path>", "crush.json path (default: ~/.config/crush/crush.json)") .option("--config-path <path>", "crush.json path (default: ~/.config/crush/crush.json)")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupCrushCommand(opts); const code = await runSetupCrushCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -10,6 +10,7 @@
import { printHeading, printInfo, printSuccess } from "../io.mjs"; import { printHeading, printInfo, printSuccess } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { isContainerRuntime } from "../utils/config-home-guard.mjs";
function ensureV1(url) { function ensureV1(url) {
const s = String(url || "").replace(/\/+$/, ""); const s = String(url || "").replace(/\/+$/, "");
@@ -71,7 +72,7 @@ async function fetchModelIds(apiBase, apiKey) {
}); });
if (!res.ok) return []; if (!res.ok) return [];
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch { } catch {
return []; return [];
@@ -84,19 +85,32 @@ export async function runSetupCursorCommand(opts = {}) {
printInfo(`Server: ${apiBase}`); printInfo(`Server: ${apiBase}`);
let models = []; let models = [];
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null; const only = opts.only
? opts.only
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
const ids = await fetchModelIds(apiBase, apiKey); const ids = await fetchModelIds(apiBase, apiKey);
models = only ? ids.filter((id) => only.some((f) => id.includes(f))) : ids; models = only ? ids.filter((id) => only.some((f) => id.includes(f))) : ids;
console.log("\n" + buildCursorInstructions({ apiBase, models })); console.log("\n" + buildCursorInstructions({ apiBase, models }));
printSuccess("\nCursor is configured manually (no file written — Cursor's storage is opaque)."); printSuccess("\nCursor is configured manually (no file written — Cursor's storage is opaque).");
if (await isContainerRuntime()) {
printInfo(
"Note: this ran inside a container, so the base URL above is the container's own view. " +
"Use the address the host reaches OmniRoute on (e.g. the published port) in Cursor's settings."
);
}
return 0; return 0;
} }
export function registerSetupCursor(program) { export function registerSetupCursor(program) {
program program
.command("setup-cursor") .command("setup-cursor")
.description("Print the steps to point Cursor at OmniRoute (chat panel; Cursor config is not file-writable)") .description(
"Print the steps to point Cursor at OmniRoute (chat panel; Cursor config is not file-writable)"
)
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128") .option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") .option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")

View File

@@ -14,6 +14,7 @@ import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function stripToRoot(url) { function stripToRoot(url) {
const s = String(url || "").replace(/\/+$/, ""); const s = String(url || "").replace(/\/+$/, "");
@@ -26,7 +27,9 @@ export function resolveGooseTarget(opts = {}) {
if (opts.remote) root = stripToRoot(opts.remote); if (opts.remote) root = stripToRoot(opts.remote);
else { else {
try { try {
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl); root = stripToRoot(
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
);
} catch { } catch {
/* none */ /* none */
} }
@@ -80,7 +83,7 @@ async function fetchModelIds(host, apiKey) {
const res = await fetch(`${host}/v1/models`, { headers, signal: AbortSignal.timeout(8000) }); const res = await fetch(`${host}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return []; if (!res.ok) return [];
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch { } catch {
return []; return [];
@@ -90,7 +93,16 @@ async function fetchModelIds(host, apiKey) {
export async function runSetupGooseCommand(opts = {}) { export async function runSetupGooseCommand(opts = {}) {
const { host, apiKey } = resolveGooseTarget(opts); const { host, apiKey } = resolveGooseTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml"); const configPath =
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml");
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "Goose",
hostCommand: "omniroute setup-goose",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printHeading("OmniRoute → Goose (openai-compatible)"); printHeading("OmniRoute → Goose (openai-compatible)");
printInfo(`OPENAI_HOST: ${host} (no /v1 — Goose appends it)`); printInfo(`OPENAI_HOST: ${host} (no /v1 — Goose appends it)`);
@@ -128,14 +140,16 @@ export async function runSetupGooseCommand(opts = {}) {
printInfo("\nProvide the key (Goose reads it from the env / OS keyring):"); printInfo("\nProvide the key (Goose reads it from the env / OS keyring):");
console.log(buildGooseEnvRecipe({ host, model })); console.log(buildGooseEnvRecipe({ host, model }));
printInfo("Then run: goose session (or: goose run -t \"reply OK\")"); printInfo('Then run: goose session (or: goose run -t "reply OK")');
return 0; return 0;
} }
export function registerSetupGoose(program) { export function registerSetupGoose(program) {
program program
.command("setup-goose") .command("setup-goose")
.description("Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + print the env recipe") .description(
"Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + print the env recipe"
)
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128") .option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") .option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
@@ -143,6 +157,10 @@ export function registerSetupGoose(program) {
.option("--config-path <path>", "config.yaml path (default: ~/.config/goose/config.yaml)") .option("--config-path <path>", "config.yaml path (default: ~/.config/goose/config.yaml)")
.option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupGooseCommand(opts); const code = await runSetupGooseCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -14,6 +14,7 @@ import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
/** Ensure the URL ends with /v1 (Kilo appends /chat/completions to it). */ /** Ensure the URL ends with /v1 (Kilo appends /chat/completions to it). */
function ensureV1(url) { function ensureV1(url) {
@@ -61,7 +62,11 @@ export function buildKiloAuth(existing, { apiKey, baseUrl, model }) {
/** Merge the kilocode.* keys into VS Code settings.json (extension surface). */ /** Merge the kilocode.* keys into VS Code settings.json (extension surface). */
export function buildKiloVscodeSettings(existing, { apiKey, baseUrl, model }) { export function buildKiloVscodeSettings(existing, { apiKey, baseUrl, model }) {
const s = { ...(existing || {}) }; const s = { ...(existing || {}) };
s["kilocode.customProvider"] = { name: "OmniRoute", baseURL: baseUrl, apiKey: apiKey || "sk_omniroute" }; s["kilocode.customProvider"] = {
name: "OmniRoute",
baseURL: baseUrl,
apiKey: apiKey || "sk_omniroute",
};
s["kilocode.defaultModel"] = model; s["kilocode.defaultModel"] = model;
return s; return s;
} }
@@ -85,7 +90,7 @@ async function fetchModelIds(root, apiKey) {
}); });
if (!res.ok) return []; if (!res.ok) return [];
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch { } catch {
return []; return [];
@@ -95,9 +100,22 @@ async function fetchModelIds(root, apiKey) {
export async function runSetupKiloCommand(opts = {}) { export async function runSetupKiloCommand(opts = {}) {
const { baseUrl, apiKey } = resolveKiloTarget(opts); const { baseUrl, apiKey } = resolveKiloTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const authPath = opts.authPath ?? opts["auth-path"] ?? join(os.homedir(), ".local", "share", "kilo", "auth.json"); const authPath =
opts.authPath ??
opts["auth-path"] ??
join(os.homedir(), ".local", "share", "kilo", "auth.json");
const guard = await guardHostConfigTarget(authPath, {
toolLabel: "Kilo Code",
hostCommand: "omniroute setup-kilo",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
const vscodePath = const vscodePath =
opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json"); opts.vscodeSettings ??
opts["vscode-settings"] ??
join(os.homedir(), ".config", "Code", "User", "settings.json");
printHeading("OmniRoute → Kilo Code (OpenAI-compatible)"); printHeading("OmniRoute → Kilo Code (OpenAI-compatible)");
printInfo(`Server: ${baseUrl}`); printInfo(`Server: ${baseUrl}`);
@@ -116,7 +134,9 @@ export async function runSetupKiloCommand(opts = {}) {
} }
} }
if (!model) { if (!model) {
printError("A model is required. Pass --model <id> (Kilo's extension has no model auto-discovery)."); printError(
"A model is required. Pass --model <id> (Kilo's extension has no model auto-discovery)."
);
return 2; return 2;
} }
@@ -132,12 +152,19 @@ export async function runSetupKiloCommand(opts = {}) {
console.log(`\n── [dry-run] ${authPath} ──`); console.log(`\n── [dry-run] ${authPath} ──`);
console.log( console.log(
JSON.stringify( JSON.stringify(
{ "openai-compatible": { ...auth["openai-compatible"], apiKey: apiKey ? "set" : "sk_omniroute" } }, {
"openai-compatible": {
...auth["openai-compatible"],
apiKey: apiKey ? "set" : "sk_omniroute",
},
},
null, null,
2 2
) )
); );
console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would merge kilocode.* keys)" : "(skipped — file absent)"}`); console.log(
`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would merge kilocode.* keys)" : "(skipped — file absent)"}`
);
} else { } else {
mkdirSync(join(authPath, ".."), { recursive: true }); mkdirSync(join(authPath, ".."), { recursive: true });
writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n", "utf8"); writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n", "utf8");
@@ -167,10 +194,20 @@ export function registerSetupKilo(program) {
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") .option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
.option("--model <id>", "Model id for Kilo (required unless picked interactively)") .option("--model <id>", "Model id for Kilo (required unless picked interactively)")
.option("--auth-path <path>", "Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)") .option(
.option("--vscode-settings <path>", "VS Code settings.json (default: ~/.config/Code/User/settings.json)") "--auth-path <path>",
"Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)"
)
.option(
"--vscode-settings <path>",
"VS Code settings.json (default: ~/.config/Code/User/settings.json)"
)
.option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupKiloCommand(opts); const code = await runSetupKiloCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -30,6 +30,7 @@ import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -316,6 +317,13 @@ export async function runSetupOpenCodeCommand(opts = {}) {
printInfo(`OpenCode config dir: ${opencodeConfigDir}`); printInfo(`OpenCode config dir: ${opencodeConfigDir}`);
printInfo(`OpenCode data dir: ${opencodeDataDir}`); printInfo(`OpenCode data dir: ${opencodeDataDir}`);
const guard = await guardHostConfigTarget(opencodeConfigDir, {
toolLabel: "OpenCode",
hostCommand: "omniroute setup opencode",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
});
if (guard !== 0) return { exitCode: guard };
// 1. Resolve bundled plugin // 1. Resolve bundled plugin
let pluginInfo; let pluginInfo;
try { try {
@@ -420,6 +428,10 @@ export function registerSetupOpenCode(setupCommand) {
false false
) )
.option("--non-interactive", "Do not prompt; skip the auth login step", false) .option("--non-interactive", "Do not prompt; skip the auth login step", false)
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts, cmd) => { .action(async (opts, cmd) => {
// The parent `setup` command uses cmd.optsWithGlobals(); we mirror // The parent `setup` command uses cmd.optsWithGlobals(); we mirror
// that here so global flags (--json, --base-url, --api-key) still // that here so global flags (--json, --base-url, --api-key) still

View File

@@ -14,6 +14,7 @@ import { basename, dirname } from "node:path";
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser"; import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
const ENV_KEY_REF = "{env:OMNIROUTE_API_KEY}"; const ENV_KEY_REF = "{env:OMNIROUTE_API_KEY}";
const JSON_FORMATTING_OPTIONS = { insertSpaces: true, tabSize: 2 }; const JSON_FORMATTING_OPTIONS = { insertSpaces: true, tabSize: 2 };
@@ -119,6 +120,15 @@ export async function runSetupOpencodeCommand(opts = {}) {
const { resolveOpencodeConfigPath } = const { resolveOpencodeConfigPath } =
await import("../../../src/shared/services/opencodeConfigPath.ts"); await import("../../../src/shared/services/opencodeConfigPath.ts");
configPath = resolveOpencodeConfigPath(); configPath = resolveOpencodeConfigPath();
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "OpenCode",
hostCommand: "omniroute setup-opencode",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
raw = await generateOpencodeConfig({ raw = await generateOpencodeConfig({
baseUrl, baseUrl,
apiKey, apiKey,
@@ -163,6 +173,10 @@ export function registerSetupOpencode(program) {
.option("--model <id>", "Set the default top-level model (omniroute/<id>)") .option("--model <id>", "Set the default top-level model (omniroute/<id>)")
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs") .option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupOpencodeCommand(opts); const code = await runSetupOpencodeCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -18,6 +18,7 @@ import {
normalizeQwenCodeBaseUrl, normalizeQwenCodeBaseUrl,
} from "../../../src/shared/services/qwenCodeConfig.ts"; } from "../../../src/shared/services/qwenCodeConfig.ts";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import { createPrompt, printError, printHeading, printInfo, printSuccess } from "../io.mjs"; import { createPrompt, printError, printHeading, printInfo, printSuccess } from "../io.mjs";
/** Resolve base URL and key from flags, active context, then local defaults. */ /** Resolve base URL and key from flags, active context, then local defaults. */
@@ -102,6 +103,16 @@ export async function runSetupQwenCommand(opts = {}) {
printHeading("OmniRoute → Qwen Code (OpenAI-compatible)"); printHeading("OmniRoute → Qwen Code (OpenAI-compatible)");
printInfo(`baseUrl: ${baseUrl}`); printInfo(`baseUrl: ${baseUrl}`);
for (const target of [settingsPath, envPath]) {
const guard = await guardHostConfigTarget(target, {
toolLabel: "Qwen Code",
hostCommand: "omniroute setup-qwen",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
}
let model = String(opts.model || "").trim(); let model = String(opts.model || "").trim();
if (!model && !opts.yes) { if (!model && !opts.yes) {
const modelIds = await fetchModelIds(baseUrl, apiKey); const modelIds = await fetchModelIds(baseUrl, apiKey);
@@ -159,6 +170,10 @@ export function registerSetupQwen(program) {
.option("--env-path <path>", "Qwen Code .env path") .option("--env-path <path>", "Qwen Code .env path")
.option("--yes", "Non-interactive; requires --model") .option("--yes", "Non-interactive; requires --model")
.option("--dry-run", "Print settings without writing files or secrets") .option("--dry-run", "Print settings without writing files or secrets")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupQwenCommand(opts); const code = await runSetupQwenCommand(opts);
if (code !== 0) process.exitCode = code; if (code !== 0) process.exitCode = code;

View File

@@ -16,6 +16,7 @@ import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function ensureV1(url) { function ensureV1(url) {
const s = String(url || "").replace(/\/+$/, ""); const s = String(url || "").replace(/\/+$/, "");
@@ -89,7 +90,7 @@ async function fetchModelIds(baseUrl, apiKey) {
}); });
if (!res.ok) return []; if (!res.ok) return [];
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch { } catch {
return []; return [];
@@ -99,9 +100,20 @@ async function fetchModelIds(baseUrl, apiKey) {
export async function runSetupRooCommand(opts = {}) { export async function runSetupRooCommand(opts = {}) {
const { baseUrl, apiKey } = resolveRooTarget(opts); const { baseUrl, apiKey } = resolveRooTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const importPath = opts.importPath ?? opts["import-path"] ?? join(os.homedir(), ".omniroute", "roo-settings.json"); const importPath =
opts.importPath ?? opts["import-path"] ?? join(os.homedir(), ".omniroute", "roo-settings.json");
const guard = await guardHostConfigTarget(importPath, {
toolLabel: "Roo Code",
hostCommand: "omniroute setup-roo",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
const vscodePath = const vscodePath =
opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json"); opts.vscodeSettings ??
opts["vscode-settings"] ??
join(os.homedir(), ".config", "Code", "User", "settings.json");
printHeading("OmniRoute → Roo Code (OpenAI-compatible)"); printHeading("OmniRoute → Roo Code (OpenAI-compatible)");
printInfo(`Server: ${baseUrl}`); printInfo(`Server: ${baseUrl}`);
@@ -130,8 +142,27 @@ export async function runSetupRooCommand(opts = {}) {
if (dryRun) { if (dryRun) {
console.log(`\n── [dry-run] ${importPath} ──`); console.log(`\n── [dry-run] ${importPath} ──`);
console.log(JSON.stringify({ ...importDoc, providerProfiles: { ...importDoc.providerProfiles, apiConfigs: { OmniRoute: { ...importDoc.providerProfiles.apiConfigs.OmniRoute, openAiApiKey: apiKey ? "set" : "sk_omniroute" } } } }, null, 2)); console.log(
console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would set roo-cline.autoImportSettingsPath)" : "(skipped — file absent)"}`); JSON.stringify(
{
...importDoc,
providerProfiles: {
...importDoc.providerProfiles,
apiConfigs: {
OmniRoute: {
...importDoc.providerProfiles.apiConfigs.OmniRoute,
openAiApiKey: apiKey ? "set" : "sk_omniroute",
},
},
},
},
null,
2
)
);
console.log(
`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would set roo-cline.autoImportSettingsPath)" : "(skipped — file absent)"}`
);
} else { } else {
mkdirSync(join(importPath, ".."), { recursive: true }); mkdirSync(join(importPath, ".."), { recursive: true });
writeFileSync(importPath, JSON.stringify(importDoc, null, 2) + "\n", "utf8"); writeFileSync(importPath, JSON.stringify(importDoc, null, 2) + "\n", "utf8");
@@ -161,10 +192,20 @@ export function registerSetupRoo(program) {
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") .option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
.option("--model <id>", "Model id for Roo (required unless picked interactively)") .option("--model <id>", "Model id for Roo (required unless picked interactively)")
.option("--import-path <path>", "Roo import JSON path (default: ~/.omniroute/roo-settings.json)") .option(
.option("--vscode-settings <path>", "VS Code settings.json (default: ~/.config/Code/User/settings.json)") "--import-path <path>",
"Roo import JSON path (default: ~/.omniroute/roo-settings.json)"
)
.option(
"--vscode-settings <path>",
"VS Code settings.json (default: ~/.config/Code/User/settings.json)"
)
.option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupRooCommand(opts); const code = await runSetupRooCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -921,6 +921,11 @@
"model": "Filter by model" "model": "Filter by model"
} }
}, },
"radar": {
"description": "Inspect and synchronize the local Radar catalog feeds",
"status": "Show local Radar settings and feed cache status",
"sync": "Synchronize catalog, referrals, offers, and Intel through the local server"
},
"resilience": { "resilience": {
"description": "Inspect and manage resilience mechanisms", "description": "Inspect and manage resilience mechanisms",
"status": { "status": {
@@ -1282,6 +1287,9 @@
"notRunning": "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.", "notRunning": "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.",
"notFound": "The 'claude' CLI was not found in PATH." "notFound": "The 'claude' CLI was not found in PATH."
}, },
"run": {
"description": "Launch a supported CLI target through OmniRoute"
},
"setupClaude": { "setupClaude": {
"description": "Generate ~/.claude/profiles Claude Code profiles from the OmniRoute model catalog" "description": "Generate ~/.claude/profiles Claude Code profiles from the OmniRoute model catalog"
}, },
@@ -1299,5 +1307,23 @@
}, },
"setupCodex": { "setupCodex": {
"description": "Generate ~/.codex profile files from OmniRoute live model catalog" "description": "Generate ~/.codex profile files from OmniRoute live model catalog"
},
"packs": {
"description": "Manage optional runtime packs (ML / browser automation)",
"listDescription": "List optional packs and their install state",
"installDescription": "Install an optional pack into DATA_DIR",
"verifyDescription": "Verify installed packs against the shipped checksum index",
"removeDescription": "Remove an installed optional pack",
"sourceOpt": "Directory holding pack payloads and the pack index",
"warnNoIndex": "optional-packs.index.json not found — install/verify are unavailable in this checkout (desktop bundles ship it)",
"errUnknown": "unknown pack: {name}",
"errNoIndex": "pack index not found; pass --source <dir> holding the pack payload (desktop bundles ship it next to the app)",
"installed": "pack \"{name}\" installed and verified at {dir}",
"restartHint": "restart the OmniRoute server (or desktop app) so the runtime picks the pack up",
"removed": "pack \"{name}\" removed",
"notInstalled": "pack \"{name}\" was not installed",
"verifyOk": "all installed packs verified",
"verifyFailed": "{count} pack(s) failed verification",
"noneInstalled": "no optional packs installed"
} }
} }

View File

@@ -918,6 +918,11 @@
"model": "Filtrar por model" "model": "Filtrar por model"
} }
}, },
"radar": {
"description": "Inspecionar e sincronizar os feeds locais do catálogo Radar",
"status": "Mostrar configurações locais e estado dos caches do Radar",
"sync": "Sincronizar catálogo, indicações, ofertas e Intel pelo servidor local"
},
"resilience": { "resilience": {
"description": "Inspecionar e gerenciar mecanismos de resiliência", "description": "Inspecionar e gerenciar mecanismos de resiliência",
"status": { "status": {
@@ -1279,6 +1284,9 @@
"notRunning": "OmniRoute não está acessível em {port}. Inicie com 'omniroute serve'.", "notRunning": "OmniRoute não está acessível em {port}. Inicie com 'omniroute serve'.",
"notFound": "O CLI 'claude' não foi encontrado no PATH." "notFound": "O CLI 'claude' não foi encontrado no PATH."
}, },
"run": {
"description": "Inicia um alvo de CLI compatível pelo OmniRoute"
},
"setupClaude": { "setupClaude": {
"description": "Gera profiles do Claude Code em ~/.claude/profiles a partir do catálogo de modelos do OmniRoute" "description": "Gera profiles do Claude Code em ~/.claude/profiles a partir do catálogo de modelos do OmniRoute"
}, },
@@ -1296,5 +1304,23 @@
}, },
"setupCodex": { "setupCodex": {
"description": "Gera os arquivos de perfil ~/.codex a partir do catálogo de modelos ao vivo do OmniRoute" "description": "Gera os arquivos de perfil ~/.codex a partir do catálogo de modelos ao vivo do OmniRoute"
},
"packs": {
"description": "Gerencia packs opcionais de runtime (ML / automação de navegador)",
"listDescription": "Lista os packs opcionais e seu estado de instalação",
"installDescription": "Instala um pack opcional no DATA_DIR",
"verifyDescription": "Verifica os packs instalados contra o índice de checksums embarcado",
"removeDescription": "Remove um pack opcional instalado",
"sourceOpt": "Diretório com os payloads dos packs e o índice de packs",
"warnNoIndex": "optional-packs.index.json não encontrado — install/verify indisponíveis neste checkout (instaladores desktop o embarcam)",
"errUnknown": "pack desconhecido: {name}",
"errNoIndex": "índice de packs não encontrado; passe --source <dir> com o payload do pack (instaladores desktop o embarcam ao lado do app)",
"installed": "pack \"{name}\" instalado e verificado em {dir}",
"restartHint": "reinicie o servidor OmniRoute (ou o app desktop) para o runtime reconhecer o pack",
"removed": "pack \"{name}\" removido",
"notInstalled": "o pack \"{name}\" não estava instalado",
"verifyOk": "todos os packs instalados verificados",
"verifyFailed": "{count} pack(s) falharam na verificação",
"noneInstalled": "nenhum pack opcional instalado"
} }
} }

View File

@@ -0,0 +1,122 @@
import { printError, printInfo } from "../io.mjs";
/**
* Container guard for CLI-tool config writes.
*
* `omniroute setup-*` writes to `~/.codex`, `~/.claude`, ... — paths that only
* mean something on the operator's host. Run the same command inside the
* OmniRoute container and the write "succeeds" into an ephemeral layer that no
* host CLI ever reads and that disappears with the container. This guard turns
* that silent no-op into an actionable refusal.
*
* Bind-mounted targets (the compose `host` profile) are allowed through: the
* mount is the operator's explicit statement that the path reaches the host.
*/
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
/** Exit code for a refused write — matches the CLI's usage-error convention. */
export const CONTAINER_WRITE_EXIT_CODE = 2;
function envAllowsContainerWrite(env = process.env) {
return TRUE_VALUES.has(
String(env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE ?? "")
.trim()
.toLowerCase()
);
}
/**
* Classify a pending config write.
*
* @param {string} targetPath Absolute path the command is about to write.
* @param {{
* toolLabel?: string,
* hostCommand?: string,
* allowContainerWrite?: boolean,
* dryRun?: boolean,
* env?: NodeJS.ProcessEnv,
* deps?: object,
* }} options
* @returns {Promise<{ok: boolean, message?: string, warning?: string}>}
*/
export async function assertHostConfigTarget(targetPath, options = {}) {
const {
toolLabel,
hostCommand,
allowContainerWrite = false,
dryRun = false,
env = process.env,
deps,
} = options;
let describeContainerTarget;
let buildContainerWriteRefusal;
let CLI_OVERRIDE_HINT;
try {
// `.ts` extension is required so the published package (which ships only TS
// source, resolved through tsx) can load these. See #2509.
({ describeContainerTarget } = await import("../../../src/shared/utils/containerEnv.ts"));
({ buildContainerWriteRefusal, CLI_OVERRIDE_HINT } =
await import("../../../src/shared/utils/containerConfigGuard.ts"));
} catch {
// Fail open: a guard that cannot load must not block a legitimate host run.
return { ok: true };
}
const info = describeContainerTarget(targetPath, deps);
if (!info.ephemeral) return { ok: true };
if (dryRun) {
return {
ok: true,
warning:
`[dry-run] ${targetPath} is inside the container and is not mounted from the host — ` +
`a real run would be refused. See --allow-container-write.`,
};
}
if (allowContainerWrite || envAllowsContainerWrite(env)) {
return {
ok: true,
warning:
`Writing to ${targetPath} inside the container as requested — this file is lost when ` +
`the container is recreated and host CLIs will not see it.`,
};
}
return {
ok: false,
message: buildContainerWriteRefusal(targetPath, {
toolLabel,
hostCommand,
overrideHint: CLI_OVERRIDE_HINT,
}),
};
}
/**
* Container check for commands that write nothing but still print host-oriented
* instructions (setup-cursor). Fails closed to `false` so a broken import never
* turns into a spurious warning.
*/
export async function isContainerRuntime(deps) {
try {
const { isRunningInContainer } = await import("../../../src/shared/utils/containerEnv.ts");
return isRunningInContainer(deps);
} catch {
return false;
}
}
/**
* Guard + report. Returns 0 to continue, or CONTAINER_WRITE_EXIT_CODE when the
* caller should abort and return that code.
*/
export async function guardHostConfigTarget(targetPath, options = {}) {
const result = await assertHostConfigTarget(targetPath, options);
if (result.warning) printInfo(result.warning);
if (result.ok) return 0;
printError(result.message);
return CONTAINER_WRITE_EXIT_CODE;
}

View File

@@ -0,0 +1,26 @@
import { hostname, platform } from "node:os";
/**
* Resolve the bind host passed to the standalone Next.js server.
*
* HOSTNAME is a standard shell variable on Unix-like systems, so only the
* dedicated OmniRoute variable is treated as configuration there. Windows
* keeps the legacy HOSTNAME fallback for compatibility with existing .env
* files, while still ignoring the OS-reported machine name.
*
* @param {NodeJS.ProcessEnv} [env]
* @param {NodeJS.Platform} [runtimePlatform]
* @param {string} [machineHostname]
* @returns {string}
*/
export function resolveServerHost(
env = process.env,
runtimePlatform = platform(),
machineHostname = hostname()
) {
if (env.OMNIROUTE_SERVER_HOST) return env.OMNIROUTE_SERVER_HOST;
if (runtimePlatform === "win32" && env.HOSTNAME && env.HOSTNAME !== machineHostname) {
return env.HOSTNAME;
}
return "0.0.0.0";
}

View File

@@ -0,0 +1 @@
- **feat(cli):** container-aware auto-config — `setup-*`, `omniroute configure`, `omniroute config set` and the CLI-tool config APIs now refuse to write into a containerised OmniRoute's ephemeral home (CLI exits `2`, API returns `422` with `containerEphemeralTarget`) and point at the host-CLI or bind-mount setup instead; `--allow-container-write` / `OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` opt back in. Also fixes `CLI_CONFIG_HOME` so the Compose `host` profile's `/host-home` bind mounts are honoured instead of silently falling back to the container home. (#10057)

View File

@@ -0,0 +1 @@
- feat(modality-bridge): bridge Chat and Responses video parts through a strict trusted-loopback, quota-bounded FFmpeg broker; enforce HTTPS redirects/SSRF plus format, protocol, stream, pixel, frame, 50 MiB broker/remote, 36 MiB inline, and 120-second limits; propagate caller aborts; preserve the actual successful fallback model through cache/meta/headers; expose sampled latency and honest success telemetry; and ship the localized Video settings UI (#9760)

View File

@@ -0,0 +1 @@
- **feat(radar):** Persist local model display-name/enabled overrides and hide/restore tombstones, with authenticated catalog controls and feed safety precedence ([#9830](https://github.com/diegosouzapw/OmniRoute/pull/9830))

View File

@@ -0,0 +1 @@
- **feat(radar):** add curated-family combo suggestions, a guided combo page, and the read-only Radar MCP catalog tool ([#9836](https://github.com/diegosouzapw/OmniRoute/pull/9836))

View File

@@ -0,0 +1 @@
- **feat(radar):** add a signed live offers feed and supporter offers dashboard ([#9912](https://github.com/diegosouzapw/OmniRoute/pull/9912))

View File

@@ -0,0 +1 @@
- **feat(radar):** add signed Intel insights, supporter recognition, and local Radar CLI commands ([#9923](https://github.com/diegosouzapw/OmniRoute/pull/9923))

View File

@@ -0,0 +1 @@
- **feat(radar):** add a localized public news feed and dismissible dashboard launch banner, with the Radar announcement staged inactive for a separately authorized launch ([#9926](https://github.com/diegosouzapw/OmniRoute/pull/9926))

View File

@@ -0,0 +1 @@
- **fix(oauth):** Claude connections created via `claude-auth/import` now send required CLI headers on the bootstrap identity call and persist a `cliUserID` device identity, fixing intermittent "Third-party apps now draw from your extra usage" 400s on otherwise valid imported subscription tokens ([#10144](https://github.com/diegosouzapw/OmniRoute/pull/10144), fixes [#10143](https://github.com/diegosouzapw/OmniRoute/issues/10143))

View File

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

View File

@@ -0,0 +1 @@
- **fix(cursor):** Stop truncating pending tool calls on non-composer models when a KV checkpoint arrives after text but before the `exec_mcp` frame — the KV short-circuit is now gated to the composer family where it was verified ([#10215](https://github.com/diegosouzapw/OmniRoute/issues/10215)).

View File

@@ -0,0 +1 @@
- **fix(responses):** repair corrupted SSE deltas for non-ASCII streams by keeping a single stream-aware `TextDecoder` (`{ stream: true }`) across `transform()` calls instead of recreating it per chunk and decoding without the `stream` flag. When a multi-byte UTF-8 character (CJK/emoji) was split across two TCP chunks — common in Chinese streaming text — the per-chunk decoder truncated it to `U+FFFD`, corrupting every delta while the rebuilt `*.done` snapshot stayed internally identical ([#10223](https://github.com/diegosouzapw/OmniRoute/issues/10223))

View File

@@ -0,0 +1 @@
- **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233))

View File

@@ -0,0 +1 @@
- **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234))

View File

@@ -0,0 +1 @@
- **fix(providers):** preserve validator HTTP status codes in API-key and web connection-test results so callers can distinguish authentication, rate-limit, and upstream failures ([#10272](https://github.com/diegosouzapw/OmniRoute/pull/10272)) — thanks @Zartharas

View File

@@ -0,0 +1 @@
- **fix(sse):** tiny-budget reasoning probes (e.g. Claude Code's `/model` check sends `max_tokens: 1`) are answered with a valid truncated 200 instead of relaying the upstream 5xx "empty response content" — which previously also marked the connection unavailable and poisoned fallback/cooldown bookkeeping for a request that is only a probe ([#10281](https://github.com/diegosouzapw/OmniRoute/issues/10281)) — thanks @harkaranbrar7

View File

@@ -0,0 +1 @@
- fix(sse): mark gemini-3.5-flash as thinking-capable so reasoning_effort is no longer rejected with a spurious 400 (#10286)

View File

@@ -0,0 +1 @@
- **fix(build):** stop Turbopack from dead-code-eliminating the Windows Tailscale branches of `src/lib/tailscaleTunnel.ts` in the published build (#10293). The release `dist` is bundled on a Linux runner, and the bundler constant-folds `process.platform`, pruning every non-Linux branch — the Windows installers shipped with no `where` lookup, an always-injected `--socket`, and a lost `net start Tailscale`/windows-default-binary path. The module now reads the platform at runtime via `os.platform()` (a function call a bundler cannot fold), so the Windows branches survive on any build machine; a vitest regression test mocking `os.platform()``win32` guards the anti-fold invariant (RED before, GREEN after).

View File

@@ -0,0 +1 @@
- **fix(chat-body-admission):** restore a single process-wide admission budget — heavyweight leases and queued bytes are now bounded once for the whole process instead of per session, so one session can no longer mint extra capacity or starve others; per-session fairness is preserved via round-robin dispatch ([#10110](https://github.com/diegosouzapw/OmniRoute/issues/10110))

View File

@@ -0,0 +1 @@
- **fix(providers):** validate Z.ai web Local Storage sessions against the authenticated user-settings endpoint and preserve exact upstream status codes ([#10329](https://github.com/diegosouzapw/OmniRoute/pull/10329)) — thanks @Zartharas

View File

@@ -0,0 +1 @@
- fix(backend): redact client IPs and account prefixes from default proxy logs (#10348)

View File

@@ -0,0 +1 @@
- **fix(db):** `getSettings()` defaults `debugMode` to `false` — fresh installs no longer run in debug mode (persisted `debugMode: true` is preserved) ([#10372](https://github.com/diegosouzapw/OmniRoute/pull/10372) — thanks @lamchun1110)

View File

@@ -0,0 +1 @@
- **fix(executors):** OpencodeExecutor and MimocodeExecutor now rotate to the next account on network exceptions (timeout, connection refused/reset) when the failed account has a dedicated proxy, not only on 429 — a throw on one account no longer fails the whole request when other accounts remain. Accounts sharing the default egress (no proxy) fail fast instead of retrying the same outage against every account. The shared rotation mechanics (`pickAccount`/`markCooldown`/`markSuccess`) are now extracted into `accountRotation.ts`, fixing an identical unconditional-cooldown gap that pre-dated this PR in MimocodeExecutor ([#10393](https://github.com/diegosouzapw/OmniRoute/pull/10393))

View File

@@ -0,0 +1 @@
- **fix(sse):** the header-budget drop warning fires once per unique dropped-header set instead of on every SSE response (warn-storm fix) ([#10397](https://github.com/diegosouzapw/OmniRoute/pull/10397) — thanks @lamchun1110)

View File

@@ -0,0 +1 @@
- **fix(guardrails):** Vision Bridge now reroutes whole requests for named combos whose targets have zero vision-capable models (previously such image requests died with `capability_mismatch` when the describe path could not run), and when the fallback describe path also fails for every image the request degrades to explicit `(unavailable)` stub text instead of preserving images the combo cannot consume ([#10415](https://github.com/diegosouzapw/OmniRoute/pull/10415)) — thanks @rqzbeh

View File

@@ -0,0 +1,2 @@
- **fix(antigravity):** geo-blocked egress (Google "User location is not supported") is now classified (scoped to the Google AI surfaces that emit it: Cloud Code/Gemini Code Assist, Gemini API, Vertex), cached as a 24h per-account exclusion so routing continues with other accounts, and surfaced with an actionable message; the dashboard connection test now probes the real `streamGenerateContent` model surface instead of the non-geo-restricted OAuth userinfo endpoint ([#10420](https://github.com/diegosouzapw/OmniRoute/pull/10420)) — thanks @rqzbeh
- **fix(antigravity):** strip competing-agent identity sentences from system prompts (e.g. "You are a Claude agent, built on Anthropic's Claude Agent SDK.") that Antigravity flags and answers with 429 RESOURCE_EXHAUSTED (port of decolua/9router b566b20) ([#10420](https://github.com/diegosouzapw/OmniRoute/pull/10420)) — thanks @rqzbeh

View File

@@ -0,0 +1,2 @@
- **fix(antigravity):** accounts with an empty Cloud Code `projectId` now heal themselves — failed auto-onboarding (`onboardUser`) attempts are retried after a short backoff instead of being memoized forever, so the missing Google project is created without user action on a later request or token refresh ([#10424](https://github.com/diegosouzapw/OmniRoute/pull/10424)) — thanks @rqzbeh
- **fix(antigravity):** Google deprecated automatic project creation for standard-tier (personal) accounts — when `onboardUser` completes without a project id the account now fails fast with a clear `403 GCP_PROJECT_REQUIRED` message (no more generic 422 or delayed 429 RESOURCE_EXHAUSTED), and a manual GCP Project ID override is available in the connection editor so operators can enter their own project id ([#10424](https://github.com/diegosouzapw/OmniRoute/pull/10424)) — thanks @rqzbeh

View File

@@ -0,0 +1 @@
- **fix(usage):** read Gemini `usageMetadata` out of the antigravity `{ response: {...} }` envelope so non-streaming requests log real token usage instead of `IN 0 | OUT 0` (port of decolua/9router#59d858b) ([#10430](https://github.com/diegosouzapw/OmniRoute/pull/10430)) — thanks @rqzbeh

View File

@@ -0,0 +1 @@
- **fix(usage):** surface Gemini `cachedContentTokenCount` into `cached_tokens` for non-streaming requests so cache-hit accounting matches the OpenAI/Claude/Responses branches and the streaming path (follow-up to the #10430 envelope fix) ([#10465](https://github.com/diegosouzapw/OmniRoute/pull/10465)) — thanks @rqzbeh

View File

@@ -0,0 +1 @@
- **fix(docker):** point the bifrost sidecar at the real `ghcr.io/maximhq/bifrost:v1.6.11` tag and the cliproxyapi sidecar at the official `docker.io/eceasy/cli-proxy-api:v6.9.7` image (the previously pinned tags never existed), and complete the runtime `OMNIROUTE_BASE_PATH` subpath patch for Next 16 standalone (assetPrefix + client env + baked asset URLs) so prebuilt images respect the webpath env var ([#10482](https://github.com/diegosouzapw/OmniRoute/pull/10482))

View File

@@ -0,0 +1 @@
- fix(sse): stop ZWJ-obfuscating the substring "hermes" in user messages and hostnames (#10484)

View File

@@ -0,0 +1 @@
- **fix(providers):** allow token-backed web sessions stored with `authType: "cookie"` to refresh their token through the provider update API ([#10518](https://github.com/diegosouzapw/OmniRoute/pull/10518)) — thanks @Zartharas

View File

@@ -0,0 +1 @@
- **fix(cli):** ignore the operating system `HOSTNAME` when choosing the server bind address on Linux and macOS, preventing startup failures when the shell hostname differs from `os.hostname()`; use `OMNIROUTE_SERVER_HOST` for explicit non-Windows configuration while preserving the legacy `HOSTNAME` fallback on Windows ([#10557](https://github.com/diegosouzapw/OmniRoute/pull/10557), closes [#10492](https://github.com/diegosouzapw/OmniRoute/issues/10492)) — thanks @redzrush101

View File

@@ -0,0 +1 @@
- fix(combo): recovery hint for all_targets_skipped now points at provider quota/availability instead of 'transient, just retry' (#9303)

View File

@@ -0,0 +1 @@
- fix(providers): strip uniqueItems from Gemini tool schemas (Gemini rejects it with 400 'Unknown name uniqueItems') (#9617)

View File

@@ -0,0 +1 @@
- fix(dashboard): media mini-playgrounds authenticate via session instead of sending the masked API key as Bearer, fixing 401s under REQUIRE_API_KEY (#9935)

View File

@@ -0,0 +1 @@
- fix(sse): exclude search providers from credential-health scheduler sweep to stop burning billed API queries (#9970)

View File

@@ -0,0 +1 @@
- **fix(api-manager):** Allowed Combos can now be restricted to zero entries: **All** is stored explicitly as `combo/*`, while **Restrict** with no selection saves an empty allowlist that denies Combo routes without blocking direct models. Existing keys are migrated to preserve their previous allow-all behavior.

View File

@@ -0,0 +1 @@
- fix(ci): make `Build (advisory)` produce a signal again — pinned to a hosted runner with the swap/heap provisioning `Fast Production Build` proves sufficient, and scoped to fork PRs, which are the only ones `build.yml` cannot cover (72 of the last 100 PRs into `release/**`)

View File

@@ -0,0 +1 @@
- **fix(api):** hash API keys in the `/v1/models` catalog cache Map key so heap dumps cannot leak bearer tokens (`src/app/api/v1/models/catalogCache.ts`)

View File

@@ -0,0 +1 @@
- **fix(db):** the `compression_run_telemetry` retention sweep now actually deletes expired rows. Its cutoff was computed in epoch seconds while the column stores epoch milliseconds, so `WHERE timestamp < cutoff` never matched and the table added by #6848 to bound `storage.sqlite` growth was unbounded in practice. Same unit mismatch as #9625, which corrected the sibling `domain_cost_history` sweep and missed this call site

View File

@@ -0,0 +1 @@
- **fix(db):** database settings API no longer returns HTTP 500 on SQLite builds compiled without the optional `dbstat` virtual table (sql.js/WASM); per-table sizes degrade to 0 instead of failing the whole stats call

View File

@@ -0,0 +1 @@
- **fix(ops):** Docker HEALTHCHECK probes lightweight `/healthz` instead of `/api/monitoring/health` so a busy event loop does not mark the container Unhealthy (`scripts/dev/healthcheck.mjs`)

View File

@@ -0,0 +1 @@
- **fix(sse):** keep Codex/Anthropic quota headers under the upstream forwarding budget; drop `x-codex-turn-state` and raise the 768-byte cap (`open-sse/handlers/chatCore/responseHeaders.ts`)

View File

@@ -0,0 +1 @@
- **fix(models):** honor `MODELS_DEV_SYNC_ENABLED=0` as a hard kill switch over the dashboard setting so a wedged `/healthz` / UI can be recovered without HTTP (`src/lib/modelsDevSync.ts`)

View File

@@ -0,0 +1 @@
- **fix(db):** the sql.js fallback now publishes the database atomically — temp file in the same directory, `fsync`, then `rename()` — instead of rewriting it in place with `writeFileSync`. sql.js has no incremental write path, so every save rewrote the whole image through an `O_TRUNC` open: for the duration of the write the on-disk database was 0 bytes and then partial, a window that scales with database size and recurs on every save. Unlike better-sqlite3 / node:sqlite, that window is not covered by SQLite's locking protocol, so it was visible to every OTHER process reading the same file (a backup job, a metrics exporter, an operator running `sqlite3`), which got `SQLITE_CORRUPT` — "database disk image is malformed" — while `PRAGMA integrity_check` passed moments later. It also closes a total-loss window: a crash mid-write used to leave the real database truncated, and now only leaves a stale temp file

View File

@@ -0,0 +1 @@
- **docs(ops):** document Kubernetes probe recommendations — TCP (or soft HTTP) liveness, HTTP `/healthz` readiness, avoid `/api/monitoring/health` as kubelet liveness ([#10297](https://github.com/diegosouzapw/OmniRoute/pull/10297)) — thanks @RaviTharuma

View File

@@ -0,0 +1 @@
- **chore(release):** resync the v3.8.50 provider and CLI catalogs, register the existing ChatCore mutation-coverage test, and document the local ZCode handshake identifier so the release quality gates reflect the current tree without changing ratchet baselines.

View File

@@ -0,0 +1 @@
- **chore(release):** synchronize migration-count documentation and document the opt-in `PROXY_LOG_INCLUDE_IPS` logging flag so the v3.8.50 quality gates match the release tree.

View File

@@ -27,6 +27,7 @@
"providers-bailian-coding-plan.spec.ts": 240, "providers-bailian-coding-plan.spec.ts": 240,
"providers-management.spec.ts": 324, "providers-management.spec.ts": 324,
"proxy-registry.smoke.spec.ts": 218, "proxy-registry.smoke.spec.ts": 218,
"radar-guided-setup.spec.ts": 177,
"resilience-plan-alignment.spec.ts": 382, "resilience-plan-alignment.spec.ts": 382,
"responsive.spec.ts": 21, "responsive.spec.ts": 21,
"search-tools-studio.spec.ts": 133, "search-tools-studio.spec.ts": 133,
@@ -36,4 +37,4 @@
"traffic-inspector.spec.ts": 212, "traffic-inspector.spec.ts": 212,
"translator-friendly.spec.ts": 115, "translator-friendly.spec.ts": 115,
"visual-resilience-smoke.spec.ts": 20 "visual-resilience-smoke.spec.ts": 20
} }

View File

@@ -3319,4 +3319,4 @@
"count": 5 "count": 5
} }
} }
} }

View File

@@ -247,7 +247,7 @@ services:
# fall back to the chatCore path with zero code changes. See # fall back to the chatCore path with zero code changes. See
# docs/architecture/cluster-decisions.md for the activation plan. # docs/architecture/cluster-decisions.md for the activation plan.
bifrost: bifrost:
image: ghcr.io/maximhq/bifrost:1.5.21 image: ghcr.io/maximhq/bifrost:v1.6.11
container_name: omniroute-bifrost container_name: omniroute-bifrost
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -266,9 +266,12 @@ services:
- bifrost - bifrost
# ── Profile: cliproxyapi (CLIProxyAPI as sidecar) ───────────────── # ── Profile: cliproxyapi (CLIProxyAPI as sidecar) ─────────────────
# Official pre-built image lives on Docker Hub (eceasy/cli-proxy-api);
# ghcr.io/router-for-me/* is not publicly pullable. v6.9.7 is the pinned
# version the sidecar integration (port 8317, /v1/models healthcheck) targets.
cliproxyapi: cliproxyapi:
container_name: cliproxyapi container_name: cliproxyapi
image: ghcr.io/router-for-me/cliproxyapi:v6.9.7 image: docker.io/eceasy/cli-proxy-api:v6.9.7
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}" - "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}"

View File

@@ -0,0 +1,29 @@
# Developer environment notes
This page explains the project's local `.env` behavior and how to handle environment files and secrets when developing OmniRoute.
## .env postinstall behavior
The project may generate a local `.env` file during `npm install` / `postinstall` for developer convenience. This file is intended only for local development and testing and must never be committed to version control.
Key points:
- The repository's `.gitignore` already ignores `.env*` files (see the `.gitignore` entry). Do not remove or alter that rule unless you deliberately intend to commit a specific example file and have a documented process for it.
- If a real secret is accidentally committed to the repo, rotate/revoke the credential immediately and remove it from the repository history (for example, using `git filter-repo` or an equivalent remediation workflow). Contact the security/contact owner if you need help.
- For CI and production, use the CI secrets or a secrets manager (GitHub Actions Secrets, Azure Key Vault, HashiCorp Vault, etc.) rather than committing secrets to files.
## Recommended local workflow
- Keep `.env` in your local workspace only. Use `.env.example` (already tracked) to document required variables and acceptable example values.
- When running tests locally that require secret-like values, prefer synthetic placeholders or runtime-generated ephemeral keys rather than real credentials.
- Add a short comment in tests that use placeholders so reviewers understand the fixture is synthetic.
## Scanner notes
- Some compiled or binary assets (e.g., embedded base64 WASM blobs) can contain ASCII substrings that look like credentials and may trigger text-based secret scanners. If these assets are legitimate, either mark them in the scanner's allowlist or exclude the directories in the scanner config.
## If you find a leak
1. Rotate/revoke the key immediately.
2. Remove the secret from the history and force-push a cleaned branch if necessary.
3. Notify maintainers and follow your org's incident response checklist.

View File

@@ -205,10 +205,10 @@ Runs on pull requests only.
Runs after `build`. Blocks merge on failure. Runs after `build`. Blocks merge on failure.
| Suite | Validates | Blocking | | Suite | Validates | Blocking |
| ---------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | ---------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `test:vitest` | MCP server (105 tools), autoCombo, cache — vitest runner | Yes | | `test:vitest` | MCP server (109 tools), autoCombo, cache — vitest runner | Yes |
| `test:vitest:ui` | UI component tests — vitest runner | **Blocking** — pre-existing failures are explicitly excluded in `vitest.config.ts`; new failures fail the job | | `test:vitest:ui` | UI component tests — vitest runner | **Blocking** — pre-existing failures are explicitly excluded in `vitest.config.ts`; new failures fail the job |
### Nightly workflows (scheduled, advisory) ### Nightly workflows (scheduled, advisory)

View File

@@ -102,7 +102,7 @@ OmniRoute/
| **.gitleaks.toml** | gitleaks secret-scan ruleset | | **.gitleaks.toml** | gitleaks secret-scan ruleset |
| **.zizmor.yml** | zizmor GitHub-Actions security-lint config | | **.zizmor.yml** | zizmor GitHub-Actions security-lint config |
| **socket.yml** | Socket.dev supply-chain config | | **socket.yml** | Socket.dev supply-chain config |
| **news.json** | In-app release-notes feed (read by `src/shared/utils/releaseNotes.ts`) | | **news.json** | Localized v2 announcement feed; Radar launch item ships inactive |
| **flake.nix** / **flake.lock** | Nix dev-shell definition + lock | | **flake.nix** / **flake.lock** | Nix dev-shell definition + lock |
| **.env** | Local secrets (gitignored — generated from `.env.example`) | | **.env** | Local secrets (gitignored — generated from `.env.example`) |
@@ -182,7 +182,7 @@ src/
| `compliance/` | Audit log + provider audit — see `docs/security/COMPLIANCE.md` | | `compliance/` | Audit log + provider audit — see `docs/security/COMPLIANCE.md` |
| `compression/` | Compression engine glue (engines live in `open-sse/services/compression/`) | | `compression/` | Compression engine glue (engines live in `open-sse/services/compression/`) |
| `config/` | Runtime config helpers | | `config/` | Runtime config helpers |
| `db/` | 95+ domain DB modules + 144 migrations (always go through here for SQLite) | | `db/` | 95+ domain DB modules + 148 migrations (always go through here for SQLite) |
| `quota/` | Quota Sharing Engine: `dimensions.ts` (types/Zod), `types.ts` (QuotaStore interface), `sqliteQuotaStore.ts`, `redisQuotaStore.ts`, `storeFactory.ts`, `fairShare.ts`, `burnRate.ts`, `planResolver.ts`, `planRegistry.ts`, `saturationSignals.ts`, `enforce.ts`, `spendRecorder.ts` — see `docs/routing/QUOTA_SHARE.md` | | `quota/` | Quota Sharing Engine: `dimensions.ts` (types/Zod), `types.ts` (QuotaStore interface), `sqliteQuotaStore.ts`, `redisQuotaStore.ts`, `storeFactory.ts`, `fairShare.ts`, `burnRate.ts`, `planResolver.ts`, `planRegistry.ts`, `saturationSignals.ts`, `enforce.ts`, `spendRecorder.ts` — see `docs/routing/QUOTA_SHARE.md` |
| `radar/` | Radar free-model catalog client: `feedSchema.ts`, `pinnedKeys.ts`, `verify.ts`, `sync.ts`, `applyFeed.ts`, `index.ts` (`getRadarCatalog()`) — see `docs/frameworks/RADAR.md` | | `radar/` | Radar free-model catalog client: `feedSchema.ts`, `pinnedKeys.ts`, `verify.ts`, `sync.ts`, `applyFeed.ts`, `index.ts` (`getRadarCatalog()`) — see `docs/frameworks/RADAR.md` |
| `display/` | UI formatting helpers (cost, latency, etc.) | | `display/` | UI formatting helpers (cost, latency, etc.) |
@@ -196,7 +196,7 @@ src/
| `memory/vectorStore.ts` | sqlite-vec v0.1.9 wrapper — KNN brute-force + hybrid RRF (FTS5 + vector, k=60). Lazy-init, degrades gracefully when sqlite-vec unavailable. (plan 21) | | `memory/vectorStore.ts` | sqlite-vec v0.1.9 wrapper — KNN brute-force + hybrid RRF (FTS5 + vector, k=60). Lazy-init, degrades gracefully when sqlite-vec unavailable. (plan 21) |
| `memory/reindex.ts` | `runReindexBatch()` — processes memories with `needs_reindex=1` in background; called by `POST /api/memory/reindex` and lazy-backfill path. (plan 21) | | `memory/reindex.ts` | `runReindexBatch()` — processes memories with `needs_reindex=1` in background; called by `POST /api/memory/reindex` and lazy-backfill path. (plan 21) |
| `monitoring/` | Health checks, metrics emission | | `monitoring/` | Health checks, metrics emission |
| `oauth/` | OAuth/import flows for 22 provider modules (agy, antigravity, claude, cline, codebuddy-cn, codex, cursor, devin-desktop, ghe-copilot, github, gitlab-duo, grok-cli-oauth, grok-cli, kilocode, kimi-coding, kiro, qoder, raycast, trae, xai-oauth, zed-hosted, zed) | | `oauth/` | OAuth/import flows for 22 provider modules (agy, antigravity, claude, cline, codebuddy-cn, codex, cursor, devin-desktop, ghe-copilot, github, gitlab-duo, grok-cli-oauth, grok-cli, kilocode, kimi-coding, kiro, qoder, raycast, trae, xai-oauth, zed-hosted, zed) |
| `plugins/` | Plugin registry | | `plugins/` | Plugin registry |
| `promptCache/` | Anthropic-style prompt cache breakpoints | | `promptCache/` | Anthropic-style prompt cache breakpoints |
| `skills/` | Skills framework (built-in + marketplace + SkillsSH) — see `docs/frameworks/SKILLS.md` | | `skills/` | Skills framework (built-in + marketplace + SkillsSH) — see `docs/frameworks/SKILLS.md` |
@@ -209,7 +209,7 @@ src/
| `cacheLayer.ts`, `idempotencyLayer.ts` | Request caching + idempotency | | `cacheLayer.ts`, `idempotencyLayer.ts` | Request caching + idempotency |
| (~30 more top-level files) | Specialized helpers (logEnv, modelsDevSync, piiSanitizer, etc.) | | (~30 more top-level files) | Specialized helpers (logEnv, modelsDevSync, piiSanitizer, etc.) |
### `src/db/` — Database (117 modules + 144 migrations) ### `src/db/` — Database (117 modules + 148 migrations)
| Subdir | Purpose | | Subdir | Purpose |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -256,6 +256,7 @@ src/
| `utils/circuitBreaker.ts` | Provider circuit breaker (see `docs/architecture/RESILIENCE_GUIDE.md`) | | `utils/circuitBreaker.ts` | Provider circuit breaker (see `docs/architecture/RESILIENCE_GUIDE.md`) |
| `utils/apiAuth.ts` | API key validation, scope checking | | `utils/apiAuth.ts` | API key validation, scope checking |
| `utils/fetchTimeout.ts` | Timeout/abort wrappers for upstream fetch | | `utils/fetchTimeout.ts` | Timeout/abort wrappers for upstream fetch |
| `utils/releaseNotes.ts` | Closed v2/legacy announcement parser, localization and ID dismissal |
--- ---
@@ -270,7 +271,7 @@ open-sse/
├── translator/ # Format converters (9 request, 9 response, 9 helpers) ├── translator/ # Format converters (9 request, 9 response, 9 helpers)
├── transformer/ # Responses API ↔ Chat Completions (TransformStream) ├── transformer/ # Responses API ↔ Chat Completions (TransformStream)
├── services/ # ~80+ service modules (combo, accountFallback, autoCombo, reasoningCache, claude code/chatgpt stealth, modelDeprecation, taskAwareRouter, workflowFSM, etc.) ├── services/ # ~80+ service modules (combo, accountFallback, autoCombo, reasoningCache, claude code/chatgpt stealth, modelDeprecation, taskAwareRouter, workflowFSM, etc.)
├── mcp-server/ # MCP server (105 tools, 3 transports, 31 scopes) ├── mcp-server/ # MCP server (109 tools, 3 transports, 33 scopes)
├── config/ # Provider/model registries, header config, model aliases ├── config/ # Provider/model registries, header config, model aliases
├── utils/ # TLS client, proxy fetch/dispatcher, network helpers ├── utils/ # TLS client, proxy fetch/dispatcher, network helpers
├── index.ts # Workspace entry ├── index.ts # Workspace entry
@@ -406,7 +407,7 @@ open-sse/
| Doc | Purpose | | Doc | Purpose |
| -------------------------- | ------------------------------------------------------------------- | | -------------------------- | ------------------------------------------------------------------- |
| `MCP-SERVER.md` | MCP server: 105 tools, 3 transports, 31 scopes, REST endpoints | | `MCP-SERVER.md` | MCP server: 109 tools, 3 transports, 33 scopes, REST endpoints |
| `A2A-SERVER.md` | A2A v0.3: JSON-RPC, 6 skills, REST helpers, agent card | | `A2A-SERVER.md` | A2A v0.3: JSON-RPC, 6 skills, REST helpers, agent card |
| `AGENT_PROTOCOLS_GUIDE.md` | Unified guide: A2A vs ACP vs Cloud Agents | | `AGENT_PROTOCOLS_GUIDE.md` | Unified guide: A2A vs ACP vs Cloud Agents |
| `CLOUD_AGENT.md` | Codex Cloud / Devin / Jules orchestration | | `CLOUD_AGENT.md` | Codex Cloud / Devin / Jules orchestration |

View File

@@ -330,32 +330,75 @@ excludeMarkers, defaultRetryAfterMs}`), matched via `applyStatusRestatement()`.
Permanent errors (agentrouter's `无权访问模型` — no access to this model) are Permanent errors (agentrouter's `无权访问模型` — no access to this model) are
NEVER restated: `excludeMarkers` vetoes the rule even when `textMarkers` hit, NEVER restated: `excludeMarkers` vetoes the rule even when `textMarkers` hit,
so the error keeps its original status and nothing retries it forever. A so the error keeps its original status and nothing retries it forever. The
separate provider classification rule matching provider classification rule
(`agentrouter-model-access-denied` in `open-sse/config/providerErrorRules.ts`) (`agentrouter-model-access-denied` in `open-sse/config/providerErrorRules.ts`:
declares an `auth_error`/scope-`model` match for this text, but it does not `reason: "auth_error"`, `scope: "model"`, a `6h` declared base cooldown) is
fire on the live production path today: the rule only matches `status === consulted by `checkFallbackError` (`open-sse/services/accountFallback.ts`)
403`, and `checkFallbackError`'s apikey-category `FORBIDDEN` branch *before* the generic apikey-category `FORBIDDEN` early-return, gated on
(`open-sse/services/accountFallback.ts`) returns early for a plain 403 `honorsRuleLockScope(provider)` (#10334 — currently agentrouter-exclusive via
*before* the provider-rule lookup ever runs. In practice a `无权访问模型` 403 the `HONORS_RULE_LOCK_SCOPE_PROVIDERS` allowlist in
is handled the same way as the base apikey-provider 403 path (see Connection `providerErrorRules.ts`). The rule's declared 6h cooldown flows through as
Cooldown, §2), not as a 6h model lockout. The rule still exists as a `fallbackResult.baseCooldownMs`, but it still feeds the pre-existing
declarative classification consumable by future callers of `classifyError` per-model-quota lockout path (`lockModelIfPerModelQuota()` /
with context — wiring it into the production `checkFallbackError` path is `recordModelLockoutFailure()`, unchanged by #10334 except for the cooldown
tracked as a follow-up, not yet done. source): it is clamped down to the operator's `mlSettings.maxCooldownMs`
(default `1_800_000ms` / 30min), like every other model lockout, and the
*persisted lockout reason* stays the pre-existing hardcoded `"forbidden"`,
not the rule's `"auth_error"` — only the cooldown duration is honored
end-to-end, not the reason string. The connection itself stays active;
sibling models on the same connection are unaffected.
Restated quota errors (`额度不足`) do reach a provider rule in production Restated quota errors (`额度不足`) reach a provider rule in production
(`agentrouter-user-quota-exhausted`, scope `"connection"`), but `scope` on (`agentrouter-user-quota-exhausted`: `reason: "quota_exhausted"`, `scope:
`ProviderErrorRuleMatch` is currently informational — the persistence path "connection"`, no declared cooldown of its own — the persistence layer's
(`checkFallbackError``combo.ts`) only consumes `reason` and `cooldownMs`, scaled backoff default applies). Since #10334, `scope` on
never `scope`. What actually happens for agentrouter (`passthroughModels: `ProviderErrorRuleMatch` IS consumed end-to-end, but **only** for providers in
true``hasPerModelQuota()` returns `true`) is a **per-model** lockout via the `HONORS_RULE_LOCK_SCOPE_PROVIDERS` allowlist (`providerErrorRules.ts`
`recordModelLockoutFailure()`: the connection itself is never cooled down for today only `"agentrouter"`, gated via `honorsRuleLockScope()`). For every
this error (`combo.ts` skips `recordProviderCooldown` for 429 when other provider `scope` remains informational, exactly as before #10334.
`hasPerModelQuota` is true), so other models on the same account keep being `checkFallbackError` surfaces the matched rule's scope as
tried — each one burns one call and its own lockout before combo routing `fallbackResult.ruleScope`; `isAgentrouterConnectionQuotaScope()`
moves on. Honoring `scope` end-to-end (so a `"connection"` match actually (`src/sse/services/auth.ts`) is the shared guard that confirms a
locks the connection) is tracked as a follow-up. `ruleScope` is genuinely safe to honor as a connection-wide, self-recovering
signal (scope `"connection"`, reason `quota_exhausted`, never `permanent`,
never `creditsExhausted` — a defense against a future rule pairing scope
`"connection"` with a permanent account state). Two consumers call it:
- **Persistence** (`markAccountUnavailable()`, `src/sse/services/auth.ts`):
instead of falling into the passthrough-provider **per-model** lockout
branch (agentrouter is `passthroughModels: true``hasPerModelQuota()`
returns `true`), it applies a **temporary connection cooldown**
`testStatus: "unavailable"` + `rateLimitedUntil`, never a terminal status
(`credits_exhausted`/`banned`/`expired`) — so the connection self-recovers
once the cooldown lapses instead of requiring a manual credential reset.
Skipped for connections with `disableCooling: true` (#2997): that opt-out
falls through to the per-model lockout instead (a documented trade-off —
see the code comment above the branch).
- **Same-request combo routing** (`applyComboTargetExhaustion()`,
`open-sse/services/combo/targetExhaustion.ts`): the same guard marks the
connection into the in-memory `exhaustedConnections` set, keyed
`${provider}:${connectionId}`. This only skips a remaining SAME-REQUEST
target that *itself already carries that exact `connectionId`* on its own
target object (`getExhaustedTargetSkipReason()`,
`open-sse/services/combo/comboPredicates.ts`, `if (provider &&
connectionId)` before the `exhaustedConnections` lookup) — a plain
model-list combo, where sibling targets carry no pinned `connectionId` of
their own and one is only resolved per-dispatch from the response's
`X-OmniRoute-Selected-Connection-Id` header, never hits that key match. For
that common case, the real protection against a remaining leg reusing the
just-exhausted account is NOT this Set — it is the persistence layer above
(the connection's `rateLimitedUntil` is now in the future) combined with
this same guard suppressing `transientRateLimitedProviders` for the
failure (see "Two-stage design" and the code comment on the
`isAgentrouterConnectionQuotaScope` branch in `targetExhaustion.ts`): with
that Set left unmarked, `combo.ts`'s `allowRateLimitedConnection` force-allow
(`open-sse/services/combo.ts:1005-1013`, `:2734-2738`) does NOT kick in for
the provider's remaining legs, so credential selection's `rateLimitedUntil`
filter (`src/sse/services/auth.ts:1238`) is honored normally and a
remaining leg either picks a different, still-eligible agentrouter
connection or fails with no credentials available — it does not force its
way back onto the connection this branch just cooled down.
### Two-stage design: status restatement, then classification ### Two-stage design: status restatement, then classification
@@ -380,6 +423,15 @@ allowlisted providers, the structured error otherwise. Adding a provider to
that the default path for every provider not on the list stays that the default path for every provider not on the list stays
byte-for-byte unchanged. byte-for-byte unchanged.
A rule's `scope` (`model` / `provider` / `connection`) is a separate opt-in
from `FULL_TEXT_RULE_PROVIDERS`: `checkFallbackError` only surfaces it as
`fallbackResult.ruleScope`, and downstream consumers only honor it as
anything other than an informational label, for providers in the
`HONORS_RULE_LOCK_SCOPE_PROVIDERS` allowlist in the same file (`gated via
honorsRuleLockScope()` — today only `"agentrouter"`). See "Restated quota
errors" above for what a `scope: "connection"` match actually does once a
provider is on that allowlist.
### Adding a new quota-misstating gateway ### Adding a new quota-misstating gateway
1. Register one rule array in `statusRestatementRegistry` 1. Register one rule array in `statusRestatementRegistry`
@@ -395,7 +447,15 @@ byte-for-byte unchanged.
`checkFallbackError` only ever hands the rule the structured `checkFallbackError` only ever hands the rule the structured
`{code, type}` error and a body-text rule will never match live traffic. `{code, type}` error and a body-text rule will never match live traffic.
Rules that match purely on `status`/`headers` (like Opencode's or Rules that match purely on `status`/`headers` (like Opencode's or
Minimax's) do not need this opt-in. Minimax's) do not need this opt-in. Separately, if the rule declares
`scope: "connection"` and the intent is an actual connection-wide cooldown
plus same-request combo skip (not just an informational label), add the
provider id to `HONORS_RULE_LOCK_SCOPE_PROVIDERS` in the same file — this
is what gates `isAgentrouterConnectionQuotaScope()`-style consumption in
`markAccountUnavailable()` (`src/sse/services/auth.ts`) and
`applyComboTargetExhaustion()`
(`open-sse/services/combo/targetExhaustion.ts`); without it, `scope`
still flows through `fallbackResult.ruleScope` but nothing acts on it.
3. Add unit tests mirroring `tests/unit/upstream-status-restatement.test.ts` 3. Add unit tests mirroring `tests/unit/upstream-status-restatement.test.ts`
and `tests/unit/agentrouter-error-rules.test.ts` (including the and `tests/unit/agentrouter-error-rules.test.ts` (including the
not-permanent / not-creditsExhausted guards, and — if the provider needs not-permanent / not-creditsExhausted guards, and — if the provider needs

View File

@@ -55,9 +55,9 @@ The two profiles here are **scale-out options for deployments that hit the SQLit
**What it adds:** **What it adds:**
| Service | Image | Ports | Notes | | Service | Image | Ports | Notes |
| --------- | -------------------------------- | ------ | ----------------------------------------------------------------------- | | --------- | --------------------------------- | ------ | ----------------------------------------------------------------------- |
| `bifrost` | `ghcr.io/maximhq/bifrost:1.5.21` | `8080` | Go-based Tier-1 router; persistent logs volume `omniroute_bifrost_logs` | | `bifrost` | `ghcr.io/maximhq/bifrost:v1.6.11` | `8080` | Go-based Tier-1 router; persistent logs volume `omniroute_bifrost_logs` |
**Activation:** set `BIFROST_BASE_URL=http://bifrost:8080` in `.env.example`. The existing sidecar proxy route at [`src/app/api/v1/relay/chat/completions/bifrost/route.ts`](../../src/app/api/v1/relay/chat/completions/bifrost/route.ts) (added in PR #4381) will pick this up automatically. **Activation:** set `BIFROST_BASE_URL=http://bifrost:8080` in `.env.example`. The existing sidecar proxy route at [`src/app/api/v1/relay/chat/completions/bifrost/route.ts`](../../src/app/api/v1/relay/chat/completions/bifrost/route.ts) (added in PR #4381) will pick this up automatically.

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 (339 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 (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.">
<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> <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> <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"/> <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: 339 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, a built-in MCP server with 105 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: 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.">
<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> <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> <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> <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>
@@ -57,7 +57,7 @@
</g> </g>
<g opacity="0"><animate attributeName="opacity" values="0;1" dur="0.4s" begin="0.51s" fill="freeze"/> <g opacity="0"><animate attributeName="opacity" values="0;1" dur="0.4s" begin="0.51s" fill="freeze"/>
<text x="44" y="364" font-size="14.5" fill="#c9d1d9">Built-in MCP server (own tools)</text> <text x="44" y="364" font-size="14.5" fill="#c9d1d9">Built-in MCP server (own tools)</text>
<text x="440" y="364" text-anchor="middle" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="15" font-weight="800" fill="#7ee787">105</text> <text x="440" y="364" text-anchor="middle" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="15" font-weight="800" fill="#7ee787">109</text>
<use href="#no" x="604" y="359"/> <use href="#no" x="604" y="359"/>
<use href="#mid" x="760" y="359"/> <use href="#mid" x="760" y="359"/>
<use href="#no" x="916" y="359"/> <use href="#no" x="916" y="359"/>

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, 339 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 339 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, 40+ 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 105 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, 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.">
<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> <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> <defs>
<pattern id="gridPaperP" width="32" height="32" patternUnits="userSpaceOnUse"> <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"/> <line x1="150" y1="53" x2="1160" y2="53" stroke="#232b38" stroke-width="1.5"/>
</g> </g>
<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">339 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">341 providers.</tspan> Never stop building — OmniRoute picks <tspan fill="#7ee787" font-weight="700">the cheapest one that works</tspan>.</text>
</g> </g>
<g font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif"> <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"/> <line x1="3.9" y1="3.9" x2="18.1" y2="18.1"/>
</g> </g>
<text x="102" y="170" font-size="18" font-weight="800" fill="#74b9ff">Never hit limits</text> <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 339 providers in</text> <text x="66" y="204" font-size="13.5" fill="#a1a1aa">Auto-fallback across 341 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="226" font-size="13.5" fill="#a1a1aa">milliseconds. Quota out? The next provider</text>
<text x="66" y="248" font-size="13.5" fill="#a1a1aa">takes over — zero downtime.</text> <text x="66" y="248" font-size="13.5" fill="#a1a1aa">takes over — zero downtime.</text>
</g> </g>
@@ -73,7 +73,7 @@
<circle cx="6.6" cy="6.6" r="1.4" fill="#fdcb6e" stroke="none"/> <circle cx="6.6" cy="6.6" r="1.4" fill="#fdcb6e" stroke="none"/>
</g> </g>
<text x="862" y="170" font-size="18" font-weight="800" fill="#fdcb6e">$0 to start</text> <text x="862" y="170" font-size="18" font-weight="800" fill="#fdcb6e">$0 to start</text>
<text x="826" y="204" font-size="13.5" fill="#a1a1aa">90+ providers with a free tier, 40+ free</text> <text x="826" y="204" font-size="13.5" fill="#a1a1aa">90+ providers with a free tier, 56 free</text>
<text x="826" y="226" font-size="13.5" fill="#a1a1aa">forever — Qoder, Pollinations, Cloudflare,</text> <text x="826" y="226" font-size="13.5" fill="#a1a1aa">forever — Qoder, Pollinations, Cloudflare,</text>
<text x="826" y="248" font-size="13.5" fill="#a1a1aa">SiliconFlow… No card needed.</text> <text x="826" y="248" font-size="13.5" fill="#a1a1aa">SiliconFlow… No card needed.</text>
</g> </g>
@@ -125,7 +125,7 @@
<path d="M 7,11 L 10,14 L 15.4,8.2"/> <path d="M 7,11 L 10,14 L 15.4,8.2"/>
</g> </g>
<text x="862" y="354" font-size="18" font-weight="800" fill="#7ee787">Production-grade</text> <text x="862" y="354" font-size="18" font-weight="800" fill="#7ee787">Production-grade</text>
<text x="826" y="388" font-size="13.5" fill="#a1a1aa">Circuit breakers, TLS stealth, MCP (105</text> <text x="826" y="388" font-size="13.5" fill="#a1a1aa">Circuit breakers, TLS stealth, MCP (109</text>
<text x="826" y="410" font-size="13.5" fill="#a1a1aa">tools), A2A, memory, guardrails, evals —</text> <text x="826" y="410" font-size="13.5" fill="#a1a1aa">tools), A2A, memory, guardrails, evals —</text>
<text x="826" y="432" font-size="13.5" fill="#a1a1aa">25,000+ tests.</text> <text x="826" y="432" font-size="13.5" fill="#a1a1aa">25,000+ tests.</text>
</g> </g>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

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