Compare commits

..

1 Commits

343 changed files with 2044 additions and 19934 deletions

View File

@@ -714,16 +714,6 @@ 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
@@ -745,21 +735,6 @@ 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.
@@ -2398,12 +2373,6 @@ 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,12 +697,11 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 30
needs: build
# WS1.5 (v3.8.49 plan): the Electron native-module path previously executed for
# WS1.5 (v3.8.49 plan): the Electron rebuild/spawn 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 (better-sqlite3 prebuild verification since
# v13 — the node-gyp rebuild is gone) per release PR; ubuntu keeps the full
# pack + headless smoke.
# windows-latest runs prepare:bundle (the ABI rebuild + spawn plan) per release
# PR; ubuntu keeps the full pack + headless smoke.
strategy:
fail-fast: false
matrix:
@@ -739,7 +738,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 prebuild verification)
- name: Prepare Electron standalone (Windows ABI rebuild + spawn path)
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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
- uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
languages: javascript-typescript
queries: security-extended
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
- uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
category: "/language:javascript-typescript"

View File

@@ -37,7 +37,7 @@ jobs:
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
- 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.6
uses: github/codeql-action/upload-sarif@v4.37.4
with:
sarif_file: trivy-results.sarif
category: trivy-image

View File

@@ -55,75 +55,9 @@ 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, 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') }}
needs: validate
runs-on: ${{ matrix.runner }}
permissions:
contents: write # electron-builder may publish artifacts with GH_TOKEN
@@ -135,27 +69,19 @@ 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
@@ -167,6 +93,14 @@ 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:
@@ -182,11 +116,7 @@ jobs:
mkdir -p "$RUNNER_TEMP/home"
echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV"
- 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'
- name: Build Next.js standalone
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
NODE_OPTIONS: "--max_old_space_size=6144"
@@ -204,30 +134,6 @@ 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:
@@ -252,7 +158,7 @@ jobs:
- name: Install Electron dependencies
working-directory: electron
run: npm ci --no-audit --no-fund
run: npm install --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
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
# 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
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
@@ -430,7 +430,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
# 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
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
# 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
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
@@ -583,7 +583,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
- 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,7 +1,6 @@
# 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=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 \
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 \
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=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 \
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 \
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=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
RUN --mount=type=cache,id=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=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \
RUN --mount=type=cache,id=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=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 \
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 \
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=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 \
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 \
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=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
RUN --mount=type=cache,id=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,7 +702,6 @@ 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>
@@ -712,33 +711,6 @@ 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,7 +5,6 @@ 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;
@@ -88,13 +87,6 @@ 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) {
@@ -279,10 +271,6 @@ 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, {
@@ -318,10 +306,6 @@ 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,7 +4,6 @@ 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
@@ -76,12 +75,6 @@ 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`);
@@ -93,7 +86,6 @@ 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) {
@@ -138,9 +130,7 @@ 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();
@@ -159,7 +149,7 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
const ctxWindow = contextWindowOf(entry);
if (target === "codex") {
return await configureCodex(chosenId, ctxWindow, opts);
await configureCodex(chosenId, ctxWindow, opts);
}
return 0;
}
@@ -183,10 +173,6 @@ 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);

View File

@@ -1,166 +0,0 @@
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,7 +79,6 @@ 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);
@@ -164,5 +163,4 @@ export function registerCommands(program) {
registerApiCommands(program);
registerPlugin(program);
registerRadar(program);
registerPacks(program);
}

View File

@@ -13,7 +13,6 @@ 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,9 +25,7 @@ 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 */
}
@@ -81,7 +78,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 [];
@@ -91,16 +88,7 @@ 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 guard = await guardHostConfigTarget(configPath, {
toolLabel: "Aider",
hostCommand: "omniroute setup-aider",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".aider.conf.yml");
printHeading("OmniRoute → Aider (openai-compatible via LiteLLM)");
printInfo(`OPENAI_API_BASE: ${apiBase} (no /v1 — LiteLLM appends it)`);
@@ -119,9 +107,7 @@ 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;
}
@@ -153,10 +139,6 @@ 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,7 +20,6 @@ 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,
@@ -148,14 +147,6 @@ 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 {
@@ -229,10 +220,6 @@ 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,7 +16,6 @@ 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(/\/+$/, "");
@@ -29,14 +28,11 @@ 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) {
@@ -85,7 +81,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 [];
@@ -97,14 +93,6 @@ 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}`);
@@ -134,18 +122,7 @@ 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 });
@@ -156,9 +133,7 @@ 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}`);
@@ -178,10 +153,6 @@ 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,7 +16,6 @@ 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 ──────────────────────────────────────────────────────
@@ -307,14 +306,6 @@ 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 ───────────────────────────────────────────────────
@@ -389,10 +380,6 @@ 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,7 +14,6 @@ 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 }}";
@@ -93,7 +92,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}`);
@@ -103,22 +102,8 @@ 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 guard = await guardHostConfigTarget(configPath, {
toolLabel: "Continue",
hostCommand: "omniroute setup-continue",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
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");
printHeading("OmniRoute → Continue (config.yaml)");
printInfo(`apiBase: ${apiBase}`);
@@ -165,7 +150,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;
}
@@ -181,10 +166,6 @@ 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,7 +13,6 @@ 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";
@@ -88,29 +87,15 @@ 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 guard = await guardHostConfigTarget(configPath, {
toolLabel: "Crush",
hostCommand: "omniroute setup-crush",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
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");
printHeading("OmniRoute → Crush (openai-compat)");
printInfo(`base_url: ${baseUrl}`);
@@ -135,17 +120,13 @@ 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;
}
@@ -160,10 +141,6 @@ 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,7 +10,6 @@
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(/\/+$/, "");
@@ -72,7 +71,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 [];
@@ -85,32 +84,19 @@ 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,7 +14,6 @@ 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(/\/+$/, "");
@@ -27,9 +26,7 @@ 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 */
}
@@ -83,7 +80,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 [];
@@ -93,16 +90,7 @@ 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 guard = await guardHostConfigTarget(configPath, {
toolLabel: "Goose",
hostCommand: "omniroute setup-goose",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml");
printHeading("OmniRoute → Goose (openai-compatible)");
printInfo(`OPENAI_HOST: ${host} (no /v1 — Goose appends it)`);
@@ -140,16 +128,14 @@ 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)")
@@ -157,10 +143,6 @@ 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,7 +14,6 @@ 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) {
@@ -62,11 +61,7 @@ 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;
}
@@ -90,7 +85,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 [];
@@ -100,22 +95,9 @@ 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 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 authPath = opts.authPath ?? opts["auth-path"] ?? join(os.homedir(), ".local", "share", "kilo", "auth.json");
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}`);
@@ -134,9 +116,7 @@ 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;
}
@@ -152,19 +132,12 @@ 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");
@@ -194,20 +167,10 @@ 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,7 +30,6 @@ 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);
@@ -317,13 +316,6 @@ 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 {
@@ -428,10 +420,6 @@ 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,7 +14,6 @@ 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 };
@@ -120,15 +119,6 @@ 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,
@@ -173,10 +163,6 @@ 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,7 +18,6 @@ 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. */
@@ -103,16 +102,6 @@ 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);
@@ -170,10 +159,6 @@ 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,7 +16,6 @@ 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(/\/+$/, "");
@@ -90,7 +89,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 [];
@@ -100,20 +99,9 @@ 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 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 importPath = opts.importPath ?? opts["import-path"] ?? join(os.homedir(), ".omniroute", "roo-settings.json");
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}`);
@@ -142,27 +130,8 @@ 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");
@@ -192,20 +161,10 @@ 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,23 +1304,5 @@
},
"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,23 +1301,5 @@
},
"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

