Merge remote-tracking branch 'origin/release/v3.8.50' into fix/qdrant-health-badge

This commit is contained in:
Rouzbeh
2026-08-16 04:15:34 +00:00
194 changed files with 10499 additions and 1339 deletions

View File

@@ -714,6 +714,16 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Allow OmniRoute to write CLI config files (token refresh, etc.).
# CLI_ALLOW_CONFIG_WRITES=true
# Force container detection on (1/true) or off (0/false). Leave unset for auto-detect
# via /.dockerenv, /run/.containerenv, cgroup markers, or KUBERNETES_SERVICE_HOST.
# Used by: src/shared/utils/containerEnv.ts — gates ephemeral-home CLI config writes.
# OMNIROUTE_CONTAINER=1
# Allow CLI-tool config writes into an unmounted container path anyway (default off).
# Prefer host-side `omniroute configure` / Remote Mode, or a bind-mounted CLI_CONFIG_HOME.
# CLI equivalent: --allow-container-write. Used by: src/shared/utils/containerEnv.ts
# OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true
# Auto-sync CLI profile files after provider model discovery changes. OPT-IN, default OFF for
# both. When enabled, writes only the tool's profile files (~/.codex/*.config.toml or
# ~/.claude/profiles/<name>/settings.json); never changes the active/default config. Both also
@@ -735,6 +745,21 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# CLI_AUGGIE_BIN=auggie
# AUGGIE_BIN=auggie
# ── ZCode (Z.ai GLM coding-plan CLI) local provider ──
# The local "zcode" provider talks to the authenticated ZCode app-server over a
# custom framed stdio protocol. Overrides below tune that stdio lifecycle.
# ZCODE_BIN=zcode
# ZCODE_ARGS=["--some-flag"]
# ZCODE_CWD=
# ZCODE_PROVIDER_ID=builtin:zai-coding-plan
# ZCODE_SERVER_RUNTIME_ROOT=~/.zcode/server
# ZCODE_SERVER_NODE=~/.zcode/server/node
# ZCODE_SERVER_ENTRY=~/.zcode/server/zcode-server.cjs
# ZCODE_STARTUP_TIMEOUT_MS=10000
# ZCODE_RPC_TIMEOUT_MS=30000
# ZCODE_TURN_TIMEOUT_MS=120000
# ZCODE_POLL_INTERVAL_MS=250
# Override the Hermes Agent home directory (where OmniRoute reads/writes the
# Hermes CLI config). Matches the env var the Hermes PowerShell installer sets
# on Windows (%LOCALAPPDATA%\hermes); defaults to ~/.hermes when unset.

View File

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

View File

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

View File

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

View File

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

View File

