Compare commits

...

55 Commits

Author SHA1 Message Date
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
339 changed files with 19914 additions and 1828 deletions

View File

@@ -714,6 +714,16 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Allow OmniRoute to write CLI config files (token refresh, etc.).
# 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
# 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
@@ -735,6 +745,21 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# CLI_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
# Hermes CLI config). Matches the env var the Hermes PowerShell installer sets
# on Windows (%LOCALAPPDATA%\hermes); defaults to ~/.hermes when unset.
@@ -2373,6 +2398,12 @@ APP_LOG_TO_FILE=true
# intended to be published as `omniroute-secure`. See SECURITY.md.
# 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_URL=http://127.0.0.1:20128/login
# ELECTRON_SMOKE_TIMEOUT_MS=45000

View File

@@ -697,11 +697,12 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 30
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
# without shell, CVE-2024-27980 behavior change) could only surface at release.
# windows-latest runs prepare:bundle (the ABI rebuild + spawn plan) per release
# PR; ubuntu keeps the full pack + headless smoke.
# windows-latest runs prepare:bundle (better-sqlite3 prebuild verification since
# v13 — the node-gyp rebuild is gone) per release PR; ubuntu keeps the full
# pack + headless smoke.
strategy:
fail-fast: false
matrix:
@@ -738,7 +739,7 @@ jobs:
# 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
# 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'
working-directory: electron
continue-on-error: true

View File

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

View File

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

View File

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

View File

@@ -55,9 +55,75 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
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:
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 }}
permissions:
contents: write # electron-builder may publish artifacts with GH_TOKEN
@@ -69,19 +135,27 @@ jobs:
runner: windows-latest
target: win
ext: .exe
os: win32
arch: x64
- platform: macos-intel
runner: macos-15-intel
target: mac-x64
ext: .dmg
os: darwin
arch: x64
- platform: macos-arm64
runner: macos-latest
target: mac-arm64
ext: -arm64.dmg
os: darwin
arch: arm64
- platform: linux
runner: ubuntu-latest
target: linux
ext: .AppImage
deb_ext: .deb
os: linux
arch: x64,arm64
steps:
- uses: actions/checkout@v7
@@ -93,14 +167,6 @@ jobs:
node-version: 24
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
run: npm ci
env:
@@ -116,7 +182,11 @@ jobs:
mkdir -p "$RUNNER_TEMP/home"
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:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
NODE_OPTIONS: "--max_old_space_size=6144"
@@ -134,6 +204,30 @@ jobs:
OMNIROUTE_USE_TURBOPACK: ${{ matrix.platform == 'linux' && '0' || '1' }}
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
shell: bash
env:
@@ -158,7 +252,7 @@ jobs:
- name: Install Electron dependencies
working-directory: electron
run: npm install --no-audit --no-fund
run: npm ci --no-audit --no-fund
- name: Build Electron for ${{ matrix.platform }}
working-directory: electron

View File

@@ -137,7 +137,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
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).
- run: npm run check:api-docs-refs
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
@@ -181,7 +181,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- uses: ./.github/actions/npm-ci-retry
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
@@ -430,7 +430,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
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,
# 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.
@@ -476,7 +476,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
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
# 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.
@@ -516,7 +516,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- uses: ./.github/actions/npm-ci-retry
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
@@ -583,7 +583,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- uses: ./.github/actions/npm-ci-retry
- name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result)
run: npm run check:changelog-integrity
- name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo)

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# project-specific directories
/output/
.slim/deepwork/
.omnivscodeagent/
omnirouteCloud/

View File