@@ -1,122 +0,0 @@
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

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- 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

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- **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(resilience): keep combo quality and auth failure reasons separate and redact connection labels in terminal errors (#10314)

View File

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- **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

@@ -1,2 +0,0 @@
- **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

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- **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.
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.
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.
**OmniRoute solves this transparently:**
@@ -87,8 +87,8 @@ Many providers, including GLM and Kimi, still expose only a Chat Completions end
Codex CLI
→ wire_api = "responses"
→ POST /v1/responses (OmniRoute)
→ OmniRoute selects the provider's native protocol and translates when needed
→ POST /responses (DeepSeek V4) or /chat/completions (Mistral / GLM / Kimi / others)
→ OmniRoute Responses ↔ Chat Completions transformer
→ POST /chat/completions (DeepSeek / Mistral / GLM / Kimi / any provider)
```
You never need a separate translation proxy when using OmniRoute. **All models use `wire_api = "responses"`** — OmniRoute handles the rest.

View File

@@ -14,7 +14,6 @@ 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)
@@ -83,61 +82,6 @@ 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.
@@ -326,22 +270,7 @@ 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`. 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).
`OMNIROUTE_BASE_PATH`.
## 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` | root `^13.0.2` (Node-API prebuilds — no Electron rebuild) |
| 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` | `^12.9.0` |
| 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 | 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` |
| 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` |
## See Also

View File

@@ -5468,78 +5468,15 @@ paths:
get:
tags: [System]
summary: Get Modality Bridge telemetry
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.
description: In-memory per-modality bridge counters (bridged, cacheHits, failures, lastUsedAt). Counters reset on process restart.
security:
- ManagementSessionAuth: []
responses:
"200":
description: Per-modality bridge stats (vision, audio, video)
description: Per-modality bridge stats (vision, audio)
"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.50
lastUpdated: 2026-08-13
version: 3.8.40
lastUpdated: 2026-06-28
---
# Monitoring & Observability Guide
@@ -103,29 +103,9 @@ Per-combo:
## Health Check API
OmniRoute exposes **two** HTTP health surfaces. They are not interchangeable for orchestrators.
> **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.
| 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)
### System Health
```bash
GET /api/monitoring/health
@@ -155,48 +135,6 @@ 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,15 +665,13 @@ 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 `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 |
| 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) |
### Backup & Export/Import

View File

@@ -69,20 +69,6 @@ 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
@@ -108,33 +94,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.
---
@@ -215,16 +201,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`). 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_CONFIG_HOME` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Override home directory for reading CLI configs (`~/.claude`, `~/.codex`). |
| `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,17 +400,6 @@ 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
@@ -428,25 +417,11 @@ 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=/host-home
CLI_CONFIG_HOME=/root
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
@@ -1501,9 +1476,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: 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_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_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. |
@@ -1521,7 +1496,6 @@ 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) | ⚠️ **DEPRECATED.** Monster API shuttered operations on 2026-06-30. Use alternative OpenAI-compatible providers. |
| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | Get API key at monsterapi.ai |
| `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-14
lastUpdated: 2026-08-08
---
# Guardrails
> **Source of truth:** `src/lib/guardrails/`
> **Last updated:** 2026-08-15 — v3.8.50 (Video Bridge broker confinement)
> **Last updated:** 2026-08-08 — v3.8.50 (Modality Bridge PR-3: Audio Bridge runtime and functional Audio settings tab)
Guardrails enforce safety, policy, and content transformations at the boundary
between OmniRoute and upstream providers. Each guardrail can inspect (and
@@ -20,14 +20,13 @@ request. Blocking is an explicit decision (`block: true`), never an accident.
## Built-in Guardrails
The registry auto-loads six guardrails in priority order on import
The registry auto-loads five 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` |
@@ -173,12 +172,7 @@ 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
`{ 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.
`{ bridged, cacheHits, failures, lastUsedAt }` for `vision` and `audio`.
Counters reset on process restart by design
(telemetry, not accounting).
@@ -192,9 +186,8 @@ 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. 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.
counters, and an `input_audio` sample test. Video remains the explicit placeholder
tracked in issue `#9760`.
The former Vision Bridge card under AI settings is a compatibility link to the
new page; it no longer owns a second copy of the form. Media Providers also
@@ -274,88 +267,6 @@ 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.
@@ -480,7 +391,6 @@ interface GuardrailContext {
method?: string | null;
model?: string | null;
provider?: string | null;
signal?: AbortSignal;
sourceFormat?: string | null;
stream?: boolean;
targetFormat?: string | null;
@@ -490,7 +400,6 @@ 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`)
@@ -581,12 +490,6 @@ 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,7 +43,6 @@ 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

@@ -1,61 +0,0 @@
/**
* 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,7 +39,6 @@ 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();
@@ -87,7 +86,6 @@ 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
@@ -114,32 +112,7 @@ function resolveNodeExecutable(env = process.env) {
return process.execPath;
}
// 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 = []) {
function resolveServerNodePath(env = process.env) {
const seen = new Set();
const entries = [];
@@ -161,12 +134,6 @@ function resolveServerNodePath(env = process.env, extraDirs = []) {
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.
@@ -218,6 +185,26 @@ 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;
@@ -546,7 +533,7 @@ async function changePort(newPort) {
// Start server on new port
startNextServer();
await waitForServer(getServerReadinessUrl());
await waitForServer(getServerUrl());
// Reload window and update tray
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -616,7 +603,7 @@ async function setRemoteServerUrl(nextUrl) {
startNextServer();
try {
await waitForServer(getServerReadinessUrl());
await waitForServer(`${getServerUrl()}/api/monitoring/health`);
} catch (err) {
console.warn("[Electron] Server did not become ready after remote-server change:", err.message);
}
@@ -783,7 +770,7 @@ function startNextServer() {
PORT: String(serverPort),
NODE_ENV: "production",
ELECTRON_RUN_AS_NODE: "1",
NODE_PATH: resolveServerNodePath(serverEnv, resolvePackNodePaths(dataDir)),
NODE_PATH: resolveServerNodePath(serverEnv),
NODE_OPTIONS: serverNodeOptions,
},
stdio: "pipe",
@@ -948,7 +935,7 @@ function setupIpcHandlers() {
stopNextServer();
await waitForServerExit(serverToStop);
startNextServer();
await waitForServer(getServerReadinessUrl());
await waitForServer(getServerUrl());
return { success: true };
});
@@ -1091,8 +1078,8 @@ app.whenReady().then(async () => {
startNextServer();
let serverReady = true;
if (!isDev) {
// Probe the lightweight auth-exempt endpoint instead of aggregating full monitoring state.
serverReady = await waitForServer(getServerReadinessUrl());
// Probe the auth-exempt health endpoint (not the root URL, which may redirect).
serverReady = await waitForServer(`${getServerUrl()}/api/monitoring/health`);
}
if (isHeadless) {
@@ -1108,7 +1095,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(getServerReadinessUrl(), 300000).then((ready) => {
void waitForServer(`${getServerUrl()}/api/monitoring/health`, 300000).then((ready) => {
if (ready && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.loadURL(getServerUrl());
}

View File

@@ -297,45 +297,6 @@
"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",
@@ -1130,15 +1091,6 @@
"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",
@@ -1459,19 +1411,6 @@
"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",
@@ -1506,66 +1445,6 @@
"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",
@@ -2480,20 +2359,6 @@
"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",
@@ -2757,36 +2622,6 @@
"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",
@@ -2981,21 +2816,6 @@
"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",
@@ -3251,21 +3071,6 @@
"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,7 +66,6 @@
"lib/resolveNodeHelper.js",
"lib/resolveRemoteServerUrl.js",
"lib/remoteServerPreferences.js",
"lib/serverReadiness.js",
"assets/remoteServerPrompt.html",
"package.json",
"node_modules/**/*"
@@ -75,6 +74,14 @@
{
"from": "../.build/electron-standalone",
"to": "app",
"filter": [
"**/*",
"node_modules/**/*"
]
},
{
"from": "../.build/electron-standalone/node_modules",
"to": "app/node_modules",
"filter": [
"**/*"
]

View File

@@ -1,26 +1,6 @@
export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([
// 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.
// 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.
{
id: "gemini-3.6-flash-high",
name: "Gemini 3.6 Flash (High)",
@@ -215,32 +195,6 @@ 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;
@@ -280,16 +234,3 @@ 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,10 +77,6 @@ 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,7 +1,12 @@
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;
@@ -11,7 +16,6 @@ export interface ProviderPluginModel {
toolCalling?: boolean;
supportsReasoning?: boolean;
supportsVision?: boolean;
supportsVideo?: boolean;
unsupportedParams?: readonly string[];
targetFormat?: string;
}
@@ -54,7 +58,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>;
}
@@ -67,7 +71,6 @@ 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;
@@ -127,7 +130,7 @@ function capabilitiesFor(entry: RegistryEntry, eligible: boolean): ProviderPlugi
}
export function createProviderPluginManifestEntry(
entry: RegistryEntry
entry: RegistryEntry,
): ProviderPluginManifestEntry {
const sidecar = sidecarEligibility(entry);
@@ -160,7 +163,7 @@ export function createProviderPluginManifestEntry(
}
export function generateProviderPluginManifestFromRegistry(
registry: Record<string, RegistryEntry>
registry: Record<string, RegistryEntry>,
): ProviderPluginManifest {
return {
schemaVersion: 1,
@@ -188,7 +191,7 @@ export function createServiceBackendManifestEntry(
template: Pick<
ProviderPluginManifestEntry,
"format" | "executor" | "auth" | "endpoints" | "capabilities" | "passthroughModels" | "sidecar"
>
>,
): ProviderPluginManifestEntry {
return {
id: pluginId,
@@ -199,10 +202,11 @@ 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,7 +145,8 @@ 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, xai_oauthProvider } from "./registry/xai/index.ts";
import { xaiProvider } from "./registry/xai/index.ts";
import { xai_oauthProvider } from "./registry/xai-oauth/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";
@@ -153,7 +154,6 @@ 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,7 +412,6 @@ 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,40 +1,25 @@
import { getAnthropicCompatHeaders, type RegistryEntry } from "../../shared.ts";
import type { RegistryEntry } from "../../shared.ts";
export const deepseekProvider: RegistryEntry = {
id: "deepseek",
alias: "ds",
format: "openai-responses",
format: "openai",
executor: "default",
baseUrl: "https://api.deepseek.com/responses",
baseUrl: "https://api.deepseek.com/v1/chat/completions",
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 (0813)",
contextLength: 1_000_000,
maxOutputTokens: 384_000,
name: "DeepSeek V4 Pro",
supportsReasoning: true,
supportedThinkingEfforts: ["none", "high", "max"],
toolCalling: true,
},
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash (0731)",
contextLength: 1_000_000,
maxOutputTokens: 384_000,
name: "DeepSeek V4 Flash",
supportsReasoning: true,
supportedThinkingEfforts: ["none", "low", "high", "max"],
toolCalling: true,
},
],
};

View File

@@ -5,39 +5,34 @@ export const freeaiapikeyProvider: RegistryEntry = {
alias: "faik",
format: "openai",
executor: "default",
// 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",
baseUrl: "https://freeaiapikey.com/v1/chat/completions",
modelsUrl: "https://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.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: "openai/gpt-5.2-codex", name: "GPT-5.2 Codex (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: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5 (via FreeAIAPIKey)" },
{
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,
},
],
};

View File

@@ -20,15 +20,6 @@ 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

@@ -0,0 +1,33 @@
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,5 +1,4 @@
import type { RegistryEntry } from "../../shared.ts";
import { resolvePublicCred } from "../../shared.ts";
export const xaiProvider: RegistryEntry = {
id: "xai",
@@ -15,17 +14,6 @@ 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
@@ -39,40 +27,3 @@ 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

@@ -1,18 +0,0 @@
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,7 +51,6 @@ export interface RegistryModel {
supportedThinkingEfforts?: readonly string[];
supportsVision?: boolean;
supportsAudio?: boolean;
supportsVideo?: boolean;
supportsXHighEffort?: boolean;
maxOutputTokens?: number;
targetFormat?: string;

View File

@@ -1,109 +0,0 @@
/**
* 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,45 +339,6 @@ 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;
@@ -397,10 +358,7 @@ function sanitizeAntigravityGeminiRequest(
}
if (asRecord(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.systemInstruction = request.systemInstruction;
}
clean.generationConfig = asRecord(request.generationConfig)

View File

@@ -8,20 +8,12 @@
* `buildErrorBody` instead so the client sees a proper error (hard rule #12).
*/
import { buildErrorBody } from "../utils/error.ts";
import { isGeoBlockedError } from "../services/errorClassifier.ts";
// 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) {
export function buildAntigravityUpstreamError(
status: number,
statusText: string,
rawBody: string
) {
let upstreamDetails: unknown;
try {
upstreamDetails = JSON.parse(rawBody);
@@ -29,12 +21,5 @@ export function buildAntigravityUpstreamError(status: number, statusText: string
// 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,41 +605,24 @@ export class DefaultExecutor extends BaseExecutor {
/**
* Downgrade `response_format: { type: "json_schema" }` to `json_object` for
* `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.
* `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.
*/
applyJsonSchemaFallback<T>(body: T): T {
const provider = this.provider ?? "";
const isOpenAiCompatible = provider.startsWith("openai-compatible-");
const isKiloCode = provider === "kilocode";
if (!isOpenAiCompatible && !isKiloCode) return body;
if (!this.provider?.startsWith?.("openai-compatible-")) 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) return body;
{ type?: string; json_schema?: { schema?: unknown } } | undefined;
if (rf?.type !== "json_schema" || !rf.json_schema?.schema) return body;
// 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 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.`;
const messages: Array<Record<string, unknown>> = Array.isArray(record.messages)
? (record.messages as Array<Record<string, unknown>>).map((m) => ({ ...m }))
@@ -655,14 +638,6 @@ 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,4 +1,3 @@
import { SEARCH_PROVIDERS } from "../config/searchRegistry.ts";
import { AntigravityExecutor } from "./antigravity.ts";
import { GithubExecutor } from "./github.ts";
import { GheCopilotExecutor } from "./ghe-copilot.ts";
@@ -34,7 +33,6 @@ 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";
@@ -136,8 +134,6 @@ 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(),
@@ -234,17 +230,6 @@ 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)) {
@@ -254,13 +239,6 @@ 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,21 +27,13 @@ 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";
@@ -90,12 +82,24 @@ const USER_AGENTS = [
// ── Account State ──────────────────────────────────────────────────────────
/** Per-account proxy configuration, passed through providerSpecificData.accountProxies. */
export type AccountProxyConfig = SharedAccountProxyConfig;
export interface AccountProxyConfig {
fingerprint: string;
proxy: {
type: string;
host: string;
port: number;
username?: string;
password?: string;
relayAuth?: string;
} | null;
}
interface AccountState extends RotatableAccount {
interface AccountState {
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 —
@@ -219,10 +223,7 @@ function rewriteModelName(model: string): string {
export class MimocodeExecutor extends BaseExecutor {
private accounts: AccountState[] = [];
// 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 nextAccountIdx = 0;
private baseUrl: string;
private proxyUrlMap = new Map<string, string>();
private static encoder = new TextEncoder();
@@ -341,15 +342,30 @@ export class MimocodeExecutor extends BaseExecutor {
}
private pickAccount(): AccountState {
return pickRotatableAccount(this.accounts, this, isAccountReady);
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];
}
private markCooldown(account: AccountState): void {
markAccountCooldown(account);
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;
}
private markSuccess(account: AccountState): void {
markAccountSuccess(account);
account.consecutiveFails = 0;
}
/**
@@ -576,25 +592,9 @@ 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,60 +623,16 @@ 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(
buildErrorBody(502, msg, undefined, {
type: "upstream_error",
code: "EXECUTOR_ERROR",
})
)
JSON.stringify({
error: { message: msg, type: "upstream_error", code: "EXECUTOR_ERROR" },
})
),
{ status: 502, headers: { "Content-Type": "application/json" } }
),

View File

@@ -7,30 +7,37 @@ 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 type OpencodeAccountProxyConfig = AccountProxyConfig;
export interface OpencodeAccountProxyConfig {
fingerprint: string;
proxy: {
type: string;
host: string;
port: number;
username?: string;
password?: string;
relayAuth?: string;
} | null;
}
/** Runtime rotation/cooldown state for one "OpenCode Free" account. */
interface OpencodeAccountState extends RotatableAccount {
interface OpencodeAccountState {
/** 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;
/**
@@ -140,10 +147,7 @@ export class OpencodeExecutor extends BaseExecutor {
private accounts: OpencodeAccountState[] = [
{ fingerprint: "", cooldownUntil: 0, consecutiveFails: 0, proxy: null },
];
// 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 nextAccountIdx = 0;
constructor(provider: string) {
super(provider, PROVIDERS[provider] || PROVIDERS.openai);
@@ -186,17 +190,42 @@ 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 {
return pickRotatableAccount(this.accounts, this);
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];
}
private markCooldown(account: OpencodeAccountState): void {
markAccountCooldown(account);
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;
}
private markSuccess(account: OpencodeAccountState): void {
markAccountSuccess(account);
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)}`;
}
async execute(input: ExecuteInput) {
@@ -238,35 +267,11 @@ export class OpencodeExecutor extends BaseExecutor {
}
const { log } = input;
// 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;
let lastResult: Awaited<ReturnType<BaseExecutor["execute"]>> | null = null;
for (let attempt = 0; attempt < this.accounts.length; attempt++) {
const account = this.pickAccount();
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;
}
const masked = OpencodeExecutor.maskAccountId(account.fingerprint);
// #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).
@@ -282,46 +287,9 @@ 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.
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;
}
const result = await runWithProxyContext(account.proxy, () =>
super.execute({ ...input, skipUpstreamRetry: true })
);
lastResult = result;
const status = result.response.status;
@@ -335,16 +303,6 @@ 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 {

View File

@@ -1,375 +0,0 @@
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

@@ -1,438 +0,0 @@
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, relocateDirectiveOnlyMessages } from "./chatCore/claudeSystemRole.ts";
export { extractSystemRoleMessages, relocateDirectiveOnlyMessages } from "./chatCore/claudeSystemRole.ts";
import { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts";
export { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts";
import { checkIdempotencyCache } from "./chatCore/idempotency.ts";
import { checkSemanticCache } from "./chatCore/semanticCache.ts";
import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts";
@@ -159,13 +159,7 @@ import {
buildCapabilityMismatchMessage,
} from "@/shared/constants/capabilities/capabilityFilter.ts";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts";
import {
REASONING_BUFFER_MIN_TRIGGER,
buildReasoningProbeTruncatedResponse,
isEmptyContentUpstreamFailure,
isTinyBudgetReasoningProbe,
toPositiveInteger,
} from "../services/reasoningTokenBuffer.ts";
import { toPositiveInteger } from "../services/reasoningTokenBuffer.ts";
import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts";
import {
buildErrorBody,
@@ -254,10 +248,7 @@ 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 {
@@ -1832,11 +1823,7 @@ 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)`
@@ -1906,12 +1893,7 @@ 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, {
@@ -2150,12 +2132,6 @@ 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(
@@ -3752,33 +3728,6 @@ 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()) {
@@ -3932,28 +3881,6 @@ 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
@@ -4422,11 +4349,7 @@ 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) {
@@ -4571,14 +4494,9 @@ 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,21 +135,6 @@ 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;
@@ -163,85 +148,3 @@ 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,10 +40,7 @@ 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;
}
@@ -59,31 +56,8 @@ 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;
}
@@ -208,30 +182,12 @@ export function buildStreamingResponseHeaders(
}
if (droppedHeaders.length > 0) {
const dropPayload = {
log?.warn?.("HTTP", "Dropped upstream response headers that exceeded forwarding budget", {
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,17 +3,14 @@
* decomposition, #3501).
*
* Pure resolution of the provider alias + the upstream target format used to translate the request.
* 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.
* 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.
* 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";
@@ -49,22 +46,15 @@ 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,17 +93,14 @@ export function extractUsageFromResponse(responseBody, provider) {
};
}
// 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 format
if (responseBody.usageMetadata && typeof responseBody.usageMetadata === "object") {
// Gemini reports thoughts outside candidates. Fold them into completion so
// every provider keeps reasoning as a subset of completion tokens.
const thoughts = usageMetadata.thoughtsTokenCount || 0;
const thoughts = responseBody.usageMetadata.thoughtsTokenCount || 0;
return {
prompt_tokens: usageMetadata.promptTokenCount || 0,
completion_tokens: (usageMetadata.candidatesTokenCount || 0) + thoughts,
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
completion_tokens: (responseBody.usageMetadata.candidatesTokenCount || 0) + thoughts,
reasoning_tokens: thoughts,
};
}

View File

@@ -211,14 +211,6 @@ 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,7 +23,6 @@ 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";
@@ -482,41 +481,15 @@ 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

@@ -93,6 +93,13 @@ import {
expandPromptCacheAffinityTargetsFromConnections,
resolvePromptCacheAffinityKey,
} from "./combo/promptCacheAffinity.ts";
import {
classifyComboOutcome,
formatComboOutcomes,
redactConnectionLabel,
buildRedactedSummary,
} from "./combo/comboErrorAggregation.ts";
import type { ComboErrorEntry } from "./combo/comboErrorAggregation.ts";
import type { CompressionMode } from "./compression/types.ts";
import { getCachedProviderConnections } from "../../src/lib/db/readCache";
import { isProviderInCooldown, recordProviderCooldown } from "./providerCooldownTracker.ts";
@@ -853,7 +860,7 @@ export async function handleComboChat({
let comboExpired = false;
// Accumulator for per-model error details across targets in the current set try.
// Reset at the start of each set retry (same lifecycle as lastError/recordedAttempts).
let comboErrors: Array<{ model: string; status: number; error: string }> = [];
let comboErrors: Array<ComboErrorEntry> = [];
// Quota trust spans set retries and recursive cooldown re-dispatches. Once any
// failure is non-quota, a nested caller must never treat this dispatch as quota-only.
let observedFailure = false;
@@ -1343,6 +1350,15 @@ export async function handleComboChat({
// misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality.
lastError = `Upstream response failed quality validation: ${quality.reason}`;
lastStatus = 502;
// #10314: record quality failures as a FIRST-CLASS per-target outcome
// so a quality reason is never silently dropped from the aggregated
// terminal message when a later sibling overwrites lastError.
comboErrors.push({
model: modelStr,
status: 502,
error: quality.reason || "upstream response failed quality validation",
kind: "quality",
});
if (i > 0) fallbackCount++;
if (provider && rawModel) {
const mlSettings = resolveModelLockoutSettings(settings);
@@ -1850,6 +1866,7 @@ export async function handleComboChat({
model: modelStr,
status: result.status,
error: errorText || String(result.status),
kind: classifyComboOutcome(result.status, errorText),
});
lastStatus = result.status;
if (i > 0) fallbackCount++;
@@ -2043,6 +2060,7 @@ export async function handleComboChat({
model: modelStr,
status: result.status,
error: errorText || String(result.status),
kind: classifyComboOutcome(result.status, errorText),
});
lastStatus = result.status;
if (i > 0) fallbackCount++;
@@ -2197,15 +2215,10 @@ export async function handleComboChat({
// Global combo timeout: return aggregated error immediately, skipping set retries.
if (comboExpired) {
const summary = comboErrors
.slice(0, 5)
.map((e) => `${e.model} (${e.status})`)
.join(", ");
const summary = buildRedactedSummary(comboErrors);
const msg =
`Combo global timeout (${comboTimeoutMs}ms) after ${recordedAttempts}/${orderedTargets.length} targets` +
(comboErrors.length > 0
? ` | tried: ${summary}${comboErrors.length > 5 ? `... (+${comboErrors.length - 5})` : ""}`
: "");
(comboErrors.length > 0 ? ` | tried: ${summary}` : "");
const latencyMs = Date.now() - startTime;
if (recordedAttempts === 0) {
recordComboRequest(combo.name, null, {
@@ -2276,18 +2289,12 @@ export async function handleComboChat({
}
const status = lastStatus;
// Build aggregated error message with per-model failure details for diagnostics.
const comboErrorSummary =
comboErrors.length > 0
? " [" +
comboErrors
.slice(0, 5)
.map((e) => `${e.model} (${e.status})`)
.join(", ") +
(comboErrors.length > 5 ? `... (+${comboErrors.length - 5})` : "") +
"]"
: "";
const msg = (lastError || "All combo models unavailable") + comboErrorSummary;
// #10314: build the terminal message from the structured per-target
// outcomes (each distinct class+reason listed separately) instead of
// mashing a single lastError with raw `[model (status)]` markers. Connection
// identifiers are redacted. Falls back to lastError when no target recorded
// a structured outcome.
const msg = formatComboOutcomes(comboErrors) || lastError || "All combo models unavailable";
// Cooldown-aware retry: instead of crystallizing a transient failure, wait
// out a SHORT cooldown and re-run the whole set loop. Guarded by the helper
@@ -2715,6 +2722,10 @@ async function handleRoundRobinCombo({
let globalAttempts = 0;
let fallbackCount = 0;
let recordedAttempts = 0;
// #10314: per-target outcome accumulator for the round-robin twin so the
// terminal message lists each distinct reason separately (see the quality path
// and the "Done with this model" path below), mirroring handleComboChat.
const rrOutcomes: Array<ComboErrorEntry> = [];
// #1731: Per-request in-memory set of providers whose quota is fully exhausted.
// When a target returns a quota-exhausted 429, remaining targets from the same
@@ -2911,6 +2922,12 @@ async function handleRoundRobinCombo({
// misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality.
lastError = `Upstream response failed quality validation: ${quality.reason}`;
lastStatus = 502;
rrOutcomes.push({
model: modelStr,
status: 502,
error: quality.reason || "upstream response failed quality validation",
kind: "quality",
});
if (offset > 0) fallbackCount++;
break; // move to next model
}
@@ -3217,6 +3234,12 @@ async function handleRoundRobinCombo({
recordedAttempts++;
lastError = errorText || String(result.status);
lastStatus = result.status;
rrOutcomes.push({
model: modelStr,
status: result.status,
error: errorText || String(result.status),
kind: classifyComboOutcome(result.status, errorText),
});
if (offset > 0) fallbackCount++;
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status });
@@ -3337,7 +3360,10 @@ async function handleRoundRobinCombo({
}
const status = lastStatus;
const msg = lastError || "All round-robin combo models unavailable";
// #10314: same structured per-target aggregation as handleComboChat — list each
// distinct reason separately (redacted), fall back to lastError when no outcome.
const msg =
formatComboOutcomes(rrOutcomes) || lastError || "All round-robin combo models unavailable";
if (earliestRetryAfter && isRetryAfterEligibleStatus(status)) {
const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter));

View File

@@ -39,11 +39,6 @@ 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,
@@ -463,27 +458,10 @@ 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) {
// #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}`;
const providerModels = getProviderModels(providerId);
for (const model of providerModels) {
const modelStr = `${providerId}/${model.id}`;
if (!seenModelStrs.has(modelStr)) {
seenModelStrs.add(modelStr);
eligibleTargets.push({

View File

@@ -1,37 +0,0 @@
/**
* 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

@@ -0,0 +1,115 @@
/**
* Shared combo terminal-error aggregation.
*
* #10314 — combo error aggregation mixes quality and auth. Prior to this module
* the combo terminal message was built as a single `lastError` string (last
* writer wins — it can only ever represent ONE target's reason) concatenated
* with a raw `[model (status)]` suffix. A quality-failure reason from one
* target and a sibling's 401 were collapsed into one client-facing sentence
* (`invalid_api_key [openai/proxy-account-b (401)]`) and a quality reason that
* was not the final failing target was dropped entirely.
*
* This module gives each per-target failure a structured {model, status, error,
* kind} entry, so the terminal message can list every distinct reason
* separately (and classification-labelled) instead of mashing them, and it
* redacts connection/account identifiers that, on openai-compatible proxy
* connections, used to surface verbatim in client-visible and shared-warn
* strings (ops/PII leak).
*/
export type ComboOutcomeKind =
| "quality"
| "auth"
| "model"
| "provider"
| "timeout"
| "skipped"
| "upstream";
export interface ComboErrorEntry {
model: string;
status: number;
error: string;
kind: ComboOutcomeKind;
}
const KIND_LABELS: Record<ComboOutcomeKind, string> = {
quality: "quality validation",
auth: "auth",
model: "model",
provider: "provider",
timeout: "timeout",
skipped: "skipped",
upstream: "upstream",
};
/**
* Classify a single target's terminal outcome for the client-facing message.
* Auth-class errors (401/403 or auth-sounding text) are kept distinct from
* model-class (400/422) and provider-class (5xx) so a sibling's 401 is never
* presented as "quality failed". Fall through to `model` for everything else.
*/
export function classifyComboOutcome(status: number, errorText: string): ComboOutcomeKind {
const text = typeof errorText === "string" ? errorText : "";
if (
status === 401 ||
status === 403 ||
/(invalid.?api.?key|unauthorized|not.?authorized|auth(entication|orization)?)/i.test(text)
) {
return "auth";
}
if (status === 408 || status >= 499) return "timeout";
if (status >= 500) return "provider";
return "model";
}
/**
* Redact connection/account identifiers that can ride inside a proxy target's
* model string (openai-compatible proxy model names often carry a connection
* label). UUIDs and long hex hashes are truncated to a short `conn:` prefix.
* Provider/model names operators need for debugging are left intact.
*/
export function redactConnectionLabel(modelStr: string | null | undefined): string {
const label = typeof modelStr === "string" && modelStr ? modelStr : "unknown";
return label
.replace(
/\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g,
(m) => `conn:${m.slice(0, 8)}`
)
.replace(/\b[0-9a-fA-F]{16,}\b/g, (m) => `conn:${m.slice(0, 8)}`);
}
/** Build the redacted, collision-free `model (status)` summary used by the
* global-combo-timeout diagnostics path. */
export function buildRedactedSummary(
entries: Array<{ model: string; status: number }> | ReadonlyArray<{ model: string; status: number }>
): string {
const slice = entries.slice(0, 5);
const parts = slice.map((e) => `${redactConnectionLabel(e.model)} (${e.status})`).join(", ");
return entries.length > 5 ? `${parts}... (+${entries.length - 5})` : parts;
}
/**
* Format per-target terminal outcomes into one client-facing sentence that keeps
* every distinct reason separate (and classification-labelled) instead of
* mashing a single `lastError` with raw status markers. Always redacts
* connection identifiers unless `{ redact: false }` is explicitly passed.
*/
export function formatComboOutcomes(
entries: ReadonlyArray<{ model: string; status: number; error: string; kind?: ComboOutcomeKind }>,
opts?: { redact?: boolean }
): string {
if (!entries.length) return "";
const redact = opts?.redact !== false;
const slice = entries.slice(0, 5);
const parts = slice.map((e) => {
const label = redact ? redactConnectionLabel(e.model) : e.model;
const kind = e.kind ? KIND_LABELS[e.kind] ?? e.kind : null;
const reason = e.error || `HTTP ${e.status}`;
const statusTxt = ` (HTTP ${e.status})`;
return kind ? `${label}: ${kind}${reason}${statusTxt}` : `${label}: ${reason}${statusTxt}`;
});
return entries.length > 5
? `${parts.join("; ")}... (+${entries.length - 5} more)`
: parts.join("; ");
}

View File

@@ -10,10 +10,6 @@
* 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. */
@@ -50,7 +46,7 @@ export function buildTargetTimeoutRunner(deps: {
"COMBO",
`Model ${modelStr} exceeded ${comboTargetTimeoutMs}ms timeout — falling back`
);
timeoutController.abort(new Error(COMBO_PER_MODEL_TIMEOUT_REASON));
timeoutController.abort(new Error("combo-per-model-timeout"));
// 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
@@ -79,10 +75,10 @@ export function buildTargetTimeoutRunner(deps: {
let onParentHedgeAbort: (() => void) | null = null;
if (parentHedgeSignal) {
if (parentHedgeSignal.aborted) {
timeoutController.abort(new Error(COMBO_HEDGE_CANCELLED_REASON));
timeoutController.abort(new Error("hedge-cancelled"));
} else {
onParentHedgeAbort = () => {
timeoutController.abort(new Error(COMBO_HEDGE_CANCELLED_REASON));
timeoutController.abort(new Error("hedge-cancelled"));
};
parentHedgeSignal.addEventListener("abort", onParentHedgeAbort, { once: true });
}

View File

@@ -37,7 +37,6 @@ 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). */
@@ -122,12 +121,7 @@ let _depsAvailable: boolean | null = null;
*/
export function depsAvailable(): boolean {
if (_depsAvailable !== null) return _depsAvailable;
// 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);
_depsAvailable = firstAncestorWith(runtimeAnchors(), GATE_DEP_REL) !== null;
return _depsAvailable;
}

View File

@@ -79,7 +79,6 @@ 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 = [
@@ -115,61 +114,6 @@ 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
@@ -298,24 +242,6 @@ 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,74 +54,3 @@ 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 {
isDiscoverableAntigravityModelId,
isUserCallableAntigravityModelId,
toClientAntigravityQuotaModelId,
} from "../../config/antigravityModelAliases.ts";
import { isUserCallableAgyModelId } from "../../config/agyModels.ts";
@@ -273,12 +273,15 @@ 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;
@@ -646,7 +649,7 @@ export async function getAntigravityUsage(
info.isInternal === true ||
!(provider === "agy"
? isUserCallableAgyModelId(modelKey)
: isDiscoverableAntigravityModelId(modelKey)) ||
: isUserCallableAntigravityModelId(modelKey)) ||
Object.keys(quotaInfo).length === 0
) {
continue;
@@ -699,7 +702,7 @@ export async function getAntigravityUsage(
quotas[modelKey] ||
!(provider === "agy"
? isUserCallableAgyModelId(modelKey)
: isDiscoverableAntigravityModelId(modelKey))
: isUserCallableAntigravityModelId(modelKey))
) {
continue;
}

View File

@@ -31,8 +31,6 @@
* 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.
@@ -52,89 +50,59 @@ 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 ->
// 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.
// 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.
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(
startupEvents.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`).join("")
[
{
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("")
);
// 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-
@@ -216,7 +184,8 @@ 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>,

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