diff --git a/.env.example b/.env.example index 80f60b55b8..2545510595 100644 --- a/.env.example +++ b/.env.example @@ -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//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. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b32487da27..36f8254977 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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" diff --git a/.github/workflows/dast-smoke.yml b/.github/workflows/dast-smoke.yml index f4e2d65155..23055c46e4 100644 --- a/.github/workflows/dast-smoke.yml +++ b/.github/workflows/dast-smoke.yml @@ -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" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 9cca65ac09..6e4adc0192 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index f1e3f869e6..33708fc426 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -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 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index dd34601323..9047db295e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -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) diff --git a/Dockerfile b/Dockerfile index ddcb04975b..eefd6ed57d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/bin/cli/commands/config.mjs b/bin/cli/commands/config.mjs index 348d59969f..6376ba9217 100644 --- a/bin/cli/commands/config.mjs +++ b/bin/cli/commands/config.mjs @@ -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 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 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", { diff --git a/bin/cli/commands/configure.mjs b/bin/cli/commands/configure.mjs index 2a92cd25a2..c84846148f 100644 --- a/bin/cli/commands/configure.mjs +++ b/bin/cli/commands/configure.mjs @@ -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 ` — 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 ", "Model id (skips the interactive model prompt)") .option("--name ", "Profile name to write (default: derived from model)") .option("--codex-home ", "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); diff --git a/bin/cli/commands/setup-aider.mjs b/bin/cli/commands/setup-aider.mjs index f9c0b5c8bb..f3002533ed 100644 --- a/bin/cli/commands/setup-aider.mjs +++ b/bin/cli/commands/setup-aider.mjs @@ -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 (the openai/ prefix is added automatically)."); + printError( + "A model is required. Pass --model (the openai/ prefix is added automatically)." + ); return 2; } @@ -139,6 +153,10 @@ export function registerSetupAider(program) { .option("--config-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); diff --git a/bin/cli/commands/setup-claude.mjs b/bin/cli/commands/setup-claude.mjs index 600a33d8bb..6567824490 100644 --- a/bin/cli/commands/setup-claude.mjs +++ b/bin/cli/commands/setup-claude.mjs @@ -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); diff --git a/bin/cli/commands/setup-cline.mjs b/bin/cli/commands/setup-cline.mjs index 1a76273855..aadbdb41c4 100644 --- a/bin/cli/commands/setup-cline.mjs +++ b/bin/cli/commands/setup-cline.mjs @@ -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: `); printInfo(` Model: ${model}`); @@ -153,6 +178,10 @@ export function registerSetupCline(program) { .option("--cline-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); diff --git a/bin/cli/commands/setup-codex.mjs b/bin/cli/commands/setup-codex.mjs index b820d37e2b..1cdf4afd8b 100644 --- a/bin/cli/commands/setup-codex.mjs +++ b/bin/cli/commands/setup-codex.mjs @@ -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); diff --git a/bin/cli/commands/setup-continue.mjs b/bin/cli/commands/setup-continue.mjs index 6320d8a9c4..3e7eb3cac6 100644 --- a/bin/cli/commands/setup-continue.mjs +++ b/bin/cli/commands/setup-continue.mjs @@ -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 ", "Comma-separated substrings — keep only matching model IDs") .option("--config-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); diff --git a/bin/cli/commands/setup-crush.mjs b/bin/cli/commands/setup-crush.mjs index fe6ceafc71..475126d207 100644 --- a/bin/cli/commands/setup-crush.mjs +++ b/bin/cli/commands/setup-crush.mjs @@ -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 ", "Comma-separated substrings — keep only matching model IDs") .option("--config-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); diff --git a/bin/cli/commands/setup-cursor.mjs b/bin/cli/commands/setup-cursor.mjs index c23b5accdd..45dedfd43b 100644 --- a/bin/cli/commands/setup-cursor.mjs +++ b/bin/cli/commands/setup-cursor.mjs @@ -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 ", "Local OmniRoute port (ignored when --remote is set)", "20128") .option("--remote ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") diff --git a/bin/cli/commands/setup-goose.mjs b/bin/cli/commands/setup-goose.mjs index 789c71dcf7..d977078028 100644 --- a/bin/cli/commands/setup-goose.mjs +++ b/bin/cli/commands/setup-goose.mjs @@ -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 ", "Local OmniRoute port (ignored when --remote is set)", "20128") .option("--remote ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") @@ -143,6 +157,10 @@ export function registerSetupGoose(program) { .option("--config-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); diff --git a/bin/cli/commands/setup-kilo.mjs b/bin/cli/commands/setup-kilo.mjs index c42e4d8246..ada147fe67 100644 --- a/bin/cli/commands/setup-kilo.mjs +++ b/bin/cli/commands/setup-kilo.mjs @@ -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 (Kilo's extension has no model auto-discovery)."); + printError( + "A model is required. Pass --model (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 ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") .option("--model ", "Model id for Kilo (required unless picked interactively)") - .option("--auth-path ", "Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)") - .option("--vscode-settings ", "VS Code settings.json (default: ~/.config/Code/User/settings.json)") + .option( + "--auth-path ", + "Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)" + ) + .option( + "--vscode-settings ", + "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); diff --git a/bin/cli/commands/setup-open-code.mjs b/bin/cli/commands/setup-open-code.mjs index 60f08158c2..1837bfe1d3 100644 --- a/bin/cli/commands/setup-open-code.mjs +++ b/bin/cli/commands/setup-open-code.mjs @@ -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 diff --git a/bin/cli/commands/setup-opencode.mjs b/bin/cli/commands/setup-opencode.mjs index f2d5889f22..f6039fb1a9 100644 --- a/bin/cli/commands/setup-opencode.mjs +++ b/bin/cli/commands/setup-opencode.mjs @@ -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 ", "Set the default top-level model (omniroute/)") .option("--only ", "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); diff --git a/bin/cli/commands/setup-qwen.mjs b/bin/cli/commands/setup-qwen.mjs index ee5ec6d18d..18f45f603f 100644 --- a/bin/cli/commands/setup-qwen.mjs +++ b/bin/cli/commands/setup-qwen.mjs @@ -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 ", "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; diff --git a/bin/cli/commands/setup-roo.mjs b/bin/cli/commands/setup-roo.mjs index bc6a00a670..4e5fc3e731 100644 --- a/bin/cli/commands/setup-roo.mjs +++ b/bin/cli/commands/setup-roo.mjs @@ -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 ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") .option("--model ", "Model id for Roo (required unless picked interactively)") - .option("--import-path ", "Roo import JSON path (default: ~/.omniroute/roo-settings.json)") - .option("--vscode-settings ", "VS Code settings.json (default: ~/.config/Code/User/settings.json)") + .option( + "--import-path ", + "Roo import JSON path (default: ~/.omniroute/roo-settings.json)" + ) + .option( + "--vscode-settings ", + "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); diff --git a/bin/cli/utils/config-home-guard.mjs b/bin/cli/utils/config-home-guard.mjs new file mode 100644 index 0000000000..8d5cabae1f --- /dev/null +++ b/bin/cli/utils/config-home-guard.mjs @@ -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; +} diff --git a/changelog.d/features/10057-docker-aware-auto-config.md b/changelog.d/features/10057-docker-aware-auto-config.md new file mode 100644 index 0000000000..d8718c5801 --- /dev/null +++ b/changelog.d/features/10057-docker-aware-auto-config.md @@ -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) diff --git a/changelog.d/fixes/10233-freeaiapikey-endpoint-moved.md b/changelog.d/fixes/10233-freeaiapikey-endpoint-moved.md new file mode 100644 index 0000000000..cd7abc5c32 --- /dev/null +++ b/changelog.d/fixes/10233-freeaiapikey-endpoint-moved.md @@ -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)) diff --git a/changelog.d/fixes/10234-monsterapi-deprecation-inert.md b/changelog.d/fixes/10234-monsterapi-deprecation-inert.md new file mode 100644 index 0000000000..62a95d78ab --- /dev/null +++ b/changelog.d/fixes/10234-monsterapi-deprecation-inert.md @@ -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)) diff --git a/changelog.d/fixes/10272-provider-test-statuscode-propagation.md b/changelog.d/fixes/10272-provider-test-statuscode-propagation.md new file mode 100644 index 0000000000..5102bf9c45 --- /dev/null +++ b/changelog.d/fixes/10272-provider-test-statuscode-propagation.md @@ -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 diff --git a/changelog.d/fixes/10284-reasoning-probe-truncated-200.md b/changelog.d/fixes/10284-reasoning-probe-truncated-200.md new file mode 100644 index 0000000000..c3ddd311d2 --- /dev/null +++ b/changelog.d/fixes/10284-reasoning-probe-truncated-200.md @@ -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 diff --git a/changelog.d/fixes/10322-process-wide-admission-budget.md b/changelog.d/fixes/10322-process-wide-admission-budget.md new file mode 100644 index 0000000000..defab2a7fe --- /dev/null +++ b/changelog.d/fixes/10322-process-wide-admission-budget.md @@ -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)) diff --git a/changelog.d/fixes/10329-zai-web-auth-semantics.md b/changelog.d/fixes/10329-zai-web-auth-semantics.md new file mode 100644 index 0000000000..c4e6703112 --- /dev/null +++ b/changelog.d/fixes/10329-zai-web-auth-semantics.md @@ -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 diff --git a/changelog.d/fixes/10372-debug-mode-default-false.md b/changelog.d/fixes/10372-debug-mode-default-false.md new file mode 100644 index 0000000000..c1a59b4fb3 --- /dev/null +++ b/changelog.d/fixes/10372-debug-mode-default-false.md @@ -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) diff --git a/changelog.d/fixes/10393-opencode-rotate-network-throw.md b/changelog.d/fixes/10393-opencode-rotate-network-throw.md new file mode 100644 index 0000000000..b0d8e9fb1e --- /dev/null +++ b/changelog.d/fixes/10393-opencode-rotate-network-throw.md @@ -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)) diff --git a/changelog.d/fixes/10397-header-budget-warn-dedupe.md b/changelog.d/fixes/10397-header-budget-warn-dedupe.md new file mode 100644 index 0000000000..d4d117b913 --- /dev/null +++ b/changelog.d/fixes/10397-header-budget-warn-dedupe.md @@ -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) diff --git a/changelog.d/fixes/10415-vision-bridge-combo-reroute.md b/changelog.d/fixes/10415-vision-bridge-combo-reroute.md new file mode 100644 index 0000000000..a3df3019c4 --- /dev/null +++ b/changelog.d/fixes/10415-vision-bridge-combo-reroute.md @@ -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 diff --git a/changelog.d/fixes/10420-antigravity-geoblock-resilience.md b/changelog.d/fixes/10420-antigravity-geoblock-resilience.md new file mode 100644 index 0000000000..cb465299b2 --- /dev/null +++ b/changelog.d/fixes/10420-antigravity-geoblock-resilience.md @@ -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 diff --git a/changelog.d/fixes/10430-antigravity-usage-envelope.md b/changelog.d/fixes/10430-antigravity-usage-envelope.md new file mode 100644 index 0000000000..645045e7ea --- /dev/null +++ b/changelog.d/fixes/10430-antigravity-usage-envelope.md @@ -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 diff --git a/changelog.d/fixes/docker-healthcheck-use-healthz.md b/changelog.d/fixes/docker-healthcheck-use-healthz.md new file mode 100644 index 0000000000..a139e2dd4c --- /dev/null +++ b/changelog.d/fixes/docker-healthcheck-use-healthz.md @@ -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`) diff --git a/changelog.d/fixes/sqljs-atomic-persist.md b/changelog.d/fixes/sqljs-atomic-persist.md new file mode 100644 index 0000000000..db50495f4d --- /dev/null +++ b/changelog.d/fixes/sqljs-atomic-persist.md @@ -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 diff --git a/changelog.d/maintenance/10297-k8s-probe-recommendations.md b/changelog.d/maintenance/10297-k8s-probe-recommendations.md new file mode 100644 index 0000000000..adc9d4491e --- /dev/null +++ b/changelog.d/maintenance/10297-k8s-probe-recommendations.md @@ -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 diff --git a/docs/guides/CODEX-CLI-CONFIGURATION.md b/docs/guides/CODEX-CLI-CONFIGURATION.md index 943ca86607..0749637fe5 100644 --- a/docs/guides/CODEX-CLI-CONFIGURATION.md +++ b/docs/guides/CODEX-CLI-CONFIGURATION.md @@ -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. diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 6740a1958e..2fb1b75f79 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -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 ` 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 Docker’s 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) diff --git a/docs/ops/MONITORING_GUIDE.md b/docs/ops/MONITORING_GUIDE.md index a9d1db9422..82a66eeb49 100644 --- a/docs/ops/MONITORING_GUIDE.md +++ b/docs/ops/MONITORING_GUIDE.md @@ -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, Docker’s 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. diff --git a/docs/reference/CLI-TOOLS.md b/docs/reference/CLI-TOOLS.md index 81cbd0c4d7..fe3f65d1e0 100644 --- a/docs/reference/CLI-TOOLS.md +++ b/docs/reference/CLI-TOOLS.md @@ -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). diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 86bd169eb8..5c89b3acc6 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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` | `/node` | `open-sse/executors/zcode.ts` | Node executable used to host the ZCode app-server. | +| `ZCODE_SERVER_ENTRY` | `/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. | diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 2cd50b7edc..b4fe4f5294 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -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://--.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). | diff --git a/electron/lib/serverReadiness.js b/electron/lib/serverReadiness.js new file mode 100644 index 0000000000..0ae9eb24b2 --- /dev/null +++ b/electron/lib/serverReadiness.js @@ -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, +}; diff --git a/electron/main.js b/electron/main.js index b98692b295..7947ad9cb3 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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()); } diff --git a/electron/package-lock.json b/electron/package-lock.json index cef8fecab5..262e136645 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -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", diff --git a/electron/package.json b/electron/package.json index 4c98337587..4d475a4076 100644 --- a/electron/package.json +++ b/electron/package.json @@ -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": [ "**/*" ] diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index 61aac462a8..80e066592d 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -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); +} diff --git a/open-sse/config/errorConfig.ts b/open-sse/config/errorConfig.ts index 3081f53fb9..b326af7378 100644 --- a/open-sse/config/errorConfig.ts +++ b/open-sse/config/errorConfig.ts @@ -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, }; /** diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 6206719384..91584babc9 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -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 = { novita: novitaProvider, regolo: regoloProvider, "devin-desktop": devin_desktopProvider, + zcode: zcodeProvider, "zed-hosted": zed_hostedProvider, nanogpt: nanogptProvider, scaleway: scalewayProvider, diff --git a/open-sse/config/providers/registry/deepseek/index.ts b/open-sse/config/providers/registry/deepseek/index.ts index 6825b23078..933fb9bba1 100644 --- a/open-sse/config/providers/registry/deepseek/index.ts +++ b/open-sse/config/providers/registry/deepseek/index.ts @@ -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, }, ], }; diff --git a/open-sse/config/providers/registry/freeaiapikey/index.ts b/open-sse/config/providers/registry/freeaiapikey/index.ts index 2fe8a4ba5d..6a4785990d 100644 --- a/open-sse/config/providers/registry/freeaiapikey/index.ts +++ b/open-sse/config/providers/registry/freeaiapikey/index.ts @@ -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)" }, ], }; diff --git a/open-sse/config/providers/registry/grok-cli/index.ts b/open-sse/config/providers/registry/grok-cli/index.ts index 75effafe78..f257f8d60a 100644 --- a/open-sse/config/providers/registry/grok-cli/index.ts +++ b/open-sse/config/providers/registry/grok-cli/index.ts @@ -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", diff --git a/open-sse/config/providers/registry/xai-oauth/index.ts b/open-sse/config/providers/registry/xai-oauth/index.ts deleted file mode 100644 index cd644a707e..0000000000 --- a/open-sse/config/providers/registry/xai-oauth/index.ts +++ /dev/null @@ -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 || []), - ], -}; diff --git a/open-sse/config/providers/registry/xai/index.ts b/open-sse/config/providers/registry/xai/index.ts index f33e247080..efd0a72e3a 100644 --- a/open-sse/config/providers/registry/xai/index.ts +++ b/open-sse/config/providers/registry/xai/index.ts @@ -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 || []), + ], +}; diff --git a/open-sse/config/providers/registry/zcode/index.ts b/open-sse/config/providers/registry/zcode/index.ts new file mode 100644 index 0000000000..cd2a4eece6 --- /dev/null +++ b/open-sse/config/providers/registry/zcode/index.ts @@ -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], +}; diff --git a/open-sse/executors/accountRotation.ts b/open-sse/executors/accountRotation.ts new file mode 100644 index 0000000000..b5f25afc66 --- /dev/null +++ b/open-sse/executors/accountRotation.ts @@ -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( + 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; +} diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 7f949284c6..137b996920 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -339,6 +339,45 @@ function asRecord(value: unknown): Record | 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>) : []; + 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) diff --git a/open-sse/executors/antigravityUpstreamError.ts b/open-sse/executors/antigravityUpstreamError.ts index 7b285c1ea0..074824ef17 100644 --- a/open-sse/executors/antigravityUpstreamError.ts +++ b/open-sse/executors/antigravityUpstreamError.ts @@ -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); } diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 3c230ee91f..39c5880780 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -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); } diff --git a/open-sse/executors/mimocode.ts b/open-sse/executors/mimocode.ts index 356963a581..9ee27e0afc 100644 --- a/open-sse/executors/mimocode.ts +++ b/open-sse/executors/mimocode.ts @@ -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(); 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" } } ), diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index dddcbe4900..26be70bf2c 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -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> | 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>, + { 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 { diff --git a/open-sse/executors/zcode.ts b/open-sse/executors/zcode.ts new file mode 100644 index 0000000000..0841b4daa8 --- /dev/null +++ b/open-sse/executors/zcode.ts @@ -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; +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(promise: Promise, signal?: AbortSignal | null): Promise { + if (!signal) return promise; + if (signal.aborted) { + promise.catch(() => undefined); + throw abortError(); + } + let onAbort: (() => void) | undefined; + const aborted = new Promise((_, 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 { + if (ms <= 0) { + if (signal?.aborted) throw abortError(); + return; + } + await raceAbort(new Promise((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 { + 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 { + 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 { + return {}; + } +} diff --git a/open-sse/executors/zcodeProtocol.ts b/open-sse/executors/zcodeProtocol.ts new file mode 100644 index 0000000000..12a5cd1a0e --- /dev/null +++ b/open-sse/executors/zcodeProtocol.ts @@ -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; + +export interface ZcodeAppServerClientOptions { + command: string; + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + startupTimeoutMs?: number; + requestTimeoutMs?: number; +} + +export interface ZcodeClientLike { + start(): Promise; + call(channel: string, method: string, args: unknown[]): Promise; + close(): Promise; +} + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +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; + private serverReady?: () => void; + private serverReadyError?: (error: Error) => void; + private nextRequestId = 1; + private readonly pending = new Map(); + + 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 { + 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 { + 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((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 { + 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 { + 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 { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise((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 | undefined; + await Promise.race([ + exited, + new Promise((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(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } +} diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8fbb425eda..cdd93178c9 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -159,7 +159,13 @@ import { buildCapabilityMismatchMessage, } from "@/shared/constants/capabilities/capabilityFilter.ts"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts"; -import { toPositiveInteger } from "../services/reasoningTokenBuffer.ts"; +import { + REASONING_BUFFER_MIN_TRIGGER, + buildReasoningProbeTruncatedResponse, + isEmptyContentUpstreamFailure, + isTinyBudgetReasoningProbe, + toPositiveInteger, +} from "../services/reasoningTokenBuffer.ts"; import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts"; import { buildErrorBody, @@ -248,7 +254,10 @@ import { normalizeOpenAIToolFinishReasons, restoreNonStreamingToolNames, } from "./chatCore/passthroughToolNames.ts"; -import { createDisabledCompressionConfig, resolveCompressionSettings } from "./chatCore/compressionSettings.ts"; +import { + createDisabledCompressionConfig, + resolveCompressionSettings, +} from "./chatCore/compressionSettings.ts"; import type { EnforceDecision } from "@/lib/quota/types"; import { isCompressionExcluded } from "../services/compression/exclusions.ts"; import { @@ -1823,7 +1832,11 @@ export async function handleChatCore({ // engines (Caveman/RTK). Codex Desktop / Responses clients need this path even // when those engines are off, otherwise multi-turn image sessions hard-reject // at the budget check below (#8560). - if (reactiveContextCompactionEnabled && !nativeCodexPassthrough && estimatedTokens > threshold) { + if ( + reactiveContextCompactionEnabled && + !nativeCodexPassthrough && + estimatedTokens > threshold + ) { log?.info?.( "CONTEXT", `Proactive compression triggered: ${estimatedTokens} tokens > ${threshold} threshold (${contextLimit} limit)` @@ -1893,7 +1906,12 @@ export async function handleChatCore({ // Last-resort compaction against the concrete input budget (not the 70% threshold). // Covers cases where the proactive pass was skipped or still left the request oversized (#8560). - if (reactiveContextCompactionEnabled && !nativeCodexPassthrough && finalEstimatedInputTokens >= finalContextLimit && body) { + if ( + reactiveContextCompactionEnabled && + !nativeCodexPassthrough && + finalEstimatedInputTokens >= finalContextLimit && + body + ) { const lastResortTarget = Math.max(1, finalContextLimit - toolsReserve - 1); const lastResortAdapter = adaptBodyForCompression(body as Record); 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); diff --git a/open-sse/handlers/chatCore/responseHeaders.ts b/open-sse/handlers/chatCore/responseHeaders.ts index 43fdc5a88e..1a6602f506 100644 --- a/open-sse/handlers/chatCore/responseHeaders.ts +++ b/open-sse/handlers/chatCore/responseHeaders.ts @@ -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) => void; + debug?: (tag: string, message: string, data?: Record) => 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(); + +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 = { diff --git a/open-sse/handlers/chatCore/targetFormat.ts b/open-sse/handlers/chatCore/targetFormat.ts index 991a5c3eb2..27ce3aa3d8 100644 --- a/open-sse/handlers/chatCore/targetFormat.ts +++ b/open-sse/handlers/chatCore/targetFormat.ts @@ -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); diff --git a/open-sse/handlers/usageExtractor.ts b/open-sse/handlers/usageExtractor.ts index 8239c3feb9..313d1ff0e4 100644 --- a/open-sse/handlers/usageExtractor.ts +++ b/open-sse/handlers/usageExtractor.ts @@ -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, }; } diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 7fb8a341e4..2cf9105121 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -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) diff --git a/open-sse/services/combo/comboAbortReasons.ts b/open-sse/services/combo/comboAbortReasons.ts new file mode 100644 index 0000000000..0f44eff6a6 --- /dev/null +++ b/open-sse/services/combo/comboAbortReasons.ts @@ -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; +} diff --git a/open-sse/services/combo/targetTimeoutRunner.ts b/open-sse/services/combo/targetTimeoutRunner.ts index 6264cb7ff7..09a80559b2 100644 --- a/open-sse/services/combo/targetTimeoutRunner.ts +++ b/open-sse/services/combo/targetTimeoutRunner.ts @@ -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 }); } diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index daa3bab657..43c1aa3079 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -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 diff --git a/open-sse/services/reasoningTokenBuffer.ts b/open-sse/services/reasoningTokenBuffer.ts index 4c5ce88059..fc4ac7157f 100644 --- a/open-sse/services/reasoningTokenBuffer.ts +++ b/open-sse/services/reasoningTokenBuffer.ts @@ -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; + 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" }, + }); +} diff --git a/open-sse/services/usage/antigravity.ts b/open-sse/services/usage/antigravity.ts index 693771d681..66ceab9311 100644 --- a/open-sse/services/usage/antigravity.ts +++ b/open-sse/services/usage/antigravity.ts @@ -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; } diff --git a/open-sse/utils/earlyStreamKeepalive.ts b/open-sse/utils/earlyStreamKeepalive.ts index b8c3c631f8..fe9964e477 100644 --- a/open-sse/utils/earlyStreamKeepalive.ts +++ b/open-sse/utils/earlyStreamKeepalive.ts @@ -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, diff --git a/open-sse/utils/responsesOutputIndexStack.ts b/open-sse/utils/responsesOutputIndexStack.ts new file mode 100644 index 0000000000..5eab5b4ca8 --- /dev/null +++ b/open-sse/utils/responsesOutputIndexStack.ts @@ -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(", ")}` + ); + } + } +} diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 0eeccec17d..acc85e2c43 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -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, diff --git a/open-sse/utils/thinkTagParser.ts b/open-sse/utils/thinkTagParser.ts index bd75e28ee8..66ed5f7955 100644 --- a/open-sse/utils/thinkTagParser.ts +++ b/open-sse/utils/thinkTagParser.ts @@ -21,6 +21,15 @@ import { appendBoundedText, buildSyntheticChatChunk } from "./streamHelpers.ts"; const THINK_OPEN = ""; const THINK_CLOSE = ""; +/** + * Every proper prefix of `` ("<", " 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) || - ["<", " value.endsWith(suffix)) - ); + return value.includes(THINK_OPEN) || THINK_OPEN_PARTIALS.some((suffix) => value.endsWith(suffix)); } /** diff --git a/package-lock.json b/package-lock.json index 91dd584ddf..f08d7a25ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "packages/browser-pool" ], "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", @@ -24,51 +24,51 @@ "@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", - "onnxruntime-node": "~1.24.3", + "onnxruntime-node": "~1.27.0", "open": "^11.0.0", "ora": "^9.4.1", "parse5": "^8.0.1", "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", @@ -78,17 +78,18 @@ "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", + "wreq-js": "3.0.0", + "ws": "^8.21.3", "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", "zod": "^4.4.3", @@ -162,7 +163,7 @@ "keytar": "^7.9.0", "sqlite-vec": "^0.1.9", "tls-client-node": "^0.2.0", - "wreq-js": "^2.3.1" + "wreq-js": "^3.0.0" } }, "node_modules/@adobe/css-tools": { @@ -610,21 +611,38 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1096.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1096.0.tgz", - "integrity": "sha512-5aZmG71QnMoQQry/UmT9tM1p/W2Sux34bg3nJPN4GP31Ei321jCgOaEVgCNzaRPzUZ94QuKIA5ND9obTlOw3vw==", + "version": "3.1107.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1107.0.tgz", + "integrity": "sha512-qeaRwHqwPx7OU3d3zuI4Kivtq3vF3WL4w83vuWpZosmbQzgQCka8jsMoHhIVr1ewmuTekYhcPsYY32TqjC6HcA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/credential-provider-node": "^3.972.73", - "@aws-sdk/eventstream-handler-node": "^3.972.30", - "@aws-sdk/middleware-eventstream": "^3.972.25", - "@aws-sdk/middleware-websocket": "^3.972.44", - "@aws-sdk/token-providers": "3.1096.0", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-node": "^3.972.78", + "@aws-sdk/eventstream-handler-node": "^3.972.31", + "@aws-sdk/middleware-eventstream": "^3.972.26", + "@aws-sdk/middleware-websocket": "^3.972.49", + "@aws-sdk/token-providers": "3.1107.0", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/fetch-http-handler": "^5.6.10", - "@smithy/node-http-handler": "^4.9.10", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/token-providers": { + "version": "3.1107.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1107.0.tgz", + "integrity": "sha512-cZXQRFWBxswmcUOin+ZvzTyGEE1Daj9E2n+1jdBSAsWCD+56jlfSgCd+I2qVE6h3ZJBDNI9aTSwWLX0f4lpLhg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -678,16 +696,16 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.977.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.1.tgz", - "integrity": "sha512-KVtQRtc00ES/y+Sc3vYXeP6pCIcNlBJCZOwvqSy8ZpVGmbM5+IG+AfhuTKQ2oXmIVqZJewaGMMpzPkywC6xg0w==", + "version": "3.977.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", + "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@aws-sdk/xml-builder": "^3.972.37", + "@aws-sdk/types": "^3.974.3", + "@aws-sdk/xml-builder": "^3.972.38", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.29.8", - "@smithy/signature-v4": "^5.6.9", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" @@ -697,14 +715,14 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.62", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.62.tgz", - "integrity": "sha512-BkDrk2cNjed31IKin/Oksb2ziF+gfuyRskFVuT4EU9Mep7M8Y/d8DJG4+anHme4Vuse7CwaEscwEfGyR6mzBhQ==", + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", + "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -713,16 +731,16 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.64", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.64.tgz", - "integrity": "sha512-Wj1FGK2IxY5EccQCvH+niTYhIvDoDujJf2CpRRgS3NpYNEgiFNVItNbJYQjINRlu7fG7jSsXkKV0UWKriEplrw==", + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", + "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/fetch-http-handler": "^5.6.10", - "@smithy/node-http-handler": "^4.9.10", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -731,22 +749,22 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.7.tgz", - "integrity": "sha512-2CefB8cCxDu52P24B8Ay93/cTT199bcSvNHQ8e2f4BjSCF83yErBnTIZEBo0VeIgCfmw+PJKFUXnlQWxm2dkug==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", + "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/credential-provider-env": "^3.972.62", - "@aws-sdk/credential-provider-http": "^3.972.64", - "@aws-sdk/credential-provider-login": "^3.972.69", - "@aws-sdk/credential-provider-process": "^3.972.62", - "@aws-sdk/credential-provider-sso": "^3.973.6", - "@aws-sdk/credential-provider-web-identity": "^3.972.68", - "@aws-sdk/nested-clients": "^3.997.36", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/credential-provider-imds": "^4.4.13", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-login": "^3.972.75", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -755,15 +773,15 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.69", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.69.tgz", - "integrity": "sha512-gM3j0Ie9+FoLNTYODY+QWbg3vCRBc7mR9cRdntxTMkFYIrwfRmuucfavP6HNBlYSuaYww54TNJGej4GFgoPZAg==", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", + "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/nested-clients": "^3.997.36", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -772,20 +790,20 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.73", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.73.tgz", - "integrity": "sha512-VTzdbf8Ukjdb9yUubZzRI678CWZvKovhE8Nv3qihwhC187sRMGls+r9N8Wuht5q1xjKx2nmpS48ar8ppupjkCA==", + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", + "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.62", - "@aws-sdk/credential-provider-http": "^3.972.64", - "@aws-sdk/credential-provider-ini": "^3.973.7", - "@aws-sdk/credential-provider-process": "^3.972.62", - "@aws-sdk/credential-provider-sso": "^3.973.6", - "@aws-sdk/credential-provider-web-identity": "^3.972.68", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/credential-provider-imds": "^4.4.13", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-ini": "^3.973.13", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -794,14 +812,14 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.62", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.62.tgz", - "integrity": "sha512-zXYU9UWNL66gtMgNLhmxlrvEokuI7r6G2q7FRGu41Bya4iS30JLelUipJX9SV4zhyCPWJhI9Li54R1d9H8Tq6A==", + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", + "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -810,16 +828,16 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.6.tgz", - "integrity": "sha512-DobZggy3K49xdCpjeyMou0FQhkoYbluVGNydL6D+lcxF8GoAsttFX0xnH5GmiQ89We5dB6TRpW+CD/VowBH6HQ==", + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", + "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/nested-clients": "^3.997.36", - "@aws-sdk/token-providers": "3.1096.0", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/token-providers": "3.1108.0", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -828,15 +846,15 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.68", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.68.tgz", - "integrity": "sha512-bq+yTt+uWJx60VVp/OIAX5xqUAu/K2Uc3eknWnWl+KtfcU2CQe0uNw6lySrn2t5GKHq7jsV0Z63HiBGVtzr/lg==", + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", + "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/nested-clients": "^3.997.36", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -845,13 +863,13 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.30.tgz", - "integrity": "sha512-hJboPgIpq5+ADc++/B9TBqn65CXV21cZLGB8V5RBQbxkZ/rQ6qMfcxTnW/SvQlasX4jhaSG8B1wsVjhQyDrsnQ==", + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.32.tgz", + "integrity": "sha512-rlbmsMG7ZNgrVhWSqqXpq6y9hfiREyzCg3CNTk9UK+AoP7+65kOkqpWmqwLfV1UrRSHATdLnZF2rt9ZTUxYQJA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -860,13 +878,13 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.25.tgz", - "integrity": "sha512-9SFbPzJDHHR5k6Q6KvXVas/veUm/TzNcNTFM2UhdXHZHpyIvI2lS+s4cxljw1BihGpVhsAkQDo/2nW7dHxpf4Q==", + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.27.tgz", + "integrity": "sha512-M7Ay1VpBpf/YFfic9kkjwE3wyCh4G0gEM4RypRXYm7aPjyfqi+D8FEYMR2E3IqbvN+qi2rEFYAiwWL0XHtQYdQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -894,16 +912,16 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.44", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.44.tgz", - "integrity": "sha512-MPjH/vT1UZc7RSdvP/bIZCJqQCOORei84D6a7dwBuvdwOIskTsQ2EczlTRFQu7yWpGMQr1x3xdpDRHjWlTH2Tw==", + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.50.tgz", + "integrity": "sha512-gdcWRbmIf1dWA/prf44Bnnzgqj+AbsXX2yfhZhOQLwSm7NfKIYPmkRlPqP0CTepHzjxMIBdWBDdtQB+Y/dFUeg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/fetch-http-handler": "^5.6.10", - "@smithy/signature-v4": "^5.6.9", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -912,17 +930,17 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.36.tgz", - "integrity": "sha512-b71Suv7L+DnhM0MsQHU4WO42I32kxLZi96PbVhZbxMYIoKnEZz3v+LSrG8fupAoA4cBSshCk1Dl/PeRz49qUSg==", + "version": "3.997.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", + "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/signature-v4-multi-region": "^3.996.42", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/fetch-http-handler": "^5.6.10", - "@smithy/node-http-handler": "^4.9.10", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -931,13 +949,13 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.42.tgz", - "integrity": "sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==", + "version": "3.996.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", + "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@smithy/signature-v4": "^5.6.9", + "@aws-sdk/types": "^3.974.3", + "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -946,15 +964,15 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1096.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1096.0.tgz", - "integrity": "sha512-hdUS2hDppy3vkWeFl5y86RLNU6OWH2mQB09yOSsRefwhhGTSFPkaZvfLDD/9vFcvMzlr8QFQFw3fw2FtrurVQA==", + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", + "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/nested-clients": "^3.997.36", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -963,9 +981,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", - "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", + "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -976,9 +994,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", - "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", + "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -3251,9 +3269,9 @@ "license": "MIT" }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "3.5.15", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.15.tgz", - "integrity": "sha512-5o4grXKotAB3JqQuisLApHG43g17N+paoRTa92Jiz35Zvfemq0cVf4EDvuxyHAzmsJji7igaEowicLO/VmfJ8Q==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.16.tgz", + "integrity": "sha512-kl6b/4D56gjGZi4ZewSmvXbalHwjOUI5ogEHPZqw42goeXTTrL7/yuPzvdrvr0QigDtvaOeb+UeMf62jks43Yg==", "license": "MIT", "dependencies": { "@formatjs/icu-skeleton-parser": "2.1.11" @@ -3488,6 +3506,97 @@ "sharp": "^0.34.5" } }, + "node_modules/@huggingface/transformers/node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/@huggingface/transformers/node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@huggingface/transformers/node_modules/onnxruntime-common": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", + "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "license": "MIT" + }, + "node_modules/@huggingface/transformers/node_modules/onnxruntime-node": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", + "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.24.3" + } + }, + "node_modules/@huggingface/transformers/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@huggingface/transformers/node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@huggingface/transformers/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -5358,9 +5467,9 @@ "license": "MIT" }, "node_modules/@next/env": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz", - "integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", + "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -5375,9 +5484,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz", - "integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", + "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", "cpu": [ "arm64" ], @@ -5391,9 +5500,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz", - "integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", + "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", "cpu": [ "x64" ], @@ -5407,12 +5516,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz", - "integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", + "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5423,12 +5535,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz", - "integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", + "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5439,12 +5554,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz", - "integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", + "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5455,12 +5573,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz", - "integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", + "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5471,9 +5592,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz", - "integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", + "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", "cpu": [ "arm64" ], @@ -5487,9 +5608,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz", - "integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", + "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", "cpu": [ "x64" ], @@ -6991,15 +7112,6 @@ "node": ">=14" } }, - "node_modules/@orama/orama": { - "version": "3.1.18", - "resolved": "https://registry.npmjs.org/@orama/orama/-/orama-3.1.18.tgz", - "integrity": "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==", - "license": "Apache-2.0", - "engines": { - "node": ">= 20.0.0" - } - }, "node_modules/@oven/bun-darwin-aarch64": { "version": "1.3.14", "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.14.tgz", @@ -8526,38 +8638,6 @@ "node": ">=20" } }, - "node_modules/@playwright/test/node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.62.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/@playwright/test/node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -9843,15 +9923,15 @@ "license": "MIT" }, "node_modules/@shikijs/core": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.1.tgz", - "integrity": "sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", "license": "MIT", "dependencies": { - "@shikijs/primitive": "4.3.1", - "@shikijs/types": "4.3.1", + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", + "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" }, "engines": { @@ -9859,12 +9939,12 @@ } }, "node_modules/@shikijs/engine-javascript": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.3.1.tgz", - "integrity": "sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" }, @@ -9873,12 +9953,12 @@ } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.3.1.tgz", - "integrity": "sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" }, "engines": { @@ -9886,51 +9966,51 @@ } }, "node_modules/@shikijs/langs": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.3.1.tgz", - "integrity": "sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1" + "@shikijs/types": "4.4.3" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/primitive": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.3.1.tgz", - "integrity": "sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@types/hast": "^3.0.5" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/themes": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.3.1.tgz", - "integrity": "sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1" + "@shikijs/types": "4.4.3" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/types": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.3.1.tgz", - "integrity": "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@types/hast": "^3.0.5" }, "engines": { "node": ">=20" @@ -10138,12 +10218,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.0.tgz", - "integrity": "sha512-sylYk2l9d7CmRv8ts8p0SDQUr3VO+HMeS1nrjL6+UtbO8ktJHTOeQ1McX+aAyvGGccp5aZX9eNtdcXrSwzoZaw==", + "version": "3.33.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.0.tgz", + "integrity": "sha512-uKbkxgqLyepQDZoq8aRSdUqD1ID//rOqG96ixBhp++O7vBtmwYM6fwldGhr9HJP0iYrdc7GP/AlgzPWEZIrNRg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -10151,13 +10231,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.15", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.15.tgz", - "integrity": "sha512-xYVGrisQqTJWhOnScUhbx8s9H63TMtoxzuUoxG6mP8J+B/YbX3vZxVsgV0xDf43abJnJP0fjP7BkQh7OESwuRA==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", + "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.0", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -10165,13 +10245,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.12", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.12.tgz", - "integrity": "sha512-OpQgP6IGH4j0NJ2zjfYZLjQL85ai+Wi/q51EmZJovXsEwKSvu89qiXUq77Q6EmwZ/hSl7fKpn2Z9mhiDN6OM+Q==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", + "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.0", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -10179,13 +10259,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.9.12", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.12.tgz", - "integrity": "sha512-dWW5KRt4mnEvjNzbGqGeCuAvgum85Y9ZoyuMQqcTEfapndyVJ1k9BEHK7kdXJZ32enyRmmwcFjMwlB/KgLKI3Q==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.0.tgz", + "integrity": "sha512-ssHIZsadPUA3lGdnoByxfnjtb9xPYQLvdfJRLKIwxOoa6tO1suG4sLFSsgd7D/CsvYd8QbBIuKTImuJha5l6aQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.0", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.33.0", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -10193,13 +10273,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.6.11", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.11.tgz", - "integrity": "sha512-7HsspeiNCZvZHEJ22vV5L/QYuJdTyJvPJvMrYD3AgkM3IJB0pkln4jkjPvtpTWRMkHXbO8WKwNjoVdVlBFwHmw==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", + "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.0", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -10207,9 +10287,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", - "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", + "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -11483,9 +11563,9 @@ "optional": true }, "node_modules/@toon-format/toon": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-4.1.0.tgz", - "integrity": "sha512-dBB3pkEx9QYvHnHR6rtkaBAh+7x4W/oA5ONur4G0fh7Ow69PbPuM7OFxzNRABqyxC0t6SZ3RixiGbCuaFjPDAQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-4.1.1.tgz", + "integrity": "sha512-SGCkS7IjVpwRmGPgnY8ENKpAf0EdAnZDOQkvFW0d2cgOpdn9FEFl7sTgryESyypXrWr0YajHGpwsAUX4zw9ZvA==", "license": "MIT" }, "node_modules/@tufjs/canonical-json": { @@ -11884,9 +11964,9 @@ "license": "MIT" }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -13896,13 +13976,13 @@ } }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -15611,9 +15691,9 @@ } }, "node_modules/cnfast": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/cnfast/-/cnfast-0.0.8.tgz", - "integrity": "sha512-EjXKMfGfdwtV4AcNSQ6AwQaVzpC1B7IxeiwA3FlhTXz+YFlMKVi4c1JX9tgD2QOlahQXjB8KUXrBaYG+3v871Q==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cnfast/-/cnfast-0.1.0.tgz", + "integrity": "sha512-rH0jBKeLkVrK7NsZ5Ba2l7WdMBmm1k0FMpABeXUU1PgTUxbz3261gEuCSsrYuXo4BwAe3yCcIbQ93YyS52lOGQ==", "license": "MIT", "bin": { "cnfast": "bin/cli.js" @@ -16180,9 +16260,9 @@ } }, "node_modules/cron-parser": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.7.0.tgz", - "integrity": "sha512-iSpDHpwwW/GhIg4JVODYlWUEpMNSimaHvqOhHpOz1W+Y97z1lL1nf+dpcF17cNwFRpTtKN9devgi1fxflp3Phw==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.8.1.tgz", + "integrity": "sha512-fVw5nGEkTVmiPKo3fY0j28Thq6jR00VKWyL22llWrsbII4sDHI+8Kx1kcL+QzGQJfCfk64bbMotrgTZRpzYpLQ==", "license": "MIT", "dependencies": { "luxon": "^3.7.2" @@ -16282,9 +16362,9 @@ "license": "MIT" }, "node_modules/csv-stringify": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.8.1.tgz", - "integrity": "sha512-tZ6X6TKQyQgCo5OptXcyAbfN1pwmoxEqELPQ7KFazNErx7kiVsDK8o+VYRXhfMl4N9vvOOLXuioquR2MeP847A==", + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.8.3.tgz", + "integrity": "sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA==", "license": "MIT" }, "node_modules/ctrf": { @@ -19811,12 +19891,12 @@ } }, "node_modules/framer-motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", - "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", + "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", "license": "MIT", "dependencies": { - "motion-dom": "^12.42.2", + "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, @@ -19896,29 +19976,29 @@ } }, "node_modules/fumadocs-core": { - "version": "16.13.0", - "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.13.0.tgz", - "integrity": "sha512-J+XhngvMn+tKCrk3MyZzE0xMECCJUjSfRtGTKKP4lP8Py8lXGhgnRMuc+yUip2eCdUIs2+maYyeYEgAFIGHMtA==", + "version": "16.14.3", + "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.14.3.tgz", + "integrity": "sha512-xoGy6YelmU8GD4RKUiSuraFnRW91DqBM328Gs/YasltLrnMDWgEaYMKAcrGLVrikpqJkFx+etHo8BcClOMNt+A==", "license": "MIT", "dependencies": { - "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", - "npm-to-yarn": "3.1.0", + "npm-to-yarn": "3.2.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", - "shiki": "^4.3.1", + "shiki": "^4.4.1", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", - "yaml": "^2.9.0" + "yaml": "^2.9.0", + "zbsearch": "^3.3.4" }, "peerDependencies": { "@mdx-js/mdx": "*", @@ -20084,38 +20164,38 @@ } }, "node_modules/fumadocs-ui": { - "version": "16.13.0", - "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.13.0.tgz", - "integrity": "sha512-kaULXwY9W0MYEKzFCeDjCX9XW3ABDmsabdYWAFPp2jncH9BO+9xgI/t8OWkTIVngyT5PAMvTJphjMxCSTeIVRQ==", + "version": "16.14.3", + "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.14.3.tgz", + "integrity": "sha512-ASL9BgFxSe6VrbQ60nxVfpnKBPboeADp330JQvyEAqR1U8uw0T1+Vko8ySXRAWbivdZfYK5fZbCh78EOAMTEqw==", "license": "MIT", "dependencies": { "@fuma-translate/react": "^1.0.2", "@fumadocs/tailwind": "0.1.1", - "@radix-ui/react-accordion": "^1.2.17", - "@radix-ui/react-collapsible": "^1.1.17", - "@radix-ui/react-dialog": "^1.1.20", - "@radix-ui/react-direction": "^1.1.2", - "@radix-ui/react-navigation-menu": "^1.2.19", - "@radix-ui/react-popover": "^1.1.20", - "@radix-ui/react-presence": "^1.1.8", - "@radix-ui/react-scroll-area": "^1.2.15", - "@radix-ui/react-slot": "^1.3.0", - "@radix-ui/react-tabs": "^1.1.18", + "@radix-ui/react-accordion": "^1.2.20", + "@radix-ui/react-collapsible": "^1.1.20", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-direction": "^1.1.4", + "@radix-ui/react-navigation-menu": "^1.2.22", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-presence": "^1.1.10", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tabs": "^1.1.21", "class-variance-authority": "^0.7.1", - "cnfast": "^0.0.8", - "lucide-react": "^1.25.0", - "motion": "^12.42.2", + "cnfast": "^0.1.0", + "lucide-react": "^1.28.0", + "motion": "^12.43.0", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", - "shiki": "^4.3.1", + "shiki": "^4.4.1", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/mdx": "*", "@types/react": "*", - "fumadocs-core": "16.13.0", + "fumadocs-core": "16.14.3", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -20477,17 +20557,15 @@ } }, "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz", + "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==", "license": "BSD-3-Clause", "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" + "globalthis": "^1.0.2", + "matcher": "^4.0.0", + "semver": "^7.3.5", + "serialize-error": "^8.1.0" }, "engines": { "node": ">=10.0" @@ -21900,9 +21978,9 @@ } }, "node_modules/icu-minify": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.13.4.tgz", - "integrity": "sha512-yK6HyPLGlQjqm8fTKtnBpM77z7vl7JdDBN2EXLvmgAu/b7XaOHWZb73M3ISl9ahBTehBv7RYeqqWSHfk1v2YcA==", + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.13.6.tgz", + "integrity": "sha512-iYZGCJZ+kX6o7GrxpVe2sOSdW86AvEqh8RQBvWeBd9jqmuABsMc2B6xongACfItLOogyIWH6GuBslNHr79OU8Q==", "funding": [ { "type": "individual", @@ -22793,13 +22871,13 @@ } }, "node_modules/intl-messageformat": { - "version": "11.2.12", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.12.tgz", - "integrity": "sha512-KW70Xxfcvy7vV3qODfvShWkFDPMqKDAa4N+hSyVBWGNtVhTUFYaqlD/l88DaYPKiVcPP4rPQ3qnH7i5K82Mg7g==", + "version": "11.2.13", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.13.tgz", + "integrity": "sha512-JaPaE6TIX+TAS5XLhDUh41geLw4QfBHX4s5pW8Km+L9fVC8HzB9yOuhbh4EMR/F1+8C6b9qk4763Cv+LdOG1kg==", "license": "BSD-3-Clause", "dependencies": { "@formatjs/fast-memoize": "3.1.7", - "@formatjs/icu-messageformat-parser": "3.5.15" + "@formatjs/icu-messageformat-parser": "3.5.16" } }, "node_modules/intl-messageformat/node_modules/@formatjs/fast-memoize": { @@ -23743,9 +23821,9 @@ } }, "node_modules/jose": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", - "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -23819,9 +23897,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", - "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", + "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", "funding": [ { "type": "github", @@ -25781,9 +25859,9 @@ } }, "node_modules/lucide-react": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz", - "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==", + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", + "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -25885,9 +25963,9 @@ } }, "node_modules/marked": { - "version": "18.0.7", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.7.tgz", - "integrity": "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==", + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.9.tgz", + "integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -25942,21 +26020,24 @@ } }, "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz", + "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==", "license": "MIT", "dependencies": { "escape-string-regexp": "^4.0.0" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/material-symbols": { - "version": "0.45.9", - "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.45.9.tgz", - "integrity": "sha512-CuNJwHm/c13L2NDGvap4k90iFBZFoMQrjBU+GHO/9bh9sUOe5tm3WHFPLa3BEWukMw6dAxwu5PvtlfWh3NppYA==", + "version": "0.45.10", + "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.45.10.tgz", + "integrity": "sha512-2A2mgdfZO4es9DFpIOSMAx3d/7erCLQWRm7zGxA3iXt9jV8e2vNLOszbG3kxzYQ6qp5GAWXVl13de8lS2oHVng==", "license": "Apache-2.0" }, "node_modules/math-intrinsics": { @@ -27579,12 +27660,12 @@ "optional": true }, "node_modules/motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", - "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.43.0.tgz", + "integrity": "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==", "license": "MIT", "dependencies": { - "framer-motion": "^12.42.2", + "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -27605,9 +27686,9 @@ } }, "node_modules/motion-dom": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", - "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", + "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", "license": "MIT", "dependencies": { "motion-utils": "^12.39.0" @@ -28024,16 +28105,16 @@ } }, "node_modules/next": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz", - "integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", + "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", "license": "MIT", "dependencies": { - "@next/env": "16.2.12", + "@next/env": "16.3.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", + "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "bin": { @@ -28043,15 +28124,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.12", - "@next/swc-darwin-x64": "16.2.12", - "@next/swc-linux-arm64-gnu": "16.2.12", - "@next/swc-linux-arm64-musl": "16.2.12", - "@next/swc-linux-x64-gnu": "16.2.12", - "@next/swc-linux-x64-musl": "16.2.12", - "@next/swc-win32-arm64-msvc": "16.2.12", - "@next/swc-win32-x64-msvc": "16.2.12", - "sharp": "^0.34.5" + "@next/swc-darwin-arm64": "16.3.0", + "@next/swc-darwin-x64": "16.3.0", + "@next/swc-linux-arm64-gnu": "16.3.0", + "@next/swc-linux-arm64-musl": "16.3.0", + "@next/swc-linux-x64-gnu": "16.3.0", + "@next/swc-linux-x64-musl": "16.3.0", + "@next/swc-win32-arm64-msvc": "16.3.0", + "@next/swc-win32-x64-msvc": "16.3.0", + "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -28077,9 +28158,9 @@ } }, "node_modules/next-intl": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.13.4.tgz", - "integrity": "sha512-jhPAT0u0lahIK6E4gVdZAehugWCosBhLG8sV7xMzgSVoJpxHObP+Fiu+z2FfkEW0XPPtr7uEXoUlLEfhxhNMTg==", + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.13.6.tgz", + "integrity": "sha512-loS6tjWWkr/IP+EV1yXUm9URB54QmZOp4+ZsMZNmeYxY8IZxLvO2esUegnXIDxj5DpK/4BsxwDGfGhlqodpkCQ==", "funding": [ { "type": "individual", @@ -28091,11 +28172,11 @@ "@formatjs/intl-localematcher": "^0.8.1", "@parcel/watcher": "^2.4.1", "@swc/core": "^1.15.2", - "icu-minify": "^4.13.4", + "icu-minify": "^4.13.6", "negotiator": "^1.0.0", - "next-intl-swc-plugin-extractor": "^4.13.4", + "next-intl-swc-plugin-extractor": "^4.13.6", "po-parser": "^2.1.1", - "use-intl": "^4.13.4" + "use-intl": "^4.13.6" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", @@ -28108,9 +28189,9 @@ } }, "node_modules/next-intl-swc-plugin-extractor": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.4.tgz", - "integrity": "sha512-uN1+NMUYbG6YkO3q+rjc2bvAPX9nQ23owemvHJAyW0pRbQjVDwvNhmrV5qaak0oQc/9okbK17KLT49AoMGhVEQ==", + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.6.tgz", + "integrity": "sha512-M2L8jtPEAXj0CPmXbiW66THdr3OnDqA9IsU1hqv3CdxtVow3Bl9eXPdT9Opeji7L4AFrUZ016dJOs+CoTw66OA==", "license": "MIT" }, "node_modules/next-themes": { @@ -28683,9 +28764,9 @@ } }, "node_modules/npm-to-yarn": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/npm-to-yarn/-/npm-to-yarn-3.1.0.tgz", - "integrity": "sha512-9gNsO/JB3LeWOZXBX09cKMsCPwVcu1ExIf+GUuTN9G+0zZvLIK0nU9+lE9jue3MSKAxPdrh0rO072mWNvciqeQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/npm-to-yarn/-/npm-to-yarn-3.2.0.tgz", + "integrity": "sha512-K1HmQeZT2HrjpsR6KgqbN2FAXL2NrJJmNUSD9ck7HGTVu1JKXox8n9SB+tjbU8m8JGLF4OscrroPepew/L7/Xw==", "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -28980,15 +29061,15 @@ } }, "node_modules/onnxruntime-common": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", - "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.27.0.tgz", + "integrity": "sha512-3KxL5wIVqa8Ex08jxSzncm9CMgw8CjOFyOQ7SxvG9o0cVLlhTNKXyIQuTbtX4tGPJEf73OER2xrjt4HJSBL4ow==", "license": "MIT" }, "node_modules/onnxruntime-node": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", - "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.27.0.tgz", + "integrity": "sha512-QEzGwrvNBgv4uPVdnbHsOGG4G6T96mdlcFI8aAKPjMU8wOPpVocPXb6k3QGkaZagVTv2G9Bnnbo6Z3JdXr1fQw==", "hasInstallScript": true, "license": "MIT", "os": [ @@ -28998,8 +29079,8 @@ ], "dependencies": { "adm-zip": "^0.5.16", - "global-agent": "^3.0.0", - "onnxruntime-common": "1.24.3" + "global-agent": "^4.1.3", + "onnxruntime-common": "1.27.0" } }, "node_modules/onnxruntime-web": { @@ -30270,12 +30351,12 @@ "license": "MIT" }, "node_modules/playwright": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", - "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.0" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" @@ -30292,7 +30373,6 @@ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "license": "Apache-2.0", - "optional": true, "bin": { "playwright-core": "cli.js" }, @@ -30337,9 +30417,9 @@ } }, "node_modules/playwright/node_modules/playwright-core": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", - "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -32931,12 +33011,12 @@ } }, "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", "license": "MIT", "dependencies": { - "type-fest": "^0.13.1" + "type-fest": "^0.20.2" }, "engines": { "node": ">=10" @@ -32946,9 +33026,9 @@ } }, "node_modules/serialize-error/node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -33127,19 +33207,19 @@ } }, "node_modules/shiki": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.3.1.tgz", - "integrity": "sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", "license": "MIT", "dependencies": { - "@shikijs/core": "4.3.1", - "@shikijs/engine-javascript": "4.3.1", - "@shikijs/engine-oniguruma": "4.3.1", - "@shikijs/langs": "4.3.1", - "@shikijs/themes": "4.3.1", - "@shikijs/types": "4.3.1", + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@types/hast": "^3.0.5" }, "engines": { "node": ">=20" @@ -33381,9 +33461,9 @@ } }, "node_modules/smol-toml": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz", - "integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.2.tgz", + "integrity": "sha512-pXFZ9B2WinEPzxWkMmlYE/oYx2BP+qLrE95wP8tCuK901uLSMGdCb6QSr82z+wnhXkG4+cO+OMLbZB2Cn+97zw==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -34996,9 +35076,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", - "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -35084,12 +35164,16 @@ } }, "node_modules/turndown": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz", - "integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==", + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", "license": "MIT", "dependencies": { "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" } }, "node_modules/turndown-plugin-gfm": { @@ -35799,9 +35883,9 @@ } }, "node_modules/use-intl": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.13.4.tgz", - "integrity": "sha512-wRhU5zyPNgu845++EJ8ckQsi89b22QUop7NlGxNXpsnKSwEJr7WErAkdAYeVQgFTmDWsa8e2NI1e14XbWz9Ecw==", + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.13.6.tgz", + "integrity": "sha512-RLej84qL6PGTDp/PSG3tqRpwr7IvJfOu4Qfv/uyy8CrnYn1oEOQb6osNJb++jZ7FQxqN3aQ5BI7wTIerUgrgMA==", "funding": [ { "type": "individual", @@ -35812,7 +35896,7 @@ "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", - "icu-minify": "^4.13.4", + "icu-minify": "^4.13.6", "intl-messageformat": "^11.1.0" }, "peerDependencies": { @@ -36739,9 +36823,9 @@ "license": "ISC" }, "node_modules/wreq-js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/wreq-js/-/wreq-js-2.3.1.tgz", - "integrity": "sha512-vaKasaKeskrDKEuuO5Q5uamEG9a6FrF5ZSicH7TCvYS4RxF7/gzaU/vYqwJzcs+uydyJPVWY1KCvfVCgp0tiGA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/wreq-js/-/wreq-js-3.0.0.tgz", + "integrity": "sha512-RZCoRSevVPpH4A4B4MxbFGo/pVPFveWd2gbe4ENKpPWlKXEYklZSDESOjBMmrIsmnkHh+nhM4PNJvG+NL7wBPA==", "cpu": [ "x64", "arm64" @@ -36752,7 +36836,10 @@ "darwin", "linux", "win32" - ] + ], + "engines": { + "node": ">=20.0.0" + } }, "node_modules/write-file-atomic": { "version": "7.0.1", @@ -36768,9 +36855,9 @@ } }, "node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -37178,6 +37265,15 @@ "@yuku-toolchain/types": "^0.8.4" } }, + "node_modules/zbsearch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/zbsearch/-/zbsearch-3.3.4.tgz", + "integrity": "sha512-xGsv9rIwrili/fpLpVwmnCovEcvaAJg1ey+3Ur0+m3x1mnGoVO71iAwn4op420QLGNsQJNmScZjFq5TQ+cRi/g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20.0.0" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -37256,7 +37352,7 @@ "name": "@omniroute/browser-pool", "version": "0.1.0", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.62.1" }, "devDependencies": { "@types/node": "^22" diff --git a/package.json b/package.json index 51a6e5791d..c4c527f79a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/browser-pool/package.json b/packages/browser-pool/package.json index 6e773eb7ab..7d0355f1b4 100644 --- a/packages/browser-pool/package.json +++ b/packages/browser-pool/package.json @@ -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" diff --git a/scripts/build/electronRuntimeDocs.mjs b/scripts/build/electronRuntimeDocs.mjs new file mode 100644 index 0000000000..b9d5a8aa10 --- /dev/null +++ b/scripts/build/electronRuntimeDocs.mjs @@ -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; +} diff --git a/scripts/build/hydrateNativeDeps.mjs b/scripts/build/hydrateNativeDeps.mjs new file mode 100644 index 0000000000..4b7d4a2f9a --- /dev/null +++ b/scripts/build/hydrateNativeDeps.mjs @@ -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/_`, better-sqlite3 v13 ships Node-API prebuilds for + * 8 platforms, wreq-js ships `rust/wreq-js.-[-libc].node`, and + * onnxruntime-node ships `bin/napi-v6//`. + * - 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 }; +} diff --git a/scripts/build/prepare-electron-standalone.mjs b/scripts/build/prepare-electron-standalone.mjs index e195f6480f..6265a94b31 100644 --- a/scripts/build/prepare-electron-standalone.mjs +++ b/scripts/build/prepare-electron-standalone.mjs @@ -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(); diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts index 6de4af27e2..e8872c0a9a 100644 --- a/scripts/build/prepublish.ts +++ b/scripts/build/prepublish.ts @@ -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, diff --git a/scripts/build/standaloneBundle.mjs b/scripts/build/standaloneBundle.mjs new file mode 100644 index 0000000000..6e440e34a8 --- /dev/null +++ b/scripts/build/standaloneBundle.mjs @@ -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 "); + 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 "); + 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 --arch "); + 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 [--dir .build/next] [--manifest ]", + " standaloneBundle.mjs restore --archive [--manifest ] [--dir .build/next]", + " standaloneBundle.mjs hydrate --platform --arch ", + " [--standalone-node-modules ] [--source-node-modules ]", + ].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)); +} diff --git a/scripts/build/standaloneManifest.mjs b/scripts/build/standaloneManifest.mjs new file mode 100644 index 0000000000..19a3eb8288 --- /dev/null +++ b/scripts/build/standaloneManifest.mjs @@ -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 }; +} diff --git a/scripts/build/standaloneTarball.mjs b/scripts/build/standaloneTarball.mjs new file mode 100644 index 0000000000..94afbc0334 --- /dev/null +++ b/scripts/build/standaloneTarball.mjs @@ -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("/")); +} diff --git a/scripts/dev/healthcheck.mjs b/scripts/dev/healthcheck.mjs index c65b0957b1..6124b83a81 100644 --- a/scripts/dev/healthcheck.mjs +++ b/scripts/dev/healthcheck.mjs @@ -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() : ""; diff --git a/skills/cli-setup/SKILL.md b/skills/cli-setup/SKILL.md index 4df7cb49bb..11697d2367 100644 --- a/skills/cli-setup/SKILL.md +++ b/skills/cli-setup/SKILL.md @@ -104,6 +104,7 @@ Write config for a tool - `--model ` - `--non-interactive` - `--yes` +- `--allow-container-write` **Example:** @@ -135,6 +136,7 @@ Generate OpenCode config (alias for - `--model ` - `--non-interactive` - `--yes` +- `--allow-container-write` **Example:** diff --git a/skills/cli-skill-collector/SKILL.md b/skills/cli-skill-collector/SKILL.md index add237ad11..ec3e36c37b 100644 --- a/skills/cli-skill-collector/SKILL.md +++ b/skills/cli-skill-collector/SKILL.md @@ -104,6 +104,7 @@ Write config for a tool - `--model ` - `--non-interactive` - `--yes` +- `--allow-container-write` **Example:** @@ -135,6 +136,7 @@ Generate OpenCode config (alias for - `--model ` - `--non-interactive` - `--yes` +- `--allow-container-write` **Example:** diff --git a/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx index 4f9452abdb..b7877dc05e 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx @@ -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({
{message && (
{message.type === "success" ? "check_circle" : "error"} - {message.text} + {/* The container refusal is a multi-line runbook — keep its line + breaks instead of collapsing it into one unreadable line. */} + + {message.text} +
)}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index f08d62d870..4f9fb8388e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -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 ─────────────── diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx index 92a2c40169..45aa63ec63 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx @@ -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({ setShowOAuthModal(false)} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/dual-auth-actions.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/dual-auth-actions.test.tsx index dff34c534f..3dfb48e616 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/dual-auth-actions.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/dual-auth-actions.test.tsx @@ -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); diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 1405ce875c..185be8e945 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -8,6 +8,7 @@ import { type StaticProviderCatalogCategory, } from "@/lib/providers/catalog"; import { + getProviderConnectionFamilyIds, isClaudeCodeCompatibleProvider, supportsApiKeyOnFreeProvider, supportsDualAuthProvider, @@ -204,13 +205,8 @@ type ProviderRecord> = Record = { - 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; } /** diff --git a/src/app/api/cli-tools/apply/route.ts b/src/app/api/cli-tools/apply/route.ts index b0235df1ee..dc7449bae4 100644 --- a/src/app/api/cli-tools/apply/route.ts +++ b/src/app/api/cli-tools/apply/route.ts @@ -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 = { 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 = { + 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); diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts index b0487d304b..f43895fcf5 100644 --- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts @@ -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; 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": diff --git a/src/app/api/providers/[id]/models/discovery/normalizers.ts b/src/app/api/providers/[id]/models/discovery/normalizers.ts index 5b553fb3fa..50e5d3dcb6 100644 --- a/src/app/api/providers/[id]/models/discovery/normalizers.ts +++ b/src/app/api/providers/[id]/models/discovery/normalizers.ts @@ -8,7 +8,7 @@ import { getAntigravityContentHeaders } from "@omniroute/open-sse/services/antig import { resolveAntigravityClientVersion } from "@omniroute/open-sse/services/antigravityClientProfile.ts"; import { getClientVisibleAntigravityModelName, - isUserCallableAntigravityModelId, + isDiscoverableAntigravityModelId, toClientAntigravityModelId, } from "@omniroute/open-sse/config/antigravityModelAliases.ts"; import { @@ -81,7 +81,7 @@ export function filterUserCallableAntigravityModels( model.isInternal !== true && (provider === "agy" ? isDiscoverableAgyModelId(model.id) - : isUserCallableAntigravityModelId(model.id)) + : isDiscoverableAntigravityModelId(model.id)) ); } diff --git a/src/app/api/providers/[id]/test/apiKeyTestResult.ts b/src/app/api/providers/[id]/test/apiKeyTestResult.ts new file mode 100644 index 0000000000..80e8788f7d --- /dev/null +++ b/src/app/api/providers/[id]/test/apiKeyTestResult.ts @@ -0,0 +1,28 @@ +export interface ApiKeyValidationResult { + valid: boolean; + warning?: string | null; + statusCode?: number | null; + deployments?: unknown; +} + +export interface ApiKeyTestDiagnosis { + type: string; + source: string; + message: string | null; + code: string | null; +} + +export function buildApiKeyConnectionTestResult( + result: ApiKeyValidationResult, + error: string | null, + diagnosis: ApiKeyTestDiagnosis +) { + return { + valid: !!result.valid, + error, + warning: result.warning || null, + statusCode: result.valid ? null : (result.statusCode ?? null), + diagnosis, + ...(Array.isArray(result.deployments) ? { deployments: result.deployments } : {}), + }; +} diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts index 52f8713ee2..41a9aa3f20 100644 --- a/src/app/api/providers/[id]/test/oauthTestConfig.ts +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -1,4 +1,38 @@ import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab"; +import { ANTIGRAVITY_RUNTIME_BASE_URLS } from "@omniroute/open-sse/config/antigravityUpstream.ts"; +import { getAntigravityContentHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; +import { getAntigravityClientProfile } from "@omniroute/open-sse/services/antigravityClientProfile.ts"; + +// Real model-surface probe for antigravity/agy. The previous probe only hit the +// OAuth userinfo endpoint, which is NOT geo-restricted — so "Test Connection" +// stayed green while every model call failed with "User location is not +// supported for the API use." Probe the actual Cloud Code model endpoint +// (streamGenerateContent) with a minimal body: +// 2xx -> model path reachable (auth ok) +// 400 geo -> egress location blocked (auth ok — NOT an account problem) +// 401/403 -> token bad +// Mirrors AntigravityExecutor.buildUrl/buildHeaders so the probe exercises the +// exact same surface as real requests. +function buildAntigravityProbe( + connection: { providerSpecificData?: unknown }, + accessToken: string +) { + const profile = getAntigravityClientProfile(connection as never); + return { + url: `${ANTIGRAVITY_RUNTIME_BASE_URLS[0]}/v1internal:streamGenerateContent?alt=sse`, + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Accept: "text/event-stream", + ...getAntigravityContentHeaders(profile, accessToken), + }, + body: JSON.stringify({ + contents: [{ role: "user", parts: [{ text: "ping" }] }], + generationConfig: { maxOutputTokens: 1 }, + }), + }; +} const CLINE_OAUTH_TEST_CONFIG = { // Cline does not expose a stable lightweight auth probe. Validate token @@ -27,7 +61,34 @@ const XAI_CHAT_OAUTH_TEST_CONFIG = { // OAuth provider test endpoints. Extracted from route.ts (#7610) so adding a // provider entry doesn't grow the frozen route.ts file past its check-file-size // cap — this module carries no logic of its own beyond the GitLab URL builder. -export const OAUTH_TEST_CONFIG = { +// Probe request built at test time by provider-specific configs (e.g. +// antigravity), which need dynamic headers (client profile) the static fields +// cannot express. +export interface OAuthTestProbeRequest { + url: string; + method: string; + headers: Record; + body?: string; +} + +export interface OAuthTestConfigEntry { + url?: string; + method?: string; + authHeader?: string; + authPrefix?: string; + extraHeaders?: Record; + body?: string; + acceptStatuses?: number[]; + checkExpiry?: boolean; + refreshable?: boolean; + getUrl?: (connection: any) => string; + buildProbe?: ( + connection: any, + accessToken: string + ) => OAuthTestProbeRequest | Promise; +} + +export const OAUTH_TEST_CONFIG: Record = { claude: { // Claude doesn't have userinfo, we verify token exists and not expired checkExpiry: true, @@ -62,22 +123,18 @@ export const OAUTH_TEST_CONFIG = { refreshable: true, }, antigravity: { - url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", + // Real model-surface probe (see buildAntigravityProbe above): userinfo-only + // probing stayed green while the model API was geo-blocked. + buildProbe: buildAntigravityProbe, refreshable: true, }, // `agy` is a separate connection id that shares the Antigravity backend and the same // Google OAuth token lifecycle (tokenRefresh.ts routes it to refreshGoogleToken), but // it was missing here — so "Test Connection" fell through to "Provider test not // supported", recorded testStatus="error", and painted the home topology node red on a - // perfectly good account. Probe the same userinfo endpoint as antigravity. + // perfectly good account. Probe the same model surface as antigravity. agy: { - url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", + buildProbe: buildAntigravityProbe, refreshable: true, }, xai: XAI_CHAT_OAUTH_TEST_CONFIG, diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 4d440e6936..66cf2f6df9 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -23,7 +23,9 @@ import { isGitLabDirectAccessDisabled } from "@/lib/oauth/gitlab"; import { providerAllowsOptionalApiKey } from "@/shared/constants/providers"; import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts"; import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth"; +import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult"; import { OAUTH_TEST_CONFIG } from "./oauthTestConfig"; +import { isGeoBlockedError } from "@omniroute/open-sse/services/errorClassifier.ts"; // Bound the OAuth probe so a hung upstream can't block the connection-test queue // forever (#1449). Mirrors the 30s timeout the API-key path uses via validateProviderApiKey. @@ -437,20 +439,34 @@ export async function testOAuthConnection( // Call test endpoint try { - const headers = { - [config.authHeader]: `${config.authPrefix}${accessToken}`, - ...config.extraHeaders, - }; + // Provider-specific probe builders (e.g. antigravity) construct the full + // request — url/method/headers/body — because the real surface needs + // dynamic headers (client profile) that the static config cannot express. + const builtProbe = + typeof config.buildProbe === "function" + ? await config.buildProbe(connection, accessToken) + : null; + const headers = builtProbe + ? builtProbe.headers + : { + [config.authHeader]: `${config.authPrefix}${accessToken}`, + ...config.extraHeaders, + }; - const url = typeof config.getUrl === "function" ? config.getUrl(connection) : config.url; + const url = builtProbe + ? builtProbe.url + : typeof config.getUrl === "function" + ? config.getUrl(connection) + : config.url; const fetchInit: RequestInit = { - method: config.method, + method: builtProbe?.method ?? config.method, headers, signal: AbortSignal.timeout(timeoutMs), }; // Port of decolua/9router#347: providers like Codex must send a body so the // upstream returns 400 (auth ok) instead of 405/415. - if (config.body) fetchInit.body = config.body; + if (config.body && !builtProbe) fetchInit.body = config.body; + if (builtProbe?.body) fetchInit.body = builtProbe.body; const res = await fetch(url, fetchInit); // Port of decolua/9router#347: some providers (Codex) intentionally trigger a @@ -496,14 +512,20 @@ export async function testOAuthConnection( if (tokens) { // Retry with new token const retryInit: RequestInit = { - method: config.method, - headers: { - [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, - ...config.extraHeaders, - }, + method: builtProbe?.method ?? config.method, + headers: builtProbe + ? { + ...builtProbe.headers, + Authorization: `Bearer ${tokens.accessToken ?? accessToken}`, + } + : { + ...headers, + [config.authHeader]: `${config.authPrefix}${tokens.accessToken ?? accessToken}`, + }, signal: AbortSignal.timeout(timeoutMs), }; - if (config.body) retryInit.body = config.body; + if (builtProbe?.body) retryInit.body = builtProbe.body; + else if (config.body) retryInit.body = config.body; const retryRes = await fetch(url, retryInit); const retryAccepted = @@ -545,16 +567,25 @@ export async function testOAuthConnection( // #1444: read a 401/403 body so a deactivated account is labeled distinctly from a // revoked token. (The body is unread here for non-gitlab providers; the guard keeps - // it safe if it was already consumed.) + // it safe if it was already consumed.) antigravity/agy read any failure body so a + // geo-blocked egress location is labeled with an actionable message instead of a + // generic "API returned 400". const bodyText = - res.status === 401 || res.status === 403 ? await res.text().catch(() => "") : ""; - const error = isAccountDeactivatedMessage(bodyText) - ? "Account deactivated by the provider" - : res.status === 401 - ? "Token invalid or revoked" - : res.status === 403 - ? "Access denied" - : `API returned ${res.status}`; + res.status === 401 || + res.status === 403 || + connection.provider === "antigravity" || + connection.provider === "agy" + ? await res.text().catch(() => "") + : ""; + const error = isGeoBlockedError(bodyText) + ? "Egress location blocked by Google (User location is not supported). The Cloud Code API is not offered from this server's proxy exit region — route antigravity/agy through a proxy in a supported region (e.g. US/EU) or use a different provider. This is NOT an account problem." + : isAccountDeactivatedMessage(bodyText) + ? "Account deactivated by the provider" + : res.status === 401 + ? "Token invalid or revoked" + : res.status === 403 + ? "Access denied" + : `API returned ${res.status}`; return { valid: false, @@ -614,15 +645,7 @@ async function testApiKeyConnection(connection: any) { ? makeDiagnosis("ok", "upstream", null, null) : classifyFailure({ error, statusCode: result.statusCode, provider: connection.provider }); - return { - valid: !!result.valid, - error, - warning: result.warning || null, - diagnosis, - ...(Array.isArray((result as any).deployments) - ? { deployments: (result as any).deployments } - : {}), - }; + return buildApiKeyConnectionTestResult(result, error, diagnosis); } /** @@ -709,7 +732,8 @@ export async function testSingleConnection(connectionId: string, validationModel // failures a short cooldown so the lazy-recovery path retries them. const terminalTestStatuses = new Set(["banned", "expired", "credits_exhausted"]); const isTerminalFailure = - !result.valid && terminalTestStatuses.has(String(diagnosis.code ?? diagnosis.type ?? "").toLowerCase()); + !result.valid && + terminalTestStatuses.has(String(diagnosis.code ?? diagnosis.type ?? "").toLowerCase()); const testFailureCooldownMs = result.valid ? 0 : 30_000; // 30s retry window const updateData: Record = { diff --git a/src/app/api/providers/test-batch/route.ts b/src/app/api/providers/test-batch/route.ts index f8acb869d6..8daab38afd 100644 --- a/src/app/api/providers/test-batch/route.ts +++ b/src/app/api/providers/test-batch/route.ts @@ -12,6 +12,7 @@ import { AUDIO_ONLY_PROVIDERS, CLOUD_AGENT_PROVIDERS, IDE_PROVIDER_IDS, + getProviderConnectionFamilyIds, OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, } from "@/shared/constants/providers"; @@ -104,7 +105,8 @@ export async function POST(request) { const idSet = new Set(connectionIds || []); connectionsToTest = allConnections.filter((c) => idSet.has(c.id)); } else if (mode === "provider" && providerId) { - connectionsToTest = allConnections.filter((c) => c.provider === providerId); + const familyProviderIds = new Set(getProviderConnectionFamilyIds(providerId)); + connectionsToTest = allConnections.filter((c) => familyProviderIds.has(c.provider)); } else if (mode === "oauth") { connectionsToTest = allConnections.filter((c) => { const authGroup = getAuthGroup(c.provider); diff --git a/src/lib/acp/registry.ts b/src/lib/acp/registry.ts index 6945762409..a1cc297b3e 100644 --- a/src/lib/acp/registry.ts +++ b/src/lib/acp/registry.ts @@ -96,6 +96,15 @@ const AGENT_DEFINITIONS: Omit[] = [ spawnArgs: ["--no-auto-commits"], protocol: "stdio", }, + { + id: "zcode", + name: "ZCode (GLM Coding Plan)", + binary: "zcode", + versionCommand: "zcode --version", + providerAlias: "zcode", + spawnArgs: ["app-server"], + protocol: "stdio", + }, { id: "opencode", name: "OpenCode", diff --git a/src/lib/api/cliConfigWriteGuard.ts b/src/lib/api/cliConfigWriteGuard.ts new file mode 100644 index 0000000000..0ef2e4cf97 --- /dev/null +++ b/src/lib/api/cliConfigWriteGuard.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime"; +import { isContainerWriteRefusal } from "@/shared/utils/containerConfigGuard"; + +/** + * Shared gate for API routes that write a host CLI's config file. + * + * Returns `null` when the write may proceed, otherwise the response to send: + * - 422 + `containerEphemeralTarget` when OmniRoute runs in a container and + * the target is not bind-mounted from the host (the write would vanish), + * - 403 when CLI config writes are switched off entirely. + * + * Clients key off `containerEphemeralTarget` to render the host-CLI guidance + * inline, the same way the Zed import card handles its Docker 422. + */ +export function guardCliConfigWrite( + targetPath: string, + options: { toolLabel?: string; hostCommand?: string } = {} +): NextResponse | null { + const writeError = ensureCliConfigWriteAllowed(targetPath, options); + if (!writeError) return null; + + const containerEphemeralTarget = isContainerWriteRefusal(writeError); + return NextResponse.json( + { + error: writeError, + ...(containerEphemeralTarget + ? { containerEphemeralTarget, hostSetupCommand: options.hostCommand } + : {}), + }, + { status: containerEphemeralTarget ? 422 : 403 } + ); +} diff --git a/src/lib/db/adapters/sqljsAdapter.ts b/src/lib/db/adapters/sqljsAdapter.ts index 42abd2158a..886c52672b 100644 --- a/src/lib/db/adapters/sqljsAdapter.ts +++ b/src/lib/db/adapters/sqljsAdapter.ts @@ -127,10 +127,62 @@ export async function createSqlJsAdapter(filePath: string): Promise | null = null; let _isOpen = true; + /** + * Writes the whole database image out atomically: temp file in the SAME + * directory, fsync, then `rename()` over the destination. + * + * WHY NOT `writeFileSync(filePath, …)` DIRECTLY + * --------------------------------------------- + * sql.js has no incremental write path — every save rewrites the entire image. + * `writeFileSync` opens the destination with `O_TRUNC`, so for the whole + * duration of the write the on-disk database is 0 bytes and then partial. The + * window scales with the database size and recurs on every save, so on a busy + * instance it is open a significant fraction of the time. + * + * Unlike better-sqlite3 / node:sqlite, that window is not protected by SQLite's + * locking protocol, so it is visible to every OTHER process that reads the same + * file — a backup job, a metrics exporter, an operator running `sqlite3`. Those + * readers get `SQLITE_CORRUPT` ("database disk image is malformed") even though + * `PRAGMA integrity_check` passes moments later, which makes the failure look + * random and points the blame at the reader. + * + * `rename()` within a directory is atomic on POSIX and on Windows for a + * same-volume replace, so a reader now sees either the previous image or the + * new one — never a truncated one. It also removes the total-loss window: a + * crash mid-write used to leave the real database truncated, while it now only + * leaves a stale temp file behind. + */ function persist(): void { if (filePath === ":memory:") return; const data = db.export(); - fs.writeFileSync(filePath, Buffer.from(data)); + // Same directory, so `rename` stays within one filesystem — a temp file in + // os.tmpdir() would make it a cross-device copy, which is not atomic. + const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; + let fd: number | null = null; + try { + fd = fs.openSync(tmpPath, "w"); + fs.writeFileSync(fd, Buffer.from(data)); + // The rename is atomic, but only orders against data that already reached + // the disk; without this an unclean shutdown can publish an empty file. + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = null; + fs.renameSync(tmpPath, filePath); + } catch (err) { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + try { + fs.unlinkSync(tmpPath); + } catch { + /* never created, or already gone */ + } + throw err; + } dirty = false; } diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index c04c1fb75b..c8bf28ae45 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -212,7 +212,9 @@ export async function getSettings() { idempotencyWindowMs: 5000, wsAuth: false, maxBodySizeMb: requestBodyLimitMbFromEnv(process.env.MAX_BODY_SIZE_BYTES), - debugMode: true, + // #10312: opt-in only — a fresh install (or one missing the persisted key) + // must not run in debug mode; installs that persisted `true` keep it. + debugMode: false, // Opt-in diagnostic: when true, the chat handler emits a `log.debug("TOOLS", …)` // line per request summarizing tool count + MCP/hosted/client source breakdown. logToolSources: false, diff --git a/src/lib/guardrails/visionBridge.ts b/src/lib/guardrails/visionBridge.ts index 1fc032c374..85cd792512 100644 --- a/src/lib/guardrails/visionBridge.ts +++ b/src/lib/guardrails/visionBridge.ts @@ -3,6 +3,9 @@ * Intercepts image-bearing requests to non-vision models. * For individual non-vision models: reroutes to the fastest available vision-capable model. * For combos with non-vision targets: extracts descriptions via vision model and replaces images with text. + * For combos with ZERO vision-capable targets: falls back to whole-request reroute to a + * vision-capable model (same semantics as an individual text-only model), so image + * requests do not die in the combo capability filter when describing is impossible. */ import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base"; @@ -31,7 +34,7 @@ import { export { isProviderConnectionUsable, hasUsableCredentialsForModel }; -type ComboVisionBridgeDecision = "process" | "skip" | "not-combo"; +type ComboVisionBridgeDecision = "process" | "skip" | "not-combo" | "no-vision"; export function resolveVisionComboName(mapping: Record): string | null { const comboName = mapping.comboName ?? mapping.name ?? null; @@ -40,10 +43,15 @@ export function resolveVisionComboName(mapping: Record): string /// Check if a combo model should trigger vision bridge processing. /// Resolves combo targets and returns: -/// - "process" if any target cannot be proven vision-capable +/// - "process" if some (but not all) model targets lack proven vision support /// - "skip" if all model targets can handle images directly +/// - "no-vision" when the combo has model targets but NONE can handle images — +/// the combo behaves like a single text-only model, so the bridge may +/// whole-request reroute to a vision-capable model (mirroring non-combos) /// - "not-combo" when the model is not a combo/mapping -async function getComboVisionBridgeDecision(model: string): Promise { +export async function getComboVisionBridgeDecision( + model: string +): Promise { try { const { getComboByName } = await import("@/lib/localDb"); const { resolveComboForModel } = await import("@/lib/db/modelComboMappings"); @@ -70,7 +78,10 @@ async function getComboVisionBridgeDecision(model: string): Promise; if (s.kind === "combo-ref") return "process"; @@ -79,8 +90,10 @@ async function getComboVisionBridgeDecision(model: string): Promise d === null); - if (allNull && comboVisionBridgeDecision === "process") { + if ( + allNull && + (comboVisionBridgeDecision === "process" || comboVisionBridgeDecision === "no-vision") + ) { for (let i = 0; i < descriptions.length; i++) { descriptions[i] = `[Image ${i + 1}]: (unavailable — no vision-capable provider connected)`; } diff --git a/src/lib/monitoring/providerHealthAutopilot.ts b/src/lib/monitoring/providerHealthAutopilot.ts index 8fc54e2d86..e29844da36 100644 --- a/src/lib/monitoring/providerHealthAutopilot.ts +++ b/src/lib/monitoring/providerHealthAutopilot.ts @@ -1,11 +1,9 @@ import { createHash } from "crypto"; -import { - getProviderConnections, - updateProviderConnection, -} from "@/lib/db/providers"; +import { getProviderConnections, updateProviderConnection } from "@/lib/db/providers"; import { getCachedProviderConnectionById } from "@/lib/localDb"; import { clearProviderFailure, clearModelLock } from "@omniroute/open-sse/services/accountFallback"; +import { resolveProviderAlias } from "@omniroute/open-sse/services/model"; type JsonRecord = Record; @@ -113,6 +111,11 @@ function toString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } +function canonicalProviderId(value: unknown): string | null { + const provider = toString(value); + return provider ? (resolveProviderAlias(provider) ?? provider) : null; +} + function toNumber(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { @@ -253,7 +256,7 @@ export async function buildProviderHealthAutopilotReport( const checkedAt = new Date(now).toISOString(); const includeHealthy = options.includeHealthy === true; const includeActions = options.includeActions !== false; - const providerFilter = toString(options.provider); + const providerFilter = canonicalProviderId(options.provider); const [{ getAllCircuitBreakerStatuses }, { getAllModelLockouts }, quotaMonitor] = await Promise.all([ @@ -262,40 +265,45 @@ export async function buildProviderHealthAutopilotReport( import("@omniroute/open-sse/services/quotaMonitor.ts").catch(() => null), ]); - const connections = (await getProviderConnections( - providerFilter ? { provider: providerFilter } : {} - )) as JsonRecord[]; + // Connections generally use canonical ids, while breakers, lockouts, and quota + // snapshots can retain the alias used at dispatch time. Normalize the aggregation + // key, but preserve each raw source id for actions that must mutate runtime state. + const connections = ((await getProviderConnections({})) as JsonRecord[]).filter((connection) => { + const provider = canonicalProviderId(connection.provider); + return provider && (!providerFilter || provider === providerFilter); + }); const breakers = getAllCircuitBreakerStatuses().filter((breaker) => { const name = toString((breaker as JsonRecord).name); - if (!name || name.startsWith("test-") || name.startsWith("test_")) return false; - return !providerFilter || name === providerFilter; + const provider = canonicalProviderId(name); + if (!name || !provider || name.startsWith("test-") || name.startsWith("test_")) return false; + return !providerFilter || provider === providerFilter; }); const lockouts = (getAllModelLockouts() as JsonRecord[]).filter((lockout) => { - const provider = providerFromLockout(lockout); + const provider = canonicalProviderId(providerFromLockout(lockout)); return provider && (!providerFilter || provider === providerFilter); }); const quotaSnapshots = quotaMonitor?.getQuotaMonitorSnapshots ? (quotaMonitor.getQuotaMonitorSnapshots() as JsonRecord[]).filter((snapshot) => { - const provider = toString(snapshot.provider); + const provider = canonicalProviderId(snapshot.provider); return provider && (!providerFilter || provider === providerFilter); }) : []; const providerIds = new Set(); for (const connection of connections) { - const provider = toString(connection.provider); + const provider = canonicalProviderId(connection.provider); if (provider) providerIds.add(provider); } for (const breaker of breakers) { - const provider = toString((breaker as JsonRecord).name); + const provider = canonicalProviderId((breaker as JsonRecord).name); if (provider) providerIds.add(provider); } for (const lockout of lockouts) { - const provider = providerFromLockout(lockout); + const provider = canonicalProviderId(providerFromLockout(lockout)); if (provider) providerIds.add(provider); } for (const snapshot of quotaSnapshots) { - const provider = toString(snapshot.provider); + const provider = canonicalProviderId(snapshot.provider); if (provider) providerIds.add(provider); } if (providerFilter) providerIds.add(providerFilter); @@ -303,19 +311,21 @@ export async function buildProviderHealthAutopilotReport( const providers: ProviderAutopilotProvider[] = []; for (const provider of [...providerIds].sort()) { const providerConnections = connections.filter( - (connection) => connection.provider === provider + (connection) => canonicalProviderId(connection.provider) === provider ); - const breaker = breakers.find((entry) => (entry as JsonRecord).name === provider) as - | JsonRecord - | undefined; + const breaker = breakers.find( + (entry) => canonicalProviderId((entry as JsonRecord).name) === provider + ) as JsonRecord | undefined; const providerLockouts = lockouts.filter( - (lockout) => providerFromLockout(lockout) === provider + (lockout) => canonicalProviderId(providerFromLockout(lockout)) === provider + ); + const providerQuota = quotaSnapshots.filter( + (snapshot) => canonicalProviderId(snapshot.provider) === provider ); - const providerQuota = quotaSnapshots.filter((snapshot) => snapshot.provider === provider); const issues: ProviderAutopilotIssue[] = []; if (breaker && OPEN_BREAKER_STATES.has(String(breaker.state))) { - const target = { provider }; + const target = { provider: toString(breaker.name) ?? provider }; const evidence = { state: breaker.state, failureCount: toNumber(breaker.failureCount) ?? 0, @@ -342,7 +352,7 @@ export async function buildProviderHealthAutopilotReport( for (const connection of providerConnections) { const connectionId = toString(connection.id); if (!connectionId) continue; - const target = { provider, connectionId }; + const target = { provider: toString(connection.provider) ?? provider, connectionId }; const label = sanitizeConnectionLabel(connection); const cooldownUntil = parseTimeMs(connection.rateLimitedUntil); const terminal = isTerminalConnection(connection); @@ -450,7 +460,11 @@ export async function buildProviderHealthAutopilotReport( if (!connectionId || !model) continue; const connection = providerConnections.find((entry) => entry.id === connectionId); const terminalConnection = connection ? isTerminalConnection(connection) : false; - const target = { provider, connectionId, model }; + const target = { + provider: providerFromLockout(lockout) ?? provider, + connectionId, + model, + }; const evidence = { reason: lockout.reason ?? null, remainingMs: toNumber(lockout.remainingMs) ?? 0, @@ -483,7 +497,10 @@ export async function buildProviderHealthAutopilotReport( if (!status || !["warning", "exhausted", "error"].includes(status)) continue; const connectionId = toString(snapshot.accountId) ?? undefined; const sessionId = toString(snapshot.sessionId) ?? undefined; - const target = { provider, ...(connectionId ? { connectionId } : {}) }; + const target = { + provider: toString(snapshot.provider) ?? provider, + ...(connectionId ? { connectionId } : {}), + }; issues.push({ id: issueId("quota_monitor_warning", { ...target, diff --git a/src/lib/monitoring/providerHealthMatrix.ts b/src/lib/monitoring/providerHealthMatrix.ts index dcc99a3830..b1ff287bae 100644 --- a/src/lib/monitoring/providerHealthMatrix.ts +++ b/src/lib/monitoring/providerHealthMatrix.ts @@ -3,6 +3,7 @@ import { getProviderConnections } from "@/lib/db/providers"; import { getDbInstance } from "@/lib/db/core"; import { getAllCircuitBreakerStatuses } from "@/shared/utils/circuitBreaker"; import { getAllModelLockouts } from "@omniroute/open-sse/services/accountFallback"; +import { resolveProviderAlias } from "@omniroute/open-sse/services/model"; import { getWebSessionPoolHealth } from "@omniroute/open-sse/services/webSessionPoolHealth"; type JsonRecord = Record; @@ -133,6 +134,11 @@ function toString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } +function canonicalProviderId(value: unknown): string | null { + const provider = toString(value); + return provider ? (resolveProviderAlias(provider) ?? provider) : null; +} + function toNumber(value: unknown): number { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { @@ -345,40 +351,46 @@ export async function buildProviderHealthMatrix( const checkedAt = new Date(now).toISOString(); const range = normalizeRange(options.range); const cutoff = new Date(now - RANGE_MS[range]).toISOString(); - const providerFilter = toString(options.provider); + const providerFilter = canonicalProviderId(options.provider); const includeHealthy = options.includeHealthy !== false; - const [connections, breakers, lockouts, stats] = await Promise.all([ - getProviderConnections(providerFilter ? { provider: providerFilter } : {}), + // Connections use canonical ids while circuit breakers, lockouts, and historical + // call logs can retain the alias used at dispatch time. Normalize all sources here + // so a provider has one health row with every related signal attached. + const [connections, breakers, lockouts, rawStats] = await Promise.all([ + getProviderConnections({}), getAllCircuitBreakerStatuses(), getAllModelLockouts(), - Promise.resolve(queryCallLogTargetStats(cutoff, providerFilter)), + Promise.resolve(queryCallLogTargetStats(cutoff, null)), ]); const connectionRows = (connections as JsonRecord[]).filter((connection) => { - const provider = toString(connection.provider); + const provider = canonicalProviderId(connection.provider); return provider && (!providerFilter || provider === providerFilter); }); const breakerRows = (breakers as JsonRecord[]).filter((breaker) => { - const provider = toString(breaker.name); + const provider = canonicalProviderId(breaker.name); return provider && (!providerFilter || provider === providerFilter); }); const lockoutRows = (lockouts as JsonRecord[]).filter((lockout) => { - const provider = toString(lockout.provider); + const provider = canonicalProviderId(lockout.provider); return provider && (!providerFilter || provider === providerFilter); }); + const stats = rawStats + .map((row) => ({ ...row, provider: canonicalProviderId(row.provider) ?? row.provider })) + .filter((row) => !providerFilter || row.provider === providerFilter); const providerIds = new Set(); for (const connection of connectionRows) { - const provider = toString(connection.provider); + const provider = canonicalProviderId(connection.provider); if (provider) providerIds.add(provider); } for (const breaker of breakerRows) { - const provider = toString(breaker.name); + const provider = canonicalProviderId(breaker.name); if (provider) providerIds.add(provider); } for (const lockout of lockoutRows) { - const provider = toString(lockout.provider); + const provider = canonicalProviderId(lockout.provider); if (provider) providerIds.add(provider); } for (const row of stats) providerIds.add(row.provider); @@ -407,7 +419,7 @@ export async function buildProviderHealthMatrix( const lockoutsByTarget = new Map(); const lockoutCountByProvider = new Map(); for (const lockout of lockoutRows) { - const provider = toString(lockout.provider); + const provider = canonicalProviderId(lockout.provider); const connectionId = toString(lockout.connectionId); const model = toString(lockout.model); if (!provider || !model) continue; @@ -418,10 +430,12 @@ export async function buildProviderHealthMatrix( const providers: ProviderHealthMatrixProvider[] = []; for (const provider of [...providerIds].sort()) { const providerConnections = connectionRows.filter( - (connection) => toString(connection.provider) === provider + (connection) => canonicalProviderId(connection.provider) === provider ); const providerStats = stats.filter((row) => row.provider === provider); - const providerBreaker = breakerRows.find((breaker) => toString(breaker.name) === provider); + const providerBreaker = breakerRows.find( + (breaker) => canonicalProviderId(breaker.name) === provider + ); const circuitBreaker = providerBreaker ? { state: toString(providerBreaker.state) || "CLOSED", @@ -443,7 +457,7 @@ export async function buildProviderHealthMatrix( if (!accountRows.has(key)) accountRows.set(key, null); } for (const lockout of lockoutRows) { - if (toString(lockout.provider) !== provider) continue; + if (canonicalProviderId(lockout.provider) !== provider) continue; const key = accountKey(provider, toString(lockout.connectionId)); if (!accountRows.has(key)) accountRows.set(key, null); } @@ -466,7 +480,7 @@ export async function buildProviderHealthMatrix( modelIds.add(stat.model); } for (const lockout of lockoutRows) { - if (toString(lockout.provider) !== provider) continue; + if (canonicalProviderId(lockout.provider) !== provider) continue; if ((toString(lockout.connectionId) ?? "") !== (connectionId ?? "")) continue; const model = toString(lockout.model); if (model) modelIds.add(model); diff --git a/src/lib/providers/catalog.ts b/src/lib/providers/catalog.ts index 47cae957d7..1f53542c6b 100644 --- a/src/lib/providers/catalog.ts +++ b/src/lib/providers/catalog.ts @@ -51,6 +51,8 @@ export interface ProviderCatalogMetadata { riskNoticeVariant?: RiskNoticeVariant; apiType?: string; baseUrl?: string; + /** Backend OAuth provider ID when one dashboard card fronts both auth modes. */ + oauthProviderId?: string; hiddenFromDashboard?: boolean; /** Optional operator-supplied remote icon URL (#2166) for compatible provider nodes. */ iconUrl?: string; diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 2d05ece895..5efff93cb9 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -81,6 +81,7 @@ import { validatePoeProvider, } from "./validation/audioMiscProviders"; import { validateChatGptWebCodexProvider } from "./validation/chatgptWebCodex"; +import { validateZaiWebProvider } from "./validation/zaiWeb"; import { validateSearchProvider, SEARCH_VALIDATOR_CONFIGS } from "./validation/searchProviders"; import { validateClarifaiProvider, @@ -227,7 +228,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi error: "Modal requires a Base URL pointing to your OpenAI-compatible Modal app " + "(e.g. https://--.modal.run/v1). " + - "Fill in the \"Base URL override\" field.", + 'Fill in the "Base URL override" field.', }; } return validateOpenAILikeProvider({ @@ -249,6 +250,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi snowflake: validateSnowflakeProvider, gigachat: validateGigachatProvider, "deepseek-web": validateDeepSeekWebProvider, + "zai-web": validateZaiWebProvider, "grok-web": validateGrokWebProvider, "qwen-web": validateQwenWebProvider, "kimi-web": validateKimiWebProvider, diff --git a/src/lib/providers/validation/zaiWeb.ts b/src/lib/providers/validation/zaiWeb.ts new file mode 100644 index 0000000000..6d151d4130 --- /dev/null +++ b/src/lib/providers/validation/zaiWeb.ts @@ -0,0 +1,52 @@ +import { extractZaiToken } from "@omniroute/open-sse/services/zaiWebCredentials.ts"; +import { toValidationErrorResult, validationRead } from "./transport"; + +const ZAI_SESSION_PROBE_URL = "https://chat.z.ai/api/v1/users/user/settings"; + +export async function validateZaiWebProvider({ apiKey }: { apiKey?: string }) { + const token = extractZaiToken(String(apiKey || "")); + + if (!token) { + return { + valid: false, + error: + 'Invalid Z.ai web-session credential — copy the "token" value from chat.z.ai Local Storage.', + }; + } + + try { + const response = await validationRead(ZAI_SESSION_PROBE_URL, { + method: "GET", + headers: { + Accept: "application/json, text/plain, */*", + Authorization: `Bearer ${token}`, + Origin: "https://chat.z.ai", + Referer: "https://chat.z.ai/", + }, + }); + + if (response.status >= 200 && response.status < 300) { + return { + valid: true, + error: null, + }; + } + + if (response.status === 401) { + return { + valid: false, + error: + 'Invalid or expired Z.ai web-session credential — copy a fresh "token" value from chat.z.ai Local Storage.', + statusCode: 401, + }; + } + + return { + valid: false, + error: `Z.ai session validation returned HTTP ${response.status}`, + statusCode: response.status, + }; + } catch (error: unknown) { + return toValidationErrorResult(error); + } +} diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index d66927f96a..acdba14050 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -85,6 +85,25 @@ export const CLI_TOOLS: Record = { baseUrlSupport: "full", defaultCommand: "codex", }, + zcode: { + id: "zcode", + name: "ZCode (GLM Coding Plan)", + color: "#3B82F6", + description: "Local ZCode app-server backend; auth remains in the user's ZCode profile", + docsUrl: "https://zcode.z.ai", + configType: "custom", + category: "code", + vendor: "Z.ai", + // ZCode's app-server is a native length-prefixed protocol, not ACP. The + // zcode provider executor owns its lifecycle instead of ACP spawning it. + acpSpawnable: false, + baseUrlSupport: "none", + defaultCommand: "zcode", + notes: [ + { type: "info", text: "Uses the local ZCode app-server and its existing builtin:zai-coding-plan login." }, + { type: "warning", text: "The response is buffered until the ZCode turn completes." }, + ], + }, droid: { id: "droid", name: "Factory Droid", diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 4e0a8a4477..ed7e74c652 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -164,6 +164,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "danger", }, + { + key: "NETWORK_ROTATION_SHARED_EGRESS_GUARD", + label: "Network Rotation Shared-Egress Guard", + description: + "On a network exception (timeout, connection refused/reset) for a multi-account rotation executor, when the failing account has no dedicated proxy, apply a short cooldown and skip other proxy-less accounts for the rest of the request instead of retrying each one. On by default (safe: no egress IP change, only reduces latency/cooldown risk on shared-egress accounts). Disable to restore immediate propagation on the first proxy-less throw.", + descriptionI18nKey: "featureFlagNetworkRotationSharedEgressGuardDescription", + category: "network", + defaultValue: "true", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, { key: "MITM_DISABLE_TLS_VERIFY", label: "Disable TLS Verify (MITM)", diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index ebf1f2ca1d..aef1437926 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -174,10 +174,12 @@ export const MODEL_SPECS: Record = { thinkingBudgetCap: 0, }, - // ── Gemini 3.6 Flash (Antigravity live tiers) ─────────────────── + // ── Gemini 3.7 / 3.6 Flash (Antigravity live tiers) ───────────── // The model id itself selects the upstream 10k/4k/1k reasoning tier. Antigravity // still rejects client-supplied thinking parameters, so keep the explicit-parameter // capability aligned with the existing Gemini 3.5 tier ids. + "gemini-3.7-flash-high": { ...GEMINI_35_FLASH_MODEL_SPEC }, + "gemini-3.7-flash-medium": { ...GEMINI_35_FLASH_MODEL_SPEC }, "gemini-3.6-flash-high": { ...GEMINI_35_FLASH_MODEL_SPEC }, "gemini-3.6-flash-medium": { ...GEMINI_35_FLASH_MODEL_SPEC }, "gemini-3.6-flash-low": { ...GEMINI_35_FLASH_MODEL_SPEC }, diff --git a/src/shared/constants/pricing/frontier-labs.ts b/src/shared/constants/pricing/frontier-labs.ts index 59e580e971..a7cbf79935 100644 --- a/src/shared/constants/pricing/frontier-labs.ts +++ b/src/shared/constants/pricing/frontier-labs.ts @@ -315,20 +315,20 @@ export const DEFAULT_PRICING_FRONTIER = { reasoning: 2.19, cache_creation: 0.55, }, - // DeepSeek V4 Pro — promo until 2026-05-31, then list ($0.145 / $3.48) + // DeepSeek official API list prices, checked 2026-08-13. "deepseek-v4-pro": { input: 0.435, output: 0.87, - cached: 0.0036, + cached: 0.003625, reasoning: 0.87, cache_creation: 0.435, }, "deepseek-v4-flash": { - input: 0.07, + input: 0.14, output: 0.28, - cached: 0.014, + cached: 0.0028, reasoning: 0.28, - cache_creation: 0.07, + cache_creation: 0.14, }, }, blackbox: { @@ -340,6 +340,15 @@ export const DEFAULT_PRICING_FRONTIER = { "blackboxai-pro": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, }, xai: { + // The static rate covers prompts below 200K tokens. xAI's provider-reported + // cost_in_usd_ticks remains authoritative for the >=200K pricing tier. + "grok-4.6": { + input: 2.0, + output: 6.0, + cached: 0.5, + reasoning: 6.0, + cache_creation: 2.0, + }, "grok-3": { input: 3.0, output: 15.0, diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 143a11a45d..526ec3ed9a 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -38,22 +38,43 @@ export const FREE_APIKEY_PROVIDER_IDS = new Set([ // accepts an optional connection row for display/priority/testStatus tracking — // no apiKey is ever required or sent upstream. "auggie", + // zcode is a local app-server backend; auth stays in the ZCode profile. + "zcode", ]); export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean { return typeof providerId === "string" && FREE_APIKEY_PROVIDER_IDS.has(providerId); } -// OAuth-primary providers that also accept a direct API key. Keep these out of -// FREE_APIKEY_PROVIDER_IDS so the dashboard's primary action remains OAuth. -const DUAL_AUTH_PROVIDER_IDS = new Set(["clinepass", "codebuddy-cn"]); +// Providers presented as one dashboard card with OAuth as the primary action +// and a direct API-key alternative. Keep these out of FREE_APIKEY_PROVIDER_IDS. +const DUAL_AUTH_PROVIDER_IDS = new Set(["clinepass", "codebuddy-cn", "xai"]); export function supportsDualAuthProvider(providerId: unknown): boolean { return typeof providerId === "string" && DUAL_AUTH_PROVIDER_IDS.has(providerId); } -// Web / Cookie Providers +/** + * Backend provider IDs that are managed from one dashboard provider family. + * + * Family members intentionally remain distinct in the registry and database: + * the xAI OAuth ID has different token-refresh and quota semantics from the + * API-key ID. Consumers that need to list or test every connection for a + * family should use getProviderConnectionFamilyIds() rather than duplicating + * this compatibility map. + */ +export const PROVIDER_CONNECTION_FAMILY_ALIASES: Readonly> = { + alibaba: ["alibaba-cn"], + "kimi-coding": ["kimi-coding-apikey"], + xai: ["xai-oauth", "xao"], +}; +export function getProviderConnectionFamilyIds(providerId: unknown): readonly string[] { + if (typeof providerId !== "string" || providerId.length === 0) return []; + return [providerId, ...(PROVIDER_CONNECTION_FAMILY_ALIASES[providerId] || [])]; +} + +// Web / Cookie Providers // API Key Providers diff --git a/src/shared/constants/providers/apikey/frontier-labs.ts b/src/shared/constants/providers/apikey/frontier-labs.ts index cd1f98975a..45faf24359 100644 --- a/src/shared/constants/providers/apikey/frontier-labs.ts +++ b/src/shared/constants/providers/apikey/frontier-labs.ts @@ -101,6 +101,13 @@ export const APIKEY_PROVIDERS_FRONTIER = { textIcon: "XA", website: "https://x.ai", serviceKinds: ["llm", "imageToText"], + subscriptionRisk: true, + riskNoticeVariant: "oauth", + authHint: + "Use an official xAI API key, or sign in with xAI OAuth. Grok Build JWT sessions remain a separate provider.", + // The dashboard presents xAI as one dual-auth provider while preserving + // the separate backend OAuth provider ID for token refresh and quota flow. + oauthProviderId: "xai-oauth", }, mistral: { id: "mistral", diff --git a/src/shared/constants/providers/apikey/inference-hosts.ts b/src/shared/constants/providers/apikey/inference-hosts.ts index 2830e691a3..470ad9ea57 100644 --- a/src/shared/constants/providers/apikey/inference-hosts.ts +++ b/src/shared/constants/providers/apikey/inference-hosts.ts @@ -310,7 +310,7 @@ export const APIKEY_PROVIDERS_INFERENCE = { "One-time signup trial credits for decentralized GPU inference (no recurring free plan). No credit card required.", passthroughModels: true, authHint: "Get API key at monsterapi.ai", - isDeprecated: true, + deprecated: true, deprecationReason: "Monster API shuttered operations on 2026-06-30. Use alternative OpenAI-compatible providers.", }, diff --git a/src/shared/constants/providers/noauth.ts b/src/shared/constants/providers/noauth.ts index 7b7246beba..b572b68888 100644 --- a/src/shared/constants/providers/noauth.ts +++ b/src/shared/constants/providers/noauth.ts @@ -157,6 +157,24 @@ export const NOAUTH_PROVIDERS = { text: "Augment (Auggie CLI) requires the `auggie` binary installed and authenticated locally (`auggie login`). OmniRoute spawns it as a subprocess and never sees or stores your Augment credentials.", }, }, + zcode: { + id: "zcode", + alias: "zc", + name: "ZCode (GLM Coding Plan)", + icon: "terminal", + color: "#3B82F6", + textIcon: "ZC", + website: "https://zcode.z.ai", + noAuth: true, + hasFree: false, + serviceKinds: ["llm"], + isLocalCli: true, + authHint: + "No API key stored by OmniRoute. The local ZCode app-server uses the existing builtin:zai-coding-plan login.", + notice: { + text: "ZCode runs locally through its native app-server. OmniRoute never receives or stores the Z.ai credential.", + }, + }, aihorde: { id: "aihorde", alias: "horde", diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index c9237f0711..ec2faa8a8c 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -26,6 +26,9 @@ export const OAUTH_PROVIDERS = { website: "https://x.ai", subscriptionRisk: true, riskNoticeVariant: "oauth", + // Render xAI OAuth through the unified xAI dashboard card. Keep this + // catalog entry addressable for existing routes and stored connections. + hiddenFromDashboard: true, authHint: "Sign in with xAI to use api.x.ai models such as Grok 4.5. This is separate from Grok Build JWT sessions, which use cli-chat-proxy.grok.com and grok-build model aliases.", }, diff --git a/src/shared/middleware/chatBodyAdmission.ts b/src/shared/middleware/chatBodyAdmission.ts index 290a18f6db..e85fce30ec 100644 --- a/src/shared/middleware/chatBodyAdmission.ts +++ b/src/shared/middleware/chatBodyAdmission.ts @@ -7,20 +7,17 @@ * local heavyweight capacity before parsing and enforces the hard limit against bytes read, * not an untrusted Content-Length header. * - * Per-connection virtual admission lanes (#9654): each distinct API-key (or anonymous) - * bucket gets its own FairCostQueue so one connection cannot exhaust heavyweight capacity - * and starve others. Idle sessions are auto-evicted after a TTL. + * Process-wide admission budget (#10110): ALL requests — every API key, every + * session — contend for ONE global heavyweight budget, so the documented + * "in one process" bound holds against fake-credential sharding. Per-request + * session identity is used only as a fairness scheduling key: waiters are + * grouped per session and served round-robin against the shared budget, so one + * connection's burst cannot starve others (#9654). */ import { CORS_HEADERS } from "../utils/cors"; import { createHash } from "crypto"; - -const OMNIROUTE_CHAT_VIRTUAL_TTL_MS = parsePositiveInt( - process.env.OMNIROUTE_CHAT_VIRTUAL_TTL_MS, - 60_000 -); - function parsePositiveInt(value: string | undefined, fallback: number): number { const parsed = Number.parseInt(String(value), 10); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; @@ -109,6 +106,12 @@ export interface ChatAdmissionLease { release(): void; } +/** A parked waiter, grouped by fairness key for round-robin dispatch. */ +interface AdmissionWaiter { + readonly key: string; + readonly resolve: () => void; +} + /** * Process-local heavyweight reservation. The capacity check and increment execute in one * synchronous JavaScript turn, making acquisition atomic within an OmniRoute process. @@ -119,7 +122,13 @@ export interface ChatAdmissionLease { export class ChatAdmissionController { #activeHeavy = 0; #queuedBytes = 0; - #waiters: Array<() => void> = []; + /** Per-key FIFOs. A key groups one client's waiters so they are served + * round-robin against the shared budget instead of monopolizing a strict + * FIFO (see #dispatchFair). */ + #queues = new Map(); + /** Keys in creation order; #fairCursor scans them round-robin. */ + #fairKeys: string[] = []; + #fairCursor = 0; constructor( readonly maxHeavyInFlight = 1, @@ -137,11 +146,25 @@ export class ChatAdmissionController { return this.#activeHeavy; } - /** Total buffered bytes currently parked in the FIFO (heap valve accounting). */ + /** Total buffered bytes currently parked across all queues (heap valve accounting). */ get queuedBytes(): number { return this.#queuedBytes; } + /** Total waiters parked across all keys (diagnostics). */ + get waitingCount(): number { + let total = 0; + for (const queue of this.#queues.values()) total += queue.length; + return total; + } + + /** Per-key waiter depths (diagnostics) — opaque scheduler keys, never raw credentials. */ + get waitersByKey(): ReadonlyArray<{ key: string; waiting: number }> { + const out: Array<{ key: string; waiting: number }> = []; + for (const [key, queue] of this.#queues) out.push({ key, waiting: queue.length }); + return out; + } + tryAcquireHeavy(): ChatAdmissionLease | null { if (this.#activeHeavy >= this.maxHeavyInFlight) return null; this.#activeHeavy += 1; @@ -154,7 +177,7 @@ export class ChatAdmissionController { if (released) return; released = true; this.#activeHeavy = Math.max(0, this.#activeHeavy - 1); - this.#waiters.shift()?.(); + this.#dispatchFair(); }, }; } @@ -163,10 +186,14 @@ export class ChatAdmissionController { * Wait up to `timeoutMs` for heavyweight capacity, retrying atomically on each * release. Resolves `null` when the deadline expires with no capacity freed, in * which case the caller answers the retryable 503. `timeoutMs <= 0` is the - * legacy immediate-reject path. Waiters are served FIFO. + * legacy immediate-reject path. + * + * Waiters are grouped by `sessionKey` and served round-robin across keys + * (#dispatchFair), so one client's burst cannot starve another's bounded wait + * while every key contends for the SAME process-wide budget. * * When `signal` aborts while parked (client disconnect), the waiter is removed - * from the FIFO immediately and the promise resolves `null` early instead of + * from its queue immediately and the promise resolves `null` early instead of * parking for the full `timeoutMs` — the caller's 503 is dropped on the dead * connection, so no capacity is consumed and the freed slot never wakes a * waiter the client no longer needs. A signal that is already aborted never @@ -181,7 +208,8 @@ export class ChatAdmissionController { async acquireHeavyWithin( timeoutMs: number, signal?: AbortSignal, - queuedBytes = 0 + queuedBytes = 0, + sessionKey = "default" ): Promise { const deadline = Date.now() + Math.max(0, Math.floor(timeoutMs)); for (;;) { @@ -195,14 +223,26 @@ export class ChatAdmissionController { return null; } this.#queuedBytes += queuedBytes; - let resolver: (() => void) | null = null; - const released = new Promise((resolve) => { - resolver = () => resolve(); - this.#waiters.push(resolver); + // Park into this key's FIFO (creating the key on first use). + let queue = this.#queues.get(sessionKey); + if (!queue) { + queue = []; + this.#queues.set(sessionKey, queue); + this.#fairKeys.push(sessionKey); + } + const lane = queue; + let resolveParked: (() => void) | null = null; + const waiter: AdmissionWaiter = { + key: sessionKey, + resolve: () => resolveParked?.(), + }; + const parked = new Promise((resolve) => { + resolveParked = () => resolve(); + lane.push(waiter); }); let deadlineTimer: ReturnType | null = null; const races: Array> = [ - released.then(() => false), + parked.then(() => false), new Promise((resolve) => { deadlineTimer = setTimeout(() => resolve(true), remaining); }), @@ -220,41 +260,80 @@ export class ChatAdmissionController { ); } const timedOut = await Promise.race(races); - // The waiter has left the FIFO (wake, abort, or timeout) — release its charge. + // The waiter has left its queue (wake, abort, or timeout) — release its charge. this.#queuedBytes = Math.max(0, this.#queuedBytes - queuedBytes); - if (resolver) { - const index = this.#waiters.indexOf(resolver); - if (index >= 0) this.#waiters.splice(index, 1); - } + this.#removeWaiter(waiter); // Cancel the deadline timer when abort/release wins; a fired timer is a no-op. if (deadlineTimer) clearTimeout(deadlineTimer); if (onAbort) signal?.removeEventListener("abort", onAbort); if (timedOut) return null; } } + + /** Remove a parked waiter from its key's queue, dropping empty keys. Idempotent. */ + #removeWaiter(waiter: AdmissionWaiter): void { + const queue = this.#queues.get(waiter.key); + if (!queue) return; + const index = queue.indexOf(waiter); + if (index >= 0) queue.splice(index, 1); + if (queue.length === 0) this.#removeFairKey(waiter.key); + } + + #removeFairKey(key: string): void { + this.#queues.delete(key); + const index = this.#fairKeys.indexOf(key); + if (index < 0) return; + this.#fairKeys.splice(index, 1); + if (index < this.#fairCursor) this.#fairCursor -= 1; + if (this.#fairKeys.length === 0) this.#fairCursor = 0; + } + + /** + * Round-robin dispatch across per-key queues (#9654 fairness, #10110 global + * budget). Called on every release; wakes exactly ONE waiter — the head of + * the next key in rotation — so the freed slot is claimed atomically by the + * woken waiter's re-loop. A strict FIFO would let one client's burst consume + * every freed slot; rotating the cursor gives each contending key a turn. + */ + #dispatchFair(): void { + if (this.#fairKeys.length === 0) return; + for (let i = 0; i < this.#fairKeys.length; i++) { + const key = this.#fairKeys[this.#fairCursor % this.#fairKeys.length]; + this.#fairCursor += 1; + const queue = this.#queues.get(key); + if (!queue || queue.length === 0) continue; + const waiter = queue.shift() as AdmissionWaiter; + if (queue.length === 0) this.#removeFairKey(key); + waiter.resolve(); + return; + } + } } const defaultAdmissionController = new ChatAdmissionController(CHAT_MAX_HEAVY_IN_FLIGHT); /** - * Per-connection virtual admission lanes (#9654). + * Process-wide byte-level admission budget (#10110). * - * Maps a sessionId (API-key hash or "anonymous") → ChatAdmissionController. - Each connection gets its own bounded heavyweight capacity so one connection - * cannot exhaust `CHAT_MAX_HEAVY_IN_FLIGHT` and starve others at the byte-level - * admission stage. + * Every request — every session, every API key — admits against ONE global + * ChatAdmissionController, so `CHAT_MAX_HEAVY_IN_FLIGHT` and + * `CHAT_ADMISSION_MAX_QUEUED_BYTES` are enforced process-wide, exactly as + * documented in docs/reference/ENVIRONMENT.md. The pre-#10110 design minted a + * per-session controller per request, multiplying the process bound by up to + * 64 lanes and letting unauthenticated fake credentials shard capacity. * - * Idle sessions are auto-evicted after OMNIROUTE_CHAT_VIRTUAL_TTL_MS - * (default 60s) to prevent unbounded Map growth. + * Per-request session identity survives ONLY as a fairness scheduling key: + * waiters are grouped per key and served round-robin against the shared + * budget (ChatAdmissionController#dispatchFair), preserving the #9654 + * guarantee that one connection's burst cannot starve others — without any + * per-key capacity being allocated. */ -const OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS = parsePositiveInt( - process.env.OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS, - 64 -); export function resolveSessionId(request: Request): string { - // Reuse the existing internal-bypass auth extraction: bearer token from - // Authorization, x-api-key (Anthropic-style), or Google API key header. + // Fairness scheduling key ONLY (never a capacity shard): hashed so raw key + // material never appears in diagnostics. Reuses the internal-bypass auth + // extraction: bearer token from Authorization, x-api-key (Anthropic-style), + // or Google API key header. const authHeader = request.headers.get("authorization") || ""; const bearerMatch = /^bearer\s+(\S+)$/i.exec(authHeader.trim()); if (bearerMatch) { @@ -271,106 +350,63 @@ export function resolveSessionId(request: Request): string { return "anonymous"; } -interface SessionRecord { - controller: ChatAdmissionController; - lastUsedMs: number; -} - export class PerConnectionAdmissionController { - #sessions = new Map(); - #evictionTimer: ReturnType | null = null; - readonly maxSessions: number; - readonly sessionTtlMs: number; + readonly #controller: ChatAdmissionController; constructor( - readonly maxHeavyPerSession: number, - opts?: { maxSessions?: number; sessionTtlMs?: number } + readonly maxHeavyInFlight = 1, + // Deprecated pre-#10110 lane-eviction knobs: accepted for API + // compatibility and ignored — there are no per-session lanes to evict. + _opts?: { maxSessions?: number; sessionTtlMs?: number } ) { - this.maxSessions = opts?.maxSessions ?? OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS; - this.sessionTtlMs = opts?.sessionTtlMs ?? OMNIROUTE_CHAT_VIRTUAL_TTL_MS; + this.#controller = new ChatAdmissionController(maxHeavyInFlight); } - getController(sessionId: string): ChatAdmissionController { - this.evictIfDue(); - const existing = this.#sessions.get(sessionId); - if (existing) { - existing.lastUsedMs = Date.now(); - return existing.controller; - } - // Evict oldest if at capacity (LRU fallback when TTL hasn't fired). - if (this.#sessions.size >= this.maxSessions) { - const oldestKey = this.oldestKey(); - if (oldestKey) this.#sessions.delete(oldestKey); - } - const controller = new ChatAdmissionController(this.maxHeavyPerSession); - this.#sessions.set(sessionId, { controller, lastUsedMs: Date.now() }); - this.armEviction(); - return controller; + /** Returns the process-global budget — the same instance for every session. */ + getController(_sessionId: string): ChatAdmissionController { + return this.#controller; } - /** Snapshot for observability — never exposes raw API keys. */ - snapshot(): ReadonlyArray<{ sessionId: string; activeHeavy: number; idleMs: number }> { - const now = Date.now(); - const arr: Array<{ sessionId: string; activeHeavy: number; idleMs: number }> = []; - for (const [sessionId, record] of this.#sessions) { - arr.push({ - sessionId, - activeHeavy: record.controller.activeHeavy, - idleMs: now - record.lastUsedMs, - }); - } - return arr; + /** + * Process-wide aggregate snapshot for observability: global totals plus + * per-key waiter depths. Keys are opaque scheduler keys, never raw + * credentials. + */ + snapshot(): { + activeHeavy: number; + queuedBytes: number; + waiting: number; + lanes: ReadonlyArray<{ key: string; waiting: number }>; + } { + return { + activeHeavy: this.#controller.activeHeavy, + queuedBytes: this.#controller.queuedBytes, + waiting: this.#controller.waitingCount, + lanes: this.#controller.waitersByKey, + }; } - get sessionCount(): number { - return this.#sessions.size; + get activeHeavy(): number { + return this.#controller.activeHeavy; } - private oldestKey(): string | undefined { - let oldest: string | undefined; - let oldestMs = Infinity; - for (const [key, record] of this.#sessions) { - // Use <= so that for equal timestamps, later-inserted entries win, - // preserving LRU semantics when Date.now() returns the same value. - if (record.lastUsedMs <= oldestMs) { - oldestMs = record.lastUsedMs; - oldest = key; - } - } - return oldest; + get queuedBytes(): number { + return this.#controller.queuedBytes; } - private evictIfDue(): void { - const now = Date.now(); - let evicted = false; - for (const [sessionId, record] of this.#sessions) { - if (now - record.lastUsedMs >= this.sessionTtlMs) { - this.#sessions.delete(sessionId); - evicted = true; - } - } - if (evicted) this.armEviction(); + get waitingCount(): number { + return this.#controller.waitingCount; } - private armEviction(): void { - if (this.#evictionTimer !== null) return; - this.#evictionTimer = setTimeout(() => { - this.#evictionTimer = null; - this.evictIfDue(); - }, this.sessionTtlMs).unref(); - } - - /** Force cleanup of all sessions (used by shutdown / tests). */ + /** No per-session state to clean; kept for API compatibility. */ dispose(): void { - this.#sessions.clear(); - if (this.#evictionTimer !== null) { - clearTimeout(this.#evictionTimer); - this.#evictionTimer = null; - } + // Intentionally empty: the process-global controller owns no session state. } } -export const perConnectionAdmissionController = new PerConnectionAdmissionController(CHAT_MAX_HEAVY_IN_FLIGHT); +export const perConnectionAdmissionController = new PerConnectionAdmissionController( + CHAT_MAX_HEAVY_IN_FLIGHT +); export type ChatRequestAdmission = | { admit: true; request: Request; lease: ChatAdmissionLease | null } @@ -530,7 +566,8 @@ export async function admitChatStructure( const acquired = await controller.acquireHeavyWithin( options.queueMs ?? 0, options.signal, - CHAT_LARGE_BODY_BYTES + CHAT_LARGE_BODY_BYTES, + options.sessionId ); return acquired ? { admit: true, lease: acquired } @@ -700,7 +737,7 @@ export async function admitChatRequest( let lease: ChatAdmissionLease | null = null; const reserve = async (bytes = 0): Promise => { if (lease) return true; - lease = await controller.acquireHeavyWithin(queueMs, request.signal, bytes); + lease = await controller.acquireHeavyWithin(queueMs, request.signal, bytes, sessionId); return lease !== null; }; diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index bee2577555..284618971c 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -9,6 +9,13 @@ import { withSettingsFallback } from "./cliInstallFallback"; import { GROK_BUILD_RUNTIME_ENTRY, AMP_RUNTIME_ENTRY } from "./cliRuntimeGrokBuild"; import { isLocationTrusted, findKnownPathMatch } from "./cliRuntimeKnownPath"; import { buildHealthcheckPath } from "./cliRuntimeHealthcheckPath"; +import { + describeContainerTarget, + hasBindMountAt, + isRunningInContainer, + type ContainerEnvDeps, +} from "../utils/containerEnv"; +import { buildContainerWriteRefusal } from "../utils/containerConfigGuard"; import { resolveOpencodeConfigPath as resolveOpenCodeConfigPath } from "./opencodeConfigPath"; const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]); const FALSE_VALUES = new Set(["0", "false", "no", "off"]); @@ -93,6 +100,17 @@ const CLI_TOOLS: Record = { }, }, }, + zcode: { + defaultCommand: "zcode", + envBinKey: "ZCODE_BIN", + requiresBinary: true, + // The app-server performs a local runtime handshake and can be slower on + // the first launch while the user's ZCode profile is loaded. + healthcheckTimeoutMs: 15000, + paths: { + config: ".zcode", + }, + }, cline: { defaultCommand: "cline", envBinKey: "CLI_CLINE_BIN", @@ -942,12 +960,32 @@ const checkRunnable = async ( export const isCliConfigWriteAllowed = () => parseBoolean(process.env.CLI_ALLOW_CONFIG_WRITES, true); -export const ensureCliConfigWriteAllowed = () => { - if (isCliConfigWriteAllowed()) return null; - return "CLI config writes are disabled (CLI_ALLOW_CONFIG_WRITES=false)"; +/** + * Gate for every CLI-tool config write. + * + * Pass `targetPath` whenever the caller knows it: inside a container, a path + * that is not bind-mounted from the host is thrown away when the container is + * recreated, and the host CLI never sees it. Refusing beats writing a file the + * operator will never find. Callers that omit the path keep the historical + * flag-only behavior. + */ +export const ensureCliConfigWriteAllowed = ( + targetPath?: string, + options: { containerDeps?: ContainerEnvDeps; toolLabel?: string; hostCommand?: string } = {} +) => { + if (!isCliConfigWriteAllowed()) { + return "CLI config writes are disabled (CLI_ALLOW_CONFIG_WRITES=false)"; + } + if (!targetPath) return null; + if (parseBoolean(process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE, false)) return null; + if (!describeContainerTarget(targetPath, options.containerDeps).ephemeral) return null; + return buildContainerWriteRefusal(targetPath, { + toolLabel: options.toolLabel, + hostCommand: options.hostCommand, + }); }; -export const getCliConfigHome = () => { +export const getCliConfigHome = (containerDeps?: ContainerEnvDeps) => { const override = String(process.env.CLI_CONFIG_HOME || "").trim(); if (!override) return os.homedir(); @@ -960,10 +998,18 @@ export const getCliConfigHome = () => { // Must not contain path traversal if (path.normalize(override).includes("..")) return os.homedir(); - // Must be within user's home directory (prevent reading from system dirs) + // Must be within user's home directory (prevent reading from system dirs). + // + // Exception for containers: the compose `host` profile deliberately mounts the + // operator's real config dirs at /host-home, which is outside the container + // user's home (/home/node). A bind mount is proof the operator wired that path + // in on purpose, so it is honoured; an arbitrary unmounted system dir is not. const home = os.homedir(); const normalized = path.normalize(override); if (!isPathWithin(normalized, home)) { + if (isRunningInContainer(containerDeps) && hasBindMountAt(normalized, containerDeps)) { + return normalized; + } return home; // Silently fall back to home } diff --git a/src/shared/utils/containerConfigGuard.ts b/src/shared/utils/containerConfigGuard.ts new file mode 100644 index 0000000000..2f4f8ca63f --- /dev/null +++ b/src/shared/utils/containerConfigGuard.ts @@ -0,0 +1,60 @@ +/** + * Shared wording for "this config write would vanish with the container". + * + * The CLI and the dashboard/API both refuse the same situation, so they share + * one message: an operator who hits it in the terminal and then again in the UI + * should read the same two escape routes. + */ + +export interface ContainerWriteRefusalOptions { + /** Human label for the tool being configured, e.g. "Codex". */ + toolLabel?: string; + /** The command that would fix it from the host, e.g. "omniroute setup-codex". */ + hostCommand?: string; + /** How to override, worded for the surface that is refusing. */ + overrideHint?: string; +} + +/** + * Opening words of every container refusal. Callers that receive a message + * rather than a structured result use `isContainerWriteRefusal()` to tell this + * apart from the other reasons a write can be denied. + */ +const REFUSAL_PREFIX = "Refusing to write"; + +export function isContainerWriteRefusal(message: string | null | undefined): boolean { + return typeof message === "string" && message.startsWith(REFUSAL_PREFIX); +} + +/** Default override hint for server-side (API) callers. */ +export const SERVER_OVERRIDE_HINT = + "Set OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true to configure the container's own CLIs anyway."; + +/** Default override hint for CLI callers. */ +export const CLI_OVERRIDE_HINT = + "Re-run with --allow-container-write to configure the container's own CLIs anyway."; + +export function buildContainerWriteRefusal( + targetPath: string, + options: ContainerWriteRefusalOptions = {} +): string { + const { toolLabel, hostCommand, overrideHint = SERVER_OVERRIDE_HINT } = options; + const subject = toolLabel ? `${toolLabel} config` : "CLI tool config"; + + return [ + `${REFUSAL_PREFIX} ${subject} to ${targetPath} — OmniRoute is running in a container ` + + `and that path is not mounted from the host, so the file would be discarded when the ` + + `container is recreated and your host CLI would never read it.`, + "", + "Configure from the host instead (recommended):", + " npm install -g omniroute", + " omniroute connect http://localhost:20128", + ` ${hostCommand || "omniroute setup-"}`, + "", + 'Or bind-mount the host config dir into the container (compose profile "host"):', + ' volumes: [ "~/.codex:/host-home/.codex:rw" ]', + ' environment: [ "CLI_CONFIG_HOME=/host-home", "CLI_ALLOW_CONFIG_WRITES=true" ]', + "", + overrideHint, + ].join("\n"); +} diff --git a/src/shared/utils/containerEnv.ts b/src/shared/utils/containerEnv.ts new file mode 100644 index 0000000000..00b81a5313 --- /dev/null +++ b/src/shared/utils/containerEnv.ts @@ -0,0 +1,144 @@ +import fs from "node:fs"; + +/** + * Container awareness for CLI-tool config writes. + * + * OmniRoute frequently runs as a container while the AI CLIs it configures + * (Codex, Claude Code, Cursor, ...) live on the operator's host. Writing + * `~/.codex/...` inside the container "succeeds" and then silently disappears + * with the container, so every auto-config write path consults this module + * before touching disk. + * + * A bind mount is treated as the operator's explicit statement that a path + * reaches the host, which is what makes the compose `host` profile safe. + */ + +export interface ContainerEnvDeps { + existsSync: (path: string) => boolean; + readFileSync: (path: string, encoding: string) => string; + env: NodeJS.ProcessEnv; +} + +const defaultDeps = (): ContainerEnvDeps => ({ + existsSync: fs.existsSync, + readFileSync: (path, encoding) => fs.readFileSync(path, encoding as BufferEncoding) as string, + env: process.env, +}); + +/** cgroup substrings emitted by the common container runtimes. */ +const CGROUP_MARKERS = ["docker", "containerd", "kubepods", "podman", "lxc"]; + +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); +const FALSE_VALUES = new Set(["0", "false", "no", "off"]); + +/** + * Best-effort container detection. Superset of the Zed-specific + * `isRunningInDocker()` (kept separate so its callers keep their behavior). + * + * `OMNIROUTE_CONTAINER` forces the answer either way — needed for tests and for + * operators on exotic runtimes we fail to recognise. + */ +export function isRunningInContainer(deps: ContainerEnvDeps = defaultDeps()): boolean { + const override = String(deps.env?.OMNIROUTE_CONTAINER ?? "") + .trim() + .toLowerCase(); + if (TRUE_VALUES.has(override)) return true; + if (FALSE_VALUES.has(override)) return false; + + for (const marker of ["/.dockerenv", "/run/.containerenv"]) { + try { + if (deps.existsSync(marker)) return true; + } catch { + // not Linux, or permission denied — fall through to the next probe + } + } + + if (deps.env?.KUBERNETES_SERVICE_HOST) return true; + + try { + const cgroup = deps.readFileSync("/proc/1/cgroup", "utf8"); + if (CGROUP_MARKERS.some((marker) => cgroup.includes(marker))) return true; + } catch { + // /proc not mounted + } + + return false; +} + +/** mountinfo escapes these four characters as octal sequences. */ +function decodeMountPath(raw: string): string { + return raw + .replace(/\\040/g, " ") + .replace(/\\011/g, "\t") + .replace(/\\012/g, "\n") + .replace(/\\134/g, "\\"); +} + +/** Strip a trailing slash so "/host-home/" and "/host-home" compare equal. */ +function stripTrailingSlash(p: string): string { + return p.length > 1 && p.endsWith("/") ? p.replace(/\/+$/, "") : p; +} + +/** + * True when `targetPath` is connected to a mount, in any of three ways: + * + * 1. the path IS a mount point (`-v ~/.codex:/host-home/.codex`) + * 2. the path sits INSIDE a mount point (`/host-home/.codex/profiles`) + * 3. a mount point sits BENEATH the path (`/host-home`, whose children are + * the actual mounts — this is exactly how the compose `host` profile is + * wired, so case 3 is not optional) + * + * Returns false whenever `/proc/self/mountinfo` is unavailable, which keeps + * host machines (macOS, Windows) on the conservative path. + */ +export function hasBindMountAt( + targetPath: string, + deps: ContainerEnvDeps = defaultDeps() +): boolean { + const target = stripTrailingSlash(String(targetPath || "").trim()); + if (!target || !target.startsWith("/") || target === "/") return false; + + let content: string; + try { + content = deps.readFileSync("/proc/self/mountinfo", "utf8"); + } catch { + return false; + } + + for (const line of content.split("\n")) { + // mountinfo field 5 (1-indexed) is the mount point. + const fields = line.split(" "); + if (fields.length < 5) continue; + const mountPoint = stripTrailingSlash(decodeMountPath(fields[4] || "")); + if (!mountPoint || mountPoint === "/") continue; + + if (mountPoint === target) return true; + if (mountPoint.startsWith(`${target}/`)) return true; + if (target.startsWith(`${mountPoint}/`)) return true; + } + + return false; +} + +export interface ContainerTargetInfo { + inContainer: boolean; + bindMounted: boolean; + /** Writing here would be lost when the container is recreated. */ + ephemeral: boolean; +} + +/** + * Classify a would-be config write target. `ephemeral` is the signal callers + * act on: refuse the write and point the operator at the host CLI instead. + */ +export function describeContainerTarget( + targetPath: string, + deps: ContainerEnvDeps = defaultDeps() +): ContainerTargetInfo { + const inContainer = isRunningInContainer(deps); + if (!inContainer) { + return { inContainer: false, bindMounted: false, ephemeral: false }; + } + const bindMounted = hasBindMountAt(targetPath, deps); + return { inContainer: true, bindMounted, ephemeral: !bindMounted }; +} diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index 7568e982fd..9a3582370d 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -111,3 +111,15 @@ export function isControlPlaneProxyDirectFallbackEnabled(): boolean { return false; } } + +export function isNetworkRotationSharedEgressGuardEnabled(): boolean { + try { + return isFeatureFlagEnabled("NETWORK_ROTATION_SHARED_EGRESS_GUARD"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve NETWORK_ROTATION_SHARED_EGRESS_GUARD, defaulting to enabled:", + error instanceof Error ? error.message : error + ); + return true; + } +} diff --git a/src/shared/validation/providerSchema.ts b/src/shared/validation/providerSchema.ts index 5fb2bc920f..929108e555 100644 --- a/src/shared/validation/providerSchema.ts +++ b/src/shared/validation/providerSchema.ts @@ -30,6 +30,7 @@ export const ProviderSchema = z.object({ freeNote: z.string().optional(), authHint: z.string().optional(), apiHint: z.string().optional(), + oauthProviderId: z.string().min(1).optional(), serviceKinds: z.array(z.enum(SERVICE_KIND_VALUES)).optional(), noAuth: z.boolean().optional(), anonymousFallback: z.boolean().optional(), diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index b0defec2a7..2e0d426722 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -62,6 +62,7 @@ import { evictSessionAccountAffinityForConnection, getSessionAccountAffinity, } from "@/lib/db/sessionAccountAffinity"; +import { dispatchChatWithAffinityEviction } from "./chatDispatch"; import { getCachedSettings, getCombosCacheVersion } from "@/lib/db/readCache"; import { getCombos } from "@/lib/db/combos"; import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings"; @@ -1535,42 +1536,41 @@ async function handleSingleModelChat( const proxyStartTime = Date.now(); // 4. Execute chat via core after breaker gate checks (with optional TLS tracking) if (telemetry) telemetry.startPhase("connect"); - const dispatchClientRawRequest = resolveDispatchClientRawRequest( - clientRawRequest, - runtimeOptions.modelAbortSignal - ); - let execution: Awaited>; + let execution: Awaited>; try { - execution = await executeChatWithBreaker({ - bypassCircuitBreaker: forceLiveComboTest || hasForcedConnection, - breaker, - body: requestBody, - provider, - model: effectiveModel, - refreshedCredentials, - proxyInfo, - appliedProxySink, - log, - clientRawRequest: dispatchClientRawRequest, - credentials, - apiKeyInfo, - userAgent, - comboName, - comboStrategy, - isCombo, - comboStepId: runtimeOptions.comboStepId ?? null, - comboExecutionKey: runtimeOptions.comboExecutionKey ?? runtimeOptions.comboStepId ?? null, - extendedContext, - modelApiFormat: apiFormat, - modelTargetFormat: targetFormat, - providerProfile, - cachedSettings: runtimeOptions.cachedSettings, - skipUpstreamRetry: runtimeOptions.skipUpstreamRetry ?? false, - correlationId: runtimeOptions?.correlationId ?? null, - modelPinned: runtimeOptions?.modelPinned ?? false, - routingComboId: runtimeOptions?.routingComboId ?? null, - sessionAffinityKey: runtimeOptions.sessionAffinityKey ?? null, - }); + execution = await dispatchChatWithAffinityEviction( + { + bypassCircuitBreaker: forceLiveComboTest || hasForcedConnection, + breaker, + body: requestBody, + provider, + model: effectiveModel, + refreshedCredentials, + proxyInfo, + appliedProxySink, + log, + clientRawRequest, + credentials, + apiKeyInfo, + userAgent, + comboName, + comboStrategy, + isCombo, + comboStepId: runtimeOptions.comboStepId ?? null, + comboExecutionKey: runtimeOptions.comboExecutionKey ?? runtimeOptions.comboStepId ?? null, + extendedContext, + modelApiFormat: apiFormat, + modelTargetFormat: targetFormat, + providerProfile, + cachedSettings: runtimeOptions.cachedSettings, + skipUpstreamRetry: runtimeOptions.skipUpstreamRetry ?? false, + correlationId: runtimeOptions?.correlationId ?? null, + modelPinned: runtimeOptions?.modelPinned ?? false, + routingComboId: runtimeOptions?.routingComboId ?? null, + sessionAffinityKey: runtimeOptions.sessionAffinityKey ?? null, + }, + runtimeOptions + ); } catch (error) { releaseOAuthSession(); throw error; diff --git a/src/sse/handlers/chatDispatch.ts b/src/sse/handlers/chatDispatch.ts new file mode 100644 index 0000000000..87b8676807 --- /dev/null +++ b/src/sse/handlers/chatDispatch.ts @@ -0,0 +1,72 @@ +/** + * Dispatch seam between chat.ts and executeChatWithBreaker, extracted so the + * frozen god-file `chat.ts` does not grow (check:file-size). + * + * Its only job beyond forwarding the call is the #6219 follow-up: when a combo + * per-model timeout abandons the account this session is pinned to, drop the + * pin. See `evictSessionAffinityOnComboTimeout` for why the existing #6219 + * eviction never covers this path. + */ + +import { executeChatWithBreaker } from "./chatHelpers"; +import { resolveDispatchClientRawRequest } from "./chat/clientRawRequest.ts"; +import { evictSessionAffinityOnComboTimeout } from "../services/sessionAffinityPin"; + +/** The dispatch arguments chat.ts already assembles, plus what the eviction reads. */ +type DispatchArgs = { + provider: string; + credentials: { connectionId: string }; + clientRawRequest: any; + [key: string]: unknown; +}; + +/** Runtime fields this seam consults; the rest of runtimeOptions is ignored. */ +type DispatchRuntimeOptions = { + sessionAffinityKey?: string | null; + modelAbortSignal?: AbortSignal | null; +}; + +/** + * Merge the per-model abort signal into the outgoing request, run the upstream + * dispatch, and evict the sticky session pin when a combo per-model timeout + * abandons it. + * + * The abort surfaces two ways: as a rejection out of `executeChatWithBreaker` + * (the common case — `buildTargetTimeoutRunner` then swallows it behind its + * synthetic 524), or as a failed result when an executor catches the abort + * itself. Both are covered. The eviction is a no-op unless this dispatch was + * aborted by the per-model timeout specifically. + */ +export async function dispatchChatWithAffinityEviction( + args: DispatchArgs, + runtimeOptions: DispatchRuntimeOptions +): Promise>> { + const evict = () => + evictSessionAffinityOnComboTimeout({ + sessionKey: runtimeOptions.sessionAffinityKey, + provider: args.provider, + connectionId: args.credentials?.connectionId, + modelAbortSignal: runtimeOptions.modelAbortSignal, + }); + + let dispatched: Awaited>; + try { + dispatched = await executeChatWithBreaker({ + ...args, + clientRawRequest: resolveDispatchClientRawRequest( + args.clientRawRequest, + runtimeOptions.modelAbortSignal + ), + }); + } catch (dispatchErr) { + evict(); + throw dispatchErr; + } + + // A resource-pressure short-circuit (no upstream dispatch happened) is not a + // combo per-model timeout — never treat it as one. + if ("localResourcePressureResult" in dispatched) return dispatched; + + if (!dispatched.result?.success) evict(); + return dispatched; +} diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index f3ec47870a..da70522843 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -387,6 +387,7 @@ function resolveTerminalConnectionStatus( if (result.creditsExhausted || status === 402) return "credits_exhausted"; if ( providerErrorType === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR || + providerErrorType === PROVIDER_ERROR_TYPES.GEO_BLOCKED || providerErrorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN || // #1010: Cloudflare fingerprint rejection is the CDN refusing the CLIENT's // signature, not the account's credentials — never a terminal account state. diff --git a/src/sse/services/sessionAffinityPin.ts b/src/sse/services/sessionAffinityPin.ts index 5cb1447408..a2055e0fa5 100644 --- a/src/sse/services/sessionAffinityPin.ts +++ b/src/sse/services/sessionAffinityPin.ts @@ -31,6 +31,7 @@ import { upsertSessionAccountAffinity, touchSessionAccountAffinity, deleteSessionAccountAffinity, + evictSessionAccountAffinityForConnection, } from "@/lib/db/sessionAccountAffinity"; import { touchConnectionLastUsed } from "@/lib/db/providers"; import { isModelExcludedByConnection } from "@/domain/connectionModelRules"; @@ -39,6 +40,7 @@ import { isAccountUnavailable, isModelLocked, } from "@omniroute/open-sse/services/accountFallback.ts"; +import { isComboPerModelTimeoutAbort } from "@omniroute/open-sse/services/combo/comboAbortReasons.ts"; import * as log from "../utils/logger"; /** Minimal structural view of a provider connection this module reads. */ @@ -139,6 +141,61 @@ export async function selectSessionAffinityConnection 0) next |= 0x80; + bytes.push(next); + } while (remaining > 0); + return Buffer.from(bytes); +} + +function encode(value) { + if (value === undefined) return Buffer.from([0]); + if (typeof value === "string") { + const bytes = Buffer.from(value, "utf8"); + return Buffer.concat([Buffer.from([1]), vql(bytes.length), bytes]); + } + if (Buffer.isBuffer(value) || value instanceof Uint8Array) { + const bytes = Buffer.from(value); + return Buffer.concat([Buffer.from([2]), vql(bytes.length), bytes]); + } + if (Array.isArray(value)) { + return Buffer.concat([Buffer.from([4]), vql(value.length), ...value.map(encode)]); + } + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { + return Buffer.concat([Buffer.from([6]), vql(value)]); + } + const bytes = Buffer.from(JSON.stringify(value), "utf8"); + return Buffer.concat([Buffer.from([5]), vql(bytes.length), bytes]); +} + +function readVql(data, state) { + let value = 0; + let multiplier = 1; + for (let i = 0; i < 8; i += 1) { + if (state.offset >= data.length) throw new Error("truncated vql"); + const next = data[state.offset++]; + value += (next & 0x7f) * multiplier; + if ((next & 0x80) === 0) return value; + multiplier *= 128; + } + throw new Error("invalid vql"); +} + +function decode(data, state) { + const type = data[state.offset++]; + if (type === 0) return undefined; + if (type === 1 || type === 2) { + const length = readVql(data, state); + const end = state.offset + length; + if (end > data.length) throw new Error("truncated bytes"); + const bytes = data.subarray(state.offset, end); + state.offset = end; + return type === 1 ? bytes.toString("utf8") : bytes; + } + if (type === 4) { + const length = readVql(data, state); + return Array.from({ length }, () => decode(data, state)); + } + if (type === 5) { + const length = readVql(data, state); + const end = state.offset + length; + const value = JSON.parse(data.subarray(state.offset, end).toString("utf8")); + state.offset = end; + return value; + } + if (type === 6) return readVql(data, state); + throw new Error(`unknown type ${type}`); +} + +function frame(body) { + const result = Buffer.alloc(HEADER_SIZE + body.length); + result.writeUInt8(1, 0); + result.writeUInt32BE(0, 1); + result.writeUInt32BE(0, 5); + result.writeUInt32BE(body.length, 9); + body.copy(result, HEADER_SIZE); + return result; +} + +function send(header, payload) { + const packet = frame(Buffer.concat([encode(header), encode(payload)])); + process.stdout.write(packet.subarray(0, 5)); + setTimeout(() => process.stdout.write(packet.subarray(5)), 1); +} + +function response(id, payload) { + send([201, id], payload); +} + +function handleFrame(body) { + const state = { offset: 0 }; + const header = decode(body, state); + const args = decode(body, state); + const id = Array.isArray(header) ? header[1] : undefined; + const method = Array.isArray(header) ? header[3] : undefined; + const request = Array.isArray(args) && args[0] && typeof args[0] === "object" ? args[0] : {}; + + switch (method) { + case "initialize": + response(id, { available: true, protocolName: "ZCode Protocol", protocolVersion: 1, transportKind: "stdio" }); + break; + case "createSession": + sessionId = "fake-zcode-session"; + response(id, { session: { sessionId, status: "idle", workspace: { workspacePath: request.workspacePath } }, messages: [] }); + break; + case "setModel": + selectedModel = request.model; + response(id, { ok: true, model: selectedModel }); + break; + case "sendPrompt": + response(id, { session: { sessionId, status: "running" }, accepted: true }); + break; + case "readSession": + response(id, { + session: { sessionId, status: "completed", model: selectedModel }, + messages: [ + { info: { messageId: "fake-user-message", role: "user" }, parts: [{ type: "text", text: request.content || "prompt" }] }, + { info: { messageId: "fake-assistant-message", role: "assistant" }, parts: [{ type: "text", text: "fake zcode response" }] }, + ], + }); + break; + case "closeSession": + response(id, { ok: true }); + break; + default: + response(id, { ok: true }); + break; + } +} + +function consumeFrames() { + while (input.length >= HEADER_SIZE) { + const length = input.readUInt32BE(9); + const total = HEADER_SIZE + length; + if (input.length < total) return; + const body = input.subarray(HEADER_SIZE, total); + input = input.subarray(total); + handleFrame(body); + } +} + +process.stdout.write(`${JSON.stringify({ type: "zcode-hello", version: "fixture", platform: "test", arch: "test", pid: process.pid })}\n`); + +process.stdin.on("data", (chunk) => { + input = Buffer.concat([input, chunk]); + if (!handshaken) { + const newline = input.indexOf(0x0a); + if (newline < 0) return; + const ack = JSON.parse(input.subarray(0, newline).toString("utf8")); + if (ack.type !== "zcode-hello-ack") throw new Error("missing ZCode hello ack"); + input = input.subarray(newline + 1); + handshaken = true; + send([200], undefined); + } + consumeFrames(); +}); + +process.stdin.on("end", () => process.exit(0)); diff --git a/tests/helpers/assertResponsesOutputIndexLifecycle.ts b/tests/helpers/assertResponsesOutputIndexLifecycle.ts new file mode 100644 index 0000000000..b1ae64fa9b --- /dev/null +++ b/tests/helpers/assertResponsesOutputIndexLifecycle.ts @@ -0,0 +1,55 @@ +/** + * Validates the Responses-API output_index lifecycle invariant that real + * clients (e.g. OpenClaw's outputSlots tracker) enforce: an output_index + * claimed by response.output_item.added must be closed by a matching + * response.output_item.done before any later item reuses that same index. + * + * Existing coverage (responses-reasoning-close-before-message-466.test.ts) + * asserts this invariant by hand for one specific emitter path (the real + * translator/transformer). This helper generalizes that check so any SSE + * event sequence — including hand-rolled synthetic frames like the early + * keepalive placeholder — can be verified against the same contract a real + * downstream client applies, without duplicating the tracking logic per test. + * + * Mirrors OpenClaw's createResponsesOutputSlotTracker() closely enough to + * reproduce the exact failure mode: "Responses stream reused active output + * index N" (see OpenClaw issue #123342 / the RESPONSES_STARTUP_THINKING_FRAME + * missing-output_item.done incident this helper was added for). + */ + +export type ResponsesLifecycleEvent = { event?: string; data: Record }; + +export function assertResponsesOutputIndexLifecycle( + events: ResponsesLifecycleEvent[], + options: { requireAllClosed?: boolean } = {} +): void { + const open = new Map(); + + for (const { data } of events) { + const type = data?.type; + if (type !== "response.output_item.added" && type !== "response.output_item.done") continue; + + const outputIndex = data.output_index; + if (typeof outputIndex !== "number") continue; + + if (type === "response.output_item.added") { + if (open.has(outputIndex)) { + const item = data.item as { id?: unknown; type?: unknown } | undefined; + throw new Error( + `Responses stream reused active output index ${outputIndex} ` + + `(item id=${String(item?.id)} type=${String(item?.type)} was still open)` + ); + } + open.set(outputIndex, data.item); + } else { + open.delete(outputIndex); + } + } + + if (options.requireAllClosed !== false && open.size > 0) { + const stillOpen = [...open.keys()].join(", "); + throw new Error( + `Responses stream left output index(es) open with no output_item.done: ${stillOpen}` + ); + } +} diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 699c8acbd4..9d6a45573b 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -1604,7 +1604,7 @@ } }, "deepseek": { - "format": "openai", + "format": "openai-responses", "headers": { "apiKey": { "Accept": "text/event-stream", @@ -1622,8 +1622,8 @@ } }, "url": { - "nonStream": "https://api.deepseek.com/v1/chat/completions", - "stream": "https://api.deepseek.com/v1/chat/completions" + "nonStream": "https://api.deepseek.com/responses", + "stream": "https://api.deepseek.com/responses" } }, "deepseek-web": { @@ -2082,8 +2082,8 @@ } }, "url": { - "nonStream": "https://freeaiapikey.com/v1/chat/completions", - "stream": "https://freeaiapikey.com/v1/chat/completions" + "nonStream": "https://api.freeaiapikey.com/v1/chat/completions", + "stream": "https://api.freeaiapikey.com/v1/chat/completions" } }, "freeinference": { @@ -6156,6 +6156,29 @@ "stream": "https://chat.z.ai" } }, + "zcode": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "zcode://app-server/stdio", + "stream": "zcode://app-server/stdio" + } + }, "zed-hosted": { "format": "openai", "headers": { diff --git a/tests/unit/8676-monsterapi-deprecation.test.ts b/tests/unit/8676-monsterapi-deprecation.test.ts index 6f435957c8..4bcfd1f358 100644 --- a/tests/unit/8676-monsterapi-deprecation.test.ts +++ b/tests/unit/8676-monsterapi-deprecation.test.ts @@ -2,16 +2,63 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { APIKEY_PROVIDERS_INFERENCE } from "../../src/shared/constants/providers/apikey/inference-hosts.ts"; +/** + * #8676 deprecated MonsterAPI after its domain stopped resolving, but wrote the flag + * as `isDeprecated` — a key no schema declares and no consumer reads. The catalog + * field the codebase actually consumes is `deprecated`: + * + * src/shared/validation/providerSchema.ts declares `deprecated`, not `isDeprecated` + * ProviderCard.tsx `provider.deprecated` (strikethrough + block icon) + * ProviderTestSlideOver.tsx `provider.deprecated` (warning) + * providerOnboardingCatalog.ts `Boolean(provider.deprecated)` + sorts last + * ProviderOnboardingWizard.tsx `option.deprecated` (badge) + * scripts/docs/gen-provider-reference.ts `p.deprecated` gates the DEPRECATED note + * + * Because Zod object schemas ignore undeclared keys, `isDeprecated` never failed + * validation — it was silently dropped, so the deprecation had no effect anywhere + * while this test stayed green. + * + * Upstream state re-probed 2026-08-13, with paired controls: + * GET https://api.monsterapi.ai/v1/chat/completions -> 000 (does not resolve) + * GET https://monsterapi.ai -> 000 (does not resolve) + * GET https://api.openai.com/v1/models -> 401 (control: reachable) + * GET https:// -> 000 (control: unreachable) + */ test("Monster API provider is marked as deprecated (fixes #8676)", () => { - const monsterEntry = APIKEY_PROVIDERS_INFERENCE.monsterapi; + const monsterEntry = APIKEY_PROVIDERS_INFERENCE.monsterapi as Record; assert.ok(monsterEntry, "monsterapi entry must exist in APIKEY_PROVIDERS_INFERENCE"); assert.equal( - (monsterEntry as Record).isDeprecated, + monsterEntry.deprecated, true, - "monsterapi must be marked isDeprecated" + "monsterapi must set `deprecated` — the field every consumer and the Zod schema read" ); assert.ok( - typeof (monsterEntry as Record).deprecationReason === "string", + typeof monsterEntry.deprecationReason === "string", "monsterapi must specify deprecationReason" ); }); + +test("Monster API deprecation uses no undeclared flag name (#8676)", () => { + const monsterEntry = APIKEY_PROVIDERS_INFERENCE.monsterapi as Record; + assert.equal( + "isDeprecated" in monsterEntry, + false, + "`isDeprecated` is read by nothing and silently dropped by the provider schema — " + + "the consumed field is `deprecated`" + ); +}); + +test("Monster API deprecation matches the flag shape of its sibling entries (#8676)", () => { + // predibase, in this same catalog, is the reference implementation: its `deprecated` + // flag is what makes the generated PROVIDER_REFERENCE.md render its DEPRECATED note. + const monsterEntry = APIKEY_PROVIDERS_INFERENCE.monsterapi as Record; + const predibaseEntry = APIKEY_PROVIDERS_INFERENCE.predibase as Record; + assert.equal(predibaseEntry.deprecated, true, "predibase is the in-file reference for the flag"); + for (const field of ["deprecated", "deprecationReason"]) { + assert.equal( + typeof monsterEntry[field], + typeof predibaseEntry[field], + `monsterapi must declare ${field} the same way predibase does` + ); + } +}); diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 26da5a7d5a..5bd6ca24a8 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -1144,6 +1144,19 @@ test("isCreditsExhausted returns true for actual credits-exhausted signals", () // #5239: "Insufficient account balance" out-of-credit bodies assert.equal(isCreditsExhausted("Insufficient account balance"), true); assert.equal(isCreditsExhausted("insufficient_balance"), true); + assert.equal(isCreditsExhausted("Insufficient credit balance"), true); + // 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 is unclassified (errorType=null), so the connection is never + // marked credits_exhausted and keeps being re-selected on every request. + assert.equal(isCreditsExhausted("insufficient credits"), true); + assert.equal( + isCreditsExhausted( + "You have insufficient credits to make this request. Please purchase more credits to continue using the service." + ), + true + ); }); test("CREDITS_EXHAUSTED_SIGNALS no longer contains generic gRPC resource-exhausted patterns", () => { diff --git a/tests/unit/account-rotation.test.ts b/tests/unit/account-rotation.test.ts new file mode 100644 index 0000000000..f4864ee2dd --- /dev/null +++ b/tests/unit/account-rotation.test.ts @@ -0,0 +1,116 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + isAccountReady, + pickAccount, + markCooldown, + markSuccess, + maskAccountId, + isNetworkErrorRotatable, + type RotatableAccount, +} from "../../open-sse/executors/accountRotation.ts"; + +function account(overrides: Partial = {}): RotatableAccount { + return { + fingerprint: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + cooldownUntil: 0, + consecutiveFails: 0, + proxy: null, + ...overrides, + }; +} + +describe("accountRotation", () => { + it("isAccountReady is true when cooldownUntil is in the past", () => { + assert.strictEqual(isAccountReady(account({ cooldownUntil: Date.now() - 1000 })), true); + }); + + it("isAccountReady is false when cooldownUntil is in the future", () => { + assert.strictEqual(isAccountReady(account({ cooldownUntil: Date.now() + 60_000 })), false); + }); + + it("markCooldown increments consecutiveFails and sets a future cooldownUntil", () => { + const acct = account(); + markCooldown(acct); + assert.strictEqual(acct.consecutiveFails, 1); + assert.ok(acct.cooldownUntil > Date.now()); + }); + + it("markCooldown backs off exponentially with consecutive failures", () => { + const acct = account(); + markCooldown(acct); + const firstCooldown = acct.cooldownUntil; + markCooldown(acct); + assert.strictEqual(acct.consecutiveFails, 2); + // Second backoff (base*2^1) must be strictly larger than the first + // (base*2^0), modulo the shared jitter window — compare the floor. + assert.ok(acct.cooldownUntil - Date.now() > firstCooldown - Date.now() - 1000); + }); + + it("markCooldown uses the same magnitude regardless of why it was called (429 or network throw)", () => { + // No `short`/severity parameter: proxy-attributable failures (429, dead + // proxy) and shared-egress network throws use the identical formula — + // the repo's own established "transient, not clearly attributable" + // cooldown (errorConfig.ts TRANSIENT_COOLDOWN_MS/transientMax) already + // covers both cases at the same magnitude. The behavioral fix for + // shared-egress accounts lives in the caller's skip logic, not here. + const a = account(); + const b = account(); + markCooldown(a); + markCooldown(b); + // Both draw from the same base backoff ± up to 1s jitter — same formula, + // no separate "short" magnitude for either call site. + assert.ok( + Math.abs(a.cooldownUntil - b.cooldownUntil) <= 1000, + "same account state must produce cooldowns within the shared jitter window" + ); + }); + + it("markSuccess resets consecutiveFails to 0", () => { + const acct = account({ consecutiveFails: 5 }); + markSuccess(acct); + assert.strictEqual(acct.consecutiveFails, 0); + }); + + it("maskAccountId masks a real fingerprint to its first 8 chars + ellipsis", () => { + assert.strictEqual(maskAccountId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), "aaaaaaaa…"); + }); + + it("maskAccountId reports the empty/default fingerprint as 'direct'", () => { + assert.strictEqual(maskAccountId(""), "direct"); + }); + + it("pickAccount skips accounts in cooldown and rotates nextAccountIdx", () => { + const a = account({ fingerprint: "a", cooldownUntil: Date.now() + 60_000 }); + const b = account({ fingerprint: "b", cooldownUntil: 0 }); + const state = { nextAccountIdx: 0 }; + const picked = pickAccount([a, b], state); + assert.strictEqual(picked.fingerprint, "b", "must skip the account still in cooldown"); + }); + + it("pickAccount falls back to the next index when every account is in cooldown", () => { + const a = account({ fingerprint: "a", cooldownUntil: Date.now() + 60_000 }); + const b = account({ fingerprint: "b", cooldownUntil: Date.now() + 60_000 }); + const state = { nextAccountIdx: 0 }; + const picked = pickAccount([a, b], state); + assert.strictEqual(picked.fingerprint, "a", "must still return an account, not throw/hang"); + }); + + it("pickAccount accepts a custom isReady predicate (e.g. JWT-freshness-aware)", () => { + const a = account({ fingerprint: "a", cooldownUntil: 0 }); + const b = account({ fingerprint: "b", cooldownUntil: 0 }); + const state = { nextAccountIdx: 0 }; + // Custom predicate rejects "a" for a reason cooldown alone wouldn't catch. + const picked = pickAccount([a, b], state, (acct: RotatableAccount) => acct.fingerprint !== "a"); + assert.strictEqual(picked.fingerprint, "b"); + }); + + it("isNetworkErrorRotatable is true only when the account has a configured proxy", () => { + const withProxy = account({ + proxy: { type: "http", host: "127.0.0.1", port: 8080 }, + }); + const withoutProxy = account({ proxy: null }); + assert.strictEqual(isNetworkErrorRotatable(withProxy), true); + assert.strictEqual(isNetworkErrorRotatable(withoutProxy), false); + }); +}); diff --git a/tests/unit/alternate-formats.test.ts b/tests/unit/alternate-formats.test.ts index 9aa40d7f34..c285c7f2c8 100644 --- a/tests/unit/alternate-formats.test.ts +++ b/tests/unit/alternate-formats.test.ts @@ -4,6 +4,8 @@ import { resolveAlternateFormat } from "../../open-sse/config/providers/alternat import type { RegistryEntry } from "../../open-sse/config/providers/shared.ts"; import { getTargetFormat } from "../../open-sse/services/provider.ts"; import { DefaultExecutor } from "../../open-sse/executors/default.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; +import { translateRequest } from "../../open-sse/translator/index.ts"; import { getAlternateFormats } from "../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts"; const ENTRY: RegistryEntry = { @@ -42,7 +44,10 @@ test("retorna null quando a conexao nao tem targetFormat", () => { }); test("retorna null quando a entry nao declara alternativas", () => { - assert.equal(resolveAlternateFormat({ ...ENTRY, alternateFormats: undefined }, { targetFormat: "claude" }), null); + assert.equal( + resolveAlternateFormat({ ...ENTRY, alternateFormats: undefined }, { targetFormat: "claude" }), + null + ); assert.equal(resolveAlternateFormat(null, { targetFormat: "claude" }), null); }); @@ -103,7 +108,11 @@ test("resolveBaseUrl: baseUrl manual da conexao vence a alternativa", () => { }); test("resolveBaseUrl: alternativa vence o baseUrl padrao", () => { - const url = precedence({ targetFormat: "claude" }, ENTRY_WITH_ALT, "https://default.example.com/v1"); + const url = precedence( + { targetFormat: "claude" }, + ENTRY_WITH_ALT, + "https://default.example.com/v1" + ); assert.equal(url, "https://alt.example.com/anthropic/v1/messages"); }); @@ -179,7 +188,81 @@ test("getAlternateFormats: provedor com alternativas retorna a lista", () => { }); test("getAlternateFormats: provedor sem alternativas retorna lista vazia", () => { - assert.deepEqual(getAlternateFormats("deepseek"), []); + assert.deepEqual(getAlternateFormats("xai"), []); assert.deepEqual(getAlternateFormats(null), []); assert.deepEqual(getAlternateFormats(undefined), []); }); + +test("DeepSeek defaults to Responses and exposes the official Anthropic endpoint", () => { + assert.equal(getTargetFormat("deepseek", null), "openai-responses"); + assert.equal(getTargetFormat("deepseek", { targetFormat: "claude" }), "claude"); + + const defaultExecutor = new DefaultExecutor("deepseek"); + assert.equal( + defaultExecutor.buildUrl("deepseek-v4-pro", true, 0, { apiKey: "sk-test" } as never), + "https://api.deepseek.com/responses" + ); + const defaultHeaders = defaultExecutor.buildHeaders({ apiKey: "sk-test" } as never, true); + assert.equal(defaultHeaders.Authorization, "Bearer sk-test"); + + const anthropicCredentials = { + apiKey: "sk-test", + providerSpecificData: { targetFormat: "claude" }, + } as never; + assert.equal( + defaultExecutor.buildUrl("deepseek-v4-pro", true, 0, anthropicCredentials), + "https://api.deepseek.com/anthropic/v1/messages" + ); + const anthropicHeaders = defaultExecutor.buildHeaders(anthropicCredentials, true); + assert.equal(anthropicHeaders["x-api-key"], "sk-test"); + assert.equal(typeof anthropicHeaders["Anthropic-Version"], "string"); + + const alternates = getAlternateFormats("deepseek"); + assert.equal(alternates.length, 1); + assert.equal(alternates[0].format, "claude"); +}); + +test("DeepSeek reuses the generic Chat-to-Responses and Responses-to-Anthropic translators", () => { + const responsesBody = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI_RESPONSES, + "deepseek-v4-pro", + { + model: "deepseek-v4-pro", + messages: [{ role: "user", content: "hello" }], + max_tokens: 123, + stream: true, + }, + true, + {}, + "deepseek" + ) as Record; + assert.equal(responsesBody.messages, undefined); + assert.equal(responsesBody.max_output_tokens, 123); + assert.deepEqual(responsesBody.input, [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "hello" }], + status: "completed", + }, + ]); + + const anthropicBody = translateRequest( + FORMATS.OPENAI_RESPONSES, + FORMATS.CLAUDE, + "deepseek-v4-pro", + { + model: "deepseek-v4-pro", + input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }], + stream: true, + }, + true, + {}, + "deepseek" + ) as Record; + assert.equal(anthropicBody.input, undefined); + assert.deepEqual(anthropicBody.messages, [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + ]); +}); diff --git a/tests/unit/antigravity-competitive-prompt-strip.test.ts b/tests/unit/antigravity-competitive-prompt-strip.test.ts new file mode 100644 index 0000000000..c7b6d3bf1e --- /dev/null +++ b/tests/unit/antigravity-competitive-prompt-strip.test.ts @@ -0,0 +1,64 @@ +/** + * Competitive system-prompt strip (port of decolua/9router b566b20, + * generalized): Antigravity's server-side filter flags system prompts + * advertising competing agents ("You are a Claude agent, built on + * Anthropic's Claude Agent SDK.") and answers with 429 RESOURCE_EXHAUSTED. + * The strip removes the identity sentences before dispatch. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { stripCompetitiveAgentPrompts } from "../../open-sse/executors/antigravity.ts"; + +test("strips the exact Claude Agent SDK identity line (9router b566b20 case)", () => { + const input = { + parts: [{ text: "You are a Claude agent, built on Anthropic's Claude Agent SDK." }], + }; + const out = stripCompetitiveAgentPrompts(input) as { parts: Array<{ text: string }> }; + assert.equal(out.parts[0].text, ""); +}); + +test("keeps the instruction text that follows the identity sentence", () => { + const input = { + parts: [ + { + text: + "You are a Claude agent, built on Anthropic's Claude Agent SDK.\n" + + "Answer concisely and cite sources.", + }, + ], + }; + const out = stripCompetitiveAgentPrompts(input) as { parts: Array<{ text: string }> }; + assert.equal(out.parts[0].text, "Answer concisely and cite sources."); +}); + +test("strips 'You are Claude Code' and Anthropic-created assistant lines", () => { + const input = { parts: [{ text: "You are Claude Code, an agentic coding tool." }] }; + const out = stripCompetitiveAgentPrompts(input) as { parts: Array<{ text: string }> }; + assert.equal(out.parts[0].text, ""); +}); + +test("leaves ordinary system prompts untouched (same reference, no allocation)", () => { + const input = { parts: [{ text: "You are a helpful assistant. Be concise." }] }; + const out = stripCompetitiveAgentPrompts(input); + assert.strictEqual(out, input, "must return the original reference when nothing matched"); +}); + +test("only rewrites matching parts in a multi-part system instruction", () => { + const input = { + parts: [ + { text: "You are a Claude agent, built on Anthropic's Claude Agent SDK." }, + { text: "Use the tools when available." }, + ], + }; + const out = stripCompetitiveAgentPrompts(input) as { parts: Array<{ text: string }> }; + assert.equal(out.parts[0].text, ""); + assert.equal(out.parts[1].text, "Use the tools when available."); +}); + +test("returns the input unchanged for non-systemInstruction shapes", () => { + const input = { contents: [{ role: "user", parts: [{ text: "hi" }] }] }; + assert.strictEqual(stripCompetitiveAgentPrompts(input), input); + assert.strictEqual(stripCompetitiveAgentPrompts(null), null); + assert.strictEqual(stripCompetitiveAgentPrompts(undefined), undefined); +}); diff --git a/tests/unit/antigravity-geoblock-resilience.test.ts b/tests/unit/antigravity-geoblock-resilience.test.ts new file mode 100644 index 0000000000..5f85e6f8a4 --- /dev/null +++ b/tests/unit/antigravity-geoblock-resilience.test.ts @@ -0,0 +1,189 @@ +/** + * Antigravity geo-block resilience (#PR): the Cloud Code / Gemini Code Assist + * model API refuses unsupported egress locations with 400 FAILED_PRECONDITION + * "User location is not supported for the API use." Previously this was + * classified as a generic 400 ("Antigravity upstream error (400)"), never + * excluded the account, and the dashboard connection test stayed green because + * it only probed the (non-geo-restricted) OAuth userinfo endpoint. + * + * Coverage: + * 1. classifyProviderError maps the geo refusal to GEO_BLOCKED (non-terminal), + * scoped to the Google AI surfaces that emit it (Cloud Code / Gemini API). + * 2. isGeoBlockedError recognizes the real Google wording and rejects lookalikes. + * 3. classify429 keeps Google's RESOURCE_EXHAUSTED-per-minute as rate_limited + * (established repo behavior — guards against future regressions here). + * 4. buildAntigravityUpstreamError surfaces an actionable geo message. + * 5. The dashboard probe for antigravity/agy hits the REAL model surface + * (streamGenerateContent), not userinfo. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { classifyProviderError, isGeoBlockedError, PROVIDER_ERROR_TYPES } = + await import("../../open-sse/services/errorClassifier.ts"); +const { classify429 } = await import("../../open-sse/services/antigravity429Engine.ts"); +const { buildAntigravityUpstreamError } = + await import("../../open-sse/executors/antigravityUpstreamError.ts"); +const { OAUTH_TEST_CONFIG } = + await import("../../src/app/api/providers/[id]/test/oauthTestConfig.ts"); + +const GEO_BODY = { + error: { + code: 400, + message: "User location is not supported for the API use.", + status: "FAILED_PRECONDITION", + }, +}; + +// ── 1. classifyProviderError ──────────────────────────────────────────────── + +test("geo refusal (400 FAILED_PRECONDITION) -> GEO_BLOCKED", () => { + assert.equal( + classifyProviderError(400, GEO_BODY, "antigravity"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); +}); + +test("geo refusal with a raw text body -> GEO_BLOCKED", () => { + assert.equal( + classifyProviderError( + 400, + '{"error":{"status":"FAILED_PRECONDITION","message":"User location is not supported for the API use."}}', + "agy" + ), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); +}); + +test("generic 400 (not geo) does NOT classify as GEO_BLOCKED", () => { + const result = classifyProviderError(400, { error: { message: "bad request" } }, "antigravity"); + assert.notEqual(result, PROVIDER_ERROR_TYPES.GEO_BLOCKED); +}); + +test("429 stays RATE_LIMITED (geo classification is status-scoped)", () => { + assert.equal( + classifyProviderError(429, GEO_BODY, "antigravity"), + PROVIDER_ERROR_TYPES.RATE_LIMITED + ); +}); + +// ── 1b. provider scoping of GEO_BLOCKED ────────────────────────────────────── + +test("geo refusal from Gemini API / Vertex providers -> GEO_BLOCKED", () => { + assert.equal( + classifyProviderError(400, GEO_BODY, "gemini"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); + assert.equal( + classifyProviderError(400, GEO_BODY, "vertex"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); + assert.equal( + classifyProviderError(400, GEO_BODY, "gemini-cli"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); +}); + +test("geo-looking body from a non-Google provider does NOT classify as GEO_BLOCKED", () => { + // Falls through to the generic path (null for an unclassified 400): the 24h + // non-terminal exclusion is reserved for egress-fixable Google blocks — an + // unrelated provider's region wording may mean a permanent block. + assert.equal(classifyProviderError(400, GEO_BODY, "openai"), null); + assert.equal(classifyProviderError(400, GEO_BODY, "anthropic"), null); + assert.equal(classifyProviderError(400, GEO_BODY, "g4f-gemini"), null); + assert.equal( + classifyProviderError(400, "The API is not available in your region.", "mistral"), + null + ); +}); + +test("geo body with no provider does NOT classify as GEO_BLOCKED", () => { + assert.equal(classifyProviderError(400, GEO_BODY, undefined), null); +}); + +test("403 geo refusal stays GEO_BLOCKED for eligible providers", () => { + assert.equal( + classifyProviderError(403, GEO_BODY, "antigravity"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); +}); + +// ── 2. isGeoBlockedError ──────────────────────────────────────────────────── + +test("isGeoBlockedError matches Google wording variants", () => { + assert.equal(isGeoBlockedError("User location is not supported for the API use."), true); + assert.equal( + isGeoBlockedError('{"message":"This location is not supported for the API use"}'), + true + ); + assert.equal(isGeoBlockedError("The API is not available in your region."), true); +}); + +test("isGeoBlockedError rejects lookalike errors", () => { + assert.equal(isGeoBlockedError("Invalid API key"), false); + assert.equal(isGeoBlockedError("Quota exceeded for the API use"), false); + assert.equal(isGeoBlockedError("model not supported"), false); + assert.equal(isGeoBlockedError(""), false); +}); + +// ── 3. classify429: RESOURCE_EXHAUSTED stays rate_limited ─────────────────── + +test("classify429 keeps Google 'Resource has been exhausted (per minute)' as rate_limited", () => { + // Deliberate existing behavior (antigravity-429-quota-cooldown.test.ts): Google + // uses RESOURCE_EXHAUSTED for per-minute rate limits too, and the + // "(e.g. queries per minute limit was reached)" phrasing is the RPM case — + // short cooldown + same-auth retry, NOT a daily quota wall. + assert.equal( + classify429( + "RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. queries per minute limit was reached)." + ), + "rate_limited" + ); + // A genuine quota-wall message still classifies as quota_exhausted. + assert.equal( + classify429("Individual quota reached. Contact your administrator."), + "quota_exhausted" + ); +}); + +// ── 4. buildAntigravityUpstreamError ──────────────────────────────────────── + +test("geo-blocked upstream error body carries an actionable hint", () => { + const body = buildAntigravityUpstreamError(400, "", JSON.stringify(GEO_BODY)) as { + error?: { message?: string }; + }; + assert.match(String(body.error?.message), /location is not supported/i); + assert.match(String(body.error?.message), /proxy in a supported region/i); + assert.match(String(body.error?.message), /connection test/i); +}); + +test("non-geo upstream error body is unchanged in shape", () => { + const body = buildAntigravityUpstreamError(500, "", '{"error":"boom"}') as { + error?: { message?: string }; + }; + assert.match(String(body.error?.message), /Antigravity upstream error \(500\)/); + assert.doesNotMatch(String(body.error?.message), /supported region/i); +}); + +// ── 5. dashboard probe hits the real model surface ────────────────────────── + +test("antigravity/agy connection test probes streamGenerateContent, not userinfo", async () => { + for (const provider of ["antigravity", "agy"]) { + const entry = OAUTH_TEST_CONFIG[provider]; + assert.ok(entry, `${provider} has a test config`); + assert.equal(typeof entry.buildProbe, "function", `${provider} uses a buildProbe`); + + const probe = await entry.buildProbe( + { providerSpecificData: { clientProfile: "ide" } }, + "sk-test-token" + ); + assert.match(probe.url, /v1internal:streamGenerateContent\?alt=sse/); + assert.equal(probe.method, "POST"); + assert.match(probe.headers.Authorization, /Bearer sk-test-token/); + assert.equal(probe.headers["Content-Type"], "application/json"); + assert.ok(probe.body, "probe carries a minimal generation body"); + const parsedBody = JSON.parse(probe.body as string); + assert.ok(Array.isArray(parsedBody.contents)); + assert.equal(parsedBody.generationConfig.maxOutputTokens, 1); + } +}); diff --git a/tests/unit/antigravity-model-aliases.test.ts b/tests/unit/antigravity-model-aliases.test.ts index 18b84a97a0..f1e5cda7f2 100644 --- a/tests/unit/antigravity-model-aliases.test.ts +++ b/tests/unit/antigravity-model-aliases.test.ts @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { ANTIGRAVITY_PUBLIC_MODELS, getClientVisibleAntigravityModelName, + isDiscoverableAntigravityModelId, isUserCallableAntigravityModelId, resolveAntigravityModelId, toClientAntigravityModelId, @@ -17,6 +18,8 @@ function getPublicModel(id: string) { } const EXPECTED_FLASH_TIERS = [ + ["gemini-3.7-flash-high", "Gemini 3.7 Flash (High)"], + ["gemini-3.7-flash-medium", "Gemini 3.7 Flash (Medium)"], ["gemini-3.6-flash-low", "Gemini 3.6 Flash (Low)"], ["gemini-3.6-flash-medium", "Gemini 3.6 Flash (Medium)"], ["gemini-3.6-flash-high", "Gemini 3.6 Flash (High)"], @@ -96,6 +99,18 @@ test("isUserCallableAntigravityModelId only allows public chat-capable model IDs assert.equal(isUserCallableAntigravityModelId("unknown-model"), false); }); +test("isDiscoverableAntigravityModelId accepts new live chat models without a static catalog entry", () => { + assert.equal(isDiscoverableAntigravityModelId("gemini-3.8-flash-high"), true); + assert.equal(isDiscoverableAntigravityModelId("claude-sonnet-5"), true); + assert.equal(isDiscoverableAntigravityModelId("gemini-new-live-tier"), true); + + assert.equal(isDiscoverableAntigravityModelId("tab_flash_lite_preview"), false); + assert.equal(isDiscoverableAntigravityModelId("gemini-3.1-flash-image"), false); + assert.equal(isDiscoverableAntigravityModelId("gemini-3.1-flash-tts-preview"), false); + assert.equal(isDiscoverableAntigravityModelId("gemini-2.5-flash-preview-tts"), false); + assert.equal(isDiscoverableAntigravityModelId(""), false); +}); + test("ANTIGRAVITY_PUBLIC_MODELS exposes current live names and capabilities", () => { // #3184: Claude is exposed in the antigravity catalog (same backend as `agy`, verified). // #7129: Opus 4.6, Sonnet 4.6, and Sonnet 5 graduated to a 1M-token context window at GA diff --git a/tests/unit/antigravity-retired-public-models.test.ts b/tests/unit/antigravity-retired-public-models.test.ts index bddb7b7e75..207e6aca43 100644 --- a/tests/unit/antigravity-retired-public-models.test.ts +++ b/tests/unit/antigravity-retired-public-models.test.ts @@ -34,6 +34,12 @@ const EXPECTED_LEADING_MODEL_ORDER = [ "gemini-3.5-flash-extra-low", ] as const; +const EXPECTED_ANTIGRAVITY_LEADING_MODEL_ORDER = [ + "gemini-3.7-flash-high", + "gemini-3.7-flash-medium", + ...EXPECTED_LEADING_MODEL_ORDER, +] as const; + const ACTIVE_FLASH_MODEL_IDS = [ "gemini-3-flash-agent", "gemini-3.5-flash-low", @@ -46,15 +52,15 @@ const CURRENT_36_FLASH_MODEL_IDS = [ "gemini-3.6-flash-low", ] as const; -test("Antigravity and AGY place the live Gemini 3.6 default tiers first", () => { - for (const [provider, models] of [ - ["antigravity", ANTIGRAVITY_PUBLIC_MODELS], - ["agy", AGY_PUBLIC_MODELS], +test("Antigravity and AGY place their live Gemini Flash tiers first", () => { + for (const [provider, models, expectedOrder] of [ + ["antigravity", ANTIGRAVITY_PUBLIC_MODELS, EXPECTED_ANTIGRAVITY_LEADING_MODEL_ORDER], + ["agy", AGY_PUBLIC_MODELS, EXPECTED_LEADING_MODEL_ORDER], ] as const) { assert.deepEqual( - models.slice(0, EXPECTED_LEADING_MODEL_ORDER.length).map((model) => model.id), - EXPECTED_LEADING_MODEL_ORDER, - `${provider} public catalog must place the live Gemini 3.6 default tiers first` + models.slice(0, expectedOrder.length).map((model) => model.id), + expectedOrder, + `${provider} public catalog must place its live Gemini Flash tiers first` ); } }); diff --git a/tests/unit/autoCombo/provider-family-combos.test.ts b/tests/unit/autoCombo/provider-family-combos.test.ts index 45c28c3a30..3a7e33e4f2 100644 --- a/tests/unit/autoCombo/provider-family-combos.test.ts +++ b/tests/unit/autoCombo/provider-family-combos.test.ts @@ -141,7 +141,10 @@ describe("auto/ materialization (#6453)", () => { // `devin-cli-agentic` joined for the same documented reason as `auggie`: // #8914 added the Devin ACP bridge whose catalog (registry/devin/catalog.ts) // advertises the glm-5-2* line, so it genuinely serves the family. - assert.deepEqual(providerIds, ["auggie", "devin-cli-agentic", "glm", "zai"]); + // `zcode` joined for the same documented reason too — #10184 added the local + // ZCode app-server backend whose registry (registry/zcode) advertises the + // full GLM_SHARED_MODELS line-up, so it genuinely serves the family. + assert.deepEqual(providerIds, ["auggie", "devin-cli-agentic", "glm", "zai", "zcode"]); // Every candidate must be a glm-family model (the Cartesian pool now surfaces // each backend's full glm line-up, not only the glm-5.2 default), and the // connected openai/gpt-4o-mini backend must be excluded — same family diff --git a/tests/unit/build/standalone-bundle.test.ts b/tests/unit/build/standalone-bundle.test.ts new file mode 100644 index 0000000000..2444469202 --- /dev/null +++ b/tests/unit/build/standalone-bundle.test.ts @@ -0,0 +1,304 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; + +/** + * Stage 8 (issue #10321) — shared standalone web bundle. + * + * One ubuntu `web-build` job packs `.build/next` into a deterministic + * archive + byte-level manifest; every desktop leg restores it (verifying + * every entry) and re-forks install-machine-forked native optionals for its + * platform. These tests pin the integrity chain on temp trees: pack/restore + * roundtrip, byte determinism, tamper detection (archive and restored tree), + * manifest-version gating, native fork hydration, and the bundled-native + * serviceability assertion (including the onnxruntime darwin-x64 exemption). + */ + +const bundleMod = await import("../../../scripts/build/standaloneBundle.mjs"); +const manifestMod = await import("../../../scripts/build/standaloneManifest.mjs"); +const hydrateMod = await import("../../../scripts/build/hydrateNativeDeps.mjs"); + +const { runPack, runRestore } = bundleMod as typeof bundleMod & { + runPack: (opts: { dir?: string; out: string; manifest?: string }) => Promise<{ + archive: string; + manifest: string; + files: number; + archiveBytes: number; + }>; + runRestore: (opts: { archive: string; manifest?: string; dir?: string }) => Promise<{ + archive: string; + dir: string; + files: number; + }>; +}; +const { verifyStandaloneManifest, MANIFEST_VERSION } = manifestMod as typeof manifestMod & { + MANIFEST_VERSION: number; + verifyStandaloneManifest: ( + rootDir: string, + manifest: unknown + ) => Promise<{ ok: true } | { ok: false; errors: string[] }>; +}; +const { hydratePlatformNatives, verifyBundledNatives } = hydrateMod as typeof hydrateMod & { + hydratePlatformNatives: (opts: { standaloneNodeModules: string; sourceNodeModules: string }) => { + replaced: string[]; + removed: string[]; + copied: string[]; + }; + verifyBundledNatives: (opts: { nodeModulesDir: string; platform: string; arch: string }) => { + ok: boolean; + errors: string[]; + }; +}; + +const IS_WINDOWS = process.platform === "win32"; + +function tmpDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function sha256File(filePath: string): string { + return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +/** Minimal fake `.build/next` tree: nested files, exec bit, and a symlink. */ +function buildWebTree(root: string): void { + const standalone = path.join(root, "standalone"); + fs.mkdirSync(path.join(standalone, "node_modules", "left-pad"), { recursive: true }); + fs.writeFileSync(path.join(standalone, "server.js"), "console.log('omniroute');\n"); + fs.writeFileSync( + path.join(standalone, "node_modules", "left-pad", "index.js"), + "module.exports = (s, n) => String(s).padStart(n);\n" + ); + fs.writeFileSync(path.join(standalone, "node_modules", "left-pad", "package.json"), "{}\n"); + const bin = path.join(standalone, "server-cli.js"); + fs.writeFileSync(bin, "#!/usr/bin/env node\n"); + fs.chmodSync(bin, 0o755); + fs.mkdirSync(path.join(root, "static"), { recursive: true }); + fs.writeFileSync(path.join(root, "static", "app.css"), "body{margin:0}\n"); + if (!IS_WINDOWS) { + fs.symlinkSync("../standalone/server.js", path.join(root, "static", "server-link.js")); + } +} + +function writeNative(root: string, relPath: string, content: string): void { + const target = path.join(root, ...relPath.split("/")); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +test("pack → restore roundtrip restores the tree byte-for-byte", async () => { + const src = tmpDir("s8-src-"); + const out = path.join(tmpDir("s8-out-"), "web-bundle.tar.gz"); + const dst = tmpDir("s8-dst-"); + try { + buildWebTree(src); + const packed = await runPack({ dir: src, out }); + assert.ok(packed.files > 0, "manifest must list entries"); + assert.ok(fs.existsSync(`${out}.manifest.json`), "manifest written next to archive"); + + const restored = await runRestore({ archive: out, dir: dst }); + assert.equal(restored.files, packed.files); + + assert.equal( + fs.readFileSync(path.join(dst, "standalone", "server.js"), "utf8"), + "console.log('omniroute');\n" + ); + // The restored tree satisfies the manifest (sizes + hashes + symlink targets). + const manifest = JSON.parse(fs.readFileSync(`${out}.manifest.json`, "utf8")); + const verdict = await verifyStandaloneManifest(dst, manifest); + assert.equal( + verdict.ok, + true, + `restored tree must verify: ${verdict.ok ? "" : (verdict as { errors: string[] }).errors.join("; ")}` + ); + if (!IS_WINDOWS) { + assert.equal( + fs.readlinkSync(path.join(dst, "static", "server-link.js")), + "../standalone/server.js", + "symlink target preserved" + ); + assert.equal( + fs.statSync(path.join(dst, "standalone", "server-cli.js")).mode & 0o111, + 0o111, + "exec bit preserved" + ); + } + } finally { + fs.rmSync(src, { recursive: true, force: true }); + fs.rmSync(path.dirname(out), { recursive: true, force: true }); + fs.rmSync(dst, { recursive: true, force: true }); + } +}); + +test("packing is byte-deterministic across runs", async () => { + const src = tmpDir("s8-det-"); + const outDir = tmpDir("s8-det-out-"); + try { + buildWebTree(src); + const a = path.join(outDir, "a.tar.gz"); + const b = path.join(outDir, "b.tar.gz"); + await runPack({ dir: src, out: a }); + await runPack({ dir: src, out: b }); + assert.equal(sha256File(a), sha256File(b), "two packs of the same tree must be identical"); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + fs.rmSync(outDir, { recursive: true, force: true }); + } +}); + +test("restore rejects a corrupted archive before extraction", async () => { + const src = tmpDir("s8-tamper-"); + const outDir = tmpDir("s8-tamper-out-"); + try { + buildWebTree(src); + const out = path.join(outDir, "web-bundle.tar.gz"); + await runPack({ dir: src, out }); + const raw = fs.readFileSync(out); + raw[raw.length - 10] ^= 0xff; // flip one byte in the gzip trailer region + fs.writeFileSync(out, raw); + await assert.rejects(() => runRestore({ archive: out, dir: path.join(outDir, "dst") }), /sha/); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + fs.rmSync(outDir, { recursive: true, force: true }); + } +}); + +test("manifest verification flags modified and smuggled files in a restored tree", async () => { + const src = tmpDir("s8-verify-"); + const outDir = tmpDir("s8-verify-out-"); + const dst = tmpDir("s8-verify-dst-"); + try { + buildWebTree(src); + const out = path.join(outDir, "web-bundle.tar.gz"); + await runPack({ dir: src, out }); + await runRestore({ archive: out, dir: dst }); + + fs.appendFileSync(path.join(dst, "standalone", "server.js"), "// tampered\n"); + fs.writeFileSync(path.join(dst, "static", "smuggled.js"), "evil();\n"); + + const manifest = JSON.parse(fs.readFileSync(`${out}.manifest.json`, "utf8")); + const verdict = await verifyStandaloneManifest(dst, manifest); + assert.equal(verdict.ok, false); + assert.ok( + verdict.errors.some((e) => e.includes("standalone/server.js")), + `content tampering detected: ${verdict.errors.join("; ")}` + ); + assert.ok( + verdict.errors.some((e) => e.includes("unlisted files") && e.includes("static/smuggled.js")), + `smuggled file detected: ${verdict.errors.join("; ")}` + ); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + fs.rmSync(outDir, { recursive: true, force: true }); + fs.rmSync(dst, { recursive: true, force: true }); + } +}); + +test("manifest verification rejects an unsupported manifest version", async () => { + const dst = tmpDir("s8-ver-"); + try { + const verdict = await verifyStandaloneManifest(dst, { + version: MANIFEST_VERSION + 1, + entries: [], + }); + assert.equal(verdict.ok, false); + assert.match(verdict.errors[0] ?? "", /unsupported manifest version/); + } finally { + fs.rmSync(dst, { recursive: true, force: true }); + } +}); + +test("hydratePlatformNatives swaps install-machine-forked packages for this leg", () => { + const standalone = tmpDir("s8-hydrate-sa-"); + const source = tmpDir("s8-hydrate-src-"); + try { + // The ubuntu-built standalone carries linux sharp + darwin-only fsevents. + writeNative( + standalone, + "node_modules/@img/sharp-linux-x64/package.json", + '{"name":"@img/sharp-linux-x64"}' + ); + writeNative(standalone, "node_modules/@img/sharp-linux-x64/lib/index.js", "linux fork"); + writeNative(standalone, "node_modules/fsevents/fsevents.js", "mac only"); + // This leg (darwin-arm64) resolved its own forks: different sharp, no fsevents. + writeNative( + source, + "node_modules/@img/sharp-darwin-arm64/package.json", + '{"name":"@img/sharp-darwin-arm64"}' + ); + writeNative(source, "node_modules/@img/sharp-darwin-arm64/lib/index.js", "darwin fork"); + + const result = hydratePlatformNatives({ + standaloneNodeModules: path.join(standalone, "node_modules"), + sourceNodeModules: path.join(source, "node_modules"), + }); + + // Platform forks ship under different package names, so hydration is + // remove(standalone fork) + copy(this leg's fork); `replaced` stays empty + // unless the exact same name exists on both sides. + assert.deepEqual(result.copied.sort(), ["@img/sharp-darwin-arm64"]); + assert.deepEqual(result.replaced, []); + assert.deepEqual(result.removed.sort(), ["@img/sharp-linux-x64", "fsevents"]); + assert.ok( + fs.existsSync( + path.join(standalone, "node_modules", "@img", "sharp-darwin-arm64", "lib", "index.js") + ), + "darwin fork copied in" + ); + assert.ok( + !fs.existsSync(path.join(standalone, "node_modules", "@img", "sharp-linux-x64")), + "linux fork removed" + ); + assert.ok( + !fs.existsSync(path.join(standalone, "node_modules", "fsevents")), + "fsevents dropped on non-matching leg" + ); + } finally { + fs.rmSync(standalone, { recursive: true, force: true }); + fs.rmSync(source, { recursive: true, force: true }); + } +}); + +test("verifyBundledNatives asserts serviceability and honors the onnx darwin-x64 exemption", () => { + const root = tmpDir("s8-natives-"); + try { + const nm = path.join(root, "node_modules"); + writeNative(nm, "koffi/build/koffi/linux_x64/koffi.node", "elf"); + writeNative(nm, "better-sqlite3/prebuilds/linux-x64.node", "napi"); + writeNative(nm, "wreq-js/rust/wreq-js.linux-x64-gnu.node", "rust"); + writeNative(nm, "onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime.so", "ort"); + + const good = verifyBundledNatives({ nodeModulesDir: nm, platform: "linux", arch: "x64" }); + assert.equal( + good.ok, + true, + `expected serviceable: ${(good as { errors?: string[] }).errors?.join("; ")}` + ); + + const missingKoffi = verifyBundledNatives({ + nodeModulesDir: nm, + platform: "darwin", + arch: "arm64", + }); + assert.equal(missingKoffi.ok, false); + assert.ok((missingKoffi as { errors: string[] }).errors.some((e) => e.startsWith("koffi:"))); + + // darwin-x64 has no onnxruntime-node prebuild at all — the exemption must keep it green + // as long as the other bundled natives service that triple. + const nm2 = path.join(root, "node_modules2"); + writeNative(nm2, "koffi/build/koffi/darwin_x64/koffi.node", "macho"); + writeNative(nm2, "better-sqlite3/prebuilds/darwin-x64.node", "napi"); + writeNative(nm2, "wreq-js/rust/wreq-js.darwin-x64.node", "rust"); + const exempted = verifyBundledNatives({ nodeModulesDir: nm2, platform: "darwin", arch: "x64" }); + assert.equal( + exempted.ok, + true, + `darwin-x64 must pass via exemption: ${(exempted as { errors?: string[] }).errors?.join("; ")}` + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/chat-body-admission-aggregate-10110.test.ts b/tests/unit/chat-body-admission-aggregate-10110.test.ts new file mode 100644 index 0000000000..22e67fd763 --- /dev/null +++ b/tests/unit/chat-body-admission-aggregate-10110.test.ts @@ -0,0 +1,271 @@ +// #10110: Aggregate process-wide bounds for byte-level chat admission. +// +// The always-on per-connection admission layer (#9940 / #9654) enforces +// CHAT_MAX_HEAVY_IN_FLIGHT and CHAT_ADMISSION_MAX_QUEUED_BYTES PER LANE, so the +// documented "in one process" contract (docs/reference/ENVIRONMENT.md:193) is +// multiplied by OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS (default 64): up to 64 +// concurrent heavy requests and 256 MiB of parked bodies process-wide. +// +// These tests assert the AGGREGATE contract that the issue's acceptance +// criteria demand. They are intentionally deterministic (exact === assertions, +// no <= fudge) and are RED on release/v3.8.50 — they only pass once the byte +// level admits against one process-global budget with per-key fairness. +import test from "node:test"; +import assert from "node:assert/strict"; + +const admissionModule = await import("../../src/shared/middleware/chatBodyAdmission.ts"); +const { PerConnectionAdmissionController, CHAT_ADMISSION_MAX_QUEUED_BYTES } = admissionModule; + +const GLOBAL_QUEUED_BUDGET = CHAT_ADMISSION_MAX_QUEUED_BYTES; // 4 MiB default + +// Aggregate across distinct controllers. With the fix every key resolves to +// ONE process-global controller (shared budget), so dedupe-by-identity yields +// the true process-wide totals — never double-counted, never multiplied by +// the number of keys. +function aggregateActiveHeavy( + pc: InstanceType, + keys: string[] +): number { + const seen = new Set(); + let total = 0; + for (const key of keys) { + const controller = pc.getController(key); + if (seen.has(controller)) continue; + seen.add(controller); + total += controller.activeHeavy; + } + return total; +} + +function aggregateQueuedBytes( + pc: InstanceType, + keys: string[] +): number { + const seen = new Set(); + let total = 0; + for (const key of keys) { + const controller = pc.getController(key); + if (seen.has(controller)) continue; + seen.add(controller); + total += controller.queuedBytes; + } + return total; +} + +// ── Family 1: active-LRU eviction must not mint replacement capacity ────── +// Issue repro: maxSessions=1, key A acquires; key B admission LRU-evicts A; +// A re-admits and gets a FRESH controller with fresh capacity while the old +// lease still holds → effectiveActiveForA = 2. The aggregate must stay 1. + +test("LRU eviction of a live lane does not mint a second capacity slot", () => { + // Red on release/v3.8.50: B's admission LRU-evicts A's lane, and A's re-admit + // gets a FRESH controller with fresh capacity while the old lease still holds. + // With the fix there are no per-session lanes at all: getController returns the + // one shared process-global controller, so no capacity can ever be minted. + const pc = new PerConnectionAdmissionController(1, { maxSessions: 1, sessionTtlMs: 60_000 }); + + const ctrlA1 = pc.getController("A"); + const leaseA = ctrlA1.tryAcquireHeavy(); + assert.ok(leaseA, "A acquires the only slot"); + + // B's admission must NOT evict the lane holding a live lease; and even if + // the lane is retired/recreated, it must not mint fresh capacity. + pc.getController("B"); + + // A re-admits: no fresh capacity may appear while the old lease is live. + const ctrlA2 = pc.getController("A"); + assert.equal( + ctrlA2.tryAcquireHeavy(), + null, + "a live lease must keep its slot; no second capacity may be minted" + ); + + // Aggregate active heavy across every lane stays at the process-wide bound. + assert.equal( + aggregateActiveHeavy(pc, ["A", "B"]), + 1, + "process-wide active heavy must be 1, not 2 (orphaned lease + minted slot)" + ); + + leaseA.release(); +}); + +// ── Family 2: active-TTL eviction must not mint replacement capacity ────── +// Same invariant via the idle-TTL path: a lane that still holds a live lease +// must not be evicted (or, if retired, must not hand out fresh capacity). + +test("TTL eviction of a live lane does not mint a second capacity slot", async () => { + // Red on release/v3.8.50: the idle-TTL evicts A's lane mid-lease; a re-admit + // then mints a fresh controller with fresh capacity (orphaned lease + new slot). + // With the fix the shared controller outlives any session and never mints. + const pc = new PerConnectionAdmissionController(1, { maxSessions: 64, sessionTtlMs: 50 }); + + const ctrlA1 = pc.getController("A"); + const leaseA = ctrlA1.tryAcquireHeavy(); + assert.ok(leaseA, "A acquires the only slot"); + + // Wait past the idle TTL so evictIfDue() would mark A's lane stale. + await new Promise((resolve) => setTimeout(resolve, 120)); + + // Re-admitting A must not produce a controller with fresh capacity. + const ctrlA2 = pc.getController("A"); + assert.equal( + ctrlA2.tryAcquireHeavy(), + null, + "a live lease must survive TTL; no fresh capacity may be minted" + ); + assert.equal( + aggregateActiveHeavy(pc, ["A"]), + 1, + "process-wide active heavy must stay 1 after TTL with a live lease" + ); + + leaseA.release(); +}); + +// ── Family 3: aggregate parked bytes stay within the process-wide budget ── +// Regression guard for the byte side of the multiplication: waiters parked +// from DIFFERENT lanes must share ONE process-wide queued-bytes budget. +// +// Note on shape: on the buggy code an idle lane never parks (its waiter +// acquires on its own free capacity instantly), so cross-lane bytes are only +// observable while multiple lanes are simultaneously busy — an arrangement +// the global-budget fix makes impossible by construction. The active-heavy +// families (1/2/4) are the RED probes; this family locks in the byte budget +// once the shared budget exists: one busy slot + waiters parked from two +// lanes, aggregate must never exceed the single process-wide budget. + +test("parked bytes across lanes share one process-wide budget", async () => { + const pc = new PerConnectionAdmissionController(1, { maxSessions: 64, sessionTtlMs: 60_000 }); + + // One lane holds the single busy slot. + const ctrlA = pc.getController("A"); + const heldA = ctrlA.tryAcquireHeavy(); + assert.ok(heldA); + + // Two waiters park — one keyed A, one keyed B — against the SAME busy slot. + // Together they must respect the single process-wide budget. + const wA = ctrlA.acquireHeavyWithin(2_000, undefined, GLOBAL_QUEUED_BUDGET, "A"); + const wB = pc.getController("B").acquireHeavyWithin(2_000, undefined, GLOBAL_QUEUED_BUDGET, "B"); + await new Promise((resolve) => setTimeout(resolve, 30)); + + const aggregate = aggregateQueuedBytes(pc, ["A", "B"]); + assert.ok( + aggregate <= GLOBAL_QUEUED_BUDGET, + `aggregate queued bytes (${aggregate}) must stay within the single process-wide budget (${GLOBAL_QUEUED_BUDGET})` + ); + + heldA.release(); + const leases = await Promise.all([wA, wB]); + for (const lease of leases) lease?.release(); + assert.equal(aggregateQueuedBytes(pc, ["A", "B"]), 0, "all parked bytes released"); +}); + +test("a 16 MiB per-lane config still respects the process-wide byte budget", async () => { + // The issue's 1 GiB scenario shape: per-lane budgets that would multiply + // into 1 GiB must instead be capped by the single process-wide budget. + const MiB = 1024 * 1024; + const pc = new PerConnectionAdmissionController(1, { maxSessions: 64, sessionTtlMs: 60_000 }); + + const ctrlA = pc.getController("A"); + const heldA = ctrlA.tryAcquireHeavy(); + assert.ok(heldA); + + const wA = ctrlA.acquireHeavyWithin(2_000, undefined, 16 * MiB, "A"); + const wB = pc.getController("B").acquireHeavyWithin(2_000, undefined, 16 * MiB, "B"); + await new Promise((resolve) => setTimeout(resolve, 30)); + + const aggregate = aggregateQueuedBytes(pc, ["A", "B"]); + assert.ok( + aggregate <= GLOBAL_QUEUED_BUDGET, + `16 MiB per-lane config must still respect the process-wide budget; aggregate was ${aggregate}` + ); + + heldA.release(); + const leases = await Promise.all([wA, wB]); + for (const lease of leases) lease?.release(); +}); + +// ── Family 4: same-session recreation waits on the global slot ──────────── +// A session that released and re-admits while ANOTHER session holds the +// process-wide slot must queue, not bypass. + +test("same-session recreation waits while another session holds the global slot", () => { + const pc = new PerConnectionAdmissionController(1, { maxSessions: 64, sessionTtlMs: 60_000 }); + + const ctrlA = pc.getController("A"); + const leaseA = ctrlA.tryAcquireHeavy(); + assert.ok(leaseA); + leaseA.release(); // A releases; the global slot is free. + + // B takes the single process-wide slot. + const ctrlB = pc.getController("B"); + const leaseB = ctrlB.tryAcquireHeavy(); + assert.ok(leaseB); + + // A re-admits while B holds the slot → must wait, not bypass. + assert.equal( + ctrlA.tryAcquireHeavy(), + null, + "recreated A must not bypass the process-wide slot held by B" + ); + assert.equal( + aggregateActiveHeavy(pc, ["A", "B"]), + 1, + "aggregate active heavy is 1 with B holding the slot" + ); + + leaseB.release(); +}); + +// ── Fairness guard (B2): lanes are served round-robin over the shared budget ─ +// A session that queues a burst must not consume every dispatch turn: when A +// queues two waiters and B queues one behind the same busy slot, B's waiter +// must be served BEFORE A's second follow-up (round-robin across lanes, the +// adaptive dispatchLanes precedent). A strict single FIFO would serve A-A-B +// and starve B under sustained load. + +test("one session's burst does not starve another session's bounded wait", async () => { + const pc = new PerConnectionAdmissionController(1, { maxSessions: 64, sessionTtlMs: 60_000 }); + + // A holds the single slot and queues two follow-ups. + const ctrlA = pc.getController("A"); + const leaseA = ctrlA.tryAcquireHeavy(); + assert.ok(leaseA); + + const aWaiters = [ + ctrlA.acquireHeavyWithin(2_000, undefined, 0, "A"), + ctrlA.acquireHeavyWithin(2_000, undefined, 0, "A"), + ]; + + // B queues one bounded wait behind the shared slot. + const ctrlB = pc.getController("B"); + const bWaiter = ctrlB.acquireHeavyWithin(2_000, undefined, 0, "B"); + + // Free the slot, then release each lease the moment it arrives so the next + // waiter can proceed. Record acquisition order. + leaseA.release(); + const order: string[] = []; + const track = (label: string) => (lease: unknown) => { + if (lease) { + order.push(label); + (lease as { release: () => void }).release(); + } + }; + void aWaiters[0].then(track("a1")); + void aWaiters[1].then(track("a2")); + void bWaiter.then(track("b1")); + await Promise.all([...aWaiters, bWaiter]); + + assert.equal( + order.length, + 3, + "all three queued sessions must acquire within the bounded wait; none starve" + ); + assert.equal( + order.indexOf("b1"), + 1, + `round-robin must serve B before A's second follow-up (strict FIFO would starve B); got order ${order.join(" -> ")}` + ); + assert.equal(aggregateActiveHeavy(pc, ["A", "B"]), 0); +}); diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts index b7174d6358..e0b71badc9 100644 --- a/tests/unit/chat-route-coverage.test.ts +++ b/tests/unit/chat-route-coverage.test.ts @@ -202,6 +202,7 @@ test("handleChat rejects requests without a model", async () => { test("handleChat applies task-aware routing when a semantic override is enabled", async () => { await seedConnection("deepseek", { apiKey: "sk-deepseek-task-route" }); const seenAuthHeaders = []; + const seenRequestBodies = []; setTaskRoutingConfig({ enabled: true, detectionEnabled: true, @@ -214,7 +215,26 @@ test("handleChat applies task-aware routing when a semantic override is enabled" globalThis.fetch = async (_url, init = {}) => { const headers = toPlainHeaders(init.headers); seenAuthHeaders.push(headers.Authorization ?? headers.authorization); - return buildOpenAIResponse("Task-routed response", "deepseek/deepseek-chat"); + seenRequestBodies.push(JSON.parse(String(init.body))); + return new Response( + JSON.stringify({ + id: "resp_task_route", + object: "response", + status: "completed", + model: "deepseek-v4-flash", + output: [ + { + id: "msg_task_route", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "Task-routed response", annotations: [] }], + }, + ], + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); }; const response = await handleChat( @@ -230,6 +250,8 @@ test("handleChat applies task-aware routing when a semantic override is enabled" assert.equal(response.status, 200); assert.deepEqual(seenAuthHeaders, ["Bearer sk-deepseek-task-route"]); + assert.equal(seenRequestBodies[0].messages, undefined); + assert.equal(seenRequestBodies[0].input[0].role, "user"); assert.equal(json.choices[0].message.content, "Task-routed response"); }); diff --git a/tests/unit/chatcore-header-drop-warn-dedupe-10315.test.ts b/tests/unit/chatcore-header-drop-warn-dedupe-10315.test.ts new file mode 100644 index 0000000000..8485024b1e --- /dev/null +++ b/tests/unit/chatcore-header-drop-warn-dedupe-10315.test.ts @@ -0,0 +1,102 @@ +// #10315: the header-budget drop warn must not storm — identical dropped-header +// sets recur on every SSE response from the same upstream, so we warn once per +// unique drop fingerprint per process and fall back to debug afterwards. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { + buildStreamingResponseHeaders, + fingerprintDroppedHeaders, + resetDroppedHeaderWarnFingerprints, +} = await import("../../open-sse/handlers/chatCore/responseHeaders.ts"); + +type DropPayload = { + budgetBytes: number; + forwardedBytes: number; + droppedCount: number; + droppedHeaders: Array<{ name: string; bytes: number }>; +}; + +function makeLogger() { + const warns: DropPayload[] = []; + const debugs: DropPayload[] = []; + return { + logger: { + warn: (_tag: string, _msg: string, data?: DropPayload) => warns.push(data as DropPayload), + debug: (_tag: string, _msg: string, data?: DropPayload) => debugs.push(data as DropPayload), + }, + warns, + debugs, + }; +} + +const meta = {} as Parameters[1]; + +// Small header + two ~600-byte headers: the first big one fits the 768-byte +// budget alongside the small one, the second is always dropped. +function oversizedProviderHeaders(): Headers { + return new Headers({ + "x-kept-small": "k".repeat(10), + "x-drop-alpha": "a".repeat(600), + "x-drop-beta": "b".repeat(600), + }); +} + +test("#10315: 100 identical oversized responses emit exactly one warn, the rest at debug", () => { + resetDroppedHeaderWarnFingerprints(); + const { logger, warns, debugs } = makeLogger(); + for (let i = 0; i < 100; i++) { + buildStreamingResponseHeaders(oversizedProviderHeaders(), meta, logger); + } + assert.equal(warns.length, 1); + assert.equal(debugs.length, 99); + assert.equal(warns[0].droppedCount, 1); +}); + +test("#10315: a different drop set warns again", () => { + resetDroppedHeaderWarnFingerprints(); + const { logger, warns } = makeLogger(); + buildStreamingResponseHeaders(oversizedProviderHeaders(), meta, logger); + assert.equal(warns.length, 1); + buildStreamingResponseHeaders( + new Headers({ + "x-kept-small": "k".repeat(10), + "x-drop-gamma": "g".repeat(600), + "x-drop-delta": "d".repeat(600), + }), + meta, + logger + ); + assert.equal(warns.length, 2); +}); + +test("#10315: fingerprint is order-insensitive to dropped header names", () => { + assert.equal( + fingerprintDroppedHeaders([ + { name: "X-Drop-Beta", bytes: 600 }, + { name: "x-drop-alpha", bytes: 600 }, + ]), + fingerprintDroppedHeaders([ + { name: "x-drop-alpha", bytes: 600 }, + { name: "X-Drop-Beta", bytes: 600 }, + ]) + ); +}); + +test("#10315: reset hook forgets fingerprints so the same drop set warns again", () => { + resetDroppedHeaderWarnFingerprints(); + const { logger, warns } = makeLogger(); + buildStreamingResponseHeaders(oversizedProviderHeaders(), meta, logger); + resetDroppedHeaderWarnFingerprints(); + buildStreamingResponseHeaders(oversizedProviderHeaders(), meta, logger); + assert.equal(warns.length, 2); +}); + +test("#10315: responses within budget never warn", () => { + resetDroppedHeaderWarnFingerprints(); + const { logger, warns, debugs } = makeLogger(); + const headers = buildStreamingResponseHeaders(new Headers({ "x-fits": "ok" }), meta, logger); + assert.equal(headers["x-fits"], "ok"); + assert.equal(warns.length, 0); + assert.equal(debugs.length, 0); +}); diff --git a/tests/unit/chatcore-target-format.test.ts b/tests/unit/chatcore-target-format.test.ts index 4eb22d5598..b820b245fe 100644 --- a/tests/unit/chatcore-target-format.test.ts +++ b/tests/unit/chatcore-target-format.test.ts @@ -115,6 +115,18 @@ test("#8994: customModelTargetFormat takes precedence over apiFormat='responses' assert.equal(r.targetFormat, "claude", "model-level targetFormat must win over apiFormat"); }); +test("a declared connection alternate overrides the inbound Responses protocol", () => { + const r = resolveChatCoreTargetFormat({ + provider: "deepseek", + resolvedModel: "deepseek-v4-pro", + apiFormat: "responses", + sourceFormat: FORMATS.OPENAI_RESPONSES, + customModelTargetFormat: undefined, + providerSpecificData: { targetFormat: FORMATS.CLAUDE }, + }); + assert.equal(r.targetFormat, FORMATS.CLAUDE); +}); + test("unmapped provider → alias falls back to the provider id", () => { const r = resolveChatCoreTargetFormat({ provider: "some-unmapped-provider", diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 397f3f50d6..c82d6987b2 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -607,8 +607,10 @@ test("chatCore applies Responses input policy to openai-compatible targets", asy }); test("chatCore replays no-tool reasoning across public Responses turns", async () => { + // Direct DeepSeek now speaks Responses upstream. Keep this regression on a + // Chat-compatible DeepSeek host so it continues to exercise the Responses-to-Chat replay path. saveModelsDevCapabilities({ - deepseek: { + siliconflow: { "deepseek-v4-pro": { ...capabilityEntry(128_000), reasoning: true, @@ -640,7 +642,7 @@ test("chatCore replays no-tool reasoning across public Responses turns", async ( ); const first = await invokeChatCore({ - provider: "deepseek", + provider: "siliconflow", model: "deepseek-v4-pro", endpoint: "/v1/responses", body: { @@ -656,7 +658,7 @@ test("chatCore replays no-tool reasoning across public Responses turns", async ( assert.equal(first.result.success, true); const second = await invokeChatCore({ - provider: "deepseek", + provider: "siliconflow", model: "deepseek-v4-pro", endpoint: "/v1/responses", body: { @@ -687,7 +689,7 @@ test("chatCore replays no-tool reasoning across public Responses turns", async ( }); test("chatCore captures streaming no-tool reasoning for Responses replay", async () => { saveModelsDevCapabilities({ - deepseek: { + siliconflow: { "deepseek-v4-pro": { ...capabilityEntry(128_000), reasoning: true, @@ -730,7 +732,7 @@ test("chatCore captures streaming no-tool reasoning for Responses replay", async ); const first = await invokeChatCore({ - provider: "deepseek", + provider: "siliconflow", model: "deepseek-v4-pro", endpoint: "/v1/responses", body: { @@ -748,7 +750,7 @@ test("chatCore captures streaming no-tool reasoning for Responses replay", async await flushAsyncSideEffects(); const second = await invokeChatCore({ - provider: "deepseek", + provider: "siliconflow", model: "deepseek-v4-pro", endpoint: "/v1/responses", body: { diff --git a/tests/unit/cli-config-home-container.test.ts b/tests/unit/cli-config-home-container.test.ts new file mode 100644 index 0000000000..1d85121fff --- /dev/null +++ b/tests/unit/cli-config-home-container.test.ts @@ -0,0 +1,149 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +// The compose `host` profile mounts the operator's real config dirs at +// /host-home, which sits OUTSIDE the container user's home (/home/node). Before +// this fix getCliConfigHome() silently dropped that override and every write +// landed back in the ephemeral container home. See docker-compose.yml. + +const modulePath = path.join(process.cwd(), "src/shared/services/cliRuntime.ts"); +const originalEnv = { ...process.env }; + +async function importFresh(label: string) { + return import(`${pathToFileURL(modulePath).href}?case=${label}-${Date.now()}-${Math.random()}`); +} + +function restoreEnv() { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); +} + +test.afterEach(restoreEnv); + +// Deps are injected because CI and dev machines are not containers and macOS +// has no /proc/self/mountinfo at all. +const HOST_PROFILE_MOUNTINFO = [ + "31 28 254:1 /volumes/omniroute-data/_data /app/data rw,relatime - ext4 /dev/vda1 rw", + "44 28 254:1 /Users/me/.codex /host-home/.codex rw,relatime - ext4 /dev/vda1 rw", + "45 28 254:1 /Users/me/.claude /host-home/.claude rw,relatime - ext4 /dev/vda1 rw", +].join("\n"); + +const containerDeps = { + existsSync: (p: string) => p === "/.dockerenv", + readFileSync: (p: string, _enc: string) => { + if (p === "/proc/self/mountinfo") return HOST_PROFILE_MOUNTINFO; + if (p === "/proc/1/cgroup") return "12:cpuset:/docker/abc\n"; + throw new Error("ENOENT"); + }, + env: {} as NodeJS.ProcessEnv, +}; + +const hostDeps = { + existsSync: (_p: string) => false, + readFileSync: (_p: string, _enc: string) => "12:cpuset:/\n", + env: {} as NodeJS.ProcessEnv, +}; + +test("container + bind-mounted CLI_CONFIG_HOME outside home is honoured", async () => { + const cliRuntime = await importFresh("container-mounted"); + process.env.CLI_CONFIG_HOME = "/host-home"; + assert.equal(cliRuntime.getCliConfigHome(containerDeps), "/host-home"); +}); + +test("container + unmounted CLI_CONFIG_HOME outside home still falls back", async () => { + const cliRuntime = await importFresh("container-unmounted"); + process.env.CLI_CONFIG_HOME = "/opt/not-mounted"; + assert.equal(cliRuntime.getCliConfigHome(containerDeps), os.homedir()); +}); + +test("host machine keeps rejecting an outside-home CLI_CONFIG_HOME", async () => { + const cliRuntime = await importFresh("host-outside"); + process.env.CLI_CONFIG_HOME = "/tmp/outside-home"; + assert.equal(cliRuntime.getCliConfigHome(hostDeps), os.homedir()); + // ...and with the real (non-container) environment too. + assert.equal(cliRuntime.getCliConfigHome(), os.homedir()); +}); + +test("container exception does not bypass the other CLI_CONFIG_HOME guards", async () => { + const cliRuntime = await importFresh("container-guards"); + const home = os.homedir(); + + process.env.CLI_CONFIG_HOME = "relative/host-home"; + assert.equal(cliRuntime.getCliConfigHome(containerDeps), home, "relative paths rejected"); + + process.env.CLI_CONFIG_HOME = "/host-home/../etc"; + assert.equal(cliRuntime.getCliConfigHome(containerDeps), home, "traversal rejected"); + + process.env.CLI_CONFIG_HOME = "/host-home;rm -rf /"; + assert.equal(cliRuntime.getCliConfigHome(containerDeps), home, "metacharacters rejected"); +}); + +test("an in-home CLI_CONFIG_HOME is unaffected by container detection", async () => { + const cliRuntime = await importFresh("in-home"); + const safe = path.join(os.homedir(), "tmp-cli-config-home"); + process.env.CLI_CONFIG_HOME = safe; + assert.equal(cliRuntime.getCliConfigHome(containerDeps), safe); + assert.equal(cliRuntime.getCliConfigHome(hostDeps), safe); +}); + +// ── ensureCliConfigWriteAllowed ────────────────────────────────────────────── + +test("ensureCliConfigWriteAllowed without a path keeps flag-only behavior", async () => { + const cliRuntime = await importFresh("gate-flag-only"); + assert.equal(cliRuntime.ensureCliConfigWriteAllowed(), null); + + process.env.CLI_ALLOW_CONFIG_WRITES = "false"; + assert.match(cliRuntime.ensureCliConfigWriteAllowed(), /CLI_ALLOW_CONFIG_WRITES=false/); +}); + +test("ensureCliConfigWriteAllowed refuses an ephemeral container target", async () => { + const cliRuntime = await importFresh("gate-ephemeral"); + const message = cliRuntime.ensureCliConfigWriteAllowed("/home/node/.codex", { containerDeps }); + assert.ok(message, "expected a refusal"); + assert.match(message, /Refusing to write/); + assert.match(message, /\/home\/node\/\.codex/); + assert.match(message, /omniroute connect/); + assert.match(message, /CLI_CONFIG_HOME=\/host-home/); +}); + +test("ensureCliConfigWriteAllowed allows a bind-mounted container target", async () => { + const cliRuntime = await importFresh("gate-mounted"); + assert.equal( + cliRuntime.ensureCliConfigWriteAllowed("/host-home/.codex/config.toml", { containerDeps }), + null + ); +}); + +test("ensureCliConfigWriteAllowed allows any target on a host", async () => { + const cliRuntime = await importFresh("gate-host"); + assert.equal( + cliRuntime.ensureCliConfigWriteAllowed(path.join(os.homedir(), ".codex"), { + containerDeps: hostDeps, + }), + null + ); +}); + +test("OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE overrides the container refusal", async () => { + const cliRuntime = await importFresh("gate-override"); + process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = "true"; + assert.equal( + cliRuntime.ensureCliConfigWriteAllowed("/home/node/.codex", { containerDeps }), + null + ); +}); + +test("the write-disabled flag still wins over the container override", async () => { + const cliRuntime = await importFresh("gate-precedence"); + process.env.CLI_ALLOW_CONFIG_WRITES = "false"; + process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = "true"; + assert.match( + cliRuntime.ensureCliConfigWriteAllowed("/home/node/.codex", { containerDeps }), + /CLI_ALLOW_CONFIG_WRITES=false/ + ); +}); diff --git a/tests/unit/cli-container-write-guard.test.ts b/tests/unit/cli-container-write-guard.test.ts new file mode 100644 index 0000000000..0a2cfaccdb --- /dev/null +++ b/tests/unit/cli-container-write-guard.test.ts @@ -0,0 +1,125 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + assertHostConfigTarget, + guardHostConfigTarget, + CONTAINER_WRITE_EXIT_CODE, +} from "../../bin/cli/utils/config-home-guard.mjs"; + +// Container/mount state is injected — CI and dev machines are not containers, +// and macOS has no /proc/self/mountinfo. + +const MOUNTINFO = "44 28 254:1 /Users/me/.codex /host-home/.codex rw,relatime - ext4 /dev/vda1 rw"; + +const containerDeps = { + existsSync: (p: string) => p === "/.dockerenv", + readFileSync: (p: string, _enc: string) => { + if (p === "/proc/self/mountinfo") return MOUNTINFO; + throw new Error("ENOENT"); + }, + env: {} as NodeJS.ProcessEnv, +}; + +const hostDeps = { + existsSync: (_p: string) => false, + readFileSync: (_p: string, _enc: string) => "12:cpuset:/\n", + env: {} as NodeJS.ProcessEnv, +}; + +test("guard allows any write on a host machine", async () => { + const result = await assertHostConfigTarget("/Users/me/.codex", { + deps: hostDeps, + env: {}, + }); + assert.deepEqual(result, { ok: true }); +}); + +test("guard refuses an ephemeral container home and explains both escape routes", async () => { + const result = await assertHostConfigTarget("/home/node/.codex", { + toolLabel: "Codex", + hostCommand: "omniroute setup-codex", + deps: containerDeps, + env: {}, + }); + + assert.equal(result.ok, false); + assert.match(result.message!, /Refusing to write Codex config to \/home\/node\/\.codex/); + assert.match(result.message!, /omniroute setup-codex/); + assert.match(result.message!, /CLI_CONFIG_HOME=\/host-home/); + assert.match(result.message!, /--allow-container-write/); +}); + +test("guard allows a bind-mounted container target without warning", async () => { + const result = await assertHostConfigTarget("/host-home/.codex/glm.config.toml", { + deps: containerDeps, + env: {}, + }); + assert.deepEqual(result, { ok: true }); +}); + +test("--allow-container-write proceeds but warns about the ephemeral write", async () => { + const result = await assertHostConfigTarget("/home/node/.codex", { + allowContainerWrite: true, + deps: containerDeps, + env: {}, + }); + assert.equal(result.ok, true); + assert.match(result.warning!, /lost when the container is recreated/); +}); + +test("OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE env has the same effect as the flag", async () => { + for (const value of ["1", "true", "yes", "on", "TRUE"]) { + const result = await assertHostConfigTarget("/home/node/.codex", { + deps: containerDeps, + env: { OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE: value }, + }); + assert.equal(result.ok, true, `expected ${value} to allow the write`); + } +}); + +test("a falsy env override does not allow the write", async () => { + for (const value of ["0", "false", "off", ""]) { + const result = await assertHostConfigTarget("/home/node/.codex", { + deps: containerDeps, + env: { OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE: value }, + }); + assert.equal(result.ok, false, `expected ${value} to keep the refusal`); + } +}); + +test("--dry-run is not blocked but says a real run would be refused", async () => { + const result = await assertHostConfigTarget("/home/node/.codex", { + dryRun: true, + deps: containerDeps, + env: {}, + }); + assert.equal(result.ok, true); + assert.match(result.warning!, /\[dry-run\]/); + assert.match(result.warning!, /would be refused/); +}); + +test("guardHostConfigTarget returns exit code 2 on refusal and 0 otherwise", async () => { + const originalLog = console.log; + const lines: string[] = []; + console.log = (msg?: unknown) => { + lines.push(String(msg)); + }; + try { + const blocked = await guardHostConfigTarget("/home/node/.codex", { + deps: containerDeps, + env: {}, + }); + const allowed = await guardHostConfigTarget("/host-home/.codex", { + deps: containerDeps, + env: {}, + }); + assert.equal(blocked, CONTAINER_WRITE_EXIT_CODE); + assert.equal(allowed, 0); + assert.ok( + lines.some((l) => l.includes("Refusing to write")), + "refusal should be printed" + ); + } finally { + console.log = originalLog; + } +}); diff --git a/tests/unit/cli-setup-container-guard-coverage.test.ts b/tests/unit/cli-setup-container-guard-coverage.test.ts new file mode 100644 index 0000000000..536fa9e986 --- /dev/null +++ b/tests/unit/cli-setup-container-guard-coverage.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +/** + * Static scan, not behavior: a new `setup-*` command that writes a CLI-tool + * config must not silently no-op inside the OmniRoute container. Anything that + * writes has to route through the container guard first. + */ + +const COMMANDS_DIR = path.join(process.cwd(), "bin/cli/commands"); +const WRITE_CALLS = /\b(writeFileSync|writeAtomic|cpSync|copyFileSync|renameSync)\s*\(/; +const GUARD_CALL = /guardHostConfigTarget\s*\(/; + +/** + * Commands whose writes never target a host CLI's own config (they write to a + * user-chosen --out path, OmniRoute's own data dir, etc.). Keep this list tiny + * and justified — an entry here is an opt-out from the guard. + */ +const NOT_CLI_TOOL_CONFIG = new Set([]); + +function setupCommandFiles(): string[] { + return fs + .readdirSync(COMMANDS_DIR) + .filter((name) => name.startsWith("setup-") && name.endsWith(".mjs")) + .sort(); +} + +test("every setup-* command that writes files calls the container guard", () => { + const offenders: string[] = []; + + for (const name of setupCommandFiles()) { + if (NOT_CLI_TOOL_CONFIG.has(name)) continue; + const source = fs.readFileSync(path.join(COMMANDS_DIR, name), "utf8"); + if (!WRITE_CALLS.test(source)) continue; + if (!GUARD_CALL.test(source)) offenders.push(name); + } + + assert.deepEqual( + offenders, + [], + `these setup-* commands write config without guardHostConfigTarget(): ${offenders.join(", ")}` + ); +}); + +test("every guarded setup-* command exposes --allow-container-write", () => { + const offenders: string[] = []; + + for (const name of setupCommandFiles()) { + const source = fs.readFileSync(path.join(COMMANDS_DIR, name), "utf8"); + if (!GUARD_CALL.test(source)) continue; + if (!source.includes("--allow-container-write")) offenders.push(name); + } + + assert.deepEqual(offenders, [], `missing the --allow-container-write escape hatch: ${offenders}`); +}); + +test("the scan actually sees the commands it is meant to protect", () => { + const files = setupCommandFiles(); + assert.ok(files.length >= 12, `expected the setup-* family, found ${files.length}`); + for (const expected of ["setup-codex.mjs", "setup-claude.mjs", "setup-crush.mjs"]) { + assert.ok(files.includes(expected), `${expected} should be scanned`); + } +}); + +test("config set and configure are guarded too", () => { + for (const name of ["config.mjs", "configure.mjs"]) { + const source = fs.readFileSync(path.join(COMMANDS_DIR, name), "utf8"); + assert.match(source, GUARD_CALL, `${name} should call the container guard`); + assert.ok( + source.includes("--allow-container-write"), + `${name} should expose --allow-container-write` + ); + } +}); diff --git a/tests/unit/cli-tools-apply-container-422.test.ts b/tests/unit/cli-tools-apply-container-422.test.ts new file mode 100644 index 0000000000..b4fc5cf6bf --- /dev/null +++ b/tests/unit/cli-tools-apply-container-422.test.ts @@ -0,0 +1,164 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +/** + * POST /api/cli-tools/apply writes host CLI config files. Inside a container + * with no bind mount that write is thrown away with the container, so the route + * must refuse with a structured 422 instead of reporting success. + */ + +const routePath = path.join(process.cwd(), "src/app/api/cli-tools/apply/route.ts"); +const originalEnv = { ...process.env }; +const tempDirs = new Set(); + +async function importRoute(label: string) { + return import(`${pathToFileURL(routePath).href}?case=${label}-${Date.now()}-${Math.random()}`); +} + +function restoreEnv() { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); +} + +test.afterEach(restoreEnv); + +// The auth guard reads settings, which opens the SQLite singleton. Releasing it +// before the temp dirs go away keeps the node:test runner from hanging on an +// open handle (see AGENTS.md → "Database Handles in Tests"). +test.after(async () => { + try { + const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + resetDbInstance(); + } catch { + // the DB was never opened + } + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function applyRequest(body: Record) { + return new Request("http://localhost:20128/api/cli-tools/apply", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ toolId: "codex", apiKey: "sk-test", ...body }), + }); +} + +test("refuses with 422 and does not write when the target is container-ephemeral", async () => { + // OMNIROUTE_CONTAINER forces detection; the fake HOME has no bind mount, so + // the target classifies as ephemeral. + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "or-apply-ephemeral-")); + tempDirs.add(fakeHome); + process.env.OMNIROUTE_CONTAINER = "1"; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + delete process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE; + + const { POST } = await importRoute("ephemeral"); + const response = await POST(applyRequest({})); + + assert.equal(response.status, 422); + const body = await response.json(); + assert.equal(body.containerEphemeralTarget, true); + assert.equal(body.hostSetupCommand, "omniroute setup-codex"); + assert.match(body.error, /Refusing to write/); + assert.match(body.error, /omniroute connect/); + // Nothing may hit disk. + assert.equal(fs.existsSync(path.join(fakeHome, ".codex")), false); +}); + +test("the 422 body carries no stack trace", async () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "or-apply-stack-")); + tempDirs.add(fakeHome); + process.env.OMNIROUTE_CONTAINER = "1"; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + + const { POST } = await importRoute("nostack"); + const body = await (await POST(applyRequest({}))).json(); + + assert.ok(!body.error.includes("at /"), "error must not leak a stack trace"); + assert.ok(!body.error.includes(".ts:"), "error must not leak source locations"); +}); + +test("dry-run still previews the config inside a container", async () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "or-apply-dry-")); + tempDirs.add(fakeHome); + process.env.OMNIROUTE_CONTAINER = "1"; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + + const { POST } = await importRoute("dryrun"); + const response = await POST(applyRequest({ dryRun: true })); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.dryRun, true); +}); + +test("OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE lets the write through", async () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "or-apply-override-")); + tempDirs.add(fakeHome); + process.env.OMNIROUTE_CONTAINER = "1"; + process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = "true"; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + + const { POST } = await importRoute("override"); + const response = await POST(applyRequest({})); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.success, true); + assert.ok(fs.existsSync(body.configPath), `expected ${body.configPath} to be written`); +}); + +test("the dashboard's guide-settings writer refuses the same way", async () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "or-guide-ephemeral-")); + tempDirs.add(fakeHome); + process.env.OMNIROUTE_CONTAINER = "1"; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + delete process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE; + + const guideRoute = path.join( + process.cwd(), + "src/app/api/cli-tools/guide-settings/[toolId]/route.ts" + ); + const { POST } = await import(`${pathToFileURL(guideRoute).href}?case=guide-${Date.now()}`); + + const response = await POST( + new Request("http://localhost:20128/api/cli-tools/guide-settings/continue", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: "http://localhost:20128/v1", model: "glm/glm-5.2" }), + }), + { params: Promise.resolve({ toolId: "continue" }) } + ); + + assert.equal(response.status, 422); + const body = await response.json(); + assert.equal(body.containerEphemeralTarget, true); + assert.equal(body.hostSetupCommand, "omniroute setup-continue"); + assert.equal(fs.existsSync(path.join(fakeHome, ".continue")), false); +}); + +test("a host environment applies the config normally", async () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "or-apply-host-")); + tempDirs.add(fakeHome); + process.env.OMNIROUTE_CONTAINER = "0"; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + + const { POST } = await importRoute("host"); + const response = await POST(applyRequest({})); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.success, true); +}); diff --git a/tests/unit/container-env-detect.test.ts b/tests/unit/container-env-detect.test.ts new file mode 100644 index 0000000000..4e963fd514 --- /dev/null +++ b/tests/unit/container-env-detect.test.ts @@ -0,0 +1,235 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + describeContainerTarget, + hasBindMountAt, + isRunningInContainer, +} from "../../src/shared/utils/containerEnv.ts"; + +// Dependency injection everywhere — no module mocking, no real /proc reads. + +const throwingFs = { + existsSync: (_p: string) => { + throw new Error("ENOENT"); + }, + readFileSync: (_p: string, _enc: string): string => { + throw new Error("ENOENT"); + }, +}; + +const hostDeps = { + existsSync: (_p: string) => false, + readFileSync: (_p: string, _enc: string) => "12:cpuset:/\n", + env: {} as NodeJS.ProcessEnv, +}; + +// A realistic mountinfo from the compose `host` profile: /host-home itself is a +// plain directory created by Docker, only the per-tool dirs are bind mounts. +const HOST_PROFILE_MOUNTINFO = [ + "22 28 0:20 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw", + "24 28 0:22 / /sys ro,nosuid,nodev,noexec,relatime - sysfs sysfs ro", + "31 28 254:1 /var/lib/docker/volumes/omniroute-data/_data /app/data rw,relatime - ext4 /dev/vda1 rw", + "44 28 254:1 /Users/me/.codex /host-home/.codex rw,relatime - ext4 /dev/vda1 rw", + "45 28 254:1 /Users/me/.claude /host-home/.claude rw,relatime - ext4 /dev/vda1 rw", + "", +].join("\n"); + +// ── isRunningInContainer ───────────────────────────────────────────────────── + +test("isRunningInContainer detects /.dockerenv", () => { + assert.equal( + isRunningInContainer({ + ...throwingFs, + existsSync: (p: string) => p === "/.dockerenv", + env: {}, + }), + true + ); +}); + +test("isRunningInContainer detects Podman via /run/.containerenv", () => { + assert.equal( + isRunningInContainer({ + ...throwingFs, + existsSync: (p: string) => p === "/run/.containerenv", + env: {}, + }), + true + ); +}); + +test("isRunningInContainer detects Kubernetes via KUBERNETES_SERVICE_HOST", () => { + assert.equal( + isRunningInContainer({ + ...throwingFs, + existsSync: (_p: string) => false, + env: { KUBERNETES_SERVICE_HOST: "10.96.0.1" }, + }), + true + ); +}); + +for (const marker of ["docker", "containerd", "kubepods", "podman", "lxc"]) { + test(`isRunningInContainer detects '${marker}' in /proc/1/cgroup`, () => { + assert.equal( + isRunningInContainer({ + existsSync: (_p: string) => false, + readFileSync: (_p: string, _enc: string) => `12:cpuset:/${marker}/abc123\n`, + env: {}, + }), + true + ); + }); +} + +test("isRunningInContainer returns false on a plain host", () => { + assert.equal(isRunningInContainer(hostDeps), false); +}); + +test("isRunningInContainer returns false when every probe throws", () => { + assert.equal(isRunningInContainer({ ...throwingFs, env: {} }), false); +}); + +test("OMNIROUTE_CONTAINER=1 forces detection on even without container markers", () => { + assert.equal(isRunningInContainer({ ...hostDeps, env: { OMNIROUTE_CONTAINER: "1" } }), true); + assert.equal(isRunningInContainer({ ...hostDeps, env: { OMNIROUTE_CONTAINER: "true" } }), true); +}); + +test("OMNIROUTE_CONTAINER=0 forces detection off even inside a container", () => { + const inContainer = { + existsSync: (p: string) => p === "/.dockerenv", + readFileSync: (_p: string, _enc: string) => "12:cpuset:/docker/abc\n", + env: { OMNIROUTE_CONTAINER: "0" } as NodeJS.ProcessEnv, + }; + assert.equal(isRunningInContainer(inContainer), false); + assert.equal( + isRunningInContainer({ ...inContainer, env: { OMNIROUTE_CONTAINER: "false" } }), + false + ); +}); + +// ── hasBindMountAt ─────────────────────────────────────────────────────────── + +const mountDeps = (mountinfo: string) => ({ + existsSync: (_p: string) => true, + readFileSync: (p: string, _enc: string) => { + if (p === "/proc/self/mountinfo") return mountinfo; + throw new Error("ENOENT"); + }, + env: {} as NodeJS.ProcessEnv, +}); + +test("hasBindMountAt is true for a directory whose children are bind mounts", () => { + // The compose `host` profile case: CLI_CONFIG_HOME=/host-home is not itself a + // mount point, but ~/.codex and ~/.claude are mounted beneath it. + assert.equal(hasBindMountAt("/host-home", mountDeps(HOST_PROFILE_MOUNTINFO)), true); +}); + +test("hasBindMountAt is true for an exact mount point", () => { + assert.equal(hasBindMountAt("/host-home/.codex", mountDeps(HOST_PROFILE_MOUNTINFO)), true); + assert.equal(hasBindMountAt("/app/data", mountDeps(HOST_PROFILE_MOUNTINFO)), true); +}); + +test("hasBindMountAt is true for a path nested inside a mount point", () => { + assert.equal( + hasBindMountAt("/host-home/.codex/profiles", mountDeps(HOST_PROFILE_MOUNTINFO)), + true + ); +}); + +test("hasBindMountAt ignores trailing slashes", () => { + assert.equal(hasBindMountAt("/host-home/", mountDeps(HOST_PROFILE_MOUNTINFO)), true); +}); + +test("hasBindMountAt is false for an unmounted container path", () => { + assert.equal(hasBindMountAt("/home/node", mountDeps(HOST_PROFILE_MOUNTINFO)), false); + assert.equal(hasBindMountAt("/opt/whatever", mountDeps(HOST_PROFILE_MOUNTINFO)), false); +}); + +test("hasBindMountAt never treats / as a bind mount", () => { + assert.equal(hasBindMountAt("/", mountDeps(HOST_PROFILE_MOUNTINFO)), false); +}); + +test("hasBindMountAt decodes octal escapes in mount points", () => { + const mountinfo = "44 28 254:1 / /host-home/my\\040dir rw,relatime - ext4 /dev/vda1 rw\n"; + assert.equal(hasBindMountAt("/host-home/my dir", mountDeps(mountinfo)), true); +}); + +test("hasBindMountAt returns false when /proc/self/mountinfo is unreadable", () => { + assert.equal(hasBindMountAt("/host-home", { ...throwingFs, env: {} }), false); +}); + +test("hasBindMountAt tolerates malformed mountinfo lines", () => { + const mountinfo = [ + "garbage", + "1 2 3", + "", + "44 28 254:1 / /host-home rw - ext4 /dev/vda1 rw", + ].join("\n"); + assert.equal(hasBindMountAt("/host-home", mountDeps(mountinfo)), true); + assert.equal(hasBindMountAt("/nope", mountDeps(mountinfo)), false); +}); + +test("hasBindMountAt returns false for empty or relative paths", () => { + assert.equal(hasBindMountAt("", mountDeps(HOST_PROFILE_MOUNTINFO)), false); + assert.equal(hasBindMountAt("relative/path", mountDeps(HOST_PROFILE_MOUNTINFO)), false); +}); + +// ── describeContainerTarget ────────────────────────────────────────────────── + +test("describeContainerTarget flags an ephemeral container home", () => { + const deps = { + existsSync: (p: string) => p === "/.dockerenv", + readFileSync: (p: string, _enc: string) => { + if (p === "/proc/self/mountinfo") return HOST_PROFILE_MOUNTINFO; + throw new Error("ENOENT"); + }, + env: {} as NodeJS.ProcessEnv, + }; + assert.deepEqual(describeContainerTarget("/home/node/.codex", deps), { + inContainer: true, + bindMounted: false, + ephemeral: true, + }); +}); + +test("describeContainerTarget clears ephemeral for a bind-mounted target", () => { + const deps = { + existsSync: (p: string) => p === "/.dockerenv", + readFileSync: (p: string, _enc: string) => { + if (p === "/proc/self/mountinfo") return HOST_PROFILE_MOUNTINFO; + throw new Error("ENOENT"); + }, + env: {} as NodeJS.ProcessEnv, + }; + assert.deepEqual(describeContainerTarget("/host-home/.codex/foo.toml", deps), { + inContainer: true, + bindMounted: true, + ephemeral: false, + }); +}); + +test("describeContainerTarget is inert on a host", () => { + assert.deepEqual(describeContainerTarget("/Users/me/.codex", hostDeps), { + inContainer: false, + bindMounted: false, + ephemeral: false, + }); +}); + +test("describeContainerTarget does not probe mounts when not in a container", () => { + let mountReads = 0; + const deps = { + existsSync: (_p: string) => false, + readFileSync: (p: string, _enc: string) => { + if (p === "/proc/self/mountinfo") { + mountReads += 1; + return HOST_PROFILE_MOUNTINFO; + } + return "12:cpuset:/\n"; + }, + env: {} as NodeJS.ProcessEnv, + }; + describeContainerTarget("/Users/me/.codex", deps); + assert.equal(mountReads, 0); +}); diff --git a/tests/unit/db-settings-debug-mode-default-10312.test.ts b/tests/unit/db-settings-debug-mode-default-10312.test.ts new file mode 100644 index 0000000000..327b4ce74d --- /dev/null +++ b/tests/unit/db-settings-debug-mode-default-10312.test.ts @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-settings-debug-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const settings = await import("../../src/lib/db/settings.ts"); + +async function resetStorage() { + const globalDb = (globalThis as { __omnirouteDb?: { open: boolean; close(): void } }) + .__omnirouteDb; + try { + if (globalDb?.open) { + globalDb.close(); + } + } catch {} + delete (globalThis as { __omnirouteDb?: unknown }).__omnirouteDb; + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + core.getDbInstance(); +} + +await resetStorage(); + +test("#10312: empty store defaults debugMode to false (fresh install is not in debug)", async () => { + await resetStorage(); + const result = await settings.getSettings(); + assert.equal(result.debugMode, false); +}); + +test("#10312: persisted debugMode=true is preserved after the default flip", async () => { + await resetStorage(); + await settings.updateSettings({ debugMode: true }); + const result = await settings.getSettings(); + assert.equal(result.debugMode, true); +}); + +test("#10312: persisted debugMode=false stays false after the default flip", async () => { + await resetStorage(); + await settings.updateSettings({ debugMode: false }); + const result = await settings.getSettings(); + assert.equal(result.debugMode, false); +}); diff --git a/tests/unit/db-sqljs-atomic-persist.test.ts b/tests/unit/db-sqljs-atomic-persist.test.ts new file mode 100644 index 0000000000..bb61f2a5c3 --- /dev/null +++ b/tests/unit/db-sqljs-atomic-persist.test.ts @@ -0,0 +1,116 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Regression guard: sql.js has no incremental write path, so every save rewrites +// the whole database image. When that write went through +// `fs.writeFileSync(filePath, …)`, the destination was opened with `O_TRUNC` — +// for the whole duration of the write the on-disk database was 0 bytes and then +// partial. 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 (backup job, metrics exporter, an operator running `sqlite3`). Those +// readers got SQLITE_CORRUPT — "database disk image is malformed" — while +// `PRAGMA integrity_check` passed moments later, which made the failure look +// random and blamed the reader. The window scales with database size and recurs +// on every save. +// +// The fix writes to a temp file in the same directory and `rename()`s it over the +// destination. The property that distinguishes the two implementations, and the +// one asserted below, is inode identity: `rename` publishes a NEW inode, so a +// reader that already opened the file keeps reading a complete, coherent image, +// whereas `writeFileSync` mutates the inode the reader is holding. +// +// This is deliberately not a timing race — a sleep-based test would be flaky and +// would not prove anything about small databases that get written in one go. + +async function openAdapter(sqliteFile: string) { + const { createSqlJsAdapter } = await import("../../src/lib/db/adapters/sqljsAdapter"); + return createSqlJsAdapter(sqliteFile); +} + +test( + "sql.js persist() publishes the database atomically — a reader holding the file " + + "open never observes a truncated image (rename, not in-place O_TRUNC)", + async () => { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sqljs-atomic-")); + const sqliteFile = path.join(dataDir, "storage.sqlite"); + let adapter: Awaited> | null = null; + let readerFd: number | null = null; + try { + adapter = await openAdapter(sqliteFile); + adapter.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"); + adapter.exec("INSERT INTO t (v) VALUES ('first')"); + adapter.checkpoint(); + + assert.ok(fs.existsSync(sqliteFile), "first checkpoint should have written the database"); + const firstBytes = fs.readFileSync(sqliteFile); + const firstInode = fs.statSync(sqliteFile).ino; + + // A concurrent reader that opened the file before the next save. It keeps + // reading through THIS descriptor, exactly like another process mid-read. + readerFd = fs.openSync(sqliteFile, "r"); + + // Grow the image so the second save is unmistakably a different payload. + for (let i = 0; i < 200; i++) { + adapter.exec(`INSERT INTO t (v) VALUES ('row-${i}')`); + } + adapter.checkpoint(); + + // 1. The reader's descriptor still resolves to a COMPLETE image. Under + // writeFileSync it resolves to the same inode that was truncated and + // rewritten, so this read returns the new (or a torn) payload. + const viaReader = Buffer.alloc(firstBytes.length); + const read = fs.readSync(readerFd, viaReader, 0, firstBytes.length, 0); + assert.equal(read, firstBytes.length, "the pre-opened descriptor lost bytes mid-write"); + assert.deepEqual( + viaReader, + firstBytes, + "a reader holding the file open observed the image change underneath it — " + + "persist() replaced the file in place instead of renaming a new one over it" + ); + assert.equal( + viaReader.subarray(0, 15).toString("latin1"), + "SQLite format 3", + "the pre-opened descriptor no longer sees a valid SQLite header" + ); + + // 2. The published file is the NEW image, on a NEW inode — that is what + // makes the swap atomic for everyone who opens it afterwards. + const secondInode = fs.statSync(sqliteFile).ino; + assert.notEqual( + secondInode, + firstInode, + "persist() reused the same inode — the write was not published by rename()" + ); + assert.equal( + fs.readFileSync(sqliteFile).subarray(0, 15).toString("latin1"), + "SQLite format 3", + "the published file is not a valid SQLite image" + ); + + // 3. No temp file survives a successful save. + const leftovers = fs.readdirSync(dataDir).filter((n) => n.startsWith("storage.sqlite.tmp-")); + assert.deepEqual(leftovers, [], "persist() left a temporary file behind"); + } finally { + if (readerFd !== null) fs.closeSync(readerFd); + if (adapter?.open) adapter.close(); + fs.rmSync(dataDir, { recursive: true, force: true }); + } + } +); + +test("sql.js persist() is a no-op for :memory: databases (no temp file, no throw)", async () => { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sqljs-atomic-mem-")); + let adapter: Awaited> | null = null; + try { + adapter = await openAdapter(":memory:"); + adapter.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); + adapter.checkpoint(); + assert.deepEqual(fs.readdirSync(dataDir), [], "an in-memory database wrote to disk"); + } finally { + if (adapter?.open) adapter.close(); + fs.rmSync(dataDir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/docker-healthcheck-3151.test.ts b/tests/unit/docker-healthcheck-3151.test.ts index a8717d521d..945c516302 100644 --- a/tests/unit/docker-healthcheck-3151.test.ts +++ b/tests/unit/docker-healthcheck-3151.test.ts @@ -19,7 +19,7 @@ const { probeHealth } = (await import("../../scripts/dev/healthcheck.mjs")) as { function startServer(host: string): Promise<{ server: http.Server; port: number }> { return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { - if (req.url === "/api/monitoring/health") { + if (req.url === "/healthz") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ status: "ok" })); } else { diff --git a/tests/unit/docker-healthcheck-base-path.test.ts b/tests/unit/docker-healthcheck-base-path.test.ts index a49b548a55..1ac2d6ba85 100644 --- a/tests/unit/docker-healthcheck-base-path.test.ts +++ b/tests/unit/docker-healthcheck-base-path.test.ts @@ -3,11 +3,11 @@ import assert from "node:assert/strict"; import { resolveHealthPath } from "../../scripts/dev/healthcheck.mjs"; test("resolveHealthPath keeps the default route at the domain root", () => { - assert.equal(resolveHealthPath(""), "/api/monitoring/health"); - assert.equal(resolveHealthPath(undefined), "/api/monitoring/health"); + assert.equal(resolveHealthPath(""), "/healthz"); + assert.equal(resolveHealthPath(undefined), "/healthz"); }); test("resolveHealthPath prefixes the health route with OMNIROUTE_BASE_PATH", () => { - assert.equal(resolveHealthPath("/omniroute/"), "/omniroute/api/monitoring/health"); - assert.equal(resolveHealthPath("/omniroute"), "/omniroute/api/monitoring/health"); + assert.equal(resolveHealthPath("/omniroute/"), "/omniroute/healthz"); + assert.equal(resolveHealthPath("/omniroute"), "/omniroute/healthz"); }); diff --git a/tests/unit/early-sse-route-intent.test.ts b/tests/unit/early-sse-route-intent.test.ts index b8b48918fc..486b176831 100644 --- a/tests/unit/early-sse-route-intent.test.ts +++ b/tests/unit/early-sse-route-intent.test.ts @@ -22,7 +22,7 @@ const ROUTES = [ for (const route of ROUTES) { test(`${route.name} early-heartbeat gate uses the real stream resolver`, () => { - const escapedBodyExpression = route.bodyExpression.replace(/[?.]/g, "\\$&"); + const escapedBodyExpression = route.bodyExpression.replace(/[.?\\]/g, "\\$&"); assert.match( route.source, new RegExp( diff --git a/tests/unit/early-stream-keepalive.test.ts b/tests/unit/early-stream-keepalive.test.ts index 824b49cf44..c52514886f 100644 --- a/tests/unit/early-stream-keepalive.test.ts +++ b/tests/unit/early-stream-keepalive.test.ts @@ -17,6 +17,7 @@ import { OPENAI_CHAT_ERROR_FRAME, OPENAI_RESPONSES_ERROR_FRAME, } from "../../open-sse/utils/earlyStreamKeepalive.ts"; +import { assertResponsesOutputIndexLifecycle } from "../helpers/assertResponsesOutputIndexLifecycle.ts"; async function readAll(response: Response): Promise { const reader = response.body!.getReader(); @@ -201,10 +202,11 @@ test("RESPONSES_STARTUP_THINKING_FRAME is a self-closed synthetic reasoning item "response.reasoning_summary_part.added", "response.reasoning_summary_text.delta", "response.reasoning_summary_part.done", + "response.output_item.done", ] ); - const [added, partAdded, delta, partDone] = events; + const [added, partAdded, delta, partDone, itemDone] = events; assert.equal(added.data.item.type, "reasoning"); const itemId = added.data.item.id; assert.ok(itemId, "reasoning item must have an id"); @@ -214,6 +216,67 @@ test("RESPONSES_STARTUP_THINKING_FRAME is a self-closed synthetic reasoning item assert.equal(delta.data.delta, "✨"); assert.equal(partDone.data.item_id, itemId); assert.equal(partDone.data.part.text, "✨"); + + // Regression for the live 2026-08-13 incident (OpenClaw issue #123342): + // reasoning_summary_part.done only closes the nested summary part, not the + // output item itself. Without a matching response.output_item.done here, + // a client tracking open items by output_index still sees this synthetic + // item open at index 0 when the real upstream response later reuses that + // same index for its own response.output_item.added, and throws a + // collision ("Responses stream reused active output index 0"). + assert.equal(itemDone.data.output_index, added.data.output_index); + assert.equal(itemDone.data.item.id, itemId); + assert.equal(itemDone.data.item.type, "reasoning"); + + // General-purpose form of the same check: this frame alone must be a fully + // self-closed lifecycle (no output_item left open at the end). + assertResponsesOutputIndexLifecycle(events); +}); + +test("RESPONSES_STARTUP_THINKING_FRAME does not collide when the real upstream response reuses output_index 0", () => { + // Reproduces the actual live failure shape (OpenClaw issue #123342): the + // keepalive placeholder fires, then the real upstream response starts its + // own independent response.created lifecycle and reuses output_index 0 for + // its own real reasoning item. Concatenating the two and replaying them + // through the same output_index-lifecycle contract a real client enforces + // is what actually would have caught the missing output_item.done — the + // frame-shape-only test above could pass while this still failed. + const decoded = new TextDecoder().decode(RESPONSES_STARTUP_THINKING_FRAME); + const keepaliveEvents = decoded + .split("\n\n") + .filter(Boolean) + .map((frame) => { + const [eventLine, dataLine] = frame.split("\n"); + return { + event: eventLine.replace(/^event: /, ""), + data: JSON.parse(dataLine.replace(/^data: /, "")), + }; + }); + + const realResponseEvents = [ + { event: "response.created", data: { type: "response.created" } }, + { event: "response.in_progress", data: { type: "response.in_progress" } }, + { + event: "response.output_item.added", + data: { + type: "response.output_item.added", + output_index: 0, + item: { id: "rs_real", type: "reasoning", summary: [] }, + }, + }, + { + event: "response.output_item.done", + data: { + type: "response.output_item.done", + output_index: 0, + item: { id: "rs_real", type: "reasoning", summary: [] }, + }, + }, + ]; + + assert.doesNotThrow(() => + assertResponsesOutputIndexLifecycle([...keepaliveEvents, ...realResponseEvents]) + ); }); test("slow handler emits the Responses API startup frame before the real body", async () => { diff --git a/tests/unit/electron-main.test.ts b/tests/unit/electron-main.test.ts index 7ba0746f22..b8da073bdd 100644 --- a/tests/unit/electron-main.test.ts +++ b/tests/unit/electron-main.test.ts @@ -19,6 +19,7 @@ import { join } from "node:path"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); +const { waitForServer } = require("../../electron/lib/serverReadiness"); function raceDelays(firstMs, secondMs) { return new Promise((resolve) => { @@ -272,23 +273,12 @@ describe("Server Port Management", () => { describe("Server Readiness Logic", () => { it("waitForServer should timeout and return false", async () => { - // Simulate the polling logic with an always-failing fetch - async function waitForServer(url, timeoutMs = 100) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const res = await fetch(url); - if (res.ok || res.status < 500) return true; - } catch { - /* not ready */ - } - await new Promise((r) => setTimeout(r, 30)); - } - return false; - } - - // Should timeout immediately since nothing is running on that port - const result = await waitForServer("http://localhost:59999", 100); + const result = await waitForServer("http://localhost:59999/api/health/ping", 20, { + fetchFn: async () => ({ ok: false }), + pollIntervalMs: 1, + requestTimeoutMs: 5, + warnFn: () => {}, + }); assert.equal(result, false); }); @@ -302,18 +292,20 @@ describe("Server Readiness Logic", () => { serverUp = true; }, 60); - async function waitForServer(_url, timeoutMs) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (serverUp) return true; - await new Promise((r) => setTimeout(r, 15)); - } - return false; - } + const readinessOptions = { + fetchFn: async () => ({ ok: serverUp }), + pollIntervalMs: 5, + requestTimeoutMs: 5, + warnFn: () => {}, + }; try { // Initial probe with a short budget times out (server not up yet). - const initialReady = await waitForServer("http://localhost/api/monitoring/health", 20); + const initialReady = await waitForServer( + "http://localhost/api/health/ping", + 20, + readinessOptions + ); assert.equal(initialReady, false); let reloaded = false; @@ -325,7 +317,11 @@ describe("Server Readiness Logic", () => { }; // Background retry with a generous budget should succeed and reload the window. - const retryReady = await waitForServer("http://localhost/api/monitoring/health", 5000); + const retryReady = await waitForServer( + "http://localhost/api/health/ping", + 5000, + readinessOptions + ); if (retryReady && mainWindow && !mainWindow.isDestroyed()) { mainWindow.loadURL("http://localhost"); } diff --git a/tests/unit/electron-packaging.test.ts b/tests/unit/electron-packaging.test.ts index 3d06cdd9b2..1e231f9aca 100644 --- a/tests/unit/electron-packaging.test.ts +++ b/tests/unit/electron-packaging.test.ts @@ -1,28 +1,29 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { pruneElectronRuntimeDocs } from "../../scripts/build/electronRuntimeDocs.mjs"; const ROOT = join(import.meta.dirname, "..", ".."); -test("electron build copies standalone runtime dependencies into resources/app/node_modules", () => { +test("electron build copies the standalone runtime into resources/app exactly once", () => { const electronPackage = JSON.parse(readFileSync(join(ROOT, "electron", "package.json"), "utf8")); const extraResources = electronPackage.build?.extraResources; assert.ok(Array.isArray(extraResources), "electron build.extraResources must be an array"); - assert.deepEqual( - extraResources.find( - (resource) => - resource?.from === "../.build/electron-standalone/node_modules" && - resource?.to === "app/node_modules" - ), - { - from: "../.build/electron-standalone/node_modules", - to: "app/node_modules", - filter: ["**/*"], - } + const appResources = extraResources.filter( + (resource) => resource?.to === "app" || resource?.to?.startsWith("app/") ); + + assert.deepEqual(appResources, [ + { + from: "../.build/electron-standalone", + to: "app", + filter: ["**/*"], + }, + ]); }); test("electron standalone assembly normalizes Turbopack hashed external imports", () => { @@ -37,3 +38,69 @@ test("electron standalone assembly normalizes Turbopack hashed external imports" "Electron packages must strip Turbopack's hashed external package names before bundling" ); }); + +test("electron docs manifest prunes authoring payloads without removing runtime docs", () => { + const bundleRoot = mkdtempSync(join(tmpdir(), "omniroute-electron-docs-")); + const files = new Map([ + ["docs/openapi.yaml", "openapi: 3.1.0"], + ["docs/guides/CODEX-CLI-CONFIGURATION.md", "# Codex CLI"], + ["docs/i18n/ko/docs/guides/ELECTRON_GUIDE.md", "# Electron"], + ["docs/i18n/ko/CHANGELOG.md", "translated release history"], + ["docs/i18n/fr/CHANGELOG.md", "historique traduit"], + ["docs/research/desktop-notes.md", "authoring notes"], + ["docs/superpowers/plans/desktop-plan.md", "implementation plan"], + ]); + + try { + for (const [relativePath, content] of files) { + const absolutePath = join(bundleRoot, relativePath); + mkdirSync(join(absolutePath, ".."), { recursive: true }); + writeFileSync(absolutePath, content); + } + + const result = pruneElectronRuntimeDocs(bundleRoot); + + assert.deepEqual(result.removedPaths, [ + "docs/i18n/fr/CHANGELOG.md", + "docs/i18n/ko/CHANGELOG.md", + "docs/research", + "docs/superpowers", + ]); + assert.equal(result.removedFiles, 4); + assert.equal( + result.removedBytes, + Buffer.byteLength("translated release history") + + Buffer.byteLength("historique traduit") + + Buffer.byteLength("authoring notes") + + Buffer.byteLength("implementation plan") + ); + + assert.equal(existsSync(join(bundleRoot, "docs/openapi.yaml")), true); + assert.equal(existsSync(join(bundleRoot, "docs/guides/CODEX-CLI-CONFIGURATION.md")), true); + assert.equal(existsSync(join(bundleRoot, "docs/i18n/ko/docs/guides/ELECTRON_GUIDE.md")), true); + assert.equal(existsSync(join(bundleRoot, "docs/i18n/ko/CHANGELOG.md")), false); + assert.equal(existsSync(join(bundleRoot, "docs/research")), false); + assert.equal(existsSync(join(bundleRoot, "docs/superpowers")), false); + + assert.deepEqual(pruneElectronRuntimeDocs(bundleRoot), { + removedFiles: 0, + removedBytes: 0, + removedPaths: [], + }); + } finally { + rmSync(bundleRoot, { recursive: true, force: true }); + } +}); + +test("electron bundle preparation applies the runtime docs manifest to its staging tree", () => { + const prepareScript = readFileSync( + join(ROOT, "scripts", "build", "prepare-electron-standalone.mjs"), + "utf8" + ); + + assert.match( + prepareScript, + /pruneElectronRuntimeDocs\(ELECTRON_STANDALONE_DIR\)/, + "Electron staging must prune authoring docs before electron-builder copies the bundle" + ); +}); diff --git a/tests/unit/electron-release-efficiency.test.ts b/tests/unit/electron-release-efficiency.test.ts new file mode 100644 index 0000000000..6e19cfc244 --- /dev/null +++ b/tests/unit/electron-release-efficiency.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +const ROOT = join(import.meta.dirname, "..", ".."); +const workflow = readFileSync(join(ROOT, ".github", "workflows", "electron-release.yml"), "utf8"); + +test("Electron release relies on setup-node's npm cache instead of caching node_modules", () => { + assert.doesNotMatch(workflow, /path:\s*node_modules/); + assert.match(workflow, /uses:\s*actions\/setup-node@[^\n]+[\s\S]*?cache:\s*npm/); +}); + +test("Electron release installs both dependency trees deterministically", () => { + assert.match(workflow, /- name: Install dependencies\s+run: npm ci/); + assert.match( + workflow, + /- name: Install Electron dependencies\s+working-directory: electron\s+run: npm ci --no-audit --no-fund/ + ); + assert.doesNotMatch(workflow, /run:\s*npm install --no-audit --no-fund/); +}); + +test("Electron release retains packaged-app smoke coverage", () => { + assert.match(workflow, /- name: Smoke packaged Electron app\s+if: matrix\.platform != 'linux'/); + assert.match( + workflow, + /- name: Smoke packaged Electron app \(Linux\)\s+if: matrix\.platform == 'linux'/ + ); +}); diff --git a/tests/unit/electron-server-readiness.test.ts b/tests/unit/electron-server-readiness.test.ts new file mode 100644 index 0000000000..323291ed4d --- /dev/null +++ b/tests/unit/electron-server-readiness.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { describe, it } from "node:test"; + +const require = createRequire(import.meta.url); +const { buildReadinessUrl, waitForServer } = require("../../electron/lib/serverReadiness"); + +describe("Electron server readiness", () => { + it("builds the lightweight readiness URL from local and remote base URLs", () => { + assert.equal( + buildReadinessUrl("http://localhost:20128"), + "http://localhost:20128/api/health/ping" + ); + assert.equal( + buildReadinessUrl("https://omniroute.example.com/"), + "https://omniroute.example.com/api/health/ping" + ); + }); + + it("accepts only a successful HTTP response", async () => { + let attempts = 0; + const ready = await waitForServer("http://localhost/api/health/ping", 100, { + fetchFn: async () => ({ ok: ++attempts === 2 }), + pollIntervalMs: 1, + requestTimeoutMs: 20, + warnFn: () => {}, + }); + + assert.equal(ready, true); + assert.equal(attempts, 2); + }); + + it("returns false after repeated unsuccessful responses", async () => { + const ready = await waitForServer("http://localhost/api/health/ping", 20, { + fetchFn: async () => ({ ok: false }), + pollIntervalMs: 1, + requestTimeoutMs: 5, + warnFn: () => {}, + }); + + assert.equal(ready, false); + }); + + it("bounds a stalled request by both the attempt and overall deadlines", async () => { + const startedAt = Date.now(); + const ready = await waitForServer("http://localhost/api/health/ping", 35, { + fetchFn: () => new Promise(() => {}), + pollIntervalMs: 1, + requestTimeoutMs: 10, + warnFn: () => {}, + }); + const elapsedMs = Date.now() - startedAt; + + assert.equal(ready, false); + assert.ok(elapsedMs < 150, `stalled readiness probe took ${elapsedMs}ms`); + }); +}); diff --git a/tests/unit/executor-xai.test.ts b/tests/unit/executor-xai.test.ts index 5d4d05fa74..4668efd22e 100644 --- a/tests/unit/executor-xai.test.ts +++ b/tests/unit/executor-xai.test.ts @@ -6,6 +6,7 @@ import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/in import { xaiProvider } from "../../open-sse/config/providers/registry/xai/index.ts"; // Real xai catalog ids (open-sse/config/providers/registry/xai/index.ts): +// grok-4.6 — Responses-first flagship with vision + reasoning // grok-4.3 — plain, reasoning-capable // grok-build-0.1 — build/tool model, no reasoning mode // grok-4.20-multi-agent-0309 — neutral (not in either allow/deny list) @@ -28,6 +29,21 @@ test("XaiExecutor can target the separate xAI OAuth provider config", () => { assert.equal(executor.buildUrl("grok-4.5", false), "https://api.x.ai/v1/responses"); }); +test("Grok 4.6 advertises its official capabilities and uses native Responses", () => { + const model = xaiProvider.models.find((entry) => entry.id === "grok-4.6"); + assert.ok(model); + assert.equal(model.contextLength, 500000); + assert.equal(model.supportsVision, true); + assert.equal(model.supportsReasoning, true); + assert.equal(model.toolCalling, true); + assert.equal(model.supportsXHighEffort, true); + assert.deepEqual(model.supportedThinkingEfforts, ["low", "medium", "high", "xhigh"]); + assert.equal(model.targetFormat, "openai-responses"); + + const executor = new XaiExecutor(); + assert.equal(executor.buildUrl("grok-4.6", true), "https://api.x.ai/v1/responses"); +}); + test("strips a -{level} suffix from an allow-listed model and sets reasoning_effort", () => { const executor = new XaiExecutor(); diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 1bf86905d3..8458edf857 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -1,4 +1,4 @@ -import { describe, it, before, beforeEach, after } from "node:test"; +import { describe, it, beforeEach, after } from "node:test"; import assert from "node:assert/strict"; import os from "node:os"; import path from "node:path"; @@ -30,7 +30,7 @@ const { isControlPlaneProxyDirectFallbackEnabled, } = await import("../../src/shared/utils/featureFlags.ts"); -const EXPECTED_FEATURE_FLAG_COUNT = 47; +const EXPECTED_FEATURE_FLAG_COUNT = 48; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -161,6 +161,18 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.warningLevel, "danger"); }); + it("defines network rotation shared-egress guard as a network boolean flag enabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "NETWORK_ROTATION_SHARED_EGRESS_GUARD" + ); + assert.ok(def, "NETWORK_ROTATION_SHARED_EGRESS_GUARD should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "true"); + assert.strictEqual(def.requiresRestart, false); + assert.strictEqual(def.warningLevel, "info"); + }); + it("defines remote audio provider nodes as a network boolean flag disabled by default", () => { // Guards the egress default: with this on, /v1/audio/* may reach a provider node // hosted outside localhost. It must never become an implicit default (cf. #3963). diff --git a/tests/unit/freeaiapikey-endpoint-moved.test.ts b/tests/unit/freeaiapikey-endpoint-moved.test.ts new file mode 100644 index 0000000000..6176bcf46c --- /dev/null +++ b/tests/unit/freeaiapikey-endpoint-moved.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { freeaiapikeyProvider } from "../../open-sse/config/providers/registry/freeaiapikey/index.ts"; + +/** + * FreeAIAPIKey retired its apex-host API and moved it to a dedicated `api.` host. + * + * Live probe (2026-08-13), each paired with a control call so a network fault + * cannot be mistaken for an upstream verdict: + * + * GET https://freeaiapikey.com/v1/models → 410 + * GET https://freeaiapikey.com/v1/chat/completions → 410 + * GET https://api.freeaiapikey.com/v1/models → 200 + * GET https://api.freeaiapikey.com/v1/chat/completions → 405 (POST-only endpoint) + * GET https://api.openai.com/v1/models → 401 (control: reachable) + * GET https:///v1/models → 000 (control: unreachable) + * + * The 410 body names its own replacement, so the target host is upstream's own + * instruction rather than an inference: + * + * {"error":{"message":"This API endpoint has moved. Please update your base_url + * to https://api.freeaiapikey.com/v1 — the old endpoint on freeaiapikey.com no + * longer works.","type":"endpoint_moved","code":"endpoint_moved"}} + * + * Provider entry added in #2708. + */ +const LIVE_API_BASE = "https://api.freeaiapikey.com/v1"; + +/** + * Every model id returned by GET https://api.freeaiapikey.com/v1/models on 2026-08-13. + * The response carries only id/object/created/owned_by — upstream publishes no context + * window, so models catalogued from it declare no contextLength and inherit the entry's + * defaultContextLength rather than an invented number. + */ +const LIVE_MODEL_IDS = [ + "openai/gpt-4o", + "openai/gpt-5.4", + "openai/gpt-5.5", + "openai/gpt-5.6-sol", + "anthropic/claude-opus-4.6", + "anthropic/claude-opus-4.7", + "anthropic/claude-opus-4.8", + "anthropic/claude-sonnet-4.6", + "anthropic/claude-sonnet-5", + "anthropic/claude-opus-5", +]; + +test("freeaiapikey targets the live api. host (upstream 410 endpoint_moved)", () => { + assert.equal( + freeaiapikeyProvider.baseUrl, + `${LIVE_API_BASE}/chat/completions`, + "baseUrl must point at the host named in upstream's 410 endpoint_moved body" + ); + assert.equal( + freeaiapikeyProvider.modelsUrl, + `${LIVE_API_BASE}/models`, + "modelsUrl must point at the host named in upstream's 410 endpoint_moved body" + ); +}); + +test("freeaiapikey keeps no endpoint on the retired freeaiapikey.com apex host", () => { + for (const [field, url] of [ + ["baseUrl", freeaiapikeyProvider.baseUrl], + ["modelsUrl", freeaiapikeyProvider.modelsUrl], + ] as const) { + assert.ok(url, `${field} must be set`); + assert.doesNotMatch( + url, + /^https:\/\/freeaiapikey\.com\//, + `${field} still targets the apex host, which answers 410 endpoint_moved` + ); + } +}); + +test("freeaiapikey catalogs exactly the models upstream serves", () => { + const declared = freeaiapikeyProvider.models.map((model) => model.id); + assert.deepEqual( + [...declared].sort(), + [...LIVE_MODEL_IDS].sort(), + "registry catalog must match the ids returned by the live /v1/models" + ); +}); + +test("freeaiapikey declares no duplicate model ids", () => { + const declared = freeaiapikeyProvider.models.map((model) => model.id); + assert.equal(new Set(declared).size, declared.length, "model ids must be unique"); +}); + +test("freeaiapikey gives every catalogued model a display name", () => { + for (const model of freeaiapikeyProvider.models) { + assert.equal(typeof model.name, "string", `${model.id} must declare a name`); + assert.ok(model.name.length > 0, `${model.id} must declare a non-empty name`); + } +}); + +test("freeaiapikey keeps a provider-wide default for unpublished context windows", () => { + // Upstream reports no context windows, so the models added from its catalog carry + // no contextLength of their own; this default is what they fall back to. + assert.equal( + typeof freeaiapikeyProvider.defaultContextLength, + "number", + "entry must keep a defaultContextLength for models with no upstream-published window" + ); +}); diff --git a/tests/unit/grok-cli-responses-compat.test.ts b/tests/unit/grok-cli-responses-compat.test.ts index ad9888fe94..3af78305b7 100644 --- a/tests/unit/grok-cli-responses-compat.test.ts +++ b/tests/unit/grok-cli-responses-compat.test.ts @@ -22,6 +22,12 @@ test("grok-cli exposes the authenticated grok-build model catalog", () => { targetFormat, })), [ + { + id: "grok-4.6", + name: "Grok 4.6", + contextLength: 500000, + targetFormat: "openai-responses", + }, { id: "grok-4.5", name: "Grok 4.5", @@ -36,13 +42,15 @@ test("grok-cli exposes the authenticated grok-build model catalog", () => { }, ] ); + assert.equal(getModelTargetFormat("gc", "grok-4.6"), "openai-responses"); assert.equal(getModelTargetFormat("gc", "grok-4.5"), "openai-responses"); assert.equal(getModelTargetFormat("gc", "grok-composer-2.5-fast"), "openai-responses"); assert.equal(grok_cliProvider.modelsUrl, GROK_BUILD_MODELS_URL); }); -test("grok-cli routes both models to the Responses endpoint", () => { +test("grok-cli routes its catalog models to the Responses endpoint", () => { const executor = new GrokCliExecutor(); + assert.equal(executor.buildUrl("grok-4.6", true), "https://cli-chat-proxy.grok.com/v1/responses"); assert.equal(executor.buildUrl("grok-4.5", true), "https://cli-chat-proxy.grok.com/v1/responses"); assert.equal( executor.buildUrl("grok-composer-2.5-fast", false), diff --git a/tests/unit/guardrails/visionBridge-combo-reroute.test.ts b/tests/unit/guardrails/visionBridge-combo-reroute.test.ts new file mode 100644 index 0000000000..7757d028b6 --- /dev/null +++ b/tests/unit/guardrails/visionBridge-combo-reroute.test.ts @@ -0,0 +1,292 @@ +/** + * Vision Bridge × named-combo reroute tests. + * + * Regression: a named combo whose targets have ZERO vision-capable models was + * never reroute-eligible. The bridge only described images for it, and when + * the describe path could not run (unreachable bridge model, failed self-loop, + * missing credentials) the raw images stayed in the payload, the combo + * capability filter excluded every target, and the request died with + * capability_mismatch — "vision bridge does not affect combo models". + * + * Fix under test: `getComboVisionBridgeDecision` returns "no-vision" for a + * combo with model targets but no vision-capable target, and preCall treats + * that decision as reroute-eligible (mirroring non-combo text-only models), + * falling back to describe only when no usable reroute target exists. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-vb-combo-reroute-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { VisionBridgeGuardrail, getComboVisionBridgeDecision } = + await import("../../../src/lib/guardrails/visionBridge.ts"); +const { resetGuardrailsForTests } = await import("../../../src/lib/guardrails/registry.ts"); +const { getResolvedModelCapabilities } = await import("../../../src/lib/modelCapabilities.ts"); +const core = await import("../../../src/lib/db/core.ts"); +const combosDb = await import("../../../src/lib/db/combos.ts"); +const mappingsDb = await import("../../../src/lib/db/modelComboMappings.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function createCombo(name, models, overrides = {}) { + return combosDb.createCombo({ + name, + models, + strategy: "priority", + ...overrides, + }); +} + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +const VISION_MODEL = "openai/gpt-4o"; +const TEXT_MODEL_A = "google/gemma-2-27b"; +const TEXT_MODEL_B = "mistral/mistral-large-latest"; + +// Fail loudly if the static vision heuristic drifts: these fixtures drive +// every assertion in this file. +test("fixture models have the expected static vision capability", () => { + assert.equal(getResolvedModelCapabilities(VISION_MODEL).supportsVision, true); + assert.notEqual(getResolvedModelCapabilities(TEXT_MODEL_A).supportsVision, true); + assert.notEqual(getResolvedModelCapabilities(TEXT_MODEL_B).supportsVision, true); +}); + +const mockSettings = { + visionBridgeEnabled: true, + visionBridgeModel: VISION_MODEL, + visionBridgePrompt: "Describe this image concisely.", + visionBridgeTimeout: 30000, + visionBridgeMaxImages: 10, +}; + +let visionCallCount = 0; + +// Each describe-path test uses a UNIQUE prompt: the shared describe cache keys +// on (contentRef, prompt, model), so a reused prompt would serve a cached +// description and skip callVisionModel, breaking the assertion on call count. +function createGuardrail(depsOverrides = {}, prompt = "Describe this image concisely.") { + return new VisionBridgeGuardrail({ + deps: { + getSettings: async () => ({ ...mockSettings, visionBridgePrompt: prompt }), + callVisionModel: async () => { + visionCallCount++; + return "A red circle on a white background"; + }, + // null = fail-open (no credential DB in unit tests), matching the + // existing visionBridge.test.ts convention. + hasUsableCredentials: async () => null, + ...depsOverrides, + }, + }); +} + +const IMAGE_PAYLOAD = { + model: "text-only-combo", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Describe this image in one sentence." }, + { + type: "image_url", + image_url: { + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + }, + }, + ], + }, + ], +}; + +function hasImagePart(messages) { + return JSON.stringify(messages).includes("image_url"); +} + +// GuardrailResult types modifiedPayload as `unknown`; the existing +// visionBridge.test.ts casts it the same way. +type ModifiedBody = { model?: string; messages?: unknown[] }; +function asModifiedBody(result: { modifiedPayload?: unknown }): ModifiedBody { + return (result.modifiedPayload ?? {}) as ModifiedBody; +} + +// ── getComboVisionBridgeDecision ──────────────────────────────────────────── + +test("decision: combo with zero vision-capable targets returns 'no-vision'", async () => { + await createCombo("text-only-combo", [ + { provider: "google", model: TEXT_MODEL_A }, + { provider: "mistral", model: TEXT_MODEL_B }, + ]); + assert.equal(await getComboVisionBridgeDecision("text-only-combo"), "no-vision"); +}); + +test("decision: combo with all vision-capable targets returns 'skip'", async () => { + await createCombo("vision-combo", [ + { provider: "openai", model: VISION_MODEL }, + { provider: "anthropic", model: "anthropic/claude-sonnet-4-20250514" }, + ]); + assert.equal(await getComboVisionBridgeDecision("vision-combo"), "skip"); +}); + +test("decision: mixed combo (some vision, some not) returns 'process'", async () => { + await createCombo("mixed-combo", [ + { provider: "openai", model: VISION_MODEL }, + { provider: "google", model: TEXT_MODEL_A }, + ]); + assert.equal(await getComboVisionBridgeDecision("mixed-combo"), "process"); +}); + +test("decision: unknown model returns 'not-combo'", async () => { + assert.equal(await getComboVisionBridgeDecision("not-a-combo"), "not-combo"); +}); + +test("decision: model-combo mapping routes to the combo decision", async () => { + const combo = await createCombo("mapped-text-only", [ + { provider: "google", model: TEXT_MODEL_A }, + ]); + await mappingsDb.createModelComboMapping({ + pattern: "mapped-model-alias", + comboId: combo.id as string, + priority: 20, + description: "test alias", + }); + assert.equal(await getComboVisionBridgeDecision("mapped-model-alias"), "no-vision"); +}); + +// ── preCall: no-vision combo reroutes whole request ───────────────────────── + +test("preCall: zero-vision combo reroutes the whole request to the bridge model", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("text-only-combo", [ + { provider: "google", model: TEXT_MODEL_A }, + { provider: "mistral", model: TEXT_MODEL_B }, + ]); + visionCallCount = 0; + const guardrail = createGuardrail(); + const result = await guardrail.preCall(IMAGE_PAYLOAD, {}); + + assert.equal(result.block, false); + // Rerouted: model swapped to the vision bridge model, image bytes KEPT. + assert.equal(asModifiedBody(result).model, VISION_MODEL); + assert.equal(result.meta.rerouted, true); + assert.equal(result.meta.fromModel, "text-only-combo"); + assert.equal(hasImagePart(asModifiedBody(result).messages), true); + // Describe never ran — no extra vision call. + assert.equal(visionCallCount, 0); +}); + +test("preCall: zero-vision combo falls back to describe when reroute target is unusable", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("text-only-combo", [{ provider: "google", model: TEXT_MODEL_A }]); + visionCallCount = 0; + // Reroute target has no usable credentials → describe path must run. + const guardrail = createGuardrail( + { hasUsableCredentials: async () => false }, + "Describe the fallback image." + ); + const result = await guardrail.preCall(IMAGE_PAYLOAD, {}); + + assert.equal(result.block, false); + assert.equal(result.meta.rerouted, undefined); + // Images replaced with the described text; combo model kept. + assert.equal(asModifiedBody(result).model, "text-only-combo"); + assert.equal(hasImagePart(asModifiedBody(result).messages), false); + assert.equal(visionCallCount, 1); +}); + +test("preCall: no-vision combo, unusable reroute target AND describe failure -> stub text", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("text-only-combo", [{ provider: "google", model: TEXT_MODEL_A }]); + visionCallCount = 0; + // Double failure: the reroute target has no usable credentials AND the + // describe call fails for every image. The allNull stub fallback must fire + // for "no-vision" too — otherwise the raw images stay in the payload, the + // combo capability filter rejects every target, and the original + // capability_mismatch recurs. + const guardrail = createGuardrail( + { + hasUsableCredentials: async () => false, + callVisionModel: async () => { + visionCallCount++; + throw new Error("no vision-capable provider connected"); + }, + }, + "Describe the double-failure image." + ); + const result = await guardrail.preCall(IMAGE_PAYLOAD, {}); + + assert.equal(result.block, false); + assert.equal(result.meta.rerouted, undefined); + // Combo model kept; raw image replaced with the stub text. + assert.equal(asModifiedBody(result).model, "text-only-combo"); + assert.equal(hasImagePart(asModifiedBody(result).messages), false); + assert.match( + JSON.stringify(asModifiedBody(result).messages), + /\(unavailable — no vision-capable provider connected\)/ + ); + assert.equal(visionCallCount, 1); +}); + +test("preCall: zero-vision combo with no images is left untouched", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("text-only-combo", [{ provider: "google", model: TEXT_MODEL_A }]); + visionCallCount = 0; + const guardrail = createGuardrail(); + const result = await guardrail.preCall( + { + model: "text-only-combo", + messages: [{ role: "user", content: "no images here" }], + }, + {} + ); + assert.equal(result.block, false); + assert.equal(result.modifiedPayload, undefined); + assert.equal(visionCallCount, 0); +}); + +// ── preCall: unchanged semantics for other combo shapes ───────────────────── + +test("preCall: all-vision combo still skips the bridge entirely", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("vision-combo", [{ provider: "openai", model: VISION_MODEL }]); + visionCallCount = 0; + const guardrail = createGuardrail(); + const result = await guardrail.preCall({ ...IMAGE_PAYLOAD, model: "vision-combo" }, {}); + assert.equal(result.block, false); + assert.equal(result.modifiedPayload, undefined); + assert.equal(visionCallCount, 0); +}); + +test("preCall: mixed combo keeps the describe path (no reroute, model unchanged)", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("mixed-combo", [ + { provider: "openai", model: VISION_MODEL }, + { provider: "google", model: TEXT_MODEL_A }, + ]); + visionCallCount = 0; + const guardrail = createGuardrail({}, "Describe the mixed-combo image."); + const result = await guardrail.preCall({ ...IMAGE_PAYLOAD, model: "mixed-combo" }, {}); + + assert.equal(result.block, false); + // Mixed combo is NOT reroute-eligible: model stays, images described. + assert.equal(result.meta.rerouted, undefined); + assert.equal(asModifiedBody(result).model, "mixed-combo"); + assert.equal(hasImagePart(asModifiedBody(result).messages), false); + assert.equal(visionCallCount, 1); +}); diff --git a/tests/unit/mimocode-executor.test.ts b/tests/unit/mimocode-executor.test.ts index 3c01022c31..92e43ab13d 100644 --- a/tests/unit/mimocode-executor.test.ts +++ b/tests/unit/mimocode-executor.test.ts @@ -1,4 +1,4 @@ -import { describe, it } from "node:test"; +import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert"; import { MimocodeExecutor, @@ -254,8 +254,6 @@ describe("mimocode providerRegistry entry", () => { }); describe("mimocode per-account proxy", () => { - const exec = new MimocodeExecutor(); - it("AccountProxyConfig type has required fields", () => { const config: AccountProxyConfig = { fingerprint: "abc123", @@ -498,6 +496,7 @@ interface TestAccountState { expiresAt: number; cooldownUntil: number; consecutiveFails: number; + proxy?: unknown; } interface ExecutorAccountAccess { @@ -625,3 +624,250 @@ describe("mimocode 400 classification (#2101/#4976)", () => { } }); }); + +describe("mimocode network-error rotation (parity with OpencodeExecutor)", () => { + function makeJwt(): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 }) + ).toString("base64url"); + return `${header}.${payload}.sig`; + } + + function twoAccountExecutor(proxies: [unknown, unknown]): MimocodeExecutor { + const exec = new MimocodeExecutor(); + const access = accountAccess(exec); + access.accounts = [ + { + fingerprint: "acct-a", + jwt: "", + expiresAt: 0, + cooldownUntil: 0, + consecutiveFails: 0, + proxy: proxies[0], + }, + { + fingerprint: "acct-b", + jwt: "", + expiresAt: 0, + cooldownUntil: 0, + consecutiveFails: 0, + proxy: proxies[1], + }, + ] as TestAccountState[]; + access.nextAccountIdx = 0; + return exec; + } + + const A_PROXY = { type: "http", host: "127.0.0.1", port: 8080 }; + const B_PROXY = { type: "http", host: "127.0.0.1", port: 8081 }; + + it("rotates to the next account on a network throw when the failed account has a dedicated proxy", async () => { + const testExec = twoAccountExecutor([A_PROXY, B_PROXY]); + // Force both dispatch legs (bootstrap + chat) through the plain `fetch()` + // fallback instead of a real undici proxy dispatcher — this test exercises + // the rotation DECISION (account.proxy is configured → rotate), not actual + // proxy network I/O, which has its own dedicated dispatcher tests below. + (testExec as unknown as { getProxyDispatcher: () => undefined }).getProxyDispatcher = () => + undefined; + let chatCalls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: unknown) => { + const urlStr = String(url); + if (urlStr.includes("/api/free-ai/bootstrap")) { + return new Response(JSON.stringify({ jwt: makeJwt() }), { status: 200 }); + } + if (urlStr.includes("/api/free-ai/openai/chat")) { + chatCalls++; + if (chatCalls === 1) throw new Error("ECONNRESET"); + return new Response(JSON.stringify({ id: "ok", choices: [] }), { status: 200 }); + } + throw new Error(`unexpected fetch: ${urlStr}`); + }) as typeof fetch; + + const warnCalls: string[] = []; + try { + const result = await testExec.execute({ + model: "mimo-auto", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: { + providerSpecificData: { + fingerprints: ["acct-a", "acct-b"], + accountProxies: [ + { fingerprint: "acct-a", proxy: A_PROXY }, + { fingerprint: "acct-b", proxy: B_PROXY }, + ], + }, + }, + log: { + debug: () => {}, + info: () => {}, + warn: (_tag: unknown, msg: string) => warnCalls.push(msg), + error: () => {}, + }, + }); + + assert.strictEqual(chatCalls, 2, "should retry on the next account after the throw"); + assert.strictEqual(result.response.status, 200); + const acctA = accountAccess(testExec).accounts[0]; + assert.ok(acctA.cooldownUntil > Date.now(), "account with a dedicated proxy must cool down"); + assert.ok( + warnCalls.some((m) => /network error/i.test(m)), + `expected a "network error" warn log; got=${JSON.stringify(warnCalls)}` + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + describe("NETWORK_ROTATION_SHARED_EGRESS_GUARD", () => { + const FLAG = "NETWORK_ROTATION_SHARED_EGRESS_GUARD"; + let originalEnvValue: string | undefined; + + beforeEach(() => { + originalEnvValue = process.env[FLAG]; + }); + + afterEach(() => { + if (originalEnvValue === undefined) delete process.env[FLAG]; + else process.env[FLAG] = originalEnvValue; + }); + + it("rotates to a proxied account after a proxy-less account throws (mixed fleet, guard on by default)", async () => { + const testExec = twoAccountExecutor([null, B_PROXY]); + // Force both dispatch legs through the plain `fetch()` fallback instead + // of a real undici proxy dispatcher — this test exercises the rotation + // DECISION, not actual proxy network I/O. + (testExec as unknown as { getProxyDispatcher: () => undefined }).getProxyDispatcher = () => + undefined; + let chatCalls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: unknown) => { + const urlStr = String(url); + if (urlStr.includes("/api/free-ai/bootstrap")) { + return new Response(JSON.stringify({ jwt: makeJwt() }), { status: 200 }); + } + if (urlStr.includes("/api/free-ai/openai/chat")) { + chatCalls++; + if (chatCalls === 1) throw new Error("ETIMEDOUT"); + return new Response(JSON.stringify({ id: "ok", choices: [] }), { status: 200 }); + } + throw new Error(`unexpected fetch: ${urlStr}`); + }) as typeof fetch; + + try { + const result = await testExec.execute({ + model: "mimo-auto", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: { + providerSpecificData: { + fingerprints: ["acct-a", "acct-b"], + accountProxies: [{ fingerprint: "acct-b", proxy: B_PROXY }], + }, + }, + log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + }); + + assert.strictEqual(chatCalls, 2, "the proxied account (B) must still be tried"); + assert.strictEqual(result.response.status, 200); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("makes a single real network call when no account has a configured proxy (guard on by default)", async () => { + const testExec = twoAccountExecutor([null, null]); + let chatCalls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: unknown) => { + const urlStr = String(url); + if (urlStr.includes("/api/free-ai/bootstrap")) { + return new Response(JSON.stringify({ jwt: makeJwt() }), { status: 200 }); + } + if (urlStr.includes("/api/free-ai/openai/chat")) { + chatCalls++; + throw new Error("ETIMEDOUT"); + } + throw new Error(`unexpected fetch: ${urlStr}`); + }) as typeof fetch; + + try { + const result = await testExec.execute({ + model: "mimo-auto", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: {}, + log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + }); + + assert.strictEqual( + chatCalls, + 1, + "remaining proxy-less accounts must be skipped without a network call once the shared egress is known down" + ); + assert.strictEqual(result.response.status, 502); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("fails fast without rotating when the guard is disabled (legacy behavior)", async () => { + process.env[FLAG] = "false"; + const testExec = twoAccountExecutor([null, null]); + let chatCalls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: unknown) => { + const urlStr = String(url); + if (urlStr.includes("/api/free-ai/bootstrap")) { + return new Response(JSON.stringify({ jwt: makeJwt() }), { status: 200 }); + } + if (urlStr.includes("/api/free-ai/openai/chat")) { + chatCalls++; + throw new Error("ETIMEDOUT"); + } + throw new Error(`unexpected fetch: ${urlStr}`); + }) as typeof fetch; + + const warnCalls: string[] = []; + try { + const result = await testExec.execute({ + model: "mimo-auto", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: {}, + log: { + debug: () => {}, + info: () => {}, + warn: (_tag: unknown, msg: string) => warnCalls.push(msg), + error: () => {}, + }, + }); + + assert.strictEqual( + chatCalls, + 1, + "must NOT retry against another account sharing the same egress" + ); + const acctA = accountAccess(testExec).accounts[0]; + assert.strictEqual( + acctA.cooldownUntil, + 0, + "an account without a dedicated proxy must not be cooled down for a shared-egress failure" + ); + assert.strictEqual(result.response.status, 502); + assert.ok( + warnCalls.some((m) => /network error/i.test(m) && /not rotating/i.test(m)), + `expected a "network error … not rotating" warn log; got=${JSON.stringify(warnCalls)}` + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); +}); diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index 8a4725eae7..93f80274e5 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -171,8 +171,10 @@ test("Antigravity Gemini 3.5 upstream IDs share the Flash capability profile", ( } }); -test("Antigravity Gemini 3.6 tier IDs share the Flash capability profile", () => { +test("Antigravity Gemini 3.7 and 3.6 tier IDs share the Flash capability profile", () => { for (const modelId of [ + "gemini-3.7-flash-high", + "gemini-3.7-flash-medium", "gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", diff --git a/tests/unit/opencode-proxy-rotation-4954.test.ts b/tests/unit/opencode-proxy-rotation-4954.test.ts index 43a1458e1a..c2f1484d3d 100644 --- a/tests/unit/opencode-proxy-rotation-4954.test.ts +++ b/tests/unit/opencode-proxy-rotation-4954.test.ts @@ -2,6 +2,7 @@ import { describe, it, beforeEach, afterEach, before, after } from "node:test"; import assert from "node:assert"; import net from "node:net"; import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import type { ExecutorLog } from "../../open-sse/executors/base.ts"; import { resolveProxyForRequest, runWithAppliedProxyCapture, @@ -57,17 +58,21 @@ after(() => { serverB?.close(); }); -function credentialsWithProxies() { +/** Two fingerprints; `withProxies: false` omits accountProxies so both accounts + * share the default egress instead of each having a dedicated proxy. */ +function credentialsWithProxies(withProxies = true) { return { apiKey: null, accessToken: null, connectionId: "noauth", providerSpecificData: { fingerprints: [ACCOUNT_A, ACCOUNT_B], - accountProxies: [ - { fingerprint: ACCOUNT_A, proxy: { type: "http", host: "127.0.0.1", port: portA } }, - { fingerprint: ACCOUNT_B, proxy: { type: "http", host: "127.0.0.1", port: portB } }, - ], + ...(withProxies && { + accountProxies: [ + { fingerprint: ACCOUNT_A, proxy: { type: "http", host: "127.0.0.1", port: portA } }, + { fingerprint: ACCOUNT_B, proxy: { type: "http", host: "127.0.0.1", port: portB } }, + ], + }), }, } as any; } @@ -89,7 +94,8 @@ describe("OpencodeExecutor per-account proxy + rotation (#4954)", () => { function installFetchStub(statuses: number[]) { let call = 0; globalThis.fetch = (async (input: any) => { - const url = typeof input === "string" ? input : input?.url || String(input); + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const resolved = resolveProxyForRequest(url); let host: string | null = null; let port: string | null = null; @@ -169,6 +175,221 @@ describe("OpencodeExecutor per-account proxy + rotation (#4954)", () => { } }); + it("rotates to the next account on a network throw (not just 429)", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + let call = 0; + const originalFetchForThrow = globalThis.fetch; + globalThis.fetch = (async (input: Parameters[0]) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + observed.push({ + source: resolved.source, + host: resolved.proxyUrl ? new URL(resolved.proxyUrl).hostname : null, + port: resolved.proxyUrl ? new URL(resolved.proxyUrl).port : null, + }); + call++; + if (call === 1) { + throw new Error("ECONNRESET: connection reset by peer"); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + + try { + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsWithProxies(), + log, + }); + + assert.strictEqual( + (result as { response: { status: number } }).response.status, + 200, + "a throw on account A must not abort the request — account B must be tried" + ); + assert.ok(observed.length >= 2, "should have retried on a second account after the throw"); + assert.notStrictEqual( + observed[0].port, + observed[1].port, + "rotation must switch to a different account/proxy after a throw" + ); + } finally { + globalThis.fetch = originalFetchForThrow; + } + }); + + it("logs a network-error rotation and does not swallow it silently", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + let call = 0; + const originalFetchForThrow = globalThis.fetch; + globalThis.fetch = (async () => { + call++; + if (call === 1) throw new Error("ETIMEDOUT"); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + + const warnCalls: Array<{ tag: unknown; msg: string }> = []; + const spyLog: ExecutorLog = { + debug() {}, + info() {}, + warn: (tag, msg) => { + warnCalls.push({ tag, msg }); + }, + error() {}, + }; + + try { + await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsWithProxies(), + log: spyLog, + }); + + assert.ok( + warnCalls.some((c) => c.tag === "OPENCODE" && /network error/i.test(c.msg)), + `expected a warn-level "network error" log; got=${JSON.stringify(warnCalls)}` + ); + } finally { + globalThis.fetch = originalFetchForThrow; + } + }); + + describe("NETWORK_ROTATION_SHARED_EGRESS_GUARD", () => { + const FLAG = "NETWORK_ROTATION_SHARED_EGRESS_GUARD"; + let originalEnvValue: string | undefined; + + beforeEach(() => { + originalEnvValue = process.env[FLAG]; + }); + + afterEach(() => { + if (originalEnvValue === undefined) delete process.env[FLAG]; + else process.env[FLAG] = originalEnvValue; + }); + + it("rotates to a proxied account after a proxy-less account throws (mixed fleet, guard on by default)", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + let call = 0; + const originalFetchForThrow = globalThis.fetch; + globalThis.fetch = (async () => { + call++; + if (call === 1) throw new Error("ETIMEDOUT"); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + + try { + // ACCOUNT_A has no proxy, ACCOUNT_B does — credentialsWithProxies(true) + // only configures a proxy for accounts present in accountProxies; give + // A no entry so it stays proxy-less while B keeps its dedicated proxy. + const credentials = credentialsWithProxies(); + credentials.providerSpecificData.accountProxies = + credentials.providerSpecificData.accountProxies.filter( + (ap: { fingerprint: string }) => ap.fingerprint !== ACCOUNT_A + ); + + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials, + log, + }); + + assert.strictEqual( + (result as { response: { status: number } }).response.status, + 200, + "the proxied account (B) must still be tried and must succeed the request" + ); + assert.strictEqual(call, 2, "exactly one throw (A) then one success (B)"); + } finally { + globalThis.fetch = originalFetchForThrow; + } + }); + + it("makes a single real network call when no account has a configured proxy (guard on by default)", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + let call = 0; + const originalFetchForThrow = globalThis.fetch; + globalThis.fetch = (async () => { + call++; + throw new Error("ETIMEDOUT"); + }) as typeof globalThis.fetch; + + try { + await assert.rejects( + () => + exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsWithProxies(false), + log, + }), + /ETIMEDOUT/, + "must ultimately propagate once no candidate account remains" + ); + assert.strictEqual( + call, + 1, + "remaining proxy-less accounts must be skipped without a network call once the shared egress is known down" + ); + } finally { + globalThis.fetch = originalFetchForThrow; + } + }); + + it("propagates immediately on the first proxy-less throw when the guard is disabled", async () => { + process.env[FLAG] = "false"; + const exec = new OpencodeExecutor("opencode-zen"); + let call = 0; + const originalFetchForThrow = globalThis.fetch; + globalThis.fetch = (async () => { + call++; + throw new Error("ETIMEDOUT"); + }) as typeof globalThis.fetch; + + try { + await assert.rejects( + () => + exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsWithProxies(false), + log, + }), + /ETIMEDOUT/, + "a network throw on a proxy-less account must propagate, not be swallowed into rotation" + ); + assert.strictEqual( + call, + 1, + "must not retry against another account when the guard is disabled" + ); + } finally { + globalThis.fetch = originalFetchForThrow; + } + }); + }); + // #5217 (Gap 2): the per-request account/proxy selection log was log.debug, which // is hidden at the default APP_LOG_LEVEL=info — operators could not see which // account/proxy a request rotated to. It must be emitted at info level. diff --git a/tests/unit/per-connection-admission-9654.test.ts b/tests/unit/per-connection-admission-9654.test.ts index 8f26b88129..ccfd494c0a 100644 --- a/tests/unit/per-connection-admission-9654.test.ts +++ b/tests/unit/per-connection-admission-9654.test.ts @@ -1,4 +1,10 @@ -// #9654: Per-connection virtual admission lanes +// #9654/#10110: process-wide admission budget with per-key fair scheduling. +// +// The pre-#10110 per-connection lanes minted a controller per session, so the +// documented "in one process" heavy/bytes bound multiplied by up to 64 lanes +// (#10110). The fix removes the lanes entirely: every session resolves to ONE +// process-global controller, and per-request session identity is used only as +// a fairness scheduling key (round-robin dispatch, not capacity allocation). import test from "node:test"; import assert from "node:assert/strict"; @@ -10,7 +16,6 @@ const { admitChatStructure, perConnectionAdmissionController, ChatAdmissionController, - CHAT_MAX_HEAVY_IN_FLIGHT, } = admissionModule; function makeRequest(headers: Record, body = "{}"): Request { @@ -50,21 +55,26 @@ test("resolveSessionId does not leak raw API key in the session ID", () => { assert.ok(!sid.includes("secret")); }); -test("PerConnectionAdmissionController isolates capacity across sessions", () => { +// ── #10110: one process-global budget, shared by every session ──────────── +// The pre-fix lanes isolated capacity per session (up to 64× the process +// bound, and fake credentials could shard capacity). The fix returns the SAME +// controller for every session so the process-wide bound is real. + +test("PerConnectionAdmissionController shares ONE global budget across sessions", () => { const pc = new PerConnectionAdmissionController(1); const ctrlA = pc.getController("session-a"); const ctrlB = pc.getController("session-b"); - // Session A acquires the only slot + // A acquires the only process-wide slot. const leaseA = ctrlA.tryAcquireHeavy(); assert.ok(leaseA); - // Session A is now full - assert.equal(ctrlA.tryAcquireHeavy(), null); - // Session B still has capacity — isolation works - const leaseB = ctrlB.tryAcquireHeavy(); - assert.ok(leaseB); + // B shares the SAME budget: no per-session capacity is available while A + // holds the single process-wide slot (pre-#10110 this minted B its own slot). + assert.equal(ctrlB.tryAcquireHeavy(), null); leaseA.release(); - leaseB.release(); + // Slot freed → either session may now acquire. + assert.ok(ctrlB.tryAcquireHeavy()); + ctrlB.tryAcquireHeavy()?.release(); }); test("PerConnectionAdmissionController returns same controller for same session", () => { @@ -74,83 +84,93 @@ test("PerConnectionAdmissionController returns same controller for same session" assert.equal(a1, a2); }); -test("PerConnectionAdmissionController creates new controller for new session", () => { +test("PerConnectionAdmissionController returns the same controller for ALL sessions", () => { const pc = new PerConnectionAdmissionController(1); const a = pc.getController("session-a"); const b = pc.getController("session-b"); - assert.notEqual(a, b); + const c = pc.getController("session-c"); + // The shared process-global budget is a single instance (#10110): no per-key + // controllers exist to multiply the bound. + assert.equal(a, b); + assert.equal(b, c); }); -test("PerConnectionAdmissionController enforces maxSessions LRU eviction", () => { - const pc = new PerConnectionAdmissionController(1, { maxSessions: 2, sessionTtlMs: 60000 }); - const a = pc.getController("a"); +test("PerConnectionAdmissionController never evicts a live lease (no lane lifecycle)", () => { + // The pre-#10110 LRU/TTL lane eviction could drop a controller mid-lease and + // mint a fresh one on re-admit — silently doubling capacity (#10110). With a + // single shared controller there is nothing to evict and nothing to mint. + const pc = new PerConnectionAdmissionController(1, { maxSessions: 1, sessionTtlMs: 50 }); + const ctrlA1 = pc.getController("a"); + const leaseA = ctrlA1.tryAcquireHeavy(); + assert.ok(leaseA); + + // Touching another session (LRU pressure) and waiting past the TTL must not + // replace the controller holding the live lease. const b = pc.getController("b"); - assert.equal(pc.sessionCount, 2); - // Touch 'a' so 'b' is oldest + assert.equal(b, ctrlA1, "same global controller, no per-session lane to evict"); const aAgain = pc.getController("a"); - assert.equal(aAgain, a, "same a reference"); - // Creating 'c' should evict 'b' (oldest) - const c = pc.getController("c"); - assert.equal(pc.sessionCount, 2); - // 'a' survives, 'b' is evicted - const aAfter = pc.getController("a"); - assert.equal(aAfter, a, "a should still exist after c added"); - // 'b' gets a fresh controller (old one was evicted) - const newB = pc.getController("b"); - assert.notEqual(newB, b, "b should be evicted and recreated"); + assert.equal(aAgain, ctrlA1, "controller identity is stable across TTL"); + assert.equal(aAgain.tryAcquireHeavy(), null, "live lease keeps the only slot"); + leaseA.release(); }); -test("PerConnectionAdmissionController evicts idle sessions after TTL", async () => { - const pc = new PerConnectionAdmissionController(1, { - sessionTtlMs: 50, - maxSessions: 64, - }); - const ctrl = pc.getController("idle-session"); - assert.ok(ctrl); - assert.equal(pc.sessionCount, 1); - - // Wait past TTL + eviction tick - await new Promise((resolve) => setTimeout(resolve, 120)); - // Accessing again should trigger eviction → fresh controller - const fresh = pc.getController("idle-session"); - assert.notEqual(fresh, ctrl); -}); - -test("PerConnectionAdmissionController snapshot does not leak raw keys", () => { +test("PerConnectionAdmissionController snapshot reports process-wide aggregates", async () => { const pc = new PerConnectionAdmissionController(1); pc.getController("key_abc123"); pc.getController("anonymous"); + + const empty = pc.snapshot(); + assert.equal(empty.activeHeavy, 0); + assert.equal(empty.queuedBytes, 0); + assert.equal(empty.waiting, 0); + assert.deepEqual(empty.lanes, []); + + // Occupy the global slot and park a waiter from a second key. + const ctrlA = pc.getController("key_abc123"); + const leaseA = ctrlA.tryAcquireHeavy(); + assert.ok(leaseA); + const wB = pc.getController("anonymous").acquireHeavyWithin(500, undefined, 100, "anonymous"); + await new Promise((resolve) => setTimeout(resolve, 30)); + const snap = pc.snapshot(); - assert.equal(snap.length, 2); - for (const entry of snap) { - assert.ok(typeof entry.sessionId === "string"); - assert.ok(entry.sessionId.includes("key_abc123") || entry.sessionId === "anonymous"); - assert.ok(typeof entry.activeHeavy === "number"); - assert.ok(typeof entry.idleMs === "number"); + assert.equal(snap.activeHeavy, 1); + assert.equal(snap.queuedBytes, 100); + assert.equal(snap.waiting, 1); + assert.ok(Array.isArray(snap.lanes)); + for (const lane of snap.lanes) { + assert.ok(typeof lane.key === "string"); + assert.ok(lane.waiting === 0 || lane.waiting === 1); + // Keys are opaque hashed scheduler keys — raw credentials never appear. + assert.ok(!lane.key.includes("secret")); } + + const waiterLease = await wB; + waiterLease?.release(); + leaseA.release(); }); test("admitChatRequest uses per-connection controller by default", async () => { - const result = await admitChatRequest( - makeRequest({ authorization: "Bearer sk-test-key" }), - { largeBodyBytes: 32, hardMaxBytes: 1024 } - ); + const result = await admitChatRequest(makeRequest({ authorization: "Bearer sk-test-key" }), { + largeBodyBytes: 32, + hardMaxBytes: 1024, + }); assert.equal(result.admit, true); if (result.admit) result.lease?.release(); }); test("admitChatRequest with explicit controller overrides per-connection lookup", async () => { const explicitController = new ChatAdmissionController(1); - const result = await admitChatRequest( - makeRequest({ authorization: "Bearer sk-test-key" }), - { controller: explicitController, largeBodyBytes: 32, hardMaxBytes: 1024 } - ); + const result = await admitChatRequest(makeRequest({ authorization: "Bearer sk-test-key" }), { + controller: explicitController, + largeBodyBytes: 32, + hardMaxBytes: 1024, + }); assert.equal(result.admit, true); if (result.admit) result.lease?.release(); }); test("admitChatStructure routes structural rejection to per-connection controller", async () => { - // occupy sess-a's per-connection controller via the module-level instance + // occupy sess-a's controller — which is the shared process-global budget const controller = perConnectionAdmissionController.getController("sess-a"); const occupied = controller.tryAcquireHeavy(); assert.ok(occupied); @@ -168,7 +188,7 @@ test("admitChatStructure routes structural rejection to per-connection controlle heavyTokens: 10_000, } ); - // Session A is busy → 503 + // The process-wide slot is busy → 503 assert.equal(result.admit, false); if (result.admit) return; assert.equal(result.response.status, 503); @@ -176,13 +196,14 @@ test("admitChatStructure routes structural rejection to per-connection controlle occupied.release(); }); -test("admitChatStructure with different sessionId gets independent capacity", async () => { - // occupy sess-a's per-connection controller +test("admitChatStructure with different sessionId shares the global budget", async () => { + // occupy the shared process-global budget via sess-a const ctrlA = perConnectionAdmissionController.getController("sess-a"); const occupied = ctrlA.tryAcquireHeavy(); assert.ok(occupied); - // Session B should get its own controller → admitted + // Session B must NOT get independent capacity (pre-#10110 it did — that was + // the defect): it shares the one process-wide slot and must be rejected. const result = await admitChatStructure( { messages: Array.from({ length: 500 }, () => ({ role: "user", content: "x" })), @@ -196,10 +217,7 @@ test("admitChatStructure with different sessionId gets independent capacity", as heavyTokens: 32_000, } ); - assert.equal(result.admit, true); - if (result.admit) { - assert.notEqual(result.lease, null); - result.lease?.release(); - } + assert.equal(result.admit, false); + assert.equal(result.response.status, 503); occupied.release(); }); diff --git a/tests/unit/provider-connections-fetch-url-2998.test.ts b/tests/unit/provider-connections-fetch-url-2998.test.ts index 3e20261216..c8c99a8c46 100644 --- a/tests/unit/provider-connections-fetch-url-2998.test.ts +++ b/tests/unit/provider-connections-fetch-url-2998.test.ts @@ -11,3 +11,7 @@ test("provider detail keeps alias-backed pages on the unfiltered request", () => assert.equal(getProviderConnectionsRequestUrl("alibaba"), "/api/providers"); assert.equal(getProviderConnectionsRequestUrl("kimi-coding"), "/api/providers"); }); + +test("unified xAI detail fetches all auth variants through the unfiltered request", () => { + assert.equal(getProviderConnectionsRequestUrl("xai"), "/api/providers"); +}); diff --git a/tests/unit/provider-health-matrix.test.ts b/tests/unit/provider-health-matrix.test.ts index 7ed151cd28..7e4edd12ab 100644 --- a/tests/unit/provider-health-matrix.test.ts +++ b/tests/unit/provider-health-matrix.test.ts @@ -22,6 +22,8 @@ const route = await import("../../src/app/api/providers/health-matrix/route.ts") const accountFallback = await import("@omniroute/open-sse/services/accountFallback"); const PROVIDER = "matrix-test-provider"; +const ALIAS_PROVIDER = "nous"; +const CANONICAL_ALIAS_PROVIDER = "nous-research"; async function resetStorage() { core.resetDbInstance(); @@ -33,6 +35,13 @@ async function resetStorage() { } } accountFallback.clearProviderFailure(PROVIDER); + accountFallback.clearProviderFailure(ALIAS_PROVIDER); + accountFallback.clearProviderFailure(CANONICAL_ALIAS_PROVIDER); + for (const lockout of accountFallback.getAllModelLockouts()) { + if (lockout.provider === ALIAS_PROVIDER || lockout.provider === CANONICAL_ALIAS_PROVIDER) { + accountFallback.clearModelLock(lockout.provider, lockout.connectionId, lockout.model); + } + } } async function enableManagementAuth() { @@ -139,6 +148,60 @@ test("provider health matrix combines connections, synced models, logs and locko assert.equal(locked.lockoutReason, "quota_exhausted"); }); +test("provider health matrix collapses alias-keyed signals into one canonical provider", async () => { + const connection = (await providersDb.createProviderConnection({ + id: "matrix-nous-connection", + provider: CANONICAL_ALIAS_PROVIDER, + authType: "apikey", + name: "nous-key", + apiKey: "test-key", + isActive: true, + })) as Record; + const connectionId = String(connection.id); + + accountFallback.lockModel( + ALIAS_PROVIDER, + connectionId, + "nous-locked-model", + "quota_exhausted", + 60_000, + {} + ); + accountFallback.recordProviderFailure(ALIAS_PROVIDER, undefined, undefined, { + failureThreshold: 1, + resetTimeoutMs: 60_000, + }); + + const report = await matrix.buildProviderHealthMatrix({ includeHealthy: true, range: "24h" }); + const canonicalRows = report.providers.filter( + (provider) => provider.provider === CANONICAL_ALIAS_PROVIDER + ); + + assert.equal(canonicalRows.length, 1, "the canonical provider must have exactly one health row"); + assert.equal( + report.providers.some((provider) => provider.provider === ALIAS_PROVIDER), + false, + "the alias must not create a duplicate provider row" + ); + + const provider = canonicalRows[0]; + assert.equal(provider.connections.total, 1); + assert.equal(provider.circuitBreaker?.state, "OPEN"); + assert.equal(provider.modelLockoutCount, 1); + assert.equal(provider.accounts[0]?.models[0]?.model, "nous-locked-model"); + assert.equal(provider.accounts[0]?.models[0]?.isLockedOut, true); + + const filteredByAlias = await matrix.buildProviderHealthMatrix({ + provider: ALIAS_PROVIDER, + includeHealthy: true, + range: "24h", + }); + assert.equal(filteredByAlias.providers.length, 1); + assert.equal(filteredByAlias.providers[0]?.provider, CANONICAL_ALIAS_PROVIDER); + assert.equal(filteredByAlias.providers[0]?.connections.total, 1); + assert.equal(filteredByAlias.providers[0]?.circuitBreaker?.state, "OPEN"); +}); + test("provider health matrix treats recovered models as degraded instead of error", async () => { const connection = (await providersDb.createProviderConnection({ id: "matrix-recovered-connection", diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index d46cecf631..f959807e8f 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -954,6 +954,9 @@ test("provider models route retries Antigravity discovery endpoints before retur models: [ { id: "gemini-3.1-pro-high", displayName: "Gemini 3.1 Pro (High)" }, { id: "gemini-pro-agent", displayName: "Gemini 3.1 Pro (High)" }, + { id: "gemini-3.7-flash-high", displayName: "Gemini 3.7 Flash High" }, + { id: "gemini-3.7-flash-medium", displayName: "Gemini 3.7 Flash Medium" }, + { id: "gemini-3.8-flash-high", displayName: "Gemini 3.8 Flash High" }, { id: "gemini-3.6-flash-high", displayName: "upstream-3.6-high" }, { id: "gemini-3.6-flash-medium", displayName: "upstream-3.6-medium" }, { id: "gemini-3.6-flash-low", displayName: "upstream-3.6-low" }, @@ -987,6 +990,9 @@ test("provider models route retries Antigravity discovery endpoints before retur // #9106: both alias ids are user-callable now, so the upstream echo survives the filter. { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro (High)" }, { id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" }, + { id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" }, + { id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" }, + { id: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash High" }, { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" }, { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" }, { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" }, diff --git a/tests/unit/provider-test-statuscode-propagation.test.ts b/tests/unit/provider-test-statuscode-propagation.test.ts new file mode 100644 index 0000000000..74831a5589 --- /dev/null +++ b/tests/unit/provider-test-statuscode-propagation.test.ts @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildApiKeyConnectionTestResult } = + await import("../../src/app/api/providers/[id]/test/apiKeyTestResult.ts"); + +const FAILURE_DIAGNOSIS = { + type: "synthetic_failure", + source: "test", + message: "Synthetic validator failure", + code: "synthetic", +}; + +test("API-key connection tests preserve validator failure status codes", () => { + for (const statusCode of [401, 403, 429, 503]) { + const result = buildApiKeyConnectionTestResult( + { + valid: false, + warning: null, + statusCode, + }, + `Synthetic validator failure ${statusCode}`, + FAILURE_DIAGNOSIS + ); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, statusCode); + assert.deepEqual(result.diagnosis, FAILURE_DIAGNOSIS); + } +}); + +test("status-less semantic failures remain status-less", () => { + const result = buildApiKeyConnectionTestResult( + { + valid: false, + warning: null, + }, + "Synthetic semantic failure", + FAILURE_DIAGNOSIS + ); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, null); + assert.deepEqual(result.diagnosis, FAILURE_DIAGNOSIS); +}); + +test("successful validation does not synthesize an HTTP status", () => { + const diagnosis = { + type: "ok", + source: "upstream", + message: null, + code: null, + }; + + const result = buildApiKeyConnectionTestResult( + { + valid: true, + warning: null, + statusCode: 200, + }, + null, + diagnosis + ); + + assert.equal(result.valid, true); + assert.equal(result.statusCode, null); + assert.deepEqual(result.diagnosis, diagnosis); +}); diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index 03811be80f..9e2081539a 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -1102,3 +1102,47 @@ test("connectionMatchesProviderCard counts a dual-auth provider's PAT (apikey) c assert.equal(connectionMatchesProviderCard(null, "qoder", "oauth"), false); assert.equal(connectionMatchesProviderCard(undefined, "qoder", "oauth"), false); }); + +test("unified xAI OAuth card includes canonical and legacy connection provider IDs", () => { + const { + buildStaticProviderEntries, + connectionBelongsToProviderPage, + connectionMatchesProviderCard, + resolveProviderOAuthBackendId, + } = providerPageUtils; + const connections = [ + { provider: "xai", authType: "apikey" }, + { provider: "xai-oauth", authType: "oauth" }, + { provider: "xao", authType: "oauth" }, + ]; + + assert.deepEqual( + connections + .filter((connection) => connectionBelongsToProviderPage(connection.provider, "xai")) + .map((connection) => connection.provider), + ["xai", "xai-oauth", "xao"] + ); + assert.deepEqual( + connections + .filter((connection) => connectionMatchesProviderCard(connection, "xai", "oauth")) + .map((connection) => connection.provider), + ["xai", "xai-oauth", "xao"] + ); + assert.equal(resolveProviderOAuthBackendId("xai", providers.APIKEY_PROVIDERS.xai), "xai-oauth"); + assert.equal( + resolveProviderOAuthBackendId("openai", providers.APIKEY_PROVIDERS.openai), + "openai" + ); + assert.equal(providers.OAUTH_PROVIDERS["xai-oauth"].hiddenFromDashboard, true); + assert.equal(providers.supportsDualAuthProvider("xai"), true); + + const emptyStats = () => ({ total: 0 }); + assert.ok( + buildStaticProviderEntries("apikey", emptyStats).some((entry) => entry.providerId === "xai") + ); + assert.ok( + !buildStaticProviderEntries("oauth", emptyStats).some( + (entry) => entry.providerId === "xai-oauth" + ) + ); +}); diff --git a/tests/unit/reasoning-probe-truncated-response-10281.test.ts b/tests/unit/reasoning-probe-truncated-response-10281.test.ts new file mode 100644 index 0000000000..b69a452def --- /dev/null +++ b/tests/unit/reasoning-probe-truncated-response-10281.test.ts @@ -0,0 +1,171 @@ +/** + * #10281 — tiny-budget reasoning probes (e.g. Claude Code's `/model` capability + * check sends `max_tokens: 1`) must be answered with a valid truncated 200 when + * the upstream answers the reasoning-only outcome with a 5xx ("empty response + * content") instead of a truncated 200 — rather than relaying the upstream + * failure, which also poisons connection cooldown/health bookkeeping. + * + * Covers the pure helpers in open-sse/services/reasoningTokenBuffer.ts: + * - isTinyBudgetReasoningProbe — probe detection + * - isEmptyContentUpstreamFailure — empty-content 5xx detection + * - buildReasoningProbeTruncatedResponse — synthetic truncated 200 + * plus the invariant that the synthetic body is NOT flagged as empty content by + * errorClassifier.isEmptyContentResponse (finish_reason "length" is legitimate). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-reasoning-probe-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { saveModelsDevCapabilities, clearModelsDevCapabilities } = + await import("../../src/lib/modelsDevSync.ts"); +const { + REASONING_BUFFER_MIN_TRIGGER, + buildReasoningProbeTruncatedResponse, + isEmptyContentUpstreamFailure, + isTinyBudgetReasoningProbe, +} = await import("../../open-sse/services/reasoningTokenBuffer.ts"); +const { isEmptyContentResponse } = await import("../../open-sse/services/errorClassifier.ts"); + +function capabilityEntry(limitContext: unknown, overrides: Record = {}) { + return { + tool_call: true, + reasoning: false, + attachment: false, + structured_output: true, + temperature: true, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: false, + limit_context: limitContext, + limit_input: limitContext, + limit_output: 4096, + interleaved_field: null, + ...overrides, + }; +} + +test.before(() => { + saveModelsDevCapabilities({ + zhipu: { + // A thinking-capable model: probe detection + buffer logic both engage. + "glm-5.2": capabilityEntry(200000, { reasoning: true, limit_output: 65536 }), + // A non-reasoning sibling: probes are not special-cased. + "glm-5.2-flash": capabilityEntry(200000, { reasoning: false, limit_output: 4096 }), + }, + }); +}); + +test.after(() => { + clearModelsDevCapabilities(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#10281 isTinyBudgetReasoningProbe detects tiny explicit budgets on reasoning models", () => { + const thinking = "zhipu/glm-5.2"; + // Claude Code's `/model` probe (max_tokens: 1) is a tiny-budget reasoning probe. + assert.equal( + isTinyBudgetReasoningProbe({ model: thinking, body: { max_tokens: 1 } }), + true, + "max_tokens=1 on a reasoning model is a probe" + ); + // Just below the trigger threshold is still a probe. + assert.equal( + isTinyBudgetReasoningProbe({ + model: thinking, + body: { max_tokens: REASONING_BUFFER_MIN_TRIGGER - 1 }, + }), + true, + "budgets below REASONING_BUFFER_MIN_TRIGGER are probes" + ); + // At/above the threshold it is a genuine budget, not a probe. + assert.equal( + isTinyBudgetReasoningProbe({ + model: thinking, + body: { max_tokens: REASONING_BUFFER_MIN_TRIGGER }, + }), + false, + "budgets at REASONING_BUFFER_MIN_TRIGGER are not probes" + ); + assert.equal( + isTinyBudgetReasoningProbe({ model: thinking, body: { max_tokens: 512 } }), + false, + "genuine budgets are not probes" + ); + // OpenAI Responses format field is honoured. + assert.equal( + isTinyBudgetReasoningProbe({ model: thinking, body: { max_completion_tokens: 1 } }), + true, + "max_completion_tokens=1 is a probe" + ); + // Missing / non-positive budgets are not probes. + assert.equal( + isTinyBudgetReasoningProbe({ model: thinking, body: {} }), + false, + "no budget is not a probe" + ); + assert.equal( + isTinyBudgetReasoningProbe({ model: thinking, body: { max_tokens: 0 } }), + false, + "non-positive budget is not a probe" + ); + // Non-reasoning models never probe-special-case. + assert.equal( + isTinyBudgetReasoningProbe({ model: "zhipu/glm-5.2-flash", body: { max_tokens: 1 } }), + false, + "non-reasoning models are not probes" + ); +}); + +test("#10281 isEmptyContentUpstreamFailure matches empty-content 5xx markers", () => { + assert.equal(isEmptyContentUpstreamFailure(500, "empty response content"), true); + assert.equal(isEmptyContentUpstreamFailure(500, "No content was produced"), true); + assert.equal(isEmptyContentUpstreamFailure(502, "empty response content"), true); + assert.equal( + isEmptyContentUpstreamFailure(500, "empty response body"), + false, + "generic empty-body 5xx is not a reasoning outcome" + ); + assert.equal(isEmptyContentUpstreamFailure(500, "server_error"), false); + assert.equal(isEmptyContentUpstreamFailure(503, "upstream timeout"), false); + assert.equal( + isEmptyContentUpstreamFailure(429, "empty response content"), + false, + "non-5xx is not an empty-content failure" + ); + assert.equal(isEmptyContentUpstreamFailure(200, "empty response content"), false); +}); + +test("#10281 buildReasoningProbeTruncatedResponse yields a valid truncated 200", async () => { + const res = buildReasoningProbeTruncatedResponse({ + model: "zhipu/glm-5.2", + maxTokens: 1, + requestId: "test-request-id", + }); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") || "", /application\/json/); + + const body = (await res.json()) as Record; + const choice = (body.choices as Array>)[0]; + assert.equal(body.object, "chat.completion"); + assert.equal(body.model, "zhipu/glm-5.2"); + assert.equal(choice.finish_reason, "length"); + assert.equal((choice.message as Record).content, ""); + assert.equal((body.usage as Record).completion_tokens, 1); + + // The synthetic body must pass the empty-content guard (finish_reason "length" + // is a legitimate truncated completion — see errorClassifier.ts) so the + // non-stream success path does not re-flag it as a fake-success failure. + assert.equal(isEmptyContentResponse(body), false, "truncated probe response is a legitimate 200"); +}); diff --git a/tests/unit/responses-output-index-stack.test.ts b/tests/unit/responses-output-index-stack.test.ts new file mode 100644 index 0000000000..0343868de8 --- /dev/null +++ b/tests/unit/responses-output-index-stack.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { ResponsesOutputIndexStack } from "../../open-sse/utils/responsesOutputIndexStack.ts"; + +test("open() allocates sequential indices starting at 0", () => { + const stack = new ResponsesOutputIndexStack(); + assert.equal(stack.open(), 0); + assert.equal(stack.open(), 1); +}); + +test("close() on the current top does not throw", () => { + const stack = new ResponsesOutputIndexStack(); + const index = stack.open(); + assert.doesNotThrow(() => stack.close(index)); +}); + +test("close() with a mismatched index throws (catches the exact keepalive bug shape)", () => { + const stack = new ResponsesOutputIndexStack(); + const first = stack.open(); + stack.open(); + assert.throws(() => stack.close(first), /closing output_index 0 but the open top was 1/); +}); + +test("assertAllClosed() passes when everything opened was closed", () => { + const stack = new ResponsesOutputIndexStack(); + const index = stack.open(); + stack.close(index); + assert.doesNotThrow(() => stack.assertAllClosed()); +}); + +test("assertAllClosed() throws when an index was never closed — the exact regression this stack prevents", () => { + const stack = new ResponsesOutputIndexStack(); + stack.open(); + assert.throws(() => stack.assertAllClosed(), /still open with no close/); +}); + +test("a later open() after a forgotten close() gets the next index, never a reused one", () => { + // This is the structural guarantee replacing the old hand-tracked literal + // output_index: 0 in RESPONSES_STARTUP_THINKING_FRAME: even if a caller + // forgets to close(), the next open() can never collide with it. + const stack = new ResponsesOutputIndexStack(); + const first = stack.open(); + const second = stack.open(); + assert.notEqual(first, second); +}); diff --git a/tests/unit/search-providers-chat-guard.test.ts b/tests/unit/search-providers-chat-guard.test.ts new file mode 100644 index 0000000000..ac04fd5650 --- /dev/null +++ b/tests/unit/search-providers-chat-guard.test.ts @@ -0,0 +1,55 @@ +// Probe for issue #10274 -- "Search providers (tavily/exa/firecrawl) leak API keys to +// api.openai.com when used as chat/combo targets". +// +// Search-only providers (tavily-search, exa-search, firecrawl, serper-search, ...) exist +// ONLY in SEARCH_PROVIDERS (open-sse/config/searchRegistry.ts) + the /v1/search catalog; +// they have no chat REGISTRY entry and no specialized executor. Routing one of them as a +// chat-completions target (e.g. a round-robin combo with targets "tavily-search/web") +// therefore fell through to DefaultExecutor's `PROVIDERS[provider] || PROVIDERS.openai` +// fallback, which forwarded the user's real search API key to https://api.openai.com +// (observed as `[401] Incorrect API key provided: tvly-...` from OpenAI, not from Tavily). +// This probe proves the executor-level root cause directly and pins the guard: getExecutor() +// must throw a clear, sanitized 400 for every search provider instead of silently inheriting +// OpenAI's base URL/config. The guard set is DERIVED from SEARCH_PROVIDERS so adding a new +// search provider without updating the guard fails this test. +import test from "node:test"; +import assert from "node:assert/strict"; +import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; +import { SEARCH_PROVIDERS } from "../../open-sse/config/searchRegistry.ts"; + +const SEARCH_PROVIDER_IDS = Object.keys(SEARCH_PROVIDERS); + +test("#10274: no search provider has a specialized chat executor", () => { + for (const id of SEARCH_PROVIDER_IDS) { + assert.equal( + hasSpecializedExecutor(id), + false, + `search provider '${id}' must not have a specialized chat executor` + ); + } +}); + +test("#10274: a chat-completion request routed to a search provider must not silently hit OpenAI's endpoint", () => { + // Desired behavior: search providers (registered only in SEARCH_PROVIDERS, never in the + // chat REGISTRY) must not silently resolve to OpenAI's chat/completions endpoint when + // routed through the normal chat-completions executor path. getExecutor() must throw a + // clear, sanitized error for this set instead of falling through to DefaultExecutor's + // `PROVIDERS.openai` fallback (which produced the "Incorrect API key provided: tvly-..." + // OpenAI error the reporter saw for genuine Tavily/Exa/Firecrawl keys). Before the fix, + // getExecutor("tavily-search") returned a working executor whose buildUrl() resolved to + // OpenAI's endpoint -- this assertion FAILS on unfixed release/v3.8.50 code because no + // error is thrown at all. + for (const id of SEARCH_PROVIDER_IDS) { + assert.throws( + () => getExecutor(id), + (err) => { + assert.match(err.message, /search provider/i); + assert.match(err.message, /does not support chat completions/i); + assert.match(err.message, /\/v1\/search/i); + assert.equal(err.status, 400); + return true; + }, + `search provider '${id}' must raise a clear error instead of inheriting OpenAI's base URL/config` + ); + } +}); diff --git a/tests/unit/serial/provider-health-autopilot.test.ts b/tests/unit/serial/provider-health-autopilot.test.ts index c7ea6c4fc5..67e0b22909 100644 --- a/tests/unit/serial/provider-health-autopilot.test.ts +++ b/tests/unit/serial/provider-health-autopilot.test.ts @@ -17,7 +17,8 @@ const core = await import("../../../src/lib/db/core.ts"); const settingsDb = await import("../../../src/lib/db/settings.ts"); const providersDb = await import("../../../src/lib/db/providers.ts"); const autopilot = await import("../../../src/lib/monitoring/providerHealthAutopilot.ts"); -const actionsRoute = await import("../../../src/app/api/providers/health-autopilot/actions/route.ts"); +const actionsRoute = + await import("../../../src/app/api/providers/health-autopilot/actions/route.ts"); const reportRoute = await import("../../../src/app/api/providers/health-autopilot/route.ts"); const routeGuard = await import("../../../src/server/authz/routeGuard.ts"); const authzPipeline = await import("../../../src/server/authz/pipeline.ts"); @@ -113,6 +114,59 @@ test("provider health autopilot reports actionable cooldown and model lockout is } }); +test("provider health autopilot canonicalizes alias-keyed signals while preserving raw breaker actions", async () => { + const canonicalProvider = "nous-research"; + const aliasProvider = "nous"; + const connection = await createCooldownConnection(canonicalProvider); + for (let failure = 0; failure < 20; failure += 1) { + accountFallback.recordProviderFailure(aliasProvider); + } + accountFallback.lockModel( + aliasProvider, + String(connection.id), + "alias-locked-model", + "quota", + 60_000, + {} + ); + + try { + const report = await autopilot.buildProviderHealthAutopilotReport({ + provider: aliasProvider, + includeHealthy: true, + }); + assert.equal(report.providers.length, 1); + const provider = report.providers[0]; + assert.equal(provider.provider, canonicalProvider); + assert.equal(provider.signals.connections.total, 1); + assert.equal(provider.signals.modelLockouts, 1); + + const clearBreaker = findAction(report, "clear_provider_breaker"); + assert.ok(clearBreaker); + assert.equal(clearBreaker.target.provider, aliasProvider); + + const applied = await autopilot.executeProviderHealthAutopilotAction({ + type: clearBreaker.type, + target: clearBreaker.target, + preconditionsHash: clearBreaker.preconditionsHash, + confirm: true, + }); + assert.equal(applied.status, 200); + + const afterReset = await autopilot.buildProviderHealthAutopilotReport({ + provider: aliasProvider, + includeHealthy: true, + }); + assert.equal( + afterReset.providers[0].issues.some((issue) => issue.kind === "provider_circuit_open"), + false + ); + } finally { + accountFallback.clearModelLock(aliasProvider, String(connection.id), "alias-locked-model"); + accountFallback.clearProviderFailure(aliasProvider); + } +}); + test("provider health autopilot action clears cooldown with stale-state protection", async () => { await enableManagementAuth(); const connection = await createCooldownConnection(); diff --git a/tests/unit/session-affinity-combo-timeout-eviction.test.ts b/tests/unit/session-affinity-combo-timeout-eviction.test.ts new file mode 100644 index 0000000000..04a5764957 --- /dev/null +++ b/tests/unit/session-affinity-combo-timeout-eviction.test.ts @@ -0,0 +1,202 @@ +// #6219 follow-up — a COMBO per-model timeout must also evict the sticky session +// pin. +// +// Observed in production: combo "coding" [priority] pinned a session to one codex +// account. That account stalled past comboTargetTimeoutMs, the combo aborted the +// target and synthesized a 524, and — because a stall is not a quota/auth failure — +// nothing called markAccountUnavailable. The #6219 eviction only runs on that +// generic failover path, so the pin survived its full 30-minute TTL and every +// following request in the session was handed straight back to the stalled +// account: four consecutive requests, four 120s timeouts, "all targets exhausted" +// each time, while four sibling codex accounts sat healthy and unused. +// +// The fix classifies the abort reason (open-sse/services/combo/comboAbortReasons.ts) +// and evicts the connection-matched pin from the dispatch site in chat.ts. + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-timeout-affinity-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-timeout-affinity-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const affinityDb = await import("../../src/lib/db/sessionAccountAffinity.ts"); +const pin = await import("../../src/sse/services/sessionAffinityPin.ts"); +const abortReasons = await import("../../open-sse/services/combo/comboAbortReasons.ts"); + +const PROVIDER = "codex"; +const SESSION = "session-combo-timeout"; +const STALLED = "conn-stalled"; +const HEALTHY = "conn-healthy"; +const TTL = 30 * 60_000; + +function abortedWith(reason: unknown): AbortSignal { + const controller = new AbortController(); + controller.abort(reason); + return controller.signal; +} + +const timedOutSignal = () => abortedWith(new Error(abortReasons.COMBO_PER_MODEL_TIMEOUT_REASON)); + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("evicts the pin when the combo per-model timeout abandons the pinned account", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, STALLED, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: timedOutSignal(), + }); + + assert.equal(evicted, true, "a timed-out pinned account must lose its pin"); + assert.equal( + affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL), + null, + "the next request must be free to pick another account" + ); +}); + +test("leaves the pin intact on a client disconnect", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, STALLED, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: abortedWith(new Error("request_signal_aborted")), + }); + + assert.equal(evicted, false, "a client hanging up says nothing about account health"); + assert.equal( + affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL)?.connectionId, + STALLED, + "pin must survive so the session keeps its prompt-cache locality" + ); +}); + +test("leaves the pin intact when a hedged sibling cancelled this target", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, STALLED, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: abortedWith(new Error(abortReasons.COMBO_HEDGE_CANCELLED_REASON)), + }); + + assert.equal(evicted, false, "losing a hedge race is not an account failure"); + assert.equal(affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL)?.connectionId, STALLED); +}); + +test("leaves the pin intact when the dispatch was never aborted", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, STALLED, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: new AbortController().signal, + }); + + assert.equal(evicted, false); + assert.equal(affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL)?.connectionId, STALLED); +}); + +test("never evicts a pin that points at a different (healthy) account", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, HEALTHY, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: timedOutSignal(), + }); + + assert.equal(evicted, false, "connection-matched guard must hold"); + assert.equal(affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL)?.connectionId, HEALTHY); +}); + +test("no-ops without a session key or connection id", () => { + const signal = timedOutSignal(); + assert.equal( + pin.evictSessionAffinityOnComboTimeout({ + sessionKey: null, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: signal, + }), + false + ); + assert.equal( + pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: null, + modelAbortSignal: signal, + }), + false + ); +}); + +test("isComboPerModelTimeoutAbort accepts a bare string abort reason", () => { + assert.equal( + abortReasons.isComboPerModelTimeoutAbort( + abortedWith(abortReasons.COMBO_PER_MODEL_TIMEOUT_REASON) + ), + true + ); + assert.equal(abortReasons.isComboPerModelTimeoutAbort(null), false); +}); + +test("the combo timeout runner aborts with the shared reason constant", () => { + const src = fs.readFileSync( + new URL("../../open-sse/services/combo/targetTimeoutRunner.ts", import.meta.url), + "utf8" + ); + assert.match( + src, + /timeoutController\.abort\(new Error\(COMBO_PER_MODEL_TIMEOUT_REASON\)\)/, + "the runner must use the constant the eviction predicate matches on" + ); +}); + +test("chat.ts routes its upstream dispatch through the eviction-aware seam", () => { + const src = fs.readFileSync(new URL("../../src/sse/handlers/chat.ts", import.meta.url), "utf8"); + assert.match( + src, + /dispatchChatWithAffinityEviction\(/, + "chat.ts must dispatch through the seam that owns the eviction" + ); + assert.doesNotMatch( + src, + /await executeChatWithBreaker\(/, + "chat.ts must not bypass the seam by calling executeChatWithBreaker directly" + ); +}); + +test("the dispatch seam evicts when a dispatch is abandoned", () => { + const src = fs.readFileSync( + new URL("../../src/sse/handlers/chatDispatch.ts", import.meta.url), + "utf8" + ); + assert.match( + src, + /evictSessionAffinityOnComboTimeout\(/, + "chatDispatch.ts must call the eviction" + ); +}); diff --git a/tests/unit/t28-model-catalog-updates.test.ts b/tests/unit/t28-model-catalog-updates.test.ts index 6c9229920b..78da2a8ef9 100644 --- a/tests/unit/t28-model-catalog-updates.test.ts +++ b/tests/unit/t28-model-catalog-updates.test.ts @@ -24,6 +24,8 @@ test("T28: antigravity static catalog exposes only callable Gemini tier IDs", () const staticIds = (getStaticModelsForProvider("antigravity") || []).map((m) => m.id); assert.ok(!staticIds.includes("gemini-3-pro-preview")); + assert.ok(staticIds.includes("gemini-3.7-flash-high")); + assert.ok(staticIds.includes("gemini-3.7-flash-medium")); assert.ok(staticIds.includes("gemini-3.6-flash-high")); assert.ok(staticIds.includes("gemini-3.6-flash-medium")); assert.ok(staticIds.includes("gemini-3.6-flash-low")); diff --git a/tests/unit/think-tag-parser.test.ts b/tests/unit/think-tag-parser.test.ts index 2199e9356d..533fcb8d4a 100644 --- a/tests/unit/think-tag-parser.test.ts +++ b/tests/unit/think-tag-parser.test.ts @@ -1,8 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { hasThinkTags, extractThinkTags, processStreamingThinkDelta, flushThinkBuffer } = - await import("../../open-sse/utils/thinkTagParser.ts"); +const { + hasThinkTags, + extractThinkTags, + processStreamingThinkDelta, + flushThinkBuffer, + containsOrMayEndWithThinkOpenTag, + applyThinkTag, +} = await import("../../open-sse/utils/thinkTagParser.ts"); test("hasThinkTags detects opening tags and ignores empty input", () => { assert.equal(hasThinkTags("before plan after"), true); @@ -60,6 +66,33 @@ test("processStreamingThinkDelta extracts content and reasoning across split tag }); }); +test("containsOrMayEndWithThinkOpenTag flags a chunk ending on any partial open tag", () => { + for (const partial of ["<", "' across deltas", () => { + const ctx = { enabled: true, active: false, insideThink: false, buffer: "" }; + + const opening: { content: unknown; reasoning_content?: string } = { content: "plananswer", + }; + assert.equal(applyThinkTag(ctx, rest), true); + assert.equal(rest.reasoning_content, "plan"); + assert.equal(rest.content, ""); + + assert.deepEqual(flushThinkBuffer(ctx), { + reasoningDelta: null, + contentDelta: "answer", + }); +}); + test("processStreamingThinkDelta keeps partial closing tags buffered while inside think", () => { const ctx = { insideThink: true, buffer: "" }; diff --git a/tests/unit/usage-extractor.test.ts b/tests/unit/usage-extractor.test.ts index 6fa9c7b100..851032690c 100644 --- a/tests/unit/usage-extractor.test.ts +++ b/tests/unit/usage-extractor.test.ts @@ -232,6 +232,46 @@ test("extractUsageFromResponse reads Gemini usageMetadata and thinking tokens", }); }); +test("extractUsageFromResponse reads Gemini usageMetadata from the antigravity response envelope", () => { + // Antigravity / gemini-cli wrap non-streaming payloads in { response: {...} } + // (port of decolua/9router#59d858b — previously logged zero usage). + const usage = extractUsageFromResponse( + { + response: { + usageMetadata: { + promptTokenCount: 42, + candidatesTokenCount: 13, + thoughtsTokenCount: 4, + cachedContentTokenCount: 7, + }, + }, + }, + "antigravity" + ); + + assert.deepEqual(usage, { + prompt_tokens: 42, + completion_tokens: 17, + reasoning_tokens: 4, + }); +}); + +test("extractUsageFromResponse prefers top-level usageMetadata over the envelope", () => { + const usage = extractUsageFromResponse( + { + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 2 }, + response: { usageMetadata: { promptTokenCount: 99, candidatesTokenCount: 99 } }, + }, + "gemini" + ); + + assert.deepEqual(usage, { + prompt_tokens: 1, + completion_tokens: 2, + reasoning_tokens: 0, + }); +}); + test("extractUsageFromResponse returns null when usage is missing", () => { const usage = extractUsageFromResponse( { diff --git a/tests/unit/xai-oauth-provider.test.ts b/tests/unit/xai-oauth-provider.test.ts index 6fdba9a1ec..6d58db0bc6 100644 --- a/tests/unit/xai-oauth-provider.test.ts +++ b/tests/unit/xai-oauth-provider.test.ts @@ -6,7 +6,7 @@ import { xaiOauth, decodeXaiIdTokenIdentity } from "../../src/lib/oauth/provider import { XAI_OAUTH_CONFIG } from "../../src/lib/oauth/constants/oauth.ts"; import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; import { XaiExecutor } from "../../open-sse/executors/xai.ts"; -import { xai_oauthProvider } from "../../open-sse/config/providers/registry/xai-oauth/index.ts"; +import { xai_oauthProvider } from "../../open-sse/config/providers/registry/xai/index.ts"; const originalFetch = globalThis.fetch; diff --git a/tests/unit/zai-web-auth-semantics.test.ts b/tests/unit/zai-web-auth-semantics.test.ts new file mode 100644 index 0000000000..ff0561f718 --- /dev/null +++ b/tests/unit/zai-web-auth-semantics.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const originalFetch = globalThis.fetch; + +const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); + +let nextStatus = 200; +let lastRequest: { + url: string; + method: string; + authorization: string; + cookie: string; +} | null = null; + +let lastResponse: Response | null = null; + +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + + lastRequest = { + url: String(input), + method: String(init?.method || "GET"), + authorization: headers.get("authorization") || "", + cookie: headers.get("cookie") || "", + }; + + lastResponse = new Response( + JSON.stringify({ + secret: "response-body-must-not-be-consumed", + }), + { + status: nextStatus, + headers: { + "content-type": "application/json", + }, + } + ); + + return lastResponse; +}) as typeof fetch; + +test.after(() => { + globalThis.fetch = originalFetch; +}); + +async function validate(status: number) { + nextStatus = status; + lastRequest = null; + lastResponse = null; + + return validateProviderApiKey({ + provider: "zai-web", + apiKey: "synthetic-zai-token", + providerSpecificData: {}, + }); +} + +test("zai-web uses the token-only authenticated user-settings GET", async () => { + const result = await validate(200); + + assert.equal(result.valid, true); + + assert.equal(lastRequest?.url, "https://chat.z.ai/api/v1/users/user/settings"); + + assert.equal(lastRequest?.method, "GET"); + + assert.equal(lastRequest?.authorization, "Bearer synthetic-zai-token"); + + assert.equal(lastRequest?.cookie, ""); + + assert.equal(lastResponse?.bodyUsed, false); +}); + +test("zai-web preserves exact 401 as credential rejection", async () => { + const result = await validate(401); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, 401); + + assert.match(result.error, /invalid or expired/i); + + assert.equal(lastResponse?.bodyUsed, false); +}); + +test("zai-web preserves 403 without calling it expired", async () => { + const result = await validate(403); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, 403); + + assert.doesNotMatch(result.error, /invalid or expired/i); + + assert.equal(lastResponse?.bodyUsed, false); +}); + +test("zai-web preserves rate-limit and server statuses", async () => { + for (const status of [429, 503]) { + const result = await validate(status); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, status); + + assert.doesNotMatch(result.error, /invalid or expired/i); + + assert.equal(lastResponse?.bodyUsed, false); + } +}); diff --git a/tests/unit/zcode-executor.test.ts b/tests/unit/zcode-executor.test.ts new file mode 100644 index 0000000000..c82e7b3368 --- /dev/null +++ b/tests/unit/zcode-executor.test.ts @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const fixture = join(process.cwd(), "tests/fixtures/fake-zcode-app-server.mjs"); +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-zcode-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +test.after(() => rmSync(TEST_DATA_DIR, { recursive: true, force: true })); + +async function loadZcodeExecutor() { + return import("../../open-sse/executors/zcode.ts"); +} + +function requestBody() { + return { + messages: [ + { role: "system", content: "You are a coding assistant." }, + { role: "user", content: "Reply with a short status." }, + ], + }; +} + +test("ZCode accepts GLM Coding Plan models and rejects unsafe/unknown ids", async () => { + const { resolveZcodeModel } = await loadZcodeExecutor(); + assert.deepEqual(resolveZcodeModel("glm-5.2"), { ok: true, model: "glm-5.2" }); + assert.equal(resolveZcodeModel("-unexpected").ok, false); + assert.equal(resolveZcodeModel("unknown-model").ok, false); +}); + +test("ZCode runs a local app-server turn and returns an OpenAI chat completion", async () => { + const { ZcodeExecutor } = await loadZcodeExecutor(); + const executor = new ZcodeExecutor({ + command: process.execPath, + args: [fixture], + cwd: process.cwd(), + requestTimeoutMs: 3000, + turnTimeoutMs: 3000, + pollIntervalMs: 1, + }); + + const result = await executor.execute({ + model: "glm-5.2", + body: requestBody(), + stream: false, + credentials: {}, + }); + const response = "response" in result ? result.response : result; + assert.equal(response.status, 200); + assert.match(response.headers.get("content-type") || "", /application\/json/); + const body = await response.json(); + assert.equal(body.object, "chat.completion"); + assert.equal(body.model, "glm-5.2"); + assert.equal(body.choices?.[0]?.message?.role, "assistant"); + assert.equal(body.choices?.[0]?.message?.content, "fake zcode response"); + assert.equal(body.choices?.[0]?.finish_reason, "stop"); +}); + +test("ZCode buffers the completed turn into OpenAI SSE when stream=true", async () => { + const { ZcodeExecutor } = await loadZcodeExecutor(); + const executor = new ZcodeExecutor({ + command: process.execPath, + args: [fixture], + cwd: process.cwd(), + requestTimeoutMs: 3000, + turnTimeoutMs: 3000, + pollIntervalMs: 1, + }); + + const result = await executor.execute({ + model: "glm-5.2-high", + body: requestBody(), + stream: true, + credentials: {}, + }); + const response = "response" in result ? result.response : result; + const text = await response.text(); + assert.equal(response.status, 200); + assert.match(response.headers.get("content-type") || "", /text\/event-stream/); + assert.match(text, /fake zcode response/); + assert.match(text, /data: \[DONE\]/); +}); diff --git a/tests/unit/zcode-protocol.test.ts b/tests/unit/zcode-protocol.test.ts new file mode 100644 index 0000000000..6f40c6f9fd --- /dev/null +++ b/tests/unit/zcode-protocol.test.ts @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { ZcodeAppServerClient } from "../../open-sse/executors/zcodeProtocol.ts"; + +const fixture = join(process.cwd(), "tests/fixtures/fake-zcode-app-server.mjs"); + +test("ZCode protocol performs hello handshake and exchanges fragmented framed RPC", async () => { + const client = new ZcodeAppServerClient({ + command: process.execPath, + args: [fixture], + cwd: process.cwd(), + startupTimeoutMs: 3000, + requestTimeoutMs: 3000, + }); + try { + await client.start(); + const result = await client.call("zcode-agent", "initialize", [ + { workspacePath: "/workspace", workspaceIdentity: "/workspace" }, + ]); + assert.deepEqual(result, { + available: true, + protocolName: "ZCode Protocol", + protocolVersion: 1, + transportKind: "stdio", + }); + } finally { + await client.close(); + } +}); diff --git a/tests/unit/zcode-provider.test.ts b/tests/unit/zcode-provider.test.ts new file mode 100644 index 0000000000..3acf3c862c --- /dev/null +++ b/tests/unit/zcode-provider.test.ts @@ -0,0 +1,14 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { zcodeProvider } from "../../open-sse/config/providers/registry/zcode/index.ts"; + +test("ZCode provider registry exposes a local no-auth GLM Coding Plan backend", () => { + assert.equal(zcodeProvider.id, "zcode"); + assert.equal(zcodeProvider.alias, "zc"); + assert.equal(zcodeProvider.executor, "zcode"); + assert.equal(zcodeProvider.format, "openai"); + assert.equal(zcodeProvider.baseUrl, "zcode://app-server/stdio"); + assert.equal(zcodeProvider.authType, "none"); + assert.equal(zcodeProvider.authHeader, "none"); + assert.equal(zcodeProvider.models.some((model) => model.id === "glm-5.2"), true); +});