@@ -8,8 +8,8 @@ WORKDIR /app
# 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
# 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 \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,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=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& apt-get upgrade -y \
&& 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
# 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 \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,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=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& 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
# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a
# 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 \
&& (cd node_modules/better-sqlite3 \
&& 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}"
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 \
&& 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);"
@@ -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
# accessible to the non-root runtime user.
ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,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=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& node node_modules/playwright/cli.js install chromium --with-deps \
&& 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
# Install system dependencies required by openclaw (git+ssh references).
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,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=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \
&& rm -rf /var/lib/apt/lists/* \
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
# 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
USER node

View File

@@ -702,6 +702,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
<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>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>
</table>
@@ -711,6 +712,33 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
<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
</div>

View File

@@ -5,6 +5,7 @@ import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { resolveDataDir } from "../data-dir.mjs";
import { registerContexts } from "./contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function ensureBackup(configPath) {
if (!fs.existsSync(configPath)) return;
@@ -87,6 +88,13 @@ async function runConfigSetCommand(toolId, opts = {}) {
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;
if (!nonInteractive) {
@@ -271,6 +279,10 @@ export function registerConfig(program) {
.option("--model <model>", "Model identifier (where applicable)")
.option("--non-interactive", "Do not prompt for confirmation")
.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) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runConfigSetCommand(tool, {
@@ -306,6 +318,10 @@ export function registerConfig(program) {
.option("--model <model>", "Model identifier")
.option("--non-interactive", "Do not prompt for confirmation")
.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) => {
const globalOpts = cmd.parent.optsWithGlobals();
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 { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs";
import { t } from "../i18n.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
/**
* `omniroute configure <cli>` — interactive provider+model picker that writes a
@@ -75,6 +76,12 @@ function buildCodexProfile(modelId, ctx) {
async function configureCodex(modelId, ctxWindow, opts) {
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 });
const profile = opts.name || profileNameFromModel(modelId);
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("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block");
printInfo(" (run the Codex setup once — see docs/guides/CODEX-CLI-CONFIGURATION.md).");
return 0;
}
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 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");
} finally {
prompt.close();
@@ -149,7 +159,7 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
const ctxWindow = contextWindowOf(entry);
if (target === "codex") {
await configureCodex(chosenId, ctxWindow, opts);
return await configureCodex(chosenId, ctxWindow, opts);
}
return 0;
}
@@ -173,6 +183,10 @@ export function registerConfigure(program) {
.option("--model <id>", "Model id (skips the interactive model prompt)")
.option("--name <name>", "Profile name to write (default: derived from model)")
.option("--codex-home <dir>", "Codex home dir (default: ~/.codex)")
.option(
"--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) => {
const code = await runConfigureCommand(cli, opts, cmd);
if (code !== 0) process.exit(code);

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

@@ -79,6 +79,7 @@ import { registerConfigure } from "./configure.mjs";
import { registerApiCommands } from "../api-commands/registry.mjs";
import { registerPlugin } from "./plugin.mjs";
import { registerRadar } from "./radar.mjs";
import { registerPacks } from "./packs.mjs";
export function registerCommands(program) {
registerMemory(program);
@@ -163,4 +164,5 @@ export function registerCommands(program) {
registerApiCommands(program);
registerPlugin(program);
registerRadar(program);
registerPacks(program);
}

View File

@@ -13,6 +13,7 @@ import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function stripToRoot(url) {
const s = String(url || "").replace(/\/+$/, "");
@@ -25,7 +26,9 @@ export function resolveAiderTarget(opts = {}) {
if (opts.remote) root = stripToRoot(opts.remote);
else {
try {
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
root = stripToRoot(
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
);
} catch {
/* none */
}
@@ -78,7 +81,7 @@ async function fetchModelIds(apiBase, apiKey) {
const res = await fetch(`${apiBase}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return [];
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);
} catch {
return [];
@@ -88,7 +91,16 @@ async function fetchModelIds(apiBase, apiKey) {
export async function runSetupAiderCommand(opts = {}) {
const { apiBase, apiKey } = resolveAiderTarget(opts);
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)");
printInfo(`OPENAI_API_BASE: ${apiBase} (no /v1 — LiteLLM appends it)`);
@@ -107,7 +119,9 @@ export async function runSetupAiderCommand(opts = {}) {
}
}
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;
}
@@ -139,6 +153,10 @@ export function registerSetupAider(program) {
.option("--config-path <path>", ".aider.conf.yml path (default: ~/.aider.conf.yml)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.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) => {
const code = await runSetupAiderCommand(opts);
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 os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import {
categoriseModel,
isCodexCompatibleTextModel,
@@ -147,6 +148,14 @@ export async function runSetupClaudeCommand(opts = {}) {
printHeading("OmniRoute → Claude Code profile generator");
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 ───────────────────────────────────────────────────
let models;
try {
@@ -220,6 +229,10 @@ export function registerSetupClaude(program) {
"Comma-separated substrings — only matching model IDs (e.g. glm,kimi)"
)
.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) => {
const exitCode = await runSetupClaudeCommand(opts);
if (exitCode !== 0) process.exit(exitCode);

View File

@@ -16,6 +16,7 @@ import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function stripToRoot(url) {
let s = String(url || "").replace(/\/+$/, "");
@@ -28,11 +29,14 @@ export function resolveClineTarget(opts = {}) {
if (opts.remote) baseUrl = stripToRoot(opts.remote);
else {
try {
baseUrl = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
baseUrl = stripToRoot(
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
);
} catch {
/* 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"];
if (!apiKey) {
@@ -81,7 +85,7 @@ async function fetchModelIds(baseUrl, apiKey) {
const res = await fetch(`${baseUrl}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return [];
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);
} catch {
return [];
@@ -93,6 +97,14 @@ export async function runSetupClineCommand(opts = {}) {
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
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)");
printInfo(`Server: ${baseUrl}`);
@@ -122,7 +134,18 @@ export async function runSetupClineCommand(opts = {}) {
if (dryRun) {
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"})`);
} else {
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.
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(` API Key: <your OMNIROUTE_API_KEY>`);
printInfo(` Model: ${model}`);
@@ -153,6 +178,10 @@ export function registerSetupCline(program) {
.option("--cline-dir <dir>", "Cline data dir (default: ~/.cline/data)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.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) => {
const code = await runSetupClineCommand(opts);
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 os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import { t } from "../i18n.mjs";
// ── Model categorisation ──────────────────────────────────────────────────────
@@ -306,6 +307,14 @@ export async function runSetupCodexCommand(opts = {}) {
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
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}`);
// ── 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)"
)
.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) => {
const exitCode = await runSetupCodexCommand(opts);
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 { resolveActiveContext } from "../contexts.mjs";
import { categoriseModel } from "./setup-codex.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
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}`);
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);
} catch (e) {
throw new Error(`Could not fetch models: ${e.message}`);
@@ -102,8 +103,22 @@ async function fetchModelIds(apiBase, apiKey) {
export async function runSetupContinueCommand(opts = {}) {
const { apiBase, apiKey } = resolveContinueTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml");
const only = opts.only
? 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)");
printInfo(`apiBase: ${apiBase}`);
@@ -150,7 +165,7 @@ export async function runSetupContinueCommand(opts = {}) {
printInfo("\nProvide the key (config.yaml references it, not stores it):");
printInfo(" cn CLI: export OMNIROUTE_API_KEY=... (read from your shell)");
printInfo(" IDE: echo 'OMNIROUTE_API_KEY=...' >> ~/.continue/.env");
printInfo("Run: cn -p \"reply OK\"");
printInfo('Run: cn -p "reply OK"');
return 0;
}
@@ -166,6 +181,10 @@ export function registerSetupContinue(program) {
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
.option("--config-path <path>", "config.yaml path (default: ~/.continue/config.yaml)")
.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) => {
const code = await runSetupContinueCommand(opts);
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 { resolveActiveContext } from "../contexts.mjs";
import { categoriseModel } from "./setup-codex.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
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}`);
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);
}
export async function runSetupCrushCommand(opts = {}) {
const { baseUrl, apiKey } = resolveCrushTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const only = opts.only ? 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 only = opts.only
? 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)");
printInfo(`base_url: ${baseUrl}`);
@@ -120,13 +135,17 @@ export async function runSetupCrushCommand(opts = {}) {
if (dryRun) {
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;
}
mkdirSync(join(configPath, ".."), { recursive: true });
writeFileSync(configPath, out, "utf8");
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");
return 0;
}
@@ -141,6 +160,10 @@ export function registerSetupCrush(program) {
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
.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(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => {
const code = await runSetupCrushCommand(opts);
if (code !== 0) process.exit(code);

View File

@@ -10,6 +10,7 @@
import { printHeading, printInfo, printSuccess } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { isContainerRuntime } from "../utils/config-home-guard.mjs";
function ensureV1(url) {
const s = String(url || "").replace(/\/+$/, "");
@@ -71,7 +72,7 @@ async function fetchModelIds(apiBase, apiKey) {
});
if (!res.ok) return [];
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);
} catch {
return [];
@@ -84,19 +85,32 @@ export async function runSetupCursorCommand(opts = {}) {
printInfo(`Server: ${apiBase}`);
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);
models = only ? ids.filter((id) => only.some((f) => id.includes(f))) : ids;
console.log("\n" + buildCursorInstructions({ apiBase, models }));
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;
}
export function registerSetupCursor(program) {
program
.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("--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)")

View File

@@ -14,6 +14,7 @@ import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function stripToRoot(url) {
const s = String(url || "").replace(/\/+$/, "");
@@ -26,7 +27,9 @@ export function resolveGooseTarget(opts = {}) {
if (opts.remote) root = stripToRoot(opts.remote);
else {
try {
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
root = stripToRoot(
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
);
} catch {
/* none */
}
@@ -80,7 +83,7 @@ async function fetchModelIds(host, apiKey) {
const res = await fetch(`${host}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return [];
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);
} catch {
return [];
@@ -90,7 +93,16 @@ async function fetchModelIds(host, apiKey) {
export async function runSetupGooseCommand(opts = {}) {
const { host, apiKey } = resolveGooseTarget(opts);
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)");
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):");
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;
}
export function registerSetupGoose(program) {
program
.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("--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)")
@@ -143,6 +157,10 @@ export function registerSetupGoose(program) {
.option("--config-path <path>", "config.yaml path (default: ~/.config/goose/config.yaml)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.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) => {
const code = await runSetupGooseCommand(opts);
if (code !== 0) process.exit(code);

View File

@@ -14,6 +14,7 @@ import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.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). */
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). */
export function buildKiloVscodeSettings(existing, { apiKey, baseUrl, model }) {
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;
return s;
}
@@ -85,7 +90,7 @@ async function fetchModelIds(root, apiKey) {
});
if (!res.ok) return [];
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);
} catch {
return [];
@@ -95,9 +100,22 @@ async function fetchModelIds(root, apiKey) {
export async function runSetupKiloCommand(opts = {}) {
const { baseUrl, apiKey } = resolveKiloTarget(opts);
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 =
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)");
printInfo(`Server: ${baseUrl}`);
@@ -116,7 +134,9 @@ export async function runSetupKiloCommand(opts = {}) {
}
}
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;
}
@@ -132,12 +152,19 @@ export async function runSetupKiloCommand(opts = {}) {
console.log(`\n── [dry-run] ${authPath} ──`);
console.log(
JSON.stringify(
{ "openai-compatible": { ...auth["openai-compatible"], apiKey: apiKey ? "set" : "sk_omniroute" } },
{
"openai-compatible": {
...auth["openai-compatible"],
apiKey: apiKey ? "set" : "sk_omniroute",
},
},
null,
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 {
mkdirSync(join(authPath, ".."), { recursive: true });
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("--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("--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(
"--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("--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) => {
const code = await runSetupKiloCommand(opts);
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 { t } from "../i18n.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -316,6 +317,13 @@ export async function runSetupOpenCodeCommand(opts = {}) {
printInfo(`OpenCode config dir: ${opencodeConfigDir}`);
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
let pluginInfo;
try {
@@ -420,6 +428,10 @@ export function registerSetupOpenCode(setupCommand) {
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) => {
// The parent `setup` command uses cmd.optsWithGlobals(); we mirror
// 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 { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
const ENV_KEY_REF = "{env:OMNIROUTE_API_KEY}";
const JSON_FORMATTING_OPTIONS = { insertSpaces: true, tabSize: 2 };
@@ -119,6 +120,15 @@ export async function runSetupOpencodeCommand(opts = {}) {
const { resolveOpencodeConfigPath } =
await import("../../../src/shared/services/opencodeConfigPath.ts");
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({
baseUrl,
apiKey,
@@ -163,6 +173,10 @@ export function registerSetupOpencode(program) {
.option("--model <id>", "Set the default top-level model (omniroute/<id>)")
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
.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) => {
const code = await runSetupOpencodeCommand(opts);
if (code !== 0) process.exit(code);

View File

@@ -18,6 +18,7 @@ import {
normalizeQwenCodeBaseUrl,
} from "../../../src/shared/services/qwenCodeConfig.ts";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import { createPrompt, printError, printHeading, printInfo, printSuccess } from "../io.mjs";
/** 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)");
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();
if (!model && !opts.yes) {
const modelIds = await fetchModelIds(baseUrl, apiKey);
@@ -159,6 +170,10 @@ export function registerSetupQwen(program) {
.option("--env-path <path>", "Qwen Code .env path")
.option("--yes", "Non-interactive; requires --model")
.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) => {
const code = await runSetupQwenCommand(opts);
if (code !== 0) process.exitCode = code;

View File

@@ -16,6 +16,7 @@ import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function ensureV1(url) {
const s = String(url || "").replace(/\/+$/, "");
@@ -89,7 +90,7 @@ async function fetchModelIds(baseUrl, apiKey) {
});
if (!res.ok) return [];
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);
} catch {
return [];
@@ -99,9 +100,20 @@ async function fetchModelIds(baseUrl, apiKey) {
export async function runSetupRooCommand(opts = {}) {
const { baseUrl, apiKey } = resolveRooTarget(opts);
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 =
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)");
printInfo(`Server: ${baseUrl}`);
@@ -130,8 +142,27 @@ export async function runSetupRooCommand(opts = {}) {
if (dryRun) {
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(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would set roo-cline.autoImportSettingsPath)" : "(skipped — file absent)"}`);
console.log(
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 {
mkdirSync(join(importPath, ".."), { recursive: true });
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("--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("--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(
"--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("--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) => {
const code = await runSetupRooCommand(opts);
if (code !== 0) process.exit(code);

View File

@@ -1304,5 +1304,23 @@
},
"setupCodex": {
"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

@@ -1301,5 +1301,23 @@
},
"setupCodex": {
"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 @@
- **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 @@
- **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(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(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 @@
- **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(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(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

@@ -79,7 +79,7 @@ Use a real key instead when your OmniRoute server is protected or remote.
Codex CLI deprecated `wire_api = "chat"` (Chat Completions) in February 2026 and now **requires** `wire_api = "responses"` (OpenAI Responses API). Setting `wire_api = "chat"` causes an immediate startup crash since v0.138.
DeepSeek, GLM, Kimi and others only expose a Chat Completions endpoint — not the Responses API. If you pointed Codex directly at them, it would fail.
Many providers, including GLM and Kimi, still expose only a Chat Completions endpoint. DeepSeek V4 now exposes a native Responses API as well as an Anthropic-compatible endpoint; OmniRoute uses Responses by default and lets each DeepSeek connection select Anthropic compatibility.
**OmniRoute solves this transparently:**
@@ -87,8 +87,8 @@ DeepSeek, GLM, Kimi and others only expose a Chat Completions endpoint — not t
Codex CLI
→ wire_api = "responses"
→ POST /v1/responses (OmniRoute)
→ OmniRoute Responses ↔ Chat Completions transformer
→ POST /chat/completions (DeepSeek / Mistral / GLM / Kimi / any provider)
→ OmniRoute selects the provider's native protocol and translates when needed
→ POST /responses (DeepSeek V4) or /chat/completions (Mistral / GLM / Kimi / others)
```
You never need a separate translation proxy when using OmniRoute. **All models use `wire_api = "responses"`** — OmniRoute handles the rest.

View File

@@ -14,6 +14,7 @@ lastUpdated: 2026-06-28
- [With Environment File](#with-environment-file)
- [Docker Compose](#docker-compose)
- [Available Profiles](#available-profiles)
- [Configuring host CLI tools when OmniRoute runs in Docker](#configuring-host-cli-tools-when-omniroute-runs-in-docker)
- [Redis Sidecar](#redis-sidecar)
- [Production Compose](#production-compose)
- [Dockerfile Stages](#dockerfile-stages)
@@ -82,6 +83,61 @@ OmniRoute ships four Compose profiles. Pick the one that matches your environmen
> Multiple profiles can be combined: `docker compose --profile cli --profile cliproxyapi up -d`.
## Configuring host CLI tools when OmniRoute runs in Docker
`omniroute setup-codex`, `setup-claude`, `config set <tool>` and the dashboard's
**Save config** button all write files like `~/.codex/*.config.toml`. Those paths
only mean something on the machine where the CLI actually runs. Run them inside
the container and the write lands in the container's own home (`/home/node`
the image runs `USER node`), where no host CLI will ever read it and where it is
discarded the moment the container is recreated.
OmniRoute detects this and refuses the write with instructions instead of
reporting a success you cannot use: the CLI exits `2`, and the API answers `422`
with `containerEphemeralTarget: true`.
### Recommended: run the CLI on the host, OmniRoute in Docker
The container serves the API; the CLI configures your host tools.
```bash
docker compose --profile base up -d
npm install -g omniroute
omniroute connect http://localhost:20128 # point the CLI at the container
omniroute setup-codex # writes the real ~/.codex on your host
```
This is the right choice when Codex, Claude Code, Cursor or similar run on your
laptop — which is the usual setup.
### Alternative: bind-mount the host config dirs (`host` profile)
If you want the container itself to write your host config, mount the
directories in and point `CLI_CONFIG_HOME` at the mount root. The `host` profile
already does this:
```yaml
environment:
- CLI_CONFIG_HOME=/host-home
- CLI_ALLOW_CONFIG_WRITES=true
volumes:
- ~/.codex:/host-home/.codex:rw
- ~/.claude:/host-home/.claude:rw
```
A bind mount is what makes the path trustworthy: OmniRoute reads
`/proc/self/mountinfo` and allows writes to mounted paths (and to directories
whose children are mounts, which is exactly the `/host-home` shape above) while
still refusing unmounted ones.
### Escape hatch: configure the container's own CLIs
When the CLIs genuinely live inside the container (the `cli` profile), the write
is intentional. Pass `--allow-container-write` to any `setup-*` command, or set
`OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` for the server. The write proceeds
with a warning that it will not survive the container.
## Redis Sidecar
OmniRoute relies on Redis to back the distributed rate limiter and shared cache. The `redis` service is **always defined** in `docker-compose.yml` (it has no profile gate) and starts alongside any other profile.
@@ -270,7 +326,22 @@ prefix). Traefik should route `PathPrefix(`/omniroute`)` to the container withou
`/omniroute/_next/...`.
The Docker healthcheck probes `/api/monitoring/health` prefixed with the active
`OMNIROUTE_BASE_PATH`.
`OMNIROUTE_BASE_PATH`. That path is a **deep** check (DB + monitoring summary). It is
appropriate for Dockers infrequent `HEALTHCHECK`, but **not** for Kubernetes
`livenessProbe` intervals.
For orchestrators (Kubernetes, Nomad, etc.):
| Probe | Prefer | Avoid |
| --- | --- | --- |
| Liveness | TCP on the main port (`PORT`, default `20128`), or soft HTTP `/healthz` | `/api/monitoring/health` as liveness |
| Readiness | HTTP `GET /healthz` | Tight timeouts that treat event-loop busy as dead |
| Deep / blackbox | `/api/monitoring/health` | — |
`/healthz` only reports process lifecycle (`ok` / `starting` / `stopping`). It still
runs on the same Node event loop as request handling, so CPU-bound catalog or
compression work can delay it — busy ≠ dead. Full probe guidance:
[Monitoring guide — Kubernetes probe recommendations](../ops/MONITORING_GUIDE.md#kubernetes-probe-recommendations).
## Docker Compose with Caddy (HTTPS Auto-TLS)

View File

@@ -39,15 +39,15 @@ system tray, auto-updater, IPC bridge, and zero-config secret bootstrap.
Confirmed from `electron/package.json`:
| Package | Version |
| ------------------ | -------------------------- |
| `electron` | `^41.5.1` |
| `electron-builder` | `^26.10.0` |
| `electron-updater` | `^6.8.5` |
| `better-sqlite3` | `^12.9.0` |
| App version | `3.8.0` |
| App id | `online.omniroute.desktop` |
| Product name | `OmniRoute` |
| Package | Version |
| ------------------ | --------------------------------------------------------- |
| `electron` | `^41.5.1` |
| `electron-builder` | `^26.10.0` |
| `electron-updater` | `^6.8.5` |
| `better-sqlite3` | root `^13.0.2` (Node-API prebuilds — no Electron rebuild) |
| App version | `3.8.0` |
| App id | `online.omniroute.desktop` |
| Product name | `OmniRoute` |
## Scripts (root `package.json`)
@@ -260,14 +260,14 @@ Releases are published to GitHub Releases (`diegosouzapw/OmniRoute`), which is a
## Troubleshooting
| Symptom | Fix |
| --------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `Cannot find module 'better-sqlite3'` after Electron major bump | `cd electron && npm rebuild` |
| `ERR_DLOPEN_FAILED` for native module | Re-run `prepare:bundle` and verify ABI matches Electron's Node |
| Window appears blank on Linux | Confirm Next.js server actually bound to PORT (check `[Server]` logs) |
| macOS notarization stalls | Ensure `APPLE_*` vars are exported, not just in `.env` |
| Windows SmartScreen warning | Sign with EV cert, or users right-click → "Run anyway" |
| Smoke test fails with port-in-use | Stop any local dev server on 20128 before running `electron:smoke:packaged` |
| Symptom | Fix |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Cannot find module 'better-sqlite3'` after Electron major bump | better-sqlite3 v13 ships Node-API prebuilds — re-run `npm install` at the root and `prepare:bundle` (it verifies the prebuild for the current platform) |
| `ERR_DLOPEN_FAILED` for native module | Re-run `prepare:bundle` — it fails fast when the Node-API prebuild for the current platform is missing |
| Window appears blank on Linux | Confirm Next.js server actually bound to PORT (check `[Server]` logs) |
| macOS notarization stalls | Ensure `APPLE_*` vars are exported, not just in `.env` |
| Windows SmartScreen warning | Sign with EV cert, or users right-click → "Run anyway" |
| Smoke test fails with port-in-use | Stop any local dev server on 20128 before running `electron:smoke:packaged` |
## See Also

View File

@@ -5468,15 +5468,78 @@ paths:
get:
tags: [System]
summary: Get Modality Bridge telemetry
description: In-memory per-modality bridge counters (bridged, cacheHits, failures, lastUsedAt). Counters reset on process restart.
description: In-memory per-modality bridge counters (attempts, successes, bridged, cacheHits, failures, totalLatencyMs, latencySamples, averageLatencyMs, lastUsedAt). The bridged field is the backward-compatible success count. Latency averages include sampled operations only; an unsampled Vision or Audio operation does not fabricate a zero-millisecond sample. Counters reset on process restart.
security:
- ManagementSessionAuth: []
responses:
"200":
description: Per-modality bridge stats (vision, audio)
description: Per-modality bridge stats (vision, audio, video)
"401":
description: Unauthorized
/api/modality-bridge/video/runtime:
get:
x-loopback-only: true
tags: [System]
summary: Get Video Bridge runtime status
description: Requires trusted loopback locality before authentication or probing, then management authentication. Returns sanitized FFmpeg and ffprobe availability and versions. The response never contains commands, paths, or stderr.
security:
- ManagementSessionAuth: []
responses:
"200":
description: Video Bridge runtime availability
"401":
description: Unauthorized
"403":
description: Localhost access required
/api/modality-bridge/video/extract:
post:
x-loopback-only: true
tags: [System]
summary: Extract bounded Video Bridge frames through the internal broker
description: Internal per-process-authenticated trusted-loopback broker. Accepts at most 50 MiB of video bytes; URLs, paths, executable names, and command arguments are not part of the contract. The body pipeline and streamed handler reader both enforce the input cap. The broker applies fixed FFmpeg/ffprobe confinement, a single extraction slot with four pending jobs/100 MiB queued input, a 4 MiB per-frame cap, and a 32 MiB total response cap. This is not a public upload API.
security: []
parameters:
- in: query
name: frames
required: true
schema:
type: integer
minimum: 1
maximum: 16
requestBody:
required: true
content:
application/octet-stream:
schema:
type: string
format: binary
maxLength: 52428800
responses:
"200":
description: Sanitized duration and bounded JPEG data-URI frames
"400":
description: Invalid fixed broker contract
"403":
description: Authenticated trusted-loopback broker identity required
"413":
description: Input exceeds the 50 MiB byte limit
"422":
description: Media rejected or extraction failed
"499":
description: Client request aborted
"503":
description: Queue capacity is exhausted, or FFmpeg/ffprobe is unavailable on PATH
headers:
Retry-After:
description: Present with value 1 when queue capacity is exhausted
schema:
type: integer
minimum: 1
"504":
description: Fixed 120-second broker extraction deadline exceeded
/api/cache/stats:
get:
tags: [System]

View File

@@ -1,7 +1,7 @@
---
title: "Monitoring & Observability Guide"
version: 3.8.40
lastUpdated: 2026-06-28
version: 3.8.50
lastUpdated: 2026-08-13
---
# Monitoring & Observability Guide
@@ -103,9 +103,29 @@ Per-combo:
## Health Check API
> **Note:** Only `GET /api/monitoring/health` is exposed as a REST endpoint. All other monitoring data (provider health, autopilot issues, quota monitors, token health, latency) is accessed via the **MCP tool** `observability_snapshot` or the **dashboard** pages — there are no dedicated REST routes for these.
OmniRoute exposes **two** HTTP health surfaces. They are not interchangeable for orchestrators.
### System Health
| Path | Purpose | Weight | Use for |
| --- | --- | --- | --- |
| `GET /healthz` | Lifecycle liveness/readiness (`ok` / `starting` / `stopping`) | Trivial (phase flag only) | Kubernetes **readiness**; soft **liveness** if you must use HTTP |
| `GET /api/monitoring/health` | Deep system + provider summary (DB, heap, catalog counts, …) | Heavy (sync DB / monitoring work) | Dashboards, blackbox deep checks, Dockers built-in healthcheck |
> **Note:** Provider health matrices, autopilot issues, quota monitors, token health, and latency detail beyond `/api/monitoring/health` are available via the **MCP tool** `observability_snapshot` or the **dashboard** pages — there are no dedicated REST routes for those.
Both routes run on the **same Node event loop** as request handling. A CPU-bound path (large `GET /v1/models` catalog work, long-context compression / token counting) can delay **all** HTTP handlers, including `/healthz`. Event-loop busy ≠ process dead. Prefer fixing the hog; probe tuning only reduces false kills.
### Lightweight orchestrator probe
```bash
GET /healthz
# or HEAD /healthz
```
- **200** + body `ok` when the server lifecycle phase is ready
- **503** + `starting` / `stopping` during boot or shutdown
- Implementation: `src/app/healthz/route.ts` (no DB ping)
### System Health (deep)
```bash
GET /api/monitoring/health
@@ -135,6 +155,48 @@ Response:
}
```
### Kubernetes probe recommendations
OmniRoute is a **single Node process** (one event loop). Stock Docker `HEALTHCHECK` targets `/api/monitoring/health` — that is **too heavy** for kubelet liveness intervals.
| Probe | Recommended target | Notes |
| --- | --- | --- |
| **Startup** | HTTP `GET /healthz` with a long `failureThreshold` (or large `startPeriod`) | Cold start + SQLite migration can exceed a few seconds |
| **Readiness** | HTTP `GET /healthz` | Remove endpoints while starting/stopping; still flaps if the loop is CPU-blocked |
| **Liveness** | **TCP** on the main service port (`PORT`, default `20128`), **or** HTTP `/healthz` with soft thresholds | Do **not** kill the pod on short event-loop stalls; busy ≠ dead |
| **Deep health** | `GET /api/monitoring/health` from an external checker | Not for kubelet `livenessProbe` / tight `readinessProbe` |
Example shape (adjust thresholds to your cold-start and compression load):
```yaml
ports:
- name: http
containerPort: 20128
startupProbe:
httpGet:
path: /healthz
port: http
failureThreshold: 30
periodSeconds: 5
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 6
livenessProbe:
tcpSocket:
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 6
```
**Do not** point kubelet **liveness** at `/api/monitoring/health`. That path does real DB/monitoring work and will false-positive under load.
Related: [#10052](https://github.com/diegosouzapw/OmniRoute/issues/10052) (probes while the event loop is busy), [#9685](https://github.com/diegosouzapw/OmniRoute/issues/9685) / [#10055](https://github.com/diegosouzapw/OmniRoute/pull/10055) (catalog pricing hog), [#10117](https://github.com/diegosouzapw/OmniRoute/issues/10117) (compression token-count hog).
### Provider Health
> **No REST endpoint.** Provider health data is available via the MCP tool `observability_snapshot` or the dashboard `/dashboard/providers` page.

View File

@@ -665,13 +665,15 @@ X-OmniRoute-No-Cache: true
### Monitoring
| Endpoint | Method | Description |
| ---------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/sessions` | GET | Active session tracking |
| `/api/rate-limits` | GET | Per-account rate limits |
| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) |
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
| `/api/modality-bridge/stats` | GET | In-memory Modality Bridge telemetry — per-modality `bridged`/`cacheHits`/`failures`/`lastUsedAt` counters (reset on restart; management auth) |
| Endpoint | Method | Description |
| ------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/sessions` | GET | Active session tracking |
| `/api/rate-limits` | GET | Per-account rate limits |
| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) |
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
| `/api/modality-bridge/stats` | GET | In-memory `attempts`, successes/`bridged`, failures, cache hits, `totalLatencyMs`, `latencySamples`, sample-denominated `averageLatencyMs`, and last-use time (reset on restart; management auth) |
| `/api/modality-bridge/video/runtime` | GET | Strict trusted-loopback check before management auth/probe; sanitized FFmpeg/ffprobe availability and versions (no-store) |
| `/api/modality-bridge/video/extract` | POST | Internal authenticated trusted-loopback byte broker; 50 MiB input, bounded queue/32 MiB output, `503` capacity, `499` disconnect, `504` deadline; not a public upload API |
### Backup & Export/Import

View File

@@ -69,6 +69,20 @@ with the right env injected and write no config at all.
> local vs remote, and which tools want a `/v1` suffix — lives in
> **[CLI Integrations](../guides/CLI-INTEGRATIONS.md)**.
### Running these inside a container
A `setup-*` command executed inside the OmniRoute container writes into the
container's own home, which no host CLI reads and which disappears with the
container. OmniRoute detects that and exits `2` with instructions rather than
writing. Two supported ways forward — install the CLI on the host and
`omniroute connect` to the container, or bind-mount the config dirs and set
`CLI_CONFIG_HOME` (the compose `host` profile). Every `setup-*` command, plus
`omniroute configure` and `omniroute config set`, accepts
`--allow-container-write` when configuring the container's own CLIs is what you
actually meant; `OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` does the same for
the server. See
[Docker Guide → Configuring host CLI tools](../guides/DOCKER_GUIDE.md#configuring-host-cli-tools-when-omniroute-runs-in-docker).
---
## Source of Truth
@@ -94,33 +108,33 @@ Entries with `baseUrlSupport: "none"` are **not shown** in the dashboard pages
All tools that appear in `/dashboard/cli-code`. Those with `baseUrlSupport: none` are wired through MITM or a manual guide instead of a custom base URL:
| id | name | vendor | baseUrlSupport | configType | acpSpawnable |
|----|------|--------|---------------|-----------|-------------|
| claude | Claude Code | Anthropic | full | env | true |
| codex | OpenAI Codex CLI | OpenAI | full | custom | true |
| cline | Cline | OSS (ex-Claude Dev) | full | custom | true |
| kilo | Kilo Code | Kilo-Org | full | custom | false |
| roo | Roo Code | Roo (OSS) | full | guide | false |
| continue | Continue | continue.dev | full | guide | false |
| aider | Aider | OSS (P. Gauthier) | full | guide | true |
| forge | ForgeCode | Antinomy HQ | full | custom | true |
| jcode | jcode | 1jehuang (OSS) | full | custom | false |
| deepseek-tui | DeepSeek TUI | Hunter Bown (OSS) | full | custom | false |
| codewhale | CodeWhale | Hmbown (OSS) | full | custom | false |
| opencode | OpenCode | Anomaly (ex-SST) | full | guide | true |
| droid | Factory Droid | Factory AI | partial | guide | false |
| copilot | GitHub Copilot CLI | GitHub/MS | full | custom | false |
| cursor-cli | Cursor CLI | Anysphere | partial | guide | true |
| smelt | Smelt | leonardcser (OSS) | full | custom | false |
| pi | Pi (pi-coding-agent) | M. Zechner (OSS) | full | custom | false |
| grok-build | Grok Build | xAI | full | custom | false |
| crush | Crush | OSS (Charm) | full | custom | false |
| qwen | Qwen Code | Alibaba | full | guide | true |
| cursor | Cursor | Anysphere | none | guide | false |
| antigravity | Antigravity | Google | none | mitm | false |
| hermes | Hermes | Nous Research | none | guide | false |
| kiro | Kiro AI | Amazon | none | mitm | false |
| custom | Custom CLI | — | full | custom-builder | false |
| id | name | vendor | baseUrlSupport | configType | acpSpawnable |
| ------------ | -------------------- | ------------------- | -------------- | -------------- | ------------ |
| claude | Claude Code | Anthropic | full | env | true |
| codex | OpenAI Codex CLI | OpenAI | full | custom | true |
| cline | Cline | OSS (ex-Claude Dev) | full | custom | true |
| kilo | Kilo Code | Kilo-Org | full | custom | false |
| roo | Roo Code | Roo (OSS) | full | guide | false |
| continue | Continue | continue.dev | full | guide | false |
| aider | Aider | OSS (P. Gauthier) | full | guide | true |
| forge | ForgeCode | Antinomy HQ | full | custom | true |
| jcode | jcode | 1jehuang (OSS) | full | custom | false |
| deepseek-tui | DeepSeek TUI | Hunter Bown (OSS) | full | custom | false |
| codewhale | CodeWhale | Hmbown (OSS) | full | custom | false |
| opencode | OpenCode | Anomaly (ex-SST) | full | guide | true |
| droid | Factory Droid | Factory AI | partial | guide | false |
| copilot | GitHub Copilot CLI | GitHub/MS | full | custom | false |
| cursor-cli | Cursor CLI | Anysphere | partial | guide | true |
| smelt | Smelt | leonardcser (OSS) | full | custom | false |
| pi | Pi (pi-coding-agent) | M. Zechner (OSS) | full | custom | false |
| grok-build | Grok Build | xAI | full | custom | false |
| crush | Crush | OSS (Charm) | full | custom | false |
| qwen | Qwen Code | Alibaba | full | guide | true |
| cursor | Cursor | Anysphere | none | guide | false |
| antigravity | Antigravity | Google | none | mitm | false |
| hermes | Hermes | Nous Research | none | guide | false |
| kiro | Kiro AI | Amazon | none | mitm | false |
| custom | Custom CLI | — | full | custom-builder | false |
Tools with `baseUrlSupport: "partial"` show a badge "⚠ Base URL parcial" in the dashboard card.
---
@@ -201,16 +215,16 @@ interface ToolBatchStatus {
New tools with `configType: "custom"` have dedicated settings API routes:
| Route | Tool |
| ------------------------------------------- | ------------------------------ |
| `POST /api/cli-tools/forge-settings` | ForgeCode (.forge.toml) |
| `POST /api/cli-tools/jcode-settings` | jcode (--base-url flag) |
| `POST /api/cli-tools/deepseek-tui-settings` | DeepSeek TUI (OPENAI_BASE_URL, legacy) |
| Route | Tool |
| ------------------------------------------- | ---------------------------------------------------------------- |
| `POST /api/cli-tools/forge-settings` | ForgeCode (.forge.toml) |
| `POST /api/cli-tools/jcode-settings` | jcode (--base-url flag) |
| `POST /api/cli-tools/deepseek-tui-settings` | DeepSeek TUI (OPENAI_BASE_URL, legacy) |
| `POST /api/cli-tools/codewhale-settings` | CodeWhale (OPENAI_BASE_URL, primary + legacy `~/.deepseek` sync) |
| `POST /api/cli-tools/smelt-settings` | Smelt |
| `POST /api/cli-tools/pi-settings` | Pi coding agent |
| `POST /api/cli-tools/grok-build-settings` | Grok Build (~/.grok/config.toml, `[model.omniroute]`) |
| `POST /api/cli-tools/qwen-settings` | Qwen Code (`~/.qwen/settings.json` + dedicated `.env` key) |
| `POST /api/cli-tools/smelt-settings` | Smelt |
| `POST /api/cli-tools/pi-settings` | Pi coding agent |
| `POST /api/cli-tools/grok-build-settings` | Grok Build (~/.grok/config.toml, `[model.omniroute]`) |
| `POST /api/cli-tools/qwen-settings` | Qwen Code (`~/.qwen/settings.json` + dedicated `.env` key) |
All routes use `sanitizeErrorMessage()` for error responses (Hard Rule #12).

View File

@@ -376,7 +376,7 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex,
| ------------------------- | ----------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `CLI_MODE` | `auto` | `src/shared/services/cliRuntime.ts` | `auto` = search system PATH; `manual` = use explicit paths only. |
| `CLI_EXTRA_PATHS` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Additional PATH entries for CLI binary discovery (colon-separated). |
| `CLI_CONFIG_HOME` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Override home directory for reading CLI configs (`~/.claude`, `~/.codex`). |
| `CLI_CONFIG_HOME` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Override home directory for reading CLI configs (`~/.claude`, `~/.codex`). Must be absolute and inside the process home — **or**, in a container, a bind-mounted path (that is how `/host-home` works). Anything else falls back to the home dir. |
| `CLI_ALLOW_CONFIG_WRITES` | `false` | `src/shared/services/cliRuntime.ts` | Allow OmniRoute to write CLI config files (token refresh, session data). |
| `CLI_CLAUDE_BIN` | `claude` | `src/shared/services/cliRuntime.ts` | Custom path to Claude CLI binary. |
| `CLI_CODEX_BIN` | `codex` | `src/shared/services/cliRuntime.ts` | Custom path to Codex CLI binary. |
@@ -400,6 +400,17 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex,
| `DEVIN_BRIDGE_SUBAGENT_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used for Claude Code subagents. |
| `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. |
| `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). |
| `ZCODE_BIN` | `zcode` | `open-sse/executors/zcode.ts` | Binary used for the local `zcode` provider's stdio client. Falls back to `zcode` on PATH. |
| `ZCODE_ARGS` | — | `open-sse/executors/zcode.ts` | JSON array (≤16 strings) of extra arguments passed to the `zcode` binary when launched via `cliTools`. |
| `ZCODE_CWD` | `process.cwd()` | `open-sse/executors/zcode.ts` | Working directory for the ZCode app-server subprocess. |
| `ZCODE_PROVIDER_ID` | `builtin:zai-coding-plan` | `open-sse/executors/zcode.ts` | Override for the provider id sent to the app-server. |
| `ZCODE_SERVER_RUNTIME_ROOT` | `~/.zcode/server` | `open-sse/executors/zcode.ts` | Root of the ZCode app-server runtime (where the bundled `node` and `zcode-server.cjs` live). |
| `ZCODE_SERVER_NODE` | `<runtimeRoot>/node` | `open-sse/executors/zcode.ts` | Node executable used to host the ZCode app-server. |
| `ZCODE_SERVER_ENTRY` | `<runtimeRoot>/zcode-server.cjs` | `open-sse/executors/zcode.ts` | App-server entry script used to host the ZCode server. |
| `ZCODE_STARTUP_TIMEOUT_MS` | `10000` | `open-sse/executors/zcode.ts` | Startup timeout (ms) before a ZCode app-server launch is considered failed. |
| `ZCODE_RPC_TIMEOUT_MS` | `30000` | `open-sse/executors/zcode.ts` | Per-request RPC timeout (ms) for a ZCode app-server call. |
| `ZCODE_TURN_TIMEOUT_MS` | `120000` | `open-sse/executors/zcode.ts` | Maximum duration (ms) of one ZCode turn before the supervisor times it out. |
| `ZCODE_POLL_INTERVAL_MS` | `250` | `open-sse/executors/zcode.ts` | Polling interval (ms) for ZCode turn completion. |
| `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). |
### CLI Profile Auto-Sync
@@ -417,11 +428,25 @@ the CLI Code dashboard.
```bash
# Mount host binaries into the container and tell OmniRoute where they are:
CLI_EXTRA_PATHS=/host-cli/bin
CLI_CONFIG_HOME=/root
CLI_CONFIG_HOME=/host-home
CLI_ALLOW_CONFIG_WRITES=true
CLI_CLAUDE_BIN=/host-cli/bin/claude
```
`CLI_CONFIG_HOME` only takes effect when the path is actually bind-mounted from
the host — pair it with mounts like `~/.codex:/host-home/.codex:rw` (see the
`host` profile in `docker-compose.yml`). A path that is neither inside the
container user's home nor a bind mount is ignored, because writing there would
be discarded when the container is recreated.
The image runs as `USER node`, so an unmounted `/root` is **not** a valid
override.
| Variable | Default | Source File | Description |
| ---------------------------------------- | ------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `OMNIROUTE_CONTAINER` | _(auto)_ | `src/shared/utils/containerEnv.ts` | Force container detection on (`1`/`true`) or off (`0`/`false`). Only needed on runtimes the auto-detection misses. |
| `OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE` | `false` | `src/shared/services/cliRuntime.ts` | Allow CLI-tool config writes into an unmounted container path anyway. The CLI equivalent is `--allow-container-write`. |
### CLI Binary (`omniroute`) helpers
These variables tune the `omniroute` CLI binary's own behavior (not the sidecar
@@ -1476,9 +1501,9 @@ These settings were introduced after the previous environment-contract snapshot.
| Variable | Default | Source File | Description |
| --- | --- | --- | --- |
| `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` | `2000` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum wait for a heavyweight chat admission slot before a retryable `503`; a short bounded wait serializes agent bursts instead of an instant `503`. `0` restores immediate rejection. |
| `OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES` | `4194304` (4 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Queued-bytes budget for the admission wait (#9654): bounds total buffered body bytes parked per lane so the wait cannot amplify the heap (#4380). Over-budget waits receive a retryable `503` immediately. |
| `OMNIROUTE_CHAT_VIRTUAL_TTL_MS` | `60000` (60 s) | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): idle-lane eviction TTL. |
| `OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). |
| `OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES` | `4194304` (4 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Queued-bytes budget for the admission wait: bounds total buffered body bytes parked process-wide so the wait cannot amplify the heap (#4380). Over-budget waits receive a retryable `503` immediately. |
| `OMNIROUTE_CHAT_VIRTUAL_TTL_MS` | `60000` (60 s) | `src/shared/middleware/chatBodyAdmission.ts` | Deprecated no-op since #10110: per-session admission lanes were removed in favor of one process-wide budget. Accepted for configuration compatibility; ignored. |
| `OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Deprecated no-op since #10110: per-session admission lanes were removed in favor of one process-wide budget. Accepted for configuration compatibility; ignored. |
| `OMNIROUTE_RUNNOW_TIMEOUT_MS` | `30000` | `src/app/api/jobs/[id]/run-now/route.ts` | Bounds how long a run-now call waits for an in-flight job before starting the queued run. |
| `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh through account-scoped Chrome CDP sessions; set `0` to disable. |
| `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR`; set `0` for memory-only state. |
@@ -1496,6 +1521,7 @@ These settings were introduced after the previous environment-contract snapshot.
| `TELEGRAM_DEFAULT_MODEL` | `auto/chat` | `src/lib/telegram/chatProxy.ts` | Model used for Telegram chat replies. |
| `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override for proxies or self-hosted Bot API servers. |
| `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout in milliseconds for outbound Bot API calls. |
| `OMNIROUTE_OPTIONAL_PACK_TAR` | `1` (enabled) | `scripts/build/optionalPackStaging.mjs` | Set `0` to skip emitting `.tar.gz` tarballs while staging optional ML/browser packs for the Electron standalone tree (pack directories and `optional-packs.index.json` are still produced). Used by the desktop release workflow to trim artifact upload size. |
### ChatGPT Web (Codex)
Globale Defaults für den headless Browser und den ausgehenden Tool-Tunnel. Im Dashboard gesetzte Connection-Werte haben Vorrang.

View File

@@ -252,7 +252,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `mnn-ai` | `mnn-ai` | MNN AI | API key, aggregator | [link](https://mnnai.ru) | Free plan: $1 monthly credits, 10 RPM and access only to models marked Free. |
| `modal` | `mdl` | Modal | API key, enterprise | [link](https://modal.com/docs) | Use the bearer token that protects your Modal deployment, if enabled. Base URL should point to your OpenAI-compatible Modal app, for example https://<workspace>--<app>.modal.run/v1. |
| `modelscope` | `ms` | ModelScope | API key | [link](https://modelscope.cn) | Free tier via ModelScope API-Inference — Alibaba account required. |
| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | Get API key at monsterapi.ai |
| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | ⚠️ **DEPRECATED.** Monster API shuttered operations on 2026-06-30. Use alternative OpenAI-compatible providers. |
| `moonshot` | `moonshot` | Kimi | API key | [link](https://platform.kimi.ai?aff=omniroute) | — |
| `morph` | `morph` | Morph | API key | [link](https://morphllm.com) | Free tier: 250K credits/month, $0 |
| `muse-code` | `mc` | Muse Code (Meta) | API key | [link](https://github.com/meta-llama/llama-stack) | Use your META_API_KEY env var as a Bearer token. Muse Code CLI uses the OpenAI Responses API wire format (POST /responses). |

View File

@@ -1,13 +1,13 @@
---
title: "Guardrails"
version: 3.8.50
lastUpdated: 2026-08-08
lastUpdated: 2026-08-14
---
# Guardrails
> **Source of truth:** `src/lib/guardrails/`
> **Last updated:** 2026-08-08 — v3.8.50 (Modality Bridge PR-3: Audio Bridge runtime and functional Audio settings tab)
> **Last updated:** 2026-08-15 — v3.8.50 (Video Bridge broker confinement)
Guardrails enforce safety, policy, and content transformations at the boundary
between OmniRoute and upstream providers. Each guardrail can inspect (and
@@ -20,13 +20,14 @@ request. Blocking is an explicit decision (`block: true`), never an accident.
## Built-in Guardrails
The registry auto-loads five guardrails in priority order on import
The registry auto-loads six guardrails in priority order on import
(see `registry.ts``registerDefaultGuardrails()`):
| Priority | Name | Stage(s) | File |
| -------- | ------------------- | -------------- | --------------------- |
| `5` | `vision-bridge` | `preCall` | `visionBridge.ts` |
| `6` | `audio-bridge` | `preCall` | `audioBridge.ts` |
| `7` | `video-bridge` | `preCall` | `videoBridge.ts` |
| `10` | `pii-masker` | `pre` + `post` | `piiMasker.ts` |
| `20` | `prompt-injection` | `preCall` | `promptInjection.ts` |
| `95` | `credential-masker` | `pre` + `post` | `credentialMasker.ts` |
@@ -172,7 +173,12 @@ swap is already visible in the response body's `model` field.
`GET /api/modality-bridge/stats` (management auth, same tier as
`GET /api/settings`) returns the in-memory per-modality counters
`{ bridged, cacheHits, failures, lastUsedAt }` for `vision` and `audio`.
`{ attempts, successes, bridged, cacheHits, failures, totalLatencyMs,
latencySamples, averageLatencyMs, lastUsedAt }` for `vision`, `audio`, and
`video`. `averageLatencyMs` uses `latencySamples`, not all attempts, as its
denominator; an operation without timing does not fabricate a zero-millisecond
sample. `bridged` remains the backward-compatible alias for successful
conversions; failed attempts do not increment it.
Counters reset on process restart by design
(telemetry, not accounting).
@@ -186,8 +192,9 @@ default), task-aware prompting, advanced timeout/image/description-length/cache
limits, runtime
counters, and a guarded sample request. The Audio tab is also live: it exposes
enablement, an STT-only model picker with Auto, timeout/max-clip limits, audio
counters, and an `input_audio` sample test. Video remains the explicit placeholder
tracked in issue `#9760`.
counters, and an `input_audio` sample test. The Video tab is functional: it reports
the FFmpeg/ffprobe runtime state, persists enable/model/frame/video/timeout limits,
filters the model picker to vision-capable models, and exposes video counters.
The former Vision Bridge card under AI settings is a compatibility link to the
new page; it no longer owns a second copy of the form. Media Providers also
@@ -267,6 +274,88 @@ Runtime settings are DB-backed and Zod-validated:
The shared cache remains controlled by `modalityBridgeCacheEnabled`,
`modalityBridgeCacheTtlMinutes`, and `modalityBridgeCacheMaxEntries`.
### Video Bridge (`videoBridge.ts`)
Intercepts top-level video parts in Chat Completions `messages` and Responses
API `input` before a target without known native video support is called.
Supported shapes are `input_video`, `video_url`, `video_source`, HTTPS URLs,
and `data:video/*;base64,...` data URIs. Plain filenames in text are not treated
as video.
The public `/v1` request path never imports or invokes a subprocess. Remote
videos are downloaded under a 50 MiB bound; inline base64 videos have a
conservative 36 MiB decoded per-video cap so the model/messages/framing envelope
can remain inside the public JSON request admission limit of 50 MiB. Inline
length and decoded-size estimates are checked before allocation. HTTPS is
required on the initial remote URL and every redirect, using the existing
public-only outbound guard with DNS pinning. The bytes then cross the exact internal
`POST /api/modality-bridge/video/extract` broker boundary. That route is both
`LOCAL_ONLY` and `SPAWN_CAPABLE`, accepts only a per-process authenticated,
trusted-loopback request, and never accepts a URL, filesystem path, executable,
or argument list. The API body-size pipeline and the handler's incremental body
reader independently enforce a 50 MiB broker input cap. Its bounded queue runs
one extraction at a time, allows four pending jobs, and caps pending input at
100 MiB.
Inside the broker, `ffprobe` reads a private local file; the fixed format
allowlist excludes playlist and manifest formats. For allowed MOV-family
containers, external MOV data references remain disabled by default, and the
fixed command does not opt in to them. Both `ffprobe` and `ffmpeg` use the
`file`-only protocol whitelist, one thread, fixed argument arrays, no shell,
and executables resolved from `PATH`. Attached-picture cover streams are not
playable candidates. All playable streams must satisfy the limits, and an
explicit default stream is preferred before the deterministic lowest-index
fallback. Videos are limited to 600 seconds, 8,192 pixels per dimension, and
33,554,432 source pixels. FFmpeg samples 116 midpoint JPEG frames, scales down
the long edge to at most 1,024 pixels without upscaling smaller inputs, and
never receives a URL.
Each frame is limited to 4 MiB, all raw frames together to 23 MiB, and the
serialized broker response to 32 MiB. A private temporary directory is removed
in `finally`. OmniRoute does not bundle FFmpeg and does not accept a custom
executable path.
Frames are captioned sequentially with the configured Video model. An empty
Video override inherits the Vision setting; if both are empty, the Vision
auto-router selects the effective vision-capable model. Successful captions
replace the original part with a stable `[Video description:` prefix that also
marks the text as an untrusted media-derived observation and tells downstream
models not to follow instructions found in the media. Frame-caption cache keys
include the JPEG bytes, prompt, timestamp, and effective model; only successful
captions are cached. Cache entries retain the actual successful producer model,
including a fallback model; the bridge reports `mixed` when different frames
were produced by different models. A cache hit reuses that producer identity
instead of relabeling it as the requested routing plan.
The guardrail extracts every supported video part but describes no more than
`modalityBridgeVideoMaxVideos`. For a target proven to have
`supportsVideo === false`, failed and over-limit videos become explicit safe
text markers so no raw video survives. When capability is unknown, those parts
remain untouched. Targets with `supportsVideo === true` bypass the bridge.
The client request abort signal propagates through download, broker queue,
subprocesses, and caption calls; aborts stop between videos and never fail open
to raw media.
Runtime settings are DB-backed and Zod-validated:
| Key | Default | Range / behavior |
| ------------------------------- | -------- | ------------------------------- |
| `modalityBridgeVideoEnabled` | `false` | Optional runtime, opt-in |
| `modalityBridgeVideoModel` | `""` | Inherit the Vision Bridge model |
| `modalityBridgeVideoFrameCount` | `8` | 116 |
| `modalityBridgeVideoMaxVideos` | `1` | 14 |
| `modalityBridgeVideoTimeout` | `120000` | 1000120000 ms |
Legacy persisted Video timeout values above 120 seconds are clamped to the
broker deadline; new settings writes above that limit are rejected.
`GET /api/modality-bridge/video/runtime` requires trusted stamped loopback
locality before authentication or runtime probing, then requires management
auth. It returns only `available`, sanitized FFmpeg/ffprobe versions, and a fixed
reason when the runtime is unavailable. The internal extraction endpoint is not
a public upload API: queue saturation returns `503` plus `Retry-After`, a caller
disconnect returns `499`, and the fixed broker deadline returns `504`. Converted responses add
`video->text;model=<visionModel>;parts=<videos>` to the central
`x-omniroute-modality-bridge` header without removing Vision or Audio segments.
### PII Masker (`piiMasker.ts`)
Runs on **both** stages.
@@ -391,6 +480,7 @@ interface GuardrailContext {
method?: string | null;
model?: string | null;
provider?: string | null;
signal?: AbortSignal;
sourceFormat?: string | null;
stream?: boolean;
targetFormat?: string | null;
@@ -400,6 +490,7 @@ interface GuardrailContext {
A guardrail signals "no change" by returning either `void`, `{}`, or
`{ block: false }`. Returning a `modifiedPayload`/`modifiedResponse` replaces
the value flowing through the chain for downstream guardrails.
`signal?: AbortSignal` carries the caller lifecycle into guardrails. A request abort is the deliberate fail-open exception: media bridges stop work and cleanup without restoring raw media to a target known not to support it.
## Registry (`registry.ts`)
@@ -490,6 +581,12 @@ Audio uses `modalityBridgeAudioEnabled`, `modalityBridgeAudioModel`,
`modalityBridgeCache*` settings. Audio has no legacy-key fallback because these
keys were introduced with the Modality Bridge schema.
Video uses `modalityBridgeVideoEnabled`, `modalityBridgeVideoModel`,
`modalityBridgeVideoFrameCount`, `modalityBridgeVideoMaxVideos`, and
`modalityBridgeVideoTimeout`, plus the shared `modalityBridgeCache*` settings.
It is disabled by default because FFmpeg/ffprobe are optional operational
dependencies and frame captioning adds latency and model cost.
## Custom Guardrails
```typescript

View File

@@ -43,6 +43,7 @@ spawn-capable prefixes and fails CI if any is not classified local-only.
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
| `/api/mcp/` | MCP server — spawns stdio bridges + SSE handlers | **Yes** (only one) |
| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | No — spawn-capable |
| `/api/modality-bridge/video/` | Strict trusted-loopback Video Bridge runtime probe and authenticated internal extraction broker — fixed FFmpeg/ffprobe invocations with bounded bytes/queue/output | No — spawn-capable |
| `/api/services/` | Embedded services (9router/CLIProxy) — `npm install` + spawn | No — spawn-capable |
| `/dashboard/providers/services/` | Reverse proxy to embedded-service UIs | No |
| `/api/copilot/` | Unauthenticated LLM driver — CLI-only by default | Operator opt-in: manage/admin |

View File

@@ -0,0 +1,61 @@
/**
* Pure helpers for polling the embedded or remote OmniRoute server without
* importing the Electron main process.
*/
const DEFAULT_TIMEOUT_MS = 180000;
const DEFAULT_REQUEST_TIMEOUT_MS = 2000;
const DEFAULT_POLL_INTERVAL_MS = 500;
function buildReadinessUrl(baseUrl) {
return `${baseUrl.replace(/\/+$/, "")}/api/health/ping`;
}
async function waitForServer(url, timeoutMs = DEFAULT_TIMEOUT_MS, options = {}) {
const {
fetchFn = globalThis.fetch,
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
nowFn = Date.now,
sleepFn = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
warnFn = console.warn,
} = options;
const startedAt = nowFn();
while (nowFn() - startedAt < timeoutMs) {
const remainingMs = timeoutMs - (nowFn() - startedAt);
const attemptTimeoutMs = Math.max(1, Math.min(requestTimeoutMs, remainingMs));
const controller = new AbortController();
let timeoutId;
try {
const response = await Promise.race([
fetchFn(url, { signal: controller.signal }),
new Promise((resolve) => {
timeoutId = setTimeout(() => {
controller.abort();
resolve(null);
}, attemptTimeoutMs);
}),
]);
if (response?.ok) return true;
} catch {
/* server not ready yet */
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId);
}
const pollRemainingMs = timeoutMs - (nowFn() - startedAt);
if (pollRemainingMs <= 0) break;
await sleepFn(Math.min(pollIntervalMs, pollRemainingMs));
}
warnFn("[Electron] Server readiness timeout — showing window anyway");
return false;
}
module.exports = {
buildReadinessUrl,
waitForServer,
};

View File

@@ -39,6 +39,7 @@ const { resolveServerEntry } = require("./lib/resolveServerEntry");
const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper");
const { resolveRemoteServerUrl, isValidHttpUrl } = require("./lib/resolveRemoteServerUrl");
const { writeRemoteServerUrl } = require("./lib/remoteServerPreferences");
const { buildReadinessUrl, waitForServer } = require("./lib/serverReadiness");
// ── Single Instance Lock ───────────────────────────────────
const gotTheLock = app.requestSingleInstanceLock();
@@ -86,6 +87,7 @@ let remoteServerUrl = resolveRemoteServerUrl({
});
const getServerUrl = () => remoteServerUrl || `http://localhost:${serverPort}`;
const getServerReadinessUrl = () => buildReadinessUrl(getServerUrl());
function resolveNodeExecutable(env = process.env) {
// #1081: Ensure Next.js standalone runs using Electron's Node runtime
@@ -112,7 +114,32 @@ function resolveNodeExecutable(env = process.env) {
return process.execPath;
}
function resolveServerNodePath(env = process.env) {
// Stage 7 (issue #10321): optional runtime packs are installed under
// `${DATA_DIR}/packs/<name>/node_modules` (see open-sse/utils/optionalPacks.ts —
// this is the plain-JS mirror; keep semantics identical). Prepending their
// node_modules to NODE_PATH lets the server's dynamic imports (playwright, the
// LLMLingua closure) resolve pack members while the default bundle stays slim.
function resolvePackNodePaths(dataDir) {
const packsRoot = path.join(dataDir, "packs");
let names;
try {
names = fs.readdirSync(packsRoot);
} catch {
return []; // No packs dir yet — nothing installed.
}
const dirs = [];
for (const name of names) {
const candidate = path.join(packsRoot, name, "node_modules");
try {
if (fs.statSync(candidate).isDirectory()) dirs.push(candidate);
} catch {
// Unreadable entry — treat as not installed.
}
}
return dirs;
}
function resolveServerNodePath(env = process.env, extraDirs = []) {
const seen = new Set();
const entries = [];
@@ -134,6 +161,12 @@ function resolveServerNodePath(env = process.env) {
addEntry(existing);
}
// Optional packs take precedence over bundle-resident copies so an installed
// pack can never be shadowed by a stale bundled duplicate.
for (const packDir of extraDirs) {
addEntry(packDir);
}
// Electron-builder installs native modules like better-sqlite3 under
// app.asar.unpacked, while the standalone bundle still carries helper deps
// such as bindings/file-uri-to-path inside resources/app/node_modules.
@@ -185,26 +218,6 @@ function sendToRenderer(channel, data) {
}
}
// ── Helper: Wait for server readiness (#1, #10) ────────────
// Default raised to 180s: the first launch after an upgrade can run long DB
// migrations, during which the server accepts the TCP connection but holds the
// HTTP response until handlers initialize. The previous 30s cap timed out and
// left the window stuck on a hanging connection (#2460).
async function waitForServer(url, timeoutMs = 180000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url);
if (res.ok || res.status < 500) return true;
} catch {
/* server not ready yet */
}
await new Promise((r) => setTimeout(r, 500));
}
console.warn("[Electron] Server readiness timeout — showing window anyway");
return false;
}
// ── Helper: Wait for server process exit with timeout (#2) ─
async function waitForServerExit(proc, timeoutMs = 5000) {
if (!proc) return;
@@ -533,7 +546,7 @@ async function changePort(newPort) {
// Start server on new port
startNextServer();
await waitForServer(getServerUrl());
await waitForServer(getServerReadinessUrl());
// Reload window and update tray
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -603,7 +616,7 @@ async function setRemoteServerUrl(nextUrl) {
startNextServer();
try {
await waitForServer(`${getServerUrl()}/api/monitoring/health`);
await waitForServer(getServerReadinessUrl());
} catch (err) {
console.warn("[Electron] Server did not become ready after remote-server change:", err.message);
}
@@ -770,7 +783,7 @@ function startNextServer() {
PORT: String(serverPort),
NODE_ENV: "production",
ELECTRON_RUN_AS_NODE: "1",
NODE_PATH: resolveServerNodePath(serverEnv),
NODE_PATH: resolveServerNodePath(serverEnv, resolvePackNodePaths(dataDir)),
NODE_OPTIONS: serverNodeOptions,
},
stdio: "pipe",
@@ -935,7 +948,7 @@ function setupIpcHandlers() {
stopNextServer();
await waitForServerExit(serverToStop);
startNextServer();
await waitForServer(getServerUrl());
await waitForServer(getServerReadinessUrl());
return { success: true };
});
@@ -1078,8 +1091,8 @@ app.whenReady().then(async () => {
startNextServer();
let serverReady = true;
if (!isDev) {
// Probe the auth-exempt health endpoint (not the root URL, which may redirect).
serverReady = await waitForServer(`${getServerUrl()}/api/monitoring/health`);
// Probe the lightweight auth-exempt endpoint instead of aggregating full monitoring state.
serverReady = await waitForServer(getServerReadinessUrl());
}
if (isHeadless) {
@@ -1095,7 +1108,7 @@ app.whenReady().then(async () => {
// If readiness timed out (e.g. very long first-launch migrations), don't leave the
// window stuck on a hanging connection — keep polling and reload once it responds (#2460).
if (!isDev && !serverReady && !isHeadless) {
void waitForServer(`${getServerUrl()}/api/monitoring/health`, 300000).then((ready) => {
void waitForServer(getServerReadinessUrl(), 300000).then((ready) => {
if (ready && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.loadURL(getServerUrl());
}

View File

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

View File

@@ -66,6 +66,7 @@
"lib/resolveNodeHelper.js",
"lib/resolveRemoteServerUrl.js",
"lib/remoteServerPreferences.js",
"lib/serverReadiness.js",
"assets/remoteServerPrompt.html",
"package.json",
"node_modules/**/*"
@@ -74,14 +75,6 @@
{
"from": "../.build/electron-standalone",
"to": "app",
"filter": [
"**/*",
"node_modules/**/*"
]
},
{
"from": "../.build/electron-standalone/node_modules",
"to": "app/node_modules",
"filter": [
"**/*"
]

View File

@@ -1,6 +1,26 @@
export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([
// Gemini 3.6 Flash tiers returned by the live model selector for both the IDE 2.1.1
// and CLI 1.1.x client identities. High is the current defaultAgentModelId.
// Gemini 3.7 Flash tiers listed by the current official Antigravity model catalog
// alongside the existing Gemini 3.6 tiers. Keep the upstream model ids unchanged so
// discovery and execution address the same models selected by the native client.
{
id: "gemini-3.7-flash-high",
name: "Gemini 3.7 Flash (High)",
contextLength: 1048576,
maxOutputTokens: 65536,
supportsReasoning: true,
supportsVision: true,
toolCalling: true,
},
{
id: "gemini-3.7-flash-medium",
name: "Gemini 3.7 Flash (Medium)",
contextLength: 1048576,
maxOutputTokens: 65536,
supportsReasoning: true,
supportsVision: true,
toolCalling: true,
},
// Gemini 3.6 Flash tiers retained alongside the newer Gemini 3.7 tiers.
{
id: "gemini-3.6-flash-high",
name: "Gemini 3.6 Flash (High)",
@@ -195,6 +215,32 @@ const UPSTREAM_PUBLIC_MODEL_IDS = new Set(
ANTIGRAVITY_PUBLIC_MODELS.map((model) => resolveAntigravityModelId(model.id))
);
// The authenticated Antigravity `:fetchAvailableModels` response is the source of truth for
// the models enabled for the current account and client version. Keep only known non-chat
// surfaces out of that live catalog; do not require every newly launched chat model to be
// added to this static fallback catalog first.
const ANTIGRAVITY_NON_CHAT_MODEL_IDS = new Set([
"gemini-3-pro-image-preview",
"gemini-3.1-flash-image",
"gemini-3.1-flash-tts-preview",
"gemini-2.5-flash-preview-tts",
"tab_flash_lite_preview",
"tab_jump_flash_lite_preview",
]);
const ANTIGRAVITY_RETIRED_MODEL_IDS = new Set([
"gemini-3-pro-preview",
"gemini-3.1-pro",
"gemini-3.5-flash-high",
"gemini-3.5-flash-medium",
"gemini-3.5-flash-preview",
"gemini-2.5-pro",
"gemini-2.5-computer-use-preview-10-2025",
]);
const ANTIGRAVITY_NON_CHAT_MODEL_PATTERN =
/(?:^|[-_])(image|imagen|audio|tts|embedding|embed|video|veo)(?:[-_]|$)/i;
export function resolveAntigravityModelId(modelId: string): string {
if (!modelId) return modelId;
return (ANTIGRAVITY_MODEL_ALIASES as AntigravityModelAliasMap)[modelId] || modelId;
@@ -234,3 +280,16 @@ export function isUserCallableAntigravityModelId(modelId: string): boolean {
const upstreamId = resolveAntigravityModelId(modelId);
return PUBLIC_MODEL_IDS.has(clientId) || UPSTREAM_PUBLIC_MODEL_IDS.has(upstreamId);
}
/**
* Return whether a model reported by Antigravity's authenticated live catalog is eligible for
* chat discovery. The upstream response already applies account/subscription gating and marks
* internal entries with `isInternal`; this predicate only excludes known non-chat surfaces.
*/
export function isDiscoverableAntigravityModelId(modelId: string): boolean {
const id = modelId.trim();
if (!id || ANTIGRAVITY_NON_CHAT_MODEL_IDS.has(id) || ANTIGRAVITY_RETIRED_MODEL_IDS.has(id)) {
return false;
}
return !ANTIGRAVITY_NON_CHAT_MODEL_PATTERN.test(id);
}

View File

@@ -77,6 +77,10 @@ export const COOLDOWN_MS = {
rateLimit: 2 * 60 * 1000,
serviceUnavailable: 2 * 1000,
authExpired: 2 * 60 * 1000,
// Google regional-availability refusal: nothing changes region-wise on the
// account, so re-probe only after a long window (or when the operator routes
// egress through a supported-region proxy).
geoBlocked: 24 * 60 * 60 * 1000,
};
/**

View File

@@ -1,12 +1,7 @@
import type { RegistryEntry, RegistryModel } from "./providers/shared.ts";
export type ProviderPluginCapability =
| "apikey"
| "custom-executor"
| "oauth"
| "passthrough-models"
| "responses"
| "sidecar-candidate";
"apikey" | "custom-executor" | "oauth" | "passthrough-models" | "responses" | "sidecar-candidate";
export interface ProviderPluginModel {
id: string;
@@ -16,6 +11,7 @@ export interface ProviderPluginModel {
toolCalling?: boolean;
supportsReasoning?: boolean;
supportsVision?: boolean;
supportsVideo?: boolean;
unsupportedParams?: readonly string[];
targetFormat?: string;
}
@@ -58,7 +54,7 @@ const SIDECAR_COMPATIBLE_EXECUTORS = new Set(["default"]);
function compactObject<T extends Record<string, unknown>>(value: T): Partial<T> {
return Object.fromEntries(
Object.entries(value).filter(([, entryValue]) => entryValue !== undefined),
Object.entries(value).filter(([, entryValue]) => entryValue !== undefined)
) as Partial<T>;
}
@@ -71,6 +67,7 @@ function mapModel(model: RegistryModel): ProviderPluginModel {
toolCalling: model.toolCalling,
supportsReasoning: model.supportsReasoning,
supportsVision: model.supportsVision,
supportsVideo: model.supportsVideo,
unsupportedParams: model.unsupportedParams,
targetFormat: model.targetFormat,
}) as ProviderPluginModel;
@@ -130,7 +127,7 @@ function capabilitiesFor(entry: RegistryEntry, eligible: boolean): ProviderPlugi
}
export function createProviderPluginManifestEntry(
entry: RegistryEntry,
entry: RegistryEntry
): ProviderPluginManifestEntry {
const sidecar = sidecarEligibility(entry);
@@ -163,7 +160,7 @@ export function createProviderPluginManifestEntry(
}
export function generateProviderPluginManifestFromRegistry(
registry: Record<string, RegistryEntry>,
registry: Record<string, RegistryEntry>
): ProviderPluginManifest {
return {
schemaVersion: 1,
@@ -191,7 +188,7 @@ export function createServiceBackendManifestEntry(
template: Pick<
ProviderPluginManifestEntry,
"format" | "executor" | "auth" | "endpoints" | "capabilities" | "passthroughModels" | "sidecar"
>,
>
): ProviderPluginManifestEntry {
return {
id: pluginId,
@@ -202,11 +199,10 @@ export function createServiceBackendManifestEntry(
export function getProviderPluginManifestEntryFromRegistry(
registry: Record<string, RegistryEntry>,
provider: string,
provider: string
): ProviderPluginManifestEntry | null {
const entry =
registry[provider] ||
Object.values(registry).find((candidate) => candidate.alias === provider);
registry[provider] || Object.values(registry).find((candidate) => candidate.alias === provider);
return entry ? createProviderPluginManifestEntry(entry) : null;
}

View File

@@ -145,8 +145,7 @@ import { vertex_partnerProvider } from "./registry/vertex/partner/index.ts";
import { vertexProvider } from "./registry/vertex/index.ts";
import { duckduckgo_webProvider } from "./registry/duckduckgo-web/index.ts";
import { felo_webProvider } from "./registry/felo-web/index.ts";
import { xaiProvider } from "./registry/xai/index.ts";
import { xai_oauthProvider } from "./registry/xai-oauth/index.ts";
import { xaiProvider, xai_oauthProvider } from "./registry/xai/index.ts";
import { morphProvider } from "./registry/morph/index.ts";
import { siliconflowProvider } from "./registry/siliconflow/index.ts";
import { gitlab_duoProvider } from "./registry/gitlab-duo/index.ts";
@@ -154,6 +153,7 @@ import { command_codeProvider } from "./registry/command-code/index.ts";
import { novitaProvider } from "./registry/novita/index.ts";
import { regoloProvider } from "./registry/regolo/index.ts";
import { devin_desktopProvider } from "./registry/devin-desktop/index.ts";
import { zcodeProvider } from "./registry/zcode/index.ts";
import { zed_hostedProvider } from "./registry/zed-hosted/index.ts";
import { nanogptProvider } from "./registry/nanogpt/index.ts";
import { scalewayProvider } from "./registry/scaleway/index.ts";
@@ -412,6 +412,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
novita: novitaProvider,
regolo: regoloProvider,
"devin-desktop": devin_desktopProvider,
zcode: zcodeProvider,
"zed-hosted": zed_hostedProvider,
nanogpt: nanogptProvider,
scaleway: scalewayProvider,

View File

@@ -1,25 +1,40 @@
import type { RegistryEntry } from "../../shared.ts";
import { getAnthropicCompatHeaders, type RegistryEntry } from "../../shared.ts";
export const deepseekProvider: RegistryEntry = {
id: "deepseek",
alias: "ds",
format: "openai",
format: "openai-responses",
executor: "default",
baseUrl: "https://api.deepseek.com/v1/chat/completions",
baseUrl: "https://api.deepseek.com/responses",
authType: "apikey",
authHeader: "bearer",
alternateFormats: [
{
format: "claude",
baseUrl: "https://api.deepseek.com/anthropic/v1/messages",
authHeader: "x-api-key",
headers: getAnthropicCompatHeaders(),
label: "Anthropic-compatible",
},
],
models: [
{
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
name: "DeepSeek V4 Pro (0813)",
contextLength: 1_000_000,
maxOutputTokens: 384_000,
supportsReasoning: true,
supportedThinkingEfforts: ["none", "high", "max"],
toolCalling: true,
},
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
name: "DeepSeek V4 Flash (0731)",
contextLength: 1_000_000,
maxOutputTokens: 384_000,
supportsReasoning: true,
supportedThinkingEfforts: ["none", "low", "high", "max"],
toolCalling: true,
},
],
};

View File

@@ -5,34 +5,39 @@ export const freeaiapikeyProvider: RegistryEntry = {
alias: "faik",
format: "openai",
executor: "default",
baseUrl: "https://freeaiapikey.com/v1/chat/completions",
modelsUrl: "https://freeaiapikey.com/v1/models",
// 2026-08-13: the apex host answers 410 `endpoint_moved` on every /v1 route and
// names its own replacement — "Please update your base_url to
// https://api.freeaiapikey.com/v1". The api. host serves /v1/models (200) and
// /v1/chat/completions (405 on GET, i.e. POST-only as expected).
baseUrl: "https://api.freeaiapikey.com/v1/chat/completions",
modelsUrl: "https://api.freeaiapikey.com/v1/models",
authType: "apikey",
authHeader: "bearer",
defaultContextLength: 128000,
// Catalog synced 2026-08-13 against GET https://api.freeaiapikey.com/v1/models (200).
// That response carries only id/object/created/owned_by — upstream publishes no
// context window — so models added from it declare no contextLength and inherit
// `defaultContextLength` above rather than an invented figure. The two pre-existing
// contextLength values are left exactly as they were: nothing in this sweep confirms
// or refutes them, and rewriting them would be the same guesswork in reverse.
models: [
{ id: "openai/gpt-5", name: "GPT-5 (via FreeAIAPIKey)", contextLength: 400000 },
{ id: "openai/gpt-4o", name: "GPT-4o (via FreeAIAPIKey)" },
{ id: "openai/gpt-5.2-codex", name: "GPT-5.2 Codex (via FreeAIAPIKey)" },
{ id: "openai/gpt-5.4", name: "GPT-5.4 (via FreeAIAPIKey)" },
{ id: "openai/gpt-5.5", name: "GPT-5.5 (via FreeAIAPIKey)" },
{ id: "openai/gpt-5.6-sol", name: "GPT-5.6 Sol (via FreeAIAPIKey)" },
{
id: "anthropic/claude-opus-4.6",
name: "Claude Opus 4.6 (via FreeAIAPIKey)",
contextLength: 1000000,
},
{ id: "anthropic/claude-opus-4.7", name: "Claude Opus 4.7 (via FreeAIAPIKey)" },
{ id: "anthropic/claude-opus-4.8", name: "Claude Opus 4.8 (via FreeAIAPIKey)" },
{ id: "anthropic/claude-opus-5", name: "Claude Opus 5 (via FreeAIAPIKey)" },
{
id: "anthropic/claude-sonnet-4.6",
name: "Claude Sonnet 4.6 (via FreeAIAPIKey)",
contextLength: 1000000,
},
{
id: "Alibaba/qwen3.5",
name: "Qwen 3.5 (via FreeAIAPIKey)",
contextLength: 128000,
},
{
id: "Alibaba/qwen3-vl:235b",
name: "Qwen 3 VL 235B (via FreeAIAPIKey)",
contextLength: 128000,
},
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5 (via FreeAIAPIKey)" },
],
};

View File

@@ -20,6 +20,15 @@ export const grok_cliProvider: RegistryEntry = {
authHeader: "bearer",
passthroughModels: true,
models: [
{
id: "grok-4.6",
name: "Grok 4.6",
contextLength: 500000,
supportsReasoning: true,
toolCalling: true,
targetFormat: "openai-responses",
unsupportedParams: ["presencePenalty", "frequencyPenalty", "logprobs", "topLogprobs"],
},
{
id: "grok-4.5",
name: "Grok 4.5",

View File

@@ -1,33 +0,0 @@
import type { RegistryEntry } from "../../shared.ts";
import { resolvePublicCred } from "../../shared.ts";
import { xaiProvider } from "../xai/index.ts";
export const xai_oauthProvider: RegistryEntry = {
id: "xai-oauth",
alias: "xao",
format: "openai",
executor: "xai-oauth",
baseUrl: xaiProvider.baseUrl,
responsesBaseUrl: xaiProvider.responsesBaseUrl,
authType: "oauth",
authHeader: "bearer",
passthroughModels: true,
oauth: {
clientIdEnv: "GROK_OAUTH_CLIENT_ID",
clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"),
tokenUrl: "https://auth.x.ai/oauth2/token",
},
models: [
// SuperGrok / xAI OAuth serves grok-4.5 on native /v1/responses. Tag so
// chatCore translates OpenAI Chat Completions → Responses (messages→input,
// max_tokens→max_output_tokens). Without the tag, some 3.8.50 paths hit
// /v1/responses with a chat-shaped body → 422 missing `input` (#10165).
{
id: "grok-4.5",
name: "Grok 4.5",
contextLength: 500000,
targetFormat: "openai-responses",
},
...(xaiProvider.models || []),
],
};

View File

@@ -1,4 +1,5 @@
import type { RegistryEntry } from "../../shared.ts";
import { resolvePublicCred } from "../../shared.ts";
export const xaiProvider: RegistryEntry = {
id: "xai",
@@ -14,6 +15,17 @@ export const xaiProvider: RegistryEntry = {
authType: "apikey",
authHeader: "bearer",
models: [
{
id: "grok-4.6",
name: "Grok 4.6",
contextLength: 500000,
supportsReasoning: true,
supportedThinkingEfforts: ["low", "medium", "high", "xhigh"],
supportsVision: true,
supportsXHighEffort: true,
toolCalling: true,
targetFormat: "openai-responses",
},
{ id: "grok-4.3", name: "Grok 4.3" },
{ id: "grok-build-0.1", name: "Grok Build 0.1", contextLength: 256000 },
// Responses-only per upstream 9router#2439: xAI serves this id exclusively
@@ -27,3 +39,40 @@ export const xaiProvider: RegistryEntry = {
{ id: "grok-4.20-0309-non-reasoning", name: "Grok 4.20" },
],
};
/**
* OAuth authentication variant for the unified xAI provider.
*
* Keep the backend ID distinct because refresh and quota handling key off
* `xai-oauth`, while co-locating both variants prevents their shared endpoint
* and model catalog from drifting apart.
*/
export const xai_oauthProvider: RegistryEntry = {
id: "xai-oauth",
alias: "xao",
format: xaiProvider.format,
executor: "xai-oauth",
baseUrl: xaiProvider.baseUrl,
responsesBaseUrl: xaiProvider.responsesBaseUrl,
authType: "oauth",
authHeader: xaiProvider.authHeader,
passthroughModels: true,
oauth: {
clientIdEnv: "GROK_OAUTH_CLIENT_ID",
clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"),
tokenUrl: "https://auth.x.ai/oauth2/token",
},
models: [
// SuperGrok / xAI OAuth serves grok-4.5 on native /v1/responses. Tag so
// chatCore translates OpenAI Chat Completions → Responses (messages→input,
// max_tokens→max_output_tokens). Without the tag, some 3.8.50 paths hit
// /v1/responses with a chat-shaped body → 422 missing `input` (#10165).
{
id: "grok-4.5",
name: "Grok 4.5",
contextLength: 500000,
targetFormat: "openai-responses",
},
...(xaiProvider.models || []),
],
};

View File

@@ -0,0 +1,18 @@
import type { RegistryEntry } from "../../shared.ts";
import { GLM_SHARED_MODELS } from "../../../glmProvider.ts";
/**
* Local ZCode app-server backend. Authentication remains in the user's local
* ZCode profile (`builtin:zai-coding-plan`); OmniRoute does not receive or
* persist the Z.ai credential.
*/
export const zcodeProvider: RegistryEntry = {
id: "zcode",
alias: "zc",
format: "openai",
executor: "zcode",
baseUrl: "zcode://app-server/stdio",
authType: "none",
authHeader: "none",
models: [...GLM_SHARED_MODELS],
};

View File

@@ -51,6 +51,7 @@ export interface RegistryModel {
supportedThinkingEfforts?: readonly string[];
supportsVision?: boolean;
supportsAudio?: boolean;
supportsVideo?: boolean;
supportsXHighEffort?: boolean;
maxOutputTokens?: number;
targetFormat?: string;

View File

@@ -0,0 +1,109 @@
/**
* Shared multi-account rotation mechanics for noauth executors that round-robin
* across several "accounts" (fingerprints), each with an optional dedicated
* proxy — currently `OpencodeExecutor` and `MimocodeExecutor`.
*
* Extracted after both executors independently implemented the same
* pickAccount/markCooldown/markSuccess skeleton with the same exponential
* backoff, and independently needed the same fix for the same latent bug (a
* network exception was treated as account-scoped rotation fodder even for
* accounts sharing the default egress — see `isNetworkErrorRotatable`).
*/
// Reuses the repo's established "transient, not clearly attributable" failure
// cooldown (already used by accountFallback.ts for network-error dedup, see
// its "one transient blip opens the whole-provider breaker" comment) instead
// of inventing a separate constant — same magnitude the codebase already
// applies whether the failure is a 429 or a network-level throw.
import { TRANSIENT_COOLDOWN_MS, COOLDOWN_MS } from "../config/errorConfig.ts";
/** Per-account proxy configuration, persisted by NoAuthAccountCard under
* `providerSpecificData.accountProxies` (keyed by the account id, which the UI
* stores in `providerSpecificData.fingerprints`). */
export interface AccountProxyConfig {
fingerprint: string;
proxy: {
type: string;
host: string;
port: number;
username?: string;
password?: string;
relayAuth?: string;
} | null;
}
/** The subset of per-account state the rotation mechanics need. Executors may
* carry additional fields (e.g. mimocode's `jwt`/`expiresAt`) — this is the
* minimum shape `pickAccount`/`markCooldown`/`markSuccess` operate on. */
export interface RotatableAccount {
fingerprint: string;
cooldownUntil: number;
consecutiveFails: number;
proxy: AccountProxyConfig["proxy"];
}
const COOLDOWN_BASE_MS = TRANSIENT_COOLDOWN_MS;
const COOLDOWN_MAX_MS = COOLDOWN_MS.transientMax;
export function isAccountReady(account: RotatableAccount): boolean {
return account.cooldownUntil <= Date.now();
}
/** Round-robin pick, skipping accounts not `isReady`; falls back to the next
* index (even if not ready) so a caller always gets an account rather than
* hanging when every account is unavailable. Mutates `state.nextAccountIdx`.
*
* `isReady` defaults to the plain cooldown check (`isAccountReady`); pass a
* custom predicate when readiness depends on more than cooldown (e.g.
* mimocode's JWT-freshness-aware variant). */
export function pickAccount<T extends RotatableAccount>(
accounts: T[],
state: { nextAccountIdx: number },
isReady: (account: T) => boolean = isAccountReady
): T {
for (let i = 0; i < accounts.length; i++) {
const idx = (state.nextAccountIdx + i) % accounts.length;
const acct = accounts[idx];
if (isReady(acct)) {
state.nextAccountIdx = (idx + 1) % accounts.length;
return acct;
}
}
const fallbackIdx = state.nextAccountIdx % accounts.length;
state.nextAccountIdx = (state.nextAccountIdx + 1) % accounts.length;
return accounts[fallbackIdx];
}
export function markCooldown(account: RotatableAccount): void {
account.consecutiveFails++;
const backoff = Math.min(
COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1),
COOLDOWN_MAX_MS
);
account.cooldownUntil = Date.now() + backoff + Math.random() * 1000;
}
export function markSuccess(account: RotatableAccount): void {
account.consecutiveFails = 0;
}
/** Mask an account id for logs (UI calls it a fingerprint). */
export function maskAccountId(fingerprint: string): string {
if (!fingerprint) return "direct";
return `${fingerprint.slice(0, 8)}`;
}
/**
* Whether a network exception (timeout, connection refused/reset) on this
* account should trigger rotation to the next account, vs propagating.
*
* Only true when the account has its own egress (a configured proxy) — that's
* the case a dead/unreachable proxy genuinely justifies rotating away from.
* Accounts sharing the default egress (no proxy) can all fail at once on a
* real network outage: rotating there would just retry the same failure
* against every account while poisoning each one's cooldown for a cause that
* isn't theirs.
*/
export function isNetworkErrorRotatable(account: RotatableAccount): boolean {
return account.proxy !== null;
}

View File

@@ -339,6 +339,45 @@ function asRecord(value: unknown): Record<string, unknown> | null {
: null;
}
/**
* Known competing-agent identity sentences that Antigravity's server-side
* filter flags, answering with a 429 RESOURCE_EXHAUSTED (port of
* decolua/9router b566b20, generalized). Only the identity sentence is
* removed — surrounding instruction text is untouched.
*/
const COMPETITIVE_AGENT_PROMPT_PATTERNS: RegExp[] = [
/\byou are a claude agent\b[^\n]*/i,
/\bbuilt on anthropic's claude agent sdk\b[^\n]*/i,
/\byou are claude code\b[^\n]*/i,
/\byou are an ai assistant created by anthropic\b[^\n]*/i,
];
/**
* Strip competing-agent identity sentences from systemInstruction.parts.
* Returns the original reference when nothing matched (no allocation).
*/
export function stripCompetitiveAgentPrompts(systemInstruction: unknown): unknown {
const record = asRecord(systemInstruction);
const parts = Array.isArray(record?.parts) ? (record.parts as Array<Record<string, unknown>>) : [];
if (parts.length === 0) return systemInstruction;
let changed = false;
const newParts = parts.map((part) => {
if (typeof part.text !== "string" || part.text.length === 0) return part;
let text = part.text;
for (const pattern of COMPETITIVE_AGENT_PROMPT_PATTERNS) {
const stripped = text.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimStart();
if (stripped !== text) {
changed = true;
text = stripped;
}
}
return text === part.text ? part : { ...part, text };
});
return changed ? { ...record, parts: newParts } : systemInstruction;
}
function getAntigravitySafetySettings(safetySettings: unknown): unknown[] | undefined {
if (!Array.isArray(safetySettings)) return undefined;
@@ -358,7 +397,10 @@ function sanitizeAntigravityGeminiRequest(
}
if (asRecord(request.systemInstruction)) {
clean.systemInstruction = request.systemInstruction;
// #10420: strip competing-agent identity sentences (e.g. "You are a
// Claude agent, built on Anthropic's Claude Agent SDK.") that Antigravity
// flags and answers with 429 RESOURCE_EXHAUSTED.
clean.systemInstruction = stripCompetitiveAgentPrompts(request.systemInstruction);
}
clean.generationConfig = asRecord(request.generationConfig)

View File

@@ -8,12 +8,20 @@
* `buildErrorBody` instead so the client sees a proper error (hard rule #12).
*/
import { buildErrorBody } from "../utils/error.ts";
import { isGeoBlockedError } from "../services/errorClassifier.ts";
export function buildAntigravityUpstreamError(
status: number,
statusText: string,
rawBody: string
) {
// The dashboard "Test Connection" for antigravity only probes the OAuth userinfo
// endpoint (https://www.googleapis.com/oauth2/v1/userinfo), which is NOT
// geo-restricted — so a green tick does not prove the model path works. Spell
// this out in the geo-block message so operators stop chasing accounts.
const GEO_BLOCKED_HINT =
"The Cloud Code API is not offered from this server's current egress location " +
'("User location is not supported for the API use."). This is not an account ' +
"problem: the connection test only validates the Google OAuth token and does not " +
"call the model API. Route antigravity/agy egress through a proxy in a " +
"supported region (e.g. US/EU) or use a different provider.";
export function buildAntigravityUpstreamError(status: number, statusText: string, rawBody: string) {
let upstreamDetails: unknown;
try {
upstreamDetails = JSON.parse(rawBody);
@@ -21,5 +29,12 @@ export function buildAntigravityUpstreamError(
// upstream body is not JSON (e.g. HTML error page) — omit structured details
}
const suffix = statusText ? `: ${statusText}` : "";
if (isGeoBlockedError(rawBody)) {
return buildErrorBody(
status,
`Antigravity upstream error (${status})${suffix}. ${GEO_BLOCKED_HINT}`,
upstreamDetails
);
}
return buildErrorBody(status, `Antigravity upstream error (${status})${suffix}`, upstreamDetails);
}

View File

@@ -605,24 +605,41 @@ export class DefaultExecutor extends BaseExecutor {
/**
* Downgrade `response_format: { type: "json_schema" }` to `json_object` for
* `openai-compatible-*` providers, injecting the JSON schema into the system
* prompt instead. DeepSeek / Ollama / local OpenAI-compatible models often
* lack native Structured Output and return empty or malformed content when a
* `json_schema` response_format is forwarded as-is. Gated on the
* `openai-compatible-` provider family so providers with native Structured
* Output support keep the native `json_schema` path.
* `openai-compatible-*` providers AND `kilocode`, injecting the JSON schema
* into the system prompt instead. DeepSeek / Ollama / local OpenAI-compatible
* models often lack native Structured Output and return empty or malformed
* content when a `json_schema` response_format is forwarded as-is (kilocode's
* DeepSeek V4 Flash rejects it with HTTP 400 `Invalid input: response_format`,
* verified live 2026-08-15 — same class as #9992's opencode fix). Gated so
* providers with native Structured Output support keep the native
* `json_schema` path.
*/
applyJsonSchemaFallback<T>(body: T): T {
if (!this.provider?.startsWith?.("openai-compatible-")) return body;
const provider = this.provider ?? "";
const isOpenAiCompatible = provider.startsWith("openai-compatible-");
const isKiloCode = provider === "kilocode";
if (!isOpenAiCompatible && !isKiloCode) return body;
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const record = body as Record<string, unknown>;
const rf = record.response_format as
{ type?: string; json_schema?: { schema?: unknown } } | undefined;
if (rf?.type !== "json_schema" || !rf.json_schema?.schema) return body;
| { type?: string; json_schema?: { schema?: unknown } }
| undefined;
if (!rf) return body;
const schemaJson = JSON.stringify(rf.json_schema.schema, null, 2);
const prompt = `You must respond with valid JSON that strictly follows this JSON schema:\n\`\`\`json\n${schemaJson}\n\`\`\`\nRespond ONLY with the JSON object, no other text.`;
// openai-compatible-* providers accept json_object natively — only the
// json_schema form needs downgrading there. kilocode rejects BOTH forms,
// so it enters the strip path below regardless.
if (isOpenAiCompatible && rf.type === "json_object") return body;
const schema = rf.type === "json_schema" ? rf.json_schema?.schema : undefined;
if (rf.type === "json_schema" && !schema) return body;
const schemaJson = schema ? JSON.stringify(schema, null, 2) : null;
const prompt =
schemaJson !== null
? `You must respond with valid JSON that strictly follows this JSON schema:\n\`\`\`json\n${schemaJson}\n\`\`\`\nRespond ONLY with the JSON object, no other text.`
: "You must respond with valid JSON only (a single JSON object), no other text.";
const messages: Array<Record<string, unknown>> = Array.isArray(record.messages)
? (record.messages as Array<Record<string, unknown>>).map((m) => ({ ...m }))
@@ -638,6 +655,14 @@ export class DefaultExecutor extends BaseExecutor {
messages.unshift({ role: "system", content: prompt });
}
// kilocode's DeepSeek rejects ANY response_format (verified live 2026-08-15:
// both json_schema AND json_object 400 with `param: response_format`) — strip
// it entirely and rely on the schema prompt. openai-compatible-* providers
// accept json_object, so keep the downgrade there.
if (isKiloCode) {
const { response_format: _dropped, ...rest } = record;
return { ...rest, messages } as T;
}
return { ...record, messages, response_format: { type: "json_object" } } as T;
}

View File

@@ -1,3 +1,4 @@
import { SEARCH_PROVIDERS } from "../config/searchRegistry.ts";
import { AntigravityExecutor } from "./antigravity.ts";
import { GithubExecutor } from "./github.ts";
import { GheCopilotExecutor } from "./ghe-copilot.ts";
@@ -33,6 +34,7 @@ import { NlpCloudExecutor } from "./nlpcloud.ts";
import { DevinDesktopExecutor } from "./devin-desktop.ts";
import { ZedHostedExecutor } from "./zed-hosted.ts";
import { DevinCliExecutor } from "./devin-cli.ts";
import { ZcodeExecutor } from "./zcode.ts";
import { DevinCliAgenticExecutor } from "./devin-cli-agentic.ts";
import { AuggieExecutor } from "./auggie.ts";
import { DeepSeekWebExecutor } from "./deepseek-web.ts";
@@ -134,6 +136,8 @@ const executors = {
"devin-desktop": new DevinDesktopExecutor(),
"zed-hosted": new ZedHostedExecutor(),
"devin-cli": new DevinCliExecutor(),
zcode: new ZcodeExecutor(),
zc: new ZcodeExecutor(), // Alias
"devin-cli-agentic": new DevinCliAgenticExecutor(),
devin: new DevinCliExecutor(), // Alias
"deepseek-web": new DeepSeekWebWithAutoRefreshExecutor(),
@@ -230,6 +234,17 @@ const defaultCache = new Map();
// follow-up once their own chat-routing behavior is confirmed.
const CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS = new Set(["jules"]);
// #10274 — providers that exist ONLY as /v1/search endpoint entries
// (SEARCH_PROVIDERS in open-sse/config/searchRegistry.ts) and have no chat-completions
// REGISTRY entry anywhere in open-sse/. Without this guard, getExecutor() silently falls
// through to DefaultExecutor's `PROVIDERS[provider] || PROVIDERS.openai` fallback, sending
// the user's real search API key (e.g. a Tavily `tvly-...` key) to OpenAI's endpoint and
// surfacing OpenAI's own "Incorrect API key provided" error for a provider the user believes
// is the search provider. The set is DERIVED from SEARCH_PROVIDERS so adding a new search
// provider without updating this guard fails the regression test automatically. Search
// providers must be executed through /v1/search, never the chat-completions path.
const CHAT_UNSUPPORTED_SEARCH_PROVIDERS = new Set(Object.keys(SEARCH_PROVIDERS));
export function getExecutor(provider) {
if (executors[provider]) return executors[provider];
if (CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS.has(provider)) {
@@ -239,6 +254,13 @@ export function getExecutor(provider) {
(err as Error & { status?: number }).status = 400;
throw err;
}
if (CHAT_UNSUPPORTED_SEARCH_PROVIDERS.has(provider)) {
const err = new Error(
`Provider "${provider}" is a search provider and does not support chat completions; use the /v1/search endpoint instead.`
);
(err as Error & { status?: number }).status = 400;
throw err;
}
if (!defaultCache.has(provider)) defaultCache.set(provider, new DefaultExecutor(provider));
return defaultCache.get(provider);
}

View File

@@ -27,13 +27,21 @@ import { createProxyDispatcher } from "../utils/proxyDispatcher.ts";
import { RATE_LIMIT_TEXT_PATTERNS } from "../services/accountFallback.ts";
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
import { fetch as undiciFetch, type Dispatcher } from "undici";
import {
type AccountProxyConfig as SharedAccountProxyConfig,
type RotatableAccount,
pickAccount as pickRotatableAccount,
markCooldown as markAccountCooldown,
markSuccess as markAccountSuccess,
maskAccountId,
isNetworkErrorRotatable,
} from "./accountRotation.ts";
import { isNetworkRotationSharedEgressGuardEnabled } from "@/shared/utils/featureFlags";
const BOOTSTRAP_PATH = "/api/free-ai/bootstrap";
const CHAT_PATH = "/api/free-ai/openai/chat";
const JWT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
const BOOTSTRAP_TIMEOUT_MS = 15_000;
const COOLDOWN_BASE_MS = 5_000;
const COOLDOWN_MAX_MS = 60_000;
const MIMO_SOURCE = "mimocode-cli-free";
@@ -82,24 +90,12 @@ const USER_AGENTS = [
// ── Account State ──────────────────────────────────────────────────────────
/** Per-account proxy configuration, passed through providerSpecificData.accountProxies. */
export interface AccountProxyConfig {
fingerprint: string;
proxy: {
type: string;
host: string;
port: number;
username?: string;
password?: string;
relayAuth?: string;
} | null;
}
export type AccountProxyConfig = SharedAccountProxyConfig;
interface AccountState {
interface AccountState extends RotatableAccount {
fingerprint: string;
jwt: string;
expiresAt: number;
cooldownUntil: number;
consecutiveFails: number;
/**
* #3837/#5521: the account's resolved proxy, or `null` when none is configured.
* Always present (never `undefined`) so callers can read `acct.proxy` directly —
@@ -223,7 +219,10 @@ function rewriteModelName(model: string): string {
export class MimocodeExecutor extends BaseExecutor {
private accounts: AccountState[] = [];
private nextAccountIdx = 0;
// Not `private`: passed as the mutable rotation cursor to the shared
// pickAccount() helper, which needs a plain `{ nextAccountIdx }` shape —
// TS's private-member nominal check rejects `this` there otherwise.
nextAccountIdx = 0;
private baseUrl: string;
private proxyUrlMap = new Map<string, string>();
private static encoder = new TextEncoder();
@@ -342,30 +341,15 @@ export class MimocodeExecutor extends BaseExecutor {
}
private pickAccount(): AccountState {
for (let i = 0; i < this.accounts.length; i++) {
const idx = (this.nextAccountIdx + i) % this.accounts.length;
const acct = this.accounts[idx];
if (isAccountReady(acct)) {
this.nextAccountIdx = (idx + 1) % this.accounts.length;
return acct;
}
}
const fallbackIdx = this.nextAccountIdx % this.accounts.length;
this.nextAccountIdx = (this.nextAccountIdx + 1) % this.accounts.length;
return this.accounts[fallbackIdx];
return pickRotatableAccount(this.accounts, this, isAccountReady);
}
private markCooldown(account: AccountState): void {
account.consecutiveFails++;
const backoff = Math.min(
COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1),
COOLDOWN_MAX_MS
);
account.cooldownUntil = Date.now() + backoff + Math.random() * 1000;
markAccountCooldown(account);
}
private markSuccess(account: AccountState): void {
account.consecutiveFails = 0;
markAccountSuccess(account);
}
/**
@@ -592,9 +576,25 @@ export class MimocodeExecutor extends BaseExecutor {
this.syncAccountsFromCredentials(input.credentials);
const sharedEgressGuardEnabled = isNetworkRotationSharedEgressGuardEnabled();
// Set once a proxy-less account's network throw reveals the shared egress
// is down — subsequent proxy-less accounts this request are skipped
// without a network call, but proxied accounts (independent egress) are
// still tried normally. See NETWORK_ROTATION_SHARED_EGRESS_GUARD.
let sharedEgressDown = false;
// Try each account, skip cooldown ones
for (let attempt = 0; attempt < this.accounts.length; attempt++) {
const account = this.pickAccount();
if (sharedEgressGuardEnabled && sharedEgressDown && !account.proxy) {
log?.warn?.(
"MIMOCODE",
`skipping account ${maskAccountId(account.fingerprint)} (no dedicated proxy, shared egress already down this request)`
);
continue;
}
try {
const headers = this.buildHeaders(input.credentials, stream);
const resp = await this.fetchWithAuthRetry(url, headers, reqBody, signal, account, log);
@@ -623,16 +623,60 @@ export class MimocodeExecutor extends BaseExecutor {
transformedBody: reqBody,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const masked = maskAccountId(account.fingerprint);
// Mirrors OpencodeExecutor's rotation guard: a network exception is only account-scoped
// when this account has its OWN egress (a configured proxy). Without
// one, accounts share the default egress — the failure isn't
// attributable to this account, and trying the next one would just
// retry the same outage while poisoning its cooldown for a cause
// that isn't theirs. Fail fast instead of exhausting every account.
if (!isNetworkErrorRotatable(account)) {
if (sharedEgressGuardEnabled) {
this.markCooldown(account);
sharedEgressDown = true;
log?.warn?.(
"MIMOCODE",
`network error on account ${masked} (no dedicated proxy, shared egress), cooldown applied — trying next available account… (${msg})`
);
continue;
}
log?.warn?.(
"MIMOCODE",
`network error on account ${masked} (no dedicated proxy, shared egress) — not rotating (${msg})`
);
return {
response: new Response(
encoder.encode(
JSON.stringify(
buildErrorBody(502, msg, undefined, {
type: "upstream_error",
code: "EXECUTOR_ERROR",
})
)
),
{ status: 502, headers: { "Content-Type": "application/json" } }
),
url,
headers: this.buildHeaders(input.credentials, stream),
transformedBody: body,
};
}
this.markCooldown(account);
log?.warn?.("MIMOCODE", `network error on account ${masked}, rotating to next… (${msg})`);
if (attempt === this.accounts.length - 1) {
const msg = err instanceof Error ? err.message : String(err);
log?.error?.("MIMOCODE", `Executor error: ${msg}`);
return {
response: new Response(
encoder.encode(
JSON.stringify({
error: { message: msg, type: "upstream_error", code: "EXECUTOR_ERROR" },
})
JSON.stringify(
buildErrorBody(502, msg, undefined, {
type: "upstream_error",
code: "EXECUTOR_ERROR",
})
)
),
{ status: 502, headers: { "Content-Type": "application/json" } }
),

View File

@@ -7,37 +7,30 @@ import {
} from "../utils/reasoningContentInjector.ts";
import { runWithProxyContext } from "../utils/proxyFetch.ts";
import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts";
import {
type AccountProxyConfig,
type RotatableAccount,
pickAccount as pickRotatableAccount,
markCooldown as markAccountCooldown,
markSuccess as markAccountSuccess,
maskAccountId,
isNetworkErrorRotatable,
} from "./accountRotation.ts";
import { isNetworkRotationSharedEgressGuardEnabled } from "@/shared/utils/featureFlags";
/**
* Per-account proxy configuration, persisted by NoAuthAccountCard under
* `providerSpecificData.accountProxies` (keyed by the account id, which the UI
* stores in `providerSpecificData.fingerprints`). Same shape mimocode uses.
*/
export interface OpencodeAccountProxyConfig {
fingerprint: string;
proxy: {
type: string;
host: string;
port: number;
username?: string;
password?: string;
relayAuth?: string;
} | null;
}
export type OpencodeAccountProxyConfig = AccountProxyConfig;
/** Runtime rotation/cooldown state for one "OpenCode Free" account. */
interface OpencodeAccountState {
interface OpencodeAccountState extends RotatableAccount {
/** Account id (UI: providerSpecificData.fingerprints[i]); "" for the default direct account. */
fingerprint: string;
cooldownUntil: number;
consecutiveFails: number;
/** Resolved proxy config for this account (null = direct egress). */
proxy: OpencodeAccountProxyConfig["proxy"];
}
const OPENCODE_COOLDOWN_BASE_MS = 5_000;
const OPENCODE_COOLDOWN_MAX_MS = 60_000;
const EFFORT_LEVELS = ["low", "medium", "high", "max"] as const;
/**
@@ -147,7 +140,10 @@ export class OpencodeExecutor extends BaseExecutor {
private accounts: OpencodeAccountState[] = [
{ fingerprint: "", cooldownUntil: 0, consecutiveFails: 0, proxy: null },
];
private nextAccountIdx = 0;
// Not `private`: passed as the mutable rotation cursor to the shared
// pickAccount() helper, which needs a plain `{ nextAccountIdx }` shape —
// TS's private-member nominal check rejects `this` there otherwise.
nextAccountIdx = 0;
constructor(provider: string) {
super(provider, PROVIDERS[provider] || PROVIDERS.openai);
@@ -190,42 +186,17 @@ export class OpencodeExecutor extends BaseExecutor {
if (this.nextAccountIdx >= this.accounts.length) this.nextAccountIdx = 0;
}
private isAccountReady(account: OpencodeAccountState): boolean {
return account.cooldownUntil <= Date.now();
}
/** Round-robin pick, skipping accounts in cooldown; falls back to the next index. */
private pickAccount(): OpencodeAccountState {
for (let i = 0; i < this.accounts.length; i++) {
const idx = (this.nextAccountIdx + i) % this.accounts.length;
const acct = this.accounts[idx];
if (this.isAccountReady(acct)) {
this.nextAccountIdx = (idx + 1) % this.accounts.length;
return acct;
}
}
const fallbackIdx = this.nextAccountIdx % this.accounts.length;
this.nextAccountIdx = (this.nextAccountIdx + 1) % this.accounts.length;
return this.accounts[fallbackIdx];
return pickRotatableAccount(this.accounts, this);
}
private markCooldown(account: OpencodeAccountState): void {
account.consecutiveFails++;
const backoff = Math.min(
OPENCODE_COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1),
OPENCODE_COOLDOWN_MAX_MS
);
account.cooldownUntil = Date.now() + backoff + Math.random() * 1000;
markAccountCooldown(account);
}
private markSuccess(account: OpencodeAccountState): void {
account.consecutiveFails = 0;
}
/** Mask an account id for logs (UI calls it a fingerprint). */
private static maskAccountId(fingerprint: string): string {
if (!fingerprint) return "direct";
return `${fingerprint.slice(0, 8)}`;
markAccountSuccess(account);
}
async execute(input: ExecuteInput) {
@@ -267,11 +238,35 @@ export class OpencodeExecutor extends BaseExecutor {
}
const { log } = input;
let lastResult: Awaited<ReturnType<BaseExecutor["execute"]>> | null = null;
// This loop only ever dispatches through super.execute() (the HTTP request
// path), which always resolves the object-shaped arm of ExecutorExecuteResult
// — the bare-Response arm belongs to web/scraping executors only (base.ts:290).
type HttpExecuteResult = Extract<
Awaited<ReturnType<BaseExecutor["execute"]>>,
{ response: Response }
>;
let lastResult: HttpExecuteResult | null = null;
let lastSharedEgressError: unknown = null;
const sharedEgressGuardEnabled = isNetworkRotationSharedEgressGuardEnabled();
// Set once a proxy-less account's network throw reveals the shared
// egress is down (see NETWORK_ROTATION_SHARED_EGRESS_GUARD below) —
// subsequent proxy-less accounts this request are skipped without a
// network call, but proxied accounts (independent egress) are still
// tried normally.
let sharedEgressDown = false;
for (let attempt = 0; attempt < this.accounts.length; attempt++) {
const account = this.pickAccount();
const masked = OpencodeExecutor.maskAccountId(account.fingerprint);
const masked = maskAccountId(account.fingerprint);
if (sharedEgressGuardEnabled && sharedEgressDown && !account.proxy) {
log?.warn?.(
"OPENCODE",
`skipping account ${masked} (no dedicated proxy, shared egress already down this request)`
);
continue;
}
// #5217 (Gap 2): promoted debug→info so the per-request account/proxy
// rotation selection is visible in the Console log view at the default
// APP_LOG_LEVEL=info (users could not see which account/proxy was used).
@@ -287,9 +282,46 @@ export class OpencodeExecutor extends BaseExecutor {
// Pin egress to this account's proxy for the whole BaseExecutor dispatch
// (incl. its intra-URL 429 retries). skipUpstreamRetry lets THIS loop own
// the cross-account 429 fallback instead of BaseExecutor's same-key retry.
const result = await runWithProxyContext(account.proxy, () =>
super.execute({ ...input, skipUpstreamRetry: true })
);
let result: HttpExecuteResult;
try {
// super.execute() here always dispatches the HTTP path (opencode is an
// OpenAI-compatible API, never the web/scraping bare-Response arm) —
// see base.ts:290-294.
result = (await runWithProxyContext(account.proxy, () =>
super.execute({ ...input, skipUpstreamRetry: true })
)) as HttpExecuteResult;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
// A network exception (timeout, connection refused/reset) is only
// account-scoped when this account has its OWN egress (a configured
// proxy) — that's the case a dead/unreachable proxy justifies rotating
// away from. Without a proxy, accounts share the same network egress:
// the failure isn't attributable to this account. Never swallowed
// silently either way: logged before rotating, skipping, or rethrowing.
if (!isNetworkErrorRotatable(account)) {
if (sharedEgressGuardEnabled) {
this.markCooldown(account);
sharedEgressDown = true;
lastSharedEgressError = err;
log?.warn?.(
"OPENCODE",
`network error on account ${masked} (no dedicated proxy, shared egress), cooldown applied — trying next available account… (${reason})`
);
continue;
}
log?.warn?.(
"OPENCODE",
`network error on account ${masked} (no dedicated proxy, shared egress) — not rotating (${reason})`
);
throw err;
}
this.markCooldown(account);
log?.warn?.(
"OPENCODE",
`network error on account ${masked}, rotating to next… (${reason})`
);
continue;
}
lastResult = result;
const status = result.response.status;
@@ -303,6 +335,16 @@ export class OpencodeExecutor extends BaseExecutor {
return result;
}
// The loop exhausted without a result. If it's because every remaining
// proxy-less account was skipped once the shared egress was known down
// (rather than actually tried), propagate that original throw — an
// extra direct call here would just be a second doomed attempt against
// the same dead path, which is exactly the latency this guard exists
// to avoid (see NETWORK_ROTATION_SHARED_EGRESS_GUARD).
if (sharedEgressDown && !lastResult && lastSharedEgressError !== null) {
throw lastSharedEgressError;
}
// All accounts returned 429 (or errored) — surface the last response.
return lastResult ?? (await super.execute(input));
} finally {

375
open-sse/executors/zcode.ts Normal file
View File

@@ -0,0 +1,375 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { GLM_SHARED_MODELS } from "../config/glmProvider.ts";
import { BaseExecutor, type ExecuteInput, type ExecutorExecuteResult, type ProviderCredentials } from "./base.ts";
import { ZcodeAppServerClient, type ZcodeClientLike } from "./zcodeProtocol.ts";
import { buildErrorBody, errorResponse, sanitizeErrorMessage } from "../utils/error.ts";
const ZCODE_URL = "zcode://app-server/stdio";
const DEFAULT_PROVIDER_ID = "builtin:zai-coding-plan";
const DEFAULT_TURN_TIMEOUT_MS = 120_000;
const DEFAULT_POLL_INTERVAL_MS = 250;
const TERMINAL_STATUSES = new Set(["completed", "idle", "paused", "error"]);
const ZCODE_MODEL_ALLOWLIST = new Set(GLM_SHARED_MODELS.map((model) => model.id));
const DEFAULT_ZCODE_MODEL = GLM_SHARED_MODELS[0]?.id || "glm-5.2";
type JsonRecord = Record<string, unknown>;
type OpenAIMsg = { role?: string; content?: unknown };
type ZcodeCommand = { command: string; args: string[] };
type ZcodeModelResolution = { ok: true; model: string } | { ok: false; error: string };
export interface ZcodeExecutorOptions {
command?: string;
args?: string[];
cwd?: string;
providerId?: string;
startupTimeoutMs?: number;
requestTimeoutMs?: number;
turnTimeoutMs?: number;
pollIntervalMs?: number;
clientFactory?: () => ZcodeClientLike;
}
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {};
}
function textFromContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((part) => {
if (typeof part === "string") return part;
const record = asRecord(part);
if (record.type === "text" || record.type === "input_text" || record.type === "output_text") {
return typeof record.text === "string" ? record.text : "";
}
return "";
})
.join("");
}
/** Convert an OpenAI conversation into one explicit ZCode coding turn. */
export function buildZcodePrompt(messages: OpenAIMsg[]): string {
const parts: string[] = [];
for (const message of messages) {
const text = textFromContent(message.content).trim();
if (!text) continue;
const role = String(message.role || "user");
const label = role === "system" ? "System" : role === "assistant" ? "Assistant" : "User";
parts.push(`[${label}]\n${text}`);
}
return parts.join("\n\n") || "(empty)";
}
export function resolveZcodeModel(model: unknown): ZcodeModelResolution {
const requested = typeof model === "string" ? model.trim() : "";
if (!requested) return { ok: true, model: DEFAULT_ZCODE_MODEL };
if (requested.startsWith("-")) {
return { ok: false, error: `Invalid ZCode model \"${requested}\": model must not start with \"-\".` };
}
const normalized = requested.startsWith("zcode/")
? requested.slice("zcode/".length)
: requested;
if (!ZCODE_MODEL_ALLOWLIST.has(normalized)) {
return {
ok: false,
error: `Unknown ZCode model \"${requested}\". Supported models: ${[...ZCODE_MODEL_ALLOWLIST].join(", ")}.`,
};
}
return { ok: true, model: normalized };
}
function parseArgs(raw: string | undefined): string[] {
if (!raw) return ["app-server"];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length > 16 || !parsed.every((arg) => typeof arg === "string" && arg.length <= 4096)) {
throw new Error("ZCODE_ARGS must be a JSON array of at most 16 strings");
}
return parsed as string[];
}
function defaultCommand(): ZcodeCommand {
const runtimeRoot = process.env.ZCODE_SERVER_RUNTIME_ROOT || join(homedir(), ".zcode", "server");
const serverNode = process.env.ZCODE_SERVER_NODE || join(runtimeRoot, "node");
const serverEntry = process.env.ZCODE_SERVER_ENTRY || join(runtimeRoot, "zcode-server.cjs");
if (existsSync(serverNode) && existsSync(serverEntry)) {
return { command: serverNode, args: [serverEntry] };
}
return { command: process.env.ZCODE_BIN || "zcode", args: parseArgs(process.env.ZCODE_ARGS) };
}
function extractSessionId(value: unknown): string | undefined {
const root = asRecord(value);
const nested = asRecord(root.session);
const sessionId = nested.sessionId ?? root.sessionId;
return typeof sessionId === "string" && sessionId.trim() ? sessionId : undefined;
}
function extractStatus(value: unknown): string | undefined {
const root = asRecord(value);
const nested = asRecord(root.session);
const status = nested.status ?? root.status;
return typeof status === "string" ? status : undefined;
}
function extractTextFromMessage(value: unknown): { role?: string; text: string } {
const message = asRecord(value);
const info = asRecord(message.info);
const role = typeof info.role === "string" ? info.role : typeof message.role === "string" ? message.role : undefined;
const parts = Array.isArray(message.parts) ? message.parts : [];
const text = parts
.map((part) => {
const record = asRecord(part);
if (record.type === "text" && typeof record.text === "string") return record.text;
return "";
})
.join("");
return { role, text };
}
function extractAssistantText(value: unknown): string {
const root = asRecord(value);
const messages = Array.isArray(root.messages) ? root.messages : [];
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = extractTextFromMessage(messages[i]);
if (message.text && (!message.role || message.role === "assistant")) return message.text;
}
const nestedMessage = extractTextFromMessage(root.message);
if (nestedMessage.text) return nestedMessage.text;
for (const candidate of [root.content, root.text, root.output_text]) {
if (typeof candidate === "string" && candidate.trim()) return candidate;
}
return "";
}
function extractErrorMessage(value: unknown): string {
const root = asRecord(value);
const nested = asRecord(root.error);
for (const candidate of [nested.message, root.message, root.reason]) {
if (typeof candidate === "string" && candidate.trim()) return candidate;
}
return "ZCode app-server returned an error";
}
function makeWorkspace(cwd: string): JsonRecord {
return { workspacePath: cwd, workspaceIdentity: cwd };
}
function abortError(): Error {
return new Error("ZCode request aborted");
}
async function raceAbort<T>(promise: Promise<T>, signal?: AbortSignal | null): Promise<T> {
if (!signal) return promise;
if (signal.aborted) {
promise.catch(() => undefined);
throw abortError();
}
let onAbort: (() => void) | undefined;
const aborted = new Promise<T>((_, reject) => {
onAbort = () => reject(abortError());
signal.addEventListener("abort", onAbort, { once: true });
});
promise.catch(() => undefined);
try {
return await Promise.race([promise, aborted]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
async function delay(ms: number, signal?: AbortSignal | null): Promise<void> {
if (ms <= 0) {
if (signal?.aborted) throw abortError();
return;
}
await raceAbort(new Promise<void>((resolveDelay) => {
const timer = setTimeout(resolveDelay, ms);
timer.unref?.();
}), signal);
}
function estimateTokens(text: string): number {
return Math.max(1, Math.ceil(text.length / 4));
}
function completionResponse(model: string, prompt: string, content: string): Response {
const promptTokens = estimateTokens(prompt);
const completionTokens = estimateTokens(content);
return new Response(JSON.stringify({
id: `chatcmpl-zcode-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
usage: {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens,
estimated: true,
},
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
function sseResponse(model: string, content: string): Response {
const id = `chatcmpl-zcode-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const chunks = [
{ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }] },
{ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content }, finish_reason: null }] },
{ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] },
];
const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`;
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
});
}
function sseErrorResponse(status: number, message: string): Response {
const body = `data: ${JSON.stringify(buildErrorBody(status, message))}\n\ndata: [DONE]\n\n`;
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
});
}
export class ZcodeExecutor extends BaseExecutor {
private readonly options: ZcodeExecutorOptions;
constructor(options: ZcodeExecutorOptions = {}) {
super("zcode", { id: "zcode", baseUrl: ZCODE_URL, format: "openai" });
this.options = options;
}
buildUrl(): string {
return ZCODE_URL;
}
transformRequest(): null {
return null;
}
async execute(input: ExecuteInput): Promise<ExecutorExecuteResult> {
const resolution = resolveZcodeModel(input.model);
if (!resolution.ok) {
const message = "error" in resolution ? resolution.error : "Invalid ZCode model";
return input.stream ? sseErrorResponse(400, message) : errorResponse(400, message);
}
const body = asRecord(input.body);
const messages = Array.isArray(body.messages) ? body.messages as OpenAIMsg[] : [];
const prompt = buildZcodePrompt(messages);
input.log?.info?.("ZCODE", `local app-server turn started model=${resolution.model}`);
try {
const content = await this.runTurn(resolution.model, prompt, input.signal, input.log);
const response = input.stream
? sseResponse(resolution.model, content)
: completionResponse(resolution.model, prompt, content);
return {
response,
url: ZCODE_URL,
headers: {},
transformedBody: { model: resolution.model, promptLength: prompt.length, buffered: true },
transport: "local-zcode-app-server",
};
} catch (error) {
const message = sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
input.log?.warn?.("ZCODE", message);
return input.stream ? sseErrorResponse(502, message) : errorResponse(502, message);
}
}
private createClient(): ZcodeClientLike {
if (this.options.clientFactory) return this.options.clientFactory();
const command = this.options.command || process.env.ZCODE_SERVER_NODE || defaultCommand().command;
const args = this.options.args || (process.env.ZCODE_SERVER_NODE
? [process.env.ZCODE_SERVER_ENTRY || join(process.env.ZCODE_SERVER_RUNTIME_ROOT || join(homedir(), ".zcode", "server"), "zcode-server.cjs")]
: defaultCommand().args);
return new ZcodeAppServerClient({
command,
args,
cwd: this.options.cwd || process.env.ZCODE_CWD || process.cwd(),
startupTimeoutMs: this.options.startupTimeoutMs ?? Number(process.env.ZCODE_STARTUP_TIMEOUT_MS || 10_000),
requestTimeoutMs: this.options.requestTimeoutMs ?? Number(process.env.ZCODE_RPC_TIMEOUT_MS || 30_000),
});
}
private async runTurn(
model: string,
prompt: string,
signal: AbortSignal | null | undefined,
log: ExecuteInput["log"]
): Promise<string> {
const client = this.createClient();
const cwd = resolve(this.options.cwd || process.env.ZCODE_CWD || process.cwd());
const workspace = makeWorkspace(cwd);
const providerId = this.options.providerId || process.env.ZCODE_PROVIDER_ID || DEFAULT_PROVIDER_ID;
const turnTimeoutMs = this.options.turnTimeoutMs ?? Number(process.env.ZCODE_TURN_TIMEOUT_MS || DEFAULT_TURN_TIMEOUT_MS);
const pollIntervalMs = this.options.pollIntervalMs ?? Number(process.env.ZCODE_POLL_INTERVAL_MS || DEFAULT_POLL_INTERVAL_MS);
let sessionId: string | undefined;
try {
await raceAbort(client.start(), signal);
const initialized = asRecord(await raceAbort(client.call("zcode-agent", "initialize", [workspace]), signal));
if (initialized.available !== true) {
throw new Error(extractErrorMessage(initialized));
}
const created = await raceAbort(client.call("zcode-agent", "createSession", [{
...workspace,
sessionTraceId: randomUUID(),
mode: "build",
persistence: "persistent",
}]), signal);
sessionId = extractSessionId(created);
if (!sessionId) throw new Error("ZCode createSession returned no sessionId");
await raceAbort(client.call("zcode-agent", "setModel", [{
...workspace,
sessionId,
model: { providerId, modelId: model },
}]), signal);
let state: unknown = await raceAbort(client.call("zcode-agent", "sendPrompt", [{
...workspace,
sessionId,
inputId: randomUUID(),
content: prompt,
}]), signal);
const deadline = Date.now() + Math.max(1, turnTimeoutMs);
while (Date.now() <= deadline) {
if (signal?.aborted) throw abortError();
const text = extractAssistantText(state);
const status = extractStatus(state);
if (text && (status === undefined || TERMINAL_STATUSES.has(status))) return text;
if (status === "error") throw new Error(extractErrorMessage(state));
await delay(Math.max(0, pollIntervalMs), signal);
state = await raceAbort(client.call("zcode-agent", "readSession", [{
...workspace,
sessionId,
messageLimit: 200,
}]), signal);
}
const finalText = extractAssistantText(state);
if (finalText) return finalText;
throw new Error("ZCode turn timed out before an assistant response was available");
} finally {
if (sessionId && !signal?.aborted) {
await client.call("zcode-agent", "closeSession", [{ ...workspace, sessionId }]).catch(() => undefined);
}
await client.close().catch((error) => log?.debug?.("ZCODE", `app-server close failed: ${sanitizeErrorMessage(error)}`));
}
}
// Credentials are intentionally ignored: the local ZCode profile owns auth.
override buildHeaders(_credentials: ProviderCredentials): Record<string, string> {
return {};
}
}

View File

@@ -0,0 +1,438 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
const HEADER_SIZE = 13;
const REGULAR_MESSAGE = 1;
const INITIALIZE_MESSAGE = 200;
const RESPONSE_MESSAGE = 201;
const ERROR_MESSAGE = 202;
const CANCELED_MESSAGE = 203;
const MAX_FRAME_BYTES = 32 * 1024 * 1024;
type JsonRecord = Record<string, unknown>;
export interface ZcodeAppServerClientOptions {
command: string;
args?: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
startupTimeoutMs?: number;
requestTimeoutMs?: number;
}
export interface ZcodeClientLike {
start(): Promise<void>;
call(channel: string, method: string, args: unknown[]): Promise<unknown>;
close(): Promise<void>;
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
interface DecodedValue {
value: unknown;
offset: number;
}
function encodeVql(value: number): Buffer {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`ZCode protocol requires a non-negative integer, got ${String(value)}`);
}
const bytes: number[] = [];
let remaining = value;
do {
let next = remaining % 128;
remaining = Math.floor(remaining / 128);
if (remaining > 0) next |= 0x80;
bytes.push(next);
} while (remaining > 0);
return Buffer.from(bytes);
}
function decodeVql(data: Uint8Array, offset: number): { value: number; offset: number } {
let value = 0;
let multiplier = 1;
let cursor = offset;
for (let i = 0; i < 8; i += 1) {
if (cursor >= data.byteLength) throw new Error("Truncated ZCode variable-length quantity");
const next = data[cursor++];
value += (next & 0x7f) * multiplier;
if ((next & 0x80) === 0) return { value, offset: cursor };
multiplier *= 128;
}
throw new Error("Invalid ZCode variable-length quantity");
}
/** Serialize one value using ZCode's SocketProtocol value encoding. */
export function encodeZcodeValue(value: unknown): Buffer {
if (value === undefined) return Buffer.from([0]);
if (typeof value === "string") {
const bytes = Buffer.from(value, "utf8");
return Buffer.concat([Buffer.from([1]), encodeVql(bytes.byteLength), bytes]);
}
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
const bytes = Buffer.from(value);
return Buffer.concat([Buffer.from([2]), encodeVql(bytes.byteLength), bytes]);
}
if (Array.isArray(value)) {
return Buffer.concat([
Buffer.from([4]),
encodeVql(value.length),
...value.map((item) => encodeZcodeValue(item)),
]);
}
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
return Buffer.concat([Buffer.from([6]), encodeVql(value)]);
}
if (typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") {
throw new Error(`Unsupported ZCode protocol value type: ${typeof value}`);
}
const bytes = Buffer.from(JSON.stringify(value), "utf8");
return Buffer.concat([Buffer.from([5]), encodeVql(bytes.byteLength), bytes]);
}
/** Decode one value from ZCode's SocketProtocol value encoding. */
export function decodeZcodeValue(data: Uint8Array, offset = 0): DecodedValue {
if (offset >= data.byteLength) throw new Error("Truncated ZCode serialized value");
const type = data[offset++];
if (type === 0) return { value: undefined, offset };
if (type === 1 || type === 2) {
const length = decodeVql(data, offset);
const end = length.offset + length.value;
if (end > data.byteLength) throw new Error("Truncated ZCode byte/string value");
const bytes = data.slice(length.offset, end);
return {
value: type === 1 ? Buffer.from(bytes).toString("utf8") : Buffer.from(bytes),
offset: end,
};
}
if (type === 4) {
const length = decodeVql(data, offset);
const values: unknown[] = [];
let cursor = length.offset;
for (let i = 0; i < length.value; i += 1) {
const decoded = decodeZcodeValue(data, cursor);
values.push(decoded.value);
cursor = decoded.offset;
}
return { value: values, offset: cursor };
}
if (type === 5) {
const length = decodeVql(data, offset);
const end = length.offset + length.value;
if (end > data.byteLength) throw new Error("Truncated ZCode JSON value");
return {
value: JSON.parse(Buffer.from(data.slice(length.offset, end)).toString("utf8")),
offset: end,
};
}
if (type === 6) {
const decoded = decodeVql(data, offset);
return { value: decoded.value, offset: decoded.offset };
}
throw new Error(`Unknown ZCode serialized value type ${type}`);
}
export function encodeZcodeRpcCall(
id: number,
channel: string,
method: string,
args: unknown[]
): Buffer {
const body = Buffer.concat([
encodeZcodeValue([100, id, channel, method]),
encodeZcodeValue(args),
]);
const frame = Buffer.alloc(HEADER_SIZE + body.byteLength);
frame.writeUInt8(REGULAR_MESSAGE, 0);
frame.writeUInt32BE(0, 1);
frame.writeUInt32BE(0, 5);
frame.writeUInt32BE(body.byteLength, 9);
body.copy(frame, HEADER_SIZE);
return frame;
}
function errorFromPayload(payload: unknown, fallback: string): Error {
if (payload && typeof payload === "object") {
const record = payload as JsonRecord;
const message = typeof record.message === "string" ? record.message : fallback;
const error = new Error(message);
if (typeof record.code === "string") Object.assign(error, { code: record.code });
if (record.data !== undefined) Object.assign(error, { data: record.data });
return error;
}
return new Error(fallback);
}
/**
* Local stdio client for the ZCode app-server. The protocol starts with a JSON
* hello line and then switches to 13-byte length-prefixed binary frames.
*/
export class ZcodeAppServerClient implements ZcodeClientLike {
private readonly command: string;
private readonly args: string[];
private readonly cwd?: string;
private readonly env?: NodeJS.ProcessEnv;
private readonly startupTimeoutMs: number;
private readonly requestTimeoutMs: number;
private child?: ChildProcessWithoutNullStreams;
private outputBuffer = Buffer.alloc(0);
private handshakeDone = false;
private ready = false;
private startPromise?: Promise<void>;
private serverReady?: () => void;
private serverReadyError?: (error: Error) => void;
private nextRequestId = 1;
private readonly pending = new Map<number, PendingRequest>();
constructor(options: ZcodeAppServerClientOptions) {
this.command = options.command;
this.args = options.args ?? [];
this.cwd = options.cwd;
this.env = options.env;
this.startupTimeoutMs = options.startupTimeoutMs ?? 10_000;
this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
}
async start(): Promise<void> {
if (this.ready) return;
if (this.startPromise) return this.startPromise;
this.startPromise = this.startInternal().finally(() => {
this.startPromise = undefined;
});
return this.startPromise;
}
private async startInternal(): Promise<void> {
let child: ChildProcessWithoutNullStreams;
try {
child = spawn(this.command, this.args, {
cwd: this.cwd,
env: this.env ? { ...process.env, ...this.env } : process.env,
stdio: ["pipe", "pipe", "pipe"],
shell: false,
windowsHide: true,
});
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
}
this.child = child;
this.outputBuffer = Buffer.alloc(0);
this.handshakeDone = false;
this.ready = false;
child.stdin.on("error", () => {
// EPIPE is expected when timeout/abort closes an already-exited runtime.
});
let settled = false;
const readyPromise = new Promise<void>((resolve, reject) => {
this.serverReady = () => {
if (settled) return;
settled = true;
resolve();
};
this.serverReadyError = (error) => {
if (settled) return;
settled = true;
reject(error);
};
});
child.stdout.on("data", (chunk: Buffer) => this.onStdout(chunk));
child.stderr.on("data", () => {
// ZCode stderr is intentionally not forwarded: it can contain provider
// diagnostics or credentials from the user's local runtime.
});
child.on("error", (error) => {
this.serverReadyError?.(error);
this.rejectPending(error);
});
child.on("exit", (code, signal) => {
const error = new Error(`ZCode app-server exited: ${code ?? signal ?? "unknown"}`);
this.ready = false;
this.handshakeDone = false;
this.serverReadyError?.(error);
this.rejectPending(error);
if (this.child === child) this.child = undefined;
});
try {
await this.withTimeout(readyPromise, this.startupTimeoutMs, "ZCode app-server handshake timed out");
this.ready = true;
} catch (error) {
await this.disposeChild(child);
throw error instanceof Error ? error : new Error(String(error));
} finally {
this.serverReady = undefined;
this.serverReadyError = undefined;
}
}
private onStdout(chunk: Buffer): void {
this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]);
if (!this.handshakeDone) {
const newline = this.outputBuffer.indexOf(0x0a);
if (newline < 0) {
if (this.outputBuffer.byteLength > 64 * 1024) {
this.serverReadyError?.(new Error("ZCode hello line is too large"));
}
return;
}
const line = this.outputBuffer.subarray(0, newline).toString("utf8").trim();
this.outputBuffer = this.outputBuffer.subarray(newline + 1);
let hello: unknown;
try {
hello = JSON.parse(line);
} catch {
this.serverReadyError?.(new Error("Invalid ZCode app-server hello"));
return;
}
if (!hello || typeof hello !== "object" || (hello as JsonRecord).type !== "zcode-hello") {
this.serverReadyError?.(new Error("Unexpected ZCode app-server hello"));
return;
}
const child = this.child;
if (!child) return;
child.stdin.write(`${JSON.stringify({
type: "zcode-hello-ack",
version: "omniroute",
clientId: `omniroute-${process.pid}`,
})}\n`);
this.handshakeDone = true;
}
this.consumeFrames();
}
private consumeFrames(): void {
while (this.outputBuffer.byteLength >= HEADER_SIZE) {
const type = this.outputBuffer.readUInt8(0);
const length = this.outputBuffer.readUInt32BE(9);
if (length > MAX_FRAME_BYTES) {
const error = new Error("ZCode frame exceeds the configured safety limit");
this.serverReadyError?.(error);
this.rejectPending(error);
return;
}
const frameLength = HEADER_SIZE + length;
if (this.outputBuffer.byteLength < frameLength) return;
const body = this.outputBuffer.subarray(HEADER_SIZE, frameLength);
this.outputBuffer = this.outputBuffer.subarray(frameLength);
if (type !== REGULAR_MESSAGE) continue;
try {
const header = decodeZcodeValue(body, 0);
const payload = decodeZcodeValue(body, header.offset);
this.handleMessage(header.value, payload.value);
} catch (error) {
const normalized = error instanceof Error ? error : new Error(String(error));
this.serverReadyError?.(normalized);
this.rejectPending(normalized);
}
}
}
private handleMessage(headerValue: unknown, payload: unknown): void {
if (!Array.isArray(headerValue)) return;
const type = headerValue[0];
if (type === INITIALIZE_MESSAGE) {
this.serverReady?.();
return;
}
if (type !== RESPONSE_MESSAGE && type !== ERROR_MESSAGE && type !== CANCELED_MESSAGE) return;
const requestId = headerValue[1];
if (typeof requestId !== "number") return;
const request = this.pending.get(requestId);
if (!request) return;
this.pending.delete(requestId);
clearTimeout(request.timer);
if (type === RESPONSE_MESSAGE) {
request.resolve(payload);
} else {
request.reject(errorFromPayload(
payload,
type === ERROR_MESSAGE ? "ZCode RPC request failed" : "ZCode RPC request canceled"
));
}
}
async call(channel: string, method: string, args: unknown[]): Promise<unknown> {
await this.start();
const child = this.child;
if (!child || !this.ready) throw new Error("ZCode app-server is not ready");
const requestId = this.nextRequestId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(requestId);
reject(new Error(`ZCode RPC request timed out: ${channel}.${method}`));
}, this.requestTimeoutMs);
timer.unref?.();
this.pending.set(requestId, { resolve, reject, timer });
try {
child.stdin.write(encodeZcodeRpcCall(requestId, channel, method, args));
} catch (error) {
clearTimeout(timer);
this.pending.delete(requestId);
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
async close(): Promise<void> {
const child = this.child;
this.ready = false;
this.handshakeDone = false;
this.child = undefined;
this.serverReadyError?.(new Error("ZCode app-server closed"));
this.rejectPending(new Error("ZCode app-server closed"));
if (child) await this.disposeChild(child);
}
private rejectPending(error: Error): void {
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer);
pending.reject(error);
this.pending.delete(id);
}
}
private async disposeChild(child: ChildProcessWithoutNullStreams): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = new Promise<void>((resolve) => child.once("close", () => resolve()));
try {
child.stdin.end();
} catch {
// The process may already have closed stdin.
}
if (!child.killed) child.kill("SIGTERM");
let timer: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
exited,
new Promise<void>((resolve) => {
timer = setTimeout(resolve, 1500);
timer.unref?.();
}),
]);
if (timer) clearTimeout(timer);
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
await exited;
}
}
private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
timer.unref?.();
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
}

View File

@@ -7,8 +7,8 @@ import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts";
import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts";
import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts";
import { estimateFinalInputTokens } from "./chatCore/contextEstimation.ts";
import { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts";
export { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts";
import { extractSystemRoleMessages, relocateDirectiveOnlyMessages } from "./chatCore/claudeSystemRole.ts";
export { extractSystemRoleMessages, relocateDirectiveOnlyMessages } from "./chatCore/claudeSystemRole.ts";
import { checkIdempotencyCache } from "./chatCore/idempotency.ts";
import { checkSemanticCache } from "./chatCore/semanticCache.ts";
import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts";
@@ -159,7 +159,13 @@ import {
buildCapabilityMismatchMessage,
} from "@/shared/constants/capabilities/capabilityFilter.ts";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts";
import { toPositiveInteger } from "../services/reasoningTokenBuffer.ts";
import {
REASONING_BUFFER_MIN_TRIGGER,
buildReasoningProbeTruncatedResponse,
isEmptyContentUpstreamFailure,
isTinyBudgetReasoningProbe,
toPositiveInteger,
} from "../services/reasoningTokenBuffer.ts";
import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts";
import {
buildErrorBody,
@@ -248,7 +254,10 @@ import {
normalizeOpenAIToolFinishReasons,
restoreNonStreamingToolNames,
} from "./chatCore/passthroughToolNames.ts";
import { createDisabledCompressionConfig, resolveCompressionSettings } from "./chatCore/compressionSettings.ts";
import {
createDisabledCompressionConfig,
resolveCompressionSettings,
} from "./chatCore/compressionSettings.ts";
import type { EnforceDecision } from "@/lib/quota/types";
import { isCompressionExcluded } from "../services/compression/exclusions.ts";
import {
@@ -1823,7 +1832,11 @@ export async function handleChatCore({
// engines (Caveman/RTK). Codex Desktop / Responses clients need this path even
// when those engines are off, otherwise multi-turn image sessions hard-reject
// at the budget check below (#8560).
if (reactiveContextCompactionEnabled && !nativeCodexPassthrough && estimatedTokens > threshold) {
if (
reactiveContextCompactionEnabled &&
!nativeCodexPassthrough &&
estimatedTokens > threshold
) {
log?.info?.(
"CONTEXT",
`Proactive compression triggered: ${estimatedTokens} tokens > ${threshold} threshold (${contextLimit} limit)`
@@ -1893,7 +1906,12 @@ export async function handleChatCore({
// Last-resort compaction against the concrete input budget (not the 70% threshold).
// Covers cases where the proactive pass was skipped or still left the request oversized (#8560).
if (reactiveContextCompactionEnabled && !nativeCodexPassthrough && finalEstimatedInputTokens >= finalContextLimit && body) {
if (
reactiveContextCompactionEnabled &&
!nativeCodexPassthrough &&
finalEstimatedInputTokens >= finalContextLimit &&
body
) {
const lastResortTarget = Math.max(1, finalContextLimit - toolsReserve - 1);
const lastResortAdapter = adaptBodyForCompression(body as Record<string, unknown>);
const lastResortResult = compressContext(lastResortAdapter.body, {
@@ -2132,6 +2150,12 @@ export async function handleChatCore({
!shouldUseMidConversationSystem(translatedBody, effectiveModel)
) {
extractSystemRoleMessages(translatedBody);
} else {
// The mid-conversation-system path keeps system-role messages inside
// messages[], but a directive-only message (content: [] +
// output_config) at messages[0] is rejected by Anthropic. Move it past
// the first real turn; Anthropic accepts the form at any other position.
relocateDirectiveOnlyMessages(translatedBody);
}
if (Array.isArray(translatedBody.messages)) {
translatedBody.messages = splitMisplacedToolResults(
@@ -3728,6 +3752,33 @@ export async function handleChatCore({
if (signatureRecovery.succeeded) break providerFailure;
// #10281 — tiny-budget reasoning probes (e.g. Claude Code's `/model` check
// sends `max_tokens: 1`): the model burns the whole budget 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.
// Answer such probes with a valid truncated response rather than relaying the
// upstream failure — which would also mark the connection unavailable and
// poison fallback/cooldown bookkeeping for a request that is only a probe.
if (
!stream &&
isTinyBudgetReasoningProbe({ model: currentModel, body: finalBody || translatedBody }) &&
isEmptyContentUpstreamFailure(statusCode, message)
) {
providerResponse = buildReasoningProbeTruncatedResponse({
model: currentModel,
maxTokens: toPositiveInteger(
(finalBody || translatedBody)?.max_tokens ??
(finalBody || translatedBody)?.max_completion_tokens
),
requestId: skillRequestId,
});
log?.warn?.(
"PROBE",
`Reasoning probe (max_tokens < ${REASONING_BUFFER_MIN_TRIGGER}) answered with truncated 200 — upstream reported "${message}"`
);
break providerFailure;
}
// T06/T10/T36: classify provider errors and persist terminal account states.
let errorType = classifyProviderError(statusCode, message, provider);
if (statusCode === 429 && isModelScope()) {
@@ -3881,6 +3932,28 @@ export async function handleChatCore({
console.warn(
`[provider] Node ${errorConnectionId} project routing error (${statusCode}) — not banning`
);
} else if (errorType === PROVIDER_ERROR_TYPES.GEO_BLOCKED) {
// Google regional-availability refusal (e.g. "User location is not
// supported for the API use."). Account-independent and non-terminal:
// exclude the connection for the cooldown window so routing moves to
// other accounts instead of re-selecting this one on every request,
// and never mark it banned/expired. It becomes usable again once
// egress is routed through a supported-region proxy.
const geoCooldownMs = COOLDOWN_MS.geoBlocked ?? 24 * 60 * 60 * 1000;
await updateProviderConnection(errorConnectionId, {
lastErrorType: errorType,
lastError: message,
errorCode: statusCode,
});
try {
const { setConnectionRateLimitUntil } = await import("@/lib/db/providers");
setConnectionRateLimitUntil(errorConnectionId, Date.now() + geoCooldownMs);
} catch {
// DB write failure must never break the fallback loop
}
console.warn(
`[provider] Node ${errorConnectionId} geo-blocked (${statusCode}) — excluded for ${Math.ceil(geoCooldownMs / 1000)}s, trying other accounts`
);
} else if (errorType === PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND) {
// 404 — model/endpoint does not exist upstream. Lock the model so the
// retry/backoff loop stops hammering the dead endpoint (which would
@@ -4349,7 +4422,11 @@ export async function handleChatCore({
}
: responseBody
);
sanitizeUsagePayloadForRequest(responseBody, finalBody || translatedBody || body, responsePayloadFormat);
sanitizeUsagePayloadForRequest(
responseBody,
finalBody || translatedBody || body,
responsePayloadFormat
);
effectiveServiceTier = resolveReportedServiceTier(responseBody) ?? effectiveServiceTier;
// Notify success - caller can clear error status if needed
if (onRequestSuccess) {
@@ -4494,9 +4571,14 @@ export async function handleChatCore({
// #8331: keep the client-visible metering fields real everywhere except Claude-Code-compatible
// providers, where Claude Code's own context accounting relies on the buffered number — see
// clientUsageBuffer.ts module docstring.
applyClientUsageBuffer(translatedResponse, finalBody || translatedBody || body, clientResponseFormat, {
preserveContextBudgetInVisibleUsage: isClaudeCodeCompatible,
});
applyClientUsageBuffer(
translatedResponse,
finalBody || translatedBody || body,
clientResponseFormat,
{
preserveContextBudgetInVisibleUsage: isClaudeCodeCompatible,
}
);
if (memoryOwnerId && memorySettings?.enabled && memorySettings.maxTokens > 0) {
const requestMemoryText = extractMemoryTextFromRequestBody(body as Record<string, unknown>);

View File

@@ -135,6 +135,21 @@ export function extractSystemRoleMessages(payload: Record<string, unknown>): voi
}
}
}
// Directive payload (message-level output_config, as emitted by Claude
// Code clients): the message itself is lifted away, so fold its output
// configuration into the top-level parameter instead of silently dropping
// it — whatever shape the content had. An explicit top-level output_config
// wins, and among several directive messages the first one wins.
if (payload.output_config == null) {
const directive = sm as Record<string, unknown>;
if (
directive.output_config != null &&
typeof directive.output_config === "object" &&
!Array.isArray(directive.output_config)
) {
payload.output_config = directive.output_config;
}
}
}
if (extraBlocks.length > 0) {
const existingSystem = payload.system;
@@ -148,3 +163,85 @@ export function extractSystemRoleMessages(payload: Record<string, unknown>): voi
}
payload.messages = messages.filter((m) => !isSystemRole(m.role));
}
/**
* Moves a directive-only system message (empty content array + message-level
* `output_config`, the shape Claude Code clients emit) off `messages[0]`.
*
* Anthropic treats `messages[0]` as the initial system prompt position and
* rejects the directive-only form there ("use the top-level 'system' parameter
* for the initial system prompt"), while accepting it at any other position.
* The mid-conversation-system passthrough (provider `claude` + 1M-context beta
* models) deliberately keeps system-role messages inside `messages[]`, so a
* directive that arrived first would go upstream unchanged and 400. Relocate it
* past the first real turn instead; when the conversation has no real turn at
* all, fold the `output_config` into the top-level parameter (which wins when
* already present) and drop the now-empty message.
*/
export function relocateDirectiveOnlyMessages(payload: Record<string, unknown>): void {
if (!Array.isArray(payload.messages) || payload.messages.length === 0) return;
const messages = payload.messages as Array<Record<string, unknown>>;
const isSystemRole = (role: unknown): boolean =>
typeof role === "string" &&
(role.toLowerCase() === "system" || role.toLowerCase() === "developer");
const isEmptySystem = (m: Record<string, unknown>): boolean =>
m != null &&
typeof m === "object" &&
isSystemRole(m.role) &&
Array.isArray(m.content) &&
m.content.length === 0;
const isDirectiveOnly = (m: Record<string, unknown>): boolean =>
isEmptySystem(m) &&
m.output_config != null &&
typeof m.output_config === "object" &&
!Array.isArray(m.output_config);
if (!isEmptySystem(messages[0])) {
return;
}
// Collect the whole leading run of empty system messages so consecutive
// directives are all relocated in one pass (handling only messages[0] would
// leave the second directive at the rejected position).
let runEnd = 0;
while (runEnd < messages.length && isEmptySystem(messages[runEnd])) {
runEnd++;
}
const lead = messages.slice(0, runEnd);
const directives = lead.filter(isDirectiveOnly);
// First real (user/assistant) turn after the run. System messages with text
// content are not safe insertion anchors — keep walking past them, and past
// any non-object entries a malformed body may carry.
let insertAfter = -1;
for (let i = runEnd; i < messages.length; i++) {
const candidate = messages[i];
if (
candidate != null &&
typeof candidate === "object" &&
!isSystemRole(candidate.role)
) {
insertAfter = i;
break;
}
}
if (insertAfter === -1) {
// No real turn to relocate after: fold the first directive's
// output_config into the top-level parameter (an explicit top-level value
// wins) and drop the whole run.
if (payload.output_config == null && directives.length > 0) {
payload.output_config = directives[0].output_config;
}
payload.messages = messages.slice(runEnd);
return;
}
// Move the directives (in order) past the first real turn; plain empty
// system messages carry nothing and are dropped.
payload.messages = [
...messages.slice(runEnd, insertAfter + 1),
...directives,
...messages.slice(insertAfter + 1),
];
}

View File

@@ -40,7 +40,10 @@ const DEFAULT_FORWARDED_HEADER_BUDGET_BYTES = 768;
* module-cache manipulation.
*/
export function resolveForwardedHeaderBudget(env?: string): number {
const parsed = Number.parseInt(String(env ?? process.env.OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES), 10);
const parsed = Number.parseInt(
String(env ?? process.env.OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES),
10
);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_FORWARDED_HEADER_BUDGET_BYTES;
}
@@ -56,8 +59,31 @@ const responseHeaderEncoder = new TextEncoder();
type ResponseHeaderLogger = {
warn?: (tag: string, message: string, data?: Record<string, unknown>) => void;
debug?: (tag: string, message: string, data?: Record<string, unknown>) => void;
} | null;
/**
* #10315: the dropped-header set is usually identical across responses from the
* same upstream, so warn once per unique drop fingerprint per process, then log
* at debug level — a per-SSE-response warn storm buries real errors and adds
* event-loop serialization work. Fingerprints are dropped-header-name sets, so
* the set stays bounded by the distinct upstream header shapes in practice.
*/
const DROPPED_HEADER_WARN_FINGERPRINT_LIMIT = 1000;
const droppedHeaderWarnFingerprints = new Set<string>();
export function fingerprintDroppedHeaders(dropped: Array<{ name: string; bytes: number }>): string {
return dropped
.map((header) => header.name.toLowerCase())
.sort()
.join(",");
}
/** Test hook: forget already-warned drop fingerprints. */
export function resetDroppedHeaderWarnFingerprints(): void {
droppedHeaderWarnFingerprints.clear();
}
function responseHeaderWireBytes(name: string, value: string): number {
return responseHeaderEncoder.encode(`${name}: ${value}\r\n`).byteLength;
}
@@ -182,12 +208,30 @@ export function buildStreamingResponseHeaders(
}
if (droppedHeaders.length > 0) {
log?.warn?.("HTTP", "Dropped upstream response headers that exceeded forwarding budget", {
const dropPayload = {
budgetBytes: MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES,
forwardedBytes,
droppedCount: droppedHeaders.length,
droppedHeaders: droppedHeaders.slice(0, MAX_LOGGED_DROPPED_RESPONSE_HEADERS),
});
};
const fingerprint = fingerprintDroppedHeaders(droppedHeaders);
if (droppedHeaderWarnFingerprints.has(fingerprint)) {
log?.debug?.(
"HTTP",
"Dropped upstream response headers that exceeded forwarding budget (already warned once for this drop set)",
dropPayload
);
} else {
if (droppedHeaderWarnFingerprints.size >= DROPPED_HEADER_WARN_FINGERPRINT_LIMIT) {
droppedHeaderWarnFingerprints.clear();
}
droppedHeaderWarnFingerprints.add(fingerprint);
log?.warn?.(
"HTTP",
"Dropped upstream response headers that exceeded forwarding budget",
dropPayload
);
}
}
const responseHeaders: Record<string, string> = {

View File

@@ -3,14 +3,17 @@
* decomposition, #3501).
*
* Pure resolution of the provider alias + the upstream target format used to translate the request.
* Model/custom overrides win first. A Responses-shaped inbound request normally keeps the Responses
* wire format, except for custom OpenAI-compatible connections explicitly configured for Chat.
* Model/custom overrides win first. A declared connection-level alternate protocol wins next. A
* Responses-shaped inbound request otherwise keeps the Responses wire format, except for custom
* OpenAI-compatible connections explicitly configured for Chat.
* AgentRouter may inherit the inbound protocol when no explicit connection override exists.
* Returns both `alias` (reused by the handler when stripping the `alias/` prefix off the upstream
* model id) and `targetFormat`.
*/
import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../config/providerModels.ts";
import { getRegistryEntry } from "../../config/providerRegistry.ts";
import { resolveAlternateFormat } from "../../config/providers/alternateFormats.ts";
import { getTargetFormat } from "../../services/provider.ts";
import { FORMATS } from "../../translator/formats.ts";
@@ -46,15 +49,22 @@ export function resolveChatCoreTargetFormat(opts: {
? sourceFormat
: undefined;
const providerTargetFormat = getTargetFormat(provider, providerSpecificData);
const declaredConnectionAlternate = resolveAlternateFormat(
getRegistryEntry(provider),
providerSpecificData
);
const customOpenAICompatible = provider.startsWith("openai-compatible-");
// #8994: model-level targetFormat overrides (from registry or custom-model DB override)
// take precedence over apiFormat="responses" — otherwise Vertex Claude models with
// targetFormat="claude" get wrongly routed to OpenAI Responses format.
// #9161: a custom OpenAI-compatible Chat connection must likewise keep its configured
// outbound protocol when a Responses-shaped client (for example Codex) calls /responses.
// Registry-declared connection alternates are equally explicit: a DeepSeek connection set to
// Anthropic must stay on /anthropic/v1/messages even when the caller speaks Responses.
let targetFormat =
modelTargetFormat ||
customModelTargetFormat ||
declaredConnectionAlternate?.format ||
(apiFormat === "responses" && !customOpenAICompatible
? FORMATS.OPENAI_RESPONSES
: inferredAgentRouterTargetFormat || providerTargetFormat);

View File

@@ -93,14 +93,17 @@ export function extractUsageFromResponse(responseBody, provider) {
};
}
// Gemini format
if (responseBody.usageMetadata && typeof responseBody.usageMetadata === "object") {
// Gemini format. Antigravity / gemini-cli wrap the payload in
// { response: { ... } } — read the envelope so non-streaming requests do
// not silently log zero usage (port of decolua/9router#59d858b).
const usageMetadata = responseBody.usageMetadata || responseBody.response?.usageMetadata;
if (usageMetadata && typeof usageMetadata === "object") {
// Gemini reports thoughts outside candidates. Fold them into completion so
// every provider keeps reasoning as a subset of completion tokens.
const thoughts = responseBody.usageMetadata.thoughtsTokenCount || 0;
const thoughts = usageMetadata.thoughtsTokenCount || 0;
return {
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
completion_tokens: (responseBody.usageMetadata.candidatesTokenCount || 0) + thoughts,
prompt_tokens: usageMetadata.promptTokenCount || 0,
completion_tokens: (usageMetadata.candidatesTokenCount || 0) + thoughts,
reasoning_tokens: thoughts,
};
}

View File

@@ -211,6 +211,14 @@ export const CREDITS_EXHAUSTED_SIGNALS = [
"insufficient balance",
"insufficient_balance",
"insufficient account balance",
"insufficient credit balance",
// Command Code returns 400 "You have insufficient credits to make this
// request. Please purchase more credits to continue using the service."
// when the account's billing credits run out. Without this signal the
// error stays unclassified (errorType=null), so the connection is never
// marked credits_exhausted and keeps being re-selected on every request.
"insufficient credits",
"insufficient credit",
];
// T11: Signals that indicate OAuth token is invalid/expired (not permanent deactivation)

View File

@@ -23,6 +23,7 @@ import {
import type { AutoVariant } from "./autoPrefix";
import { buildFamilyCandidateFilter, type ModelFamily } from "./modelFamily";
import { getHiddenModelsByProvider } from "@/models";
import { getSyncedAvailableModelsByConnection, getCustomModels } from "@/lib/db/models";
import { filterPaidOnlyCandidates } from "./paidModelFilter";
import { isModelExcludedByConnection } from "@/domain/connectionModelRules";
import { filterExcludedCandidates } from "./candidateOverrides";
@@ -481,15 +482,41 @@ export async function prepareVirtualAutoComboInputs(
const defaultModelIds = providerConnections
.map((conn) => (typeof conn.defaultModel === "string" ? conn.defaultModel.trim() : ""))
.filter(Boolean);
const modelIds = Array.from(new Set([...registryModelIds, ...defaultModelIds]));
const hiddenModels = hiddenModelsMap.get(providerId);
// #auto-pool-visible-only: build the credentialed pool from the models the user
// actually has available (synced + custom non-hidden) when any exist, falling
// back to the static catalog only when the user has none. This keeps catalog-only
// models (e.g. openrouter/auto) out of every auto/* pool when the operator only
// synced a subset (e.g. OpenRouter with importFreeModelsOnly).
const [syncedByConnection, customModels] = await Promise.all([
getSyncedAvailableModelsByConnection(providerId),
getCustomModels(providerId),
]);
const userVisibleIds = new Set<string>();
for (const models of Object.values(syncedByConnection)) {
for (const m of models) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);
}
for (const m of customModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);
const hasUserModels = userVisibleIds.size > 0;
const modelIds = hasUserModels
? Array.from(userVisibleIds)
: Array.from(new Set([...registryModelIds, ...defaultModelIds]));
for (const modelId of modelIds) {
if (hiddenModels?.has(modelId)) continue;
const allowedConnectionIds = providerConnections
.filter((conn) => {
if (isModelExcludedByConnection(modelId, conn.providerSpecificData)) return false;
if (hasUserModels) {
// User-synced models are scoped to the connections that carry them;
// custom models are provider-wide like registry models.
const connSynced = syncedByConnection[conn.id] ?? [];
const isSyncedForConn = connSynced.some((m) => m.id === modelId);
const isCustomForProvider = customModels.some((m) => m.id === modelId);
return isSyncedForConn || isCustomForProvider || conn.defaultModel?.trim() === modelId;
}
// Registry models are provider-wide. A non-registry default (for a custom
// or passthrough model) is scoped only to connections that selected it.
return registryModelIdSet.has(modelId) || conn.defaultModel?.trim() === modelId;

View File

@@ -39,6 +39,11 @@ import {
} from "../autoCombo/scoring.ts";
import type { RoutingHint } from "../manifestAdapter";
import { getCachedProviderConnections } from "../../../src/lib/db/readCache";
import {
getSyncedAvailableModels,
getCustomModels,
getHiddenModelsByProvider,
} from "../../../src/lib/db/models";
import { getProviderModels } from "../../config/providerModels.ts";
import {
getConnectionRoutingTags,
@@ -458,10 +463,27 @@ export async function expandAutoComboCandidatePool(
// expansion doesn't turn into O(n^2) per provider. See #OOM incident
// (zero-config auto combo expanding to 1000s of provider/model targets).
const seenModelStrs = new Set(eligibleTargets.map((t) => t.modelStr));
const hiddenModelsMap = getHiddenModelsByProvider();
for (const providerId of providerIds) {
const providerModels = getProviderModels(providerId);
for (const model of providerModels) {
const modelStr = `${providerId}/${model.id}`;
// #auto-pool-visible-only: when the operator has synced/custom models for
// this provider, expand ONLY those (minus hidden); fall back to the static
// catalog only when the user has none. This keeps catalog-only models
// (e.g. openrouter/auto) out of pure-auto pools when the operator only
// synced a subset (e.g. OpenRouter with importFreeModelsOnly).
const [syncedModels, customModels] = await Promise.all([
getSyncedAvailableModels(providerId),
getCustomModels(providerId),
]);
const hiddenModels = hiddenModelsMap.get(providerId);
const userVisibleIds = new Set<string>();
for (const m of syncedModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);
for (const m of customModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);
const hasUserModels = userVisibleIds.size > 0;
const expandIds = hasUserModels
? Array.from(userVisibleIds)
: getProviderModels(providerId).map((m) => m.id);
for (const modelId of expandIds) {
const modelStr = `${providerId}/${modelId}`;
if (!seenModelStrs.has(modelStr)) {
seenModelStrs.add(modelStr);
eligibleTargets.push({

View File

@@ -0,0 +1,37 @@
/**
* Shared abort reasons for combo target dispatch.
*
* `buildTargetTimeoutRunner` aborts a stalled target with `new Error(...)` as the
* abort reason, and hedged targets are cancelled with a different one. Consumers
* downstream (session-affinity eviction in src/sse/handlers/chat.ts) must be able
* to tell those two apart from an ordinary client disconnect: only the per-model
* TIMEOUT means "this account stalled", while a hedge cancellation means "a
* sibling target won" and says nothing about the account's health.
*
* Kept as a dependency-free leaf so src/** can import it without pulling in the
* combo dispatcher.
*/
/** Abort reason used when a combo target exceeds `comboTargetTimeoutMs`. */
export const COMBO_PER_MODEL_TIMEOUT_REASON = "combo-per-model-timeout";
/** Abort reason used when a hedged sibling target won the race. */
export const COMBO_HEDGE_CANCELLED_REASON = "hedge-cancelled";
function abortReasonMessage(signal: AbortSignal): string {
const reason: unknown = signal.reason;
if (typeof reason === "string") return reason;
if (reason && typeof reason === "object" && typeof (reason as Error).message === "string") {
return (reason as Error).message;
}
return "";
}
/**
* True only when `signal` was aborted by the combo per-model timeout. A client
* disconnect, a hedge cancellation, or a non-aborted signal all return false.
*/
export function isComboPerModelTimeoutAbort(signal: AbortSignal | null | undefined): boolean {
if (!signal?.aborted) return false;
return abortReasonMessage(signal) === COMBO_PER_MODEL_TIMEOUT_REASON;
}

View File

@@ -10,6 +10,10 @@
* See _tasks/superpowers/plans/2026-07-03-blocoJ-combo-hotpath-decomposition.md (Task 1).
*/
import { buildErrorBody, errorResponse, sanitizeErrorMessage } from "../../utils/error.ts";
import {
COMBO_HEDGE_CANCELLED_REASON,
COMBO_PER_MODEL_TIMEOUT_REASON,
} from "./comboAbortReasons.ts";
import type { HandleSingleModel, SingleModelTarget, ComboLogger } from "./types.ts";
/** Stable internal classification for OmniRoute's own combo per-target timer. */
@@ -46,7 +50,7 @@ export function buildTargetTimeoutRunner(deps: {
"COMBO",
`Model ${modelStr} exceeded ${comboTargetTimeoutMs}ms timeout — falling back`
);
timeoutController.abort(new Error("combo-per-model-timeout"));
timeoutController.abort(new Error(COMBO_PER_MODEL_TIMEOUT_REASON));
// HTTP 504 (not proprietary 524): this is OmniRoute's own per-target timer.
// Typed as combo_target_timeout so request-scoped classification can keep the
// connection eligible for fallback instead of treating it like Cloudflare 524
@@ -75,10 +79,10 @@ export function buildTargetTimeoutRunner(deps: {
let onParentHedgeAbort: (() => void) | null = null;
if (parentHedgeSignal) {
if (parentHedgeSignal.aborted) {
timeoutController.abort(new Error("hedge-cancelled"));
timeoutController.abort(new Error(COMBO_HEDGE_CANCELLED_REASON));
} else {
onParentHedgeAbort = () => {
timeoutController.abort(new Error("hedge-cancelled"));
timeoutController.abort(new Error(COMBO_HEDGE_CANCELLED_REASON));
};
parentHedgeSignal.addEventListener("abort", onParentHedgeAbort, { once: true });
}

View File

@@ -37,6 +37,7 @@ import { pathToFileURL } from "node:url";
import { LLMLINGUA_WORKER_TIMEOUT_MS, LLMLINGUA_WORKER_IDLE_MS } from "./constants.ts";
import { resolveLlmlinguaModel } from "./modelStore.ts";
import { packMemberInstalled } from "../../../../utils/optionalPacks.ts";
import type { LlmlinguaBackend } from "./index.ts";
/** One-time model-load budget on the first call for a given model (tinybert ~2s, bert-base ~27s). */
@@ -121,7 +122,12 @@ let _depsAvailable: boolean | null = null;
*/
export function depsAvailable(): boolean {
if (_depsAvailable !== null) return _depsAvailable;
_depsAvailable = firstAncestorWith(runtimeAnchors(), GATE_DEP_REL) !== null;
// Stage 7 (issue #10321): the desktop bundle ships the LLMLingua closure as an
// optional pack installed under `${DATA_DIR}/packs/ml-runtime/node_modules`
// (prepended to NODE_PATH by electron/main.js), so also probe the pack dirs —
// the ancestor walk only covers bundle-resident installs (npm/Docker).
_depsAvailable =
firstAncestorWith(runtimeAnchors(), GATE_DEP_REL) !== null || packMemberInstalled(GATE_DEP_REL);
return _depsAvailable;
}

View File

@@ -79,6 +79,7 @@ export const PROVIDER_ERROR_TYPES = {
EMPTY_CONTENT: "empty_content",
MODEL_NOT_FOUND: "model_not_found",
FINGERPRINT_REJECTION: "fingerprint_rejection",
GEO_BLOCKED: "geo_blocked",
};
export const CONTEXT_OVERFLOW_SIGNALS = [
@@ -114,6 +115,61 @@ export function containsModelUnavailableMessage(errorMessage: string): boolean {
return MODEL_NAMED_UNSUPPORTED_REGEX.test(String(errorMessage || "").toLowerCase());
}
// Google regional-availability rejection: the Cloud Code / Gemini Code Assist
// API is not offered from every country, and the upstream answers with a 400
// FAILED_PRECONDITION like "User location is not supported for the API use."
// This is an ACCOUNT-INDEPENDENT, location-scoped refusal: every account on
// this server egresses from the same region, so retrying another credential
// cannot help — but routing egress through a proxy in a supported region can.
// Detected here so routing treats it as a non-terminal, cached-per-connection
// exclusion instead of a generic 400 (which would keep re-selecting the same
// account and surface a cryptic "upstream error (400)").
const GEO_BLOCK_SIGNALS = [
"user location is not supported",
"location is not supported",
"not supported for the api use",
"region is not supported",
"unsupported location",
"not available in your location",
"not available in your region",
];
export function isGeoBlockedError(errorMessage: string): boolean {
const lower = String(errorMessage || "").toLowerCase();
return GEO_BLOCK_SIGNALS.some((signal) => lower.includes(signal));
}
// Providers whose upstream surface emits Google's regional-availability
// refusal (GEO_BLOCK_SIGNALS above): Cloud Code / Gemini Code Assist — the
// antigravity executor (antigravity, agy) — and the Gemini Developer API
// (generativelanguage.googleapis.com; gemini, vertex). The gate matters
// because classifyProviderError is shared across every provider: an unrelated
// upstream returning a lookalike "not available in your region" must NOT be
// classified as an egress-fixable geo block, or it would get the non-terminal
// 24h exclusion treatment instead of that provider's own (possibly terminal)
// path.
function isGeoBlockEligibleProvider(provider?: string | null): boolean {
const p = (provider || "").toLowerCase();
if (
p === "antigravity" ||
p === "agy" ||
p === "gemini" ||
p === "gemini-cli" ||
p === "vertex"
) {
return true;
}
if (p.includes("cloudcode") || p.includes("cloud-code")) return true;
// Registry-driven fallback: any provider whose upstream surface is the Cloud
// Code API (executor/format "antigravity") or the Gemini API (format
// "gemini") stays eligible even when a new provider id is added later.
if (!provider) return false;
const entry = getRegistryEntry(provider);
if (!entry) return false;
const surface = `${entry.executor || ""} ${entry.format || ""}`.toLowerCase();
return surface.includes("antigravity") || surface.includes("gemini");
}
// Cloudflare 1010 "Access denied ... blocked based on your browser's signature" —
// a fingerprint/browser-like rejection issued by the CDN in front of an upstream
// (e.g. opencode.ai/zen/v1), carrying error_code 1010 or error_name
@@ -242,6 +298,24 @@ export function classifyProviderError(
}
if (statusCode === 402) return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED;
// Google regional-availability refusal (400 FAILED_PRECONDITION "... location
// is not supported ..."), scoped to the Google AI surfaces that emit it
// (Cloud Code / Gemini Code Assist + Gemini Developer API — see
// isGeoBlockEligibleProvider). Account-independent: every credential egresses
// from the same server region, so fallback to another account cannot succeed
// — but the connection must be cached as excluded so routing does not
// re-select it on every request and surface a cryptic generic 400.
// Non-terminal, like PROJECT_ROUTE_ERROR: the account becomes usable again
// once egress is routed through a supported-region proxy.
if (
(statusCode === 400 || statusCode === 403) &&
isGeoBlockEligibleProvider(provider) &&
isGeoBlockedError(bodyStr)
) {
return PROVIDER_ERROR_TYPES.GEO_BLOCKED;
}
if (statusCode === 403 && isCloudflareFingerprintRejection(bodyStr)) {
// Cloudflare 1010 / error_name "browser_signature_banned": the CDN in front of the
// upstream (e.g. opencode.ai/zen/v1) rejected the CLIENT's TLS/UA signature, not the

View File

@@ -54,3 +54,74 @@ export function resolveReasoningBufferedMaxTokens(
// silent cost increase the client did not authorize.
return current;
}
/**
* A tiny-budget reasoning probe is a request with an explicit `max_tokens`
* below REASONING_BUFFER_MIN_TRIGGER targeting a reasoning-capable model — e.g.
* Claude Code's `/model` capability check sends `max_tokens: 1`. Reasoning
* models burn the whole probe on thinking, so the upstream produces no visible
* content; some upstreams (e.g. api.cline.bot for deepseek-v4-flash) answer the
* non-streaming probe with an HTTP 5xx (`"empty response content"`) instead of
* a truncated 200. See #10281.
*/
export function isTinyBudgetReasoningProbe(opts: { model: string; body: unknown }): boolean {
const body = (opts.body ?? {}) as Record<string, unknown>;
const maxTokens = toPositiveInteger(body.max_tokens ?? body.max_completion_tokens);
if (maxTokens === null || maxTokens >= REASONING_BUFFER_MIN_TRIGGER) return false;
const capabilities = getResolvedModelCapabilities(opts.model);
return capabilities.supportsThinking === true;
}
/**
* Upstream failure markers that describe the "model reasoned but produced no
* visible content" outcome (e.g. `{"error":{"message":"empty response content"}}`).
*/
const EMPTY_CONTENT_FAILURE_RE =
/empty(\s+response)?\s+content|no\s+(usable\s+)?content|reasoning\s+consumed/i;
/**
* True when the upstream failure is a 5xx describing the empty-content outcome
* of a reasoning probe rather than a genuine provider outage. Combined with
* `isTinyBudgetReasoningProbe`, false positives are not practical (a real 5xx
* carrying these markers on a tiny-budget reasoning request is this exact case).
*/
export function isEmptyContentUpstreamFailure(statusCode: number, message: string): boolean {
if (!Number.isFinite(statusCode) || statusCode < 500 || statusCode >= 600) return false;
return EMPTY_CONTENT_FAILURE_RE.test(String(message || ""));
}
/**
* Build a valid truncated OpenAI chat.completion response (200, empty content,
* `finish_reason: "length"`) used to answer a tiny-budget reasoning probe whose
* upstream answered the empty outcome with a 5xx. Mirrors the semantics OmniRoute
* already grants to `finish_reason: "length"` empty 200s (errorClassifier.ts).
*/
export function buildReasoningProbeTruncatedResponse(opts: {
model: string;
maxTokens: number | null;
requestId: string;
}): Response {
const maxTokens = opts.maxTokens ?? 1;
const body = {
id: `chatcmpl-${opts.requestId}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: opts.model,
choices: [
{
index: 0,
message: { role: "assistant", content: "" },
finish_reason: "length",
},
],
usage: {
prompt_tokens: 0,
completion_tokens: maxTokens,
total_tokens: maxTokens,
},
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}

View File

@@ -17,7 +17,7 @@ import {
getAntigravityFetchAvailableModelsUrls,
} from "../../config/antigravityUpstream.ts";
import {
isUserCallableAntigravityModelId,
isDiscoverableAntigravityModelId,
toClientAntigravityQuotaModelId,
} from "../../config/antigravityModelAliases.ts";
import { isUserCallableAgyModelId } from "../../config/agyModels.ts";
@@ -273,15 +273,12 @@ async function fetchAntigravityUserQuotaCached(
const promise = (async () => {
try {
for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) {
const response = await fetch(
`${baseUrl}/v1internal:retrieveUserQuota`,
{
method: "POST",
headers: getAntigravityContentHeaders(clientProfile, accessToken),
body: JSON.stringify({ project: projectId }),
signal: AbortSignal.timeout(10000),
}
);
const response = await fetch(`${baseUrl}/v1internal:retrieveUserQuota`, {
method: "POST",
headers: getAntigravityContentHeaders(clientProfile, accessToken),
body: JSON.stringify({ project: projectId }),
signal: AbortSignal.timeout(10000),
});
if (!response.ok) continue;
@@ -649,7 +646,7 @@ export async function getAntigravityUsage(
info.isInternal === true ||
!(provider === "agy"
? isUserCallableAgyModelId(modelKey)
: isUserCallableAntigravityModelId(modelKey)) ||
: isDiscoverableAntigravityModelId(modelKey)) ||
Object.keys(quotaInfo).length === 0
) {
continue;
@@ -702,7 +699,7 @@ export async function getAntigravityUsage(
quotas[modelKey] ||
!(provider === "agy"
? isUserCallableAgyModelId(modelKey)
: isUserCallableAntigravityModelId(modelKey))
: isDiscoverableAntigravityModelId(modelKey))
) {
continue;
}

View File

@@ -31,6 +31,8 @@
* to 200, so the HTTP status can no longer change).
*/
import { ResponsesOutputIndexStack } from "./responsesOutputIndexStack.ts";
const ENCODER = new TextEncoder();
const KEEPALIVE_FRAME = ENCODER.encode(": keepalive\n\n");
// OpenAI-compatible keepalive: a syntactically valid empty streaming chunk.
@@ -50,59 +52,89 @@ export const OPENAI_STARTUP_FRAME = OPENAI_KEEPALIVE_FRAME;
// API emits `event: ping` for exactly this reason; the /v1/messages route mirrors it.
export const ANTHROPIC_PING_FRAME = ENCODER.encode('event: ping\ndata: {"type":"ping"}\n\n');
// Responses API keepalive: a self-contained, self-closed synthetic reasoning
// item (added -> summary_part.added -> text.delta -> summary_part.done),
// matching the abbreviated close pattern open-sse/utils/stream.ts's own
// emitSyntheticResponsesReasoningSummary already uses for real mid-stream
// reasoning. Closed within this one frame (not left dangling open) since the
// real upstream response once it arrives — starts its own independent
// response.created lifecycle from scratch; this placeholder item never
// carries a response_id and isn't meant to be continued.
// item (added -> summary_part.added -> text.delta -> summary_part.done ->
// output_item.done). Unlike open-sse/utils/stream.ts's own
// emitSyntheticResponsesReasoningSummary — which only supplements a REAL
// upstream item that the real provider stream will close on its own — this
// placeholder item has no real counterpart: the upstream response, once it
// arrives, starts its own independent response.created lifecycle from
// scratch and will never close this one. It must therefore send its own
// response.output_item.done here, not just reasoning_summary_part.done
// (that only closes the nested summary part, not the output item itself).
// Without it, a strict client tracking open items by output_index (as the
// Responses API spec requires) sees this item still open at index 0 and
// throws a collision the moment the real response's own output_item.added
// reuses that same index — reproduced live 2026-08-13, OpenClaw issue
// https://github.com/openclaw/openclaw/issues/123342.
//
// The output_index is allocated from ResponsesOutputIndexStack instead of a
// hardcoded literal so this stays structurally correct: forgetting the
// close() call throws at module load (assertAllClosed() below), not
// silently at some future real request.
const RESPONSES_STARTUP_ITEM_ID = "rs_keepalive";
// Brand-neutral placeholder — clients persist this as visible reasoning.
const STARTUP_THINKING_TEXT = "✨";
const startupIndexStack = new ResponsesOutputIndexStack();
const RESPONSES_STARTUP_OUTPUT_INDEX = startupIndexStack.open();
const startupEvents = [
{
event: "response.output_item.added",
data: {
type: "response.output_item.added",
output_index: RESPONSES_STARTUP_OUTPUT_INDEX,
item: { id: RESPONSES_STARTUP_ITEM_ID, type: "reasoning", summary: [] },
},
},
{
event: "response.reasoning_summary_part.added",
data: {
type: "response.reasoning_summary_part.added",
item_id: RESPONSES_STARTUP_ITEM_ID,
output_index: RESPONSES_STARTUP_OUTPUT_INDEX,
summary_index: 0,
part: { type: "summary_text", text: "" },
},
},
{
event: "response.reasoning_summary_text.delta",
data: {
type: "response.reasoning_summary_text.delta",
item_id: RESPONSES_STARTUP_ITEM_ID,
output_index: RESPONSES_STARTUP_OUTPUT_INDEX,
summary_index: 0,
delta: STARTUP_THINKING_TEXT,
},
},
{
event: "response.reasoning_summary_part.done",
data: {
type: "response.reasoning_summary_part.done",
item_id: RESPONSES_STARTUP_ITEM_ID,
output_index: RESPONSES_STARTUP_OUTPUT_INDEX,
summary_index: 0,
part: { type: "summary_text", text: STARTUP_THINKING_TEXT },
},
},
];
// close() runs before the output_item.done event is built (not just before
// it's appended) so assertAllClosed() below is a real check, not scaffolding
// that always trivially passes.
startupIndexStack.close(RESPONSES_STARTUP_OUTPUT_INDEX);
startupEvents.push({
event: "response.output_item.done",
data: {
type: "response.output_item.done",
output_index: RESPONSES_STARTUP_OUTPUT_INDEX,
item: {
id: RESPONSES_STARTUP_ITEM_ID,
type: "reasoning",
summary: [{ type: "summary_text", text: STARTUP_THINKING_TEXT }],
},
},
});
startupIndexStack.assertAllClosed();
export const RESPONSES_STARTUP_THINKING_FRAME = ENCODER.encode(
[
{
event: "response.output_item.added",
data: {
type: "response.output_item.added",
output_index: 0,
item: { id: RESPONSES_STARTUP_ITEM_ID, type: "reasoning", summary: [] },
},
},
{
event: "response.reasoning_summary_part.added",
data: {
type: "response.reasoning_summary_part.added",
item_id: RESPONSES_STARTUP_ITEM_ID,
output_index: 0,
summary_index: 0,
part: { type: "summary_text", text: "" },
},
},
{
event: "response.reasoning_summary_text.delta",
data: {
type: "response.reasoning_summary_text.delta",
item_id: RESPONSES_STARTUP_ITEM_ID,
output_index: 0,
summary_index: 0,
delta: STARTUP_THINKING_TEXT,
},
},
{
event: "response.reasoning_summary_part.done",
data: {
type: "response.reasoning_summary_part.done",
item_id: RESPONSES_STARTUP_ITEM_ID,
output_index: 0,
summary_index: 0,
part: { type: "summary_text", text: STARTUP_THINKING_TEXT },
},
},
]
.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)
.join("")
startupEvents.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`).join("")
);
// Anthropic Messages API default — Anthropic's own spec really does use a named
// `event: error` SSE frame, so this is correct there. It is WRONG for the OpenAI-
@@ -184,8 +216,7 @@ export type EarlyStreamKeepaliveOptions = {
* type-check. A string discriminant narrows both branches under the same settings.
*/
type SettledHandler =
| { status: "fulfilled"; response: Response }
| { status: "rejected"; error: unknown };
{ status: "fulfilled"; response: Response } | { status: "rejected"; error: unknown };
export async function withEarlyStreamKeepalive(
handlerPromise: Promise<Response>,

View File

@@ -4,7 +4,7 @@
* and the combo compatibility filter (open-sse/) — the two previously kept
* divergent copies (guardrail missed input_image; combo saw it).
*/
export type MediaKind = "image" | "audio";
export type MediaKind = "image" | "audio" | "video";
export interface MediaPart {
kind: MediaKind;
@@ -36,6 +36,9 @@ export interface MediaPart {
| "audio_url"
/** Audio detected via `source.media_type: audio/*` (no explicit type). */
| "audio_source"
| "input_video"
| "video_url"
| "video_source"
/**
* Combo-parity indicator: the value looks like an image part (image-ish
* `type` in any casing, a bare `image_url`/`input_image` key, or a
@@ -149,6 +152,48 @@ function inspectAudioShapes(
return false;
}
/** Strict video shapes with an extractable URL, data URI, or base64 ref. */
function inspectVideoShapes(
obj: Record<string, unknown>,
type: string | undefined,
mediaType: unknown,
ctx: DetectCtx,
depth: number
): boolean {
if (type === "input_video") {
const ref = urlFrom(obj.video_url ?? obj.input_video ?? obj.url);
if (ref) {
pushPart(ctx, "video", ref, "input_video", depth);
return true;
}
}
if (type === "video_url") {
const ref = urlFrom(obj.video_url);
if (ref) {
pushPart(ctx, "video", ref, "video_url", depth);
return true;
}
}
const source = obj.source as Record<string, unknown> | undefined;
if (source) {
const videoMediaType =
typeof mediaType === "string" && mediaType.toLowerCase().startsWith("video/");
// Base64 must carry an explicit video MIME. This prevents a type:video wrapper
// from relabelling arbitrary base64 content as MP4.
if (videoMediaType && typeof source.data === "string") {
pushPart(ctx, "video", `data:${mediaType};base64,${source.data}`, "video_source", depth);
return true;
}
const ref = urlFrom(source.url);
const explicitAnthropicUrl = type === "video" && source.type === "url";
if (ref && (explicitAnthropicUrl || type === "video_source" || videoMediaType)) {
pushPart(ctx, "video", ref, "video_source", depth);
return true;
}
}
return false;
}
/**
* Combo-parity image indicators: the legacy valueContainsImagePart
* (comboStructure) matched image-ish `type` names case-insensitively, bare
@@ -182,6 +227,7 @@ function inspect(value: unknown, ctx: DetectCtx, depth: number): void {
if (ctx.found || depth > MAX_DEPTH || value == null) return;
if (typeof value === "string") {
if (value.startsWith("data:image/")) pushPart(ctx, "image", value, "data_uri_string", depth);
if (value.startsWith("data:video/")) pushPart(ctx, "video", value, "data_uri_string", depth);
return;
}
if (Array.isArray(value)) {
@@ -203,6 +249,7 @@ function inspect(value: unknown, ctx: DetectCtx, depth: number): void {
// matched) or nest image parts inside its payload.
inspectAudioShapes(obj, type, mediaType, ctx, depth);
if (ctx.found) return;
if (inspectVideoShapes(obj, type, mediaType, ctx, depth)) return;
if (inspectImageIndicators(obj, type, mediaType, ctx, depth)) return;
for (const nested of Object.values(obj)) {
inspect(nested, ctx, depth + 1);

View File

@@ -0,0 +1,87 @@
/**
* Optional runtime pack resolution (Stage 7 of the Electron efficiency roadmap,
* issue #10321).
*
* The desktop bundle ships WITHOUT the heavy optional ML/browser dependency
* closure; users install versioned packs (`omniroute packs install ml-runtime`)
* into `${DATA_DIR}/packs/<name>/node_modules`. `electron/main.js` prepends
* those directories to the spawned server's NODE_PATH, which is how dynamic
* imports (`await import("playwright")`, the LLMLingua worker) resolve pack
* members at runtime.
*
* This module is the runtime side and deliberately does NOT import the
* build-side manifest (`scripts/packs/optionalPackManifest.mjs`) — the
* standalone server must stay decoupled from build tooling. It embeds only the
* pack names and the index filename.
*
* Fail-open: every helper returns "absent" rather than throwing, so a missing
* or corrupt pack degrades the optional feature instead of the server.
*/
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
/** Pack names — must match OPTIONAL_PACKS in scripts/packs/optionalPackManifest.mjs. */
export const OPTIONAL_PACK_NAMES = ["ml-runtime", "browser-runtime"] as const;
export type OptionalPackName = (typeof OPTIONAL_PACK_NAMES)[number];
/** Index filename — must match PACK_INDEX_FILENAME in the manifest module. */
export const PACK_INDEX_FILENAME = "optional-packs.index.json";
/** Resolve DATA_DIR exactly like the rest of the runtime (modelStore.ts precedent). */
function resolveDataDir(override?: string): string {
return override || process.env.DATA_DIR || path.join(os.homedir(), ".omniroute");
}
/** `${DATA_DIR}/packs` — root of installed packs. */
export function packsRootDir(dataDirOverride?: string): string {
return path.join(resolveDataDir(dataDirOverride), "packs");
}
/** Install dir for one pack: `${DATA_DIR}/packs/<name>` (contains node_modules/). */
export function packInstallDir(name: string, dataDirOverride?: string): string {
return path.join(packsRootDir(dataDirOverride), name);
}
/** `node_modules` dir of an installed pack, whether or not it exists. */
export function packNodeModulesDir(name: string, dataDirOverride?: string): string {
return path.join(packInstallDir(name, dataDirOverride), "node_modules");
}
/**
* NODE_PATH entries for every INSTALLED pack (manifest order, deterministic).
* `electron/main.js` consumes this via its own plain-JS mirror — keep the
* semantics identical (existence check, no throw).
*/
export function installedPackNodePaths(dataDirOverride?: string): string[] {
const entries: string[] = [];
for (const name of OPTIONAL_PACK_NAMES) {
const dir = packNodeModulesDir(name, dataDirOverride);
try {
if (fs.statSync(dir).isDirectory()) entries.push(dir);
} catch {
// Not installed (or unreadable) — absent, not an error.
}
}
return entries;
}
/**
* Probe a pack member by its path relative to a `node_modules` root, e.g.
* `@atjsh/llmlingua-2/package.json`. A single leading `node_modules` segment is
* accepted because existing filesystem probes express the same member from an
* install root. Checks every installed pack first, so an installed pack lights
* the feature up even though the bundle tree no longer carries the member.
*/
export function packMemberInstalled(memberRelPath: string, dataDirOverride?: string): boolean {
const segments = memberRelPath.split(/[\\/]/).filter(Boolean);
if (segments[0] === "node_modules") segments.shift();
if (segments.length === 0) return false;
for (const nodeModulesDir of installedPackNodePaths(dataDirOverride)) {
if (fs.existsSync(path.join(nodeModulesDir, ...segments))) return true;
}
return false;
}

View File

@@ -0,0 +1,48 @@
/**
* @file responsesOutputIndexStack.ts
* @description Structural guard against the Responses-API output_index
* collision bug class (OpenClaw issue #123342): a hand-tracked output_index
* that an emitter forgets to close before the same number gets reused.
*
* Responses-API output items open and close one at a time within any single
* emitter — there is never a real need to hold two indices open
* simultaneously from one emitter's own bookkeeping. Modeling allocation as
* a stack makes "forgot to close" a structural impossibility instead of a
* silent bug: open() always returns the next sequential index, close()
* requires the caller to name the index being closed and throws if it does
* not match the top of the stack, and assertAllClosed() — called once the
* caller has finished building its frame/events — throws if anything is
* still open. For a module-level constant frame (like the early keepalive
* placeholder), that last check runs at import time: a regression here fails
* the build/boot instead of shipping a malformed stream to production.
*/
export class ResponsesOutputIndexStack {
private readonly openIndices: number[] = [];
private nextIndex = 0;
open(): number {
const index = this.nextIndex;
this.nextIndex += 1;
this.openIndices.push(index);
return index;
}
close(index: number): void {
const top = this.openIndices.at(-1);
if (top !== index) {
throw new Error(
`ResponsesOutputIndexStack: closing output_index ${index} but the open top was ${String(top)}`
);
}
this.openIndices.pop();
}
assertAllClosed(): void {
if (this.openIndices.length > 0) {
throw new Error(
`ResponsesOutputIndexStack: output_index(es) still open with no close(): ${this.openIndices.join(", ")}`
);
}
}
}

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