@@ -137,7 +137,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- uses: ./.github/actions/npm-ci-retry
# One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently).
- run: npm run check:api-docs-refs
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
@@ -181,7 +181,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- uses: ./.github/actions/npm-ci-retry
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
@@ -430,7 +430,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- uses: ./.github/actions/npm-ci-retry
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR,
# which is where flaky-detection volume actually comes from (ci.yml's heavy
# jobs only run on the release PR). Advisory upload, own-origin only.
@@ -476,7 +476,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- uses: ./.github/actions/npm-ci-retry
# QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do
# comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes
# silenciosamente não rodavam no fast path) e o setupPolyfill não era importado.
@@ -516,7 +516,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- uses: ./.github/actions/npm-ci-retry
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
@@ -583,7 +583,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- uses: ./.github/actions/npm-ci-retry
- name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result)
run: npm run check:changelog-integrity
- name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,7 @@ const { resolveServerEntry } = require("./lib/resolveServerEntry");
const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper");
const { resolveRemoteServerUrl, isValidHttpUrl } = require("./lib/resolveRemoteServerUrl");
const { writeRemoteServerUrl } = require("./lib/remoteServerPreferences");
const { buildReadinessUrl, waitForServer } = require("./lib/serverReadiness");
// ── Single Instance Lock ───────────────────────────────────
const gotTheLock = app.requestSingleInstanceLock();
@@ -86,6 +87,7 @@ let remoteServerUrl = resolveRemoteServerUrl({
});
const getServerUrl = () => remoteServerUrl || `http://localhost:${serverPort}`;
const getServerReadinessUrl = () => buildReadinessUrl(getServerUrl());
function resolveNodeExecutable(env = process.env) {
// #1081: Ensure Next.js standalone runs using Electron's Node runtime
@@ -185,26 +187,6 @@ function sendToRenderer(channel, data) {
}
}
// ── Helper: Wait for server readiness (#1, #10) ────────────
// Default raised to 180s: the first launch after an upgrade can run long DB
// migrations, during which the server accepts the TCP connection but holds the
// HTTP response until handlers initialize. The previous 30s cap timed out and
// left the window stuck on a hanging connection (#2460).
async function waitForServer(url, timeoutMs = 180000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url);
if (res.ok || res.status < 500) return true;
} catch {
/* server not ready yet */
}
await new Promise((r) => setTimeout(r, 500));
}
console.warn("[Electron] Server readiness timeout — showing window anyway");
return false;
}
// ── Helper: Wait for server process exit with timeout (#2) ─
async function waitForServerExit(proc, timeoutMs = 5000) {
if (!proc) return;
@@ -533,7 +515,7 @@ async function changePort(newPort) {
// Start server on new port
startNextServer();
await waitForServer(getServerUrl());
await waitForServer(getServerReadinessUrl());
// Reload window and update tray
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -603,7 +585,7 @@ async function setRemoteServerUrl(nextUrl) {
startNextServer();
try {
await waitForServer(`${getServerUrl()}/api/monitoring/health`);
await waitForServer(getServerReadinessUrl());
} catch (err) {
console.warn("[Electron] Server did not become ready after remote-server change:", err.message);
}
@@ -935,7 +917,7 @@ function setupIpcHandlers() {
stopNextServer();
await waitForServerExit(serverToStop);
startNextServer();
await waitForServer(getServerUrl());
await waitForServer(getServerReadinessUrl());
return { success: true };
});
@@ -1078,8 +1060,8 @@ app.whenReady().then(async () => {
startNextServer();
let serverReady = true;
if (!isDev) {
// Probe the auth-exempt health endpoint (not the root URL, which may redirect).
serverReady = await waitForServer(`${getServerUrl()}/api/monitoring/health`);
// Probe the lightweight auth-exempt endpoint instead of aggregating full monitoring state.
serverReady = await waitForServer(getServerReadinessUrl());
}
if (isHeadless) {
@@ -1095,7 +1077,7 @@ app.whenReady().then(async () => {
// If readiness timed out (e.g. very long first-launch migrations), don't leave the
// window stuck on a hanging connection — keep polling and reload once it responds (#2460).
if (!isDev && !serverReady && !isHeadless) {
void waitForServer(`${getServerUrl()}/api/monitoring/health`, 300000).then((ready) => {
void waitForServer(getServerReadinessUrl(), 300000).then((ready) => {
if (ready && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.loadURL(getServerUrl());
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1881,7 +1881,6 @@ export function createSSEStream(options: StreamOptions = {}) {
passthroughSawFinishReason = true;
}
if (isFinishChunk && passthroughHasToolCalls) {
toolFinishTime = now;
try {
@@ -2220,7 +2219,8 @@ export function createSSEStream(options: StreamOptions = {}) {
},
pushProviderPayload: (payload: unknown) => providerPayloadCollector.push(payload),
pushClientPayload: (payload: unknown) => clientPayloadCollector.push(payload),
sanitizeUsagePayload: (payload: unknown) => sanitizeUsagePayloadForRequest(payload, body, clientResponseFormat),
sanitizeUsagePayload: (payload: unknown) =>
sanitizeUsagePayloadForRequest(payload, body, clientResponseFormat),
setPassthroughResponsesId: (value: string) => {
passthroughResponsesId = value;
},
@@ -2281,7 +2281,8 @@ export function createSSEStream(options: StreamOptions = {}) {
const bufferedPayload = parseSSELine(bufferedLine);
if (bufferedPayload) {
providerPayloadCollector.push(bufferedPayload);
if (sanitizeUsagePayloadForRequest(bufferedPayload, body, clientResponseFormat)) output = `data: ${JSON.stringify(bufferedPayload)}\n\n`;
if (sanitizeUsagePayloadForRequest(bufferedPayload, body, clientResponseFormat))
output = `data: ${JSON.stringify(bufferedPayload)}\n\n`;
if (
shouldInjectClaudeEmptyResponseBeforeCurrentEvent(
claudeEmptyResponseLifecycle,

View File

@@ -21,6 +21,15 @@ import { appendBoundedText, buildSyntheticChatChunk } from "./streamHelpers.ts";
const THINK_OPEN = "<think>";
const THINK_CLOSE = "</think>";
/**
* Every proper prefix of `<think>` ("<", "<t", ... "<think"), derived from the
* tag itself so the list cannot drift out of sync with it.
*/
const THINK_OPEN_PARTIALS: readonly string[] = Array.from(
{ length: THINK_OPEN.length - 1 },
(_, i) => THINK_OPEN.slice(0, i + 1)
);
/**
* Create the mutable streaming-parse context for one SSE stream.
* `enabled` decides whether the caller should attempt think-tag parsing at
@@ -52,10 +61,7 @@ export function initThinkState(isPassthroughMode: boolean, provider?: unknown, m
* @returns {boolean}
*/
export function containsOrMayEndWithThinkOpenTag(value: string): boolean {
return (
value.includes(THINK_OPEN) ||
["<", "<t", "<th", "<thi", "<thin"].some((suffix) => value.endsWith(suffix))
);
return value.includes(THINK_OPEN) || THINK_OPEN_PARTIALS.some((suffix) => value.endsWith(suffix));
}
/**

996
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -257,7 +257,7 @@
"alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs"
},
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1073.0",
"@aws-sdk/client-bedrock-runtime": "^3.1107.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -266,40 +266,40 @@
"@monaco-editor/react": "^4.7.0",
"@ngrok/ngrok": "^1.7.0",
"@swc/helpers": "0.5.23",
"@toon-format/toon": "^4.1.0",
"@toon-format/toon": "^4.1.1",
"@types/mdx": "^2.0.13",
"@xyflow/react": "^12.11.1",
"axios": "^1.16.1",
"axios": "^1.19.0",
"bcryptjs": "^3.0.3",
"bottleneck": "^2.19.5",
"clsx": "^2.1.1",
"commander": "^15.0.0",
"cron-parser": "^5.6.2",
"csv-stringify": "^6.7.0",
"cron-parser": "^5.8.1",
"csv-stringify": "^6.8.3",
"dompurify": "^3.4.13",
"express": "^5.2.1",
"fetch-socks": "^1.3.3",
"fflate": "^0.8.3",
"fumadocs-core": "^16.10.5",
"fumadocs-ui": "^16.10.5",
"fumadocs-core": "^16.14.3",
"fumadocs-ui": "^16.14.3",
"http-proxy-middleware": "^4.0.0",
"https-proxy-agent": "^9.0.0",
"ink": "^7.0.3",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"ioredis": "^5.10.1",
"jose": "^6.2.3",
"js-yaml": "^5.2.2",
"jose": "^6.2.8",
"js-yaml": "^5.2.3",
"jsonc-parser": "^3.3.1",
"lowdb": "^7.0.1",
"lucide-react": "^1.21.0",
"marked": "^18.0.4",
"marked": "^18.0.9",
"marked-terminal": "^7.3.0",
"material-symbols": "^0.45.2",
"material-symbols": "^0.45.10",
"mermaid": "^11.15.0",
"monaco-editor": "^0.56.0",
"next": "16.2.12",
"next-intl": "^4.12.0",
"next": "16.3.0",
"next-intl": "^4.13.6",
"next-themes": "^0.4.6",
"node-machine-id": "^1.1.12",
"omniglyph": "^1.0.2",
@@ -309,7 +309,7 @@
"pino": "^10.3.1",
"pino-abstract-transport": "^3.0.0",
"pino-pretty": "^13.1.3",
"playwright": "1.62.0",
"playwright": "1.62.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-is": "^19.2.6",
@@ -319,23 +319,23 @@
"safe-regex": "^2.1.1",
"selfsigned": "^5.5.0",
"sharp": "^0.35.3",
"smol-toml": "1.7.1",
"smol-toml": "1.7.2",
"socks": "^2.8.7",
"sql.js": "^1.14.1",
"tailwind-merge": "^3.6.0",
"tsx": "^4.23.0",
"turndown": "7.2.0",
"tsx": "^4.23.12",
"turndown": "7.2.4",
"turndown-plugin-gfm": "1.0.2",
"undici": "^8.10.0",
"update-notifier": "^7.3.1",
"uuid": "^14.0.0",
"ws": "^8.18.0",
"ws": "^8.21.3",
"xxhash-wasm": "^1.1.0",
"yazl": "^3.3.1",
"zod": "^4.4.3",
"zustand": "^5.0.13",
"@huggingface/transformers": "^4.2.0",
"onnxruntime-node": "~1.24.3"
"onnxruntime-node": "~1.27.0"
},
"optionalDependencies": {
"@atjsh/llmlingua-2": "2.0.3",
@@ -344,7 +344,7 @@
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",
"tls-client-node": "^0.2.0",
"wreq-js": "^2.3.1",
"wreq-js": "^3.0.0",
"sqlite-vec": "^0.1.9"
},
"devDependencies": {

View File

@@ -7,7 +7,7 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"playwright": "1.61.1"
"playwright": "1.62.1"
},
"devDependencies": {
"@types/node": "^22"

View File

@@ -0,0 +1,65 @@
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
import { join, relative, resolve, sep } from "node:path";
export const ELECTRON_RUNTIME_DOC_PRUNE_RULES = Object.freeze({
localeRootFiles: Object.freeze(["CHANGELOG.md"]),
authoringDirectories: Object.freeze(["docs/research", "docs/superpowers"]),
});
function payloadSize(targetPath) {
const stat = lstatSync(targetPath);
if (!stat.isDirectory()) {
return { files: 1, bytes: stat.size };
}
return readdirSync(targetPath).reduce(
(total, entry) => {
const payload = payloadSize(join(targetPath, entry));
total.files += payload.files;
total.bytes += payload.bytes;
return total;
},
{ files: 0, bytes: 0 }
);
}
function removePayload(bundleRoot, relativePath, summary) {
const root = resolve(bundleRoot);
const targetPath = resolve(root, relativePath);
if (targetPath !== root && !targetPath.startsWith(`${root}${sep}`)) {
throw new Error(`[electron-docs] refusing to prune outside bundle root: ${relativePath}`);
}
if (!existsSync(targetPath)) return;
const payload = payloadSize(targetPath);
rmSync(targetPath, { recursive: true, force: true });
summary.removedFiles += payload.files;
summary.removedBytes += payload.bytes;
summary.removedPaths.push(relative(root, targetPath).split(sep).join("/"));
}
/**
* Remove docs that are useful while authoring OmniRoute but are never read by
* the packaged desktop runtime. Canonical docs remain untouched; bundleRoot is
* the disposable Electron staging directory.
*/
export function pruneElectronRuntimeDocs(bundleRoot) {
const summary = { removedFiles: 0, removedBytes: 0, removedPaths: [] };
const localesRoot = join(bundleRoot, "docs", "i18n");
if (existsSync(localesRoot)) {
for (const locale of readdirSync(localesRoot, { withFileTypes: true })) {
if (!locale.isDirectory()) continue;
for (const fileName of ELECTRON_RUNTIME_DOC_PRUNE_RULES.localeRootFiles) {
removePayload(bundleRoot, join("docs", "i18n", locale.name, fileName), summary);
}
}
}
for (const relativePath of ELECTRON_RUNTIME_DOC_PRUNE_RULES.authoringDirectories) {
removePayload(bundleRoot, relativePath, summary);
}
summary.removedPaths.sort();
return summary;
}

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env node
/**
* Platform hydration for the shared Next standalone web build (issue #10321,
* Stage 8).
*
* The standalone bundle is built ONCE on ubuntu and restored on every desktop
* matrix leg. Everything except install-machine-forked optional packages is
* platform-independent:
*
* - Bundled-for-all (verify only): koffi ships every triplet under
* `build/koffi/<os>_<arch>`, better-sqlite3 v13 ships Node-API prebuilds for
* 8 platforms, wreq-js ships `rust/wreq-js.<plat>-<arch>[-libc].node`, and
* onnxruntime-node ships `bin/napi-v6/<os>/<arch>`.
* - Install-machine-forked (hydrate): `@img/sharp-*`, `@img/sharp-libvips-*`,
* `@ngrok/ngrok-*` and macOS-only `fsevents` resolve to whichever platform
* ran `npm ci`. The ubuntu-built tree carries the linux forks; each leg
* replaces them with the forks from its OWN `npm ci`d node_modules.
*/
import fs from "node:fs";
import path from "node:path";
/** Scope prefixes whose members are install-machine-forked. */
export const HYDRATED_SCOPES = ["@img/sharp-", "@img/sharp-libvips-", "@ngrok/ngrok-"];
/** Standalone packages that are not forked but must never be platform-forked. */
export const HYDRATED_ROOT_PACKAGES = ["fsevents"];
/**
* onnxruntime-node does not publish a darwin-x64 binary for napi-v6 (only
* linux/win32 x64 + darwin arm64), so existence cannot be asserted there.
*/
export const BUNDLED_EXEMPTIONS = new Set(["onnxruntime-node:darwin-x64"]);
function platformTriple(platform, arch) {
// koffi uses underscore triplets; better-sqlite3/wreq-js/onnx use dashes.
return { koffi: `${platform}_${arch}`, dash: `${platform}-${arch}` };
}
function rmrf(target) {
fs.rmSync(target, { recursive: true, force: true });
}
function copyDir(from, to) {
fs.cpSync(from, to, { recursive: true, verbatimSymlinks: false, force: true });
}
function directMemberNames(nodeModulesDir, scope) {
const scopeDir = path.join(nodeModulesDir, ...scope.split("/").slice(0, -1));
const prefix = scope.split("/").pop();
try {
return fs
.readdirSync(scopeDir)
.filter((name) => name.startsWith(prefix))
.map((name) => `${scope.slice(0, scope.lastIndexOf("/"))}/${name}`);
} catch {
return [];
}
}
/**
* Replace install-machine-forked packages inside the restored standalone tree
* with the forks resolved by THIS machine's node_modules.
*
* @param {{standaloneNodeModules: string, sourceNodeModules: string}} opts
* @returns {{replaced: string[], removed: string[], copied: string[]}}
*/
export function hydratePlatformNatives({ standaloneNodeModules, sourceNodeModules }) {
const replaced = [];
const removed = [];
const copied = [];
const forkedNames = new Set();
for (const scope of HYDRATED_SCOPES) {
for (const name of directMemberNames(sourceNodeModules, scope)) forkedNames.add(name);
for (const name of directMemberNames(standaloneNodeModules, scope)) forkedNames.add(name);
}
for (const pkg of HYDRATED_ROOT_PACKAGES) {
if (fs.existsSync(path.join(sourceNodeModules, pkg))) forkedNames.add(pkg);
if (fs.existsSync(path.join(standaloneNodeModules, pkg))) forkedNames.add(pkg);
}
for (const name of forkedNames) {
const standalonePath = path.join(standaloneNodeModules, ...name.split("/"));
const sourcePath = path.join(sourceNodeModules, ...name.split("/"));
const hadIt = fs.existsSync(standalonePath);
const hasIt = fs.existsSync(sourcePath);
if (hadIt) rmrf(standalonePath);
if (!hasIt) {
if (hadIt) removed.push(name);
continue; // e.g. fsevents on non-darwin legs: simply absent everywhere.
}
copyDir(sourcePath, standalonePath);
copied.push(name);
if (hadIt) replaced.push(name);
}
return { replaced, removed, copied };
}
/**
* Assert that every bundled native dependency can service `platform`/`arch`.
*
* @returns {{ok: true} | {ok: false, errors: string[]}}
*/
export function verifyBundledNatives({ nodeModulesDir, platform, arch }) {
const errors = [];
const triple = platformTriple(platform, arch);
const koffiDir = path.join(nodeModulesDir, "koffi", "build", "koffi", triple.koffi);
if (!fs.existsSync(koffiDir)) errors.push(`koffi: missing bundled triplet ${triple.koffi}`);
const sqlitePrebuild = path.join(
nodeModulesDir,
"better-sqlite3",
"prebuilds",
`${triple.dash}.node`
);
if (!fs.existsSync(sqlitePrebuild))
errors.push(`better-sqlite3: missing prebuild ${triple.dash}.node`);
const wreqDir = path.join(nodeModulesDir, "wreq-js", "rust");
const wreqNames = fs.existsSync(wreqDir)
? fs
.readdirSync(wreqDir)
.filter((n) => n.startsWith(`wreq-js.${triple.dash}`) && n.endsWith(".node"))
: [];
if (wreqNames.length === 0) errors.push(`wreq-js: missing rust binary for ${triple.dash}`);
const exempt = BUNDLED_EXEMPTIONS.has(`onnxruntime-node:${triple.dash}`);
if (!exempt) {
const onnxDir = path.join(nodeModulesDir, "onnxruntime-node", "bin", "napi-v6", platform, arch);
if (!fs.existsSync(onnxDir))
errors.push(`onnxruntime-node: missing ${platform}/${arch} binary`);
}
return errors.length === 0 ? { ok: true } : { ok: false, errors };
}

View File

@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { assembleStandalone } from "./assembleStandalone.mjs";
import { buildRebuildSpawnPlan } from "./electronRebuildPlan.mjs";
import { pruneElectronRuntimeDocs } from "./electronRuntimeDocs.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -205,6 +206,14 @@ assembleStandalone({
materializeSymlinks: true,
});
const docsPrune = pruneElectronRuntimeDocs(ELECTRON_STANDALONE_DIR);
if (docsPrune.removedFiles > 0) {
console.log(
`[electron] pruned ${docsPrune.removedFiles} authoring doc file(s) ` +
`(${docsPrune.removedBytes} bytes) from the staging bundle`
);
}
// Electron-UNIQUE post-assembly steps
removeGeneratedElectronArtifacts();

View File

@@ -471,24 +471,66 @@ if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package
// needs the plugin's own devDependencies (typescript, @opencode-ai/plugin
// types). Without this install a fresh CI publish fails at this step.
if (!existsSync(join(opencodePluginSrc, "node_modules"))) {
// The plugin's node_modules is gitignored, so a fresh CI checkout
// ALWAYS installs here. The registry CDN is intermittently flaky
// (onnxruntime-class ETIMEDOUTs to the Microsoft CDN have repeatedly
// stalled CI npm steps for 20+ minutes), and npm's unbounded fetch
// retries turn a stalled connection into a hang that eats the whole
// job budget. Bound the fetch and retry the install a few times:
// transient network failures fail fast and recover instead of hanging.
const npmEntry = resolveBundledNpmEntry("npm-cli.js");
if (npmEntry) {
execFileSync(process.execPath, [npmEntry, "install", "--no-audit", "--no-fund"], {
cwd: opencodePluginSrc,
stdio: "inherit",
});
} else if (process.platform !== "win32") {
// No bundled npm entry found (non-standard Node layout). Plain `npm` is
// safe here — the .cmd-shim hazard #8858 guards against is Windows-only.
execFileSync("npm", ["install", "--no-audit", "--no-fund"], {
cwd: opencodePluginSrc,
stdio: "inherit",
});
} else {
throw new Error(
"npm-cli.js not found next to the running Node binary; cannot install the plugin dependencies without falling back to a .cmd shim."
);
const installArgs = [
"install",
"--no-audit",
"--no-fund",
"--fetch-retries=2",
"--fetch-retry-mintimeout=2000",
"--fetch-retry-maxtimeout=30000",
"--fetch-timeout=60000",
];
const runPluginInstall = () => {
if (npmEntry) {
execFileSync(process.execPath, [npmEntry, ...installArgs], {
cwd: opencodePluginSrc,
stdio: "inherit",
});
} else if (process.platform !== "win32") {
// No bundled npm entry found (non-standard Node layout). Plain `npm` is
// safe here — the .cmd-shim hazard #8858 guards against is Windows-only.
execFileSync("npm", installArgs, {
cwd: opencodePluginSrc,
stdio: "inherit",
});
} else {
throw new Error(
"npm-cli.js not found next to the running Node binary; cannot install the plugin dependencies without falling back to a .cmd shim."
);
}
};
const sleepSync = (ms: number) =>
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
let installError: any = null;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
if (attempt > 1) {
console.log(
` 🔄 @omniroute/opencode-plugin npm install retry (attempt ${attempt}/3)`
);
}
runPluginInstall();
installError = null;
break;
} catch (err: any) {
installError = err;
if (attempt < 3) {
console.warn(
` ⚠️ plugin npm install failed (attempt ${attempt}/3): ${err?.message ?? String(err)} — retrying in 10s`
);
sleepSync(10_000);
}
}
}
if (installError) throw installError;
}
runBuildTool("tsup", "tsup", [], {
cwd: opencodePluginSrc,

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env node
/**
* CLI entry for the shared Next standalone web build (issue #10321, Stage 8).
*
* One ubuntu `web-build` job runs `pack` once; every desktop matrix leg runs
* `restore` (byte-verified against the manifest) and `hydrate` (replaces
* install-machine-forked native optionals with this leg's own `npm ci` forks,
* then asserts the bundled natives can service the leg's platform/arch).
*
* Rollback: set repo variable ELECTRON_SHARED_STANDALONE=disabled and the
* workflow falls back to the legacy per-leg `npm run build` — no revert needed.
*/
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import {
buildStandaloneManifest,
verifyStandaloneManifest,
MANIFEST_VERSION,
} from "./standaloneManifest.mjs";
import { createTarGz, extractTarGz } from "./standaloneTarball.mjs";
import { hydratePlatformNatives, verifyBundledNatives } from "./hydrateNativeDeps.mjs";
function sha256File(filePath) {
return new Promise((resolve, reject) => {
const hash = createHash("sha256");
const stream = createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex")));
});
}
function manifestPathFor(archive) {
return `${archive}.manifest.json`;
}
/**
* Pack a web-build tree into a deterministic archive plus a byte-level
* manifest (which embeds the archive's own sha256 so transfer corruption is
* caught before extraction).
*
* @param {{dir?: string, out: string, manifest?: string}} opts
* @returns {Promise<{archive: string, manifest: string, files: number, archiveBytes: number}>}
*/
export async function runPack({ dir = ".build/next", out, manifest }) {
if (!out) throw new Error("pack requires --out <file.tar.gz>");
const rootDir = path.resolve(dir);
if (!fs.existsSync(rootDir)) {
throw new Error(`web build tree not found: ${rootDir} (did 'npm run build' run?)`);
}
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
const built = await buildStandaloneManifest(rootDir);
await createTarGz(rootDir, out);
const archiveBytes = fs.statSync(out).size;
const archiveSha = await sha256File(out);
const manifestFile = manifest ?? manifestPathFor(out);
const payload = {
version: MANIFEST_VERSION,
archive: { name: path.basename(out), bytes: archiveBytes, sha256: archiveSha },
entries: built.entries,
};
fs.writeFileSync(manifestFile, `${JSON.stringify(payload, null, 2)}\n`);
return { archive: out, manifest: manifestFile, files: built.entries.length, archiveBytes };
}
/**
* Verify + extract a packed archive into `dir`, then prove the restored tree
* matches the manifest byte-for-byte.
*
* @param {{archive: string, manifest?: string, dir?: string}} opts
* @returns {Promise<{archive: string, dir: string, files: number}>}
*/
export async function runRestore({ archive, manifest, dir = ".build/next" }) {
if (!archive) throw new Error("restore requires --archive <file.tar.gz>");
const manifestFile = manifest ?? manifestPathFor(archive);
const raw = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
if (raw.version !== MANIFEST_VERSION) {
throw new Error(`unsupported manifest version: ${raw.version}`);
}
const archiveBytes = fs.statSync(archive).size;
if (archiveBytes !== raw.archive.bytes) {
throw new Error(`archive size ${archiveBytes} != manifest ${raw.archive.bytes}`);
}
const archiveSha = await sha256File(archive);
if (archiveSha !== raw.archive.sha256) {
throw new Error(`archive sha256 mismatch (expected ${raw.archive.sha256.slice(0, 12)})`);
}
const destDir = path.resolve(dir);
fs.rmSync(destDir, { recursive: true, force: true });
await extractTarGz(archive, destDir);
const verdict = await verifyStandaloneManifest(destDir, raw);
if (!verdict.ok) {
throw new Error(
`restored tree failed manifest verification:\n ${verdict.errors.join("\n ")}`
);
}
return { archive, dir: destDir, files: raw.entries.length };
}
/**
* Hydrate the restored tree's node_modules with this machine's forked
* optionals and assert bundled natives cover every requested arch.
*
* @param {{standaloneNodeModules?: string, sourceNodeModules?: string, platform: string, arch: string}} opts
* `arch` accepts a comma-separated list (the linux leg ships x64+arm64).
* @returns {Promise<{replaced: string[], removed: string[], copied: string[], verified: string[]}>}
*/
export async function runHydrate({
standaloneNodeModules = ".build/next/standalone/node_modules",
sourceNodeModules = "node_modules",
platform,
arch,
}) {
if (!platform || !arch) throw new Error("hydrate requires --platform <os> --arch <a[,a2...]>");
const result = hydratePlatformNatives({
standaloneNodeModules: path.resolve(standaloneNodeModules),
sourceNodeModules: path.resolve(sourceNodeModules),
});
const verified = [];
for (const one of arch
.split(",")
.map((s) => s.trim())
.filter(Boolean)) {
const verdict = verifyBundledNatives({
nodeModulesDir: path.resolve(standaloneNodeModules),
platform,
arch: one,
});
if (!verdict.ok) {
throw new Error(
`bundled natives cannot service ${platform}/${one}:\n ${verdict.errors.join("\n ")}`
);
}
verified.push(one);
}
return { ...result, verified };
}
// ─── argv plumbing ───────────────────────────────────────────────────────────────
/** Minimal `--key value` parser (booleans: `--key` alone → true). */
export function parseArgs(argv) {
const opts = { _: [] };
for (let i = 0; i < argv.length; i++) {
const token = argv[i];
if (!token.startsWith("--")) {
opts._.push(token);
continue;
}
const key = token.slice(2);
const next = argv[i + 1];
if (next !== undefined && !next.startsWith("--")) {
opts[key] = next;
i++;
} else {
opts[key] = true;
}
}
return opts;
}
function usage() {
return [
"usage:",
" standaloneBundle.mjs pack --out <file.tar.gz> [--dir .build/next] [--manifest <file.json>]",
" standaloneBundle.mjs restore --archive <file.tar.gz> [--manifest <file.json>] [--dir .build/next]",
" standaloneBundle.mjs hydrate --platform <os> --arch <a[,a2...]>",
" [--standalone-node-modules <dir>] [--source-node-modules <dir>]",
].join("\n");
}
async function main(argv) {
const [command = "", ...rest] = argv;
const opts = parseArgs(rest);
try {
if (command === "pack") {
const r = await runPack({ dir: opts.dir, out: opts.out, manifest: opts.manifest });
console.log(
`[standalone-bundle] packed ${r.files} entries -> ${r.archive} ` +
`(${(r.archiveBytes / 1e6).toFixed(1)} MB); manifest ${r.manifest}`
);
} else if (command === "restore") {
const r = await runRestore({ archive: opts.archive, manifest: opts.manifest, dir: opts.dir });
console.log(
`[standalone-bundle] restored ${r.files} entries from ${path.basename(r.archive)} -> ${r.dir}`
);
} else if (command === "hydrate") {
const r = await runHydrate({
standaloneNodeModules: opts["standalone-node-modules"],
sourceNodeModules: opts["source-node-modules"],
platform: opts.platform,
arch: opts.arch,
});
console.log(
`[standalone-bundle] hydrated forks: copied=${r.copied.length} replaced=${r.replaced.length} ` +
`removed=${r.removed.length}; bundled natives verified for ${r.verified.join("+")}`
);
} else {
console.error(usage());
process.exitCode = 2;
}
} catch (err) {
console.error(`[standalone-bundle] ${command || "(no command)"} failed: ${err.message}`);
process.exitCode = 1;
}
}
if (
process.argv[1] &&
import.meta.url === new URL(`file://${path.resolve(process.argv[1])}`).href
) {
await main(process.argv.slice(2));
}

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* Byte-level manifest for the shared Next standalone web build (issue #10321,
* Stage 8).
*
* The desktop pipeline used to rebuild the identical Next standalone bundle
* four times (one per electron-release matrix leg). Stage 8 builds it once on
* an ubuntu runner and restores it on every leg; this module is the integrity
* contract that makes a restored tree provably identical to the built one.
*
* Deterministic by construction: entries are sorted by path, timestamps are
* never recorded, and symlinks are pinned by their target so a restored tree
* verifies even though tar extraction rewrites mtimes.
*/
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import fs from "node:fs";
import path from "node:path";
export const MANIFEST_VERSION = 1;
/** Streamed sha256 for large native payloads (onnxruntime is ~200 MB). */
async function sha256File(filePath) {
return new Promise((resolve, reject) => {
const hash = createHash("sha256");
const stream = createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex")));
});
}
function walkDir(root, current, entries) {
const children = fs.readdirSync(current, { withFileTypes: true });
// Sort for determinism: manifest of the same tree is byte-identical.
children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const child of children) {
const abs = path.join(current, child.name);
const rel = path.relative(root, abs).split(path.sep).join("/");
if (child.isSymbolicLink()) {
entries.push({ path: rel, symlink: fs.readlinkSync(abs) });
} else if (child.isDirectory()) {
walkDir(root, abs, entries);
} else if (child.isFile()) {
entries.push({ path: rel, file: abs });
}
// Other node types (fifo/socket) never appear in build output; ignoring
// them keeps the manifest shape minimal.
}
}
/**
* Build a manifest of every file and symlink under `rootDir`.
*
* @returns {Promise<{version: number, entries: {path: string, bytes: number, sha256: string, symlink?: string}[]}>}
*/
export async function buildStandaloneManifest(rootDir) {
const entries = [];
walkDir(rootDir, rootDir, entries);
const manifestEntries = [];
for (const entry of entries) {
if (entry.symlink !== undefined) {
manifestEntries.push({ path: entry.path, bytes: 0, sha256: "", symlink: entry.symlink });
continue;
}
const stat = fs.statSync(entry.file);
manifestEntries.push({
path: entry.path,
bytes: stat.size,
sha256: await sha256File(entry.file),
});
}
manifestEntries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
return { version: MANIFEST_VERSION, entries: manifestEntries };
}
/**
* Verify a restored tree against a manifest built by `buildStandaloneManifest`.
* Checks existence, size, and content hash of every entry, plus that no
* unlisted files were smuggled in.
*
* @returns {Promise<{ok: true} | {ok: false, errors: string[]}>}
*/
export async function verifyStandaloneManifest(rootDir, manifest) {
const errors = [];
if (!manifest || manifest.version !== MANIFEST_VERSION) {
return { ok: false, errors: [`unsupported manifest version: ${manifest?.version}`] };
}
const listed = new Map(manifest.entries.map((e) => [e.path, e]));
for (const entry of manifest.entries) {
const abs = path.join(rootDir, ...entry.path.split("/"));
let stat;
try {
stat = fs.lstatSync(abs);
} catch {
errors.push(`${entry.path}: missing`);
continue;
}
if (entry.symlink !== undefined) {
if (!stat.isSymbolicLink()) {
errors.push(`${entry.path}: expected symlink, found regular entry`);
} else {
const target = fs.readlinkSync(abs);
if (target !== entry.symlink) {
errors.push(`${entry.path}: symlink target ${target} != ${entry.symlink}`);
}
}
continue;
}
if (!stat.isFile()) {
errors.push(`${entry.path}: expected file, found directory/symlink`);
continue;
}
if (stat.size !== entry.bytes) {
errors.push(`${entry.path}: size ${stat.size} != ${entry.bytes}`);
continue;
}
const digest = await sha256File(abs);
if (digest !== entry.sha256) {
errors.push(`${entry.path}: sha256 mismatch`);
}
}
const actual = [];
walkDir(rootDir, rootDir, actual);
const actualPaths = new Set(actual.map((e) => e.path));
for (const p of listed.keys()) actualPaths.delete(p);
if (actualPaths.size > 0) {
errors.push(`unlisted files: ${[...actualPaths].sort().slice(0, 5).join(", ")}`);
}
return errors.length === 0 ? { ok: true } : { ok: false, errors };
}

View File

@@ -0,0 +1,381 @@
#!/usr/bin/env node
/**
* Deterministic tar.gz primitives for the shared web build (issue #10321,
* Stage 8).
*
* Why not shell out to system tar: the restore step runs on every desktop
* matrix leg including Windows, where bsdtar's long-path behavior on deep
* node_modules trees is not guaranteed. Node's fs layer already proves it can
* produce and consume this exact tree on Windows today (the legacy per-leg
* `npm run build` writes it with the same fs), so a pure-Node reader keeps the
* extraction on the one path layer we know works.
*
* Format: ustar with GNU LongLink ('L') entries for paths > 100 chars,
* typeflag '2' for symlinks, mtime/uid/gid zeroed and modes normalized to
* 0644/0755 (exec bit only) so the archive of a given tree is byte-identical
* on every machine.
*/
import { createReadStream, createWriteStream } from "node:fs";
import fs from "node:fs";
import path from "node:path";
import { once } from "node:events";
import { createGunzip, createGzip } from "node:zlib";
const BLOCK = 512;
function octal(value, length) {
return value.toString(8).padStart(length - 1, "0") + "\0";
}
function headerFor(name, size, typeflag, linkname = "", prefix = "", mode = 0o644) {
const buf = Buffer.alloc(BLOCK, 0);
buf.write(name.slice(0, 100), 0, 100, "utf8");
buf.write(octal(typeflag === "5" ? 0o755 : mode, 8), 100);
buf.write(octal(0, 8), 108); // uid
buf.write(octal(0, 8), 116); // gid
buf.write(octal(size, 12), 124);
buf.write(octal(0, 12), 136); // mtime = 0 for determinism
buf.write(" ", 148); // checksum placeholder: spaces
buf.write(typeflag, 156);
buf.write(linkname.slice(0, 100), 157, 100, "utf8");
buf.write("ustar\0", 257, 6, "utf8");
buf.write("00", 263, 2, "utf8");
buf.write(prefix.slice(0, 155), 345, 155, "utf8");
let sum = 0;
for (const byte of buf) sum += byte;
buf.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
return buf;
}
function dataPad(size) {
const pad = (BLOCK - (size % BLOCK)) % BLOCK;
return Buffer.alloc(pad, 0);
}
function longLinkEntry(name) {
const payload = Buffer.from(name + "\0", "utf8");
return Buffer.concat([
headerFor("././@LongLink", payload.length, "L"),
payload,
dataPad(payload.length),
]);
}
/** Emit header (with LongLink/prefix handling) for one entry. */
function entryHeader(relPath, size, typeflag, linkname, mode) {
const out = [];
if (relPath.length > 100) {
const slash = relPath.slice(0, 155).lastIndexOf("/");
const prefix = slash > 0 ? relPath.slice(0, slash) : "";
const name = prefix ? relPath.slice(slash + 1) : relPath;
if (name.length > 100) {
out.push(longLinkEntry(relPath));
name = relPath.slice(0, 100);
}
out.push(headerFor(name, size, typeflag, linkname, prefix, mode));
} else {
out.push(headerFor(relPath, size, typeflag, linkname, undefined, mode));
}
return Buffer.concat(out);
}
function* walkFiles(root, current = root) {
const children = fs
.readdirSync(current, { withFileTypes: true })
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const child of children) {
const abs = path.join(current, child.name);
const rel = path.relative(root, abs).split(path.sep).join("/");
if (child.isSymbolicLink()) {
yield { rel, symlink: fs.readlinkSync(abs) };
} else if (child.isDirectory()) {
yield* walkFiles(root, abs);
} else if (child.isFile()) {
yield { rel, abs };
}
}
}
/** Write a buffer, respecting gzip backpressure. */
async function writeWithBackpressure(stream, buf) {
if (!stream.write(buf)) await once(stream, "drain");
}
/** Stream one file's bytes into the archive (no whole-file buffering). */
function pipeFileInto(gz, failure, abs) {
return new Promise((resolve, reject) => {
const stream = createReadStream(abs, { autoClose: true });
const onDrain = () => stream.resume();
const detach = () => gz.removeListener("drain", onDrain);
stream.on("error", (err) => {
detach();
reject(err);
});
stream.on("data", (chunk) => {
if (!gz.write(chunk)) stream.pause();
});
gz.on("drain", onDrain);
stream.on("end", () => {
detach();
resolve();
});
});
}
/** Pack `srcDir` into a deterministic gzipped tarball at `outFile`. */
export async function createTarGz(srcDir, outFile) {
const out = createWriteStream(outFile);
const gz = createGzip({ level: 1 });
gz.pipe(out);
const failure = new Promise((_, reject) => {
gz.on("error", reject);
out.on("error", reject);
});
try {
for (const entry of walkFiles(srcDir)) {
if (entry.symlink !== undefined) {
if (entry.symlink.length > 100) {
throw new Error(`symlink target too long for ustar: ${entry.rel} -> ${entry.symlink}`);
}
await writeWithBackpressure(gz, entryHeader(entry.rel, 0, "2", entry.symlink));
continue;
}
const st = fs.statSync(entry.abs);
const size = st.size;
const mode = st.mode & 0o111 ? 0o755 : 0o644;
await writeWithBackpressure(gz, entryHeader(entry.rel, size, "0", undefined, mode));
if (size > 0) await Promise.race([pipeFileInto(gz, failure, entry.abs), failure]);
const pad = (BLOCK - (size % BLOCK)) % BLOCK;
if (pad > 0) await writeWithBackpressure(gz, Buffer.alloc(pad, 0));
}
await writeWithBackpressure(gz, Buffer.alloc(BLOCK * 2, 0)); // terminator
await Promise.race([
new Promise((resolve, reject) => {
out.on("finish", resolve);
out.on("error", reject);
gz.end();
}),
failure,
]);
} catch (err) {
gz.destroy();
out.destroy();
throw err;
}
}
// ─── extraction ──────────────────────────────────────────────────────────────────
/**
* Promise-based byte source over a gunzip stream. `read(n)` waits until `n`
* bytes are buffered (or EOF); `readSome()` returns whatever is available, for
* streaming large payloads into files without whole-file buffering.
*/
class BlockSource {
constructor(stream) {
this.buffer = Buffer.alloc(0);
this.error = null;
this.ended = false;
this.waiter = null;
stream.on("data", (chunk) => {
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
this.notify();
});
stream.on("end", () => {
this.ended = true;
this.notify();
});
stream.on("error", (err) => {
this.error = err;
this.notify();
});
}
notify() {
if (this.waiter) {
const waiter = this.waiter;
this.waiter = null;
waiter();
}
}
readSome() {
return new Promise((resolve, reject) => {
const attempt = () => {
if (this.error) return reject(this.error);
if (this.buffer.length > 0) {
const out = this.buffer;
this.buffer = Buffer.alloc(0);
return resolve(out);
}
if (this.ended) return resolve(null);
this.waiter = attempt;
};
attempt();
});
}
unshift(buf) {
if (buf && buf.length > 0) this.buffer = Buffer.concat([buf, this.buffer]);
}
async read(n) {
let acc = null;
let remaining = n;
while (remaining > 0) {
const chunk = await this.readSome();
if (chunk === null) return null; // EOF before n bytes
if (chunk.length > remaining) {
acc = acc
? Buffer.concat([acc, chunk.subarray(0, remaining)])
: chunk.subarray(0, remaining);
this.unshift(chunk.subarray(remaining));
remaining = 0;
} else {
acc = acc ? Buffer.concat([acc, chunk]) : chunk;
remaining -= chunk.length;
}
}
return acc ?? Buffer.alloc(0);
}
}
function parseOctal(header, offset, length) {
const raw = header.toString("utf8", offset, offset + length).replace(/[\0 ]+$/, "");
return raw.length === 0 ? 0 : Number.parseInt(raw, 8);
}
function cstring(header, offset, length) {
const raw = header.toString("utf8", offset, offset + length);
const nul = raw.indexOf("\0");
return nul === -1 ? raw : raw.slice(0, nul);
}
function checksumMatches(header) {
const stored = parseOctal(header, 148, 8);
const probe = Buffer.from(header);
probe.fill(" ", 148, 156); // checksum field counts as spaces while summing
let sum = 0;
for (const byte of probe) sum += byte;
return sum === stored;
}
/** Stream exactly `size` bytes from the reader into `outStream`. */
async function copyN(reader, size, outStream) {
let remaining = size;
while (remaining > 0) {
const chunk = await reader.readSome();
if (chunk === null) {
throw new Error(`unexpected EOF after ${size - remaining} of ${size} bytes`);
}
const take = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
if (chunk.length > remaining) reader.unshift(chunk.subarray(remaining));
remaining -= take.length;
if (!outStream.write(take)) await once(outStream, "drain");
}
}
/**
* Extract a tarball written by `createTarGz` (ustar + GNU LongLink) into
* `destDir`. Returns the number of entries written.
*/
export async function extractTarGz(archiveFile, destDir) {
fs.mkdirSync(destDir, { recursive: true });
const src = createReadStream(archiveFile);
const gunzip = createGunzip();
src.pipe(gunzip);
const reader = new BlockSource(gunzip);
const zeros = Buffer.alloc(BLOCK);
let longName = null;
let longLink = null;
let entries = 0;
for (;;) {
const header = await reader.read(BLOCK);
if (header === null) break; // tolerate archives missing the final zero blocks
if (header.equals(zeros)) {
const second = await reader.read(BLOCK);
if (second !== null && !second.equals(zeros)) {
throw new Error("corrupt archive: data after terminator block");
}
break;
}
if (!checksumMatches(header)) {
throw new Error(`tar header checksum mismatch at entry #${entries + 1}`);
}
let name = cstring(header, 0, 100);
const size = parseOctal(header, 124, 12);
const typeflag = String.fromCharCode(header[156] || 0x30);
let linkname = cstring(header, 157, 100);
const prefix = cstring(header, 345, 155);
if (prefix) name = `${prefix}/${name}`;
if (longName !== null) {
name = longName;
longName = null;
}
if (longLink !== null) {
linkname = longLink;
longLink = null;
}
const pad = (BLOCK - (size % BLOCK)) % BLOCK;
if (typeflag === "L" || typeflag === "K") {
const payload = await reader.read(size);
if (payload === null) throw new Error("unexpected EOF in LongLink payload");
const value = cstring(payload, 0, payload.length);
if (typeflag === "L") longName = value;
else longLink = value;
if (pad > 0) await reader.read(pad);
continue;
}
const target = safeJoin(destDir, name);
if (typeflag === "5") {
fs.mkdirSync(target, { recursive: true });
} else if (typeflag === "2") {
if (linkname.length === 0) throw new Error(`symlink entry ${name} has empty target`);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.rmSync(target, { force: true });
fs.symlinkSync(linkname, target);
} else if (typeflag === "1") {
const sourceAbs = safeJoin(destDir, linkname);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(sourceAbs, target);
} else {
// Regular file ("0" or "\0"). The packer never stores directory entries,
// so parent directories are materialized here.
fs.mkdirSync(path.dirname(target), { recursive: true });
const sink = createWriteStream(target, { flags: "w" });
const finished = once(sink, "finish");
sink.on("error", (err) => gunzip.destroy(err));
await copyN(reader, size, sink);
sink.end();
await finished;
const storedMode = parseOctal(header, 100, 8);
if (storedMode) fs.chmodSync(target, storedMode);
}
if (pad > 0) {
const skip = await reader.read(pad);
if (skip === null) throw new Error(`unexpected EOF in padding of ${name}`);
}
entries += 1;
}
src.destroy();
return { entries };
}
function safeJoin(destDir, name) {
const normalized = path.normalize(name).split(path.sep).join("/");
if (normalized.startsWith("/") || normalized.split("/").includes("..")) {
throw new Error(`unsafe tar entry path: ${name}`);
}
return path.join(destDir, ...normalized.split("/"));
}

View File

@@ -2,7 +2,10 @@
/**
* Docker healthcheck script for OmniRoute.
* Probes the /api/monitoring/health endpoint on the dashboard port.
* Probes the lightweight /healthz endpoint on the dashboard port.
* /api/monitoring/health is the deep human/dashboard check (SQLite ping);
* using it as Docker HEALTHCHECK marks the container Unhealthy whenever the
* event loop is busy (#10052) and can restart the only replica mid-session.
* Used by Dockerfile and docker-compose files.
*
* #3151 — in some Docker network setups the server binds to a container IP and
@@ -21,7 +24,7 @@ import { networkInterfaces } from "node:os";
const DEFAULT_HOSTS = ["127.0.0.1", "localhost", "::1"];
const DEFAULT_TIMEOUT_MS = 4000;
const DEFAULT_HEALTH_PATH = "/api/monitoring/health";
const DEFAULT_HEALTH_PATH = "/healthz";
function normalizeBasePath(value) {
const trimmed = typeof value === "string" ? value.trim() : "";

View File

@@ -104,6 +104,7 @@ Write config for a tool
- `--model <model>`
- `--non-interactive`
- `--yes`
- `--allow-container-write`
**Example:**
@@ -135,6 +136,7 @@ Generate OpenCode config (alias for
- `--model <model>`
- `--non-interactive`
- `--yes`
- `--allow-container-write`
**Example:**

View File

@@ -104,6 +104,7 @@ Write config for a tool
- `--model <model>`
- `--non-interactive`
- `--yes`
- `--allow-container-write`
**Example:**
@@ -135,6 +136,7 @@ Generate OpenCode config (alias for
- `--model <model>`
- `--non-interactive`
- `--yes`
- `--allow-container-write`
**Example:**

View File

@@ -303,6 +303,8 @@ export default function DefaultToolCard({
text:
(typeof data.error === "string" ? data.error : data.error?.message) ||
t("failedToSave"),
// 422 from the container guard: the body is host-CLI guidance, not a failure.
containerEphemeralTarget: Boolean(data.containerEphemeralTarget),
});
}
} catch (error) {
@@ -588,12 +590,16 @@ export default function DefaultToolCard({
<div className="mt-2">
{message && (
<div
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs mb-2 ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}
className={`flex gap-2 px-2 py-1.5 rounded text-xs mb-2 ${message.containerEphemeralTarget ? "items-start" : "items-center"} ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}
>
<span className="material-symbols-outlined text-[14px]">
{message.type === "success" ? "check_circle" : "error"}
</span>
<span>{message.text}</span>
{/* The container refusal is a multi-line runbook — keep its line
breaks instead of collapsing it into one unreadable line. */}
<span className={message.containerEphemeralTarget ? "whitespace-pre-line" : ""}>
{message.text}
</span>
</div>
)}
<div className="flex items-center gap-2">

View File

@@ -31,7 +31,11 @@ import { normalizeModelCatalogSource } from "@/shared/utils/modelCatalogSearch";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { useNotificationStore } from "@/store/notificationStore";
import { resolveDashboardProviderInfo, resolveProviderHeaderLink } from "../providerPageUtils";
import {
resolveDashboardProviderInfo,
resolveProviderHeaderLink,
resolveProviderOAuthBackendId,
} from "../providerPageUtils";
import { findDefaultReferral } from "@/lib/radar/referrals";
import { type ConnectionRowConnection } from "./components/ConnectionRow";
import { useProviderConnections } from "./hooks/useProviderConnections";
@@ -254,8 +258,11 @@ export default function ProviderDetailPageClient() {
providerInfo?.website,
referralUrl
);
const oauthProviderId = resolveProviderOAuthBackendId(providerId, providerInfo);
const providerSupportsOAuth =
providerInfo?.toggleAuthType === "oauth" || providerInfo?.toggleAuthType === "free";
providerInfo?.toggleAuthType === "oauth" ||
providerInfo?.toggleAuthType === "free" ||
oauthProviderId !== providerId;
const subscriptionRisk = providerInfo?.subscriptionRisk === true;
// ── Phase 1t.3: connection gate + risk-notice modal state ───────────────

View File

@@ -30,9 +30,11 @@ import { type BatchTestResults } from "../hooks/useProviderConnections";
import { type ConnectionDeleteConfirmState } from "../hooks/useConnectionDeleteConfirm";
import { type ImportProgress } from "../hooks/useModelImportHandlers";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
import { resolveProviderOAuthBackendId } from "../../providerPageUtils";
interface ProviderInfo {
name: string;
oauthProviderId?: string;
riskNoticeVariant?: string;
website?: string;
[key: string]: unknown;
@@ -228,6 +230,8 @@ export default function ProviderModalsPanel({
setShowTutorialModal,
t,
}: ProviderModalsPanelProps) {
const oauthProviderId = resolveProviderOAuthBackendId(providerId, providerInfo);
return (
<>
{showRiskNoticeModal && subscriptionRisk && (
@@ -288,7 +292,7 @@ export default function ProviderModalsPanel({
<OAuthModal
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
provider={providerId}
provider={oauthProviderId}
providerInfo={providerInfo}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}

View File

@@ -167,6 +167,15 @@ describe("dual-auth provider actions (#8882)", () => {
expectDualAuthActions(rendered.container, rendered);
});
it("renders OAuth Connect and Manual API key for empty xAI", () => {
const rendered = renderEmptyProvider({
providerId: "xai",
supportsDualAuth: true,
providerSupportsPat: false,
});
expectDualAuthActions(rendered.container, rendered);
});
it("renders OAuth Connect and Manual API key for populated CodeBuddy CN", () => {
const rendered = renderPopulatedCodeBuddy();
expectDualAuthActions(rendered.container, rendered);

View File

@@ -8,6 +8,7 @@ import {
type StaticProviderCatalogCategory,
} from "@/lib/providers/catalog";
import {
getProviderConnectionFamilyIds,
isClaudeCodeCompatibleProvider,
supportsApiKeyOnFreeProvider,
supportsDualAuthProvider,
@@ -204,13 +205,8 @@ type ProviderRecord<TProvider = Record<string, unknown>> = Record<string, TProvi
const OAUTH_CARD_API_KEY_CONNECTION_PROVIDER_IDS = new Set(["kiro", "amazon-q", "kimi-coding"]);
const PROVIDER_CONNECTION_ALIASES: Record<string, readonly string[]> = {
alibaba: ["alibaba-cn"],
"kimi-coding": ["kimi-coding-apikey"],
};
export function getProviderConnectionsRequestUrl(providerId: string): string {
const hasAliases = (PROVIDER_CONNECTION_ALIASES[providerId]?.length ?? 0) > 0;
const hasAliases = getProviderConnectionFamilyIds(providerId).length > 1;
return hasAliases
? "/api/providers"
: `/api/providers?provider=${encodeURIComponent(providerId)}`;
@@ -221,8 +217,16 @@ export function connectionBelongsToProviderPage(
providerId: string
): boolean {
if (!connectionProvider) return false;
if (connectionProvider === providerId) return true;
return PROVIDER_CONNECTION_ALIASES[providerId]?.includes(connectionProvider) === true;
return getProviderConnectionFamilyIds(providerId).includes(connectionProvider);
}
export function resolveProviderOAuthBackendId(
providerId: string,
provider: { oauthProviderId?: unknown } | null | undefined
): string {
return typeof provider?.oauthProviderId === "string" && provider.oauthProviderId.length > 0
? provider.oauthProviderId
: providerId;
}
/**

View File

@@ -5,6 +5,7 @@ import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { generateConfig } from "@/lib/cli-helper/config-generator";
import { guardCliConfigWrite } from "@/lib/api/cliConfigWriteGuard";
const applySchema = z.object({
toolId: z.string().min(1),
@@ -22,6 +23,16 @@ const TOOL_CONFIG_PATHS: Record<string, string> = {
continue: path.join(os.homedir(), ".continue", "config.yaml"),
};
/** The host-side command that does the same job when OmniRoute is containerised. */
const HOST_SETUP_COMMANDS: Record<string, string> = {
claude: "omniroute setup-claude",
codex: "omniroute setup-codex",
opencode: "omniroute setup-opencode",
cline: "omniroute setup-cline",
kilocode: "omniroute setup-kilo",
continue: "omniroute setup-continue",
};
function ensureBackup(configPath: string): string | null {
if (!fs.existsSync(configPath)) return null;
const backupDir = path.join(path.dirname(configPath), ".omniroute.bak");
@@ -69,6 +80,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: `Unknown tool: ${toolId}` }, { status: 400 });
}
// A container write into an unmounted path looks successful and then
// disappears with the container — refuse it and point at the host CLI.
const refusal = guardCliConfigWrite(configPath, {
toolLabel: toolId,
hostCommand: HOST_SETUP_COMMANDS[toolId],
});
if (refusal) return refusal;
const backupPath = ensureBackup(configPath);
const dir = path.dirname(configPath);

View File

@@ -11,6 +11,27 @@ import { guideSettingsSaveSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { resolveApiKey, getOrCreateApiKey } from "@/shared/services/apiKeyResolver";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { guardCliConfigWrite } from "@/lib/api/cliConfigWriteGuard";
/**
* Where each guide tool's config lands, and the host command that writes the
* same thing when OmniRoute itself runs in a container.
*/
const GUIDE_TOOL_TARGETS: Record<string, { resolve: () => string; hostCommand: string }> = {
continue: {
resolve: () => path.join(os.homedir(), ".continue", "config.json"),
hostCommand: "omniroute setup-continue",
},
opencode: {
resolve: () => getOpenCodeConfigPath(),
hostCommand: "omniroute setup-opencode",
},
hermes: {
resolve: () =>
getCliPrimaryConfigPath("hermes") || path.join(os.homedir(), ".hermes", "config.yaml"),
hostCommand: "omniroute config set hermes",
},
};
/**
* POST /api/cli-tools/guide-settings/:toolId
@@ -58,6 +79,15 @@ export async function POST(request, { params }) {
? await resolveApiKey(apiKeyId, validation.data.apiKey)
: await getOrCreateApiKey();
const target = GUIDE_TOOL_TARGETS[toolId];
if (target) {
const refusal = guardCliConfigWrite(target.resolve(), {
toolLabel: toolId,
hostCommand: target.hostCommand,
});
if (refusal) return refusal;
}
try {
switch (toolId) {
case "continue":

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