diff --git a/.env.example b/.env.example
index 9616acd3189e..84bde3f0256f 100644
--- a/.env.example
+++ b/.env.example
@@ -87,6 +87,10 @@ DISABLE_SQLITE_AUTO_BACKUP=false
# Used by: src/shared/utils/rateLimiter.ts
# Example: redis://localhost:6379 (or redis://redis:6379 in Docker)
# REDIS_URL=redis://localhost:6379
+# Namespace prefix for ALL OmniRoute Redis keys (rate limiter + auth cache +
+# quota store). Prevents key collisions when OmniRoute shares a Redis instance
+# with other apps (e.g. on 127.0.0.1:6379). Default when unset: omniroute:
+# REDIS_KEY_PREFIX=omniroute:
# Host interface docker-compose publishes the Redis sidecar on.
# Default: 127.0.0.1 (loopback only). The compose Redis runs WITHOUT
# `requirepass`, and app containers reach it over the compose network
@@ -372,9 +376,8 @@ ALLOW_API_KEY_REVEAL=false
# NO_LOG_API_KEY_IDS=key_abc123,key_def456
# Fallback per-day request budget applied to API keys whose `rate_limits`
-# column is null. Default (unset/empty/malformed) preserves the legacy
-# 1000/day, 5000/week, 20000/month windows so existing deployments do not
-# silently lose rate limiting on upgrade.
+# column is null. Default (unset/empty) is unlimited (no implicit caps).
+# Malformed values preserve the legacy 1000/day, 5000/week, 20000/month windows.
# Set explicitly to "0" to opt out entirely (unlimited fallback). Any
# positive integer N enables N/day, 5N/week, 20N/month.
# Used by: src/shared/utils/apiKeyPolicy.ts — checkRateLimit() fallback.
diff --git a/.github/workflows/dast-smoke.yml b/.github/workflows/dast-smoke.yml
index 23055c46e45e..674f0b20f6e0 100644
--- a/.github/workflows/dast-smoke.yml
+++ b/.github/workflows/dast-smoke.yml
@@ -46,6 +46,7 @@ jobs:
env:
PORT: "20128"
INJECTION_GUARD_MODE: block
+ REQUIRE_API_KEY: "false"
run: |
node dist/server.js > server.log 2>&1 &
echo $! > server.pid
@@ -64,16 +65,20 @@ jobs:
# those 302s as "the API accepted a schema-violating request" and the configured-off
# 400 as "rejected a schema-compliant request". Documenting the flow in the spec is
# still right (operators need it); fuzzing it is not what this smoke is for.
+ # /api/auth/login has brute-force rate limiting: repeated failed logins return 429,
+ # which Schemathesis flags as rejection of schema-compliant requests.
schemathesis run docs/openapi.yaml --url http://localhost:20128 \
--include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \
- --exclude-path-regex '^/api/auth/oidc/' \
+ --exclude-path-regex '^/api/auth/(oidc/|login)' \
--max-examples 8 --workers 4 --checks all --max-response-time 30 \
--request-timeout 20 --suppress-health-check all --no-color
+ - name: Install promptfoo
+ run: npm install -g promptfoo@0.122.0
- name: promptfoo injection-guard (blocking)
env:
OMNIROUTE_URL: http://localhost:20128
OMNIROUTE_API_KEY: not-needed-blocked-before-upstream
- run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache
+ run: promptfoo eval -c promptfooconfig.yaml --no-cache
- name: Stop server
if: always()
run: kill "$(cat server.pid)" || true
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index d8a65576dc67..3b04a20c8bb5 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -183,15 +183,55 @@ jobs:
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
+ - name: Build and push BUN base platform image by digest
+ id: build-bun-base
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
+ with:
+ context: .
+ file: Dockerfile.bun
+ target: runner-base
+ platforms: ${{ matrix.platform }}
+ outputs: type=image,push-by-digest=true,name-canonical=true,push=true
+ tags: |
+ ${{ env.IMAGE_NAME }}
+ ${{ env.GHCR_IMAGE_NAME }}
+ cache-from: type=gha,scope=docker-bun-base-${{ matrix.arch }}
+ cache-to: type=gha,scope=docker-bun-base-${{ matrix.arch }},mode=max
+ no-cache: false
+ env:
+ DOCKER_BUILDKIT_INLINE_CACHE: 1
+
+ - name: Build and push BUN web platform image by digest
+ id: build-bun-web
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
+ with:
+ context: .
+ file: Dockerfile.bun
+ target: runner-web
+ platforms: ${{ matrix.platform }}
+ outputs: type=image,push-by-digest=true,name-canonical=true,push=true
+ tags: |
+ ${{ env.IMAGE_NAME }}
+ ${{ env.GHCR_IMAGE_NAME }}
+ cache-from: type=gha,scope=docker-bun-web-${{ matrix.arch }}
+ cache-to: type=gha,scope=docker-bun-web-${{ matrix.arch }},mode=max
+ no-cache: false
+ env:
+ DOCKER_BUILDKIT_INLINE_CACHE: 1
+
- name: Export digests
env:
DIGEST_BASE: ${{ steps.build.outputs.digest }}
DIGEST_WEB: ${{ steps.build-web.outputs.digest }}
+ DIGEST_BUN_BASE: ${{ steps.build-bun-base.outputs.digest }}
+ DIGEST_BUN_WEB: ${{ steps.build-bun-web.outputs.digest }}
run: |
set -euo pipefail
- mkdir -p /tmp/digests/base /tmp/digests/web
+ mkdir -p /tmp/digests/base /tmp/digests/web /tmp/digests/bun-base /tmp/digests/bun-web
touch "/tmp/digests/base/${DIGEST_BASE#sha256:}"
touch "/tmp/digests/web/${DIGEST_WEB#sha256:}"
+ touch "/tmp/digests/bun-base/${DIGEST_BUN_BASE#sha256:}"
+ touch "/tmp/digests/bun-web/${DIGEST_BUN_WEB#sha256:}"
- name: Upload base digests
uses: actions/upload-artifact@v7
@@ -209,6 +249,22 @@ jobs:
if-no-files-found: error
retention-days: 1
+ - name: Upload bun-base digests
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: digests-bun-base-${{ matrix.arch }}
+ path: /tmp/digests/bun-base/*
+ if-no-files-found: error
+ retention-days: 1
+
+ - name: Upload bun-web digests
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: digests-bun-web-${{ matrix.arch }}
+ path: /tmp/digests/bun-web/*
+ if-no-files-found: error
+ retention-days: 1
+
merge:
name: Publish multi-arch manifests
needs:
@@ -263,6 +319,20 @@ jobs:
path: /tmp/digests/web
merge-multiple: true
+ - name: Download bun-base digests
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ pattern: digests-bun-base-*
+ path: /tmp/digests/bun-base
+ merge-multiple: true
+
+ - name: Download bun-web digests
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ pattern: digests-bun-web-*
+ path: /tmp/digests/bun-web
+ merge-multiple: true
+
- name: Create Docker Hub manifest
run: |
set -euo pipefail
@@ -286,6 +356,8 @@ jobs:
create_manifest "${IMAGE_NAME}" "" /tmp/digests/base
create_manifest "${IMAGE_NAME}" "-web" /tmp/digests/web
+ create_manifest "${IMAGE_NAME}" "-bun" /tmp/digests/bun-base
+ create_manifest "${IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web
- name: Create GHCR manifest
run: |
@@ -310,6 +382,8 @@ jobs:
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
create_manifest "${GHCR_IMAGE_NAME}" "-web" /tmp/digests/web
+ create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base
+ create_manifest "${GHCR_IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web
- name: Inspect image
if: needs.prepare.outputs.version != 'main'
diff --git a/AGENTS.md b/AGENTS.md
index 090bcf41cd63..f10fb39483c6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below.
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
-| Database | `src/lib/db/` | SQLite domain modules (157 migrations) |
+| Database | `src/lib/db/` | SQLite domain modules (159 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 110 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
diff --git a/Dockerfile.bun b/Dockerfile.bun
new file mode 100644
index 000000000000..bb547ce210da
--- /dev/null
+++ b/Dockerfile.bun
@@ -0,0 +1,146 @@
+# ── Multi-stage Dockerfile for Native Bun Runtime (web-latest-bun) ───────────
+FROM oven/bun:1.3.14-slim AS base
+WORKDIR /app
+
+RUN apt-get update \
+ && apt-get upgrade -y \
+ && apt-get install -y --no-install-recommends \
+ build-essential \
+ python3 \
+ python-is-python3 \
+ make \
+ g++ \
+ libsecret-1-0 \
+ ca-certificates \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# ── Builder stage (100% Bun Native Install & Build) ─────────────────────────
+FROM base AS builder
+WORKDIR /app
+
+COPY . .
+
+# Fast Bun native package install
+RUN bun install --include=optional --quiet
+
+# Compile native better-sqlite3 Node-API addon under Bun
+RUN if [ -d "node_modules/better-sqlite3" ]; then \
+ (cd node_modules/better-sqlite3 && bunx node-gyp rebuild); \
+ fi
+
+# Fetch tls-client-node native binary if script exists
+RUN if [ -f "node_modules/tls-client-node/scripts/postinstall.js" ]; then \
+ bun node_modules/tls-client-node/scripts/postinstall.js || true; \
+ fi
+
+# Disable Turbopack for Bun builder stage (Turbopack V8 internal worker bindings require Node)
+ENV OMNIROUTE_USE_TURBOPACK=0
+
+ARG OMNIROUTE_BASE_PATH=""
+ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
+
+ARG DASHBOARD_ALLOW_EMBED=""
+ENV DASHBOARD_ALLOW_EMBED=$DASHBOARD_ALLOW_EMBED
+
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV NODE_ENV=production
+
+# Bun native Next.js build execution
+RUN bun run --quiet build
+
+# ── Runner Base stage (100% Bun Native Production Runtime) ──────────────────
+FROM oven/bun:1.3.14-slim AS runner-base
+
+LABEL org.opencontainers.image.title="omniroute" \
+ org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint (Bun Native)" \
+ org.opencontainers.image.url="https://omniroute.online" \
+ org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" \
+ org.opencontainers.image.licenses="MIT"
+
+WORKDIR /app
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ libsecret-1-0 \
+ ca-certificates \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+ENV NODE_ENV=production
+ENV PORT=20128
+ENV HOSTNAME=0.0.0.0
+ENV OMNIROUTE_MEMORY_MB=1024
+
+ENV DATA_DIR=/app/data
+RUN mkdir -p /app/data
+
+COPY --from=builder /app/.build/next/standalone ./
+COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
+ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
+
+COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
+
+EXPOSE 20128
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
+ CMD bun healthcheck.mjs || exit 1
+
+ENTRYPOINT ["bun", "dev/run-standalone.mjs"]
+
+# ── Runner Web stage (Bun Native + Chromium/Playwright for Web providers) ───
+FROM runner-base AS runner-web
+
+USER root
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ chromium \
+ chromium-driver \
+ fonts-liberation \
+ libasound2t64 \
+ gconf-service \
+ libatk-bridge2.0-0 \
+ libatk1.0-0 \
+ libc6 \
+ libcairo2 \
+ libcups2 \
+ libdbus-1-3 \
+ libexpat1 \
+ libfontconfig1 \
+ libgbm1 \
+ libgcc-s1 \
+ libglib2.0-0 \
+ libgtk-3-0 \
+ libnspr4 \
+ libnss3 \
+ libpango-1.0-0 \
+ pangocairo-1.0-0 \
+ stdc++6 \
+ libx11-6 \
+ libx11-xcb1 \
+ libxcb1 \
+ libxcomposite1 \
+ libxcursor1 \
+ libxdamage1 \
+ libxext6 \
+ libxfixes3 \
+ libxi6 \
+ libxrandr2 \
+ libxrender1 \
+ libxss1 \
+ libxtst6 \
+ ca-certificates \
+ fonts-gargi \
+ fonts-ipafont-gothic \
+ fonts-kacst \
+ fonts-thai-tlwg \
+ fonts-wqy-zenhei \
+ && rm -rf /var/lib/apt/lists/*
+
+ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
+ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
+
+# Return to the base image non-root user after the apt install (mirrors the
+# Node Dockerfile runner-web stage, which re-asserts USER node).
+USER bun
diff --git a/README.md b/README.md
index 07c797442d72..55e6d0328a17 100644
--- a/README.md
+++ b/README.md
@@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \
-
+
@@ -646,7 +646,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
-> The most complete catalog of any open-source router: **348 providers**, **90+ with a free tier**, **57 free forever**.
+> The most complete catalog of any open-source router: **348 providers**, **90+ with a free tier**, **56 free forever**.
@@ -1009,6 +1009,19 @@ Full table: [Docker Guide — runtime RAM](docs/guides/DOCKER_GUIDE.md#runtime-r
> are **not supported for production**. See
> [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).
+**🥟 Bun**
+
+Standard `bun install` and global installation (`bun install -g omniroute`) are supported via Bun runtime detection:
+- **Built-in `bun:sqlite`**: OmniRoute uses Bun's built-in `bun:sqlite` driver when running under Bun, falling back to `better-sqlite3` on Node.js or `sql.js`.
+- **Automatic Webpack bundler selection**: Development (`bun run dev`) and production builds (`bun run build`) automatically detect Bun and disable Turbopack in favor of Webpack to prevent native V8 binding incompatibilities.
+- **Dedicated Bun Dockerfile**: Multi-stage `Dockerfile.bun` for native Bun production deployments (`docker build -f Dockerfile.bun -t omniroute:bun .`).
+
+```bash
+# Install and run with Bun
+bun install
+bun run dev
+```
+
**🛠️ From source**
```bash
@@ -1187,7 +1200,7 @@ Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações c
Runtime Node.js 22.x / 24.x LTS — >=22.22.2 <23 || >=24.0.0 <27
Language TypeScript 6.0 — 100% TypeScript across src/ and open-sse/ (zero any in core since v2.0)
Framework Next.js 16 + React 19 + Tailwind CSS 4
-
Database better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 157 migrations
+
Database better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 159 migrations
Memory SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay
Schemas Zod 4 — MCP tool I/O validation + API contracts
Protocols MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)
diff --git a/bin/cli/commands/combo.mjs b/bin/cli/commands/combo.mjs
index 554c1c1d838c..8d58cf73bd93 100644
--- a/bin/cli/commands/combo.mjs
+++ b/bin/cli/commands/combo.mjs
@@ -307,6 +307,12 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
}
const models = Array.isArray(opts.models) ? opts.models : [];
+ if (!models.length) {
+ console.error(
+ "combo create requires at least one target. Pass --models
and/or repeat --model ."
+ );
+ return 1;
+ }
try {
return await withRuntime(async ({ kind, api, db }) => {
diff --git a/bin/cli/commands/oauth.mjs b/bin/cli/commands/oauth.mjs
index 8bf547b2c00f..c9f8386d2b9d 100644
--- a/bin/cli/commands/oauth.mjs
+++ b/bin/cli/commands/oauth.mjs
@@ -228,20 +228,38 @@ async function runSocialFlow(def, opts) {
async function runDeviceFlow(def, opts) {
const providerKey = resolveBackendKey(def.id);
- const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, {
- ...targetApiOptions(opts),
- method: "POST",
- });
+ let startRes = await apiFetch(`/api/oauth/${providerKey}/device-code`, targetApiOptions(opts));
+ if (!startRes.ok) {
+ startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, {
+ ...targetApiOptions(opts),
+ method: "POST",
+ });
+ }
if (!startRes.ok) {
process.stderr.write(`Failed to start device flow: ${startRes.status}\n`);
process.exit(1);
}
const start = await startRes.json();
- process.stdout.write(
- `\nDevice code: ${start.userCode ?? start.user_code ?? ""}\nVisit: ${start.verificationUri ?? start.verification_uri}\n\n`
- );
- if (opts.browser !== false)
- await openBrowser(start.verificationUri ?? start.verification_uri ?? "");
+ const userCode = start.userCode ?? start.user_code ?? "";
+ const verificationUri =
+ start.verificationUriComplete ??
+ start.verification_uri_complete ??
+ start.verificationUri ??
+ start.verification_uri ??
+ start.authUrl ??
+ start.url ??
+ "";
+
+ if (userCode) {
+ process.stdout.write(`\nDevice code: ${userCode}\nVisit: ${verificationUri}\n\n`);
+ } else if (verificationUri) {
+ process.stdout.write(`\nVisit: ${verificationUri}\n\n`);
+ } else {
+ process.stdout.write(`\nAuthorization URL not available\n\n`);
+ }
+
+ if (opts.browser !== false && verificationUri)
+ await openBrowser(verificationUri);
process.stderr.write("Waiting for device authorization...\n");
const deadline = Date.now() + (opts.timeout ?? 300000);
const intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
diff --git a/bin/cli/commands/plugin.mjs b/bin/cli/commands/plugin.mjs
index fc433a88ea4e..971c9ef231b2 100644
--- a/bin/cli/commands/plugin.mjs
+++ b/bin/cli/commands/plugin.mjs
@@ -9,10 +9,13 @@ import { discoverPlugins } from "../plugins.mjs";
// (instead of string-interpolating into `execSync`) prevents a malicious plugin
// name like `foo; rm -rf ~` or `` foo`id` `` from being interpreted by the shell.
function runNpm(args) {
- const res = spawnSync("npm", args, { stdio: "inherit", shell: false });
+ const isBun = Boolean(process.versions.bun);
+ const pm = isBun ? "bun" : "npm";
+ const cmdArgs = isBun && args[0] === "install" ? ["add", ...args.slice(1)] : args;
+ const res = spawnSync(pm, cmdArgs, { stdio: "inherit", shell: false });
if (res.error) throw res.error;
if (typeof res.status === "number" && res.status !== 0) {
- throw new Error(`npm exited with code ${res.status}`);
+ throw new Error(`${pm} exited with code ${res.status}`);
}
}
diff --git a/bin/cli/runtime/nativeDeps.mjs b/bin/cli/runtime/nativeDeps.mjs
index 60e4d2195312..ba5274f3c6a4 100644
--- a/bin/cli/runtime/nativeDeps.mjs
+++ b/bin/cli/runtime/nativeDeps.mjs
@@ -114,30 +114,30 @@ export function isBetterSqliteBinaryValid() {
export function npmInstallRuntime(pkgs, opts = {}) {
const cwd = ensureRuntimeDir();
- // Persist to the runtime package.json (exact version) instead of --no-save so a later
- // install of a sibling runtime dep (e.g. systray2 from trayRuntime.ts, which writes to the
- // same runtime dir) does not prune this package as "extraneous" — that pruning otherwise
- // reproduces "No SQLite driver available" after a tray install removes better-sqlite3.
- // npm 12+ defaults `allowScripts` to off, silently skipping lifecycle/install
- // scripts (e.g. better-sqlite3's node-gyp/prebuild-install rebuild) unless the
- // package has a matching `allowScripts` entry — and still exits 0, masking the
- // failure (#10713). The runtime dir is a CLI-owned, non-user package.json, so
- // explicitly allowing scripts for the packages we are installing here is safe.
- const npmArgs = [
- "install",
- ...pkgs,
- "--no-audit",
- "--no-fund",
- "--prefer-online",
- "--save-exact",
- ...pkgs.map((pkg) => `--allow-scripts=${pkg}`),
- ];
- // On Windows .cmd files cannot be executed without a shell; use cmd.exe /c explicitly
- // so we never set shell:true (which would propagate env and enable injection).
const isWin = platform() === "win32";
- const [exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
+ const isBun = Boolean(process.versions.bun);
+
+ let exe, args, displayCmd;
+ if (isBun) {
+ const bunArgs = ["add", ...pkgs, "--trust"];
+ [exe, args] = isWin ? ["cmd.exe", ["/c", "bun", ...bunArgs]] : ["bun", bunArgs];
+ displayCmd = `bun ${bunArgs.join(" ")}`;
+ } else {
+ const npmArgs = [
+ "install",
+ ...pkgs,
+ "--no-audit",
+ "--no-fund",
+ "--prefer-online",
+ "--save-exact",
+ ...pkgs.map((pkg) => `--allow-scripts=${pkg}`),
+ ];
+ [exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
+ displayCmd = `npm ${npmArgs.join(" ")}`;
+ }
+
if (!opts.silent) {
- process.stdout.write(`[omniroute][runtime] npm ${npmArgs.join(" ")}\n`);
+ process.stdout.write(`[omniroute][runtime] ${displayCmd}\n`);
}
const res = spawnSync(exe, args, {
cwd,
diff --git a/bin/cli/sqlite.mjs b/bin/cli/sqlite.mjs
index ce14541480fb..982fef3520af 100644
--- a/bin/cli/sqlite.mjs
+++ b/bin/cli/sqlite.mjs
@@ -5,10 +5,14 @@ import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./
async function loadSqlite() {
if (process.versions.bun) {
- return { Database: (await import("bun:sqlite")).Database };
+ try {
+ return { Database: (await import("bun:sqlite")).Database, driver: "bun:sqlite" };
+ } catch (bunError) {
+ // fall through to better-sqlite3 if bun:sqlite fails
+ }
}
try {
- return { Database: (await import("better-sqlite3")).default };
+ return { Database: (await import("better-sqlite3")).default, driver: "better-sqlite3" };
} catch (error) {
return { error };
}
@@ -86,12 +90,14 @@ export function normalizeBunSqliteParams(params) {
export function createSqliteNativeError(error) {
const message = error instanceof Error ? error.message : String(error);
+ const isBun = Boolean(process.versions.bun);
+ const rebuildCmd = isBun ? "bun add better-sqlite3 --trust" : "npm rebuild better-sqlite3";
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
return new Error(
- "better-sqlite3 native binding is incompatible with this Node.js runtime. " +
- "Run `npm rebuild better-sqlite3` in the OmniRoute project and try again. " +
- "Or run: omniroute runtime repair " +
- "(rebuilds into a user-writable runtime; works without a C++ toolchain)."
+ `better-sqlite3 native binding is incompatible with this runtime. ` +
+ `Run \`${rebuildCmd}\` in the OmniRoute project and try again. ` +
+ `Or run: omniroute runtime repair ` +
+ `(rebuilds into a user-writable runtime; works without a C++ toolchain).`
);
}
if (
@@ -100,10 +106,9 @@ export function createSqliteNativeError(error) {
message.includes("Cannot find module 'better-sqlite3'")
) {
return new Error(
- "better-sqlite3 native binding could not be found (no prebuilt addon for this platform). " +
- "This is common under `npx`, which runs a fresh, ephemeral install that never built the addon. " +
- "Run: omniroute runtime repair " +
- "(rebuilds into a user-writable runtime; works without a C++ toolchain)."
+ `better-sqlite3 native binding could not be found (no prebuilt addon for this platform). ` +
+ `Run: omniroute runtime repair ` +
+ `(rebuilds into a user-writable runtime; works without a C++ toolchain).`
);
}
return error;
@@ -111,7 +116,7 @@ export function createSqliteNativeError(error) {
async function openSqliteDatabase(dbPath, options = {}) {
const loaded = await loadSqlite();
- if (process.versions.bun) {
+ if (loaded.driver === "bun:sqlite" || (process.versions.bun && !loaded.Database)) {
if (options.fileMustExist && !fs.existsSync(dbPath)) {
throw new Error(`SQLite file does not exist: ${dbPath}`);
}
diff --git a/bin/cli/utils/ensureAndroidCacheDir.mjs b/bin/cli/utils/ensureAndroidCacheDir.mjs
index 30fe073f8b4f..0e3f2d20ec41 100644
--- a/bin/cli/utils/ensureAndroidCacheDir.mjs
+++ b/bin/cli/utils/ensureAndroidCacheDir.mjs
@@ -94,10 +94,15 @@ export function ensureAndroidCacheDir(options = {}) {
*/
export function isFatalInstrumentationHookFailure(text) {
if (!text) return false;
- return (
- /Unsupported platform:\s*android/i.test(text) ||
- /error occurred while loading instrumentation hook/i.test(text)
- );
+ // Next.js wraps ANY throw inside instrumentation.register() with the generic
+ // "An error occurred while loading instrumentation hook:" prefix, on every
+ // platform (node_modules/next/dist/server/web/globals.js). That prefix alone
+ // therefore cannot identify the Android/Termux cache-probe failure — a bare
+ // generic instrumentation error on win32/desktop would be misreported as the
+ // Android bug and hide the real cause. Only match when the text actually
+ // carries the Android platform marker that Next's getCacheDirectory() emits.
+ // #10028
+ return /Unsupported platform:\s*android/i.test(text);
}
/**
diff --git a/bin/nodeRuntimeSupport.mjs b/bin/nodeRuntimeSupport.mjs
index 47905f0e4ff7..8f8f88f68327 100644
--- a/bin/nodeRuntimeSupport.mjs
+++ b/bin/nodeRuntimeSupport.mjs
@@ -44,6 +44,18 @@ export function getSecureFloorForMajor(major) {
}
export function getNodeRuntimeSupport(version = process.versions.node) {
+ if (process.versions.bun) {
+ return {
+ nodeVersion: `bun-${process.versions.bun} (Node.js API ${version})`,
+ nodeCompatible: true,
+ reason: "supported-bun",
+ supportedRange: SUPPORTED_NODE_RANGE + " || Bun >=1.1.0",
+ supportedDisplay: SUPPORTED_NODE_DISPLAY + ", or Bun 1.1+",
+ recommendedVersion: `v${RECOMMENDED_NODE_VERSION}`,
+ minimumSecureVersion: null,
+ };
+ }
+
const parsed = parseNodeVersion(version);
const secureFloor = getSecureFloorForMajor(parsed.major);
const nodeCompatible = secureFloor ? compareNodeVersions(parsed, secureFloor) >= 0 : false;
diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs
index fb0a4555208e..09b133df4f3b 100755
--- a/bin/omniroute.mjs
+++ b/bin/omniroute.mjs
@@ -17,7 +17,12 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
-import updateNotifier from "update-notifier";
+let updateNotifier = null;
+try {
+ updateNotifier = (await import("update-notifier")).default;
+} catch {
+ // update-notifier is optional in pruned standalone environments
+}
import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.mjs";
import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs";
import { getDefaultDataDir } from "./cli/data-dir.mjs";
@@ -251,8 +256,9 @@ if (shouldProvisionStorageKey(process.argv)) {
// Register update notifier — checks npm once per 24h, notifies on exit via stderr.
const _pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
-const _notifier = updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 });
+const _notifier = updateNotifier ? updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 }) : null;
process.on("exit", () => {
+ if (!_notifier || !_notifier.update) return;
if (process.env.OMNIROUTE_NO_UPDATE_NOTIFIER) return;
if (process.env.CI) return;
if (process.argv.includes("--quiet") || process.argv.includes("-q")) return;
diff --git a/changelog.d/features/11104-operator-error-rules.md b/changelog.d/features/11104-operator-error-rules.md
new file mode 100644
index 000000000000..f31e78c01f3e
--- /dev/null
+++ b/changelog.d/features/11104-operator-error-rules.md
@@ -0,0 +1 @@
+- **feat(providers):** let operators declare per-provider error rules through `settings.providerErrorRules` instead of patching the catalog — an operator-supplied rule for a provider is consulted before the built-in `providerRuleRegistry`, receives the raw error text, and has its declared scope/cooldown/reason actually honored end to end, for any provider (declaring the rule is the opt-in — no extra allowlist entry needed). Matches are plain case-insensitive substrings (never RegExp) and bounded to 50 rules to keep the hot path safe ([#11104](https://github.com/diegosouzapw/OmniRoute/pull/11104))
diff --git a/changelog.d/features/11190-usage-command-json.md b/changelog.d/features/11190-usage-command-json.md
new file mode 100644
index 000000000000..d7655f04c51b
--- /dev/null
+++ b/changelog.d/features/11190-usage-command-json.md
@@ -0,0 +1 @@
+- **feat(api):** `/api/usage/om-usage` gains a structured form — `?format=json` returns the key's own usage as `ApiKeyUsageLimitStatus` + `UsageSnapshot` instead of `text/plain`. This is the surface a UI (the OmniCopilot panel) consumes to show a key holder their daily/weekly spend and quota reset. The route is self-service (the caller's own key, gated by `allowUsageCommand`), not the management surface; refusals come back as a discriminated `{ "allowed": false, "error": … }` so a UI can tell "not allowed" apart from "allowed but nothing cached yet". The endpoint was previously undocumented in `API_REFERENCE.md`; it now has a section ([#11190](https://github.com/diegosouzapw/OmniRoute/pull/11190))
diff --git a/changelog.d/features/11192-usage-command-providers-array.md b/changelog.d/features/11192-usage-command-providers-array.md
new file mode 100644
index 000000000000..b7ef42110913
--- /dev/null
+++ b/changelog.d/features/11192-usage-command-providers-array.md
@@ -0,0 +1 @@
+- **feat(api):** `/api/usage/om-usage?format=json` now returns `providers[]` — every connection's quota snapshot, not just the single selected one — so a panel can render Codex / Claude / OpenCode side by side. The collector already gathered all of them; the single-pick `provider` field (kept) is a terminal presentation choice. Closes the per-connection gap from OmniCopilot #8 ([#11192](https://github.com/diegosouzapw/OmniRoute/pull/11192))
diff --git a/changelog.d/fixes/10028-windows-instrumentation-hook.md b/changelog.d/fixes/10028-windows-instrumentation-hook.md
new file mode 100644
index 000000000000..9879e2f3f303
--- /dev/null
+++ b/changelog.d/fixes/10028-windows-instrumentation-hook.md
@@ -0,0 +1 @@
+- fix(cli): stop diagnosing every Next.js instrumentation-hook failure as the Android/Termux cache bug — only the Android "Unsupported platform: android" signal now triggers the Android hint, so a win32/desktop instrumentation error surfaces its real cause instead of a useless `mkdir -p ~/.cache` (#10028)
\ No newline at end of file
diff --git a/changelog.d/fixes/10265-command-code-provider-api.md b/changelog.d/fixes/10265-command-code-provider-api.md
new file mode 100644
index 000000000000..b38e4e9d2a50
--- /dev/null
+++ b/changelog.d/fixes/10265-command-code-provider-api.md
@@ -0,0 +1 @@
+- fix(command-code): route chat to the documented /provider/v1/chat/completions endpoint instead of the CLI-only /alpha/generate, which Command Code gates/blocks for external callers (#10265)
\ No newline at end of file
diff --git a/changelog.d/fixes/10523-servicesupervisor-port-flake.md b/changelog.d/fixes/10523-servicesupervisor-port-flake.md
new file mode 100644
index 000000000000..1a98ea7fa288
--- /dev/null
+++ b/changelog.d/fixes/10523-servicesupervisor-port-flake.md
@@ -0,0 +1 @@
+- fix(services): isolate probeBeforeSpawn adoption tests on distinct ports to stop the order-dependent flake (#10523)
\ No newline at end of file
diff --git a/changelog.d/fixes/10550-responses-reasoning-transport.md b/changelog.d/fixes/10550-responses-reasoning-transport.md
index d34c433debd8..e2b40cdb8c97 100644
--- a/changelog.d/fixes/10550-responses-reasoning-transport.md
+++ b/changelog.d/fixes/10550-responses-reasoning-transport.md
@@ -1 +1 @@
-- Preserve portable plaintext reasoning by default across streaming and non-streaming Chat Completions and Responses routes while keeping provider-bound opaque state target-compatible. Combos now drop incompatible continuation reasoning by default and can explicitly skip incompatible targets, while known providers no longer show redundant encrypted-reasoning controls. (#10550)
+- Preserve portable plaintext reasoning by default across streaming and non-streaming Chat Completions and Responses routes while keeping provider-bound opaque state target-compatible. Direct requests drop incompatible continuation reasoning by default; combos can explicitly skip incompatible targets without mutating the request. Known providers no longer show redundant encrypted-reasoning controls. (#10550, #10959)
diff --git a/changelog.d/fixes/10949-mixed-reasoning-plaintext.md b/changelog.d/fixes/10949-mixed-reasoning-plaintext.md
new file mode 100644
index 000000000000..05a055ec5384
--- /dev/null
+++ b/changelog.d/fixes/10949-mixed-reasoning-plaintext.md
@@ -0,0 +1 @@
+- Preserve explicit plaintext reasoning when a Responses reasoning item also carries opaque provider state (rare OpenCode Go `deepseek-v4-flash` responses). Mixed plaintext + opaque input is projected onto the target transport: plaintext targets keep portable text, opaque targets keep provider state. Opaque-only reasoning is dropped when the selected target cannot replay it, allowing cross-model conversations to continue. (#10949, #10959)
diff --git a/changelog.d/fixes/10986-reasoning-only-content.md b/changelog.d/fixes/10986-reasoning-only-content.md
new file mode 100644
index 000000000000..0d293482bd30
--- /dev/null
+++ b/changelog.d/fixes/10986-reasoning-only-content.md
@@ -0,0 +1 @@
+- fix(command-code): surface reasoning-only output as content when a model emits no text-delta (#10986)
\ No newline at end of file
diff --git a/changelog.d/fixes/10990-v0-vercel-web-static-catalog.md b/changelog.d/fixes/10990-v0-vercel-web-static-catalog.md
new file mode 100644
index 000000000000..9d5672120802
--- /dev/null
+++ b/changelog.d/fixes/10990-v0-vercel-web-static-catalog.md
@@ -0,0 +1 @@
+- **Static model catalog for v0-vercel-web:** seed a static catalog for the v0-vercel-web web-cookie provider (v0-1.0-md, v0-1.5-lg, v0-1.5-md) so its dashboard "Available Models" / "Import from /models" UI serves a usable list instead of falling through to the route's 400 "does not support models listing" ([#10990](https://github.com/diegosouzapw/OmniRoute/issues/10990)).
\ No newline at end of file
diff --git a/changelog.d/fixes/10997-blackbox-deprecation.md b/changelog.d/fixes/10997-blackbox-deprecation.md
new file mode 100644
index 000000000000..74ac19152673
--- /dev/null
+++ b/changelog.d/fixes/10997-blackbox-deprecation.md
@@ -0,0 +1 @@
+- fix(providers): mark the blackbox provider deprecated — api.blackbox.ai returns HTTP 404 on every path variant (sweep 2026-08-21), so the public inference surface is dead and the catalog entry now carries a deprecation notice. ([#10997](https://github.com/diegosouzapw/OmniRoute/issues/10997))
\ No newline at end of file
diff --git a/changelog.d/fixes/11002-dify-key-validation.md b/changelog.d/fixes/11002-dify-key-validation.md
new file mode 100644
index 000000000000..6574714c9be6
--- /dev/null
+++ b/changelog.d/fixes/11002-dify-key-validation.md
@@ -0,0 +1 @@
+- fix(providers): validate Dify keys against its native /v1/chat-messages endpoint (#11002)
\ No newline at end of file
diff --git a/changelog.d/fixes/11015-shutdown-track-sse.md b/changelog.d/fixes/11015-shutdown-track-sse.md
new file mode 100644
index 000000000000..1ed99b3669d8
--- /dev/null
+++ b/changelog.d/fixes/11015-shutdown-track-sse.md
@@ -0,0 +1 @@
+- **fix(resilience):** count heavyweight `/v1` admission leases in the SIGTERM drain and send `Retry-After` on shutdown 503s so Recreate no longer looks like an empty 502 ([#11015](https://github.com/diegosouzapw/OmniRoute/issues/11015)) — thanks @RaviTharuma
diff --git a/changelog.d/fixes/11050-remove-ghost-webhook-events.md b/changelog.d/fixes/11050-remove-ghost-webhook-events.md
new file mode 100644
index 000000000000..6278ee6c0a93
--- /dev/null
+++ b/changelog.d/fixes/11050-remove-ghost-webhook-events.md
@@ -0,0 +1 @@
+- **fix(webhooks):** remove 3 declared-but-never-emitted events (`provider.error`, `provider.recovered`, `combo.switched`) from `WebhookEvent` — catalog now `request.completed | request.failed | quota.exceeded | test.ping`; `POST /api/webhooks` and `PUT /api/webhooks/[id]` reject ghost values with 400; OpenAPI webhook description updated across 43 locales ([11050](https://github.com/diegosouzapw/OmniRoute/pull/11050))
diff --git a/changelog.d/fixes/11060-perplexity-filter.md b/changelog.d/fixes/11060-perplexity-filter.md
new file mode 100644
index 000000000000..c221d3ccaba1
--- /dev/null
+++ b/changelog.d/fixes/11060-perplexity-filter.md
@@ -0,0 +1 @@
+- fix(providers): filter Perplexity model import to the Sonar family so Agent-API catalog ids stop surfacing as routable chat models (#11060)
diff --git a/changelog.d/fixes/11085-claude-code-tool-name-casing.md b/changelog.d/fixes/11085-claude-code-tool-name-casing.md
new file mode 100644
index 000000000000..5ad424c14182
--- /dev/null
+++ b/changelog.d/fixes/11085-claude-code-tool-name-casing.md
@@ -0,0 +1 @@
+- **fix(claude):** restore canonical tool names (`bash` → `Bash`, `croncreate` → `CronCreate`) on non-streaming OpenAI→Claude conversion and through identity-echo alias maps, so Claude Code stops rejecting tool calls with "No such tool available" ([#11085](https://github.com/diegosouzapw/OmniRoute/pull/11085)) — thanks @linhdmn
diff --git a/changelog.d/fixes/11095-termux-onnx.md b/changelog.d/fixes/11095-termux-onnx.md
new file mode 100644
index 000000000000..8c268037104c
--- /dev/null
+++ b/changelog.d/fixes/11095-termux-onnx.md
@@ -0,0 +1 @@
+- fix(install): make the ONNX dependency chain optional so Termux/Android installs succeed again (#11095)
diff --git a/changelog.d/fixes/11101-reject-silent-validation.md b/changelog.d/fixes/11101-reject-silent-validation.md
new file mode 100644
index 000000000000..04b2a67d5a07
--- /dev/null
+++ b/changelog.d/fixes/11101-reject-silent-validation.md
@@ -0,0 +1 @@
+- **fix(providers):** Reject silent validation degradation on provider connection patch — unknown `rateLimitOverrides` keys (e.g. a typo'd `tpm`) and empty/non-numeric values now return `400` with the rejected key list instead of being silently dropped ([#11101](https://github.com/diegosouzapw/OmniRoute/pull/11101))
diff --git a/changelog.d/fixes/11102-combo-suggestion-count.md b/changelog.d/fixes/11102-combo-suggestion-count.md
new file mode 100644
index 000000000000..3cbf6f11d3de
--- /dev/null
+++ b/changelog.d/fixes/11102-combo-suggestion-count.md
@@ -0,0 +1 @@
+- **Autopilot suggestion counter:** the combo health autopilot summary now reports `suggestionCount` (the real number of suggested actions across all issues) instead of conflating it with link counts, while keeping `actionableCount` as a deprecated alias for backward compatibility. The `run_combo_test` action now links to the dashboard with the combo id (`/dashboard/combos?test=`) rather than the read-only API route, so operators can actually trigger a test from the UI ([#11102](https://github.com/diegosouzapw/OmniRoute/pull/11102)).
diff --git a/changelog.d/fixes/11103-persist-config-audit-log.md b/changelog.d/fixes/11103-persist-config-audit-log.md
new file mode 100644
index 000000000000..aeb53b178145
--- /dev/null
+++ b/changelog.d/fixes/11103-persist-config-audit-log.md
@@ -0,0 +1 @@
+- **Config audit persistence:** persist the configuration audit trail to SQLite (`config_audit_log`) instead of an in-memory buffer capped at 1000 volatile entries, and bound its growth with `cleanupConfigAudit()` driven by the `retention.configAudit` setting (default 30 days), wired into `runAutoCleanup` ([#11103](https://github.com/diegosouzapw/OmniRoute/pull/11103)).
diff --git a/changelog.d/fixes/11109-stream-recovery-toolcall.md b/changelog.d/fixes/11109-stream-recovery-toolcall.md
new file mode 100644
index 000000000000..04a43382e427
--- /dev/null
+++ b/changelog.d/fixes/11109-stream-recovery-toolcall.md
@@ -0,0 +1 @@
+- fix(sse): resume mid-stream recovery after a _completed_ tool call — `finish_reason: "tool_calls"` is now tracked per-call instead of as a general terminal marker, so truncation of trailing prose after a fully-delivered tool call is recoverable while in-flight calls stay blocked ([#11109](https://github.com/diegosouzapw/OmniRoute/pull/11109))
diff --git a/changelog.d/fixes/11116-reasoning-effort-capability-discovery.md b/changelog.d/fixes/11116-reasoning-effort-capability-discovery.md
new file mode 100644
index 000000000000..fbc4dfe694e3
--- /dev/null
+++ b/changelog.d/fixes/11116-reasoning-effort-capability-discovery.md
@@ -0,0 +1 @@
+- **fix(providers):** `reasoning_effort` now learns the accepted values from a provider's own 400/422 response and clamps to the highest one instead of forwarding an unsupported `xhigh`/`max` (or a hardcoded `"high"` fallback) — fixes custom OpenAI-compatible connections and registered providers with no reasoning metadata ([#11116](https://github.com/diegosouzapw/OmniRoute/pull/11116)) — thanks @maxmad64bis
diff --git a/changelog.d/fixes/11144-responses-parallel-tool-calls-index.md b/changelog.d/fixes/11144-responses-parallel-tool-calls-index.md
new file mode 100644
index 000000000000..35ba19b27960
--- /dev/null
+++ b/changelog.d/fixes/11144-responses-parallel-tool-calls-index.md
@@ -0,0 +1 @@
+- **fix(sse):** parallel `function_call` items in a Responses API stream (e.g. several tool calls dispatched in the same turn) now each get a stable, distinct `index`/`id` when translated to Chat Completions streaming deltas, instead of colliding on index 0 and tripping strict stream parsers with `Expected 'id' to be a string.` ([#11144](https://github.com/diegosouzapw/OmniRoute/pull/11144))
diff --git a/changelog.d/fixes/11154-provider-registry-node-net-bundle.md b/changelog.d/fixes/11154-provider-registry-node-net-bundle.md
new file mode 100644
index 000000000000..b30d9e139265
--- /dev/null
+++ b/changelog.d/fixes/11154-provider-registry-node-net-bundle.md
@@ -0,0 +1 @@
+- fix(dashboard): keep `open-sse/config/providerRegistry.ts` free of `node:net` so the provider detail client bundle builds again — the host classification moved to a platform-free `src/shared/network/privateHost.ts` with a pure-JS `isIP` equivalent, leaving the #11122 routing behaviour unchanged (#11154)
diff --git a/changelog.d/fixes/11162-combo-create-requires-model.md b/changelog.d/fixes/11162-combo-create-requires-model.md
new file mode 100644
index 000000000000..228e6a9b20f8
--- /dev/null
+++ b/changelog.d/fixes/11162-combo-create-requires-model.md
@@ -0,0 +1 @@
+- **Combo create:** creating a routing combo without any model is now refused (`400`) — the CLI requires `--models`/`--model` on `combo create`, matching the dashboard which already rejected empty combos.
diff --git a/changelog.d/fixes/11165-shared-registry-passthrough-model-lockout.md b/changelog.d/fixes/11165-shared-registry-passthrough-model-lockout.md
new file mode 100644
index 000000000000..eabe67cb09cb
--- /dev/null
+++ b/changelog.d/fixes/11165-shared-registry-passthrough-model-lockout.md
@@ -0,0 +1 @@
+- **fix(resilience):** a missing-model `404` on a provider that declares `passthroughModels: true` in the shared registry (novita, uncloseai, orcarouter and 37 others) now locks out only that model instead of cooling the entire connection — `hasPerModelQuota()` previously read only the open-sse registry and the local/self-hosted families ([#11165](https://github.com/diegosouzapw/OmniRoute/pull/11165)) — thanks @yourspraveen
diff --git a/changelog.d/fixes/8864-uncloseai-noauth.md b/changelog.d/fixes/8864-uncloseai-noauth.md
new file mode 100644
index 000000000000..8a38e6b8363c
--- /dev/null
+++ b/changelog.d/fixes/8864-uncloseai-noauth.md
@@ -0,0 +1 @@
+- fix(dashboard): treat UncloseAI as a no-auth provider so the connect form no longer forces a fake API key (#8864)
diff --git a/changelog.d/fixes/9123-search-provider-local-flag-guard-mismatch.md b/changelog.d/fixes/9123-search-provider-local-flag-guard-mismatch.md
new file mode 100644
index 000000000000..bc51e121036b
--- /dev/null
+++ b/changelog.d/fixes/9123-search-provider-local-flag-guard-mismatch.md
@@ -0,0 +1 @@
+- fix(ssrf): make `getProviderOutboundGuard()` (used for search-provider connection validation, image generation and remote image fetch) honor the local-first default `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` the same way the chat validation guard already does, so a LAN-hosted SearXNG/Brave search provider works with only the LOCAL flag set instead of silently requiring `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` ([#9123](https://github.com/diegosouzapw/OmniRoute/issues/9123)).
\ No newline at end of file
diff --git a/changelog.d/fixes/pending-opencode-empty-rejection-rotation.md b/changelog.d/fixes/pending-opencode-empty-rejection-rotation.md
new file mode 100644
index 000000000000..82a88c5905a7
--- /dev/null
+++ b/changelog.d/fixes/pending-opencode-empty-rejection-rotation.md
@@ -0,0 +1 @@
+- **fix(executors):** OpencodeExecutor rotates (or retries once on a single-account direct path) on upstream 400 empty-body rejections — malformed completion envelopes with no error field were propagated as success and killed client sessions. Bounded +1 attempt per request; body reads are conditioned on status 400 so successful/streaming responses are never buffered. 400s carrying an error field keep propagating immediately.
diff --git a/changelog.d/fixes/release-v3850-basereds-tests-i18n.md b/changelog.d/fixes/release-v3850-basereds-tests-i18n.md
new file mode 100644
index 000000000000..3a6dee61b9cd
--- /dev/null
+++ b/changelog.d/fixes/release-v3850-basereds-tests-i18n.md
@@ -0,0 +1 @@
+- fix(i18n): complete Vietnamese translations for recently added UI strings (#9985)
diff --git a/changelog.d/fixes/release-v3850-basereds.md b/changelog.d/fixes/release-v3850-basereds.md
new file mode 100644
index 000000000000..30444ba7064b
--- /dev/null
+++ b/changelog.d/fixes/release-v3850-basereds.md
@@ -0,0 +1,3 @@
+- fix(api): repair broken `@/lib/db/connections` import in the usage utilization route that failed the production build (#10939 follow-up)
+- chore(docs): regenerate PROVIDER_REFERENCE and refresh README diagram SVGs to the real provider count (347)
+- chore(lint): prune ESLint suppressions orphaned on the release branch
diff --git a/changelog.d/maintenance/10906-critical-db-state-assertions.md b/changelog.d/maintenance/10906-critical-db-state-assertions.md
new file mode 100644
index 000000000000..c63f5f0039a1
--- /dev/null
+++ b/changelog.d/maintenance/10906-critical-db-state-assertions.md
@@ -0,0 +1 @@
+- **test(db):** replace three empty `test.skip` placeholders in the critical DB-state suite with real assertions — `resetDbInstance` must swap the singleton while the on-disk row survives, the on-disk DB must open in WAL journal mode, and `db_meta` must hold the seeded `schema_version` — so a regression in any of those invariants can no longer pass as silently green ([#10906](https://github.com/diegosouzapw/OmniRoute/pull/10906))
diff --git a/changelog.d/maintenance/11038-filesize-baseline-fix.md b/changelog.d/maintenance/11038-filesize-baseline-fix.md
new file mode 100644
index 000000000000..aebc2b9e2349
--- /dev/null
+++ b/changelog.d/maintenance/11038-filesize-baseline-fix.md
@@ -0,0 +1 @@
+- fix(quality): rebaseline file-size for modelCapabilities.ts (1016->1072) drift from merged tip fixes (#11034 et al)
diff --git a/changelog.d/maintenance/11053-stryker-oauth-autoimport-registration.md b/changelog.d/maintenance/11053-stryker-oauth-autoimport-registration.md
new file mode 100644
index 000000000000..b2e214190099
--- /dev/null
+++ b/changelog.d/maintenance/11053-stryker-oauth-autoimport-registration.md
@@ -0,0 +1 @@
+- fix(quality): register `tests/unit/authz/oauth-autoimport-local-only.test.ts` in stryker `tap.testFiles` (residual of #11053)
diff --git a/changelog.d/maintenance/11160-drain-v3850-basereds-docs-counts-orphan-test.md b/changelog.d/maintenance/11160-drain-v3850-basereds-docs-counts-orphan-test.md
new file mode 100644
index 000000000000..e695a2b8fc0b
--- /dev/null
+++ b/changelog.d/maintenance/11160-drain-v3850-basereds-docs-counts-orphan-test.md
@@ -0,0 +1 @@
+- chore(quality): drain two `release/v3.8.50` base-reds — refresh the drifted doc counts (159 migrations, 56 free-forever providers, 40 free-tier pools, incl. the 42 `llm.txt` locale mirrors) and move `uncloseai-noauth.test.ts` to a collected path so the UncloseAI no-auth regression guard actually runs (#11160)
diff --git a/changelog.d/maintenance/vi-harimport-parity.md b/changelog.d/maintenance/vi-harimport-parity.md
new file mode 100644
index 000000000000..b08b8dc92f7d
--- /dev/null
+++ b/changelog.d/maintenance/vi-harimport-parity.md
@@ -0,0 +1 @@
+- fix(i18n): translate the 14 `providers.harImport*` keys into Vietnamese (parity gap left by #11069)
diff --git a/config/quality/dashboard-typecheck-baseline.json b/config/quality/dashboard-typecheck-baseline.json
index b97762d325e8..b596060a8ef0 100644
--- a/config/quality/dashboard-typecheck-baseline.json
+++ b/config/quality/dashboard-typecheck-baseline.json
@@ -1,9 +1,6 @@
{
- "open-sse/services/payloadRules.ts": {
- "TS2677": 1
- },
"src/app/(dashboard)/dashboard/HomePageClient.tsx": {
- "TS2339": 16
+ "TS2339": 10
},
"src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx": {
"TS2503": 3
@@ -120,10 +117,6 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx": {
"TS2741": 1
},
- "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": {
- "TS2345": 3,
- "TS2322": 1
- },
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": {
"TS2322": 2
},
@@ -141,12 +134,6 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": {
"TS2503": 1
},
- "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
- "TS2322": 1
- },
- "src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts": {
- "TS2339": 1
- },
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts": {
"TS2339": 15
},
@@ -190,9 +177,6 @@
"src/lib/combos/builderDraft.ts": {
"TS2741": 1
},
- "src/lib/providers/codexFastTier.ts": {
- "TS2367": 1
- },
"src/lib/services/htmlRewriter.ts": {
"TS2322": 2,
"TS2345": 2
@@ -219,14 +203,7 @@
"src/shared/hooks/useElectron.ts": {
"TS2339": 19
},
- "src/shared/providers/webSessionCredentials.ts": {
- "TS2353": 1,
- "TS2322": 1
- },
"src/shared/schemas/cliCatalog.ts": {
"TS2554": 2
- },
- "src/shared/services/opencodeConfig.ts": {
- "TS2345": 1
}
}
diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json
index 2e7fa58dacd9..2eb468e16c6d 100644
--- a/config/quality/file-size-baseline.json
+++ b/config/quality/file-size-baseline.json
@@ -1,5 +1,6 @@
{
"_rebaseline_2026_08_20_10531_freebuff_provider": "PR #10531 (adrianaryaputra, feat/freebuff-provider-support, closes #6793) own growth: src/shared/constants/providers/apikey/gateways.ts 1283->1298 (+15, the freebuff APIKEY_PROVIDERS_GATEWAYS catalog entry, additive data at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines) and src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx 1062->1067 (+5, freebuff credential placeholder/hint at the existing per-provider switch chokepoint). Covered by tests/unit/freebuff-provider.test.ts (9/9 passing).",
+ "_rebaseline_2026_08_21_10987_logfare_provider": "PR #10987 (jonlwheat2-gif, feat/10644-logfare-provider, closes #10644) own growth: src/shared/constants/providers/apikey/gateways.ts 1298->1321 (+23, the logfare APIKEY_PROVIDERS_GATEWAYS catalog entry with Free badge/freeNote/apiHint documenting the request-logging policy, additive data at the existing registry chokepoint, same god-file no-split rationale as the prior gateways.ts rebaselines: #10531 freebuff, merge-storm 2026-08-11). Covered by tests/unit/logfare-registry.test.ts (1/1 passing).",
"_rebaseline_2026_08_20_10574_reasoning_transport_fallback": "PR #10574 (jackjinke, fix/responses-reasoning-transport, fixes #10550) own growth: src/sse/handlers/chatHelpers.ts 1017->1019 (+2 = the new reasoningTransportFallback option threaded through executeChatWithBreaker's options destructure and its downstream handleSingleModel call, at the existing per-attempt options-passthrough chokepoint; not extractable without splitting the option-forwarding call itself). Covered by the PR's own reasoning-policy test suite (tests/unit/chatcore-translation-paths.test.ts, tests/unit/combo-attempt-body-isolation-7847.test.ts, tests/unit/reasoning-cache.test.ts, tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts among others), 446/446 focused tests passing.",
"_rebaseline_2026_08_18_10517_zed_hosted_oauth_callback_port": "PR #10517 (phatchau036, fix/zed-hosted-oauth-callback-port) own growth: src/shared/components/OAuthModal.tsx 1131->1148 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 1134->1149, +15/+18, crosses the frozen 1134 cap). Wires the zed-hosted native-app callback auto-complete: forceManual gating on isTrueLocalhost for zed-hosted, the loopback-redirect-URI comment block, and the exchangeToken full-URL-as-code branch, all at the existing provider-switch chokepoints this modal already carries growth for (seventh bump: 969->989->993->998->1030->1056->1100->1149; structural shrink tracked in #3501). The actual port-derivation logic lives in src/lib/oauth/providers/zed-hosted.ts (not frozen here) and was hardened during pre-merge review to use the server's own getRuntimePorts() instead of a browser-guessed scheme/port, covered by the new tests/unit/zed-hosted-loopback-port-derivation.test.ts (8/8 passing).",
"_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).",
@@ -388,6 +389,10 @@
"open-sse/services/claudeCodeCompatible.ts": 1563,
"open-sse/services/combo.ts": 4742,
"open-sse/services/compression/strategySelector.ts": 1379,
+ "open-sse/services/compression/engines/ccr/index.ts": 1024,
+ "_rebaseline_2026_08_22_11084_ccr_caller_gate": "PR #11084 (HouMinXi) own growth: open-sse/services/compression/engines/ccr/index.ts 1000->1024 (first listing — the engine was unlisted and drifted just over the 1000 cap; +24 are the callerSupportsCcrRetrieve gate that skips replacement entirely for callers without the retrieve tool, closing the stranded-prompt incident measured in production). Covered by tests/unit/compression/ccr-non-mcp-full-prompt-loss-7746.test.ts. Owner pre-authorized baseline bumps 2026-08-22.",
+ "open-sse/services/contextManager.ts": 1001,
+ "_rebaseline_2026_08_22_11113_purify_system_first": "PR #11113 (ggdayup) own growth: open-sse/services/contextManager.ts 1000->1001 (+1, purifyHistory merges the compression notice into the leading system message instead of splicing a second one mid-array — live-confirmed TokenRouter 400s; the +1 is the merge-into-leading branch, not extractable). Covered by tests/unit/context-manager-purify-system-first.test.ts. Owner pre-authorized baseline bumps 2026-08-22.",
"open-sse/services/rateLimitManager.ts": 1517,
"open-sse/translator/response/openai-responses.ts": 1652,
"open-sse/utils/cursorAgentProtobuf.ts": 1956,
@@ -443,21 +448,26 @@
"src/shared/components/ModelSelectModal.tsx": 1138,
"src/shared/constants/providers/apikey/gateways.ts": 1250
},
- "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1067,
+ "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1082,
+ "_rebaseline_2026_08_22_11156_enter_check_disabled": "PR #11156 (rqzbeh) own growth: AddApiKeyModal.tsx 1080->1082 (+2, Enter keydown handler now mirrors the isCheckDisabled condition — owner-requested post-merge polish from #11056; the rest of the diff is Prettier reflow). Covered by tests/unit/ui/add-api-key-modal-enter-key.test.tsx (jsdom render test, Enter dispatch assertions).",
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051,
"src/shared/components/ModelSelectModal.tsx": 1138,
- "src/shared/constants/providers/apikey/gateways.ts": 1298,
+ "src/shared/constants/providers/apikey/gateways.ts": 1321,
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387,
"_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).",
- "src/lib/modelCapabilities.ts": 1016,
+ "src/lib/modelCapabilities.ts": 1072,
+ "_rebaseline_2026_08_21_11034_effort_variants": "DRIFT do tip (base-red #9985): modelCapabilities.ts 1016->1072 (+56) acumulado por PRs ja mergeadas no release/v3.8.50 — principalmente #11034 (resolve effort-variant capabilities a partir do modelo base), alem de #10963/#11040/#10987 growth dos catalogos. Tip puro ficou vermelho neste gate; rebaseline no tip por push direto (owner pre-autorizou crescimento legitimo). Nao tocou no arquivo da #11038.",
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014,
"open-sse/config/imageRegistry.ts": 1034,
"src/sse/handlers/chatHelpers.ts": 1019,
- "src/shared/middleware/chatBodyAdmission.ts": 1005,
+ "src/shared/middleware/chatBodyAdmission.ts": 1009,
+ "_rebaseline_2026_08_22_11020_sigterm_drain": "PR #11020 (RaviTharuma) own growth: chatBodyAdmission.ts 1005->1009 (+4, heavyweight admission leases now increment the SIGTERM drain counter and releaseChatAdmissionWhenDone holds it for the SSE lifetime — closes #11015; +4 are the lease/drain wiring lines at the existing admission chokepoint). Covered by tests/unit/chat-body-admission.test.ts heavyweight-lease cases. Owner pre-authorized baseline bumps 2026-08-22.",
"_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file).",
- "open-sse/executors/commandCode.ts": 1038,
+ "open-sse/executors/commandCode.ts": 1059,
"_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).",
- "_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts."
+ "_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.",
+ "_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming).",
+ "_rebaseline_2026_08_21_11069_m365_har_import": "#11069 own growth: AddApiKeyModal.tsx 1073->1080 (+7 = Import .har file button for the copilot-m365-web credential modal — M365 is the only provider whose credential (access_token+chathubPath) must be extracted from a DevTools HAR WebSocket URL, added as a new modal affordance). Cohesive UI at the existing modal chokepoint; not extractable. Covered by tests/unit/m365-har-import*.test.ts."
},
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
diff --git a/config/quality/open-sse-typecheck-baseline.json b/config/quality/open-sse-typecheck-baseline.json
index c6de98b41840..753e9286c432 100644
--- a/config/quality/open-sse-typecheck-baseline.json
+++ b/config/quality/open-sse-typecheck-baseline.json
@@ -1,11 +1,4 @@
{
- "open-sse/handlers/chatCore/clientUsageBuffer.ts": {
- "TS2345": 2
- },
- "open-sse/utils/stream.ts": {
- "TS2345": 2,
- "TS2322": 2
- },
"src/lib/guardrails/videoBridgeHelpers.ts": {
"TS2488": 1,
"TS2365": 2,
diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json
index 95e35a5adec2..604a5a8e19da 100644
--- a/config/quality/quality-baseline.json
+++ b/config/quality/quality-baseline.json
@@ -197,10 +197,11 @@
"_rebaseline_2026_08_09_v3850_release_close": "7666 -> 8045 (+379 gzip bytes, +4.9%). Release v3.8.50 close reconciliation measured twice with the real size-limit + @size-limit/file path on tip e0ce95c592. Per-entry measurements remain below their absolute budgets: omniroute.mjs 4380/15000, mcp-server.mjs 1195/5000, nodeRuntimeSupport.mjs 887/8000, reset-password.mjs 1583/6000. The growth accumulated through legitimate CLI/runtime work in this cycle, including global-install ESM alias resolution, Termux cache preparation, and MCP stdio startup hardening; no entrypoint is near its absolute ceiling. The direction:down ratchet stays blocking from this exact measured tip."
},
"openapiBreaking": {
- "value": 0,
+ "value": 4,
"direction": "down",
"dedicatedGate": true,
- "_note": "oasdiff breaking-change gate (Fase 9 Onda 0). Blocks any breaking change vs base spec."
+ "_note": "oasdiff breaking-change gate (Fase 9 Onda 0). Blocks any breaking change vs base spec.",
+ "_rebaseline_2026_08_22_combo_create_min1": "0 -> 4, split 3 own + 1 inherited. Docs-only alignment of components.schemas.ComboCreate with the request contract already enforced by the API since 638fc5fbd (combo create refuses an empty model list) and d5034ea52: `model`/`nodes` were phantom properties the server never accepted, and `models` (array, minItems 1) is the real required field. OWN findings (3, caused by this commit): removed `model`, removed `nodes`, added required `models` on POST /api/combos — spec-vs-server drift, not client-facing breakage, no working client could have relied on the removed shapes. INHERITED finding (1, NOT caused by this PR's code changes — pre-existing drift already present at parent d5034ea52): PATCH /api/combos/{id} request-body-added-required; that route's patch operation declares its own inline requestBody (required: true, bare object schema, docs/openapi.yaml ~2107-2118) and does not reference ComboCreate, so this finding exists independently of the ComboCreate alignment (same own-growth vs inherited-drift convention as _rebaseline_2026_07_20_aliasresolver_hook_split_7808). No code change in this PR; follow-up tracking = this change's PR description."
},
"mutationScore.src/sse/services/auth.ts": {
"value": 52.57,
diff --git a/docs/architecture/RESILIENCE_GUIDE.md b/docs/architecture/RESILIENCE_GUIDE.md
index 030f65dd4141..0048761e6093 100644
--- a/docs/architecture/RESILIENCE_GUIDE.md
+++ b/docs/architecture/RESILIENCE_GUIDE.md
@@ -448,14 +448,14 @@ classification rules pick the fallback `reason` and lock `scope`
Classification rules only see full error **text** (needed to match body
markers like `额度不足`) for providers listed in the `FULL_TEXT_RULE_PROVIDERS`
allowlist in `providerErrorRules.ts` — currently only `"agentrouter"`. For
-every other provider, `checkFallbackError` hands `getProviderErrorRuleMatch`
-only the structured error (`{code, type}`), which is enough for
-header/status/code-based rules but blind to body-text markers. The helper
-`resolveRuleMatchBody()` performs this selection: full error text for
-allowlisted providers, the structured error otherwise. Adding a provider to
-`FULL_TEXT_RULE_PROVIDERS` is an explicit per-provider opt-in — it exists so
-that the default path for every provider not on the list stays
-byte-for-byte unchanged.
+every other **built-in catalog** provider, `checkFallbackError` hands
+`getProviderErrorRuleMatch` only the structured error (`{code, type}`), which
+is enough for header/status/code-based rules but blind to body-text markers.
+The helper `resolveRuleMatchBody()` performs this selection: full error text
+for allowlisted providers, the structured error otherwise. Adding a
+**built-in** provider to `FULL_TEXT_RULE_PROVIDERS` is an explicit per-provider
+opt-in — it exists so that the default path for every provider not on the
+list stays byte-for-byte unchanged.
A rule's `scope` (`model` / `provider` / `connection`) is a separate opt-in
from `FULL_TEXT_RULE_PROVIDERS`: `checkFallbackError` only surfaces it as
@@ -466,6 +466,31 @@ honorsRuleLockScope()` — today only `"agentrouter"`). See "Restated quota
errors" above for what a `scope: "connection"` match actually does once a
provider is on that allowlist.
+**#11104 — operator-declared rules bypass both allowlists.** An operator can
+declare a per-provider rule at runtime via `settings.providerErrorRules`
+(`open-sse/config/providerErrorRules.ts::setOperatorProviderErrorRules`)
+without editing this file. Gating an operator rule behind
+`FULL_TEXT_RULE_PROVIDERS`/`HONORS_RULE_LOCK_SCOPE_PROVIDERS` — allowlists
+meant to protect the **default** behavior of built-in catalog rules — would
+make the settings mechanism inert for every provider except the ones already
+listed there, since declaring the rule is already the operator's explicit
+opt-in. `resolveRuleMatchBody()` and `honorsRuleLockScope()` both check
+`hasOperatorRuleForProvider()` first: a provider with an operator rule gets
+the raw error text and has its declared `scope` honored, regardless of
+whether it also appears in either allowlist.
+
+**Known gap — `providerRuleRegistry` is never consulted for HTTP 400.**
+`checkFallbackError`'s `BAD_REQUEST` branch classifies status 400 entirely
+through its own pattern arrays (`MODEL_ACCESS_DENIED_PATTERNS`,
+`CONTEXT_OVERFLOW_PATTERNS`, etc. in `accountFallback.ts`) and returns before
+the `configuredRule`/`getProviderErrorRuleMatch` branch above it is reached.
+A built-in catalog rule (or an operator rule) with `status: 400` is
+syntactically valid but will never fire. No existing rule targets 400 today,
+so nothing in production is affected — but a future 400 rule needs this
+branch touched first, which is a larger change than adding a rule (it
+reclassifies 400 for every provider already relying on the pattern-array
+behavior) and is out of scope for a single-provider rule addition.
+
### Adding a new quota-misstating gateway
1. Register one rule array in `statusRestatementRegistry`
diff --git a/docs/changelog/fragments/10962.md b/docs/changelog/fragments/10962.md
new file mode 100644
index 000000000000..5170a415e3f1
--- /dev/null
+++ b/docs/changelog/fragments/10962.md
@@ -0,0 +1 @@
+fix(catalog): expose only provider-routable GLM reasoning-effort tiers and remove unroutable ZCode aliases
diff --git a/docs/diagrams/free-tier-budget.svg b/docs/diagrams/free-tier-budget.svg
index e4503b5534e7..393ee005941d 100644
--- a/docs/diagrams/free-tier-budget.svg
+++ b/docs/diagrams/free-tier-budget.svg
@@ -1,4 +1,4 @@
-
+
@@ -63,7 +63,7 @@
~1.51B
FREE TOKENS / MONTH · STEADY
up to ~2.13B in your first month — signup credits
- documented free tiers · 41 provider pools · 495 models · one endpoint
+ documented free tiers · 40 provider pools · 495 models · one endpoint
diff --git a/docs/diagrams/promise-pillars.svg b/docs/diagrams/promise-pillars.svg
index 761df36f2e88..99b7f36b15a4 100644
--- a/docs/diagrams/promise-pillars.svg
+++ b/docs/diagrams/promise-pillars.svg
@@ -1,4 +1,4 @@
-
+
Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle.
diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md
index 61d414de2720..b24e7b7f6109 100644
--- a/docs/guides/DOCKER_GUIDE.md
+++ b/docs/guides/DOCKER_GUIDE.md
@@ -505,7 +505,7 @@ Stock Docker / Kubernetes OmniRoute is **one Node process + one SQLite writer**.
| Constraint | Consequence |
| --- | --- |
| Single writer | Do **not** run multiple replicas against the same SQLite file. That corrupts the DB. |
-| Recreate / restart / HEALTHCHECK kill | **Full outage** of in-flight SSE, dashboard sessions, and in-memory state. Every connected client drops. |
+| Recreate / restart / HEALTHCHECK kill | **Full outage** of in-flight SSE, dashboard sessions, and in-memory state. Every connected client drops. New requests during the empty-endpoint window get a reverse-proxy **`502 Bad Gateway: Unknown error`**, not OmniRoute JSON — clients cannot distinguish this from a provider failure (#11015). |
| Same event loop as `/healthz` | A busy catalog or compression tick can delay probes; a short timeout then restarts the **only** replica. |
**Probe matrix** (see also [Kubernetes probe recommendations](../ops/MONITORING_GUIDE.md#kubernetes-probe-recommendations)):
@@ -518,6 +518,35 @@ Stock Docker / Kubernetes OmniRoute is **one Node process + one SQLite writer**.
**Upgrades:** expect every session to drop. Drain clients if you can; there is no rolling update on default SQLite. Compose `restart: unless-stopped` plus Docker `HEALTHCHECK` will also replace the only process when the container is Unhealthy — same blast radius.
+Kubernetes snippet for a **single replica** (Recreate is required; do not raise `replicas` against one SQLite file):
+
+```yaml
+spec:
+ replicas: 1
+ strategy:
+ type: Recreate
+ template:
+ spec:
+ terminationGracePeriodSeconds: 90
+ containers:
+ - name: omniroute
+ lifecycle:
+ preStop:
+ exec:
+ command: ["/bin/sleep", "15"]
+ readinessProbe:
+ httpGet:
+ path: /healthz
+ port: 20128
+ periodSeconds: 5
+ livenessProbe:
+ tcpSocket:
+ port: 20128
+ periodSeconds: 20
+```
+
+`preStop` sleep lets kube drop Service endpoints before SIGTERM so **new** traffic stops hitting the dying process. In-flight `/v1/responses` SSE is drained up to `SHUTDOWN_TIMEOUT_MS` (default 30s) via heavyweight admission leases (#11015). New requests that still reach the process get `503` + `Retry-After: 5`. The Recreate empty-endpoint gap until the replacement is Ready remains a hard outage — that is the SQLite topology, not a probe misconfig.
+
External Postgres / multi-writer HA is **not** a documented stock path. If you need HA, keep a single replica or run a topology the project has tested and documented separately. The Postgres/MySQL work lives in [#8075](https://github.com/diegosouzapw/OmniRoute/issues/8075). Until that ships, the only supported way to multiply **large** `/v1/responses` capacity is N independent processes (next section), not `replicas > 1` on one volume.
## Scale-out: N independent processes
diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt
index 4bbfed2b1a93..188e4c546fd8 100644
--- a/docs/i18n/ar/llm.txt
+++ b/docs/i18n/ar/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt
index 22c327b815ff..1778044af165 100644
--- a/docs/i18n/az/llm.txt
+++ b/docs/i18n/az/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt
index 22c327b815ff..1778044af165 100644
--- a/docs/i18n/bg/llm.txt
+++ b/docs/i18n/bg/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt
index 5435e521e9fa..e1f037d66984 100644
--- a/docs/i18n/bn/llm.txt
+++ b/docs/i18n/bn/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt
index db22c036bc1e..c181079187f7 100644
--- a/docs/i18n/cs/llm.txt
+++ b/docs/i18n/cs/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt
index b0b593980fd6..fea53e17b2ac 100644
--- a/docs/i18n/da/llm.txt
+++ b/docs/i18n/da/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt
index 118c84fed59a..494914668a98 100644
--- a/docs/i18n/de/llm.txt
+++ b/docs/i18n/de/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt
index c9e3f0f3b4d6..6dfe5a95b36c 100644
--- a/docs/i18n/es/llm.txt
+++ b/docs/i18n/es/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt
index b222f977adf5..55c6e845de37 100644
--- a/docs/i18n/fa/llm.txt
+++ b/docs/i18n/fa/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt
index ee6328847ce9..ca8eb2a75b3b 100644
--- a/docs/i18n/fi/llm.txt
+++ b/docs/i18n/fi/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt
index b981f381ff04..6fc229f45d5a 100644
--- a/docs/i18n/fr/llm.txt
+++ b/docs/i18n/fr/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt
index a5ffba4c86fc..0ef4d516e711 100644
--- a/docs/i18n/gu/llm.txt
+++ b/docs/i18n/gu/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt
index 97b10b910bf5..e3d3b77ab8ed 100644
--- a/docs/i18n/he/llm.txt
+++ b/docs/i18n/he/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt
index 13cf7a5e14c5..f92757a9f3f1 100644
--- a/docs/i18n/hi/llm.txt
+++ b/docs/i18n/hi/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt
index 12d55db0aa44..e3ba1fa0a65d 100644
--- a/docs/i18n/hu/llm.txt
+++ b/docs/i18n/hu/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt
index bafb1ea87667..5a169f9e8627 100644
--- a/docs/i18n/id/llm.txt
+++ b/docs/i18n/id/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/in/llm.txt b/docs/i18n/in/llm.txt
index 416ca93e85a7..e0b18cb0baad 100644
--- a/docs/i18n/in/llm.txt
+++ b/docs/i18n/in/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt
index 7c155d5b8a64..dbde86f284ba 100644
--- a/docs/i18n/it/llm.txt
+++ b/docs/i18n/it/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt
index 8f3361f565f6..4bae4264258b 100644
--- a/docs/i18n/ja/llm.txt
+++ b/docs/i18n/ja/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt
index 882a73a3fc11..a2310374798c 100644
--- a/docs/i18n/ko/llm.txt
+++ b/docs/i18n/ko/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt
index 5a5a15aafe18..d40eb3779760 100644
--- a/docs/i18n/mr/llm.txt
+++ b/docs/i18n/mr/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt
index 9dca2bead653..cdf457c441ef 100644
--- a/docs/i18n/ms/llm.txt
+++ b/docs/i18n/ms/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt
index 7c8a64b955de..b2539aceb9ed 100644
--- a/docs/i18n/nl/llm.txt
+++ b/docs/i18n/nl/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt
index 00721607ea4c..9b4dedc12741 100644
--- a/docs/i18n/no/llm.txt
+++ b/docs/i18n/no/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt
index cbda0932809b..d9b674c08eb9 100644
--- a/docs/i18n/phi/llm.txt
+++ b/docs/i18n/phi/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt
index 8266f33a9d2a..4c43c03d3600 100644
--- a/docs/i18n/pl/llm.txt
+++ b/docs/i18n/pl/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt
index 53dbfb7774df..ecbeb9ad4c8d 100644
--- a/docs/i18n/pt-BR/llm.txt
+++ b/docs/i18n/pt-BR/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt
index a45d3424ffbe..7cd77fdd4a61 100644
--- a/docs/i18n/pt/llm.txt
+++ b/docs/i18n/pt/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt
index 696627ccd79b..a4f03e94e7aa 100644
--- a/docs/i18n/ro/llm.txt
+++ b/docs/i18n/ro/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt
index ce054cd01e91..8fe9b5bdb687 100644
--- a/docs/i18n/ru/llm.txt
+++ b/docs/i18n/ru/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt
index 099b49301263..c5a67d9242e2 100644
--- a/docs/i18n/sk/llm.txt
+++ b/docs/i18n/sk/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt
index f59efc12e934..3b7b7b674ea8 100644
--- a/docs/i18n/sv/llm.txt
+++ b/docs/i18n/sv/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt
index 35f14edd40f5..4c7bec4311b3 100644
--- a/docs/i18n/sw/llm.txt
+++ b/docs/i18n/sw/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt
index 76627c0648e3..5323983d3150 100644
--- a/docs/i18n/ta/llm.txt
+++ b/docs/i18n/ta/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt
index fe0c9aa843f5..ce196f99275f 100644
--- a/docs/i18n/te/llm.txt
+++ b/docs/i18n/te/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt
index 5686499ba266..39eb9c04ff88 100644
--- a/docs/i18n/th/llm.txt
+++ b/docs/i18n/th/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt
index 2add1f0955b2..7f848b6cdd65 100644
--- a/docs/i18n/tr/llm.txt
+++ b/docs/i18n/tr/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt
index 29eda2adb0d2..bca18382ad25 100644
--- a/docs/i18n/uk-UA/llm.txt
+++ b/docs/i18n/uk-UA/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt
index 76473b3d266f..85369bee2e9a 100644
--- a/docs/i18n/ur/llm.txt
+++ b/docs/i18n/ur/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt
index 9d491c439d38..da6b9b7179a1 100644
--- a/docs/i18n/vi/llm.txt
+++ b/docs/i18n/vi/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt
index 49e8eb4d4821..0c737e5dc432 100644
--- a/docs/i18n/zh-CN/llm.txt
+++ b/docs/i18n/zh-CN/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt
index 861c1e655159..643943547df8 100644
--- a/docs/i18n/zh-TW/llm.txt
+++ b/docs/i18n/zh-TW/llm.txt
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index e51b6091aeac..73941f37ead8 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -8893,12 +8893,20 @@ components:
ComboCreate:
type: object
- required: [name, model]
+ required: [name, models]
properties:
name:
type: string
- model:
- type: string
+ models:
+ type: array
+ minItems: 1
+ items:
+ oneOf:
+ - type: string
+ description: "provider/model reference"
+ - type: object
+ description: "structured combo step (provider, model, weight, ...)"
+ additionalProperties: true
strategy:
type: string
enum:
@@ -8920,14 +8928,3 @@ components:
- context-optimized
- fusion
default: priority
- nodes:
- type: array
- items:
- type: object
- properties:
- connectionId:
- type: string
- weight:
- type: integer
- priority:
- type: integer
diff --git a/docs/ops/REDIS_PRODUCTION_CONFIG.md b/docs/ops/REDIS_PRODUCTION_CONFIG.md
index e8749c7bbcdb..ada9ad445672 100644
--- a/docs/ops/REDIS_PRODUCTION_CONFIG.md
+++ b/docs/ops/REDIS_PRODUCTION_CONFIG.md
@@ -14,9 +14,12 @@ workloads:
| Workload | Driver | Client Factory | Key Pattern |
|---|---|---|---|
-| Rate limiting | `rateLimiter.ts` | `getRedisClient()` — lazy `ioredis` singleton | Lua‑atomic rate limit windows |
-| Auth cache | `apiKeys.ts` | Reuses `rateLimiter`'s client | `auth:api_key:` with TTL |
-| Quota store | `redisQuotaStore.ts` | Separate `getRedisClient(url)` singleton | Configurable per-instance |
+| Rate limiting | `rateLimiter.ts` | `getRedisClient()` — lazy `ioredis` singleton | `rl:*` Lua‑atomic rate limit windows |
+| Auth cache | `apiKeys.ts` | Reuses `rateLimiter`'s client | `auth:api_key:` with TTL |
+| Quota store | `redisQuotaStore.ts` | Separate `getRedisClient(url)` singleton | `quota:*` configurable per-instance |
+
+All three workloads share one namespace prefix so OmniRoute can co-exist with other apps on a
+single Redis instance (e.g. `127.0.0.1:6379`). See [Key Namespacing](#key-namespacing).
---
@@ -25,6 +28,7 @@ workloads:
| Setting | Value | Where |
|---|---|---|
| `REDIS_URL` env var | `redis://redis:6379` (compose), optional | `rateLimiter.ts:5`, `.env.example` |
+| `REDIS_KEY_PREFIX` env var | `omniroute:` (default) | `rateLimiter.ts`, `redisQuotaStore.ts`, `.env.example` |
| `QUOTA_STORE_REDIS_URL` env var | separate, can differ from `REDIS_URL` | `quota/storeFactory.ts` |
| `QUOTA_STORE_DRIVER` | `"sqlite"` (default), `"redis"` optional | `quota/storeFactory.ts` |
| ioredis `maxRetriesPerRequest` | `3` | `rateLimiter.ts` client creation |
@@ -36,6 +40,29 @@ workloads:
---
+## Key Namespacing
+
+OmniRoute shares a Redis instance with whatever else runs on the host. Without a namespace,
+keys like `auth:api_key:` or `rl:*` could collide with keys from other applications
+using the same Redis (this instance runs Redis on `127.0.0.1:6379` alongside other services).
+
+Set `REDIS_KEY_PREFIX` to a non-empty string to prefix **every** OmniRoute key:
+
+```bash
+# .env — all OmniRoute keys become omniroute:rl:*, omniroute:auth:*, omniroute:quota:*
+REDIS_KEY_PREFIX=omniroute:
+```
+
+- **Default:** `omniroute:` (applied when `REDIS_KEY_PREFIX` is unset or blank).
+- **Applied to:** rate limiter + auth cache (shared `ioredis` client via `keyPrefix`) and the
+ quota store (`KEY_PREFIX = "${REDIS_KEY_PREFIX}quota"`).
+- **Changing the prefix** when keys already exist in Redis orphans the old keys (they expire
+ via TTL / LRU). Safe to change; no migration needed.
+- **ioredis `keyPrefix`** automatically prepends the prefix on writes **and** strips it on reads,
+ so application code never sees the prefix.
+
+---
+
## Recommended Production Tuning
### 1. Connection Pool / Client Options (ioredis `Redis` constructor)
diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md
index 359ae4750eca..1b71551e373f 100644
--- a/docs/reference/API_REFERENCE.md
+++ b/docs/reference/API_REFERENCE.md
@@ -636,6 +636,49 @@ completion.
---
+## Self-service usage (`/api/usage/om-usage`)
+
+Any API key can read **its own** usage and quotas — no management auth. This is the endpoint a
+client (CLI, the OmniCopilot panel) uses to show a key holder their spend.
+
+```bash
+# Text form (the historical contract — plain text for a terminal)
+curl -H "Authorization: Bearer " \
+ http://localhost:20128/api/usage/om-usage
+
+# Structured form — what a UI consumes
+curl -H "Authorization: Bearer " \
+ "http://localhost:20128/api/usage/om-usage?format=json"
+```
+
+The key must have **`allowUsageCommand`** enabled (off by default — the dashboard's API-key
+manager toggles it per key). Without it the endpoint answers `403`.
+
+`?format=json` returns a discriminated shape so a caller never reads a data field off a
+refusal. On success:
+
+```jsonc
+{
+ "allowed": true,
+ // present only when the key opted into per-key usage limits (daily/weekly USD):
+ "personal": { "dailySpentUsd": 1.25, "dailyLimitUsd": 5, "dailyResetAtIso": "…", "weeklySpentUsd": 8, "weeklyLimitUsd": 20, "weeklyResetAtIso": "…" /* … */ },
+ // the selected provider quota snapshot, or null when nothing is cached yet:
+ "provider": { "connectionId": "…", "provider": "claude", "plan": "…", "quotas": { /* … */ } },
+ // every connection's snapshot, so a UI can render several providers side by side:
+ "providers": [ { "connectionId": "…", "provider": "claude", /* … */ }, { "provider": "codex", /* … */ } ]
+}
+```
+
+On refusal (`401` bad key / `403` not allowed) the same route returns
+`{ "allowed": false, "error": { "message": "…" } }` — a present-but-empty `personal`/`provider`
+(key allowed, nothing learned yet) is a different state from a refusal, and only the JSON form
+distinguishes them.
+
+**Auth:** the caller's own Bearer API key, validated with `isValidApiKey` — this is *not* the
+management surface (`/api/keys/…`), which stays behind `requireManagementAuth`.
+
+---
+
## Semantic Cache
```bash
diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md
index b3c12467f8cf..ae06e6209059 100644
--- a/docs/reference/ENVIRONMENT.md
+++ b/docs/reference/ENVIRONMENT.md
@@ -1343,6 +1343,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `OMNIROUTE_REDIS_BIND_HOST` | `127.0.0.1` | `bin/cli/commands/redis.mjs` | Host interface the 1-click Redis launcher publishes on. The launcher starts Redis WITHOUT a password, so binding `0.0.0.0` hands every host on your LAN an unauthenticated Redis — only widen this if you also set a password on the instance yourself. |
| `REDIS_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Redis sidecar on (#9286). The compose Redis runs without `requirepass`; app containers reach it over the compose network (`redis:6379`) — the published port exists only for host-side tooling. `0.0.0.0` exposes an unauthenticated Redis to the whole LAN. |
| `REDIS_PORT` | `6379` | `docker-compose.yml` | Host port for the compose Redis sidecar. |
+| `REDIS_KEY_PREFIX` | `omniroute:` | `src/shared/utils/rateLimiter.ts` | Namespace prefix applied to every OmniRoute Redis key (rate limiter, auth cache, quota store). Prevents key collisions when the Redis instance is shared with other apps (#11042). |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | _(unset — mechanism disabled)_ | `src/lib/api/internalServiceAuth.ts` | Shared secret for identity-preserving internal REST hops (#9260): OmniRoute components calling other local OmniRoute routes send it as `x-omniroute-internal-service-token` so the original caller identity is preserved. Compared with `timingSafeEqual`. |
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | _(unset)_ | `src/lib/api/internalServiceAuth.ts` | Secret-file variant of the internal service token: path to a file whose trimmed content is the token. Only consulted when the inline var is unset. |
| `OPENROUTER_PROVIDER_STATS_ENABLED` | `true` | `src/lib/catalog/openrouterProviderStats.ts` | Enrich the dashboard providers list with OpenRouter weekly ranking stats (#9324). On by default; set `false` to skip the background fetch entirely (non-blocking, never fatal). |
diff --git a/docs/reference/FREE_TIERS.md b/docs/reference/FREE_TIERS.md
index a0ff47191595..927dccf72013 100644
--- a/docs/reference/FREE_TIERS.md
+++ b/docs/reference/FREE_TIERS.md
@@ -120,7 +120,6 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve
| `firecrawl` | caution | Cloud API ToS has no explicit personal-proxy prohibition found, but the open-source self-hosted version is AGPL-3.0 (re… |
| `gemini` | caution | ToS explicitly states the free tier is for "developers building with Google AI models for professional or business purp… |
| `groq` | caution | Services Agreement §6.3 prohibits reselling, sublicensing, or distributing API access; §3.2 bars reselling/leasing acco… |
-| `hackclub` | caution | Service is explicitly scoped to Hack Club teen members building projects/learning; no public ToS found explicitly permi… |
| `huggingchat` | caution | Hugging Face ToS does not explicitly ban personal self-hosted proxies, but supplemental terms (referenced but not fully… |
| `huggingface` | caution | ToS grants a limited license to access/use the service; the document does not explicitly permit or forbid a single-user… |
| `hyperbolic` | caution | ToS grants API access "solely for your own personal or internal business purposes" and explicitly prohibits licensing, … |
@@ -222,7 +221,6 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve
| `duckduckgo-web` | keyless | — | — | avoid | 6 |
| `freemodel-dev` | keyless | — | — | unknown | 4 |
| `friendliai` | keyless | — | — | avoid | 2 |
-| `hackclub` | keyless | — | — | caution | 3 |
| `iflytek` | keyless | — | — | avoid | 1 |
| `inference-net` | keyless | — | — | caution | 3 |
| `liquid` | keyless | — | — | unknown | 1 |
@@ -280,7 +278,6 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve
- **`gitlawb`** — The shipped freeNote "Free tier available" is effectively stale. The original free MiMo access was removed in May 2026; the only remaining "free" option is a temporary promotional model (Nemotron 3 U…
- **`gitlawb-gmi`** — Partially still accurate — free tier exists but is now narrowed to a single model (Nemotron 3 Ultra) after MiMo free access was revoked in late May 2026. The shipped note "Free tier available" unders…
- **`groq`** — The shipped freeNote "30 RPM / 14.4K RPD" is accurate only for llama-3.1-8b-instant. Most other models (including llama-3.3-70b-versatile) have a much lower 1K RPD cap. The note omits model-specific …
-- **`hackclub`** — The "30+ models" count appears accurate and still matches. The core offering remains free for Hack Club members. No evidence of tightening — still "$0 ALWAYS FREE" per the homepage. The freeNote omit…
- **`huggingchat`** — The shipped freeNote ("Free LLM chat — no subscription required. Rate limits apply.") is partially accurate but significantly understates the restrictions. The free tier now operates on a hard $0.10/…
- **`huggingface`** — Significantly tightened. The shipped freeNote ("Free Inference API for thousands of models") implied unlimited/generous free access, but as of mid-2025 the free tier is capped at $0.10/month in recur…
- **`hyperbolic`** — Our shipped freeNote says "$1-5 trial credits on signup" — the $1 trial credit portion is accurate, but the "$5" figure refers to the minimum deposit required to unlock GPU rental (not free credits g…
diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md
index d8e32e8da43d..8a0a3b5d99a8 100644
--- a/docs/reference/PROVIDER_REFERENCE.md
+++ b/docs/reference/PROVIDER_REFERENCE.md
@@ -1,14 +1,14 @@
---
title: "Provider Reference"
version: 3.8.50
-lastUpdated: 2026-08-21
+lastUpdated: 2026-08-22
---
# Provider Reference
> **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand.
> Regenerate with: `npm run gen:provider-reference`
-> **Last generated:** 2026-08-21
+> **Last generated:** 2026-08-22
Total providers: **348**. See category breakdown below.
@@ -34,7 +34,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
---
-## No-auth Providers (no key required) (11)
+## No-auth Providers (no key required) (12)
| ID | Alias | Name | Tags | Website | Notes | Tool calling |
|----|-------|------|------|---------|-------|--------------|
@@ -47,6 +47,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `felo-web` | `felo` | Felo | No-auth | [link](https://felo.ai) | No credentials required — Felo is a free, no-signup chat/search aggregator. | — |
| `opencode` | `oc` | OpenCode Free | No-auth | [link](https://opencode.ai) | No API key required — uses OpenCode's public free endpoint. | — |
| `theoldllm` | `tllm` | The Old LLM (Free) | No-auth | [link](https://theoldllm.vercel.app) | No credentials required. The executor auto-generates access tokens via an embedded Playwright browser instance. | — |
+| `uncloseai` | `unc` | UncloseAI | No-auth | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync; verified live model: solidrust/Hermes-3-Llama-3.1-8B-AWQ. | — |
| `veoaifree-web` | `veo-free` | Veo AI Free | No-auth, video | [link](https://veoaifree.com) | No auth required. Rate limited to 6 requests/hour per IP. | — |
| `zcode` | `zc` | ZCode (GLM Coding Plan) | No-auth | [link](https://zcode.z.ai) | No API key stored by OmniRoute. The local ZCode app-server uses the existing builtin:zai-coding-plan login. | — |
@@ -98,7 +99,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `gemini-business` | `gembiz` | Gemini Business (Enterprise) | Web cookie | [link](https://business.gemini.google) | From your enterprise account: open business.gemini.google/home/cid/{your-cid}, then copy __Secure-1PSID and __Secure-1PSIDTS cookies from DevTools → Application → Cookies. Paste as a cookie header below. | — |
| `gemini-web` | `gweb` | Gemini Web (Free) | Web cookie | [link](https://gemini.google.com) | Paste your __Secure-1PSID cookie value from gemini.google.com. Optionally add __Secure-1PSIDTS separated by semicolon. | emulated |
| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste the full grok.com cookie line from DevTools → Application → Cookies. Include both `sso` and `sso-rw` (e.g. `sso=...; sso-rw=...`) — Grok's anti-bot rejects `sso` on its own. | — |
-| `hailuo-web` | `hailuo-web` | Hailuo Web (MiniMax) | Web cookie | [link](https://hailuo.ai) | Open hailuo.ai, log in, then open DevTools → Application → Local Storage → copy the "_token" value. device_id/uuid fingerprint fields are derived automatically; if requests fail, re-capture _token (sessions can expire). | — |
+| `hailuo-web` | `hailuo-web` | Hailuo Web (MiniMax) | Web cookie | [link](https://chat.minimax.io) | Open hailuo.ai, log in, then open DevTools → Application → Local Storage → copy the "_token" value. device_id/uuid fingerprint fields are derived automatically; if requests fail, re-capture _token (sessions can expire). | — |
| `huggingchat` | `huggingchat` | HuggingChat (Free) | Web cookie | [link](https://huggingface.co/chat) | Paste the full Cookie header from huggingface.co/chat (DevTools → Network → /chat/conversation → Request Headers → Cookie). It should include hf-chat and may also include token / aws-waf-token. | — |
| `hyperagent` | `ha` | HyperAgent (Unofficial/Experimental) | Web cookie | [link](https://hyperagent.com) | Paste the full Cookie header from hyperagent.com (DevTools → Network → any request → Request Headers → Cookie). Session cookies power chat + billing usage. | — |
| `inner-ai` | `in-ai` | Inner.ai (Subscription) | Web cookie | [link](https://app.innerai.com) | Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com | emulated |
@@ -120,7 +121,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `zai-web` | `zw` | Z.ai Web | Web cookie | [link](https://chat.z.ai) | Copy the "token" value from chat.z.ai → DevTools → Application → Local Storage. Do not copy cookies; OmniRoute handles the per-request CAPTCHA through its browser transport. | — |
| `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | — |
-## API Key Providers (paid / paid-with-free-credits) (232)
+## API Key Providers (paid / paid-with-free-credits) (231)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
@@ -149,7 +150,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `bazaarlink` | `bzl` | BazaarLink | API key | [link](https://bazaarlink.ai) | Use your BazaarLink API key (starts with sk-bl-) in Authorization: Bearer . OpenAI SDK works with base URL https://bazaarlink.ai/api/v1. Models use provider/model-name format. |
| `bedrock` | `bedrock` | Amazon Bedrock | API key, enterprise | [link](https://aws.amazon.com/bedrock) | Use your Amazon Bedrock API key and configure the AWS region where your models are enabled (for example eu-west-2). OmniRoute calls Bedrock's native Converse API directly. |
| `black-forest-labs` | `bfl` | Black Forest Labs | API key, image | [link](https://blackforestlabs.ai) | — |
-| `blackbox` | `bb` | Blackbox AI | API key | [link](https://blackbox.ai) | Limited free access is available through Blackbox; model availability and account limits apply |
+| `blackbox` | `bb` | Blackbox AI | API key | [link](https://blackbox.ai) | ⚠️ **DEPRECATED.** api.blackbox.ai returns HTTP 404 on every path variant (sweep 2026-08-21); the public inference surface has moved to the gated enterprise.blackbox.ai/v1 endpoint. |
| `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 |
| `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — |
| `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks |
@@ -166,7 +167,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `clova-studio` | `clova` | Naver CLOVA Studio | API key | [link](https://api.ncloud-docs.com/docs/en/ai-naver-clovastudio-summary) | — |
| `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — |
| `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required |
-| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. |
+| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /provider/v1/chat/completions endpoint. |
| `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api |
| `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — |
| `cursor-api` | `cua` | Cursor API | API key | [link](https://cursor.com/dashboard/api) | Paste a Cursor user API key (crsr_...) from cursor.com/dashboard/api. OmniRoute exchanges it for a session token on demand; no IDE or cursor-agent install is needed. Usage bills to the Cursor plan that owns the key. |
@@ -213,7 +214,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — |
| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — |
| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card |
-| `hackclub` | `hc` | Hackclub AI | API key, aggregator | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. |
+| `hackclub` | `hc` | Hackclub AI | API key | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. |
| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api |
| `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn |
| `helixmind` | `helixmind` | HelixMind | API key, aggregator | [link](https://helixmind.online) | Previously circulated 3 RPM/50 RPD and no-card claims were not confirmed during the 2026-08-02 audit; current quota and billing require account verification. |
@@ -331,7 +332,6 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `topaz` | `topaz` | Topaz | API key, image | [link](https://topazlabs.com) | — |
| `typhoon` | `typhoon` | Typhoon | API key | [link](https://docs.opentyphoon.ai) | Free API key with a 5 req/s and 200 req/m rate limit. |
| `udio` | `udio` | Udio | API key | [link](https://udio.com) | Paste session cookie from udio.com (Supabase auth) |
-| `uncloseai` | `unc` | UncloseAI | API key | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync; verified live model: solidrust/Hermes-3-Llama-3.1-8B-AWQ. |
| `unorouter` | `unorouter` | UnoRouter | API key, aggregator | [link](https://unorouter.ai) | Models with the :free suffix do not debit balance; limit is 1 request/minute per free model per user. |
| `upstage` | `upstage` | Upstage | API key | [link](https://www.upstage.ai) | — |
| `v0-vercel` | `v0` | v0 (Vercel) | API key | [link](https://v0.dev) | — |
diff --git a/llm.txt b/llm.txt
index 3deca0171a2d..51feb87bc326 100644
--- a/llm.txt
+++ b/llm.txt
@@ -14,7 +14,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
-- **Database:** SQLite via better-sqlite3 (local, zero-config, 157 migrations)
+- **Database:** SQLite via better-sqlite3 (local, zero-config, 159 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -434,7 +434,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
-5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 157 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
+5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 159 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts
index c45cf542a661..d99da4725b05 100644
--- a/open-sse/config/constants.ts
+++ b/open-sse/config/constants.ts
@@ -171,6 +171,7 @@ export const HTTP_STATUS = {
FORBIDDEN: 403,
NOT_FOUND: 404,
NOT_ACCEPTABLE: 406,
+ UNPROCESSABLE_ENTITY: 422,
REQUEST_TIMEOUT: 408,
GONE: 410,
RATE_LIMITED: 429,
@@ -263,11 +264,17 @@ export const PROVIDER_PROFILES = {
circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS", 30000),
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD", 15), // Scaled for 500+ connections (was 5)
- providerFailureWindowMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS", 1800000), // 30min window (was 20min)
+ providerFailureWindowMs: envInt(
+ "OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS",
+ 1800000
+ ), // 30min window (was 20min)
providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS", 600000), // 10min cooldown when threshold reached
degradationThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD", 7),
maxBackoffMultiplier: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER", 4),
- backoffEscalationCount: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT", 3),
+ backoffEscalationCount: envInt(
+ "OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT",
+ 3
+ ),
},
// Local providers (localhost inference backends like Ollama, LM Studio, oMLX).
// Not yet wired into getProviderProfile() — will be used when local provider_nodes
@@ -348,6 +355,23 @@ export const STREAM_RECOVERY = {
HOLDBACK_MS: 750,
BUFFER_MAX_BYTES: 65536,
EARLY_RETRY_MAX: 4,
+ /**
+ * Minimum character overlap `trimContinuationOverlap` must find between the
+ * already-emitted text and a mid-stream continuation for the continuation to be
+ * accepted as a real resume, rather than an unrelated restart the model produced after
+ * ignoring the assistant-prefill.
+ *
+ * This is a DOCUMENTED TRADE-OFF, not a solved distinction: a model that continues
+ * cleanly with fewer than this many echoed characters (a legitimate, even preferred,
+ * outcome — there was nothing to de-duplicate) is indistinguishable, from string data
+ * alone, from a model that silently restarted on an unrelated sentence. Both produce a
+ * low/zero overlap. Rejecting below this threshold trades some false-positive rejections
+ * of legitimate low-overlap continuations (bounded retry, then a clean close — no data
+ * loss beyond that retry) against not silently gluing two unrelated fragments into one
+ * corrupted, unrecoverable answer. It does not eliminate the residual false negative
+ * either (an accidental coincidence at or above this many characters is still accepted).
+ */
+ MIN_CONTINUATION_OVERLAP_CHARS: 8,
} as const;
/**
diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts
index e6ef081aa7e7..38c17abf2d9a 100644
--- a/open-sse/config/freeModelCatalog.data.ts
+++ b/open-sse/config/freeModelCatalog.data.ts
@@ -194,9 +194,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "groq", modelId: "openai/gpt-oss-120b", displayName: "GPT-OSS 120B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
{ provider: "groq", modelId: "openai/gpt-oss-20b", displayName: "GPT-OSS 20B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
{ provider: "groq", modelId: "qwen/qwen3-32b", displayName: "Qwen3 32B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
- { provider: "hackclub", modelId: "meta-llama/llama-3.3-70b-instruct", displayName: "Llama 3.3 70B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "hackclub", tos: "caution" },
- { provider: "hackclub", modelId: "mistralai/mistral-7b-instruct", displayName: "Mistral 7B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "hackclub", tos: "caution" },
- { provider: "hackclub", modelId: "deepseek-ai/deepseek-coder-33b", displayName: "DeepSeek Coder 33B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "hackclub", tos: "caution" },
{ provider: "huggingchat", modelId: "baidu/ERNIE-4.5-VL-424B-A47B-Base-PT", displayName: "ERNIE 4.5 VL 424B A47B Base PT", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" },
{ provider: "huggingchat", modelId: "CohereLabs/c4ai-command-r7b-12-2024", displayName: "Command R7B 12-2024", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" },
{ provider: "huggingchat", modelId: "CohereLabs/command-a-reasoning-08-2025", displayName: "Command A Reasoning 08-2025", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" },
diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts
index 9c1580e7aedd..8668de2c6368 100644
--- a/open-sse/config/glmProvider.ts
+++ b/open-sse/config/glmProvider.ts
@@ -19,17 +19,16 @@ export const GLM_ANTHROPIC_DEFAULT_BASE_URLS = Object.freeze({
export const GLM_SHARED_MODELS = Object.freeze([
{
- // GLM-5.3 (2026-08-14): one upstream id; effort is the reasoning_effort
- // param (low|high|max, default max) — the -high/-low entries below are
- // OmniRoute aliases resolved by GlmExecutor::parseGlmEffortTier.
- // Default context window not yet published by Z.ai; 1M mirrored from
- // GLM-5.2 (same base model). https://z.ai/blog/glm-5.3
+ // GLM-5.3 exposes low|high|max reasoning_effort (default max); -high/-low
+ // are OmniRoute aliases resolved by GlmExecutor::parseGlmEffortTier.
+ // https://docs.z.ai/guides/llm/glm-5.3
id: "glm-5.3",
name: "GLM 5.3",
contextLength: 1000000,
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: ["low", "high", "max"],
},
{
id: "glm-5.3-high",
@@ -38,6 +37,7 @@ export const GLM_SHARED_MODELS = Object.freeze([
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: ["high"],
},
{
id: "glm-5.3-low",
@@ -46,14 +46,19 @@ export const GLM_SHARED_MODELS = Object.freeze([
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: ["low"],
},
{
+ // GLM-5.2 has two positive effective tiers: low/medium map to high and xhigh
+ // maps to max; disabling thinking remains the separate thinking toggle.
+ // https://docs.z.ai/guides/capabilities/thinking
id: "glm-5.2",
name: "GLM 5.2",
contextLength: 1000000,
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: ["high", "max"],
},
{
id: "glm-5.2-high",
@@ -62,6 +67,7 @@ export const GLM_SHARED_MODELS = Object.freeze([
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: ["high"],
},
{
id: "glm-5.2-max",
@@ -70,14 +76,18 @@ export const GLM_SHARED_MODELS = Object.freeze([
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: ["max"],
},
{
+ // Earlier GLM families support the thinking toggle, not reasoning_effort.
+ // An explicit empty list prevents generic catalog tiers from being inferred.
id: "glm-5.1",
name: "GLM 5.1",
contextLength: 204800,
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: [],
},
{
id: "glm-5",
@@ -86,6 +96,7 @@ export const GLM_SHARED_MODELS = Object.freeze([
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: [],
},
{
id: "glm-5-turbo",
@@ -94,6 +105,7 @@ export const GLM_SHARED_MODELS = Object.freeze([
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: [],
},
{
id: "glm-4.7-flash",
@@ -102,6 +114,7 @@ export const GLM_SHARED_MODELS = Object.freeze([
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: [],
},
{
id: "glm-4.7",
@@ -110,6 +123,7 @@ export const GLM_SHARED_MODELS = Object.freeze([
maxOutputTokens: 131072,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: [],
},
{
id: "glm-4.6v",
@@ -118,6 +132,7 @@ export const GLM_SHARED_MODELS = Object.freeze([
maxOutputTokens: 32768,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: [],
supportsVision: true,
},
{
@@ -127,6 +142,7 @@ export const GLM_SHARED_MODELS = Object.freeze([
maxOutputTokens: 32768,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: [],
},
{
id: "glm-4.5v",
@@ -135,6 +151,7 @@ export const GLM_SHARED_MODELS = Object.freeze([
maxOutputTokens: 32768,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: [],
supportsVision: true,
},
{
@@ -144,6 +161,7 @@ export const GLM_SHARED_MODELS = Object.freeze([
maxOutputTokens: 32768,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: [],
},
{
id: "glm-4.5-air",
@@ -152,6 +170,7 @@ export const GLM_SHARED_MODELS = Object.freeze([
maxOutputTokens: 32768,
toolCalling: true,
supportsReasoning: true,
+ supportedThinkingEfforts: [],
},
]);
diff --git a/open-sse/config/opencodeZenGoSharedModels.ts b/open-sse/config/opencodeZenGoSharedModels.ts
new file mode 100644
index 000000000000..9f9d6628e915
--- /dev/null
+++ b/open-sse/config/opencodeZenGoSharedModels.ts
@@ -0,0 +1,16 @@
+/**
+ * Models declared identically in both the `opencode-zen` and `opencode-go` provider
+ * registries (same upstream family, opencode.ai/zen/*). Mirrors the GLM_SHARED_MODELS
+ * pattern in glmProvider.ts: one array, spread into each sibling RegistryEntry, so a
+ * metadata fix (targetFormat, supportsReasoning, ...) only has to land in one file
+ * instead of drifting out of sync across registries.
+ *
+ * Only entries that are byte-identical across both registries belong here — a model
+ * with tier-specific flags (e.g. go's effort variants, or a flag only one tier needs)
+ * stays local to that registry's own `models` array.
+ */
+export const OPENCODE_ZEN_GO_SHARED_MODELS = Object.freeze([
+ { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
+ { id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false },
+ { id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false },
+]);
diff --git a/open-sse/config/providerErrorRules.ts b/open-sse/config/providerErrorRules.ts
index c910e3fb4d91..6f00d2c5f121 100644
--- a/open-sse/config/providerErrorRules.ts
+++ b/open-sse/config/providerErrorRules.ts
@@ -30,21 +30,63 @@ export type ProviderErrorRule = {
export type ProviderErrorRuleMatch = {
reason: ConfiguredErrorReason;
/**
- * Intended lock scope. #10334: this field is CONSUMED end-to-end only for
- * providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS` (agentrouter-exclusive
- * today, gated by `honorsRuleLockScope()`) — for those, `checkFallbackError`
- * surfaces it as `ruleScope` on its return value for the persistence layer
- * to honor instead of re-deriving scope from `hasPerModelQuota()`. For
- * every other provider it remains INFORMATIONAL: `getProviderErrorRuleMatch`
- * callers still read only `reason`/`cooldownMs`, and the actual lock scope
- * is decided independently by each call site. Widening the allowlist is
- * tracked as a follow-up — see `docs/architecture/RESILIENCE_GUIDE.md` §7.
+ * Intended lock scope. #10334: for a BUILT-IN catalog rule, this field is
+ * CONSUMED end-to-end only for providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS`
+ * (agentrouter-exclusive today, gated by `honorsRuleLockScope()`) — for those,
+ * `checkFallbackError` surfaces it as `ruleScope` on its return value for the
+ * persistence layer to honor instead of re-deriving scope from
+ * `hasPerModelQuota()`. For every other built-in-rule provider it remains
+ * INFORMATIONAL. #11104: an OPERATOR-declared rule (`OperatorProviderErrorRule`)
+ * is exempt from this allowlist — `honorsRuleLockScope()` always returns true
+ * when the provider has one, since the operator already opted in by declaring
+ * the rule. Widening `HONORS_RULE_LOCK_SCOPE_PROVIDERS` itself (for a new
+ * built-in catalog rule) is tracked as a follow-up — see
+ * `docs/architecture/RESILIENCE_GUIDE.md` §7.
*/
scope: "model" | "provider" | "connection";
/** Optional explicit cooldown; falls back to the existing per-reason defaults. */
cooldownMs?: number;
};
+/**
+ * Operator-declared per-provider error rule (settings-driven).
+ *
+ * Mirrors the catalog `ProviderErrorRule` but is data-only so an operator can
+ * add a scope/cooldown/reason override for a provider without editing this
+ * file. `match` is a plain case-insensitive SUBSTRING of the error body — never
+ * a RegExp — so an operator-supplied pattern can never introduce a ReDoS on the
+ * error-classification hot path. Bounded to <= 50 rules total by the settings
+ * schema. An operator rule is consulted BEFORE the built-in `providerRuleRegistry`
+ * and wins on the first status+substring match for a provider.
+ */
+export type OperatorProviderErrorRule = {
+ status: number;
+ match: string;
+ scope: "model" | "provider" | "connection";
+ reason?: ConfiguredErrorReason;
+ cooldownMs?: number;
+};
+
+let operatorProviderErrorRules: Record = {};
+
+/**
+ * Inject operator-declared rules. Called from the runtime-settings applier
+ * (`applyRuntimeSettings`) once at boot and on every settings update, with the
+ * value validated by the settings schema. Pass `undefined`/empty/null to clear.
+ * Provider keys are lowercased so lookups are case-insensitive.
+ */
+export function setOperatorProviderErrorRules(
+ rules: Record | undefined | null
+): void {
+ operatorProviderErrorRules = {};
+ if (!rules) return;
+ for (const [provider, list] of Object.entries(rules)) {
+ if (Array.isArray(list) && list.length > 0) {
+ operatorProviderErrorRules[provider.toLowerCase()] = list;
+ }
+ }
+}
+
// ─── Opencode ───────────────────────────────────────────────────────────────────
// Opencode Go uses an account-wide quota. The body usually says "rate limit
// reached" but the presence of `x-ratelimit-remaining-requests: 0` is the
@@ -272,11 +314,21 @@ export const providerRuleRegistry = new Map([
* FULL_TEXT_RULE_PROVIDERS: that set controls what body a rule matches against
* (input), this one controls whether the matched scope changes caller behavior
* (output). A provider could need one without the other.
+ *
+ * Providers with an operator-declared rule (`setOperatorProviderErrorRules`)
+ * are honored too, without being added here: the allowlist exists to gate
+ * BUILT-IN catalog rules, which change default behavior for every operator
+ * running that provider — an operator rule is already an explicit, per-operator
+ * opt-in, so gating it a second time behind this list would make the settings
+ * mechanism (#11104) silently inert for every provider except the ones listed
+ * below. See `hasOperatorRuleForProvider`.
*/
const HONORS_RULE_LOCK_SCOPE_PROVIDERS = new Set(["agentrouter"]);
export function honorsRuleLockScope(provider: string | null | undefined): boolean {
- return !!provider && HONORS_RULE_LOCK_SCOPE_PROVIDERS.has(provider.toLowerCase());
+ if (!provider) return false;
+ const key = provider.toLowerCase();
+ return HONORS_RULE_LOCK_SCOPE_PROVIDERS.has(key) || hasOperatorRuleForProvider(key);
}
/**
@@ -310,28 +362,51 @@ export function egressBucketedLockProviders(): string[] {
}
/**
- * Providers whose rules match on the FULL upstream error text.
- * checkFallbackError's rule lookup normally passes only the structured
+ * Providers whose BUILT-IN catalog rules match on the FULL upstream error
+ * text. checkFallbackError's rule lookup normally passes only the structured
* error ({code, type} — message stripped by the combo callers), which is
* enough for header/status/code rules but blind to body-text markers like
* agentrouter's "额度不足". Providers in this set get the raw error text as
* the match body instead. EXCLUSIVE allowlist by owner decision (2026-08-13):
* adding a provider here is an explicit opt-in — the default path for every
* other provider must remain byte-for-byte unchanged.
+ *
+ * Operator-declared rules bypass this allowlist entirely (see
+ * `hasOperatorRuleForProvider`): the operator's `match` is a literal substring
+ * of the error body by construction, so a rule that never sees body text could
+ * never match anything, defeating the point of declaring it.
*/
const FULL_TEXT_RULE_PROVIDERS = new Set(["agentrouter"]);
+/**
+ * True when an operator has declared at least one rule for this provider via
+ * `settings.providerErrorRules` (injected through `setOperatorProviderErrorRules`).
+ * Presence of the rule IS the opt-in — no separate allowlist to maintain, and
+ * no widening decision needed as new operators configure new providers.
+ */
+export function hasOperatorRuleForProvider(provider: string | null | undefined): boolean {
+ if (!provider) return false;
+ const rules = operatorProviderErrorRules[provider.toLowerCase()];
+ return !!rules && rules.length > 0;
+}
+
/**
* Resolve the body handed to getProviderErrorRuleMatch inside
- * checkFallbackError: full error text for FULL_TEXT_RULE_PROVIDERS,
- * the structured error for everyone else.
+ * checkFallbackError: full error text for FULL_TEXT_RULE_PROVIDERS or any
+ * provider with an operator-declared rule, the structured error for everyone
+ * else.
*/
export function resolveRuleMatchBody(
provider: string | null | undefined,
structuredError: unknown,
errorText: string | null | undefined
): unknown {
- if (provider && FULL_TEXT_RULE_PROVIDERS.has(provider.toLowerCase()) && errorText) {
+ if (
+ provider &&
+ (FULL_TEXT_RULE_PROVIDERS.has(provider.toLowerCase()) ||
+ hasOperatorRuleForProvider(provider)) &&
+ errorText
+ ) {
return errorText;
}
return structuredError ?? null;
@@ -346,10 +421,32 @@ export function getProviderErrorRuleMatch(
provider: string | null | undefined,
status: number,
headers: Headers | Record | null | undefined,
- body?: unknown
+ body?: unknown,
+ operatorRules?: Record
): ProviderErrorRuleMatch | null {
if (!provider) return null;
- const rules = providerRuleRegistry.get(provider.toLowerCase());
+ const key = provider.toLowerCase();
+
+ // Operator-declared rules win first: an operator can override any catalog
+ // rule for a provider without editing this file. `operatorRules` is the
+ // injected source (tests / direct callers); when omitted we fall back to the
+ // settings-backed cache populated by `setOperatorProviderErrorRules`.
+ const opRules = (operatorRules ?? operatorProviderErrorRules)?.[key];
+ if (opRules && opRules.length > 0) {
+ const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
+ const lowered = text.toLowerCase();
+ for (const r of opRules) {
+ if (r.status === status && lowered.includes(r.match.toLowerCase())) {
+ return {
+ reason: r.reason ?? "quota_exhausted",
+ scope: r.scope,
+ cooldownMs: r.cooldownMs,
+ };
+ }
+ }
+ }
+
+ const rules = providerRuleRegistry.get(key);
if (!rules) return null;
// Normalize headers: accept either a `Headers` object (from `fetch()`) or
// a plain record. Provider rules access headers via plain object indexing.
diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts
index bb3b6e0f9fe3..69841c055ce1 100644
--- a/open-sse/config/providerRegistry.ts
+++ b/open-sse/config/providerRegistry.ts
@@ -10,6 +10,10 @@ export {
} from "./providers/registry/alibaba/index.ts";
export { REGISTRY } from "./providers/index.ts";
import { REGISTRY } from "./providers/index.ts";
+// Imported from `privateHost` rather than `outboundUrlGuard`: this module is reachable from
+// `ProviderDetailPageClient.tsx`, so anything it pulls in has to survive a browser bundle
+// (#11122). `privateHost` is platform-free by contract; the guard module is not.
+import { isPrivateHost } from "@/shared/network/privateHost";
import {
RegistryModel,
REASONING_UNSUPPORTED,
@@ -132,11 +136,8 @@ export function isLocalProvider(baseUrl?: string | null): boolean {
try {
const url = new URL(baseUrl);
const hostname = url.hostname;
- // Strictly matching 172.16.0.0/12 (Docker/local) and explicitly blocking ::1 per SSRF hardening
- return (
- LOCAL_HOSTNAMES.has(hostname) ||
- /^172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname)
- );
+ if (!hostname) return false;
+ return LOCAL_HOSTNAMES.has(hostname) || isPrivateHost(hostname);
} catch {
return false;
}
diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts
index 00861b24225d..b0135fa52429 100644
--- a/open-sse/config/providers/index.ts
+++ b/open-sse/config/providers/index.ts
@@ -70,7 +70,6 @@ import { togetherProvider } from "./registry/together/index.ts";
import { cohereProvider } from "./registry/cohere/index.ts";
import { cursorProvider, cursor_apiProvider } from "./registry/cursor/index.ts";
import { volcengineProvider } from "./registry/volcengine/index.ts";
-import { hackclubProvider } from "./registry/hackclub/index.ts";
import { freetheaiProvider } from "./registry/freetheai/index.ts";
import { g4f_groqProvider } from "./registry/g4f-groq/index.ts";
import { g4f_geminiProvider } from "./registry/g4f-gemini/index.ts";
@@ -336,7 +335,6 @@ export const REGISTRY: Record = {
cursor: cursorProvider,
"cursor-api": cursor_apiProvider,
volcengine: volcengineProvider,
- hackclub: hackclubProvider,
freetheai: freetheaiProvider,
"g4f-groq": g4f_groqProvider,
"g4f-gemini": g4f_geminiProvider,
diff --git a/open-sse/config/providers/registry/blackbox/index.ts b/open-sse/config/providers/registry/blackbox/index.ts
index 87d0ce26d721..46f58d35be62 100644
--- a/open-sse/config/providers/registry/blackbox/index.ts
+++ b/open-sse/config/providers/registry/blackbox/index.ts
@@ -5,6 +5,12 @@ export const blackboxProvider: RegistryEntry = {
alias: "bb",
format: "openai",
executor: "default",
+ // NOTE: api.blackbox.ai returns HTTP 404 on /v1/chat/completions and /v1/models
+ // (empty body, all path variants) since sweep 2026-08-21; the public inference
+ // surface has moved to the gated enterprise.blackbox.ai/v1 endpoint. The provider
+ // is marked deprecated in src/shared/constants/providers/apikey/frontier-labs.ts —
+ // this registry entry is kept intact (registration/execution unaffected), so
+ // existing configured keys keep working if a restored/enterprise host is reachable.
baseUrl: "https://api.blackbox.ai/v1/chat/completions",
modelsUrl: "https://api.blackbox.ai/v1/models",
authType: "apikey",
diff --git a/open-sse/config/providers/registry/cline/index.ts b/open-sse/config/providers/registry/cline/index.ts
index 21e8c600ed09..aecf811f193b 100644
--- a/open-sse/config/providers/registry/cline/index.ts
+++ b/open-sse/config/providers/registry/cline/index.ts
@@ -27,7 +27,7 @@ export const clineProvider: RegistryEntry = {
// the official free bucket and text-output models advertised as zero-cost.
models: [
{
- id: "zai/glm-5.2",
+ id: "z-ai/glm-5.2",
name: "GLM 5.2",
toolCalling: true,
supportsReasoning: true,
diff --git a/open-sse/config/providers/registry/command-code/index.ts b/open-sse/config/providers/registry/command-code/index.ts
index 77b08ab8ce80..f298bcc21fb8 100644
--- a/open-sse/config/providers/registry/command-code/index.ts
+++ b/open-sse/config/providers/registry/command-code/index.ts
@@ -8,7 +8,11 @@ export const command_codeProvider: RegistryEntry = {
format: "openai",
executor: "command-code",
baseUrl: "https://api.commandcode.ai",
- chatPath: "/alpha/generate",
+ // Chat uses the documented /provider/v1/chat/completions (OpenAI-format)
+ // endpoint — NOT the CLI-only /alpha/generate endpoint, which Command Code
+ // version-gates and proxy-blocks for external callers (#10265). Discovery
+ // already targets the sibling /provider/v1/models endpoint.
+ chatPath: "/provider/v1/chat/completions",
modelsUrl: "https://api.commandcode.ai/provider/v1/models",
// The discovery response is a partial routing catalog; static registry
// entries omitted from it can still be accepted by the gateway.
diff --git a/open-sse/config/providers/registry/dify/index.ts b/open-sse/config/providers/registry/dify/index.ts
index de1d5b03cc54..80c9c7690fbe 100644
--- a/open-sse/config/providers/registry/dify/index.ts
+++ b/open-sse/config/providers/registry/dify/index.ts
@@ -5,7 +5,11 @@ export const difyProvider: RegistryEntry = {
alias: "dify",
format: "openai",
executor: "default",
- baseUrl: "https://api.dify.ai/v1/chat/completions",
+ // Dify does not serve /chat/completions — its native completion route is
+ // POST /v1/chat-messages (validated via the dedicated dify validator, #11002).
+ // Keep this as the bare API root so route suffixes build correctly and
+ // self-hosted instances can override the base URL per connection.
+ baseUrl: "https://api.dify.ai",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "auto", name: "Auto" }],
diff --git a/open-sse/config/providers/registry/hackclub/index.ts b/open-sse/config/providers/registry/hackclub/index.ts
deleted file mode 100644
index 272ee5f86caa..000000000000
--- a/open-sse/config/providers/registry/hackclub/index.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { RegistryEntry } from "../../shared.ts";
-
-export const hackclubProvider: RegistryEntry = {
- id: "hackclub",
- alias: "hc",
- format: "openai",
- executor: "default",
- baseUrl: "https://ai.hackclub.com/proxy/v1/chat/completions",
- modelsUrl: "https://ai.hackclub.com/proxy/v1/models",
- authType: "optional",
- authHeader: "bearer",
- passthroughModels: true,
- defaultContextLength: 128000,
- models: [
- { id: "meta-llama/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
- { id: "mistralai/mistral-7b-instruct", name: "Mistral 7B" },
- { id: "deepseek-ai/deepseek-coder-33b", name: "DeepSeek Coder 33B" },
- ],
-};
diff --git a/open-sse/config/providers/registry/kimi/web/index.ts b/open-sse/config/providers/registry/kimi/web/index.ts
index 4344194974a0..ddeea4350baa 100644
--- a/open-sse/config/providers/registry/kimi/web/index.ts
+++ b/open-sse/config/providers/registry/kimi/web/index.ts
@@ -12,10 +12,9 @@ export const kimi_webProvider: RegistryEntry = {
alias: "kimi-web",
format: "openai",
executor: "kimi-web",
- // International consumer chat — the legacy `kimi.moonshot.cn` domain now
- // redirects every non-CN visitor to www.kimi.com, which speaks a different
- // Connect-RPC API. See `open-sse/executors/kimi-web.ts` for the wire format.
- baseUrl: "https://www.kimi.com",
+ // International consumer chat — Connect-RPC API at www.kimi.ai.
+ // See `open-sse/executors/kimi-web.ts` for the wire format.
+ baseUrl: "https://www.kimi.ai",
authType: "apikey",
authHeader: "Authorization",
// Curated-only catalog. Agent Swarm is excluded because it requires Kimi's
diff --git a/open-sse/config/providers/registry/minimax/web/index.ts b/open-sse/config/providers/registry/minimax/web/index.ts
index 53ac1e6fe3fe..6c2addc043b9 100644
--- a/open-sse/config/providers/registry/minimax/web/index.ts
+++ b/open-sse/config/providers/registry/minimax/web/index.ts
@@ -16,7 +16,7 @@ export const hailuo_webProvider: RegistryEntry = {
alias: "hailuo-web",
format: "openai",
executor: "hailuo-web",
- baseUrl: "https://www.hailuo.ai",
+ baseUrl: "https://chat.minimax.io",
authType: "apikey",
authHeader: "bearer",
models: HAILUO_WEB_STATIC_MODELS,
diff --git a/open-sse/config/providers/registry/opencode/go/index.ts b/open-sse/config/providers/registry/opencode/go/index.ts
index bf0c94ed90bc..abebd92c0fc6 100644
--- a/open-sse/config/providers/registry/opencode/go/index.ts
+++ b/open-sse/config/providers/registry/opencode/go/index.ts
@@ -1,4 +1,5 @@
import type { RegistryEntry } from "../../../shared.ts";
+import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../../../shared.ts";
export const opencode_goProvider: RegistryEntry = {
id: "opencode-go",
@@ -23,9 +24,13 @@ export const opencode_goProvider: RegistryEntry = {
{ id: "glm-5.2", name: "GLM-5.2", supportsReasoning: true },
{ id: "glm-5.2-high", name: "GLM-5.2 (high effort)", supportsReasoning: true },
{ id: "glm-5.2-max", name: "GLM-5.2 (max effort)", supportsReasoning: true },
+
+ ...OPENCODE_ZEN_GO_SHARED_MODELS,
+ // models[0] (glm-5.2) is the dashboard default (LlmChatCard/ProviderTestSlideOver take models[0]).
+
{ id: "glm-5.1", name: "GLM-5.1" },
{ id: "glm-5", name: "GLM-5" },
- { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
+ // kimi-k2.7-code declared identically on opencode-zen — see OPENCODE_ZEN_GO_SHARED_MODELS.
{ id: "kimi-k2.6", name: "Kimi K2.6" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
// #8353: Kimi K3 base + max-effort alias from the OpenCode Go registry.
@@ -89,7 +94,8 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: false,
supportsReasoning: true,
},
- { id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false },
+ // qwen3.6-plus / qwen3.5-plus base ids declared identically on opencode-zen — see
+ // OPENCODE_ZEN_GO_SHARED_MODELS.
{
id: "qwen3.6-plus-high",
name: "Qwen3.6 Plus (high effort)",
@@ -104,7 +110,6 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: false,
supportsReasoning: true,
},
- { id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false },
// #8353: hy3 is the Go-tier base id (distinct from hy3-preview / hy3-free).
{ id: "hy3", name: "Hunyuan3", contextLength: 256000, supportsReasoning: true },
{
@@ -138,6 +143,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
+ targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-minimal",
@@ -148,6 +154,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
+ targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-low",
@@ -158,6 +165,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
+ targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-medium",
@@ -168,6 +176,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
+ targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-high",
@@ -178,6 +187,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
+ targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-xhigh",
@@ -188,6 +198,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
+ targetFormat: "openai-responses",
},
// #8353: Grok 4.5 + effort tiers from the OpenCode Go registry.
{ id: "grok-4.5", name: "Grok 4.5", supportsReasoning: true },
diff --git a/open-sse/config/providers/registry/opencode/zen/index.ts b/open-sse/config/providers/registry/opencode/zen/index.ts
index 73da9c5dc510..9fdca2fc0a5c 100644
--- a/open-sse/config/providers/registry/opencode/zen/index.ts
+++ b/open-sse/config/providers/registry/opencode/zen/index.ts
@@ -1,4 +1,5 @@
import type { RegistryEntry } from "../../../shared.ts";
+import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../../../shared.ts";
export const opencode_zenProvider: RegistryEntry = {
id: "opencode-zen",
@@ -25,6 +26,10 @@ export const opencode_zenProvider: RegistryEntry = {
supportsReasoning: true,
interleavedField: "reasoning_content",
},
+
+ ...OPENCODE_ZEN_GO_SHARED_MODELS,
+ // models[0] (big-pickle) is the dashboard default; SHARED spread kept after it.
+
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol" },
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra" },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna" },
@@ -51,7 +56,27 @@ export const opencode_zenProvider: RegistryEntry = {
{ id: "grok-4.6", name: "Grok 4.6" },
// ── Muse ───────────────────────────────────────────────────
- { id: "muse-spark-1.2", name: "Muse Spark 1.2" },
+ // Muse Spark is served by OpenCode Zen only on the OpenAI Responses API
+ // endpoint, not /chat/completions (see the opencode provider's own
+ // muse-spark entries, #10874/#10867) — this provider is a separate
+ // registry entry for the same upstream and never got the same
+ // targetFormat declaration, so requests routed here still hit
+ // /chat/completions with a mismatched or unanswerable body and the
+ // upstream returns an empty message.
+ {
+ id: "muse-spark-1.2",
+ name: "Muse Spark 1.2",
+ supportsReasoning: true,
+ targetFormat: "openai-responses",
+ },
+ // Explicit wire-format overlay of the base opencode provider's muse-spark entry
+ // (targetFormat: openai-responses). Keep in sync with base on catalog syncs.
+ {
+ id: "muse-spark-1.2-contributor-free",
+ name: "Muse Spark 1.2 Contributor Free",
+ supportsReasoning: true,
+ targetFormat: "openai-responses",
+ },
// ── DeepSeek ────────────────────────────────────────────────
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
@@ -66,7 +91,7 @@ export const opencode_zenProvider: RegistryEntry = {
// ── Kimi / Moonshot ────────────────────────────────────────
{ id: "kimi-k3", name: "Kimi K3" },
- { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
+ // kimi-k2.7-code declared identically on opencode-go — see OPENCODE_ZEN_GO_SHARED_MODELS.
// ── Qwen ───────────────────────────────────────────────────
// Issue #2292: Qwen models return Claude-format SSE bodies even
@@ -74,8 +99,8 @@ export const opencode_zenProvider: RegistryEntry = {
// through /messages and the Claude translator.
// Issue #2822: These models are text-only — supportsVision: false
// ensures combo routing skips them on image-bearing requests.
- { id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false },
- { id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false },
+ // qwen3.5-plus / qwen3.6-plus declared identically on opencode-go — see
+ // OPENCODE_ZEN_GO_SHARED_MODELS.
// ── Free Tier ──────────────────────────────────────────────
// #6998 (2026-07-14): upstream free tier rotated — minimax-m2.5-free,
diff --git a/open-sse/config/providers/registry/zcode/index.ts b/open-sse/config/providers/registry/zcode/index.ts
index cd2a4eece64c..65e2e1c3d3d9 100644
--- a/open-sse/config/providers/registry/zcode/index.ts
+++ b/open-sse/config/providers/registry/zcode/index.ts
@@ -1,6 +1,17 @@
import type { RegistryEntry } from "../../shared.ts";
import { GLM_SHARED_MODELS } from "../../../glmProvider.ts";
+const GLM_EXECUTOR_EFFORT_ALIASES = new Set([
+ "glm-5.3-high",
+ "glm-5.3-low",
+ "glm-5.2-high",
+ "glm-5.2-max",
+]);
+
+export const ZCODE_MODELS = GLM_SHARED_MODELS.filter(
+ (model) => !GLM_EXECUTOR_EFFORT_ALIASES.has(model.id)
+).map((model) => ({ ...model, supportedThinkingEfforts: [] }));
+
/**
* Local ZCode app-server backend. Authentication remains in the user's local
* ZCode profile (`builtin:zai-coding-plan`); OmniRoute does not receive or
@@ -14,5 +25,7 @@ export const zcodeProvider: RegistryEntry = {
baseUrl: "zcode://app-server/stdio",
authType: "none",
authHeader: "none",
- models: [...GLM_SHARED_MODELS],
+ // ZCode's app-server transport does not consume reasoning_effort; keep thinking
+ // capability metadata without advertising aliases or tiers that it would ignore.
+ models: ZCODE_MODELS,
};
diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts
index d87250644dcb..62909a528d59 100644
--- a/open-sse/config/providers/shared.ts
+++ b/open-sse/config/providers/shared.ts
@@ -25,6 +25,7 @@ import {
GLMT_TIMEOUT_MS,
GLM_SHARED_MODELS,
} from "../glmProvider.ts";
+import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../opencodeZenGoSharedModels.ts";
import { MARITALK_DEFAULT_BASE_URL } from "../maritalk.ts";
import {
CURSOR_REGISTRY_VERSION,
@@ -719,6 +720,7 @@ export {
GLM_TIMEOUT_MS,
GLMT_TIMEOUT_MS,
GLM_SHARED_MODELS,
+ OPENCODE_ZEN_GO_SHARED_MODELS,
MARITALK_DEFAULT_BASE_URL,
CURSOR_REGISTRY_VERSION,
getAntigravityProviderHeaders,
diff --git a/open-sse/config/searchRegistry.ts b/open-sse/config/searchRegistry.ts
index b3ab36ccd944..28136655fe29 100644
--- a/open-sse/config/searchRegistry.ts
+++ b/open-sse/config/searchRegistry.ts
@@ -10,6 +10,8 @@
* perplexity-search reuses credentials from the "perplexity" chat provider.
*/
+import { isProviderBlockedByIdOrAlias } from "@/shared/utils/noAuthProviders";
+
export interface SearchProviderConfig {
id: string;
name: string;
@@ -394,16 +396,18 @@ export function supportsSearchType(
/**
* Get all search providers as a flat list
*/
-export function getAllSearchProviders(): Array<{
+export function getAllSearchProviders(blockedProviders: string[] = []): Array<{
id: string;
name: string;
searchTypes: string[];
}> {
- return Object.values(SEARCH_PROVIDERS).map((p) => ({
- id: p.id,
- name: p.name,
- searchTypes: p.searchTypes,
- }));
+ return Object.values(SEARCH_PROVIDERS)
+ .filter((p) => !p.disabled && !isProviderBlockedByIdOrAlias(p.id, blockedProviders))
+ .map((p) => ({
+ id: p.id,
+ name: p.name,
+ searchTypes: p.searchTypes,
+ }));
}
/**
diff --git a/open-sse/executors/accountRotation.ts b/open-sse/executors/accountRotation.ts
index a64321e83d24..67115bdd8d03 100644
--- a/open-sse/executors/accountRotation.ts
+++ b/open-sse/executors/accountRotation.ts
@@ -1,7 +1,7 @@
/**
* 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`.
+ * proxy — currently `OpencodeExecutor`.
*
* Extracted after both executors independently implemented the same
* pickAccount/markCooldown/markSuccess skeleton with the same exponential
@@ -120,3 +120,58 @@ export function maskAccountId(fingerprint: string): string {
export function isNetworkErrorRotatable(account: RotatableAccount): boolean {
return account.proxy !== null;
}
+
+/**
+ * Detect an *empty* upstream rejection: a 400 whose body carries no usable
+ * completion — the kind `OpencodeExecutor` must rotate/retry on instead of
+ * propagating as a fatal success.
+ *
+ * Signature is deliberately strict and scoped to the observed malformed
+ * envelope (`choices[0].message` with no `error`, no real `content`,
+ * `finish_reason: null`):
+ * - status must be exactly 400 (anything else → false);
+ * - body must parse and contain a `choices` array with at least one entry
+ * holding a `message` object;
+ * - an `error` field (present or empty) → false, so genuine 400s keep
+ * propagating immediately (#10460 precedent: classify by signature before
+ * rotating);
+ * - `tool_calls` / `reasoning_content` → false (real content);
+ * - `message.content` absent / null / "" → eligible; any other value
+ * (non-empty text, number, block array…) → false (conservative);
+ * - a literal `finish_reason` (not null) → false (a completed, if empty, turn).
+ *
+ * Does NOT reuse `detectMalformedNonStream` (diagnostics.ts): that classifier
+ * also flags `{error:{…}}` bodies as `empty_choices`, which would rotate on
+ * real errors — a false-positive class with a history here.
+ */
+export function isEmptyUpstreamRejection(status: number, bodyText: string): boolean {
+ if (status !== 400) return false;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(bodyText);
+ } catch {
+ return false;
+ }
+ const choices = (parsed as { choices?: unknown })?.choices;
+ if (!Array.isArray(choices) || choices.length === 0) return false;
+ const first = choices[0] as { message?: unknown; finish_reason?: unknown };
+ if (typeof first !== "object" || first === null) return false;
+ const rawMessage = (first as { message?: unknown }).message;
+ if (typeof rawMessage === "undefined" || rawMessage === null) return false;
+ if (typeof parsed !== "object" || parsed === null) return false;
+ if ("error" in (parsed as Record)) return false;
+ const msg = rawMessage as Record;
+ if ("tool_calls" in msg) return false;
+ if ("reasoning_content" in msg) return false;
+ const content = msg.content;
+ if (content !== undefined && content !== null && content !== "") return false;
+ if (first.finish_reason !== null && first.finish_reason !== undefined) return false;
+ return true;
+}
+
+/** Best-effort extraction of the upstream `chatcmpl_*` id from a response body,
+ * for observability logging. Returns `"unknown"` when absent or unparseable. */
+export function extractChatcmplId(bodyText: string): string {
+ const match = /"id"\s*:\s*"(chatcmpl_[^"]+)"/.exec(bodyText);
+ return match ? match[1] : "unknown";
+}
diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts
index 877431b68e5e..1c13442aff03 100644
--- a/open-sse/executors/base.ts
+++ b/open-sse/executors/base.ts
@@ -20,6 +20,10 @@ import {
recordLearnedThinkingCap,
parseThinkingBudgetMax,
} from "../services/learnedThinkingCaps.ts";
+import {
+ recordLearnedReasoningEffort,
+ parseReasoningEffortEnum,
+} from "../services/learnedReasoningEffortCaps.ts";
import {
getParamFilterConfig,
addParamToBlocklist,
@@ -104,6 +108,12 @@ import {
import { applyPeerTraceHeader } from "@/shared/resilience/peerRouting";
import { applyClineProtocolHeaders } from "@/shared/utils/clineAuth";
import { isProbeContext } from "@/shared/utils/probeOrigin";
+import {
+ parseAndValidatePublicUrl,
+ parseAndValidateNonMetadataUrl,
+} from "@/shared/network/outboundUrlGuard";
+import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
+import { isLocalProvider, isSelfHostedChatProvider } from "@/shared/constants/providers";
// Header helpers extracted to a pure leaf; re-exported for external importers
// (executors + tests) that import them from "./base.ts".
export {
@@ -397,6 +407,29 @@ export class BaseExecutor {
return fallback || this.config.baseUrl || "";
}
+ /**
+ * SSRF guard for the runtime dispatch path (GHSA-4f49-hj64-448x). A persisted,
+ * caller-supplied `providerSpecificData.baseUrl` reaches the fetch() calls
+ * below, so a `manage`-scope actor (or, on a keyless install, an anonymous
+ * one) could point a provider at loopback / internal / cloud-metadata hosts
+ * and exfiltrate the stored upstream key. Mirror the provider VALIDATION
+ * guard so runtime dispatch makes the same decision the validation layer
+ * already makes: local / self-hosted providers are exempt (they legitimately
+ * use private URLs, and the OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS opt-in still
+ * applies through the guard), and for everything else `public-only` mode
+ * blocks private + metadata while the default `block-metadata` mode blocks the
+ * cloud-metadata IMDS pivot. Throws on a blocked URL.
+ */
+ protected assertOutboundUrlAllowed(url: string): void {
+ if (!url) return;
+ if (isLocalProvider(this.provider) || isSelfHostedChatProvider(this.provider)) return;
+ if (getProviderValidationGuard() === "public-only") {
+ parseAndValidatePublicUrl(url);
+ return;
+ }
+ parseAndValidateNonMetadataUrl(url);
+ }
+
/**
* Alternate protocol selected on this connection, if the provider declares one
* that matches. Centralizes the registry lookup so every call-site resolves the
@@ -615,6 +648,7 @@ export class BaseExecutor {
async countTokens({ model, body, credentials, signal, log }: CountTokensInput) {
const url = this.buildCountTokensUrl(model, credentials);
if (!url) return null;
+ this.assertOutboundUrlAllowed(url); // GHSA-4f49
const headers = this.buildHeaders(credentials, false);
const requestBody =
@@ -796,6 +830,9 @@ export class BaseExecutor {
// loop. The learned cap is also recorded process-wide via
// recordLearnedThinkingCap so future requests skip the 400 entirely.
let thinkingBudgetClampedMax: number | null = null;
+ // Set by the reasoning_effort 4xx clamp-and-retry below — guards the same
+ // "fires at most once per URL" invariant as thinkingBudgetClampedMax above.
+ let reasoningEffortClamped = false;
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const requestCredentials = withForcedResponsesUpstream(
@@ -869,6 +906,9 @@ export class BaseExecutor {
// Timeout only covers response start; stream stalls are handled downstream.
const fetchStartTimeoutMs = this.getTimeoutMs();
const fetchWithStartTimeout = async (requestUrl: string, requestOptions: RequestInit) => {
+ // GHSA-4f49: guard here (not only next to the first buildUrl) so retries
+ // and fallback URLs are validated too, before any bytes leave the host.
+ this.assertOutboundUrlAllowed(requestUrl);
const timeoutController = fetchStartTimeoutMs > 0 ? new AbortController() : null;
let timeoutId: ReturnType | null = null;
if (timeoutController) {
@@ -1496,6 +1536,49 @@ export class BaseExecutor {
}
}
+ // Reasoning-effort enum 4xx clamp-and-retry (any provider/model without a
+ // declared reasoning_effort capability — custom OpenAI-compatible
+ // connections, or a registered provider the registry hasn't caught up
+ // with). Mirrors the thinking_budget clamp-and-retry above: parse the
+ // upstream-advertised accepted values, record them process-wide (so
+ // FUTURE requests clamp proactively via sanitizeReasoningEffortForProvider
+ // → getLearnedReasoningEffort), clamp the live transformedBody by
+ // re-running the sanitizer, and retry the same URL once.
+ if (
+ (response.status === HTTP_STATUS.BAD_REQUEST ||
+ response.status === HTTP_STATUS.UNPROCESSABLE_ENTITY) &&
+ !reasoningEffortClamped &&
+ transformedBody &&
+ typeof transformedBody === "object"
+ ) {
+ const errText = await response
+ .clone()
+ .text()
+ .catch(() => "");
+ const acceptedValues = parseReasoningEffortEnum(errText);
+ if (acceptedValues) {
+ reasoningEffortClamped = true;
+ const learned = recordLearnedReasoningEffort(this.provider, model, acceptedValues);
+ if (learned) {
+ transformedBody = sanitizeReasoningEffortForProvider(
+ transformedBody,
+ this.provider,
+ model,
+ log
+ );
+ let retryBody = JSON.stringify(transformedBody);
+ if (usesClaudeCodeProtocol || this.provider === "claude") {
+ retryBody = await signRequestBody(retryBody);
+ }
+ log?.info?.(
+ "REASONING_SANITIZE",
+ `Upstream ${response.status} rejected reasoning_effort on ${url} — clamped to ${learned} and retrying (learned for ${this.provider}/${model})`
+ );
+ response = await fetchWithStartTimeout(url, { ...fetchOptions, body: retryBody });
+ }
+ }
+ }
+
// Generic reactive 400 field-downgrade; each field is stripped at most once.
if (
response.status === HTTP_STATUS.BAD_REQUEST &&
diff --git a/open-sse/executors/base/reasoningEffort.ts b/open-sse/executors/base/reasoningEffort.ts
index fa416dbc1a80..8dd99904fd6e 100644
--- a/open-sse/executors/base/reasoningEffort.ts
+++ b/open-sse/executors/base/reasoningEffort.ts
@@ -8,6 +8,10 @@ import {
getProviderModel,
getProviderModels,
} from "../../config/providerModels.ts";
+import {
+ getLearnedReasoningEffort,
+ REASONING_EFFORT_ORDER,
+} from "../../services/learnedReasoningEffortCaps.ts";
/**
* Sanitize reasoning_effort for providers that don't accept all values.
@@ -338,10 +342,24 @@ export function sanitizeReasoningEffortForProvider(
const supportsXHigh = supportsXHighEffort(provider, modelStr);
const supportsMax = supportsMaxEffortForProvider(provider, modelStr);
+ // Highest value we've actually seen this provider+model accept in a real
+ // upstream 4xx (learnedReasoningEffortCaps.ts) — takes priority over the
+ // static registry (which defaults to "supports everything" when there's no
+ // entry, e.g. custom OpenAI-compatible connections) and over the hardcoded
+ // "high" fallback below (which isn't always valid either).
+ const learnedCap = getLearnedReasoningEffort(provider, modelStr);
+ const learnedRank = learnedCap ? REASONING_EFFORT_ORDER.indexOf(learnedCap) : -1;
// ── xhigh handling ──────────────────────────────────────────────────────
// xhigh is OmniRoute-internal. Map it to the best effort the model accepts.
if (effortStr === "xhigh") {
+ if (learnedCap && learnedRank < REASONING_EFFORT_ORDER.indexOf("xhigh")) {
+ log?.info?.(
+ "REASONING_SANITIZE",
+ `${provider}/${modelStr}: clamped reasoning_effort xhigh → ${learnedCap} (learned)`
+ );
+ return writeEffortValue(b, learnedCap, c);
+ }
if (supportsXHigh) return body; // model accepts xhigh natively
if (supportsMax) {
log?.info?.(
@@ -366,6 +384,13 @@ export function sanitizeReasoningEffortForProvider(
// upstream, and if it 400s the user gets a clear signal. This prevents
// new models from being unusable for weeks until they're whitelisted (#8057).
if (effortStr === "max") {
+ if (learnedCap && learnedRank < REASONING_EFFORT_ORDER.indexOf("max")) {
+ log?.info?.(
+ "REASONING_SANITIZE",
+ `${provider}/${modelStr}: clamped reasoning_effort max → ${learnedCap} (learned)`
+ );
+ return writeEffortValue(b, learnedCap, c);
+ }
if (supportsMax) return body; // explicitly known to accept max
// A model that explicitly advertises its accepted tiers is safe to normalize.
diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts
index e1674c30fb3f..b9f6cf113a29 100644
--- a/open-sse/executors/codex.ts
+++ b/open-sse/executors/codex.ts
@@ -517,10 +517,12 @@ export function filterNonstandardCodexSse(response: Response): Response {
const transform = new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
- let sep: number;
- while ((sep = buffer.indexOf("\n\n")) !== -1) {
- const block = buffer.slice(0, sep + 2);
- buffer = buffer.slice(sep + 2);
+ while (true) {
+ const separator = /\r?\n\r?\n/.exec(buffer);
+ if (!separator) break;
+ const blockEnd = separator.index + separator[0].length;
+ const block = buffer.slice(0, blockEnd);
+ buffer = buffer.slice(blockEnd);
if (!dropBlock(block)) controller.enqueue(encoder.encode(block));
}
},
@@ -1396,7 +1398,6 @@ export class CodexExecutor extends BaseExecutor {
provider: "codex",
preserveEncryptedReasoning:
credentials?.providerSpecificData?.preserveEncryptedReasoning === true,
- onIncompatibleReasoning: "drop",
});
if (nativeCodexPassthrough) {
diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts
index b736e6cdf1fa..a1023fc80cd9 100644
--- a/open-sse/executors/commandCode.ts
+++ b/open-sse/executors/commandCode.ts
@@ -1,6 +1,3 @@
-import { randomUUID } from "node:crypto";
-
-import { isVisionModelId } from "@/shared/constants/visionModels";
import { REGISTRY } from "../config/providerRegistry.ts";
import {
BaseExecutor,
@@ -11,407 +8,54 @@ import {
type JsonRecord = Record;
-export const COMMAND_CODE_VERSION = process.env.COMMAND_CODE_VERSION?.trim() || "1.15.1";
-// Hard server-side ceiling enforced by Command Code's /alpha/generate endpoint:
-// any request with params.max_tokens > 200_000 is rejected with a 400
-// "Too big: expected number to be <=200000 at params.max_tokens". We only use
-// this to clamp a CLIENT-SUPPLIED max_tokens down to a value the endpoint will
-// accept; we never fabricate this number for requests that omit the field (see
-// clampMaxTokens / buildCommandCodeBody).
+// Defensive server-side ceiling for a CLIENT-SUPPLIED max_tokens. The official
+// /provider/v1/chat/completions endpoint (documented OpenAI-format surface) is
+// the successor to the CLI-only /alpha/generate endpoint, which rejected any
+// params.max_tokens > 200_000 with a 400. We only clamp a client-supplied value
+// down; we never fabricate this number for requests that omit the field (see
+// clampMaxTokens / buildOpenAiBody).
const MAX_COMMAND_CODE_TOKENS = 200_000;
-const encoder = new TextEncoder();
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
-function asRecordArray(value: unknown): JsonRecord[] {
- return Array.isArray(value) ? value.filter(isRecord) : [];
-}
-
-function stringValue(value: unknown): string | undefined {
- return typeof value === "string" ? value : undefined;
-}
-
function numberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
-function recordOrEmpty(value: unknown): JsonRecord {
- if (isRecord(value)) return value;
- if (typeof value === "string" && value.trim()) {
- try {
- const parsed: unknown = JSON.parse(value);
- if (isRecord(parsed)) return parsed;
- } catch (error) {
- console.warn(
- "[commandCode] tool arg parse failed:",
- error instanceof Error ? error.message : String(error)
- );
- }
- }
- return {};
-}
-
-/**
- * Build the `arguments` field for an assistant tool-call part that Command
- * Code's /alpha/generate schema REQUIRES (rejects a missing field with
- * `missing required field 'arguments'`). Valid source values round-trip:
- * - object arguments -> JSON string of the object
- * - valid JSON string arguments -> the string as-is
- * - missing / empty / invalid JSON string -> "{}" (a valid empty-object string)
- */
-function toolCallArgumentsString(value: unknown): string {
- if (isRecord(value)) return JSON.stringify(value);
- if (typeof value === "string" && value.trim()) {
- try {
- const parsed: unknown = JSON.parse(value);
- if (isRecord(parsed)) return value;
- } catch {
- return "{}";
- }
- return "{}";
- }
- return JSON.stringify(recordOrEmpty(value));
-}
-
-/**
- * Tool names that collide with Command Code's server-side built-in tools.
- * The /alpha/generate server normalizes tool-call/tool-result parts against
- * ITS OWN built-in registry for matching names; for its built-in `tool_search`
- * the result normalization requires `arguments` in a shape we do not send, so
- * the result is rejected with `input[N] missing required field 'arguments'`
- * (verified live 2026-08-10 — renaming the call/result `tool_search` → `grep`
- * makes the identical request pass; the server pairs each tool-result with the
- * nearest preceding tool-call, so any result following such a call is affected).
- * We rename the colliding name consistently on the wire — definitions, calls
- * and results — then un-rename on the response path so the client still sees
- * its original tool names.
- */
-const COMMAND_CODE_RESERVED_TOOL_NAMES = new Set(["tool_search"]);
-
-function wireToolName(clientName: string, toolNameMap: Map): string {
- if (COMMAND_CODE_RESERVED_TOOL_NAMES.has(clientName)) {
- const wire = `omniroute_${clientName}`;
- toolNameMap.set(wire, clientName);
- return wire;
- }
- return clientName;
-}
-
-function clientToolName(wireName: string, toolNameMap: Map): string {
- return toolNameMap.get(wireName) ?? wireName;
-}
-
-function normalizeContentText(content: unknown): string {
- if (typeof content === "string") return content;
- return asRecordArray(content)
- .filter((part) => part.type === "text")
- .map((part) => stringValue(part.text) || "")
- .join("\n");
-}
-
-/**
- * Model id patterns for Command Code models that have `text, vision`
- * capability per the official CC model registry, but are NOT caught
- * by the shared {@link isVisionModelId} heuristic. Kept as a local
- * set because these are CC-specific model IDs (vendor-prefix shapes
- * like "moonshotai/Kimi-K2.6" or CC aliases like "gpt-5.6-luna").
- *
- * Source: Command Code /alpha/generate model registry (docs).
- */
-const CC_VISION_MODEL_PATTERNS: readonly RegExp[] = [
- // Open Source
- /kimi-k2/i, // moonshotai/Kimi-K2.6, Kimi-K2.7-Code, Kimi-K2.5
- /qwen3\.\d/i, // Qwen/Qwen3.6-Plus, Qwen/Qwen3.7-Plus
- /step-?3/i, // stepfun/Step-3.7-Flash
- // Anthropic
- /claude-fable/i, // claude-fable-5 (not covered by claude-opus/sonnet/haiku-4)
- // OpenAI
- /gpt-5/i, // gpt-5.6, gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex
- // NOTE: gpt-5.4-mini and gpt-5.3-codex deliberately stay inside the `/gpt-5/`
- // family — both accept image input on the OpenAI API, and there is no
- // verified Command Code backend data marking them text-only. Excluding them
- // without evidence would re-create #4071 (image stripped from a model that
- // can see it). Revisit only with per-model CC registry capability data.
- // Sakana
- /fugu/i, // sakana/fugu-ultra
-];
-
-/**
- * Whether a model id routed through the Command Code executor is
- * vision-capable. Checks Mimo-specific rules first, then CC-specific
- * patterns, then falls through to the shared {@link isVisionModelId}
- * heuristic (which covers minimax-m3, claude-3/4 families, gemini,
- * gpt-4o/4.1, mistral-medium-3, and general "-vision" / "multimodal").
- */
-function isCommandCodeVisionModel(model?: string | null): boolean {
- if (!model) return false;
- // mimo-v2.5-pro is text-only — exclude before any positive check
- if (/(?:^|\/)mimo-v2\.5-pro$/i.test(model)) return false;
- // Only mimo-v2.5 and mimo-v2-omni accept images per Xiaomi vendor docs
- if (/(?:^|\/)mimo-v2\.5$/i.test(model)) return true;
- if (/(?:^|\/)mimo-v2-omni$/i.test(model)) return true;
- // CC-specific patterns: Kimi K2, Qwen 3.x, Stepfun, Claude Fable,
- // GPT-5, Sakana Fugu — not covered by the shared heuristic
- if (CC_VISION_MODEL_PATTERNS.some((pattern) => pattern.test(model))) return true;
- // Fall through: minimax-m3, claude-3/4, gemini-2/3, gpt-4o, -vision, multimodal
- return isVisionModelId(model);
-}
-
-/**
- * Extract the image URL from an OpenAI-compatible or Command Code
- * content part, returning undefined for non-image parts.
- *
- * OpenAI-compatible: { type: "image_url", image_url: { url: "..." } }
- * Command Code CLI: { type: "image", image: "..." }
- * AI SDK image: { type: "image", image: "data:...;base64,..." } (#1330)
- * Anthropic image: { type: "image", source: { type: "base64", media_type, data } }
- * or { type: "image", source: { type: "url", url } }
- *
- * The Anthropic-shaped block is common for Claude-Code-compatible clients
- * (e.g. Zoo Code) that send Messages-style content arrays to the
- * OpenAI `/v1/chat/completions` surface. Without this branch the image was
- * silently dropped before reaching the upstream vision model.
- */
-function extractImageUrl(part: JsonRecord): string | undefined {
- if (part.type === "image") {
- const direct = stringValue(part.image);
- if (direct) return direct;
-
- // Anthropic source block: { source: { type: "base64", media_type, data } } or
- // { source: { type: "url", url } }.
- const source = isRecord(part.source) ? part.source : null;
- if (source) {
- if (source.type === "base64") {
- const mediaType = stringValue(source.media_type) || "image/png";
- const data = stringValue(source.data);
- if (data) return `data:${mediaType};base64,${data}`;
- }
- if (source.type === "url") {
- const url = stringValue(source.url);
- if (url) return url;
- }
- }
- return undefined;
- }
- if (part.type === "image_url") {
- if (isRecord(part.image_url)) return stringValue(part.image_url.url);
- return stringValue(part.image_url);
- }
- return undefined;
-}
-
-/**
- * Convert an OpenAI-format content array to Command Code's internal
- * CLI format. For vision-capable models (MiniMax M3, MiMo v2.5, etc.)
- * this also preserves image parts alongside text.
- */
-function convertUserContentParts(content: unknown, isVisionModel: boolean): string | unknown[] {
- // For non-vision models or string content, extract text only.
- if (!isVisionModel || typeof content === "string") {
- return normalizeContentText(content);
- }
-
- const parts: unknown[] = [];
- for (const part of asRecordArray(content)) {
- if (part.type === "text") {
- const text = stringValue(part.text);
- if (text) parts.push({ type: "text", text });
- continue;
- }
- const imgUrl = extractImageUrl(part);
- if (imgUrl) {
- parts.push({ type: "image", image: imgUrl });
- continue;
- }
- // Always drop tool_use / tool_result / thinking parts from user
- // messages (Command Code doesn't accept them for role:"user").
- }
-
- // When every part was stripped, fall back to empty text so the
- // message is still valid JSON (Command Code rejects empty content).
- if (parts.length === 0) parts.push({ type: "text", text: "" });
-
- return parts;
-}
-
-function convertTools(tools: unknown, toolNameMap: Map): unknown[] {
- return asRecordArray(tools).map((tool) => {
- const fn = isRecord(tool.function) ? tool.function : tool;
- return {
- type: "function",
- name: wireToolName(stringValue(fn.name) || "", toolNameMap),
- description: stringValue(fn.description) || "",
- input_schema: isRecord(fn.parameters) ? fn.parameters : {},
- };
- });
-}
-
-function buildToolCallMetadata(
- messages: JsonRecord[],
- toolNameMap: Map
-): {
- pairedToolCallIds: Set;
- toolCallNames: Map;
- toolCallArgs: Map;
-} {
- const callIds = new Set();
- const resultIds = new Set();
- const toolCallNames = new Map();
- const toolCallArgs = new Map();
-
- for (const message of messages) {
- if (message.role === "assistant") {
- for (const call of asRecordArray(message.tool_calls)) {
- const id = stringValue(call.id);
- if (id) {
- callIds.add(id);
- const fn = isRecord(call.function) ? call.function : {};
- const name = stringValue(fn.name) || stringValue(call.name);
- if (name) toolCallNames.set(id, wireToolName(name, toolNameMap));
- toolCallArgs.set(id, toolCallArgumentsString(fn.arguments));
- }
- }
- } else if (message.role === "tool") {
- const id = stringValue(message.tool_call_id);
- if (id) resultIds.add(id);
- }
- }
-
- const pairedToolCallIds = new Set([...callIds].filter((id) => resultIds.has(id)));
- return { pairedToolCallIds, toolCallNames, toolCallArgs };
-}
-
-function convertMessages(
- messages: unknown,
- model?: string | null,
- toolNameMap?: Map
-): { system: string; messages: unknown[] } {
- const source = asRecordArray(messages);
- const { pairedToolCallIds, toolCallNames, toolCallArgs } = buildToolCallMetadata(
- source,
- toolNameMap ?? new Map()
- );
- const out: unknown[] = [];
- const system: string[] = [];
- const isVision = isCommandCodeVisionModel(model);
-
- for (const message of source) {
- const role = stringValue(message.role);
- if (role === "system" || role === "developer") {
- const text = normalizeContentText(message.content);
- if (text) system.push(text);
- continue;
- }
-
- if (role === "user") {
- out.push({ role: "user", content: convertUserContentParts(message.content, isVision) });
- continue;
- }
-
- if (role === "assistant") {
- const parts: unknown[] = [];
- const text = normalizeContentText(message.content);
- if (text) parts.push({ type: "text", text });
-
- for (const call of asRecordArray(message.tool_calls)) {
- const id = stringValue(call.id) || "";
- if (!id || !pairedToolCallIds.has(id)) continue;
- const fn = isRecord(call.function) ? call.function : {};
- const parsedInput = recordOrEmpty(fn.arguments);
- parts.push({
- type: "tool-call",
- toolCallId: id,
- toolName: wireToolName(
- stringValue(fn.name) || stringValue(call.name) || "unknown",
- toolNameMap ?? new Map()
- ),
- input: parsedInput,
- // /alpha/generate requires this field on assistant tool-call parts;
- // a missing one is rejected with `missing required field 'arguments'`.
- arguments: toolCallArgumentsString(fn.arguments),
- });
- }
-
- if (parts.length > 0) out.push({ role: "assistant", content: parts });
- continue;
- }
-
- if (role === "tool") {
- const toolCallId = stringValue(message.tool_call_id) || "";
- if (!toolCallId || !pairedToolCallIds.has(toolCallId)) continue;
- const toolName = wireToolName(
- stringValue(message.name) || toolCallNames.get(toolCallId) || "unknown",
- toolNameMap ?? new Map()
- );
- out.push({
- role: "tool",
- content: [
- {
- type: "tool-result",
- toolCallId,
- toolName,
- // /alpha/generate requires `arguments` here too (same rejection as
- // tool-call parts); echo the paired call's args, defensively "{}".
- arguments: toolCallArgs.get(toolCallId) ?? "{}",
- output: { type: "text", value: normalizeContentText(message.content) },
- },
- ],
- });
- }
- }
-
- return { system: system.join("\n\n"), messages: out };
-}
-
// Clamp a client-supplied max_tokens to the endpoint ceiling, mirroring the
// provider-driven clamp in antigravity.ts: we only intervene when the value is
-// present, positive AND would otherwise be rejected (> 200_000). A valid value
-// is returned floored; anything absent, non-numeric or non-positive returns
-// undefined so the caller can OMIT the field entirely and let Command Code's
-// upstream apply the model's own native default (rather than us inventing a
-// number). A non-positive value such as Zoo Code's max_tokens:-1 ("let the
-// server choose") must be omitted, NOT forced to 1 — the old Math.max(1,...)
-// truncated output to a single token (#5166).
+// present, positive AND would otherwise be rejected (> MAX_COMMAND_CODE_TOKENS).
+// A valid value is returned floored; anything absent, non-numeric or non-positive
+// returns undefined so the caller can OMIT the field entirely and let the
+// provider's upstream apply the model's own native default (rather than us
+// inventing a number). A non-positive value such as Zoo Code's max_tokens:-1
+// ("let the server choose") must be omitted, NOT forced to 1 — the old
+// Math.max(1,...) truncated output to a single token (#5166).
function clampMaxTokens(value: unknown): number | undefined {
const numeric = numberValue(value);
if (numeric === undefined || numeric <= 0) return undefined;
return Math.min(Math.floor(numeric), MAX_COMMAND_CODE_TOKENS);
}
-// Reasoning/thinking fields that payload rules or clients may inject and that
-// CommandCode's upstream accepts inside `params`. Without this pass-through,
-// payload-rule overrides on these fields are silently dropped (#2986 follow-up).
-const COMMAND_CODE_PASSTHROUGH_FIELDS = [
- "reasoning_effort",
- "reasoning",
- "thinking",
- "effort",
- "output_config",
- "extra_body",
-] as const;
-
/**
- * Command Code's /alpha/generate endpoint serves most models under a
- * vendor-prefixed wire id (e.g. `xiaomi/mimo-v2.5`, `deepseek/deepseek-v4-pro`,
- * `moonshotai/Kimi-K2.6`) and defaults an unprefixed id to the `anthropic:`
- * provider, which 403s with "Model/provider not recognized: anthropic:".
+ * Command Code serves most models under a vendor-prefixed wire id (e.g.
+ * `xiaomi/mimo-v2.5`, `deepseek/deepseek-v4-pro`, `moonshotai/Kimi-K2.6`).
* The command-code registry ids already carry the vendor prefix, so a bare id
* reaching the executor is an operator-set custom model (e.g. the Vision Bridge
* picker, #10809). Map the small set of documented bare ids to their
* vendor-prefixed wire form; anything with an explicit `/` (or already wired)
- * passes through untouched. Kept minimal and doc-backed, mirroring the
- * `CC_VISION_MODEL_PATTERNS` philosophy.
+ * passes through untouched. Kept minimal and doc-backed.
*/
const COMMAND_CODE_BARE_MODEL_VENDOR_PREFIX: Readonly> = {
- // Xiaomi MiMo V2.5 — the only CC-served vision model not in the registry.
+ // Xiaomi MiMo V2.5 — a CC-served vision model not in the registry.
"mimo-v2.5": "xiaomi/mimo-v2.5",
"mimo-v2.5-pro": "xiaomi/mimo-v2.5-pro",
};
/**
- * Normalize an incoming model id to the wire form Command Code's upstream
+ * Normalize an incoming model id to the wire form Command Code's provider API
* accepts. Strips a leading provider prefix (`command-code/` / `cmd/`) that the
* pipeline may have resolved, then maps known bare ids to their
* vendor-prefixed form (see above).
@@ -424,546 +68,47 @@ function normalizeCommandCodeWireModel(model: string): string {
return COMMAND_CODE_BARE_MODEL_VENDOR_PREFIX[bare] ?? bare;
}
-function buildCommandCodeBody(
+/**
+ * Build a flat OpenAI chat.completions request body for the official
+ * /provider/v1/chat/completions endpoint. The incoming body is already the
+ * standard OpenAI chat.completions shape (registry `format: "openai"`), so this
+ * is a passthrough that: normalizes the wire model id, forces the stream flag
+ * to match the caller's expectation, clamps max_tokens, and lets reasoning /
+ * payload-rule passthrough fields flow through untouched. No CLI envelope
+ * (config/memory/taste/skills/permissionMode) and no CLI-shaped message
+ * conversion here — /provider/v1 is the documented, standard API.
+ */
+function buildOpenAiBody(
model: string,
body: unknown,
- stream = false
-): { body: JsonRecord; toolNameMap: Map } {
- const input = isRecord(body) ? body : {};
- const toolNameMap = new Map();
+ stream: boolean
+): { body: JsonRecord } {
+ const input = isRecord(body) ? { ...(body as JsonRecord) } : {};
- // Payload rules may rewrite `body.model` (e.g. deepseek-v4-pro-max →
- // deepseek/deepseek-v4-pro for the command-code provider). Prefer the
- // rewritten value if present; fall back to the resolved combo model arg.
- // Normalize to the vendor-prefixed wire id the upstream requires (#10809).
const resolvedModel = normalizeCommandCodeWireModel(
- typeof input.model === "string" && input.model.trim().length > 0 ? input.model : model
+ typeof input.model === "string" && input.model.trim().length > 0
+ ? input.model
+ : model
);
- const converted = convertMessages(input.messages, resolvedModel, toolNameMap);
- const explicitSystem = typeof input.system === "string" ? input.system : "";
- const system = [converted.system, explicitSystem].filter(Boolean).join("\n\n");
-
- const params: JsonRecord = {
+ const out: JsonRecord = {
+ ...input,
model: resolvedModel,
- messages: converted.messages,
- tools: convertTools(input.tools, toolNameMap),
- system,
- stream: true,
+ stream: stream === true,
};
- // Only forward max_tokens when the client actually supplied one. Omitting it
- // lets Command Code's upstream apply the model's own native default, so we
- // never invent a value (the old behavior, which sent the wrong number and got
- // DeepSeek V4 rejected with "Too big: expected number to be <=200000"). When
- // present, it is clamped to the endpoint ceiling so an oversized client value
- // degrades gracefully instead of 400ing.
+ // Forward max_tokens only when the client actually supplied a positive value
+ // (clamped to the endpoint ceiling). Omitting it lets the provider's upstream
+ // apply the model's own native default; a non-positive value such as -1
+ // ("let the server choose") must be omitted, NOT coerced to 1 (#5166).
const maxTokens = clampMaxTokens(input.max_tokens ?? input.max_completion_tokens);
+ delete out.max_tokens;
+ delete out.max_completion_tokens;
if (maxTokens !== undefined) {
- params.max_tokens = maxTokens;
- }
-
- for (const field of COMMAND_CODE_PASSTHROUGH_FIELDS) {
- const value = input[field];
- if (value !== undefined && value !== null) {
- params[field] = value;
- }
- }
-
- return {
- body: {
- config: {
- workingDir: "/workspace",
- date: new Date().toISOString().slice(0, 10),
- environment: "external",
- structure: [],
- isGitRepo: false,
- currentBranch: "",
- mainBranch: "",
- gitStatus: "",
- recentCommits: [],
- },
- memory: "",
- taste: "",
- skills: "",
- permissionMode: "standard",
- params,
- },
- toolNameMap,
- };
-}
-
-function parseStreamLine(line: string): unknown | undefined {
- let trimmed = line.trim();
- if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:")) return undefined;
- if (trimmed.startsWith("data:")) trimmed = trimmed.slice(5).trim();
- if (!trimmed || trimmed === "[DONE]") return undefined;
-
- try {
- return JSON.parse(trimmed);
- } catch (error) {
- console.warn(
- "[commandCode] stream line parse failed:",
- error instanceof Error ? error.message : String(error)
- );
- return undefined;
- }
-}
-
-function mapFinishReason(reason: unknown): "stop" | "length" | "tool_calls" {
- if (reason === "tool-calls" || reason === "tool_calls" || reason === "toolUse")
- return "tool_calls";
- if (
- reason === "length" ||
- reason === "max_tokens" ||
- reason === "max-tokens" ||
- reason === "max_output_tokens"
- ) {
- return "length";
- }
- return "stop";
-}
-
-function chatCompletionChunk(
- id: string,
- model: string,
- delta: JsonRecord,
- finishReason: unknown = null
-) {
- return {
- id,
- object: "chat.completion.chunk",
- created: Math.floor(Date.now() / 1000),
- model,
- choices: [{ index: 0, delta, finish_reason: finishReason }],
- };
-}
-
-function sse(data: unknown): Uint8Array {
- return encoder.encode(`data: ${JSON.stringify(data)}\n\n`);
-}
-
-type AggregateState = {
- content: string;
- reasoning: string;
- toolCalls: JsonRecord[];
- finishReason: "stop" | "length" | "tool_calls";
- usage: JsonRecord | null;
-};
-
-function firstRecord(record: JsonRecord, keys: readonly string[]): JsonRecord {
- for (const key of keys) {
- const value = record[key];
- if (isRecord(value)) return value;
- }
- return {};
-}
-
-function firstNumber(record: JsonRecord, keys: readonly string[]): number | undefined {
- for (const key of keys) {
- const value = numberValue(record[key]);
- if (value !== undefined) return value;
- }
- return undefined;
-}
-
-/** Keep earlier finish-step usage when the terminal finish event omits it. */
-function mergeCommandCodeUsage(previous: JsonRecord | null, next: unknown): JsonRecord | null {
- if (!isRecord(next)) return previous;
-
- const merged: JsonRecord = { ...(previous || {}), ...next };
- for (const key of [
- "inputTokenDetails",
- "input_token_details",
- "input_tokens_details",
- "prompt_tokens_details",
- "outputTokenDetails",
- "output_token_details",
- "output_tokens_details",
- "completion_tokens_details",
- "reasoningTokenDetails",
- "reasoning_token_details",
- ]) {
- const before = isRecord(previous?.[key]) ? previous[key] : {};
- const after = isRecord(next[key]) ? next[key] : {};
- if (Object.keys(before).length > 0 || Object.keys(after).length > 0) {
- merged[key] = { ...before, ...after };
- }
- }
- return merged;
-}
-
-function rememberCommandCodeUsage(state: AggregateState, event: JsonRecord): void {
- const usage =
- event.type === "finish-step"
- ? (event.usage ?? event.totalUsage)
- : (event.totalUsage ?? event.usage);
- state.usage = mergeCommandCodeUsage(state.usage, usage);
-}
-
-function applyEventToAggregate(
- event: JsonRecord,
- state: AggregateState,
- toolNameMap: Map
-): void {
- // Some Command Code protocol revisions attach usage to the terminal payload
- // without preserving the event type. Capture it before event-specific handling.
- rememberCommandCodeUsage(state, event);
-
- switch (event.type) {
- case "text-delta":
- state.content += stringValue(event.text) || "";
- break;
- case "reasoning-delta":
- state.reasoning += stringValue(event.text) || "";
- break;
- case "tool-call": {
- const args = recordOrEmpty(event.input ?? event.args ?? event.arguments);
- state.toolCalls.push({
- id: stringValue(event.toolCallId) || stringValue(event.id) || randomUUID(),
- type: "function",
- function: {
- name: clientToolName(
- stringValue(event.toolName) || stringValue(event.name) || "",
- toolNameMap
- ),
- arguments: JSON.stringify(args),
- },
- });
- break;
- }
- case "finish-step":
- break;
- case "finish":
- state.finishReason = mapFinishReason(event.finishReason);
- break;
- }
-}
-
-function applyEventToAggregateOrThrow(
- event: JsonRecord,
- state: AggregateState,
- toolNameMap: Map
-): void {
- if (event.type === "error") {
- const error = isRecord(event.error) ? event.error : {};
- throw new Error(
- stringValue(error.message) || stringValue(event.error) || "Command Code stream error"
- );
+ out.max_tokens = maxTokens;
}
- applyEventToAggregate(event, state, toolNameMap);
-}
-
-function usageFromCommandCode(usage: JsonRecord | null) {
- if (!usage) return undefined;
- const inputDetails = firstRecord(usage, [
- "inputTokenDetails",
- "input_token_details",
- "input_tokens_details",
- "prompt_tokens_details",
- ]);
- const outputDetails = firstRecord(usage, [
- "outputTokenDetails",
- "output_token_details",
- "output_tokens_details",
- "completion_tokens_details",
- ]);
- const reasoningDetails = firstRecord(usage, [
- "reasoningTokenDetails",
- "reasoning_token_details",
- "reasoning_tokens_details",
- ]);
- const cacheRead =
- firstNumber(usage, [
- "cachedInputTokens",
- "cached_input_tokens",
- "cacheReadInputTokens",
- "cache_read_input_tokens",
- "cacheReadTokens",
- "cache_read_tokens",
- "cached_tokens",
- ]) ??
- firstNumber(inputDetails, [
- "cachedTokens",
- "cached_tokens",
- "cacheReadTokens",
- "cache_read_tokens",
- ]);
- const noCache = firstNumber(inputDetails, ["noCacheTokens", "no_cache_tokens"]);
- // Command Code's totalUsage.inputTokens is the FULL prompt total and already
- // includes the cached portion (noCacheTokens + cacheReadTokens = inputTokens),
- // so we must NOT add cacheRead back — that would double-count. There is no
- // cache-write field in the upstream payload, so cache creation stays unset.
- const prompt =
- firstNumber(usage, ["inputTokens", "input_tokens", "promptTokens", "prompt_tokens"]) ??
- (noCache ?? 0) + (cacheRead ?? 0);
- const reasoning =
- firstNumber(usage, ["reasoningTokens", "reasoning_tokens"]) ??
- firstNumber(outputDetails, ["reasoningTokens", "reasoning_tokens"]) ??
- firstNumber(reasoningDetails, ["reasoningTokens", "reasoning_tokens"]);
- const textOutput = firstNumber(outputDetails, ["textTokens", "text_tokens"]);
- const completion =
- firstNumber(usage, [
- "outputTokens",
- "output_tokens",
- "completionTokens",
- "completion_tokens",
- ]) ?? (textOutput ?? 0) + (reasoning ?? 0);
- const total = firstNumber(usage, ["totalTokens", "total_tokens"]) ?? prompt + completion;
- const result: JsonRecord = {
- prompt_tokens: prompt,
- prompt_tokens_details: { cached_tokens: cacheRead ?? 0 },
- completion_tokens: completion,
- completion_tokens_details: { reasoning_tokens: reasoning ?? 0 },
- total_tokens: total,
- };
- // Surface the cache breakdown as informational fields so logUsage prints
- // `| cache_read=X | no_cache=Y` and appendRequestLog persists them. These are
- // NOT added to prompt_tokens (already included) — metering stays accurate.
- if (cacheRead !== undefined && cacheRead > 0) result.cache_read_input_tokens = cacheRead;
- if (noCache !== undefined && noCache > 0) result.no_cache_tokens = noCache;
- if (reasoning !== undefined && reasoning > 0) result.reasoning_tokens = reasoning;
- return result;
-}
-
-function createStreamResponse(
- upstream: Response,
- model: string,
- signal?: AbortSignal | null,
- toolNameMap: Map = new Map()
-): Response {
- const id = `chatcmpl-${randomUUID()}`;
- const reader = upstream.body?.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
- let sentRole = false;
- let closed = false;
- const state: AggregateState = {
- content: "",
- reasoning: "",
- toolCalls: [],
- finishReason: "stop",
- usage: null,
- };
-
- const stream = new ReadableStream({
- start(controller) {
- if (!reader) {
- controller.error(new Error("Command Code response missing body"));
- return;
- }
-
- const abort = () => {
- closed = true;
- reader.cancel().catch(() => undefined);
- controller.error(new DOMException("The operation was aborted", "AbortError"));
- };
- signal?.addEventListener("abort", abort, { once: true });
-
- const emitEvent = (event: unknown) => {
- if (!isRecord(event) || closed) return;
- rememberCommandCodeUsage(state, event);
- if (!sentRole) {
- sentRole = true;
- controller.enqueue(sse(chatCompletionChunk(id, model, { role: "assistant" })));
- }
-
- switch (event.type) {
- case "text-delta": {
- const text = stringValue(event.text) || "";
- if (text) controller.enqueue(sse(chatCompletionChunk(id, model, { content: text })));
- state.content += text;
- break;
- }
- case "reasoning-delta": {
- const text = stringValue(event.text) || "";
- if (text) {
- controller.enqueue(sse(chatCompletionChunk(id, model, { reasoning_content: text })));
- state.reasoning += text;
- }
- break;
- }
- case "tool-call": {
- const index = state.toolCalls.length;
- const args = recordOrEmpty(event.input ?? event.args ?? event.arguments);
- const toolCall = {
- id: stringValue(event.toolCallId) || stringValue(event.id) || randomUUID(),
- type: "function",
- function: {
- name: clientToolName(
- stringValue(event.toolName) || stringValue(event.name) || "",
- toolNameMap
- ),
- arguments: JSON.stringify(args),
- },
- };
- state.toolCalls.push(toolCall);
- controller.enqueue(
- sse(chatCompletionChunk(id, model, { tool_calls: [{ index, ...toolCall }] }))
- );
- break;
- }
- case "reasoning-end":
- break;
- case "finish-step":
- break;
- case "finish": {
- state.finishReason = mapFinishReason(event.finishReason);
- controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason)));
- // Emit a standards-compliant usage-only chunk (choices: []) before
- // [DONE] when upstream reported usage. stream.ts's extractUsage
- // recognizes this shape (see stream.ts:1661) and logs the ACTUAL
- // token counts (in/out/cache_read/no_cache) instead of estimates.
- const usagePayload = usageFromCommandCode(state.usage);
- if (usagePayload) {
- controller.enqueue(
- sse({
- id,
- object: "chat.completion.chunk",
- model,
- usage: usagePayload,
- choices: [],
- })
- );
- }
- controller.enqueue(encoder.encode("data: [DONE]\n\n"));
- closed = true;
- controller.close();
- reader.cancel().catch(() => undefined);
- break;
- }
- case "error": {
- const error = isRecord(event.error) ? event.error : {};
- throw new Error(
- stringValue(error.message) || stringValue(event.error) || "Command Code stream error"
- );
- }
- }
- };
-
- const pump = async () => {
- try {
- for (;;) {
- if (closed) return;
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split("\n");
- buffer = lines.pop() || "";
- for (const line of lines) emitEvent(parseStreamLine(line));
- }
- if (buffer.trim()) emitEvent(parseStreamLine(buffer));
- if (!closed) {
- if (!sentRole)
- controller.enqueue(sse(chatCompletionChunk(id, model, { role: "assistant" })));
- controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason)));
- controller.enqueue(encoder.encode("data: [DONE]\n\n"));
- controller.close();
- }
- } catch (error) {
- controller.error(error);
- } finally {
- signal?.removeEventListener("abort", abort);
- try {
- reader.releaseLock();
- } catch (error) {
- console.warn(
- "[commandCode] reader releaseLock failed:",
- error instanceof Error ? error.message : String(error)
- );
- }
- }
- };
-
- pump();
- },
- cancel() {
- closed = true;
- return reader?.cancel();
- },
- });
-
- return new Response(stream, {
- status: 200,
- headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" },
- });
-}
-
-async function createJsonResponse(
- upstream: Response,
- model: string,
- signal?: AbortSignal | null,
- toolNameMap: Map = new Map()
-): Promise {
- const reader = upstream.body?.getReader();
- if (!reader) throw new Error("Command Code response missing body");
-
- const decoder = new TextDecoder();
- let buffer = "";
- const state: AggregateState = {
- content: "",
- reasoning: "",
- toolCalls: [],
- finishReason: "stop",
- usage: null,
- };
-
- try {
- for (;;) {
- if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split("\n");
- buffer = lines.pop() || "";
- for (const line of lines) {
- const event = parseStreamLine(line);
- if (!isRecord(event)) continue;
- applyEventToAggregateOrThrow(event, state, toolNameMap);
- }
- }
- if (buffer.trim()) {
- const event = parseStreamLine(buffer);
- if (isRecord(event)) applyEventToAggregateOrThrow(event, state, toolNameMap);
- }
- } finally {
- try {
- await reader.cancel();
- } catch (error) {
- console.warn(
- "[commandCode] reader cancel failed:",
- error instanceof Error ? error.message : String(error)
- );
- }
- try {
- reader.releaseLock();
- } catch (error) {
- console.warn(
- "[commandCode] reader releaseLock failed:",
- error instanceof Error ? error.message : String(error)
- );
- }
- }
-
- const message: JsonRecord = { role: "assistant", content: state.content };
- if (state.reasoning) message.reasoning_content = state.reasoning;
- if (state.toolCalls.length > 0) message.tool_calls = state.toolCalls;
-
- const payload: JsonRecord = {
- id: `chatcmpl-${randomUUID()}`,
- object: "chat.completion",
- created: Math.floor(Date.now() / 1000),
- model,
- choices: [{ index: 0, message, finish_reason: state.finishReason }],
- };
- const usage = usageFromCommandCode(state.usage);
- if (usage) payload.usage = usage;
-
- return new Response(JSON.stringify(payload), {
- status: 200,
- headers: { "Content-Type": "application/json" },
- });
+ return { body: out };
}
export class CommandCodeExecutor extends BaseExecutor {
@@ -973,7 +118,7 @@ export class CommandCodeExecutor extends BaseExecutor {
buildUrl() {
const baseUrl = (this.config.baseUrl || "https://api.commandcode.ai").replace(/\/$/, "");
- return `${baseUrl}${this.config.chatPath || "/alpha/generate"}`;
+ return `${baseUrl}${this.config.chatPath || "/provider/v1/chat/completions"}`;
}
async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }: ExecuteInput) {
@@ -983,26 +128,17 @@ export class CommandCodeExecutor extends BaseExecutor {
const headers: Record = {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
- "x-command-code-version": COMMAND_CODE_VERSION,
- "x-cli-environment": "external",
- "x-project-slug": "pi-cc",
- "x-taste-learning": "false",
- "x-co-flag": "false",
- "x-session-id": randomUUID(),
+ Accept: stream ? "text/event-stream" : "application/json",
};
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
// The combo/single-model dispatch boundary does not always run
// sanitizeRequestForResolvedTarget before reaching this executor (combo
// path), and Command Code rejects unsupported reasoning_effort values
- // outright (e.g. "minimal" → 400 "expected one of low|medium|high|xhigh|max").
- // Sanitize here — the executor is the last line of defense for the wire body.
+ // outright. Sanitize here — the executor is the last line of defense for
+ // the wire body.
const sanitizedBody = sanitizeReasoningEffortForProvider(body, this.provider, model);
- const { body: transformedBody, toolNameMap } = buildCommandCodeBody(
- model,
- sanitizedBody,
- stream
- );
+ const { body: transformedBody } = buildOpenAiBody(model, sanitizedBody, stream);
const url = this.buildUrl();
const upstream = await fetch(url, {
method: "POST",
@@ -1028,10 +164,9 @@ export class CommandCodeExecutor extends BaseExecutor {
};
}
- const response = stream
- ? createStreamResponse(upstream, model, signal, toolNameMap)
- : await createJsonResponse(upstream, model, signal, toolNameMap);
-
- return { response, url, headers, transformedBody };
+ // The /provider/v1/chat/completions endpoint returns standard OpenAI-format
+ // SSE (stream) or JSON (non-stream) straight through, so the upstream
+ // Response passes through untouched — no AI-SDK/CLI event re-parsing needed.
+ return { response: upstream, url, headers, transformedBody };
}
-}
+}
\ No newline at end of file
diff --git a/open-sse/executors/copilot-m365-frames.ts b/open-sse/executors/copilot-m365-frames.ts
index 7260afebd7aa..f4172b11904e 100644
--- a/open-sse/executors/copilot-m365-frames.ts
+++ b/open-sse/executors/copilot-m365-frames.ts
@@ -30,17 +30,45 @@ export const HANDSHAKE_REQUEST = { protocol: "json", version: 1 } as const;
export const KEEPALIVE_PING = { type: 6 } as const;
/**
- * Allowed message types observed in the 2026-08 recapture of the working
- * `m365.cloud.microsoft/chat` client (#10718). The old 11-entry list is no longer
- * seen on the wire — the stale shape gets closed immediately after the type:4.
+ * Allowed message types observed in a 2026-08-21 live capture of a working
+ * `m365.cloud.microsoft/chat` session (issue: "Stream ended before producing a
+ * non-ping SSE event" on every individual/consumer M365 Copilot call). The
+ * #10718 6-entry shape above no longer produces a `type:1 target:"update"`
+ * frame at all — the socket only replies with SignalR keepalive pings and then
+ * closes, which is exactly what surfaces client-side as that generic stream
+ * error. 30 entries, up from 6.
*/
export const ALLOWED_MESSAGE_TYPES = [
"Chat",
"Suggestion",
+ "InternalSearchQuery",
"Disengaged",
+ "InternalLoaderMessage",
"Progress",
+ "GeneratedCode",
+ "RenderCardRequest",
+ "AdsQuery",
+ "SemanticSerp",
+ "GenerateContentQuery",
+ "GenerateGraphicArt",
+ "SearchQuery",
+ "ConfirmationCard",
+ "AuthError",
+ "DeveloperLogs",
+ "TriggerPlugin",
+ "HintInvocation",
+ "MemoryUpdate",
"EndOfRequest",
- "InternalLoaderMessage",
+ "TriggerConfirmation",
+ "ResumeInvokeAction",
+ "ResumeUserInputRequest",
+ "TriggerUserInputRequest",
+ "EscapeHatch",
+ "TriggerPluginAuth",
+ "ResumePluginAuth",
+ "SideBySide",
+ "ReferencesListComplete",
+ "SwitchRespondingEndpoint",
] as const;
/**
@@ -78,19 +106,26 @@ export const M365_ENTERPRISE_EXTRA_MESSAGE_TYPES = [
] as const;
/**
- * Individual / EDU option sets from the 2026-08 recapture (#10718) — 14 entries.
- * The previous 25-entry consumer/MSA set (enable_msa_user, pdnascan, cwc_code_*,
- * …) is no longer observed on the wire and belongs to the shape the substrate
- * now drops silently.
+ * Individual / EDU option sets from a 2026-08-21 live capture — 34 entries, up
+ * from the #10718 14-entry shape (which itself superseded an earlier 25-entry
+ * shape). Each recapture so far has been additive/reshuffled rather than a
+ * wholesale replacement — treat this as the protocol continuing to drift, not
+ * a one-time fix; a future capture may again need to update this list.
*/
export const M365_DEFAULT_OPTION_SETS = [
"search_result_progress_messages_with_search_queries",
"update_textdoc_response_after_streaming",
"deepleo_networking_timeout_10minutes_canmore",
"cwc_flux_image",
+ "cwc_code_interpreter",
+ "cwc_code_interpreter_amsfix",
"cwcfluxgptv",
"flux_v3_gptv_enable_upload_multi_image_in_turn_wo_ch",
"gptvnorm2048",
+ "cwc_code_interpreter_citation_fix",
+ "code_interpreter_interactive_charts",
+ "cwc_code_interpreter_interactive_charts_inline_image",
+ "code_interpreter_matplotlib_patching",
"cwc_fileupload_odb",
"update_memory_plugin",
"add_custom_instructions",
@@ -98,6 +133,20 @@ export const M365_DEFAULT_OPTION_SETS = [
"flux_v3_progress_messages",
"enable_batch_token_processing",
"enable_gg_gpt",
+ "async_client_interaction",
+ "flux_v3_references",
+ "flux_v3_references_entities",
+ "flux_v3_references_ci",
+ "add_filestore_filetype",
+ "cwc_code_interpreter_citation_sourceannotations",
+ "cdxcwc_code_interpreter_hallucinated_url_filter",
+ "flux_v3_image_gen_enable_dimensions",
+ "flux_v3_image_gen_enable_non_watermarked_storage",
+ "flux_v3_image_gen_enable_icon_dimensions",
+ "flux_v3_image_gen_enable_system_text_with_params",
+ "flux_v3_image_gen_enable_designer_dimensions_meta_prompting_in_system_prompts",
+ "flux_v3_image_gen_enable_story",
+ "rich_responses",
] as const;
/** Append the record separator to a JSON-serializable frame. */
@@ -433,12 +482,14 @@ export function resolveChatInvocationOverrides(tier: string | undefined): {
}
return {
optionsSets: [...M365_DEFAULT_OPTION_SETS],
- // #10718 — the 2026-08 recapture sends tone:"magic" (lowercase) on the
- // individual/EDU surface; the old "" default is part of the dropped shape.
- tone: "magic",
+ // 2026-08-21 capture — the individual/consumer surface now sends "Magic"
+ // (capitalized), matching the enterprise tone literal. The #10718
+ // lowercase "magic" is part of the shape that gets silently dropped.
+ tone: "Magic",
allowedMessageTypes: ALLOWED_MESSAGE_TYPES,
- // Omitted entirely on the individual/EDU wire (see ChatInvocationOptions).
- disconnectBehavior: undefined,
+ // 2026-08-21 capture — disconnectBehavior:"continue" is now present on the
+ // individual/consumer wire too, not just enterprise (see ChatInvocationOptions).
+ disconnectBehavior: "continue",
};
}
@@ -467,16 +518,33 @@ export function resolveToneForModel(model: string | undefined): string | undefin
/**
* Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated).
- * Mirrors the argument shape recaptured from a working `m365.cloud.microsoft/chat`
- * client in 2026-08 (#10718). Notable differences from the pre-#10718 shape: a
- * populated `clientInfo` + `productThreadType:"Office"`, a `conversationId`
- * matching the WS URL query, a rich `message` object, and no
- * `spokenTextMode` / `extraExtensionParameters` / `isSbsSupported` /
- * `renderReferencesBehindEOS` / `disconnectBehavior` — none of those are still
- * observed on the wire, and the stale shape gets closed immediately after the
- * invocation.
+ * Base shape from the #10718 recapture (populated `clientInfo` +
+ * `productThreadType:"Office"`, a `conversationId` matching the WS URL query, a
+ * rich `message` object), extended per a 2026-08-21 live capture that found the
+ * #10718 shape alone no longer produces a `type:1 target:"update"` frame — the
+ * socket only replies with keepalive pings and closes. The additions below
+ * (richer `clientInfo`, non-empty `plugins`, `extraExtensionParameters`,
+ * `isSbsSupported`, `renderReferencesBehindEOS`,
+ * `message.connectedFederatedConnections`, and `disconnectBehavior` on every
+ * tier) are exactly the fields the 2026-08-21 capture had that this shape was
+ * missing; the #10718 fields (`conversationId`, `productThreadType`,
+ * `toolChoice`, `message.attachments`) are kept as-is since removing them was
+ * not verified against a live socket.
*/
export function buildChatInvocation(opts: ChatInvocationOptions): Record {
+ const clientInfo = {
+ clientAppName: "Office",
+ clientPlatform: "mcmcopilot-web",
+ clientEntrypoint: "mcmcopilot-officeweb",
+ clientSessionId: opts.sessionId,
+ ProductCategory: "Chat",
+ clientAppType: "Web",
+ productEntryPoint: "ChatPanel",
+ deviceOS: "Windows",
+ deviceType: "Desktop",
+ clientPlatformVersion: "10",
+ };
+
return {
type: 4,
target: "chat",
@@ -487,17 +555,17 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record
+ * Endpoint: POST https://chat.minimax.io/v4/api/chat/msg?
* Auth: `token` header — value read from the site's `_token` localStorage
* entry, plus a per-request `yy` signature header.
* Body: multipart/form-data — characterID, msgContent, chatID, searchMode.
@@ -33,7 +33,7 @@ import { createHash } from "node:crypto";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { makeExecutorErrorResult as makeErrorResult, sanitizeErrorMessage } from "../utils/error.ts";
-const BASE_URL = "https://www.hailuo.ai";
+const BASE_URL = "https://chat.minimax.io";
const API_PATH = "/v4/api/chat/msg";
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
diff --git a/open-sse/executors/kimi-web.ts b/open-sse/executors/kimi-web.ts
index 8f9c13c33282..f2c389822c31 100644
--- a/open-sse/executors/kimi-web.ts
+++ b/open-sse/executors/kimi-web.ts
@@ -1,10 +1,10 @@
/**
- * KimiWebExecutor — Moonshot AI Chat via www.kimi.com (international)
+ * KimiWebExecutor — Moonshot AI Chat via www.kimi.ai (international)
*
* Routes requests through Kimi's consumer chat API on the international domain.
* Originally this executor targeted `kimi.moonshot.cn` (mainland-CN consumer
* chat). That domain now redirects every visitor outside CN to
- * `https://www.kimi.com/`, which speaks a completely different API surface:
+ * `https://www.kimi.ai/`, which speaks a completely different API surface:
*
* - Endpoint: POST /apiv2/kimi.gateway.chat.v1.ChatService/Chat
* - Protocol: Connect-RPC (unary envelope framing — 5-byte header + JSON)
@@ -326,7 +326,7 @@ export class KimiWebExecutor extends BaseExecutor {
if (!accessToken) {
return makeErrorResult(
400,
- "Missing Kimi access_token — log in at www.kimi.com and capture access_token from localStorage.",
+ "Missing Kimi access_token — log in at www.kimi.ai and capture access_token from localStorage.",
body,
CHAT_URL
);
@@ -410,10 +410,7 @@ export class KimiWebExecutor extends BaseExecutor {
const refreshToken =
credentials?.refreshToken || credentials?.providerSpecificData?.refreshToken;
if (refreshToken && typeof refreshToken === "string") {
- const refreshRes = await exchangeKimiRefreshToken(
- refreshToken,
- getKimiWebBaseUrl()
- );
+ const refreshRes = await exchangeKimiRefreshToken(refreshToken, getKimiWebBaseUrl());
if (refreshRes.success && refreshRes.accessToken) {
accessToken = refreshRes.accessToken;
const retryHeaders = this.buildKimiHeaders(accessToken);
diff --git a/open-sse/executors/nlpcloud.ts b/open-sse/executors/nlpcloud.ts
index d413b5a683b7..e212a38efeb7 100644
--- a/open-sse/executors/nlpcloud.ts
+++ b/open-sse/executors/nlpcloud.ts
@@ -471,6 +471,7 @@ export class NlpCloudExecutor extends BaseExecutor {
}
try {
+ this.assertOutboundUrlAllowed(url); // GHSA-4f49: nlpcloud has its own fetch path
const response = await fetch(url, {
method: "POST",
headers,
diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts
index b5f5ad7880f7..0829bfa871d3 100644
--- a/open-sse/executors/opencode.ts
+++ b/open-sse/executors/opencode.ts
@@ -1,6 +1,6 @@
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
-import { getModelTargetFormat } from "../config/providerModels.ts";
+import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
import {
injectReasoningContentForThinkingModel,
isThinkingMessageModel,
@@ -15,6 +15,8 @@ import {
markSuccess as markAccountSuccess,
maskAccountId,
isNetworkErrorRotatable,
+ isEmptyUpstreamRejection,
+ extractChatcmplId,
} from "./accountRotation.ts";
import { isNetworkRotationSharedEgressGuardEnabled } from "@/shared/utils/featureFlags";
@@ -125,6 +127,24 @@ export function isPremiumOpencodeModel(model: string, provider: string): boolean
return !OPENCODE_FREE_MODELS.has(model);
}
+/**
+ * Resolves the registry `targetFormat` for a model, aliasing `provider` first.
+ *
+ * `PROVIDER_MODELS` is keyed by the provider's public ALIAS (e.g. `"oc"`), not its
+ * raw registry id (e.g. `"opencode"`) — mirrors `resolveChatCoreTargetFormat()`
+ * (`handlers/chatCore/targetFormat.ts`), which already aliases before calling
+ * `getModelTargetFormat()`. Calling it with the raw id here made every entry miss
+ * silently (fell through to `"openai"`), while chatCore's own request-body
+ * translation (correctly aliased) still switched to the Responses API shape for
+ * `targetFormat:"openai-responses"` models — sending a Responses-shaped body to
+ * the `/chat/completions` URL this executor's own `buildUrl()` kept selecting.
+ * Exported for testability.
+ */
+export function resolveOpencodeTargetFormat(provider: string, model: string): string {
+ const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
+ return getModelTargetFormat(alias, model) || "openai";
+}
+
export class OpencodeExecutor extends BaseExecutor {
/** Delegates to `isPremiumOpencodeModel`. Exported for testability. */
static isPremiumModel(model: string, provider: string): boolean {
@@ -193,7 +213,10 @@ export class OpencodeExecutor extends BaseExecutor {
return pickRotatableAccount(this.accounts, this);
}
- private markCooldown(account: OpencodeAccountState, kind: "transient" | "terminal" = "transient"): void {
+ private markCooldown(
+ account: OpencodeAccountState,
+ kind: "transient" | "terminal" = "transient"
+ ): void {
markAccountCooldown(account, kind);
}
@@ -202,7 +225,7 @@ export class OpencodeExecutor extends BaseExecutor {
}
async execute(input: ExecuteInput) {
- this._requestFormat = getModelTargetFormat(this.provider, input.model) || "openai";
+ this._requestFormat = resolveOpencodeTargetFormat(this.provider, input.model);
// #8681: Gate premium opencode models behind a usable API key.
// When the connection is keyless (no apiKey, no accessToken) and the model
@@ -232,14 +255,41 @@ export class OpencodeExecutor extends BaseExecutor {
try {
this.syncAccountsFromCredentials(input.credentials);
+ const { log } = input;
const hasProxies = this.accounts.some((a) => a.proxy !== null);
- // Fast path: no multi-account proxy wiring configured → original behavior.
+ // Fast path: no multi-account proxy wiring configured → original behavior,
+ // plus exactly ONE bounded retry when the upstream answers a 400 empty
+ // rejection (same predicate and logging as the rotation loop). Everything
+ // else passes untouched: this path deliberately preserves BaseExecutor's
+ // intra-URL 429 retries (no skipUpstreamRetry here).
if (this.accounts.length === 1 && !hasProxies) {
- return await super.execute(input);
+ const single = (await super.execute(input)) as HttpExecuteResult;
+ if (single.response.status === 400) {
+ let bodyText: string | null = null;
+ try {
+ bodyText = await single.response.clone().text();
+ } catch {
+ log?.debug?.("OPENCODE", "body read failed on direct account");
+ }
+ if (bodyText !== null) {
+ if (isEmptyUpstreamRejection(400, bodyText)) {
+ const chatcmplId = extractChatcmplId(bodyText);
+ log?.warn?.(
+ "OPENCODE",
+ `upstream empty rejection on direct account (${chatcmplId}), retrying once…`
+ );
+ return await super.execute(input);
+ }
+ log?.debug?.(
+ "OPENCODE",
+ "400 without error field, signature not matched on direct account — observing"
+ );
+ }
+ }
+ return single;
}
- const { log } = input;
// This loop only ever dispatches through super.execute() (the HTTP request
// path), which always resolves the object-shaped arm of ExecutorExecuteResult
// — the bare-Response arm belongs to web/scraping executors only (base.ts:290).
@@ -256,8 +306,13 @@ export class OpencodeExecutor extends BaseExecutor {
// network call, but proxied accounts (independent egress) are still
// tried normally.
let sharedEgressDown = false;
+ // Bounded extra attempts for empty upstream rejections: +1 for a single
+ // account (retry the same one), none for a multi-account fleet (rotation
+ // through the accounts is the retry). Avoids an unbounded loop on a
+ // persistently malformed upstream.
+ const emptyRejectionBudget = this.accounts.length === 1 ? 1 : 0;
- for (let attempt = 0; attempt < this.accounts.length; attempt++) {
+ for (let attempt = 0; attempt < this.accounts.length + emptyRejectionBudget; attempt++) {
const account = this.pickAccount();
const masked = maskAccountId(account.fingerprint);
@@ -333,6 +388,34 @@ export class OpencodeExecutor extends BaseExecutor {
continue;
}
+ // Empty upstream rejection (malformed 400: no error field, no real
+ // content, finish_reason null — see isEmptyUpstreamRejection). Rotate/
+ // retry instead of propagating it as a fatal success: the observed
+ // envelope was marking subagent sessions as failed. Read the body ONLY
+ // for a 400 (never a 200/streaming — that would buffer the good path);
+ // classify, log, and continue. Neitheries markCooldown nor markSuccess:
+ // the failure is upstream's, not this account's.
+ if (status === 400) {
+ let bodyText: string | null = null;
+ try {
+ bodyText = await result.response.clone().text();
+ } catch {
+ log?.debug?.("OPENCODE", "body read failed on empty rejection check");
+ }
+ if (bodyText !== null && isEmptyUpstreamRejection(400, bodyText)) {
+ const chatcmplId = extractChatcmplId(bodyText);
+ log?.warn?.(
+ "OPENCODE",
+ `upstream empty rejection on account ${masked} (${chatcmplId}), rotating to next…`
+ );
+ continue;
+ }
+ // A 400 carrying a real error (or non-empty content): propagate
+ // immediately, untouched — same as before this change.
+ this.markSuccess(account);
+ return result;
+ }
+
this.markSuccess(account);
return result;
}
diff --git a/open-sse/executors/zcode.ts b/open-sse/executors/zcode.ts
index 0841b4daa8e3..8f0a98ac1420 100644
--- a/open-sse/executors/zcode.ts
+++ b/open-sse/executors/zcode.ts
@@ -2,7 +2,7 @@ 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 { ZCODE_MODELS } from "../config/providers/registry/zcode/index.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";
@@ -12,8 +12,8 @@ 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";
+const ZCODE_MODEL_ALLOWLIST = new Set(ZCODE_MODELS.map((model) => model.id));
+const DEFAULT_ZCODE_MODEL = ZCODE_MODELS[0]?.id || "glm-5.2";
type JsonRecord = Record;
type OpenAIMsg = { role?: string; content?: unknown };
diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts
index 8f11373b1fbf..fea8640f9d3c 100644
--- a/open-sse/handlers/chatCore.ts
+++ b/open-sse/handlers/chatCore.ts
@@ -516,7 +516,7 @@ export async function handleChatCore({
conversationId = null,
modelPinned = false,
skipResourcePressureGuard = false,
- reasoningTransportFallback = "skip",
+ reasoningTransportFallback = "drop",
managedLease = null,
}) {
let { provider, model, extendedContext } = modelInfo;
@@ -1213,7 +1213,7 @@ export async function handleChatCore({
provider,
preserveEncryptedReasoning:
credentials?.providerSpecificData?.preserveEncryptedReasoning === true,
- onIncompatibleReasoning: reasoningTransportFallback === "drop" ? "drop" : "reject",
+ onIncompatibleReasoning: reasoningTransportFallback === "skip" ? "reject" : "drop",
}
);
if (policy.incompatibleReasoning) {
diff --git a/open-sse/handlers/rerank.ts b/open-sse/handlers/rerank.ts
index 747e1ce50662..452e6f3500e0 100644
--- a/open-sse/handlers/rerank.ts
+++ b/open-sse/handlers/rerank.ts
@@ -199,6 +199,8 @@ export async function handleRerank({
return_documents,
credentials,
connectionId = null,
+ apiKeyId = null,
+ apiKeyName = null,
}) {
const startTime = Date.now();
if (!model) return errorResponse(400, "model is required");
@@ -267,10 +269,23 @@ export async function handleRerank({
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
- return errorResponse(
- res.status,
- errData.message || errData.error?.message || `Provider returned HTTP ${res.status}`
- );
+ const errorMessage =
+ errData.message || errData.error?.message || `Provider returned HTTP ${res.status}`;
+ saveCallLog({
+ method: "POST",
+ path: "/v1/rerank",
+ status: res.status,
+ model: `${providerId}/${modelId}`,
+ provider: providerId,
+ connectionId: connectionId || undefined,
+ duration: Date.now() - startTime,
+ requestBody,
+ responseBody: errData,
+ error: errorMessage,
+ apiKeyId: apiKeyId || undefined,
+ apiKeyName: apiKeyName || undefined,
+ }).catch(() => {});
+ return errorResponse(res.status, errorMessage);
}
const data = await res.json();
@@ -289,10 +304,13 @@ export async function handleRerank({
status: 200,
model: `${providerId}/${modelId}`,
provider: providerId,
+ connectionId: connectionId || undefined,
duration: Date.now() - startTime,
tokens: { prompt_tokens: 0, completion_tokens: 0 },
- responseBody: { results_count: Array.isArray(result?.results) ? result.results.length : 0 },
- connectionId,
+ requestBody,
+ responseBody: result,
+ apiKeyId: apiKeyId || undefined,
+ apiKeyName: apiKeyName || undefined,
}).catch(() => {});
const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" });
diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts
index 01bdd4c14a74..43e03d919d64 100644
--- a/open-sse/handlers/responseTranslator.ts
+++ b/open-sse/handlers/responseTranslator.ts
@@ -10,6 +10,7 @@ import {
caseInsensitiveToolNameLookup,
restoreOpenAIToolNames,
} from "../translator/helpers/toolCallHelper.ts";
+import { restoreClaudeToolName } from "../services/claudeCodeToolRemapper.ts";
import { extractReplayableResponsesReasoningText } from "../services/reasoningInputPolicy.ts";
import { sanitizeToolId } from "../translator/helpers/schemaCoercion.ts";
@@ -631,7 +632,7 @@ export function translateNonStreamingResponse(
// Phase 3: Translate from OpenAI back to Client Source format
if (sourceFormat === FORMATS.CLAUDE && sourceFormat !== targetFormat) {
- return convertOpenAINonStreamingToClaude(toRecord(intermediateOpenAI));
+ return convertOpenAINonStreamingToClaude(toRecord(intermediateOpenAI), toolNameMap ?? null);
}
// Gemini-family clients (Gemini, Antigravity): the streaming SSE path already
@@ -667,8 +668,18 @@ function resolveReasoningText(messageObj: JsonRecord): string {
/**
* Helper to convert an OpenAI chat.completion JSON object to Claude format for non-streaming.
+ *
+ * `toolNameMap` carries request-side aliases; when it does not resolve a name,
+ * `restoreClaudeToolName` upgrades known Claude Code tools to their canonical
+ * PascalCase ("bash" → "Bash", "croncreate" → "CronCreate"). Without this, a
+ * non-streaming upstream JSON body (or a stream:true request the upstream
+ * answered with application/json) reaches Claude Code with lowercase tool_use
+ * names the CLI rejects as "No such tool available".
*/
-function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonRecord {
+function convertOpenAINonStreamingToClaude(
+ openaiResponse: JsonRecord,
+ toolNameMap?: Map | null
+): JsonRecord {
const choices = openaiResponse.choices as unknown[] | undefined;
const isChoicesArray = Array.isArray(choices);
if (!isChoicesArray && openaiResponse.object !== "chat.completion") {
@@ -717,7 +728,7 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco
content.push({
type: "tool_use",
id: sanitizeToolId(rawId),
- name: toString(fn.name),
+ name: restoreClaudeToolName(toString(fn.name), toolNameMap ?? null),
input:
typeof fn.arguments === "string" ? JSON.parse(fn.arguments || "{}") : fn.arguments || {},
});
diff --git a/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts b/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts
index 8b7f3cee3240..50c16fa9665d 100644
--- a/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts
+++ b/open-sse/mcp-server/__tests__/glmCodingProviderConfig.test.ts
@@ -106,6 +106,29 @@ describe("GLM Coding provider registry surfaces", () => {
]);
});
+ it("declares exact GLM reasoning-effort tiers across every shared GLM provider", () => {
+ const routedTiers = new Map([
+ ["glm-5.3", ["low", "high", "max"]],
+ ["glm-5.3-high", ["high"]],
+ ["glm-5.3-low", ["low"]],
+ ["glm-5.2", ["high", "max"]],
+ ["glm-5.2-high", ["high"]],
+ ["glm-5.2-max", ["max"]],
+ ]);
+
+ for (const provider of ["glm", "glm-cn", "glmt"]) {
+ for (const model of getModelsByProviderId(provider)) {
+ expect(model.supportedThinkingEfforts, `${provider}/${model.id} effort tiers`).toEqual(
+ routedTiers.get(model.id) ?? []
+ );
+ }
+ }
+
+ for (const model of getModelsByProviderId("zcode")) {
+ expect(model.supportedThinkingEfforts, `zcode/${model.id} effort tiers`).toEqual([]);
+ }
+ });
+
it("registers GLM-5.2 with correct specs and effort tier aliases", () => {
const models = getModelsByProviderId("glm");
const get = (id: string) => models.find((m) => m.id === id);
diff --git a/open-sse/mcp-server/__tests__/mcp-runtime-blocked-provider-schema.test.ts b/open-sse/mcp-server/__tests__/mcp-runtime-blocked-provider-schema.test.ts
new file mode 100644
index 000000000000..9f55c6a52084
--- /dev/null
+++ b/open-sse/mcp-server/__tests__/mcp-runtime-blocked-provider-schema.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect } from "vitest";
+import { createMcpServer } from "../server";
+import { buildWebSearchInputSchema } from "../schemas/tools";
+import { getActiveSearchProviders } from "../schemas/providerEnums";
+
+interface ToolWithSchema {
+ inputSchema: {
+ safeParse: (arg: unknown) => { success: boolean };
+ };
+}
+
+describe("MCP Dynamic Runtime Schema Plumbing", () => {
+ it("getActiveSearchProviders excludes blocked providers dynamically by id or alias", () => {
+ const allProviders = getActiveSearchProviders([]);
+ expect(allProviders).toContain("serper-search");
+ expect(allProviders).toContain("brave-search");
+
+ const filteredProviders = getActiveSearchProviders(["serper", "brave"]);
+ expect(filteredProviders).not.toContain("serper-search");
+ expect(filteredProviders).not.toContain("brave-search");
+ expect(filteredProviders.length).toBeGreaterThan(0);
+ });
+
+ it("buildWebSearchInputSchema excludes blocked providers from Zod enum", () => {
+ const fullSchema = buildWebSearchInputSchema([]);
+ const fullParsed = fullSchema.safeParse({ query: "test", provider: "serper-search" });
+ expect(fullParsed.success).toBe(true);
+
+ const blockedSchema = buildWebSearchInputSchema(["serper"]);
+ const blockedParsed = blockedSchema.safeParse({ query: "test", provider: "serper-search" });
+ expect(blockedParsed.success).toBe(false);
+ });
+
+ it("createMcpServer with blockedProviders option registers dynamic tool schema", async () => {
+ const server = createMcpServer({ blockedProviders: ["serper", "brave"] });
+ expect(server).toBeTruthy();
+
+ const registeredTools = (
+ server as unknown as { _registeredTools: Record }
+ )._registeredTools;
+ expect(registeredTools).toBeTruthy();
+
+ const webSearchTool = registeredTools["omniroute_web_search"];
+ expect(webSearchTool).toBeTruthy();
+
+ const parsedWithUnblocked = webSearchTool.inputSchema.safeParse({
+ query: "test",
+ provider: "perplexity-search",
+ });
+ expect(parsedWithUnblocked.success).toBe(true);
+
+ const parsedWithBlocked = webSearchTool.inputSchema.safeParse({
+ query: "test",
+ provider: "serper-search",
+ });
+ expect(parsedWithBlocked.success).toBe(false);
+ });
+});
diff --git a/open-sse/mcp-server/schemas/providerEnums.ts b/open-sse/mcp-server/schemas/providerEnums.ts
index adb8da067e79..61e4648c0823 100644
--- a/open-sse/mcp-server/schemas/providerEnums.ts
+++ b/open-sse/mcp-server/schemas/providerEnums.ts
@@ -1,12 +1,16 @@
import { SEARCH_PROVIDERS } from "../../config/searchRegistry";
+import { isProviderBlockedByIdOrAlias } from "../../../src/shared/utils/noAuthProviders";
/**
* Dynamically generates a tuple of active search provider IDs for Zod enums.
- * Filters out any providers marked as disabled in the registry.
+ * Filters out any providers marked as disabled or blocked in the security policy.
*/
-export function getActiveSearchProviders(): [string, ...string[]] {
+export function getActiveSearchProviders(blockedProviders: string[] = []): [string, ...string[]] {
const activeProviders = Object.values(SEARCH_PROVIDERS)
- .filter((provider) => !provider.disabled)
+ .filter(
+ (provider) =>
+ !provider.disabled && !isProviderBlockedByIdOrAlias(provider.id, blockedProviders)
+ )
.map((provider) => provider.id);
if (activeProviders.length === 0) {
diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts
index 7b8e459180e0..d8d83c17ea5d 100644
--- a/open-sse/mcp-server/schemas/tools.ts
+++ b/open-sse/mcp-server/schemas/tools.ts
@@ -462,25 +462,29 @@ export const listModelsCatalogTool: McpToolDefinition<
};
// --- Tool 10: omniroute_web_search ---
-export const webSearchInput = z.object({
- query: z
- .string()
- .min(1, "Query is required")
- .max(500, "Query must be 500 characters or fewer")
- .describe("The search query string"),
- max_results: z
- .number()
- .int()
- .min(1)
- .max(20)
- .default(5)
- .describe("Maximum number of search results to return"),
- search_type: z.enum(["web", "news"]).default("web").describe("Type of search to perform"),
- provider: z
- .enum(getActiveSearchProviders())
- .optional()
- .describe("Specific search provider to use"),
-});
+export function buildWebSearchInputSchema(blockedProviders: string[] = []) {
+ return z.object({
+ query: z
+ .string()
+ .min(1, "Query is required")
+ .max(500, "Query must be 500 characters or fewer")
+ .describe("The search query string"),
+ max_results: z
+ .number()
+ .int()
+ .min(1)
+ .max(20)
+ .default(5)
+ .describe("Maximum number of search results to return"),
+ search_type: z.enum(["web", "news"]).default("web").describe("Type of search to perform"),
+ provider: z
+ .enum(getActiveSearchProviders(blockedProviders))
+ .optional()
+ .describe("Specific search provider to use"),
+ });
+}
+
+export const webSearchInput = buildWebSearchInputSchema();
export const webSearchOutput = z.object({
id: z.string(),
diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts
index e5f92a48f189..16e4eb8832eb 100644
--- a/open-sse/mcp-server/server.ts
+++ b/open-sse/mcp-server/server.ts
@@ -18,6 +18,7 @@ import {
costReportInput,
listModelsCatalogInput,
webSearchInput,
+ buildWebSearchInputSchema,
xSearchInput,
webFetchInput,
simulateRouteInput,
@@ -720,7 +721,24 @@ async function handleWebFetch(args: {
}
}
-export function createMcpServer(): McpServer {
+export interface CreateMcpServerOptions {
+ blockedProviders?: string[] | (() => string[]);
+}
+
+export function createMcpServer(options?: CreateMcpServerOptions): McpServer {
+ const resolveBlockedProviders = (): string[] => {
+ if (typeof options?.blockedProviders === "function") {
+ return options.blockedProviders();
+ }
+ if (Array.isArray(options?.blockedProviders)) {
+ return options.blockedProviders;
+ }
+ return [];
+ };
+
+ const blockedProviders = resolveBlockedProviders();
+ const dynamicWebSearchInput = buildWebSearchInputSchema(blockedProviders);
+
const server = new McpServer({
name: "omniroute",
version: process.env.npm_package_version || "1.8.1",
@@ -1044,10 +1062,14 @@ export function createMcpServer(): McpServer {
{
description:
"Performs a web search using OmniRoute's search gateway. Supports multiple providers (Serper, Brave, Perplexity, Exa, Tavily) with automatic failover. Returns search results with titles, URLs, snippets, and position data.",
- inputSchema: webSearchInput,
+ inputSchema: dynamicWebSearchInput,
},
withScopeEnforcement("omniroute_web_search", (args) =>
- handleWebSearch(webSearchInput.parse(args))
+ // Resolve per invocation (not the startup snapshot above) so a resolver
+ // function passed via CreateMcpServerOptions sees policy changes without
+ // a server rebuild. The advertised inputSchema stays a creation-time
+ // snapshot — MCP clients fetch it once at tools/list.
+ handleWebSearch(buildWebSearchInputSchema(resolveBlockedProviders()).parse(args))
)
);
diff --git a/open-sse/mcp-server/tools/memoryTools.ts b/open-sse/mcp-server/tools/memoryTools.ts
index 16c835fd8e51..12a8c99c0d3b 100644
--- a/open-sse/mcp-server/tools/memoryTools.ts
+++ b/open-sse/mcp-server/tools/memoryTools.ts
@@ -10,16 +10,24 @@ import {
import { resolveMcpCallerApiKeyId } from "../mcpCallerIdentity.ts";
/**
- * Resolve the memory owner id for an MCP tool call:
- * explicit arg wins, otherwise fall back to the authenticated caller's
- * principal id (HTTP auth headers on SSE/Streamable HTTP transports,
- * OMNIROUTE_API_KEY env var on stdio). Keeps MCP-stored memories under
- * the same owner id that chat-context memory uses, so retrieval in the
- * chat pipeline finds entries written via MCP.
+ * Resolve the memory owner id for an MCP tool call.
+ *
+ * The authenticated caller's principal ALWAYS wins over a caller-supplied
+ * `apiKeyId` — otherwise any MCP caller could read, write, or delete another
+ * principal's memories by putting a different id in the tool arguments
+ * (GHSA-cpv3-xr7r-xf8q, IDOR). The caller is resolved from the per-request HTTP
+ * auth headers on SSE / Streamable HTTP transports, or from OMNIROUTE_API_KEY on
+ * stdio. The explicit argument is only honored as a fallback when no caller can
+ * be resolved (a bare local stdio process with no configured key — already
+ * trusted), preserving the local-tooling flow. Keeps MCP-stored memories under
+ * the same owner id that chat-context memory uses, so retrieval in the chat
+ * pipeline finds entries written via MCP.
*/
async function resolveMemoryOwnerId(explicit?: string): Promise {
+ const caller = await resolveMcpCallerApiKeyId().catch(() => undefined);
+ if (caller) return caller;
if (explicit && explicit.trim() !== "") return explicit.trim();
- return (await resolveMcpCallerApiKeyId().catch(() => undefined)) || "mcp";
+ return "mcp";
}
export const MemorySearchSchema = z.object({
diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts
index d63c58e94ce9..63407a38fcc8 100644
--- a/open-sse/services/accountFallback.ts
+++ b/open-sse/services/accountFallback.ts
@@ -21,7 +21,11 @@ import {
honorsRuleLockScope,
} from "../config/providerErrorRules.ts";
import * as rot from "./rotationConfig.ts";
-import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts";
+import {
+ getPassthroughProviders,
+ getProviderCategory,
+ isLocalProvider,
+} from "../config/providerRegistry.ts";
import {
DEFAULT_RESILIENCE_SETTINGS,
resolveResilienceSettings,
@@ -37,7 +41,12 @@ import {
type FailureKind,
} from "../../src/shared/utils/classify429";
import { recordProviderSuccess as resetCooldownFailureCount } from "./providerCooldownTracker.ts";
-import { resolveProviderId } from "../../src/shared/constants/providers";
+import {
+ getProviderById,
+ resolveProviderId,
+ isLocalProvider as isLocalProviderId,
+ isSelfHostedChatProvider,
+} from "../../src/shared/constants/providers";
import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints";
import { getCodexModelScope } from "../config/codexQuotaScopes.ts";
import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts";
@@ -791,12 +800,20 @@ export function hasPerModelQuota(
return connectionPassthroughModels;
}
if (!provider) return false;
- if (getCanonicalLockProvider(provider) === "antigravity") return true;
- if (getCanonicalLockProvider(provider) === "codex") return true;
- if (provider === "gemini" || provider === "github") return true;
- if (provider === "antigravity" || provider === "agy") return true;
- if (getPassthroughProviders().has(provider)) return true;
- if (isCompatibleProvider(provider)) return true;
+ const canonicalId = resolveProviderId(provider);
+ if (getCanonicalLockProvider(canonicalId) === "antigravity") return true;
+ if (getCanonicalLockProvider(canonicalId) === "codex") return true;
+ if (canonicalId === "gemini" || canonicalId === "github") return true;
+ if (canonicalId === "antigravity" || canonicalId === "agy") return true;
+ if (getPassthroughProviders().has(canonicalId)) return true;
+ // #11071: getPassthroughProviders() reads the open-sse REGISTRY. A provider can declare
+ // passthroughModels:true in the SHARED registry (src/shared/constants/providers/) and be
+ // absent from that set — 40 of them are, and they are neither local nor self-hosted, so the
+ // branch below never reaches them either. Without this lookup a missing-model 404 on one of
+ // those cools the whole connection instead of locking out the single model.
+ if (getProviderById(canonicalId)?.passthroughModels === true) return true;
+ if (isCompatibleProvider(canonicalId)) return true;
+ if (isLocalProviderId(canonicalId) || isSelfHostedChatProvider(canonicalId)) return true;
return false;
}
diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts
index 9fa12875637c..28071144fbb2 100644
--- a/open-sse/services/autoCombo/virtualFactory.ts
+++ b/open-sse/services/autoCombo/virtualFactory.ts
@@ -607,7 +607,7 @@ export async function prepareVirtualAutoComboInputs(
// remaining allowance as a percentage, and a raw ">0" comparison would
// let a reading of e.g. 0.3% (rounding noise, not real headroom) pass.
minRemainingAllowance: 1,
- maxStateAgeMs: (settings.autoRefreshProviderQuotaInterval ?? 180) * 1000,
+ maxStateAgeMs: (Number(settings.autoRefreshProviderQuotaInterval) || 180) * 1000,
});
if (strictFilteredPool !== pool) pool = strictFilteredPool;
diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts
index 15995a95003f..82e408e7c872 100644
--- a/open-sse/services/claudeCodeToolRemapper.ts
+++ b/open-sse/services/claudeCodeToolRemapper.ts
@@ -57,6 +57,10 @@ const TOOL_RENAME_MAP: Record = {
cronlist: "CronList",
taskoutput: "TaskOutput",
taskstop: "TaskStop",
+ taskcreate: "TaskCreate",
+ taskupdate: "TaskUpdate",
+ tasklist: "TaskList",
+ taskget: "TaskGet",
workflow: "Workflow",
};
@@ -205,14 +209,22 @@ export function remapToolNamesInResponse(
* Restore a tool name for Claude-format clients (#9008).
*
* Preference order:
- * 1. Exact `_toolNameMap` hit (sanitized → original)
- * 2. Case-insensitive match against map keys/values (Gemini/Antigravity may
- * echo a lowercased name for a PascalCase Claude Code tool)
- * 3. REVERSE_MAP TitleCase → lowercase fallback for clients with no request map
- * (#7926 XML / OpenCode-style lowercase tools)
+ * 1. Exact `_toolNameMap` hit where the value differs from the key
+ * (sanitized → original request-side alias)
+ * 2. Canonical casing upgrade for known Claude Code tools
+ * (`croncreate` → `CronCreate`, `bash` → `Bash`, …)
+ * 3. Case-insensitive non-identity match against map keys/values
+ * (Gemini/Antigravity may echo a lowercased name for a PascalCase
+ * Claude Code tool)
+ * 4. Identity echo kept ONLY when no canonical upgrade exists
+ * 5. No-map fallbacks: REVERSE_MAP TitleCase → lowercase (#7926 XML /
+ * OpenCode-style lowercase tools), then the static table
*
- * Never apply REVERSE_MAP after a request-side original is known — that is what
- * turned Claude Code's `Read`/`WebSearch` into `read`/`websearch`.
+ * Identity entries (key === value) never pin a known tool below its
+ * canonical casing. Some upstream gateways echo the very lowercase name
+ * they emitted into the alias channel; honouring that echo is what let a
+ * literal `croncreate` reach Claude Code as an unknown tool even though
+ * the request declared `CronCreate`.
*/
export function restoreClaudeToolName(
rawName: string,
@@ -220,27 +232,49 @@ export function restoreClaudeToolName(
): string {
if (!rawName) return rawName;
- const exact = toolNameMap?.get(rawName);
- if (typeof exact === "string") return exact;
+ // Undefined when rawName already IS the canonical form — an input that
+ // maps to itself must keep flowing to the #7926 legacy paths below.
+ const lower = rawName.toLowerCase();
+ const canonicalRaw = TOOL_RENAME_MAP[lower];
+ const canonical = canonicalRaw && canonicalRaw !== rawName ? canonicalRaw : undefined;
if (toolNameMap?.size) {
- const lower = rawName.toLowerCase();
+ const exact = toolNameMap.get(rawName);
+ if (typeof exact === "string" && (exact !== rawName || !canonical)) {
+ return exact;
+ }
+
+ let identityMatch: string | undefined;
for (const [sanitized, original] of toolNameMap.entries()) {
- if (sanitized.toLowerCase() === lower || original.toLowerCase() === lower) {
+ if (sanitized.toLowerCase() !== lower && original.toLowerCase() !== lower) {
+ continue;
+ }
+ if (original !== rawName) {
return original;
}
+ identityMatch = original;
+ }
+ if (identityMatch !== undefined && !canonical) {
+ return identityMatch;
}
}
+ // Canonical echo is terminal: when the upstream echoes back the exact
+ // canonical form the request declared, keep it verbatim. The #7926
+ // REVERSE_MAP fallbacks below would otherwise downcase it for routes that
+ // carry no _toolNameMap (Claude Code → OpenAI-style upstreams), which is
+ // what let a literal `croncreate` reach Claude Code even though the client
+ // declared `CronCreate` (live repro, PR #11085).
+ if (canonicalRaw === rawName) return rawName;
+
+ if (canonical) return canonical;
+
// When no request toolNameMap is provided (e.g. non-Claude client):
// If rawName is already TitleCase, apply REVERSE_MAP for #7926 backward compatibility (Bash → bash).
if (!toolNameMap && REVERSE_MAP[rawName]) {
return REVERSE_MAP[rawName];
}
- const canonical = TOOL_RENAME_MAP[rawName.toLowerCase()];
- if (canonical) return canonical;
-
return REVERSE_MAP[rawName] ?? rawName;
}
diff --git a/open-sse/services/combo/validateQuality.ts b/open-sse/services/combo/validateQuality.ts
index 7c7f0ce8a927..76ef5546aba4 100644
--- a/open-sse/services/combo/validateQuality.ts
+++ b/open-sse/services/combo/validateQuality.ts
@@ -617,7 +617,18 @@ export async function validateResponseQuality(
try {
json = JSON.parse(text);
} catch {
- if (text.startsWith("data:") || text.startsWith("event:")) return { valid: true };
+ // An SSE stream body is expected for streamed upstreams. Besides `data:` and
+ // `event:` frames, the SSE spec also allows comment lines that begin with a
+ // colon (`:`), which providers use for keep-alives while the model is still
+ // generating — e.g. OpenRouter emits `: OPENROUTER PROCESSING` on slower /
+ // reasoning responses. A stream that opens with such a comment (or with
+ // leading whitespace/newlines) is still a valid stream, not malformed JSON,
+ // so trim and recognize the comment prefix before rejecting. Without this,
+ // otherwise-good streamed completions get failed as "not valid JSON".
+ const trimmed = text.trimStart();
+ if (trimmed.startsWith("data:") || trimmed.startsWith("event:") || trimmed.startsWith(":")) {
+ return { valid: true };
+ }
return { valid: false, reason: "response is not valid JSON" };
}
diff --git a/open-sse/services/compression/engines/ccr/index.ts b/open-sse/services/compression/engines/ccr/index.ts
index d2136fc8f191..92869bfbc9b9 100644
--- a/open-sse/services/compression/engines/ccr/index.ts
+++ b/open-sse/services/compression/engines/ccr/index.ts
@@ -45,7 +45,7 @@ import {
} from "../../../../../src/lib/db/ccrBlocks.ts";
import { createCompressionStats } from "../../stats.ts";
import { queryBlock, type CcrQuery } from "./ccrQuery.ts";
-import { injectCcrProtocolInstruction } from "./protocolInstruction.ts";
+import { callerSupportsCcrRetrieve, injectCcrProtocolInstruction } from "./protocolInstruction.ts";
import type {
CompressionEngine,
CompressionEngineApplyOptions,
@@ -939,6 +939,30 @@ export const ccrEngine: CompressionEngine = {
return { body, compressed: false, stats: null };
}
+ // #7746 follow-up: only callers whose tools[] proves they can reach
+ // omniroute_ccr_retrieve may have content replaced at all. For everyone
+ // else (plain OpenAI-compatible clients — the marker is an MCP-only
+ // contract) replacement would strand the original text behind a hash the
+ // model has no way to resolve. Skip the whole engine for them. The check
+ // is wrapped defensively: a malformed body must fail OPEN (no
+ // compression), never throw into the request pipeline.
+ let callerCanRetrieve = false;
+ try {
+ callerCanRetrieve = callerSupportsCcrRetrieve(body);
+ } catch (err) {
+ // Defensive: the helper is total, but if it ever throws we must fail
+ // OPEN (no compression) — and surface it so a future regression in the
+ // helper is visible instead of silently bypassing compression forever.
+ console.warn(
+ "[compression/ccr] callerSupportsCcrRetrieve threw; skipping compression:",
+ err instanceof Error ? err.message : err
+ );
+ callerCanRetrieve = false;
+ }
+ if (!callerCanRetrieve) {
+ return { body, compressed: false, stats: null };
+ }
+
const minChars =
typeof stepConfig["minChars"] === "number"
? (stepConfig["minChars"] as number)
diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts
index a2d678f107cd..6fe9e94c8e29 100644
--- a/open-sse/services/contextManager.ts
+++ b/open-sse/services/contextManager.ts
@@ -669,13 +669,35 @@ function purifyHistory(messages: Record[], targetTokens: number
result = fixToolPairs(result);
result = stripTrailingAssistantOrphanToolUse(result);
- // Add summary of dropped messages
+ // Add summary of dropped messages. Merge the notice INTO the leading
+ // system/developer message instead of splicing a second system-role message
+ // mid-array: strict gateways (TokenRouter confirmed live 2026-08-22, see the
+ // PROVIDERS_SYSTEM_MUST_BE_FIRST list in src/lib/memory/injection.ts) reject
+ // any system message at index > 0 with HTTP 400 "System message must be at
+ // the beginning". When there is no leading system message, prepend one --
+ // index 0 is accepted by every provider (same slot the old splice used when
+ // system[] was empty).
if (keep < nonSystem.length) {
const dropped = nonSystem.length - keep;
- result.splice(system.length, 0, {
- role: "system",
- content: `[Context compressed: ${dropped} earlier messages removed to fit context window]`,
- });
+ const droppedNotice = `[Context compressed: ${dropped} earlier messages removed to fit context window]`;
+ const first = result[0];
+ if (first && (first.role === "system" || first.role === "developer")) {
+ if (typeof first.content === "string") {
+ result[0] = {
+ ...first,
+ content: first.content ? `${droppedNotice}\n${first.content}` : droppedNotice,
+ };
+ } else if (Array.isArray(first.content)) {
+ result[0] = {
+ ...first,
+ content: [{ type: "text", text: droppedNotice }, ...(first.content as unknown[])],
+ };
+ } else {
+ result[0] = { ...first, content: droppedNotice };
+ }
+ } else {
+ result.unshift({ role: "system", content: droppedNotice });
+ }
}
return result;
diff --git a/open-sse/services/learnedReasoningEffortCaps.ts b/open-sse/services/learnedReasoningEffortCaps.ts
new file mode 100644
index 000000000000..b0125d868375
--- /dev/null
+++ b/open-sse/services/learnedReasoningEffortCaps.ts
@@ -0,0 +1,126 @@
+/**
+ * Learned Reasoning-Effort Caps — reactive capability memory for providers/models
+ * OmniRoute has no static registry entry for (custom OpenAI-compatible connections,
+ * or any registered provider whose registry entry carries no reasoning metadata).
+ *
+ * Same shape as `learnedThinkingCaps.ts` (thinking_budget), generalized from a
+ * numeric budget to an ordinal reasoning_effort scale: on a 4xx whose body
+ * enumerates the accepted values, `base.ts`'s executor calls
+ * `recordLearnedReasoningEffort`, which stores the highest recognized value in a
+ * module-level Map keyed "provider:model" (lowercased). Subsequent requests for
+ * the same provider+model read the cap via `getLearnedReasoningEffort` (consulted
+ * by `sanitizeReasoningEffortForProvider` in `executors/base/reasoningEffort.ts`)
+ * so the 4xx→retry round-trip is paid at most once per process per provider+model.
+ *
+ * In-memory only (same operator-accepted tradeoff as the thinking-budget cache):
+ * restart resets, the first request after a restart may re-learn at the cost of
+ * one upstream 4xx.
+ */
+
+export const REASONING_EFFORT_ORDER: readonly string[] = [
+ "none",
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
+];
+
+// key: `${provider}:${model}` lowercased → highest value known to be accepted.
+const learnedCaps = new Map();
+
+function buildKey(provider: string | null | undefined, model: string | null | undefined): string {
+ const p = typeof provider === "string" ? provider.trim().toLowerCase() : "";
+ const m = typeof model === "string" ? model.trim().toLowerCase() : "";
+ if (!p || !m) return "";
+ return `${p}:${m}`;
+}
+
+function rankOf(value: string): number {
+ return REASONING_EFFORT_ORDER.indexOf(value);
+}
+
+/**
+ * Return the learned cap for provider+model, or null when nothing has been
+ * learned yet (no upstream 4xx recorded). Keyed case-insensitively.
+ */
+export function getLearnedReasoningEffort(
+ provider: string | null | undefined,
+ model: string | null | undefined
+): string | null {
+ const key = buildKey(provider, model);
+ if (!key) return null;
+ return learnedCaps.get(key) ?? null;
+}
+
+/**
+ * Record that `acceptedValues` is the enum the upstream advertised for
+ * provider+model, and store the highest recognized value as the learned cap.
+ * Returns the stored value, or null when `acceptedValues` contained no token
+ * from `REASONING_EFFORT_ORDER` (nothing usable to learn) or the key is unusable.
+ *
+ * Always monotonically decreases: if a cap already stored ranks lower than the
+ * newly computed highest, the stored (lower) value wins and is returned
+ * unchanged. This keeps a later, laxer-looking response (or a race between
+ * concurrent requests) from ratcheting the cap back up.
+ */
+export function recordLearnedReasoningEffort(
+ provider: string | null | undefined,
+ model: string | null | undefined,
+ acceptedValues: string[]
+): string | null {
+ const key = buildKey(provider, model);
+ if (!key) return null;
+
+ let best: string | null = null;
+ let bestRank = -1;
+ for (const raw of acceptedValues) {
+ const rank = rankOf(raw);
+ if (rank > bestRank) {
+ bestRank = rank;
+ best = raw;
+ }
+ }
+ if (best === null) return null;
+
+ const existing = learnedCaps.get(key);
+ if (existing !== undefined && rankOf(existing) <= bestRank) {
+ return existing; // already learned an equal-or-lower cap; keep it
+ }
+ learnedCaps.set(key, best);
+ return best;
+}
+
+// Matches both prose shapes observed: OVH's `@ai-sdk/openai-compatible`
+// deserializer ("expected one of `a`, `b`") and a generic vendor prose form
+// ("Supported types are a, b, and c").
+const LIST_INTRO = /(?:expected one of|supported (?:types|values) are)[:\s]*([^.]+)/i;
+
+/**
+ * Extract the upstream-advertised accepted reasoning_effort values from a 4xx
+ * error body. Returns only tokens present in REASONING_EFFORT_ORDER (unknown
+ * tokens are dropped defensively) in the order they appeared, or null when the
+ * text names no recognized enum member.
+ */
+export function parseReasoningEffortEnum(errText: unknown): string[] | null {
+ if (typeof errText !== "string" || !errText) return null;
+ const match = LIST_INTRO.exec(errText);
+ if (!match) return null;
+ const tokens = match[1]
+ .split(/,|\band\b|&/i)
+ .map((t) =>
+ t
+ .replace(/`/g, "")
+ .replace(/\([^)]*\)/g, "")
+ .trim()
+ .toLowerCase()
+ )
+ .filter((t) => t.length > 0 && REASONING_EFFORT_ORDER.includes(t));
+ return tokens.length > 0 ? tokens : null;
+}
+
+/** Test-only: clear the learned-cap Map between tests. */
+export function __test_resetLearnedReasoningEffortCaps(): void {
+ learnedCaps.clear();
+}
diff --git a/open-sse/services/reasoningInputPolicy.ts b/open-sse/services/reasoningInputPolicy.ts
index 71a283a706d8..0e9e8d194dbf 100644
--- a/open-sse/services/reasoningInputPolicy.ts
+++ b/open-sse/services/reasoningInputPolicy.ts
@@ -1,5 +1,6 @@
import { REGISTRY } from "../config/providerRegistry.ts";
import type { ReasoningTransport } from "../config/providerRegistry.ts";
+import { isValidResponsesItemId } from "./responsesItemId.ts";
type JsonRecord = Record;
@@ -36,7 +37,6 @@ export interface ReasoningInputPolicyOptions {
export interface ReasoningInputPolicyResult {
incompatibleReasoning: boolean;
}
-
export function resolveReasoningTransport(
provider: string | null | undefined,
preserveEncryptedReasoning = false
@@ -46,18 +46,6 @@ export function resolveReasoningTransport(
return transport ?? (preserveEncryptedReasoning ? "opaque" : "plaintext");
}
-export function createReasoningTransportIncompatibleError(): Error & {
- statusCode: number;
- errorType: string;
-} {
- const error = new Error(
- "Reasoning continuation is not compatible with the selected target"
- ) as Error & { statusCode: number; errorType: string };
- error.statusCode = 400;
- error.errorType = "reasoning_transport_incompatible";
- return error;
-}
-
function asRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
}
@@ -100,11 +88,12 @@ function hasChatPlaintextReasoning(record: JsonRecord): boolean {
/**
* Returns only provider-authentic plaintext continuation state. Display summaries
- * are excluded, and a record carrying opaque state is never cross-converted.
+ * and opaque-only records are excluded. Explicit plaintext remains independently
+ * portable when the same record also carries an opaque companion (#10949).
*/
export function extractReplayableResponsesReasoningText(value: unknown): string {
const record = asRecord(value);
- if (!record || record.type !== "reasoning" || hasOpaqueReasoningState(record)) return "";
+ if (!record || record.type !== "reasoning") return "";
if (!Array.isArray(record.content)) return "";
return record.content
@@ -279,22 +268,36 @@ function sanitizeResponsesInput(
if (!hasPlaintext && !hasOpaque && (!hasDisplaySummary(next) || stripOrphanedSummaries)) {
continue;
}
- if (!hasOpaque && typeof next.id === "string") delete next.id;
+ // `id` is only worth keeping on an opaque item with a valid string value —
+ // non-opaque items don't replay their id, and a malformed value (e.g. `null`,
+ // observed on opencode/zen) must not survive either way (#11108).
+ if (!hasOpaque || !isValidResponsesItemId(next.id)) delete next.id;
+ // Some upstreams (e.g. opencode/zen) omit `summary` entirely on opaque
+ // reasoning items instead of sending an empty array. Replaying that shape
+ // verbatim trips strict Responses-API validators that require the field
+ // to be present on every `input[]` item of type `reasoning` (#11108).
+ // Plaintext-only items intentionally have no `summary` key and must stay
+ // untouched.
+ if (hasOpaque && next.summary === undefined) next.summary = [];
filtered.push(next);
continue;
}
const cloned = { ...record };
- if (typeof cloned.id === "string") delete cloned.id;
+ // Strip `id` whenever present, valid or not: these items don't need a
+ // replayed server id, and a malformed one (e.g. `null`, same opencode/zen
+ // omission pattern as the reasoning branch above) must not survive either
+ // (#11108).
+ if (cloned.id !== undefined) delete cloned.id;
filtered.push(cloned);
}
return filtered;
}
/**
- * Applies one protocol-independent compatibility decision before request translation.
- * Plaintext is portable by default; opaque state requires an explicit target declaration.
- * Display summaries do not affect compatibility; stateless input drops orphan summaries.
+ * Projects reasoning continuation onto the selected target transport.
+ * Incompatible active state is dropped by default; combo routing may reject an
+ * attempt instead so it can fall through without mutating the request.
*/
export function applyReasoningInputPolicy(
body: Record,
@@ -306,14 +309,17 @@ export function applyReasoningInputPolicy(
inputFormat === "responses"
? inspectResponsesReasoning(body.input)
: inspectChatReasoning(body.messages);
- const incompatibleReasoning = !isReasoningCompatible(inspection, transport);
+ const mixedState = inspection.hasPlaintext && inspection.hasOpaque;
+ const incompatibleReasoning = !mixedState && !isReasoningCompatible(inspection, transport);
+ // Mixed plaintext + opaque input (#10949) is never a rejection: it is projected
+ // onto the target transport by the per-item sanitizers below.
- if (incompatibleReasoning && options.onIncompatibleReasoning !== "drop") {
+ if (incompatibleReasoning && options.onIncompatibleReasoning === "reject") {
return { incompatibleReasoning: true };
}
if (inputFormat === "chat") {
- if (incompatibleReasoning && Array.isArray(body.messages)) {
+ if ((incompatibleReasoning || mixedState) && Array.isArray(body.messages)) {
body.messages = dropIncompatibleChatReasoning(body.messages, transport);
}
return { incompatibleReasoning: false };
@@ -328,12 +334,13 @@ export function applyReasoningInputPolicy(
},
];
}
- if (!Array.isArray(body.input)) return { incompatibleReasoning: false };
- body.input = sanitizeResponsesInput(
- body.input,
- transport,
- incompatibleReasoning,
- body.store === false
- );
+ if (Array.isArray(body.input)) {
+ body.input = sanitizeResponsesInput(
+ body.input,
+ transport,
+ incompatibleReasoning || mixedState,
+ body.store === false
+ );
+ }
return { incompatibleReasoning: false };
}
diff --git a/open-sse/services/responsesInputSanitizer.ts b/open-sse/services/responsesInputSanitizer.ts
index 5d81b787a10e..94cd99f934b1 100644
--- a/open-sse/services/responsesInputSanitizer.ts
+++ b/open-sse/services/responsesInputSanitizer.ts
@@ -1,3 +1,5 @@
+import { isValidResponsesItemId } from "./responsesItemId.ts";
+
type JsonRecord = Record;
type SanitizeResponsesInputOptions = {
dropInternalAssistantMessages?: boolean;
@@ -40,7 +42,12 @@ function sanitizeFunctionName(name: string): string {
}
function sanitizeInputItemId(record: JsonRecord): JsonRecord {
- if (typeof record.id !== "string") return record;
+ if (record.id === undefined) return record;
+ if (!isValidResponsesItemId(record.id)) {
+ const next = { ...record };
+ delete next.id;
+ return next;
+ }
const type = typeof record.type === "string" ? record.type : "";
const expectedPrefix = SERVER_ITEM_ID_PREFIX_BY_TYPE[type];
diff --git a/open-sse/services/responsesItemId.ts b/open-sse/services/responsesItemId.ts
new file mode 100644
index 000000000000..a57ac92e42f2
--- /dev/null
+++ b/open-sse/services/responsesItemId.ts
@@ -0,0 +1,7 @@
+// Shared by reasoningInputPolicy.ts and responsesInputSanitizer.ts: both strip a
+// Responses-API `input[]` item's `id` field when it isn't a valid string before
+// replay, so a malformed value (e.g. `null`, observed on opencode/zen) never
+// survives to trip a strict upstream with "Expected 'id' to be a string." (#11108).
+export function isValidResponsesItemId(id: unknown): id is string {
+ return typeof id === "string";
+}
diff --git a/open-sse/services/streamRecovery.ts b/open-sse/services/streamRecovery.ts
index a0a397fd87be..3a95e9a2a3fe 100644
--- a/open-sse/services/streamRecovery.ts
+++ b/open-sse/services/streamRecovery.ts
@@ -183,10 +183,33 @@ export function hasTerminalMarker(bytes: Uint8Array): boolean {
export interface OpenAiSseScan {
/** Concatenated assistant text seen across `choices[].delta.content`. */
text: string;
+ /** Concatenated reasoning trace seen across `choices[].delta.reasoning_content`. Some
+ * providers stream the entire answer here and leave `content` empty/null — tracked
+ * separately so a clean stop with reasoning-only output can still be recognized as
+ * "nothing usable was delivered" instead of "a normal empty turn". */
+ reasoningText: string;
/** True if any `choices[].delta.tool_calls` appeared — NEVER continue those. */
sawToolCall: boolean;
- /** True if a terminal marker (`[DONE]` or a non-null `finish_reason`) appeared. */
+ /**
+ * True only when `tool_calls` appeared in this scan AND its own
+ * `finish_reason: "tool_calls"` has NOT also appeared in the same scan — i.e. the
+ * call is still being streamed (arguments may be mid-flight). Once
+ * `finish_reason: "tool_calls"` closes it, the call is complete, not in flight: the
+ * client has the full arguments and a truncation past this point only drops
+ * trailing prose, which continuation can safely recover.
+ */
+ sawToolCallInFlight: boolean;
+ /**
+ * True if a terminal marker for the OVERALL stream appeared: `[DONE]`, or a
+ * `finish_reason` other than `"tool_calls"`. A `finish_reason: "tool_calls"` ends
+ * that one choice but is not terminal for continuation purposes — the model turn
+ * (and the client-visible SSE) is still eligible to be resumed past it.
+ */
terminal: boolean;
+ /** The literal `finish_reason` string when present (e.g. "stop", "tool_calls", "length",
+ * "content_filter"), or `null` if none was seen. `terminal` alone is not precise enough
+ * to gate the reasoning-only-stop continuation — it must fire on `"stop"` only. */
+ finishReason: string | null;
/** True if at least one OpenAI-shaped `choices[].delta` was parsed (format gate). */
parsedOpenAi: boolean;
}
@@ -198,11 +221,22 @@ export interface OpenAiSseScan {
*/
export function scanOpenAiSseText(sse: string): OpenAiSseScan {
let text = "";
+ let reasoningText = "";
let sawToolCall = false;
+ let toolCallFinished = false;
let terminal = false;
+ let finishReason: string | null = null;
let parsedOpenAi = false;
if (typeof sse !== "string" || sse.length === 0) {
- return { text, sawToolCall, terminal, parsedOpenAi };
+ return {
+ text,
+ reasoningText,
+ sawToolCall,
+ sawToolCallInFlight: false,
+ terminal,
+ finishReason,
+ parsedOpenAi,
+ };
}
for (const line of sse.split("\n")) {
const trimmed = line.trimStart();
@@ -227,14 +261,33 @@ export function scanOpenAiSseText(sse: string): OpenAiSseScan {
parsedOpenAi = true;
const content = (delta as { content?: unknown }).content;
if (typeof content === "string") text += content;
+ const reasoning = (delta as { reasoning_content?: unknown }).reasoning_content;
+ if (typeof reasoning === "string") reasoningText += reasoning;
const toolCalls = (delta as { tool_calls?: unknown }).tool_calls;
if (Array.isArray(toolCalls) && toolCalls.length > 0) sawToolCall = true;
}
- const finishReason = (choice as { finish_reason?: unknown })?.finish_reason;
- if (finishReason != null) terminal = true;
+ const rawFinishReason = (choice as { finish_reason?: unknown })?.finish_reason;
+ if (rawFinishReason === "tool_calls") {
+ // Ends this one choice, but the overall stream/turn stays continuable —
+ // never counts as the general terminal marker (see OpenAiSseScan.terminal).
+ toolCallFinished = true;
+ finishReason = "tool_calls";
+ } else if (rawFinishReason != null) {
+ terminal = true;
+ if (typeof rawFinishReason === "string") finishReason = rawFinishReason;
+ }
}
}
- return { text, sawToolCall, terminal, parsedOpenAi };
+ const sawToolCallInFlight = sawToolCall && !toolCallFinished;
+ return {
+ text,
+ reasoningText,
+ sawToolCall,
+ sawToolCallInFlight,
+ terminal,
+ finishReason,
+ parsedOpenAi,
+ };
}
export interface ContinuableBody {
@@ -245,8 +298,10 @@ export interface ContinuableBody {
/**
* Build a re-request body that continues from `assistantSoFar` by appending it as an
- * assistant turn. Returns null when the body has no `messages` array or the partial text
- * is empty (nothing to continue from). Does not mutate the original.
+ * assistant turn. When `assistantSoFar` is empty (nothing usable was emitted yet — e.g. a
+ * clean stop that only produced reasoning), the messages are re-sent unchanged instead of
+ * appending an empty assistant turn: this simply re-asks for a real answer. Returns null
+ * only when the body has no `messages` array at all (nothing to continue from).
*/
export function makeContinuationBody(
body: ContinuableBody,
@@ -254,10 +309,13 @@ export function makeContinuationBody(
): (ContinuableBody & { messages: unknown[] }) | null {
if (!body || typeof body !== "object") return null;
if (!Array.isArray(body.messages) || body.messages.length === 0) return null;
- if (typeof assistantSoFar !== "string" || assistantSoFar.length === 0) return null;
+ if (typeof assistantSoFar !== "string") return null;
return {
...body,
- messages: [...body.messages, { role: "assistant", content: assistantSoFar }],
+ messages:
+ assistantSoFar.length > 0
+ ? [...body.messages, { role: "assistant", content: assistantSoFar }]
+ : [...body.messages],
stream: true,
};
}
@@ -368,8 +426,13 @@ export function createRecoverableStream(
let continuations = 0;
let emittedTail = ""; // raw SSE not yet scanned (awaiting an event boundary)
let emittedText = ""; // assistant text already delivered to the client
+ let emittedReasoningText = ""; // reasoning trace already delivered (never shown to the client,
+ // tracked only to distinguish "a real empty turn" from "the whole
+ // answer stayed in the reasoning channel")
+ let emittedFinishReason: string | null = null; // literal finish_reason last seen, if any
let emittedTerminal = false;
- let emittedToolCall = false;
+ let emittedToolCallInFlight = false;
+ let emittedSawToolCall = false; // any tool_call delta seen, complete or not
let emittedParsedOpenAi = false;
// Enqueue to the client and, when continuation is enabled, fold the chunk into the
@@ -387,8 +450,11 @@ export function createRecoverableStream(
emittedTail = emittedTail.slice(boundary + 2);
const scan = scanOpenAiSseText(complete);
emittedText += scan.text;
+ emittedReasoningText += scan.reasoningText;
+ if (scan.finishReason !== null) emittedFinishReason = scan.finishReason;
if (scan.terminal) emittedTerminal = true;
- if (scan.sawToolCall) emittedToolCall = true;
+ if (scan.sawToolCallInFlight) emittedToolCallInFlight = true;
+ if (scan.sawToolCall) emittedSawToolCall = true;
if (scan.parsedOpenAi) emittedParsedOpenAi = true;
};
@@ -396,15 +462,42 @@ export function createRecoverableStream(
for (const chunk of holdback.flush()) emit(controller, chunk);
};
- // A post-commit truncation is continuable only for a plain-text OpenAI-compatible
- // stream that has not finished and has no tool call in flight.
+ // A post-commit truncation is continuable for a plain-text OpenAI-compatible stream that
+ // has no tool call in flight, AND either:
+ // - has not finished yet (the original #4131 truncation case), or
+ // - finished with a literal finish_reason of "stop" but delivered nothing usable while a
+ // non-empty reasoning trace shows the provider spent its whole turn "thinking" and never
+ // turned that into an answer (some providers put the entire response in
+ // reasoning_content and leave content empty). Gated on the LITERAL "stop" value, not the
+ // generic `terminal` flag — `terminal` also covers "length"/"content_filter"/a bare
+ // [DONE], which are out of scope for this specific recovery.
+ //
+ // Known consequence of the hallucinatedEmptyStop path (flagged in cross-review, accepted as
+ // inherent to tryContinue's existing design, not new to this fix): the original upstream's
+ // `finish_reason:"stop"` chunk was already forwarded to the client via `emit()`'s unconditional
+ // `controller.enqueue(chunk)` (streamRecovery.ts:381) BEFORE this scan ever runs — that is how
+ // `emittedFinishReason`/`emittedTerminal` get set in the first place. So the client sees an
+ // empty "stop" marker from the original turn, then — once the continuation succeeds — the real
+ // answer plus a SECOND `emitCleanTerminal` from `tryContinue`. This mirrors what already
+ // happens for the pre-existing truncation-continuation case (a truncated stream can likewise
+ // have partially delivered SSE framing before `tryContinue` appends more); it is not a new
+ // double-close of the underlying `ReadableStream` (`controller.close()` runs exactly once,
+ // after `tryContinue` returns). An SSE client that treats a bare `finish_reason:"stop"` as an
+ // unconditional end-of-turn (rather than waiting for `[DONE]`) may need updating separately —
+ // out of scope for this fix, which targets the observed opencode/OmniRoute pairing where the
+ // client kept the connection open.
+ const hallucinatedEmptyStop = () =>
+ emittedFinishReason === "stop" &&
+ !emittedSawToolCall &&
+ emittedText.length === 0 &&
+ emittedReasoningText.length > 0;
+
const canContinue = () =>
continueEnabled &&
continuations < maxContinuations &&
emittedParsedOpenAi &&
- !emittedToolCall &&
- !emittedTerminal &&
- emittedText.length > 0;
+ !emittedToolCallInFlight &&
+ (emittedText.length > 0 ? !emittedTerminal : hallucinatedEmptyStop());
const emitCleanTerminal = (controller: ReadableStreamDefaultController) => {
controller.enqueue(
@@ -448,7 +541,24 @@ export function createRecoverableStream(
}
const scan = scanOpenAiSseText(raw);
- const suffix = trimContinuationOverlap(emittedText, scan.text);
+ // A continuation whose overlap with what was already emitted falls below the documented
+ // threshold is treated as a suspected restart rather than a real resume — see
+ // STREAM_RECOVERY.MIN_CONTINUATION_OVERLAP_CHARS for the full trade-off rationale. This
+ // is a heuristic, not a proof: it deliberately trades some false-positive rejections of
+ // legitimate low-overlap continuations against never silently gluing two unrelated
+ // fragments into one corrupted message.
+ const overlapResult = trimContinuationOverlap(emittedText, scan.text);
+ const overlapChars = scan.text.length - overlapResult.length;
+ const isSuspectedRestart =
+ emittedText.length > 0 &&
+ scan.text.length > 0 &&
+ overlapChars < STREAM_RECOVERY.MIN_CONTINUATION_OVERLAP_CHARS;
+ if (isSuspectedRestart) {
+ if (await tryContinue(controller)) return true;
+ emitCleanTerminal(controller);
+ return true;
+ }
+ const suffix = overlapResult;
if (suffix) {
emit(
controller,
@@ -505,9 +615,11 @@ export function createRecoverableStream(
const { done, value } = result;
if (done) {
if (holdback.committed) {
- // Graceful end after commit: if it lacks a terminal marker it is a silent
- // truncation — try to continue; otherwise (clean finish) just close.
- if (!emittedTerminal && (await tryContinue(controller))) {
+ // Graceful end after commit: try a mid-stream continuation whenever canContinue()
+ // says the stream is worth continuing (silent truncation, or a clean-but-empty
+ // reasoning-only stop) — canContinue() is the single source of truth here, same as
+ // the read-error branch above.
+ if (await tryContinue(controller)) {
runFinalize();
controller.close();
return;
diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts
index b88665025242..d48dcdf18161 100644
--- a/open-sse/translator/request/openai-responses.ts
+++ b/open-sse/translator/request/openai-responses.ts
@@ -8,11 +8,7 @@ import { isOpenAIResponsesStoreEnabled } from "@/lib/providers/requestDefaults";
import { FORMATS } from "../formats.ts";
import { register } from "../registry.ts";
import { normalizeResponsesInputForChat } from "../../utils/responsesInputNormalization.ts";
-import {
- createReasoningTransportIncompatibleError,
- hasOpaqueReasoningState,
- extractReplayableResponsesReasoningText,
-} from "../../services/reasoningInputPolicy.ts";
+import { extractReplayableResponsesReasoningText } from "../../services/reasoningInputPolicy.ts";
import {
getRegisteredProviders,
requiresPlainStringContent,
@@ -454,10 +450,8 @@ export function openaiResponsesToOpenAIRequest(
if (itemType === "reasoning") {
// Only genuine plaintext reasoning can cross into Chat reasoning_content.
- // Opaque encrypted state and its display summary have no Chat replay form.
- if (preserveReasoningContent && hasOpaqueReasoningState(item)) {
- throw createReasoningTransportIncompatibleError();
- }
+ // Opaque encrypted state and its display summary have no Chat replay form,
+ // so opaque-only items are dropped while mixed items replay their plaintext.
if (preserveReasoningContent) {
const reasoning = extractReplayableResponsesReasoningText(item);
if (reasoning) {
diff --git a/open-sse/translator/request/openai-responses/toResponses.ts b/open-sse/translator/request/openai-responses/toResponses.ts
index bee5efab1a27..988835edeace 100644
--- a/open-sse/translator/request/openai-responses/toResponses.ts
+++ b/open-sse/translator/request/openai-responses/toResponses.ts
@@ -201,6 +201,14 @@ export function openaiToOpenAIResponsesRequest(
input.push({
type: "reasoning",
content: [{ type: "reasoning_text", text: reasoning }],
+ // Strict Responses-API upstreams (e.g. opencode/zen) require `summary`
+ // on every `input[]` item of type "reasoning", plaintext or opaque —
+ // omitting it rejects the request with `input[N] missing required
+ // field summary`. This item is always freshly built from a chat
+ // client's plaintext reasoning, so there is no source summary to
+ // preserve; default to an empty array like the replay sanitizer does
+ // for opaque items in reasoningInputPolicy.ts (#11108).
+ summary: [],
});
}
diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts
index 0c59fac4fba6..01ad55d72f28 100644
--- a/open-sse/translator/response/openai-responses.ts
+++ b/open-sse/translator/response/openai-responses.ts
@@ -866,21 +866,25 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
function openaiResponsesToOpenAIResponseStream(chunk, state) {
if (!chunk) {
- if (
- state.currentToolCallNeedsNormalization &&
- state.currentToolCallArgsBuffer &&
- state.currentToolCallName
- ) {
- const toolSchema = state.toolSchemas?.get(state.currentToolCallName);
- const argsToEmit = stripEmptyOptionalToolArgs(
- state.currentToolCallArgsBuffer,
- state.currentToolCallName,
- toolSchema
- );
- const argsStr =
- typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit ?? {});
- state.currentToolCallArgsBuffer = "";
- state.currentToolCallNeedsNormalization = false;
+ // Iterate every still-open call needing schema-aware normalization, not just a
+ // single one — multiple parallel calls can each be pending here if the stream
+ // ends before their output_item.done arrives.
+ const pendingNormalized: Array<{ index: number; argsStr: string }> = [];
+ if (state.toolCallByCallId instanceof Map) {
+ for (const entry of state.toolCallByCallId.values()) {
+ if (entry.needsNormalization && entry.argsBuffer) {
+ const toolSchema = state.toolSchemas?.get(entry.name);
+ const argsToEmit = stripEmptyOptionalToolArgs(entry.argsBuffer, entry.name, toolSchema);
+ pendingNormalized.push({
+ index: entry.index,
+ argsStr: typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit ?? {}),
+ });
+ entry.argsBuffer = "";
+ entry.needsNormalization = false;
+ }
+ }
+ }
+ if (pendingNormalized.length > 0) {
state.finishReasonSent = true;
state.finishReason = "tool_calls";
const common = {
@@ -889,24 +893,21 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
created: state.created,
model: state.model || "gpt-4",
};
- return [
- {
- ...common,
- choices: [
- {
- index: 0,
- delta: {
- tool_calls: [{ index: state.toolCallIndex, function: { arguments: argsStr } }],
- },
- finish_reason: null,
- },
- ],
- },
- {
- ...common,
- choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
- },
- ];
+ const chunks: Record[] = pendingNormalized.map(({ index, argsStr }) => ({
+ ...common,
+ choices: [
+ {
+ index: 0,
+ delta: { tool_calls: [{ index, function: { arguments: argsStr } }] },
+ finish_reason: null,
+ },
+ ],
+ }));
+ chunks.push({
+ ...common,
+ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
+ });
+ return chunks;
}
// Flush: send final chunk with finish_reason
if (!state.finishReasonSent && state.started) {
@@ -952,7 +953,23 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
state.chatId = `chatcmpl-${Date.now()}`;
state.created = Math.floor(Date.now() / 1000);
state.toolCallIndex = 0;
+ // Kept for computeFinishReason (synthesizeCompletedToolCalls.ts) compatibility —
+ // that snapshot path mutates it directly and expects it to exist. In a turn with
+ // multiple parallel calls this only ever reflects the LAST one opened/closed, so
+ // it must never be used to identify a specific call — only as the "is at least
+ // one tool call in flight this turn" signal computeFinishReason needs, which
+ // toolCallIndex > 0 already covers on its own once any call has been added.
state.currentToolCallId = null;
+ // Per-call state keyed by call_id (replaces the old singular
+ // currentToolCallId/ArgsBuffer/Name/NeedsNormalization/Deferred fields, which
+ // assumed only one function_call could ever be in flight at a time).
+ state.toolCallByCallId = new Map();
+ // response.function_call_arguments.delta carries `item_id`/`output_index`, not
+ // `call_id` — resolve either one back to the call_id key used by
+ // toolCallByCallId (two independent reverse maps, since some upstreams omit
+ // item_id on delta events but still send output_index).
+ state.toolCallItemToCallId = new Map();
+ state.toolCallOutputIndexToCallId = new Map();
}
// Text content delta
@@ -983,22 +1000,48 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
// Function call started
if (eventType === "response.output_item.added" && data.item?.type === "function_call") {
const item = data.item;
- state.currentToolCallId = item.call_id || fallbackToolCallId();
- state.currentToolCallArgsBuffer = ""; // reset per-call arg buffer
- state.currentToolCallDeferred = false;
+ const callId = item.call_id || fallbackToolCallId();
+ // Kept for computeFinishReason (synthesizeCompletedToolCalls.ts) compatibility.
+ state.currentToolCallId = callId;
+
+ const toolName = normalizeToolName(item.name);
+ // Assign this call's index NOW, at .added, not at .done — two calls opened before
+ // either closes (a genuine parallel dispatch) must never share an index. Deferred
+ // (still-nameless) calls are the one exception: they don't claim an index until
+ // .done resolves a real name, so a call that never gets one never burns a slot
+ // another call could have used.
+ let index: number | null = null;
+ if (toolName) {
+ index = state.toolCallIndex ?? 0;
+ state.toolCallIndex = index + 1;
+ }
+
+ if (!(state.toolCallByCallId instanceof Map)) state.toolCallByCallId = new Map();
+ state.toolCallByCallId.set(callId, {
+ index,
+ name: toolName,
+ argsBuffer: "",
+ deferred: !toolName,
+ needsNormalization: toolName === "Agent",
+ });
+ if (!(state.toolCallItemToCallId instanceof Map)) state.toolCallItemToCallId = new Map();
+ if (item.id) state.toolCallItemToCallId.set(item.id, callId);
+ // `output_index` is a top-level field on every Responses API streamed event
+ // (response.output_item.added/.done AND function_call_arguments.delta alike) —
+ // an identifier independent of item_id, for upstreams that omit item_id on delta
+ // events.
+ if (!(state.toolCallOutputIndexToCallId instanceof Map)) {
+ state.toolCallOutputIndexToCallId = new Map();
+ }
+ if (data.output_index != null) state.toolCallOutputIndexToCallId.set(data.output_index, callId);
// Track this call_id so response.completed doesn't synthesize a duplicate
if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set();
- if (state.currentToolCallId) state.toolCallIdsSeen.add(state.currentToolCallId);
+ state.toolCallIdsSeen.add(callId);
- const toolName = normalizeToolName(item.name);
- state.currentToolName = toolName; // track for schema lookup at done time
- state.currentToolCallName = toolName;
- state.currentToolCallNeedsNormalization = toolName === "Agent";
if (!toolName) {
// Some Responses providers briefly emit placeholder/empty tool names.
// Defer emission until output_item.done in case the final name is populated there.
- state.currentToolCallDeferred = true;
return null;
}
@@ -1013,8 +1056,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
delta: {
tool_calls: [
{
- index: state.toolCallIndex,
- id: state.currentToolCallId,
+ index,
+ id: callId,
type: "function",
function: {
name: toolName,
@@ -1037,11 +1080,26 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
const argsDelta = data.delta || "";
if (!argsDelta) return null;
- state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta;
- if (state.currentToolCallDeferred || state.currentToolCallNeedsNormalization) return null;
+ // Resolve which in-flight call this delta belongs to. Try item_id first (the
+ // field the Responses API documents for this event), then output_index (also a
+ // top-level field on this event, and independent of item_id — covers upstreams
+ // that omit item_id on delta events but still send output_index). Only once both
+ // identifying fields are absent/unresolved do we fall back to guessing (the
+ // single open call, or the most recently opened one as a last resort).
+ const map = state.toolCallByCallId instanceof Map ? state.toolCallByCallId : null;
+ let callId = data.item_id ? state.toolCallItemToCallId?.get(data.item_id) : undefined;
+ if (!callId && data.output_index != null) {
+ callId = state.toolCallOutputIndexToCallId?.get(data.output_index);
+ }
+ if (!callId && map) {
+ callId = map.size === 1 ? [...map.keys()][0] : state.currentToolCallId;
+ }
+ const entry = callId ? map?.get(callId) : undefined;
+ if (!entry) return null;
// #9168: buffer arguments until output_item.done for schema-aware null normalization
// Previously emitted raw null values for optional enum fields (e.g. isolation: null).
+ entry.argsBuffer = (entry.argsBuffer || "") + argsDelta;
return null;
}
@@ -1061,13 +1119,30 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
// carry the complete arguments only in output_item.done (no preceding delta events).
if (eventType === "response.output_item.done" && data.item?.type === "function_call") {
const item = data.item;
- const buffered = state.currentToolCallArgsBuffer || "";
- const currentIndex = state.toolCallIndex; // capture before increment
- const callId = item.call_id || state.currentToolCallId || fallbackToolCallId();
+ const map = state.toolCallByCallId instanceof Map ? state.toolCallByCallId : null;
+ let callId = item.call_id;
+ if (!callId && item.id) callId = state.toolCallItemToCallId?.get(item.id);
+ if (!callId) callId = state.currentToolCallId || fallbackToolCallId();
+ const trackedEntry = callId ? map?.get(callId) : undefined;
+ // Some upstreams (e.g. Codex) send the complete payload only in output_item.done,
+ // with no preceding output_item.added at all — there is no tracked entry to read an
+ // index from.
+ const entry = trackedEntry || { index: null, argsBuffer: "", deferred: false };
+
+ const buffered = entry.argsBuffer || "";
const toolName = normalizeToolName(item.name);
+
+ // Claim (and advance) this call's index now if it wasn't assigned at .added — either
+ // a deferred call whose name has just now resolved, or a Codex-style done-only
+ // payload that never had an .added at all. A deferred call whose name is STILL empty
+ // never claims an index (nothing was ever emitted for it either way).
+ if (entry.index == null && toolName) {
+ entry.index = state.toolCallIndex ?? 0;
+ state.toolCallIndex = entry.index + 1;
+ }
+ const currentIndex = entry.index;
const toolSchema = state.toolSchemas?.get(toolName);
const shouldNormalizeArguments = toolName === "Agent";
- state.currentToolCallNeedsNormalization = shouldNormalizeArguments;
if (toolName && state.toolCalls instanceof Map) {
const completedArguments =
@@ -1077,6 +1152,9 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
toolName,
toolSchema
);
+ // Keyed by index, not insertion order — readers that need call order for
+ // parallel calls closed out of order should sort by this key rather than
+ // relying on Map iteration order.
state.toolCalls.set(currentIndex, {
id: callId,
index: currentIndex,
@@ -1095,17 +1173,17 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set();
if (callId) state.toolCallIdsSeen.add(callId);
- if (state.currentToolCallDeferred) {
- state.currentToolCallDeferred = false;
- state.currentToolCallArgsBuffer = "";
- state.currentToolCallId = null;
+ // This call is fully closed — remove it from the in-flight map (bounds the map
+ // to genuinely in-flight calls, and keeps the single-open-call fallback in the
+ // function_call_arguments.delta handler correct for whichever call opens next).
+ if (map && callId) map.delete(callId);
+ if (state.currentToolCallId === callId) state.currentToolCallId = null;
+ if (entry.deferred) {
if (!toolName) {
return null;
}
- state.toolCallIndex++;
-
const terminalArguments =
typeof item.arguments === "string"
? item.arguments.length > 0
@@ -1148,12 +1226,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
};
}
- state.toolCallIndex++;
- state.currentToolCallArgsBuffer = ""; // reset for next tool call
- state.currentToolCallId = null;
- const needsNormalization = state.currentToolCallNeedsNormalization === true;
- state.currentToolCallNeedsNormalization = false;
- state.currentToolCallName = "";
+ const needsNormalization = shouldNormalizeArguments;
// Nullable omission sentinels must be normalized before any argument bytes reach the client.
// Other tool calls retain immediate argument streaming.
diff --git a/open-sse/utils/streamHandler.ts b/open-sse/utils/streamHandler.ts
index 4d8993c7670e..9adb5dbf2f6e 100644
--- a/open-sse/utils/streamHandler.ts
+++ b/open-sse/utils/streamHandler.ts
@@ -683,13 +683,15 @@ export function createDisconnectAwareStream(transformStream, streamController) {
if (clientTerminalSeen) return;
terminalTail += terminalDecoder.decode(chunk, { stream: true });
- if (terminalTail.length > 4096) {
- terminalTail = terminalTail.slice(-4096);
- }
+ // Scan before bounding retained state: a compaction terminal frame can
+ // exceed the tail budget because encrypted_content is carried inline.
clientTerminalSeen = hasClientTerminalSseMarker(
terminalTail,
streamController.clientResponseFormat
);
+ if (terminalTail.length > 4096) {
+ terminalTail = terminalTail.slice(-4096);
+ }
if (clientTerminalSeen) {
streamController.markClientTerminalSeen?.();
}
diff --git a/open-sse/utils/streamPayloadCollector.ts b/open-sse/utils/streamPayloadCollector.ts
index 9ca089dfe4e3..31b9e818f46c 100644
--- a/open-sse/utils/streamPayloadCollector.ts
+++ b/open-sse/utils/streamPayloadCollector.ts
@@ -128,6 +128,75 @@ function tryParseJson(raw: string): unknown {
}
}
+/**
+ * Splits a tool_call `arguments` string that is actually multiple back-to-back JSON
+ * objects glued together with no separator, into its individual object substrings.
+ *
+ * Root cause (observed on opencode/muse-spark-1.2-contributor-free via the zen
+ * provider): some upstreams never vary `index`/`id` across a 2nd/3rd/… tool_call of
+ * the SAME name emitted in one turn, so every delta in `buildOpenAISummary` above
+ * resolves to the same accumulator key and `arguments` ends up as N JSON objects
+ * concatenated with no delimiter — invalid as a single JSON value, but each object is
+ * individually well-formed. Structural, not provider-specific: applies to whichever
+ * upstream exhibits the same index-collision streaming bug.
+ *
+ * Returns `null` when `raw` is empty, already valid single JSON, or does not scan as
+ * ≥2 back-to-back valid JSON values — callers must leave `arguments` untouched in
+ * that case (never regress a value that used to reach the client as-is).
+ */
+export function splitConcatenatedToolCallArguments(raw: string): string[] | null {
+ if (!raw) return null;
+ try {
+ JSON.parse(raw);
+ return null; // Already a single valid JSON value — nothing to split.
+ } catch {
+ // Fall through to the multi-value scan below.
+ }
+
+ const parts: string[] = [];
+ let depth = 0;
+ let inString = false;
+ let escaped = false;
+ let start = -1;
+
+ for (let i = 0; i < raw.length; i++) {
+ const ch = raw[i];
+ if (start === -1) {
+ if (ch === " " || ch === "\n" || ch === "\r" || ch === "\t") continue;
+ if (ch !== "{" && ch !== "[") return null; // Not a value boundary — bail, leave untouched.
+ start = i;
+ }
+ if (inString) {
+ if (escaped) escaped = false;
+ else if (ch === "\\") escaped = true;
+ else if (ch === '"') inString = false;
+ continue;
+ }
+ if (ch === '"') {
+ inString = true;
+ continue;
+ }
+ if (ch === "{" || ch === "[") depth++;
+ else if (ch === "}" || ch === "]") {
+ depth--;
+ if (depth === 0) {
+ parts.push(raw.slice(start, i + 1));
+ start = -1;
+ }
+ }
+ }
+ if (start !== -1 || depth !== 0 || parts.length < 2) return null;
+
+ for (const part of parts) {
+ try {
+ JSON.parse(part);
+ } catch {
+ return null; // One of the scanned segments isn't valid JSON — bail entirely.
+ }
+ }
+ return parts;
+}
+
// ─── Per-format live reducers ────────────────────────────────────────────────
// Each reducer mirrors the corresponding build*Summary()'s original for-loop
// body exactly (ingest = one loop iteration, finalize = the post-loop return),
@@ -262,7 +331,28 @@ function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer {
message.reasoning_content = joinedReasoning;
}
- const finalToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
+ const mergedToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
+ // Expand any entry whose accumulated `arguments` turned out to be multiple
+ // concatenated JSON objects (upstream never varied index/id across repeated
+ // same-name tool_calls) into its own separate tool_calls entries.
+ const finalToolCalls: ToolCall[] = [];
+ let nextIndex = 0;
+ // Normalize tool_call indexes to contiguous 0-based (OpenAI contract).
+ for (const tc of mergedToolCalls) {
+ const splitArgs = splitConcatenatedToolCallArguments(tc.function.arguments);
+ if (!splitArgs) {
+ finalToolCalls.push({ ...tc, index: nextIndex++ });
+ continue;
+ }
+ for (const [i, args] of splitArgs.entries()) {
+ finalToolCalls.push({
+ id: tc.id ? `${tc.id}_split${i}` : null,
+ index: nextIndex++,
+ type: tc.type,
+ function: { name: tc.function.name, arguments: args },
+ });
+ }
+ }
if (finalToolCalls.length > 0) {
finishReason = "tool_calls";
message.tool_calls = finalToolCalls;
diff --git a/open-sse/utils/streamReadiness.ts b/open-sse/utils/streamReadiness.ts
index 2d06659b809b..1696a7a5c525 100644
--- a/open-sse/utils/streamReadiness.ts
+++ b/open-sse/utils/streamReadiness.ts
@@ -34,6 +34,14 @@ function hasUsefulValue(value: unknown): boolean {
if (Array.isArray(value)) return value.some(hasUsefulValue);
if (!isRecord(value)) return false;
+ // A Responses compaction item IS the turn's output: remote compaction
+ // completes with output = [{type:"compaction", encrypted_content}] and no
+ // assistant text. Deliberately NOT a blanket encrypted_content key — an
+ // encrypted reasoning item alone is not user-visible output and must keep
+ // tripping the #8649 empty-content guard.
+ // This shape is specific to Responses streams; chat-completion frames do not produce it.
+ if (value.type === "compaction" && hasNonEmptyString(value.encrypted_content)) return true;
+
for (const key of [
"content",
"text",
diff --git a/open-sse/utils/syncedEffortVariants.ts b/open-sse/utils/syncedEffortVariants.ts
index 2a2c7f0d68c2..33c5ec8c56ba 100644
--- a/open-sse/utils/syncedEffortVariants.ts
+++ b/open-sse/utils/syncedEffortVariants.ts
@@ -19,17 +19,17 @@
* only when the base model's own `supportedThinkingEfforts` actually declares that tier —
* never a blind string match.
*
- * Skipped entirely for `codex` and `kimi`-owned models: both already own a conflicting
- * native `-{effort}` suffix mechanism (`splitCodexReasoningSuffix` /
- * `getKimiCodeStaticThinkingPolicy`), so double-registering here would collide with their
- * own alias resolution. Also skipped for any model whose id already ends in a token that
- * matches a canonical effort value, to avoid colliding with a model that legitimately ends
- * in an effort-like token (e.g. a model literally named "...-high").
+ * Skipped entirely for `codex`, `kimi`-owned, and GLM (`glm`, `glm-cn`, `glmt`) models:
+ * they already own conflicting `-{effort}` aliases (`splitCodexReasoningSuffix`,
+ * `getKimiCodeStaticThinkingPolicy`, or `GlmExecutor::parseGlmEffortTier`), so generating
+ * another layer here would create invalid nested ids. Also skipped for any model whose id
+ * already ends in a token that matches a canonical effort value, to avoid colliding with a
+ * model that legitimately ends in an effort-like token (e.g. a model named "...-high").
*/
import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization.ts";
-/** Provider ids that already own a native `-{effort}` suffix mechanism — never double-register. */
-export const SYNCED_EFFORT_SKIP_PROVIDERS = new Set(["codex"]);
+/** Provider ids with dedicated `-{effort}` aliases — never synthesize another suffix layer. */
+export const SYNCED_EFFORT_SKIP_PROVIDERS = new Set(["codex", "glm", "glm-cn", "glmt"]);
/** Provider-id prefixes covering that mechanism's multiple connection variants (kimi-coding, kimi-coding-apikey). */
const SYNCED_EFFORT_SKIP_PROVIDER_PREFIXES = ["kimi"];
diff --git a/package-lock.json b/package-lock.json
index 57f810910e73..bf77bab602a7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
- "@huggingface/transformers": "^4.2.0",
"@lobehub/icons": "^5.16.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@monaco-editor/react": "^4.7.0",
@@ -61,7 +60,6 @@
"next-themes": "^0.4.6",
"node-machine-id": "^1.1.12",
"omniglyph": "^1.4.0",
- "onnxruntime-node": "1.24.3",
"open": "^11.0.1",
"ora": "^9.4.1",
"parse5": "^8.0.1",
@@ -156,9 +154,11 @@
},
"optionalDependencies": {
"@atjsh/llmlingua-2": "3.0.0",
+ "@huggingface/transformers": "^4.2.0",
"better-sqlite3": "^13.0.2",
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",
+ "onnxruntime-node": "1.24.3",
"sqlite-vec": "^0.1.9",
"tls-client-node": "^0.2.0",
"wreq-js": "^3.0.0"
@@ -4510,6 +4510,7 @@
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz",
"integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==",
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">=18"
}
@@ -4518,13 +4519,15 @@
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz",
"integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==",
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "optional": true
},
"node_modules/@huggingface/transformers": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz",
"integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==",
"license": "Apache-2.0",
+ "optional": true,
"dependencies": {
"@huggingface/jinja": "^0.5.6",
"@huggingface/tokenizers": "^0.1.3",
@@ -9483,30 +9486,35 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
+ "devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
+ "devOptional": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
@@ -9516,24 +9524,28 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
+ "devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@radix-ui/number": {
@@ -12737,6 +12749,7 @@
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
@@ -13998,6 +14011,7 @@
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">=14.0"
}
@@ -14971,7 +14985,8 @@
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/bottleneck": {
"version": "2.19.5",
@@ -17935,6 +17950,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
@@ -17964,6 +17980,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
@@ -18064,7 +18081,8 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/detect-node-es": {
"version": "1.1.0",
@@ -18908,7 +18926,8 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/es6-promisify": {
"version": "7.0.0",
@@ -20400,7 +20419,8 @@
"version": "25.9.23",
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "optional": true
},
"node_modules/flatted": {
"version": "3.4.2",
@@ -21226,6 +21246,7 @@
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"license": "BSD-3-Clause",
+ "optional": true,
"dependencies": {
"boolean": "^3.0.1",
"es6-error": "^4.1.1",
@@ -21243,6 +21264,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
+ "optional": true,
"bin": {
"semver": "bin/semver.js"
},
@@ -21291,6 +21313,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"define-properties": "^1.2.1",
@@ -21645,7 +21668,8 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
- "license": "ISC"
+ "license": "ISC",
+ "optional": true
},
"node_modules/hachure-fill": {
"version": "0.5.2",
@@ -21679,6 +21703,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
@@ -24790,7 +24815,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
- "license": "ISC"
+ "license": "ISC",
+ "optional": true
},
"node_modules/json5": {
"version": "2.2.3",
@@ -26654,6 +26680,7 @@
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"license": "MIT",
+ "optional": true,
"dependencies": {
"escape-string-regexp": "^4.0.0"
},
@@ -29425,6 +29452,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -29632,7 +29660,8 @@
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/onnxruntime-node": {
"version": "1.24.3",
@@ -29640,6 +29669,7 @@
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
"hasInstallScript": true,
"license": "MIT",
+ "optional": true,
"os": [
"win32",
"darwin",
@@ -29656,6 +29686,7 @@
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz",
"integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==",
"license": "MIT",
+ "optional": true,
"dependencies": {
"flatbuffers": "^25.1.24",
"guid-typescript": "^1.0.9",
@@ -29669,13 +29700,15 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "optional": true
},
"node_modules/onnxruntime-web/node_modules/onnxruntime-common": {
"version": "1.24.0-dev.20251116-b39e144322",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz",
"integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/open": {
"version": "11.0.1",
@@ -30906,7 +30939,8 @@
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/playwright": {
"version": "1.62.1",
@@ -31861,6 +31895,7 @@
"version": "7.6.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
+ "devOptional": true,
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -31884,6 +31919,7 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "devOptional": true,
"license": "Apache-2.0"
},
"node_modules/proxy-addr": {
@@ -33280,6 +33316,7 @@
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"license": "BSD-3-Clause",
+ "optional": true,
"dependencies": {
"boolean": "^3.0.1",
"detect-node": "^2.0.4",
@@ -33655,7 +33692,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/send": {
"version": "1.2.1",
@@ -33688,6 +33726,7 @@
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"license": "MIT",
+ "optional": true,
"dependencies": {
"type-fest": "^0.13.1"
},
@@ -33703,6 +33742,7 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"license": "(MIT OR CC0-1.0)",
+ "optional": true,
"engines": {
"node": ">=10"
},
@@ -34474,7 +34514,8 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
- "license": "BSD-3-Clause"
+ "license": "BSD-3-Clause",
+ "optional": true
},
"node_modules/sql.js": {
"version": "1.14.2",
@@ -36187,6 +36228,7 @@
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "devOptional": true,
"license": "MIT"
},
"node_modules/unicode-emoji-modifier-base": {
diff --git a/package.json b/package.json
index bf2740f233a8..55cc574f9ecc 100644
--- a/package.json
+++ b/package.json
@@ -265,7 +265,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
- "@huggingface/transformers": "^4.2.0",
"@lobehub/icons": "^5.16.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@monaco-editor/react": "^4.7.0",
@@ -308,7 +307,6 @@
"next-themes": "^0.4.6",
"node-machine-id": "^1.1.12",
"omniglyph": "^1.4.0",
- "onnxruntime-node": "1.24.3",
"open": "^11.0.1",
"ora": "^9.4.1",
"parse5": "^8.0.1",
@@ -343,9 +341,11 @@
},
"optionalDependencies": {
"@atjsh/llmlingua-2": "3.0.0",
+ "@huggingface/transformers": "^4.2.0",
"better-sqlite3": "^13.0.2",
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",
+ "onnxruntime-node": "1.24.3",
"sqlite-vec": "^0.1.9",
"tls-client-node": "^0.2.0",
"wreq-js": "^3.0.0"
diff --git a/scripts/build/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs
index 37cd31a14acd..2a444174f18a 100644
--- a/scripts/build/build-next-isolated.mjs
+++ b/scripts/build/build-next-isolated.mjs
@@ -131,12 +131,12 @@ function runNextBuild() {
}
export function resolveNextBuildBundlerFlag(baseEnv = process.env) {
- // Turbopack is the default production bundler (Next 16 stable). Benchmarked on
- // this codebase: 2-3x faster than the single-threaded webpack pass (17min -> 9min
- // on a 32-core box; ~20min -> 7min on ubuntu-latest), artifact validated
- // end-to-end (standalone smoke + e2e/package/electron CI jobs). Webpack stays as
- // the explicit escape hatch (=0) for bundler-compat regressions.
- return baseEnv.OMNIROUTE_USE_TURBOPACK === "0" ? "--webpack" : "--turbopack";
+ // Turbopack is the default on Node.js; on Bun or when explicitly disabled (=0),
+ // use Webpack (--webpack) to avoid Turbopack V8 internal worker API mismatches.
+ if (process.versions.bun || baseEnv.OMNIROUTE_USE_TURBOPACK === "0") {
+ return "--webpack";
+ }
+ return "--turbopack";
}
/**
diff --git a/scripts/check/check-supported-node-runtime.ts b/scripts/check/check-supported-node-runtime.ts
index 197966822e97..f2b40e532699 100644
--- a/scripts/check/check-supported-node-runtime.ts
+++ b/scripts/check/check-supported-node-runtime.ts
@@ -15,6 +15,12 @@ if (!support.nodeCompatible) {
process.exit(1);
}
-console.log(
- `Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).`
-);
+if (process.versions.bun) {
+ console.log(
+ `Bun ${process.versions.bun} (${support.nodeVersion}) satisfies OmniRoute secure runtime policy.`
+ );
+} else {
+ console.log(
+ `Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).`
+ );
+}
diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs
index 325398c2861e..e03cfc929030 100644
--- a/scripts/dev/run-next.mjs
+++ b/scripts/dev/run-next.mjs
@@ -83,8 +83,10 @@ const { dashboardPort } = runtimePorts;
const hostname = process.env.HOST || "0.0.0.0";
// Turbopack by default in dev (matches the Next 16 CLI default and the production
// build default in build-next-isolated.mjs); OMNIROUTE_USE_TURBOPACK=0 is the
-// webpack escape hatch.
-const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK !== "0";
+// webpack escape hatch. Under Bun, Turbopack native V8 bindings are unavailable,
+// so Bun automatically disables Turbopack and uses Webpack.
+const isBun = Boolean(process.versions.bun);
+const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK !== "0" && !isBun;
process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID();
// Per-process secret used to prove the trusted peer-IP stamp came from this
// server (read by the authz middleware in the same process). See peer-stamp.mjs.
diff --git a/skills/omni-webhooks/SKILL.md b/skills/omni-webhooks/SKILL.md
index 251b5f58176c..c60df46ecabe 100644
--- a/skills/omni-webhooks/SKILL.md
+++ b/skills/omni-webhooks/SKILL.md
@@ -1,12 +1,12 @@
---
name: omni-webhooks
-description: Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries.
+description: Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries.
---
## Overview
-Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries.
+Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries.
## Authentication
diff --git a/src/app/(dashboard)/dashboard/CheaperInferenceSponsorBanner.tsx b/src/app/(dashboard)/dashboard/CheaperInferenceSponsorBanner.tsx
new file mode 100644
index 000000000000..77cf19329833
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/CheaperInferenceSponsorBanner.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import { useTranslations } from "next-intl";
+import ProviderIcon from "@/shared/components/ProviderIcon";
+
+// Branded short link through our own link.omniroute.online shortener, so the
+// click lands in our Kutt metrics. Points at cheaperinference.com?utm_source=omniroute
+// (the URL in README.md's Open Source Friends section). Keep in sync with the
+// `cheaper` slug on the shortener.
+const CHEAPER_INFERENCE_URL = "https://link.omniroute.online/cheaper";
+
+// Cheaper Inference brand green (#31f889). White text on it fails contrast, so
+// the CTA pairs it with the dark ink from the provider's color token (colors.ts:
+// cheaperinference.text = #04170d). Hex values stay in sync with that token.
+
+const DISMISS_STORAGE_KEY = "omniroute-cheaperinference-sponsor-banner-dismissed-v1";
+// Same-tab signal for the dismiss button, since writing localStorage doesn't
+// fire a "storage" event in the tab that wrote it.
+const DISMISS_EVENT = "omniroute:cheaperinference-sponsor-banner-dismissed";
+
+function isNotDismissed(): boolean {
+ try {
+ return !localStorage.getItem(DISMISS_STORAGE_KEY);
+ } catch {
+ return true;
+ }
+}
+
+function subscribe(callback: () => void) {
+ window.addEventListener(DISMISS_EVENT, callback);
+ return () => window.removeEventListener(DISMISS_EVENT, callback);
+}
+
+// SSR has no localStorage, so the server always renders the banner visible;
+// useSyncExternalStore reconciles that against the real client-side value
+// right after hydration, mirroring KimiSponsorBanner's pattern.
+function getServerSnapshot() {
+ return true;
+}
+
+/**
+ * Dismissable banner announcing the Cheaper Inference OmniRoute partnership on
+ * the dashboard home page — same size/shape as KimiSponsorBanner, no version
+ * gate (durable partnership, not a time-boxed offer). The logomark reuses
+ * .
+ */
+export default function CheaperInferenceSponsorBanner() {
+ const t = useTranslations("cheaperInferenceSponsorBanner");
+ const visible = useSyncExternalStore(subscribe, isNotDismissed, getServerSnapshot);
+
+ if (!visible) {
+ return null;
+ }
+
+ const dismiss = () => {
+ try {
+ localStorage.setItem(DISMISS_STORAGE_KEY, "true");
+ } catch {
+ // ignore — worst case the banner reappears next visit
+ }
+ window.dispatchEvent(new Event(DISMISS_EVENT));
+ };
+
+ return (
+
+
+
+
+
{t("title")}
+
{t("description")}
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/VscodeCopilotBanner.tsx b/src/app/(dashboard)/dashboard/VscodeCopilotBanner.tsx
index 0b23976531cd..1715f17c8394 100644
--- a/src/app/(dashboard)/dashboard/VscodeCopilotBanner.tsx
+++ b/src/app/(dashboard)/dashboard/VscodeCopilotBanner.tsx
@@ -6,7 +6,9 @@ import { useTranslations } from "next-intl";
// Marketplace listing is the primary CTA; Open VSX (Cursor/Windsurf/VSCodium/etc.)
// is called out via secondaryNote instead of a second button, to keep this banner
// the same size as KimiSponsorBanner.
-const MARKETPLACE_URL = "https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot";
+// Branded short link through our own link.omniroute.online shortener (the `vsx`
+// slug), so the click lands in our Kutt metrics.
+const MARKETPLACE_URL = "https://link.omniroute.online/vsx";
const DISMISS_STORAGE_KEY = "omniroute-vscode-copilot-banner-dismissed-v1";
// Same-tab signal for the dismiss button, since writing localStorage doesn't
diff --git a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx
index 7970c40a8eef..8d8c5c55738f 100644
--- a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx
+++ b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx
@@ -291,7 +291,9 @@ function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) {
icon="monitor_heart"
label={t("comboHealthIssues")}
value={report.summary.issueCount.toLocaleString()}
- subValue={t("comboHealthActionable", { count: report.summary.actionableCount })}
+ subValue={t("comboHealthActionable", {
+ count: report.summary.suggestionCount ?? report.summary.actionableCount ?? 0,
+ })}
/>
+) {
+ if (typeof t?.has === "function" && t.has(key)) return t(key, values);
return fallback;
}
@@ -94,10 +99,9 @@ export default function IntelligentComboPanel({
const updatedCombo = await response.json();
onComboUpdated?.(updatedCombo);
notify.success(
- getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.").replace(
- "{pack}",
- modePackId
- )
+ getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.", {
+ pack: modePackId,
+ }).replace("{pack}", modePackId)
);
} catch (error: any) {
notify.error(error?.message || "Failed to update mode pack.");
@@ -184,10 +188,9 @@ export default function IntelligentComboPanel({
{savingModePack && (
- {getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace(
- "{pack}",
- savingModePack
- )}
+ {getI18nOrFallback(t, "savingModePack", "Saving {pack}…", {
+ pack: savingModePack,
+ }).replace("{pack}", savingModePack)}
)}
diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx
index fceada4eb562..7ca3af202480 100644
--- a/src/app/(dashboard)/dashboard/combos/page.tsx
+++ b/src/app/(dashboard)/dashboard/combos/page.tsx
@@ -533,9 +533,9 @@ function getStrategyBadgeClass(strategy) {
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
}
-function getI18nOrFallback(t, key, fallback) {
+function getI18nOrFallback(t, key, fallback, values) {
try {
- if (typeof t.has === "function" && t.has(key)) return t(key);
+ if (typeof t.has === "function" && t.has(key)) return t(key, values);
} catch {}
return fallback;
}
@@ -1565,7 +1565,8 @@ function StrategyRecommendationsPanel({ strategy, onApply, showNudge }) {
{getI18nOrFallback(
t,
"recommendationsUpdated",
- "Recommendations updated for {strategy}."
+ "Recommendations updated for {strategy}.",
+ { strategy: strategyLabel }
).replace("{strategy}", strategyLabel)}
)}
@@ -3895,15 +3896,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
)}
- {config.reasoningTransportFallback !== "skip" && (
-
- {getI18nOrFallback(
- t,
- "reasoningTransportFallbackDropWarning",
- "May lose continuation context or cause tool-call continuations to fail."
- )}
-
- )}
M365HarImportResult;
+
+// One entry per web-session provider that can offer HAR import. Add a new
+// key here (and its own extractor in src/shared/utils/) to support another
+// provider — the button renders nothing for any provider not listed.
+const HAR_IMPORTERS: Record = {
+ "copilot-m365-web": extractM365CredentialFromHar,
+};
+
+const ERROR_MESSAGE_KEYS: Record = {
+ notJson: ["harImportErrorNotJson", "That file isn't valid JSON — is it really a .har export?"],
+ noEntries: ["harImportErrorNoEntries", "This HAR has no network entries recorded."],
+ noChathubUrl: [
+ "harImportErrorNoChathubUrl",
+ "No Copilot chat connection found in this HAR. Send at least one chat message in m365.cloud.microsoft before exporting.",
+ ],
+ unparsableUrl: [
+ "harImportErrorUnparsableUrl",
+ "Found the chat connection, but couldn't read its URL.",
+ ],
+ missingFields: [
+ "harImportErrorMissingFields",
+ "Found the chat connection, but the token was missing from it.",
+ ],
+};
+
+export interface HarImportButtonProps {
+ provider: string;
+ onImport: (apiKey: string) => void;
+}
+
+export default function HarImportButton({ provider, onImport }: HarImportButtonProps) {
+ const t = useTranslations("providers") as ProviderMessageTranslator;
+ const importer = HAR_IMPORTERS[provider];
+ const fileInputRef = useRef(null);
+ const [state, setState] = useState<
+ | { phase: "idle" }
+ | { phase: "reading" }
+ | { phase: "error"; message: string }
+ | { phase: "success"; expiresAt: number | null }
+ >({ phase: "idle" });
+
+ if (!importer) return null;
+
+ async function handleFile(file: File | undefined) {
+ if (!file) return;
+ setState({ phase: "reading" });
+ let text: string;
+ try {
+ text = await file.text();
+ } catch {
+ setState({
+ phase: "error",
+ message: providerText(t, "harImportErrorReadFailed", "Couldn't read that file."),
+ });
+ return;
+ }
+
+ const result = importer(text);
+ if (!result.ok) {
+ const [key, fallback] = ERROR_MESSAGE_KEYS[result.error] ?? [
+ "harImportErrorUnknown",
+ "Couldn't extract a credential from that HAR file.",
+ ];
+ setState({ phase: "error", message: providerText(t, key, fallback) });
+ return;
+ }
+
+ onImport(result.apiKey);
+ setState({ phase: "success", expiresAt: result.expiresAt });
+ }
+
+ const expiry = state.phase === "success" ? describeHarImportExpiry(state.expiresAt) : null;
+ const expiryText =
+ expiry?.tone === "unknown"
+ ? providerText(t, "harImportStatusUnknownExpiry", "Imported. Couldn't read its expiry.")
+ : expiry?.tone === "bad"
+ ? providerText(
+ t,
+ "harImportStatusExpired",
+ "Imported, but this token already expired ({minutes}m ago) — export a fresh HAR.",
+ { minutes: Math.abs(expiry.minutesRemaining ?? 0) }
+ )
+ : expiry?.tone === "warn"
+ ? providerText(
+ t,
+ "harImportStatusExpiringSoon",
+ "Imported — valid for only ~{minutes}m more.",
+ { minutes: expiry.minutesRemaining ?? 0 }
+ )
+ : expiry?.tone === "ok"
+ ? providerText(t, "harImportStatusValid", "Imported — valid for ~{minutes}m.", {
+ minutes: expiry.minutesRemaining ?? 0,
+ })
+ : null;
+
+ return (
+
+
+ fileInputRef.current?.click()}
+ disabled={state.phase === "reading"}
+ data-testid="har-import-button"
+ className="inline-flex items-center gap-1.5 rounded border border-border px-2.5 py-1.5 text-xs font-medium text-text-main hover:bg-surface-hover disabled:opacity-50"
+ >
+
+ upload_file
+
+ {state.phase === "reading"
+ ? providerText(t, "harImportButtonBusy", "Importing…")
+ : providerText(t, "harImportButtonLabel", "Import .har file")}
+
+
+ {providerText(
+ t,
+ "harImportButtonHint",
+ "Export from DevTools Network tab after sending at least one chat message."
+ )}
+
+ {
+ void handleFile(event.target.files?.[0]);
+ event.target.value = "";
+ }}
+ />
+
+ {state.phase === "error" && (
+
+ {state.message}
+
+ )}
+ {state.phase === "success" && expiryText && (
+
+ {expiryText}
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx
index 8bdddc2aa73b..9a2bdc003802 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx
@@ -31,6 +31,7 @@ import {
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
+import HarImportButton from "../HarImportButton";
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
import { buildAddProviderSpecificData } from "./connectionProviderSpecificData";
import { getCommandCodeAuthPhaseLabel } from "./commandCodeAuthPhase";
@@ -155,7 +156,7 @@ export default function AddApiKeyModal({
if (!isOpen || wasOpen) return;
// On open, reset baseUrl and assign a unique default name so a second API key
// for the same provider doesn't reuse "main" and trigger the backend
- // name-based upsert that would silently overwrite the first connection (#6499).
+ // name-based upsert that would silently overwrite the first connection (#6499, #11033).
setFormData((current) => ({
...current,
name: computeConnectionDefaultName(existingConnectionCount),
@@ -209,13 +210,13 @@ export default function AddApiKeyModal({
? "Freebuff uses an authentic CLI auth token obtained via codebuff CLI login or automated harvester."
: isWebSessionCredential
? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false)
- : isLocalSelfHostedProvider
- ? t("localProviderApiKeyOptionalHint", {
- provider: localProviderMetadata?.name || providerName || provider || "",
- })
- : apiKeyOptional
- ? t("apiKeyOptionalHint")
- : undefined;
+ : isLocalSelfHostedProvider
+ ? t("localProviderApiKeyOptionalHint", {
+ provider: localProviderMetadata?.name || providerName || provider || "",
+ })
+ : apiKeyOptional
+ ? t("apiKeyOptionalHint")
+ : undefined;
const credentialValidationFailedMessage = isWebSessionCredential
? providerText(
t,
@@ -750,40 +751,54 @@ export default function AddApiKeyModal({
t={t}
/>
)}
- {!isNoAuthWebSessionCredential && (
-
-
setFormData({ ...formData, apiKey: e.target.value })}
- className="flex-1"
- placeholder={apiCredentialPlaceholder}
- hint={apiCredentialHint}
- autoComplete="off"
- spellCheck={false}
- autoCapitalize="off"
- />
-
-
- {validating
- ? t("checking")
- : webSessionCredential
- ? getWebSessionCredentialCheckLabel(t, webSessionCredential)
- : t("check")}
-
-
-
+ {provider && (
+ setFormData({ ...formData, apiKey })}
+ />
)}
+ {!isNoAuthWebSessionCredential && (() => {
+ const isCheckDisabled =
+ (!isCompatible && !apiKeyOptional && !formData.apiKey) ||
+ (isGooglePse && !formData.cx.trim()) ||
+ validating ||
+ saving;
+ return (
+
+
setFormData({ ...formData, apiKey: e.target.value })}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && !isCheckDisabled) {
+ e.preventDefault();
+ handleValidate();
+ }
+ }}
+ className="flex-1"
+ placeholder={apiCredentialPlaceholder}
+ hint={apiCredentialHint}
+ autoComplete="off"
+ spellCheck={false}
+ autoCapitalize="off"
+ />
+
+
+ {validating
+ ? t("checking")
+ : webSessionCredential
+ ? getWebSessionCredentialCheckLabel(t, webSessionCredential)
+ : t("check")}
+
+
+
+ );
+ })()}
{isChatGptWebCodex && (
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx
index 39028de1367f..82c41e82c44b 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx
@@ -49,6 +49,7 @@ import {
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
+import HarImportButton from "../HarImportButton";
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
import { CodexConnectionFields } from "./CodexFingerprintFields";
import { assignEditApiKeyProviderSpecificData } from "./connectionProviderSpecificData";
@@ -909,6 +910,12 @@ export default function EditConnectionModal({
t={t}
/>
)}
+ {provider && (
+
setFormData({ ...formData, apiKey })}
+ />
+ )}
{!isNoAuthWebSessionCredential && (
(typeof item === "string" ? item : item?.name ?? ""))
+ .filter(Boolean)
+ );
+ if (!names.has("main")) return "main";
+ let index = 2;
+ while (names.has(`main-${index}`)) {
+ index++;
+ }
+ return `main-${index}`;
+ }
+
+ const count = existingConnectionCountOrConnections ?? 0;
return count <= 0 ? "main" : `main-${count + 1}`;
}
diff --git a/src/app/(dashboard)/home/page.tsx b/src/app/(dashboard)/home/page.tsx
index beccde22610e..bc10df88f424 100644
--- a/src/app/(dashboard)/home/page.tsx
+++ b/src/app/(dashboard)/home/page.tsx
@@ -4,6 +4,7 @@ import { getSettings } from "@/lib/localDb";
import HomePageClient from "../dashboard/HomePageClient";
import BootstrapBanner from "../dashboard/BootstrapBanner";
import KimiSponsorBanner from "../dashboard/KimiSponsorBanner";
+import CheaperInferenceSponsorBanner from "../dashboard/CheaperInferenceSponsorBanner";
import VscodeCopilotBanner from "../dashboard/VscodeCopilotBanner";
import NewsBanner from "../dashboard/NewsBanner";
@@ -20,6 +21,7 @@ export default async function HomePage() {
<>
{isBootstrapped &&
}
+
diff --git a/src/app/a2a/route.ts b/src/app/a2a/route.ts
index e9e90a99abbc..af7d93a2e506 100644
--- a/src/app/a2a/route.ts
+++ b/src/app/a2a/route.ts
@@ -17,6 +17,8 @@ import { logRoutingDecision } from "@/lib/a2a/routingLogger";
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution";
import { getSettings } from "@/lib/db/settings";
+import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
+import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
// ============ A2A v1.0 ↔ v0.3 compatibility layer ============
// A2A 1.0 renamed the JSON-RPC methods (message/send → SendMessage,
@@ -136,14 +138,25 @@ function tokensMatch(provided: string, expected: string): boolean {
return timingSafeEqual(a, b);
}
-function authenticate(req: NextRequest): boolean {
- // If no API key is configured, allow all requests
+async function authenticate(req: NextRequest): Promise
{
+ // /a2a is outside the authz proxy matcher, so the REQUIRE_API_KEY posture the
+ // pipeline enforces for /v1 never ran here — the route accepted every caller
+ // whenever OMNIROUTE_API_KEY was unset, which is the shipped default
+ // (GHSA-v54m-6rm3-p565). Apply the same posture directly: when a client key is
+ // required, demand a valid OmniRoute key; otherwise honor the legacy explicit
+ // A2A key; otherwise stay keyless (the same local-first default as /v1).
+ const apiKey = extractApiKey(req);
+ if (isRequireApiKeyEnabled()) {
+ return apiKey ? await isValidApiKey(apiKey) : false;
+ }
+
const configuredKey = process.env.OMNIROUTE_API_KEY;
- if (!configuredKey) return true;
+ if (configuredKey) {
+ return apiKey ? tokensMatch(apiKey, configuredKey) : false;
+ }
- const authHeader = req.headers.get("authorization") || "";
- const token = authHeader.replace(/^Bearer\s+/i, "");
- return tokensMatch(token, configuredKey);
+ // No API key required and none configured — allow (keyless local-first).
+ return true;
}
// ============ JSON-RPC Helpers ============
@@ -179,7 +192,7 @@ async function rejectIfA2ADisabled(id: string | number | null) {
export async function POST(req: NextRequest) {
// Auth check
- if (!authenticate(req)) {
+ if (!(await authenticate(req))) {
return jsonRpcError(null, -32600, "Unauthorized: missing or invalid API key");
}
diff --git a/src/app/api/acp/agents/route.ts b/src/app/api/acp/agents/route.ts
index 9f8fc6bf829d..1aa6c1a3305a 100644
--- a/src/app/api/acp/agents/route.ts
+++ b/src/app/api/acp/agents/route.ts
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
+
+export const dynamic = "force-dynamic";
import {
type CliAgentInfo,
detectInstalledAgents,
diff --git a/src/app/api/admin/concurrency/route.ts b/src/app/api/admin/concurrency/route.ts
index 0d6f59bf51fc..550812e91b94 100644
--- a/src/app/api/admin/concurrency/route.ts
+++ b/src/app/api/admin/concurrency/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
+export const dynamic = "force-dynamic";
import { getAllRateLimitStatus } from "@omniroute/open-sse/services/rateLimitManager.ts";
import {
getStats as getSemaphoreStats,
diff --git a/src/app/api/analytics/compression/route.ts b/src/app/api/analytics/compression/route.ts
index a81e6fef804f..7006cac6eba7 100644
--- a/src/app/api/analytics/compression/route.ts
+++ b/src/app/api/analytics/compression/route.ts
@@ -6,6 +6,7 @@
*/
import { NextResponse } from "next/server";
+export const dynamic = "force-dynamic";
import { getCompressionAnalyticsSummary } from "@/lib/db/compressionAnalytics";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
diff --git a/src/app/api/auth/csrf/route.ts b/src/app/api/auth/csrf/route.ts
index 88fae6c3a263..a18026b4474e 100644
--- a/src/app/api/auth/csrf/route.ts
+++ b/src/app/api/auth/csrf/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
+export const dynamic = "force-dynamic";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { issueDashboardCsrfToken } from "@/server/authz/csrf";
diff --git a/src/app/api/auth/status/route.ts b/src/app/api/auth/status/route.ts
index 9cc4d872c6eb..62cb1a71918e 100644
--- a/src/app/api/auth/status/route.ts
+++ b/src/app/api/auth/status/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
+export const dynamic = "force-dynamic";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
diff --git a/src/app/api/batches/[id]/route.ts b/src/app/api/batches/[id]/route.ts
index 83f23c7e0d31..5d4a73f3d2db 100644
--- a/src/app/api/batches/[id]/route.ts
+++ b/src/app/api/batches/[id]/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
+export const dynamic = "force-dynamic";
import { getBatch } from "@/lib/localDb";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
diff --git a/src/app/api/batches/route.ts b/src/app/api/batches/route.ts
index e67ff13507f1..4c8c54d46e05 100644
--- a/src/app/api/batches/route.ts
+++ b/src/app/api/batches/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
+export const dynamic = "force-dynamic";
import { listBatches } from "@/lib/localDb";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
diff --git a/src/app/api/cache/entries/route.ts b/src/app/api/cache/entries/route.ts
index d2a7ab4bc7e8..aa2e60b04cfc 100644
--- a/src/app/api/cache/entries/route.ts
+++ b/src/app/api/cache/entries/route.ts
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
+export const dynamic = "force-dynamic";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import {
listSemanticCacheEntries,
diff --git a/src/app/api/cache/reasoning/route.ts b/src/app/api/cache/reasoning/route.ts
index 89769a2dcf24..cfa6805c4dc0 100644
--- a/src/app/api/cache/reasoning/route.ts
+++ b/src/app/api/cache/reasoning/route.ts
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
+export const dynamic = "force-dynamic";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import {
clearReasoningCacheAll,
diff --git a/src/app/api/cache/route.ts b/src/app/api/cache/route.ts
index 01d310432cd5..4fcb5bdec8e9 100644
--- a/src/app/api/cache/route.ts
+++ b/src/app/api/cache/route.ts
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
+export const dynamic = "force-dynamic";
import {
getCacheStats,
clearCache,
diff --git a/src/app/api/cache/stats/route.ts b/src/app/api/cache/stats/route.ts
index 33f3c447f9bd..43e48b5e9cc1 100644
--- a/src/app/api/cache/stats/route.ts
+++ b/src/app/api/cache/stats/route.ts
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
+export const dynamic = "force-dynamic";
import { clearMemoryCache, getMemoryCacheStats } from "@/lib/semanticCache";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
diff --git a/src/app/api/gamification/federation/leaderboard/route.ts b/src/app/api/gamification/federation/leaderboard/route.ts
index e198ae2aef82..26e284612230 100644
--- a/src/app/api/gamification/federation/leaderboard/route.ts
+++ b/src/app/api/gamification/federation/leaderboard/route.ts
@@ -38,7 +38,14 @@ export async function GET(request: NextRequest) {
const url = new URL(request.url);
const scope: LeaderboardScope = (url.searchParams.get("scope") || "global") as LeaderboardScope;
- const limit = Number(url.searchParams.get("limit") || 100);
+ const rawLimit = url.searchParams.get("limit");
+ const limit = rawLimit === null ? 100 : Number(rawLimit);
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
+ return NextResponse.json(
+ { error: "'limit' must be an integer between 1 and 200" },
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
const entries = await getTopN(scope, limit);
diff --git a/src/app/api/gamification/leaderboard/route.ts b/src/app/api/gamification/leaderboard/route.ts
index 33159af39cdc..cded801d13ab 100644
--- a/src/app/api/gamification/leaderboard/route.ts
+++ b/src/app/api/gamification/leaderboard/route.ts
@@ -18,10 +18,18 @@ export async function GET(request: NextRequest) {
const url = new URL(request.url);
const scope = (url.searchParams.get("scope") || "global") as LeaderboardScope;
- const limit = Number(url.searchParams.get("limit") || 50);
+ const rawLimit = url.searchParams.get("limit");
+ const limit = rawLimit === null ? 50 : Number(rawLimit);
const apiKeyId = url.searchParams.get("apiKeyId");
- const entries = await getTopN(scope, Math.min(limit, 200));
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
+ return NextResponse.json(
+ { error: "'limit' must be an integer between 1 and 200" },
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+
+ const entries = await getTopN(scope, limit);
let myRank: number | null = null;
let neighbors = null;
diff --git a/src/app/api/local/redis/status/route.ts b/src/app/api/local/redis/status/route.ts
index 7f7cb47bc57d..0ab37e76fdc0 100644
--- a/src/app/api/local/redis/status/route.ts
+++ b/src/app/api/local/redis/status/route.ts
@@ -63,21 +63,51 @@ async function pingRedis(port: string): Promise {
});
}
+function parseRedisUrl(url?: string): { host: string; port: number } | null {
+ if (!url) return null;
+ try {
+ const u = new URL(url);
+ return { host: u.hostname || "127.0.0.1", port: Number(u.port) || 6379 };
+ } catch {
+ return null;
+ }
+}
+
export async function GET() {
const guard = isLocalRequestAllowed();
if (!guard.allowed) {
- return NextResponse.json({ error: guard.reason }, { status: 403 });
+ const reason = (guard as { reason?: string }).reason ?? "Forbidden: not a loopback request";
+ return NextResponse.json({ error: reason }, { status: 403 });
}
+ // Docker/Podman container state (the 1-click launcher path).
const runtime = await detectRuntime();
- if (!runtime) {
- return NextResponse.json(
- { exists: false, running: false, reachable: false, error: "No container runtime (podman or docker) found on PATH" },
- { status: 503 }
- );
+ let container = { exists: false, running: false, reachable: false };
+ if (runtime) {
+ const { exists, running } = await containerState(runtime);
+ const reachable = running ? await pingRedis(HOST_PORT) : false;
+ container = { exists, running, reachable };
}
- const { exists, running } = await containerState(runtime);
- const reachable = running ? await pingRedis(HOST_PORT) : false;
- return NextResponse.json({ runtime, name: CONTAINER_NAME, port: HOST_PORT, exists, running, reachable });
+ // Native Redis via REDIS_URL (the production path this instance uses). OmniRoute
+ // is "connected" whenever REDIS_URL is configured AND the server answers — even
+ // when no Docker container is present.
+ const redisUrl = process.env.REDIS_URL?.trim() || "";
+ const parsed = parseRedisUrl(redisUrl);
+ const redisUrlReachable = parsed ? await pingRedis(String(parsed.port)) : false;
+
+ const running = container.running || redisUrlReachable;
+ const reachable = container.reachable || redisUrlReachable;
+ const exists = container.exists || redisUrlReachable;
+
+ return NextResponse.json({
+ runtime: runtime ?? null,
+ name: CONTAINER_NAME,
+ port: HOST_PORT,
+ exists,
+ running,
+ reachable,
+ redisUrlConfigured: Boolean(redisUrl),
+ redisUrlReachable,
+ });
}
\ No newline at end of file
diff --git a/src/app/api/monitoring/health/route.ts b/src/app/api/monitoring/health/route.ts
index 144d0a3ce06d..f3dceac5583c 100644
--- a/src/app/api/monitoring/health/route.ts
+++ b/src/app/api/monitoring/health/route.ts
@@ -5,6 +5,7 @@ import { readRunningBuildSha } from "@/lib/monitoring/buildSha";
import { APP_CONFIG } from "@/shared/constants/config";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { isAuthenticated } from "@/shared/utils/apiAuth";
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
/**
* GET /api/monitoring/health — System health overview
@@ -20,10 +21,25 @@ import { isAuthenticated } from "@/shared/utils/apiAuth";
let healthPayloadCache: { payload: unknown; expiresAt: number } | null = null;
const HEALTH_PAYLOAD_TTL_MS = 1000;
-export async function GET() {
+// GHSA-mvf8-qc78-5mxm: the full health payload fingerprints the host (version,
+// node version, pid, memory, provider config). An anonymous caller — the common
+// case on a keyless install, and what a liveness/load-balancer probe needs — gets
+// only the liveness verdict; the detail is reserved for a management principal.
+function publicHealthView(payload: unknown): Record {
+ const p = (payload ?? {}) as Record;
+ return {
+ status: p.status ?? "unknown",
+ ...(p.setupComplete !== undefined ? { setupComplete: p.setupComplete } : {}),
+ };
+}
+
+export async function GET(request: Request) {
+ const fullView = (await requireManagementAuth(request, { alwaysRequireAuth: true })) === null;
const cachedNow = Date.now();
if (healthPayloadCache && cachedNow <= healthPayloadCache.expiresAt) {
- return NextResponse.json(healthPayloadCache.payload);
+ return NextResponse.json(
+ fullView ? healthPayloadCache.payload : publicHealthView(healthPayloadCache.payload)
+ );
}
const readHealthValue = (label: string, reader: () => T, fallback: T): T => {
@@ -187,7 +203,7 @@ export async function GET() {
});
healthPayloadCache = { payload, expiresAt: Date.now() + HEALTH_PAYLOAD_TTL_MS };
- return NextResponse.json(payload);
+ return NextResponse.json(fullView ? payload : publicHealthView(payload));
} catch (error) {
console.error("[API] GET /api/monitoring/health error:", error);
return NextResponse.json({
diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts
index 7b71a07758c7..b52bae420128 100755
--- a/src/app/api/oauth/[provider]/[action]/route.ts
+++ b/src/app/api/oauth/[provider]/[action]/route.ts
@@ -24,6 +24,7 @@ import {
} from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { isValidGheUrl } from "@/shared/validation/providerSpecificData";
+import { AWS_REGION_PATTERN } from "@/lib/oauth/constants/oauth";
import { syncToCloud } from "@/lib/cloudSync";
import { startLocalServer } from "@/lib/oauth/utils/server";
import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
@@ -221,6 +222,16 @@ export async function GET(
(requestDeviceCode as any)(provider, null, providerOverrideConfig)
);
} else if ((provider === "kiro" || provider === "amazon-q") && startUrl) {
+ // GHSA-7x63: `region` is interpolated into the AWS OIDC endpoint URLs
+ // below, which requestDeviceCode() then fetches. Validate it against the
+ // canonical AWS region shape before it can steer the outbound host to an
+ // attacker-chosen target (userinfo/fragment tricks → SSRF / metadata).
+ if (!AWS_REGION_PATTERN.test(region)) {
+ return NextResponse.json(
+ { error: "region must be a valid AWS region (e.g. us-east-1)" },
+ { status: 400 }
+ );
+ }
const providerOverrideConfig = {
...providerData.config,
startUrl,
diff --git a/src/app/api/oauth/cliproxy-import/route.ts b/src/app/api/oauth/cliproxy-import/route.ts
index 7c60ba97756e..ca579983b8fe 100644
--- a/src/app/api/oauth/cliproxy-import/route.ts
+++ b/src/app/api/oauth/cliproxy-import/route.ts
@@ -3,7 +3,7 @@ import path from "path";
import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
-import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import {
scanCliProxyAuthDir,
@@ -23,9 +23,9 @@ function cliProxyConfigDir(): string {
}
async function requireImportAuth(request: Request) {
- if (!(await isAuthRequired(request))) return null;
- if (await isAuthenticated(request)) return null;
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ // GHSA-mg76: importing a provider connection is a state-mutating admin action;
+ // require management scope (or a dashboard session), not any valid client key.
+ return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
export async function GET(request: Request) {
diff --git a/src/app/api/oauth/codex/import-token/route.ts b/src/app/api/oauth/codex/import-token/route.ts
index 4601b2ae2d42..a5b22c4e37d5 100644
--- a/src/app/api/oauth/codex/import-token/route.ts
+++ b/src/app/api/oauth/codex/import-token/route.ts
@@ -3,7 +3,7 @@ import { z } from "zod";
import { extractCodexAccountInfo } from "@/lib/oauth/services/codexImport";
import { parseCodexSessionJson } from "@/lib/oauth/utils/codexSessionImport";
import { createProviderConnection } from "@/models";
-import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
/**
@@ -93,10 +93,11 @@ async function parseRequestBody(
return { ok: true, resolved: resolved.resolved };
}
-async function requireAuth(request: Request): Promise {
- if (!(await isAuthRequired(request))) return null;
- if (await isAuthenticated(request)) return null;
- return NextResponse.json(buildErrorBody(401, "Unauthorized"), { status: 401 });
+async function requireAuth(request: Request): Promise {
+ // GHSA-mg76: importing a provider connection is a state-mutating admin action.
+ // Require management scope (or a dashboard session) rather than accepting any
+ // valid client key, which the PUBLIC /api/oauth/ classification otherwise allows.
+ return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
export async function POST(request: Request) {
diff --git a/src/app/api/oauth/codex/import/route.ts b/src/app/api/oauth/codex/import/route.ts
index a7302a3a6db8..6ad900726130 100644
--- a/src/app/api/oauth/codex/import/route.ts
+++ b/src/app/api/oauth/codex/import/route.ts
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { normalizeCodexImportRecord, flattenCodexImportPayload } from "@/lib/oauth/services/codexImport";
import { createProviderConnection } from "@/models";
-import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { refreshCodexToken, isUnrecoverableRefreshError } from "@omniroute/open-sse/services/tokenRefresh.ts";
@@ -82,10 +82,10 @@ const bodySchema = z.object({
}),
});
-async function requireAuth(request: Request): Promise {
- if (!(await isAuthRequired(request))) return null;
- if (await isAuthenticated(request)) return null;
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+async function requireAuth(request: Request): Promise {
+ // GHSA-mg76: importing a provider connection is a state-mutating admin action;
+ // require management scope (or a dashboard session), not any valid client key.
+ return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
export async function POST(request: Request) {
diff --git a/src/app/api/oauth/cursor/auto-import/route.ts b/src/app/api/oauth/cursor/auto-import/route.ts
index c7ba57c41963..7a1fcdda3f4d 100755
--- a/src/app/api/oauth/cursor/auto-import/route.ts
+++ b/src/app/api/oauth/cursor/auto-import/route.ts
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
-import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { tryAgentAuth, tryIdeAuth } from "@/lib/cursor/tokenExtractor";
/**
@@ -11,11 +11,9 @@ import { tryAgentAuth, tryIdeAuth } from "@/lib/cursor/tokenExtractor";
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-4).
*/
export async function GET(request: Request) {
- if (await isAuthRequired(request)) {
- if (!(await isAuthenticated(request))) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
- }
+ // GHSA-mg76 / GHSA-gxv4: reading/importing host credentials is a management action.
+ const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
+ if (authError) return authError;
try {
// Try Cursor IDE first (has both accessToken and machineId)
diff --git a/src/app/api/oauth/cursor/import/route.ts b/src/app/api/oauth/cursor/import/route.ts
index 845946cae9b4..89b8c2674593 100755
--- a/src/app/api/oauth/cursor/import/route.ts
+++ b/src/app/api/oauth/cursor/import/route.ts
@@ -6,15 +6,15 @@ import { isCloudEnabled } from "@/models";
import { syncToCloud } from "@/lib/cloudSync";
import { cursorImportSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
-import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import { resolveProxyForProvider } from "@/models";
async function requireOAuthImportAuth(request: Request) {
- if (!(await isAuthRequired(request))) return null;
- if (await isAuthenticated(request)) return null;
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ // GHSA-mg76: importing a provider connection is a state-mutating admin action;
+ // require management scope (or a dashboard session), not any valid client key.
+ return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
/**
diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts
index 40b927b4c9d2..ca61b177ddba 100755
--- a/src/app/api/oauth/kiro/auto-import/route.ts
+++ b/src/app/api/oauth/kiro/auto-import/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { homedir } from "os";
import { join } from "path";
-import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import {
createProviderConnection,
getProviderConnections,
@@ -31,11 +31,9 @@ import {
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key.
*/
export async function GET(request: Request) {
- if (await isAuthRequired(request)) {
- if (!(await isAuthenticated(request))) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
- }
+ // GHSA-mg76 / GHSA-gxv4: reading/importing host credentials is a management action.
+ const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
+ if (authError) return authError;
const { searchParams } = new URL(request.url);
const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro";
diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts
index d4b89183abd7..29aa19d07fc4 100755
--- a/src/app/api/oauth/kiro/import/route.ts
+++ b/src/app/api/oauth/kiro/import/route.ts
@@ -11,7 +11,7 @@ import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { kiroImportSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
-import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { findKiroConnectionByIdentity } from "@/lib/oauth/kiroConnectionIdentity";
@@ -38,9 +38,9 @@ export function buildKiroImportError(error: unknown): string {
}
async function requireOAuthImportAuth(request: Request) {
- if (!(await isAuthRequired(request))) return null;
- if (await isAuthenticated(request)) return null;
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ // GHSA-mg76: importing a provider connection is a state-mutating admin action;
+ // require management scope (or a dashboard session), not any valid client key.
+ return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
async function upsertImportedKiroConnection(
diff --git a/src/app/api/oauth/raycast/auto-import/route.ts b/src/app/api/oauth/raycast/auto-import/route.ts
index 17e4cf48c51e..4dc3ac800186 100644
--- a/src/app/api/oauth/raycast/auto-import/route.ts
+++ b/src/app/api/oauth/raycast/auto-import/route.ts
@@ -14,14 +14,14 @@ import {
extractLocalRaycastCredentials,
isRaycastLocalExtractAvailable,
} from "@/lib/oauth/services/raycastLocal";
-import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { resolveProxyForProvider } from "@/models";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
async function requireOAuthImportAuth(request: Request) {
- if (!(await isAuthRequired(request))) return null;
- if (await isAuthenticated(request)) return null;
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ // GHSA-mg76: importing a provider connection is a state-mutating admin action;
+ // require management scope (or a dashboard session), not any valid client key.
+ return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
export async function GET(request: Request) {
diff --git a/src/app/api/oauth/raycast/import/route.ts b/src/app/api/oauth/raycast/import/route.ts
index 0ff02764fab6..266dd1aecc6d 100644
--- a/src/app/api/oauth/raycast/import/route.ts
+++ b/src/app/api/oauth/raycast/import/route.ts
@@ -11,14 +11,14 @@ import { createProviderConnection } from "@/models";
import { RaycastService } from "@/lib/oauth/services/raycast";
import { raycastImportSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
-import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { resolveProxyForProvider } from "@/models";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
async function requireOAuthImportAuth(request: Request) {
- if (!(await isAuthRequired(request))) return null;
- if (await isAuthenticated(request)) return null;
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ // GHSA-mg76: importing a provider connection is a state-mutating admin action;
+ // require management scope (or a dashboard session), not any valid client key.
+ return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
export async function POST(request: Request) {
diff --git a/src/app/api/oauth/trae/import/route.ts b/src/app/api/oauth/trae/import/route.ts
index 9f3fba5bacba..c3faeb8e9014 100644
--- a/src/app/api/oauth/trae/import/route.ts
+++ b/src/app/api/oauth/trae/import/route.ts
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
import { traeImportSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
-import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
/**
* POST /api/oauth/trae/import
@@ -22,9 +22,9 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
* region — optional, default "US-East"
*/
async function requireOAuthImportAuth(request: Request) {
- if (!(await isAuthRequired(request))) return null;
- if (await isAuthenticated(request)) return null;
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ // GHSA-mg76: importing a provider connection is a state-mutating admin action;
+ // require management scope (or a dashboard session), not any valid client key.
+ return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
}
export async function POST(request: Request) {
diff --git a/src/app/api/provider-models/route.ts b/src/app/api/provider-models/route.ts
index 6b286b5b19dc..d6b672aae61e 100644
--- a/src/app/api/provider-models/route.ts
+++ b/src/app/api/provider-models/route.ts
@@ -28,6 +28,7 @@ import {
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
import { isAuthenticated } from "@/shared/utils/apiAuth";
+export const dynamic = "force-dynamic";
import { providerModelMutationSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts
index 62988798d77e..0939b59b554d 100644
--- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts
+++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts
@@ -87,6 +87,22 @@ export function parseAlibabaModelStudioModelsForConnection(
export function parseQwenCloudTextModels(data: any): any[] {
return parseCuratedDashscopeModels(data, QWEN_CLOUD_TEXT_MODELS, QWEN_CLOUD_TEXT_MODEL_IDS);
}
+
+// Perplexity's /v1/models lists the Agent API catalog (vendor-prefixed ids like
+// "anthropic/claude-fable-5"), but chat requests always go to the classic
+// /chat/completions endpoint, which only accepts the Sonar family. Filter
+// discovery to Sonar-family ids so agent-style ids never surface as routable
+// chat models (#11060). Bounded pattern — no ReDoS-prone quantifiers.
+export function parsePerplexitySonarModels(data: any): any[] {
+ const models = Array.isArray(data?.data)
+ ? data.data
+ : Array.isArray(data?.models)
+ ? data.models
+ : [];
+ return models.filter(
+ (model: any) => typeof model?.id === "string" && /^sonar(-|$)/.test(model.id)
+ );
+}
type ProviderModelsHeaderContext = {
authType?: string;
providerSpecificData?: unknown;
@@ -659,6 +675,17 @@ export const PROVIDER_MODELS_CONFIG: Record =
headers: { Accept: "application/json" },
parseResponse: parseClinepassRecommendedModels,
},
+ // Perplexity's /v1/models lists the Agent API catalog (vendor-prefixed agent
+ // ids), but chat only accepts the Sonar family on /chat/completions. Import
+ // must keep Sonar-family ids only (#11060).
+ perplexity: {
+ url: "https://api.perplexity.ai/v1/models",
+ method: "GET",
+ headers: { "Content-Type": "application/json" },
+ authHeader: "Authorization",
+ authPrefix: "Bearer ",
+ parseResponse: parsePerplexitySonarModels,
+ },
cohere: {
url: "https://api.cohere.com/v2/models",
method: "GET",
diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts
index 562dad074448..4f588571b87c 100644
--- a/src/app/api/providers/[id]/route.ts
+++ b/src/app/api/providers/[id]/route.ts
@@ -121,7 +121,17 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
const { id } = await params;
const validation = validateBody(updateProviderConnectionSchema, rawBody);
if (isValidationFailure(validation)) {
- return NextResponse.json({ error: validation.error }, { status: 400 });
+ // never drop an operator's intent silently. Surface the rejected
+ // keys (field paths and unrecognized-key names) alongside the existing
+ // error envelope so clients and the UI can tell exactly what was refused.
+ const rejected = [
+ ...validation.error.details.map((d) => d.field).filter(Boolean),
+ ...validation.error.details.flatMap((d) => d.keys ?? []),
+ ];
+ return NextResponse.json(
+ { error: { ...validation.error, rejected } },
+ { status: 400 }
+ );
}
const body = validation.data;
const {
diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts
index d40729e2e929..9ed7f10667f3 100644
--- a/src/app/api/providers/route.ts
+++ b/src/app/api/providers/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
+export const dynamic = "force-dynamic";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import {
getProviderAuditTarget,
diff --git a/src/app/api/settings/obsidian/webdav/route.ts b/src/app/api/settings/obsidian/webdav/route.ts
index 1465b1dafd70..4b098cfdb008 100644
--- a/src/app/api/settings/obsidian/webdav/route.ts
+++ b/src/app/api/settings/obsidian/webdav/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import {
getObsidianSyncStatus,
@@ -21,10 +22,19 @@ export async function GET(request: NextRequest) {
try {
const status = await getObsidianSyncStatus();
+ // GHSA-62vw: the WebDAV password is reusable authentication material. Return
+ // the plaintext only to a genuine management principal (dashboard session or
+ // manage-scope key), never to an anonymous caller that reached this handler
+ // through the requireLogin=false open mode. The dashboard's authenticated
+ // reveal-password view is unaffected; anonymous callers get a set/unset flag.
+ const hasManagement =
+ (await requireManagementAuth(request, { alwaysRequireAuth: true })) === null;
return NextResponse.json({
webdavEnabled: status.webdavEnabled,
webdavUsername: status.webdavEnabled ? status.webdavUsername : null,
- webdavPassword: status.webdavEnabled ? status.webdavPassword : null,
+ webdavPassword:
+ status.webdavEnabled && hasManagement ? status.webdavPassword : null,
+ webdavPasswordSet: status.webdavEnabled && Boolean(status.webdavPassword),
vaultPath: status.vaultPath,
});
} catch (error) {
diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts
index 306292865a25..533733c767c6 100644
--- a/src/app/api/usage/analytics/route.ts
+++ b/src/app/api/usage/analytics/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
+export const dynamic = "force-dynamic";
import { getProviderById } from "@/shared/constants/providers";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getApiKeys } from "@/lib/db/apiKeys";
diff --git a/src/app/api/usage/call-logs/route.ts b/src/app/api/usage/call-logs/route.ts
index 0a737ea1f96b..c91a0561a5b5 100644
--- a/src/app/api/usage/call-logs/route.ts
+++ b/src/app/api/usage/call-logs/route.ts
@@ -1,8 +1,9 @@
import { NextResponse } from "next/server";
+export const dynamic = "force-dynamic";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getCallLogs } from "@/lib/usageDb";
import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory";
-import { getProviderConnections } from "@/lib/localDb";
+import { getProviderConnections } from "@/lib/db/providers";
import { getProviderNodes } from "@/models";
import { matchesSearch } from "@/shared/utils/turkishText";
@@ -26,6 +27,66 @@ function rowPriority(row: any): number {
return 2;
}
+/**
+ * Applies the active filter predicates to a single merged call-log row.
+ *
+ * `getCallLogs()` already filters the persisted DB rows server-side, but the
+ * in-memory entries (active/pending + recently-completed) are merged in by
+ * `buildCallLogListRows()` and would otherwise bypass every filter except
+ * `correlationId`. Running the same predicates over the merged rows closes that
+ * gap. It is idempotent for DB rows (they already satisfy the predicate) while
+ * correctly excluding in-memory rows that do not match.
+ */
+export function rowMatchesFilter(row: any, filter: Record): boolean {
+ if (!filter) return true;
+
+ if (filter.status === "error") {
+ if (!(Number(row?.status) >= 400 || Boolean(row?.error))) return false;
+ } else if (filter.status === "ok") {
+ if (!(Number(row?.status) >= 200 && Number(row?.status) < 300)) return false;
+ } else if (typeof filter.status === "number" || (typeof filter.status === "string" && !isNaN(Number(filter.status)))) {
+ if (Number(row?.status) !== Number(filter.status)) return false;
+ }
+
+ if (filter.model && !matchesSearch(row?.model || "", String(filter.model))) {
+ return false;
+ }
+ if (filter.provider && !matchesSearch(row?.provider || "", String(filter.provider))) {
+ return false;
+ }
+ if (filter.account && !matchesSearch(row?.account || "", String(filter.account))) {
+ return false;
+ }
+ if (filter.apiKey && !matchesSearch(row?.apiKeyName || "", String(filter.apiKey))) {
+ return false;
+ }
+ if (filter.combo && !matchesSearch(row?.comboName || "", String(filter.combo))) {
+ return false;
+ }
+ if (filter.correlationId && !matchesSearch(row?.correlationId || "", String(filter.correlationId))) {
+ return false;
+ }
+ if (filter.search) {
+ const term = String(filter.search);
+ const haystack = [
+ row?.model,
+ row?.provider,
+ row?.providerDisplay,
+ row?.account,
+ row?.apiKeyName,
+ row?.comboName,
+ row?.correlationId,
+ row?.error,
+ row?.path,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ if (!matchesSearch(haystack, term)) return false;
+ }
+
+ return true;
+}
+
export function buildCallLogListRows({
logs,
connections,
@@ -173,15 +234,8 @@ export async function GET(request: Request) {
completedDetails: getCompletedDetails().values(),
});
- // When correlationId filter is set, also filter in-memory entries
- // (active + completed) that don't match — getCallLogs already filters
- // the DB rows but activeEntries/completedEntries bypass it.
- if (filter.correlationId) {
- const cid = filter.correlationId;
- return NextResponse.json(rows.filter((r: any) => matchesSearch(r.correlationId || "", cid)));
- }
-
- return NextResponse.json(rows);
+ const filtered = rows.filter((r: any) => rowMatchesFilter(r, filter));
+ return NextResponse.json(filtered);
} catch (error) {
console.error("[API ERROR] /api/usage/call-logs failed:", error);
return NextResponse.json({ error: "Failed to fetch call logs" }, { status: 500 });
diff --git a/src/app/api/v1/combos/projectCombo.ts b/src/app/api/v1/combos/projectCombo.ts
index 0fdae64ef897..924022d505f2 100644
--- a/src/app/api/v1/combos/projectCombo.ts
+++ b/src/app/api/v1/combos/projectCombo.ts
@@ -5,6 +5,10 @@
* returning combo metadata to API-key callers. Kept in a separate module so
* the projection can be unit-tested without spinning up the Next.js route.
*
+ * #10968: the projection also reports `accountPinned` per model step — a boolean
+ * derived from the stripped `connectionId`, so callers can distinguish a combo
+ * that fails over between two accounts of one provider from a duplicated step.
+ *
* #3979: client-facing combo catalogs (the `/v1/combos`, VS Code and LobeHub /
* OpenCode import surfaces) can opt into advertising the combo's resolved
* capabilities (multimodal / reasoning / caching) so importing clients enable
@@ -17,6 +21,19 @@ export interface PublicComboStep {
model?: string;
comboName?: string;
providerId?: string;
+ /**
+ * #10968: whether this step pins one specific account of its provider.
+ *
+ * Two steps that pin different accounts of the same provider project to
+ * identical `{kind, model, providerId}` objects, so a client cannot tell a
+ * two-account failover from the same step listed twice. This says which it
+ * is without exposing the `connectionId` the flag is derived from — not even
+ * a prefix, per the issue.
+ *
+ * Set on every `model` step. Absent on `combo-ref`, which routes through
+ * another combo and has no account of its own.
+ */
+ accountPinned?: boolean;
}
/**
@@ -66,6 +83,10 @@ export function projectComboStep(step: Record): PublicComboStep
if (typeof step.providerId === "string" && step.providerId.length > 0) {
out.providerId = step.providerId;
}
+ // Same shape test as providerId above. `cleanupComboConnectionRefs` drops the
+ // key when the connection is deleted, so a step whose pinned account is gone
+ // reports false rather than pointing at nothing.
+ out.accountPinned = typeof step.connectionId === "string" && step.connectionId.length > 0;
return out;
}
return null;
diff --git a/src/app/api/v1/moderations/route.ts b/src/app/api/v1/moderations/route.ts
index 36fb4aa75a82..1ab740e5adcb 100644
--- a/src/app/api/v1/moderations/route.ts
+++ b/src/app/api/v1/moderations/route.ts
@@ -68,7 +68,7 @@ async function postHandler(request, context) {
const response = await handleModeration({ body: { ...body, model }, credentials });
if (response?.ok) {
- await clearRecoveredProviderState(credentials);
+ await clearRecoveredProviderState(credentials as Record);
}
return response;
}
diff --git a/src/app/api/v1/rerank/route.ts b/src/app/api/v1/rerank/route.ts
index bf9da386eb4a..04fb1632dc6c 100644
--- a/src/app/api/v1/rerank/route.ts
+++ b/src/app/api/v1/rerank/route.ts
@@ -10,11 +10,15 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { v1RerankSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
-import { getCachedProviderNodes } from "@/lib/localDb";
+import { getCachedProviderNodes } from "@/lib/db/readCache";
import {
isAllRateLimitedCredentials,
rateLimitedProviderResponse,
} from "@/app/api/v1/_shared/rateLimit";
+import { saveCallLog } from "@/lib/usageDb";
+import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
+import { generateRequestId } from "@/shared/utils/requestId";
+import { CORS_HEADERS } from "@omniroute/open-sse/utils/cors.ts";
/**
* Handle CORS preflight
@@ -121,6 +125,8 @@ async function postHandler(request, context) {
return_documents: body.return_documents,
credentials,
connectionId: (credentials as { connectionId?: string } | null)?.connectionId || null,
+ apiKeyId: policy.apiKeyInfo?.id || null,
+ apiKeyName: policy.apiKeyInfo?.name || null,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
@@ -148,8 +154,9 @@ async function postHandler(request, context) {
}
const token = credentials?.apiKey || credentials?.accessToken;
+ const startTime = Date.now();
try {
- const res = await fetch(localProvider.baseUrl, {
+ let res = await fetch(localProvider.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -164,19 +171,110 @@ async function postHandler(request, context) {
}),
});
+ // Some local providers (e.g. Infinity, TEI) mount at /rerank rather than /v1/rerank
+ if (res.status === 404 && localProvider.baseUrl.endsWith("/v1/rerank")) {
+ const fallbackUrl = localProvider.baseUrl.replace(/\/v1\/rerank$/, "/rerank");
+ try {
+ const fallbackRes = await fetch(fallbackUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ model: localModel,
+ query: body.query,
+ documents: body.documents,
+ top_n: body.top_n || body.documents.length,
+ return_documents: body.return_documents !== false,
+ }),
+ });
+ if (fallbackRes.ok || fallbackRes.status !== 404) {
+ res = fallbackRes;
+ }
+ } catch {
+ // retain original 404 response if fallback fetch fails
+ }
+ }
+
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
- return errorResponse(
- res.status,
- errData.message || errData.detail || `Provider returned HTTP ${res.status}`
- );
+ const errorMessage =
+ errData.message || errData.detail || `Provider returned HTTP ${res.status}`;
+ saveCallLog({
+ method: "POST",
+ path: "/v1/rerank",
+ status: res.status,
+ model: body.model,
+ provider: prefix,
+ connectionId:
+ (credentials as { connectionId?: string } | null)?.connectionId || undefined,
+ duration: Date.now() - startTime,
+ requestBody: {
+ model: body.model,
+ query: body.query,
+ documents: body.documents,
+ top_n: body.top_n,
+ return_documents: body.return_documents,
+ },
+ responseBody: errData,
+ error: errorMessage,
+ apiKeyId: policy.apiKeyInfo?.id || undefined,
+ apiKeyName: policy.apiKeyInfo?.name || undefined,
+ }).catch(() => {});
+ return errorResponse(res.status, errorMessage);
}
const data = await res.json();
- return Response.json(data, {
- headers: {},
+ const latencyMs = Date.now() - startTime;
+ saveCallLog({
+ method: "POST",
+ path: "/v1/rerank",
+ status: 200,
+ model: body.model,
+ provider: prefix,
+ connectionId:
+ (credentials as { connectionId?: string } | null)?.connectionId || undefined,
+ duration: latencyMs,
+ tokens: { prompt_tokens: 0, completion_tokens: 0 },
+ requestBody: {
+ model: body.model,
+ query: body.query,
+ documents: body.documents,
+ top_n: body.top_n,
+ return_documents: body.return_documents,
+ },
+ responseBody: data,
+ apiKeyId: policy.apiKeyInfo?.id || undefined,
+ apiKeyName: policy.apiKeyInfo?.name || undefined,
+ }).catch(() => {});
+
+ const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" });
+ attachOmniRouteMetaHeaders(headers, {
+ provider: prefix,
+ model: localModel,
+ costUsd: 0,
+ latencyMs,
+ requestId: generateRequestId(),
+ });
+ return new Response(JSON.stringify(data), {
+ status: 200,
+ headers,
});
} catch (err: any) {
+ saveCallLog({
+ method: "POST",
+ path: "/v1/rerank",
+ status: 500,
+ model: body.model,
+ provider: prefix,
+ connectionId:
+ (credentials as { connectionId?: string } | null)?.connectionId || undefined,
+ duration: Date.now() - startTime,
+ error: err.message,
+ apiKeyId: policy.apiKeyInfo?.id || undefined,
+ apiKeyName: policy.apiKeyInfo?.name || undefined,
+ }).catch(() => {});
return errorResponse(500, `Rerank request failed: ${err.message}`);
}
}
diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts
index adb888dff778..313b72dece0c 100644
--- a/src/app/api/v1/search/route.ts
+++ b/src/app/api/v1/search/route.ts
@@ -58,9 +58,7 @@ export async function OPTIONS() {
export async function GET() {
const settings = await getSettings().catch(() => ({} as any));
const blockedProviders = settings?.blockedProviders || [];
- const providers = getAllSearchProviders().filter(
- (p) => !isProviderBlockedByIdOrAlias(p.id, blockedProviders)
- );
+ const providers = getAllSearchProviders(blockedProviders);
const timestamp = Math.floor(Date.now() / 1000);
const data = providers.map((p) => ({
diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts
index 5490da1ed41b..98fc6e79c753 100644
--- a/src/app/api/webhooks/[id]/route.ts
+++ b/src/app/api/webhooks/[id]/route.ts
@@ -15,22 +15,15 @@ import { encryptMetadata } from "@/lib/webhookDispatcher";
import { isEncryptionEnabled } from "@/lib/db/encryption";
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
+import { WEBHOOK_EVENT_VALUES } from "@/lib/webhooks/eventDescriptions";
+
const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const;
-const WEBHOOK_EVENT_VALUES = [
- "*",
- "request.completed",
- "request.failed",
- "provider.error",
- "provider.recovered",
- "quota.exceeded",
- "combo.switched",
- "test.ping",
-] as const;
+const WEBHOOK_EVENT_VALUES_WITH_WILDCARD = ["*", ...WEBHOOK_EVENT_VALUES] as const;
const updateWebhookSchema = z
.object({
url: z.string().min(1).max(2000).optional(),
- events: z.array(z.enum(WEBHOOK_EVENT_VALUES)).optional(),
+ events: z.array(z.enum(WEBHOOK_EVENT_VALUES_WITH_WILDCARD)).optional(),
secret: z.string().max(500).optional(),
description: z.string().max(1000).optional(),
enabled: z.boolean().optional(),
diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts
index 44e3d7b63487..2cb018e13fb6 100644
--- a/src/app/api/webhooks/route.ts
+++ b/src/app/api/webhooks/route.ts
@@ -14,12 +14,15 @@ import { encryptMetadata } from "@/lib/webhookDispatcher";
import { isEncryptionEnabled } from "@/lib/db/encryption";
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
+import { WEBHOOK_EVENT_VALUES } from "@/lib/webhooks/eventDescriptions";
+
const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const;
+const WEBHOOK_EVENT_VALUES_WITH_WILDCARD = ["*", ...WEBHOOK_EVENT_VALUES] as const;
const createWebhookSchema = z
.object({
url: z.string().min(1).max(2000),
- events: z.array(z.string()).optional().default(["*"]),
+ events: z.array(z.enum(WEBHOOK_EVENT_VALUES_WITH_WILDCARD)).optional().default(["*"]),
secret: z.string().max(500).optional(),
description: z.string().max(1000).optional().default(""),
kind: z.enum(WEBHOOK_KINDS).optional().default("custom"),
diff --git a/src/app/readyz/route.ts b/src/app/readyz/route.ts
index 8111e32b4b21..796db3b2158f 100644
--- a/src/app/readyz/route.ts
+++ b/src/app/readyz/route.ts
@@ -2,4 +2,5 @@
* Kubernetes-style readiness alias of /healthz.
* Same lifecycle phase, same 200/503 bodies. Not a liveness probe.
*/
-export { dynamic, GET, HEAD } from "../healthz/route";
+export const dynamic = "force-dynamic";
+export { GET, HEAD } from "../healthz/route";
diff --git a/src/domain/configAudit.ts b/src/domain/configAudit.ts
index c2701b2a21f7..873d3b046a5f 100644
--- a/src/domain/configAudit.ts
+++ b/src/domain/configAudit.ts
@@ -13,6 +13,8 @@
* - Optional human notes
*/
+import { getDbInstance } from "../lib/db/core";
+
/** Types of configuration entities that can be audited */
export type AuditTarget = "provider" | "combo" | "policy" | "connection" | "settings";
@@ -72,10 +74,8 @@ export interface ConfigSnapshot {
data: Record;
}
-// ── In-memory store ──────────────────────────────────────────────────────────
-// In production, persist to SQLite alongside other domain state.
+// ── SQLite-backed store ───────────────────────────────────────────────────────
-let auditLog: ConfigAuditEntry[] = [];
let idCounter = 0;
function generateId(): string {
@@ -85,6 +85,40 @@ function generateId(): string {
return `audit-${ts}-${seq}`;
}
+function db() {
+ return getDbInstance();
+}
+
+interface ConfigAuditRow {
+ id: string;
+ timestamp: string;
+ action: string;
+ target: string;
+ target_id: string;
+ target_name: string;
+ before_json: string | null;
+ after_json: string | null;
+ diff_json: string;
+ source: string;
+ note: string | null;
+}
+
+function rowToEntry(row: ConfigAuditRow): ConfigAuditEntry {
+ return {
+ id: row.id,
+ timestamp: row.timestamp,
+ action: row.action as AuditAction,
+ target: row.target as AuditTarget,
+ targetId: row.target_id,
+ targetName: row.target_name,
+ before: row.before_json === null ? null : (JSON.parse(row.before_json) as Record | null),
+ after: row.after_json === null ? null : (JSON.parse(row.after_json) as Record | null),
+ source: row.source as AuditSource,
+ diff: JSON.parse(row.diff_json) as ConfigDiff,
+ note: row.note,
+ };
+}
+
/**
* Compute a structured diff between two configuration states.
*/
@@ -159,12 +193,24 @@ export function recordChange(
note: note ?? null,
};
- auditLog.push(entry);
-
- // Keep log bounded (max 1000 entries in memory)
- if (auditLog.length > 1000) {
- auditLog = auditLog.slice(-1000);
- }
+ db().prepare(
+ `INSERT INTO config_audit_log
+ (id, timestamp, action, target, target_id, target_name, before_json, after_json, diff_json, source, note)
+ VALUES
+ (@id, @timestamp, @action, @target, @targetId, @targetName, @beforeJson, @afterJson, @diffJson, @source, @note)`
+ ).run({
+ id: entry.id,
+ timestamp: entry.timestamp,
+ action: entry.action,
+ target: entry.target,
+ targetId: entry.targetId,
+ targetName: entry.targetName,
+ beforeJson: before === null ? null : JSON.stringify(before),
+ afterJson: after === null ? null : JSON.stringify(after),
+ diffJson: JSON.stringify(entry.diff),
+ source: entry.source,
+ note: entry.note,
+ });
return entry;
}
@@ -181,42 +227,57 @@ export function getAuditLog(options?: {
limit?: number;
offset?: number;
}): { entries: ConfigAuditEntry[]; total: number } {
- let filtered = auditLog;
+ const where: string[] = [];
+ const params: Record = {};
if (options?.target) {
- filtered = filtered.filter((e) => e.target === options.target);
+ where.push("target = @target");
+ params.target = options.target;
}
if (options?.targetId) {
- filtered = filtered.filter((e) => e.targetId === options.targetId);
+ where.push("target_id = @targetId");
+ params.targetId = options.targetId;
}
if (options?.action) {
- filtered = filtered.filter((e) => e.action === options.action);
+ where.push("action = @action");
+ params.action = options.action;
}
if (options?.source) {
- filtered = filtered.filter((e) => e.source === options.source);
+ where.push("source = @source");
+ params.source = options.source;
}
if (options?.since) {
- filtered = filtered.filter((e) => e.timestamp >= options.since!);
+ where.push("timestamp >= @since");
+ params.since = options.since;
}
- const total = filtered.length;
+ const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
- // Sort newest first
- filtered = [...filtered].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
+ const totalRow = db()
+ .prepare(`SELECT COUNT(*) AS c FROM config_audit_log ${whereSql}`)
+ .get(params) as { c: number };
+ const total = totalRow.c;
- // Paginate
const offset = options?.offset ?? 0;
const limit = options?.limit ?? 50;
- filtered = filtered.slice(offset, offset + limit);
- return { entries: filtered, total };
+ const rows = db()
+ .prepare(
+ `SELECT * FROM config_audit_log ${whereSql} ORDER BY datetime(timestamp) DESC, id DESC LIMIT @limit OFFSET @offset`
+ )
+ .all({ ...params, limit, offset }) as ConfigAuditRow[];
+
+ return { entries: rows.map(rowToEntry), total };
}
/**
* Get a specific audit entry by ID.
*/
export function getAuditEntry(id: string): ConfigAuditEntry | null {
- return auditLog.find((e) => e.id === id) ?? null;
+ const row = db()
+ .prepare("SELECT * FROM config_audit_log WHERE id = @id")
+ .get({ id }) as ConfigAuditRow | undefined;
+ return row ? rowToEntry(row) : null;
}
/**
@@ -260,19 +321,23 @@ export function getAuditSummary(): {
const byAction: Record = {};
const bySource: Record = {};
- for (const entry of auditLog) {
- byTarget[entry.target] = (byTarget[entry.target] || 0) + 1;
- byAction[entry.action] = (byAction[entry.action] || 0) + 1;
- bySource[entry.source] = (bySource[entry.source] || 0) + 1;
+ const rows = db()
+ .prepare("SELECT * FROM config_audit_log ORDER BY datetime(timestamp) DESC, id DESC")
+ .all() as ConfigAuditRow[];
+
+ for (const row of rows) {
+ byTarget[row.target] = (byTarget[row.target] || 0) + 1;
+ byAction[row.action] = (byAction[row.action] || 0) + 1;
+ bySource[row.source] = (bySource[row.source] || 0) + 1;
}
return {
- totalEntries: auditLog.length,
+ totalEntries: rows.length,
byTarget,
byAction,
bySource,
- oldestEntry: auditLog.length > 0 ? auditLog[0].timestamp : null,
- newestEntry: auditLog.length > 0 ? auditLog[auditLog.length - 1].timestamp : null,
+ oldestEntry: rows.length > 0 ? rows[rows.length - 1].timestamp : null,
+ newestEntry: rows.length > 0 ? rows[0].timestamp : null,
};
}
@@ -280,6 +345,6 @@ export function getAuditSummary(): {
* Reset the audit log. Useful for testing.
*/
export function resetAuditLog(): void {
- auditLog = [];
+ db().prepare("DELETE FROM config_audit_log").run();
idCounter = 0;
}
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index 6d94474d8f10..1d1ae38830bc 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "خطافات الويب",
- "description": "تسجيل، وعرض، واختبار، وإزالة نقاط نهاية webhook. تكوين اشتراكات الأحداث (request.completed، وprovider.error، وbudget.exceeded، إلخ) وإدارة محاولات التسليم."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "خادم MCP",
diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json
index 4518a45cd240..0a0254b70a3a 100644
--- a/src/i18n/messages/az.json
+++ b/src/i18n/messages/az.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Vebhuklar",
- "description": "Vebhuk son nöqtələrini qeydiyyatdan keçirin, siyahıya alın, sınaqdan keçirin və silin. Hadisə abunəliklərini (request.completed, provider.error, budget.exceeded və s.) konfiqurasiya edin və çatdırılmanın təkrar cəhdlərini idarə edin."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP Serveri",
diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json
index b4bcc5f92255..3450855c1c79 100644
--- a/src/i18n/messages/bg.json
+++ b/src/i18n/messages/bg.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Уебхукове",
- "description": "Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP сървър",
diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json
index 37ecf6e714bc..8d5a26b3ab20 100644
--- a/src/i18n/messages/bn.json
+++ b/src/i18n/messages/bn.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "ওয়েবহুক",
- "description": "ওয়েবহুক এন্ডপয়েন্ট রেজিস্টার, তালিকাভুক্ত, পরীক্ষা এবং অপসারণ করুন। ইভেন্ট সাবস্ক্রিপশন (request.completed, provider.error, budget.exceeded ইত্যাদি) কনফিগার করুন এবং ডেলিভারি রিট্রাই পরিচালনা করুন।"
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP সার্ভার",
diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json
index 5836bbd5506b..c35c6140c437 100644
--- a/src/i18n/messages/cs.json
+++ b/src/i18n/messages/cs.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooky",
- "description": "Registrujte, zobrazujte, testujte a odebírejte koncové body webhooků. Konfigurujte odběry událostí (request.completed, provider.error, budget.exceeded atd.) a spravujte opakování doručení."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP server",
diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json
index dc86348a4a78..764400b58b29 100644
--- a/src/i18n/messages/da.json
+++ b/src/i18n/messages/da.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooks",
- "description": "Registrer, list, test og fjern webhook-slutpunkter. Konfigurer begivenhedsabonnementer (request.completed, provider.error, budget.exceeded osv.) og administrer leveringsforsøg."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP-server",
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index e0f3646ca05f..6a33f039f3c0 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooks",
- "description": "Webhook-Endpunkte registrieren, auflisten, testen und entfernen. Ereignis-Abonnements (request.completed, provider.error, budget.exceeded etc.) konfigurieren und Zustellungsversuche verwalten."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP-Server",
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 71ce552796e6..930f5b595875 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -5692,8 +5692,8 @@
"aggregatorsGateways": "Aggregators Gateways",
"enterpriseCloud": "Enterprise & Cloud",
"apiFormatLabel": "Api Format Label",
- "apiKeyOptionalHint": "Api Key Optional Hint",
- "apiKeyOptionalLabel": "Api Key Optional Label",
+ "apiKeyOptionalHint": "Leave empty if your local setup or provider does not require authentication.",
+ "apiKeyOptionalLabel": "API Key (optional)",
"apiRegionChina": "Api Region China",
"apiRegionHint": "Api Region Hint",
"apiRegionInternational": "Api Region International",
@@ -6269,6 +6269,20 @@
"webSessionGuideStep3": "Copy the required credential from the provider's own domain. For cookies, copy only the Cookie header value and omit Cookie:.",
"webSessionGuideStep3Manual": "Manual path: open the browser developer tools (F12 → Network), refresh the page, open an authenticated request, and copy the Cookie header value from Request Headers — omit the Cookie: prefix.",
"webSessionGuideStep4": "Paste it here and check the connection. If it stops working, sign in again and replace it with a fresh value.",
+ "harImportButtonLabel": "Import .har file",
+ "harImportButtonBusy": "Importing…",
+ "harImportButtonHint": "Export from DevTools Network tab after sending at least one chat message.",
+ "harImportStatusValid": "Imported — valid for ~{minutes}m.",
+ "harImportStatusExpiringSoon": "Imported — valid for only ~{minutes}m more.",
+ "harImportStatusExpired": "Imported, but this token already expired ({minutes}m ago) — export a fresh HAR.",
+ "harImportStatusUnknownExpiry": "Imported. Couldn't read its expiry.",
+ "harImportErrorNotJson": "That file isn't valid JSON — is it really a .har export?",
+ "harImportErrorNoEntries": "This HAR has no network entries recorded.",
+ "harImportErrorNoChathubUrl": "No Copilot chat connection found in this HAR. Send at least one chat message in m365.cloud.microsoft before exporting.",
+ "harImportErrorUnparsableUrl": "Found the chat connection, but couldn't read its URL.",
+ "harImportErrorMissingFields": "Found the chat connection, but the token was missing from it.",
+ "harImportErrorReadFailed": "Couldn't read that file.",
+ "harImportErrorUnknown": "Couldn't extract a credential from that HAR file.",
"webSessionSecurityHint": "Treat this like a password: it may access your signed-in web account until it expires or is revoked.",
"webNoAuthGuideTitle": "No credential required",
"webNoAuthGuideBody": "{provider} does not need an API key or cookie. Save the connection to use its free web endpoint.",
@@ -12207,7 +12221,7 @@
},
"omni-webhooks": {
"name": "Webhooks",
- "description": "Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries."
+ "description": "Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP Server",
@@ -13855,5 +13869,12 @@
"toolsMismatch": "Provider does not support tool calling",
"structuredOutputMismatch": "Provider does not support structured output",
"contextWindowMismatch": "Request exceeds provider context window"
+ },
+ "cheaperInferenceSponsorBanner": {
+ "title": "Cheaper Inference is an OmniRoute Open Source Friend",
+ "description": "A cost-ranked gateway reselling dozens of frontier models behind one OpenAI-compatible endpoint — routing each request to the cheapest eligible provider, never above list price.",
+ "cta": "Get an API Key",
+ "partnerLinkNote": "Partner link",
+ "dismissAriaLabel": "Dismiss"
}
}
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index a56507cebb3f..3ecd2c839cf3 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooks",
- "description": "Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP Server",
diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json
index 3384c0eea9a3..4483a7ee845d 100644
--- a/src/i18n/messages/fa.json
+++ b/src/i18n/messages/fa.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "وبهوکها",
- "description": "ثبت، فهرست کردن، آزمایش و حذف نقاط پایانی وبهوک. پیکربندی اشتراکهای رویداد (request.completed، provider.error، budget.exceeded و غیره) و مدیریت تلاشهای مجدد تحویل."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "سرور MCP",
diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json
index 804855d515c5..31f38e04a143 100644
--- a/src/i18n/messages/fi.json
+++ b/src/i18n/messages/fi.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhookit",
- "description": "Rekisteröi, listaa, testaa ja poista webhook-päätepisteitä. Määritä tapahtumatilaukset (request.completed, provider.error, budget.exceeded jne.) ja hallitse toimituksen uudelleenyrityksiä."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP-palvelin",
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index 888698620609..b065a34fee32 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooks",
- "description": "Enregistrer, lister, tester et supprimer des points de terminaison de webhook. Configurer les abonnements aux événements (request.completed, provider.error, budget.exceeded, etc.) et gérer les tentatives de livraison."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "Serveur MCP",
diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json
index 085988e3d116..ce257984a0aa 100644
--- a/src/i18n/messages/gu.json
+++ b/src/i18n/messages/gu.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "વેબહૂક્સ",
- "description": "વેબહૂક એન્ડપોઇન્ટ્સ રજીસ્ટર કરો, સૂચિબદ્ધ કરો, ટેસ્ટ કરો અને દૂર કરો. ઇવેન્ટ સબ્સ્ક્રિપ્શન્સ (request.completed, provider.error, budget.exceeded, વગેરે) કન્ફિગર કરો અને ડિલિવરી પુનઃપ્રયાસો મેનેજ કરો."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP સર્વર",
diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json
index 3ee9a8f5addd..5281e5292c97 100644
--- a/src/i18n/messages/he.json
+++ b/src/i18n/messages/he.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooks",
- "description": "רשום, הצג, בדוק והסר נקודות קצה של webhook. הגדר מנויים לאירועים (request.completed, provider.error, budget.exceeded וכו') ונהל ניסיונות מסירה חוזרים."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "שרת MCP",
diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json
index 28e57d80855c..d74d2919ca9d 100644
--- a/src/i18n/messages/hi.json
+++ b/src/i18n/messages/hi.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "वेबहुक",
- "description": "वेबहुक एंडपॉइंट्स को रजिस्टर, सूचीबद्ध, टेस्ट और रिमूव करें। इवेंट सब्सक्रिप्शन (request.completed, provider.error, budget.exceeded, आदि) कॉन्फ़िगर करें और डिलीवरी रीट्राय प्रबंधित करें।"
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP सर्वर",
diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json
index 0e9a39c85fba..11d3327c80c2 100644
--- a/src/i18n/messages/hu.json
+++ b/src/i18n/messages/hu.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhookok",
- "description": "Webhook-végpontok regisztrálása, listázása, tesztelése és eltávolítása. Eseményfeliratkozások (request.completed, provider.error, budget.exceeded stb.) konfigurálása és a kézbesítési újrapróbálkozások kezelése."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP-szerver",
diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json
index 5c7fbb3c84d9..2bf5fdccdd94 100644
--- a/src/i18n/messages/id.json
+++ b/src/i18n/messages/id.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhook",
- "description": "Daftarkan, cantumkan, uji, dan hapus endpoint webhook. Konfigurasikan langganan peristiwa (request.completed, provider.error, budget.exceeded, dll.) dan kelola upaya pengiriman ulang."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "Server MCP",
diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json
index 3157c133cc55..afb01d57e8f7 100644
--- a/src/i18n/messages/in.json
+++ b/src/i18n/messages/in.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhook",
- "description": "Daftarkan, tampilkan daftar, uji, dan hapus endpoint webhook. Konfigurasikan langganan peristiwa (request.completed, provider.error, budget.exceeded, dll.) dan kelola percobaan ulang pengiriman."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "Server MCP",
diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json
index 59b9503d048a..dd69eeb4894e 100644
--- a/src/i18n/messages/it.json
+++ b/src/i18n/messages/it.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhook",
- "description": "Registra, elenca, testa e rimuovi gli endpoint webhook. Configura le sottoscrizioni agli eventi (request.completed, provider.error, budget.exceeded, ecc.) e gestisci i tentativi di recapito."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "Server MCP",
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index 8f87da9a876c..371263c8eb1c 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhook",
- "description": "Webhookエンドポイントの登録、一覧表示、テスト、削除を行います。イベントサブスクリプション(request.completed、provider.error、budget.exceededなど)を設定し、配信の再試行を管理します。"
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCPサーバー",
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index 6c297a004200..fe5ba64093db 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "웹훅",
- "description": "웹훅 엔드포인트를 등록, 나열, 테스트 및 제거합니다. 이벤트 구독(request.completed, provider.error, budget.exceeded 등)을 구성하고 전달 재시도를 관리합니다."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP 서버",
diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json
index 927c736b4b63..487cd7ff2fcc 100644
--- a/src/i18n/messages/mr.json
+++ b/src/i18n/messages/mr.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "वेबहूक्स",
- "description": "वेबहूक एंडपॉइंट्स नोंदणीकृत करा, सूचीबद्ध करा, चाचणी करा आणि काढून टाका. इव्हेंट सबस्क्रिप्शन (request.completed, provider.error, budget.exceeded, इत्यादी) कॉन्फिगर करा आणि डिलिव्हरी रिट्राय व्यवस्थापित करा."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP सर्व्हर",
diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json
index 41a469223dea..e317f33fdadc 100644
--- a/src/i18n/messages/ms.json
+++ b/src/i18n/messages/ms.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhook",
- "description": "Daftar, senaraikan, uji dan alih keluar titik akhir webhook. Konfigurasikan langganan peristiwa (request.completed, provider.error, budget.exceeded, dll.) dan urus percubaan semula penghantaran."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "Pelayan MCP",
diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json
index 3d8a86b62064..04e88d349cff 100644
--- a/src/i18n/messages/nl.json
+++ b/src/i18n/messages/nl.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooks",
- "description": "Registreer, toon, test en verwijder webhook-endpoints. Configureer gebeurtenisabonnementen (request.completed, provider.error, budget.exceeded, etc.) en beheer afleverpogingen."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP-server",
diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json
index f842ab064ee3..f07811a42bec 100644
--- a/src/i18n/messages/no.json
+++ b/src/i18n/messages/no.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooks",
- "description": "Registrer, list opp, test og fjern webhook-endepunkter. Konfigurer hendelsesabonnementer (request.completed, provider.error, budget.exceeded osv.) og administrer leveringsforsøk."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP-server",
diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json
index 96f80a8fc7c3..ba2811fe4071 100644
--- a/src/i18n/messages/phi.json
+++ b/src/i18n/messages/phi.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Mga Webhook",
- "description": "Magrehistro, maglista, magsubok, at mag-alis ng mga webhook endpoint. I-configure ang mga subscription sa event (request.completed, provider.error, budget.exceeded, atbp.) at pamahalaan ang mga muling pagsubok sa paghahatid."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP Server",
diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json
index dd99353f51ed..9eaba5246659 100644
--- a/src/i18n/messages/pl.json
+++ b/src/i18n/messages/pl.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooki",
- "description": "Rejestruj, listuj, testuj i usuwaj punkty końcowe webhooków. Konfiguruj subskrypcje zdarzeń (request.completed, provider.error, budget.exceeded itp.) i zarządzaj ponownymi próbami dostarczenia."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "Serwer MCP",
diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json
index 8e33efcbfb0c..f3e3c15d1307 100644
--- a/src/i18n/messages/pt-BR.json
+++ b/src/i18n/messages/pt-BR.json
@@ -12207,7 +12207,7 @@
},
"omni-webhooks": {
"name": "Webhooks",
- "description": "Registre, liste, teste e remova endpoints de webhook. Configure assinaturas de eventos (request.completed, provider.error, budget.exceeded, etc.) e gerencie novas tentativas de entrega."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP",
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index 3abbf2564e89..1921f3fba7fb 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooks",
- "description": "Webhooks omni"
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "Servidor MCP",
@@ -13845,5 +13845,12 @@
"toolsMismatch": "Provider does not support tool calling",
"structuredOutputMismatch": "Provider does not support structured output",
"contextWindowMismatch": "Request exceeds provider context window"
+ },
+ "cheaperInferenceSponsorBanner": {
+ "title": "A Cheaper Inference é uma Amiga do Código Aberto do OmniRoute",
+ "description": "Um gateway com custo ordenado que revende dezenas de modelos de fronteira num único endpoint compatível com OpenAI — roteando cada requisição ao provedor elegível mais barato, nunca acima do preço de tabela.",
+ "cta": "Obter uma Chave de API",
+ "partnerLinkNote": "Link de parceiro",
+ "dismissAriaLabel": "Dispensar"
}
}
diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json
index 967c6f9782aa..4ae651a40a6f 100644
--- a/src/i18n/messages/ro.json
+++ b/src/i18n/messages/ro.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhook-uri",
- "description": "Înregistrează, listează, testează și elimină endpoint-uri de webhook. Configurează abonamentele la evenimente (request.completed, provider.error, budget.exceeded etc.) și gestionează reîncercările de livrare."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "Server MCP",
diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json
index 01c8849849f6..94f6c651e050 100644
--- a/src/i18n/messages/ru.json
+++ b/src/i18n/messages/ru.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Вебхуки",
- "description": "Регистрация, просмотр списка, тестирование и удаление конечных точек вебхуков. Настройка подписок на события (request.completed, provider.error, budget.exceeded и т. д.) и управление повторными попытками доставки."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP-сервер",
diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json
index e140436f1b1f..a6c823a524d2 100644
--- a/src/i18n/messages/sk.json
+++ b/src/i18n/messages/sk.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooky",
- "description": "Registrujte, zobrazujte, testujte a odstraňujte koncové body webhookov. Konfigurujte odbery udalostí (request.completed, provider.error, budget.exceeded atď.) a spravujte opakované pokusy o doručenie."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP Server",
diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json
index f24ee58a850f..dd61134b1c65 100644
--- a/src/i18n/messages/sv.json
+++ b/src/i18n/messages/sv.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooks",
- "description": "Registrera, lista, testa och ta bort webhook-slutpunkter. Konfigurera händelseprenumerationer (request.completed, provider.error, budget.exceeded osv.) och hantera återförsök vid leverans."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP-server",
diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json
index 59d6c2cbb264..7042ca73e993 100644
--- a/src/i18n/messages/sw.json
+++ b/src/i18n/messages/sw.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooks",
- "description": "Sajili, orodhesha, jaribu, na uondoe vituo vya mwisho vya webhook. Sanidi usajili wa matukio (request.completed, provider.error, budget.exceeded, n.k.) na udhibiti majaribio tena ya uwasilishaji."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "Seva ya MCP",
diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json
index 7248e4754f8d..28eef20efcba 100644
--- a/src/i18n/messages/ta.json
+++ b/src/i18n/messages/ta.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "வெப்ஹூக்குகள்",
- "description": "வெப்ஹூக் எண்ட்பாயிண்ட்டுகளைப் பதிவு செய்யவும், பட்டியலிடவும், சோதிக்கவும் மற்றும் அகற்றவும். நிகழ்வு சந்தாக்களை (request.completed, provider.error, budget.exceeded போன்றவை) கட்டமைக்கவும் மற்றும் விநியோக மறுமுயற்சிகளை நிர்வகிக்கவும்."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP சர்வர்",
diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json
index de915ff2ed98..acecbfb40358 100644
--- a/src/i18n/messages/te.json
+++ b/src/i18n/messages/te.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "వెబ్హుక్స్",
- "description": "Webhook ఎండ్పాయింట్లను నమోదు చేయండి, జాబితా చేయండి, పరీక్షించండి మరియు తీసివేయండి. ఈవెంట్ సబ్స్క్రిప్షన్లను (request.completed, provider.error, budget.exceeded, మొదలైనవి) కాన్ఫిగర్ చేయండి మరియు డెలివరీ రీట్రైలను నిర్వహించండి."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP సర్వర్",
diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json
index d1a0fcce6ee4..9fb3122583fe 100644
--- a/src/i18n/messages/th.json
+++ b/src/i18n/messages/th.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "เว็บฮุค",
- "description": "ลงทะเบียน, แสดงรายการ, ทดสอบ และลบปลายทางเว็บฮุค กำหนดค่าการสมัครรับเหตุการณ์ (request.completed, provider.error, budget.exceeded ฯลฯ) และจัดการการพยายามส่งใหม่"
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "เซิร์ฟเวอร์ MCP",
diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json
index 413f70d379da..c05cb34b228e 100644
--- a/src/i18n/messages/tr.json
+++ b/src/i18n/messages/tr.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhook'lar",
- "description": "Webhook uç noktalarını kaydedin, listeleyin, test edin ve kaldırın. Olay aboneliklerini (request.completed, provider.error, budget.exceeded vb.) yapılandırın ve teslimat yeniden denemelerini yönetin."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP Sunucusu",
diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json
index 3dd6631fed43..7eb5a5e56783 100644
--- a/src/i18n/messages/uk-UA.json
+++ b/src/i18n/messages/uk-UA.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Вебхуки",
- "description": "Реєструйте, переглядайте, тестуйте та видаляйте кінцеві точки вебхуків. Налаштовуйте підписки на події (request.completed, provider.error, budget.exceeded тощо) та керуйте повторними спробами доставки."
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "Сервер MCP",
diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json
index 2936035ed21e..5d5e2b1c84b8 100644
--- a/src/i18n/messages/ur.json
+++ b/src/i18n/messages/ur.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "ویب ہکس",
- "description": "ویب ہک اینڈ پوائنٹس کو رجسٹر کریں، فہرست بنائیں، ٹیسٹ کریں اور ہٹائیں۔ ایونٹ سبسکرپشنز (request.completed، provider.error، budget.exceeded، وغیرہ) کو ترتیب دیں اور ڈیلیوری کی دوبارہ کوششوں کا نظم کریں۔"
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP سرور",
diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json
index c2f216b82ed6..e04b20666139 100644
--- a/src/i18n/messages/vi.json
+++ b/src/i18n/messages/vi.json
@@ -6269,6 +6269,20 @@
"webSessionGuideStep3": "Sao chép thông tin xác thực được yêu cầu từ tên miền riêng của nhà cung cấp. Đối với cookie, chỉ sao chép giá trị tiêu đề Cookie và bỏ qua Cookie:.",
"webSessionGuideStep3Manual": "Cách thủ công: mở công cụ dành cho nhà phát triển của trình duyệt (F12 → Network), tải lại trang, mở một yêu cầu đã xác thực và sao chép giá trị tiêu đề Cookie trong Request Headers — bỏ tiền tố Cookie:.",
"webSessionGuideStep4": "Dán vào đây và kiểm tra kết nối. Nếu nó ngừng hoạt động, hãy đăng nhập lại và thay thế bằng một giá trị mới.",
+ "harImportButtonLabel": "Nhập tệp .har",
+ "harImportButtonBusy": "Đang nhập…",
+ "harImportButtonHint": "Xuất từ thẻ Network của DevTools sau khi gửi ít nhất một tin nhắn chat.",
+ "harImportStatusValid": "Đã nhập — hợp lệ trong ~{minutes} phút.",
+ "harImportStatusExpiringSoon": "Đã nhập — chỉ còn hợp lệ ~{minutes} phút nữa.",
+ "harImportStatusExpired": "Đã nhập, nhưng token này đã hết hạn ({minutes} phút trước) — hãy xuất một HAR mới.",
+ "harImportStatusUnknownExpiry": "Đã nhập. Không đọc được thời hạn.",
+ "harImportErrorNotJson": "Tệp đó không phải JSON hợp lệ — có đúng là bản xuất .har không?",
+ "harImportErrorNoEntries": "HAR này không có mục network nào được ghi lại.",
+ "harImportErrorNoChathubUrl": "Không tìm thấy kết nối Copilot chat trong HAR này. Hãy gửi ít nhất một tin nhắn chat trong m365.cloud.microsoft trước khi xuất.",
+ "harImportErrorUnparsableUrl": "Tìm thấy kết nối chat, nhưng không đọc được URL của nó.",
+ "harImportErrorMissingFields": "Tìm thấy kết nối chat, nhưng token bị thiếu trong đó.",
+ "harImportErrorReadFailed": "Không đọc được tệp đó.",
+ "harImportErrorUnknown": "Không trích xuất được thông tin xác thực từ tệp HAR đó.",
"webSessionSecurityHint": "Hãy coi đây như mật khẩu: nó có thể truy cập vào tài khoản web đã đăng nhập của bạn cho đến khi hết hạn hoặc bị thu hồi.",
"webNoAuthGuideTitle": "Không yêu cầu thông tin xác thực",
"webNoAuthGuideBody": "{provider} không cần khóa API hoặc cookie. Lưu kết nối để sử dụng endpoint web miễn phí của nó.",
@@ -12207,7 +12221,7 @@
},
"omni-webhooks": {
"name": "Webhook",
- "description": "Đăng ký, liệt kê, kiểm tra và xóa endpoint webhook. Cấu hình đăng ký sự kiện như request.completed, provider.error, budget.exceeded và quản lý thử gửi lại."
+ "description": "Đăng ký, liệt kê, kiểm thử và xoá các endpoint webhook. Cấu hình đăng ký sự kiện (request.completed, request.failed, quota.exceeded, v.v.) và quản lý thử lại giao hàng."
},
"omni-mcp": {
"name": "Máy chủ MCP",
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index 14e539197861..7a96cd92b331 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhooks",
- "description": "注册、列出、测试和移除 Webhook 端点。配置事件订阅(request.completed、provider.error、budget.exceeded 等)并管理投递重试。"
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP 服务器",
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index 841424cd489c..101fdbb94892 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -12202,7 +12202,7 @@
},
"omni-webhooks": {
"name": "Webhook",
- "description": "註冊、列出、測試和移除 Webhook 端點。設定事件訂閱(request.completed、provider.error、budget.exceeded 等)並管理傳遞重試。"
+ "description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
},
"omni-mcp": {
"name": "MCP 伺服器",
diff --git a/src/lib/cli-helper/config-generator/opencode.ts b/src/lib/cli-helper/config-generator/opencode.ts
index e3d3f0e950f0..e65206dc5574 100644
--- a/src/lib/cli-helper/config-generator/opencode.ts
+++ b/src/lib/cli-helper/config-generator/opencode.ts
@@ -280,10 +280,9 @@ function buildModelEntry(
}
// Resolve the context window. Honor an explicit user override, then fall
- // back to the catalog. We do NOT synthesize a default — if the catalog
- // is unaware of a model's window, the opencode.json will simply omit
- // `limit.context` for that model and OpenCode's own heuristics apply.
- // (OpenCode v1 defaults to 128K when `limit.context` is missing.)
+ // back to the catalog. If the catalog is unaware of a model's window, we
+ // fall back to a safe default (128K) so OpenCode's v1 provider schema
+ // validator never rejects the config with a missing key error (#11035).
const userLimit = existing?.limit?.context;
const catalogLimit = catalog ? resolveContextLength(catalog) : undefined;
const context = typeof userLimit === "number" && userLimit > 0 ? userLimit : catalogLimit;
@@ -292,9 +291,6 @@ function buildModelEntry(
// Use the catalog's max_output_tokens when available; otherwise fall
// back to the user's existing `limit.output` and finally to a small
// default (8K) so OpenCode never errors on a totally missing output cap.
- // We do NOT default context — context is a property of the model and
- // we have no business guessing. Output is a per-request setting and a
- // small default is harmless when truly unknown.
const userOutput = existing?.limit?.output;
const catalogOutput =
catalog && typeof catalog.max_output_tokens === "number" && catalog.max_output_tokens > 0
@@ -303,15 +299,18 @@ function buildModelEntry(
const output =
typeof userOutput === "number" && userOutput > 0 ? userOutput : (catalogOutput ?? 8_192);
- // `limit.output` is REQUIRED by OpenCode's v1 provider schema regardless of
- // whether the catalog (or the user's existing config) knows the model's
- // context window — a model with no catalog metadata at all must still get
- // a `limit` block, or OpenCode rejects the whole config with "Missing key
- // provider.omniroute.models.{model}.limit.output" (#10940). `output` above
- // already resolves to a safe fallback (8K) when nothing else is known, so
- // we always emit it; `context`/`input` are added only when actually known.
- const limit: { context?: number; input?: number; output?: number } = { output };
- if (typeof context === "number") limit.context = context;
+ // Both `limit.context` and `limit.output` are REQUIRED by OpenCode's v1 provider schema
+ // regardless of whether the catalog (or the user's existing config) knows the model's
+ // context window — a model with no catalog metadata at all must still get both
+ // `limit.context` and `limit.output`, or OpenCode rejects the whole config with "Missing key
+ // provider.omniroute.models.{model}.limit.context" (#11035) or ".limit.output" (#10940, #11032).
+ // `output` above resolves to a safe fallback (8K) and `context` resolves to a safe fallback (128K)
+ // when nothing else is known, so we always emit both fields.
+ const resolvedContext = typeof context === "number" && context > 0 ? context : 128_000;
+ const limit: { context: number; input?: number; output: number } = {
+ context: resolvedContext,
+ output,
+ };
const userInput = existing?.limit?.input;
if (typeof userInput === "number" && userInput > 0) {
limit.input = userInput;
@@ -408,7 +407,7 @@ export interface GenerateOpencodeOptions {
/**
* If `true` (default), the generator fetches the live `/v1/models` catalog
* so every model entry has an explicit `limit.context`. The catalog is the
- * single source of truth for context windows; we never invent defaults.
+ * primary source of truth for context windows, falling back to 128K when unknown.
*
* When the catalog request fails, the generator throws — opencode.json must
* not be emitted with stale or fabricated values. The CLI can catch the
@@ -423,7 +422,7 @@ export interface GenerateOpencodeOptions {
/**
* Generate a full `opencode.json` document for OmniRoute. The catalog is the
- * single source of truth for context windows — we never hardcode values.
+ * primary source of truth for context windows, with a 128K fallback when unknown.
*
* Behavior:
* - Preserves the user's existing provider name, npm, options, and
@@ -433,8 +432,8 @@ export interface GenerateOpencodeOptions {
* - For each catalog model id the user did NOT have, a new entry is
* added with `limit.context` populated when the catalog has it.
* - If the catalog has no context for a model AND the user has no
- * override, the model is emitted WITHOUT a `limit.context` field.
- * OpenCode's own heuristic (typically 128K) applies.
+ * override, a safe default (128K) is emitted so OpenCode's schema validator
+ * does not reject the model.
* - Throws if the catalog fetch fails — the user must fix the upstream
* before we can generate a reliable opencode.json.
*/
diff --git a/src/lib/config/runtimeSettings.ts b/src/lib/config/runtimeSettings.ts
index dc5e9d720dc8..bf615749bae6 100644
--- a/src/lib/config/runtimeSettings.ts
+++ b/src/lib/config/runtimeSettings.ts
@@ -1,5 +1,9 @@
import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck";
import { setCustomBannedSignals } from "@omniroute/open-sse/services/accountFallback.ts";
+import {
+ setOperatorProviderErrorRules,
+ type OperatorProviderErrorRule,
+} from "@omniroute/open-sse/config/providerErrorRules.ts";
import { isAutomatedTestProcess } from "@/shared/utils/testProcess";
type JsonRecord = Record;
@@ -46,6 +50,7 @@ interface RuntimeSettingsSnapshot {
systemTransforms: unknown;
authzBypass: AuthzBypassSnapshot;
customBannedSignals: string[];
+ providerErrorRules: Record | null;
}
// Default bypass policy: kill-switch on, `/api/mcp/` bypassable. Mirrors the
@@ -72,6 +77,7 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = {
systemTransforms: null,
authzBypass: DEFAULT_AUTHZ_BYPASS_SNAPSHOT,
customBannedSignals: [],
+ providerErrorRules: null,
};
let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null;
@@ -138,6 +144,34 @@ function normalizeStringArray(value: unknown): string[] {
);
}
+/**
+ * Defensive shape-check of operator-declared error rules pulled from settings.
+ * The settings schema already validates this on write; this guard prevents a
+ * malformed stored value (or an unexpected shape) from crashing the
+ * error-classification hot path. Returns null when the value is missing or not
+ * a record of non-empty rule arrays.
+ */
+function normalizeOperatorProviderErrorRules(
+ value: unknown
+): Record | null {
+ if (value === null || typeof value !== "object") return null;
+ const record = value as Record;
+ const result: Record = {};
+ for (const [provider, list] of Object.entries(record)) {
+ if (!Array.isArray(list) || list.length === 0) continue;
+ const rules = list.filter(
+ (entry): entry is OperatorProviderErrorRule =>
+ !!entry &&
+ typeof entry === "object" &&
+ typeof (entry as OperatorProviderErrorRule).status === "number" &&
+ typeof (entry as OperatorProviderErrorRule).match === "string" &&
+ typeof (entry as OperatorProviderErrorRule).scope === "string"
+ );
+ if (rules.length > 0) result[provider.toLowerCase()] = rules;
+ }
+ return Object.keys(result).length > 0 ? result : null;
+}
+
function normalizeStringRecord(value: unknown): Record {
const record = toRecord(parseStoredJson(value, "modelAliases"));
const entries = Object.entries(record)
@@ -244,6 +278,7 @@ export function buildRuntimeSettingsSnapshot(
systemTransforms: parseStoredJson(settings.systemTransforms, "systemTransforms"),
authzBypass: normalizeAuthzBypass(settings),
customBannedSignals: normalizeStringArray(settings.customBannedSignals),
+ providerErrorRules: normalizeOperatorProviderErrorRules(settings.providerErrorRules),
};
}
@@ -540,6 +575,13 @@ export async function applyRuntimeSettings(
markChanged("bannedSignals");
}
+ if (
+ force ||
+ hasChanged(currentSnapshot.providerErrorRules, previousSnapshot.providerErrorRules)
+ ) {
+ setOperatorProviderErrorRules(currentSnapshot.providerErrorRules ?? undefined);
+ }
+
lastAppliedSnapshot = currentSnapshot;
return changes;
}
diff --git a/src/lib/db/adapters/driverFactory.ts b/src/lib/db/adapters/driverFactory.ts
index 134e984c28f8..304b334bd8eb 100644
--- a/src/lib/db/adapters/driverFactory.ts
+++ b/src/lib/db/adapters/driverFactory.ts
@@ -212,8 +212,7 @@ export function createSyncDriverFactory(load: DriverLoader, betterSqliteProbe?:
filePath: string,
options?: Record
): SqliteAdapter | null {
- // Bun ships a supported SQLite implementation. Prefer it over the native
- // Node addon, which Bun intentionally skips because its ABI is incompatible.
+ // 1. Bun native sqlite driver: preferred built-in driver when running under Bun
if (process.versions.bun) {
try {
const { Database } = load("bun:sqlite") as {
@@ -222,18 +221,20 @@ export function createSyncDriverFactory(load: DriverLoader, betterSqliteProbe?:
if (options?.fileMustExist === true && filePath !== ":memory:" && !existsSync(filePath)) {
throw new Error(`SQLite file does not exist: ${filePath}`);
}
- const db = new Database(filePath, {
- ...(options?.readonly === true
- ? { readonly: true }
- : { readwrite: true, create: options?.fileMustExist !== true }),
- });
+ const bunOptions: Record = {};
+ if (options?.readonly === true) bunOptions.readonly = true;
+ if (options?.create === false && filePath !== ":memory:") bunOptions.create = false;
+ const db =
+ Object.keys(bunOptions).length > 0
+ ? new Database(filePath, bunOptions)
+ : new Database(filePath);
return createBunSqliteAdapter(db, filePath);
} catch (err) {
logSwallowedDriverError("bun:sqlite", err);
}
}
- // better-sqlite3: rápido, nativo — skip em Bun
+ // 2. better-sqlite3: preferred native driver on Node.js
if (!process.versions.bun && mayLoadBetterSqlite()) {
try {
const BetterSqlite = load("better-sqlite3") as {
@@ -242,7 +243,6 @@ export function createSyncDriverFactory(load: DriverLoader, betterSqliteProbe?:
const db = new BetterSqlite(filePath, options);
return createBetterSqliteAdapter(db);
} catch (err) {
- // continua para próximo driver
logSwallowedDriverError("better-sqlite3", err);
}
}
diff --git a/src/lib/db/cleanup.ts b/src/lib/db/cleanup.ts
index 617bf4c22642..60e288bf4b47 100644
--- a/src/lib/db/cleanup.ts
+++ b/src/lib/db/cleanup.ts
@@ -193,6 +193,31 @@ export async function cleanupMcpAudit(): Promise {
return result;
}
+/**
+ * Clean up old config_audit_log based on retention settings.
+ */
+export async function cleanupConfigAudit(retentionDays = getRetentionSettings().configAudit): Promise {
+ const db = getDbInstance();
+ const result: CleanupResult = { deleted: 0, errors: 0 };
+
+ try {
+ const stmt = db.prepare(
+ "DELETE FROM config_audit_log WHERE datetime(timestamp) < datetime('now', '-' || ? || ' days')"
+ );
+ const runResult = stmt.run(String(retentionDays));
+ result.deleted = runResult.changes;
+
+ console.log(
+ `[Cleanup] Deleted ${result.deleted} config_audit_log older than ${retentionDays} days`
+ );
+ } catch (err: unknown) {
+ console.error("[Cleanup] Error cleaning config_audit_log:", err);
+ result.errors++;
+ }
+
+ return result;
+}
+
/**
* Clean up old a2a_task_events based on retention settings.
*/
@@ -420,6 +445,7 @@ export async function runAutoCleanup(): Promise<{
usageHistory: await cleanupUsageHistory(),
compressionAnalytics: await cleanupCompressionAnalytics(),
mcpAudit: await cleanupMcpAudit(),
+ configAudit: await cleanupConfigAudit(),
a2aEvents: await cleanupA2aEvents(),
memoryEntries: await cleanupMemoryEntries(),
domainCostHistory: await cleanupDomainCostHistory(),
diff --git a/src/lib/db/databaseSettings.ts b/src/lib/db/databaseSettings.ts
index e18f2a66bd9a..0a12729242cd 100644
--- a/src/lib/db/databaseSettings.ts
+++ b/src/lib/db/databaseSettings.ts
@@ -46,6 +46,7 @@ const LEGACY_FLAT_KEYS: {
quotaSnapshots: ["quotaSnapshots"],
compressionAnalytics: ["compressionAnalytics"],
mcpAudit: ["mcpAudit"],
+ configAudit: ["configAudit"],
a2aEvents: ["a2aEvents"],
callLogs: ["callLogs"],
usageHistory: ["usageHistory"],
diff --git a/src/lib/db/gamification.ts b/src/lib/db/gamification.ts
index 731c5d82adf5..cd6c907170f4 100644
--- a/src/lib/db/gamification.ts
+++ b/src/lib/db/gamification.ts
@@ -131,13 +131,24 @@ export function getRank(apiKeyId: string, scope: string): number {
return rankRow.rank;
}
+export const LEADERBOARD_MAX_LIMIT = 200;
+
export function getTopN(scope: string, limit: number, offset: number = 0): LeaderboardRow[] {
+ // Guard the SQLite LIMIT/OFFSET bind. A negative LIMIT means "no limit" in
+ // SQLite (returns the whole table), and a non-integer throws a datatype
+ // mismatch, so an unvalidated `?limit` on a caller route (e.g. the leaderboard
+ // endpoints) could read the entire leaderboard or 500. Clamp to a coherent
+ // range here as a defense-in-depth backstop, independent of route validation.
+ const safeLimit = Number.isFinite(limit)
+ ? Math.min(Math.max(Math.trunc(limit), 0), LEADERBOARD_MAX_LIMIT)
+ : 0;
+ const safeOffset = Number.isFinite(offset) ? Math.max(Math.trunc(offset), 0) : 0;
const rows = db()
.prepare(
`SELECT api_key_id, scope, score, updated_at FROM leaderboard
WHERE scope = ? ORDER BY score DESC LIMIT ? OFFSET ?`
)
- .all(scope, limit, offset) as Array<{
+ .all(scope, safeLimit, safeOffset) as Array<{
api_key_id: string;
scope: string;
score: number;
diff --git a/src/lib/db/migrations/046_database_settings.sql b/src/lib/db/migrations/046_database_settings.sql
index 57fd15c903be..6fb9864390a5 100644
--- a/src/lib/db/migrations/046_database_settings.sql
+++ b/src/lib/db/migrations/046_database_settings.sql
@@ -25,6 +25,7 @@ INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSetting
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'quotaSnapshots', '90');
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'compressionAnalytics', '30');
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'mcpAudit', '30');
+INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'configAudit', '30');
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'a2aEvents', '30');
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'callLogs', '90');
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'usageHistory', '365');
diff --git a/src/lib/db/migrations/161_config_audit_log.sql b/src/lib/db/migrations/161_config_audit_log.sql
new file mode 100644
index 000000000000..0aad91ace28e
--- /dev/null
+++ b/src/lib/db/migrations/161_config_audit_log.sql
@@ -0,0 +1,15 @@
+CREATE TABLE IF NOT EXISTS config_audit_log (
+ id TEXT PRIMARY KEY,
+ timestamp TEXT NOT NULL,
+ action TEXT NOT NULL,
+ target TEXT NOT NULL,
+ target_id TEXT NOT NULL,
+ target_name TEXT NOT NULL,
+ before_json TEXT,
+ after_json TEXT,
+ diff_json TEXT NOT NULL,
+ source TEXT NOT NULL,
+ note TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_config_audit_log_target_created ON config_audit_log(target, timestamp);
+CREATE INDEX IF NOT EXISTS idx_config_audit_log_created ON config_audit_log(timestamp);
diff --git a/src/lib/db/migrations/162_remove_hackclub_provider.sql b/src/lib/db/migrations/162_remove_hackclub_provider.sql
new file mode 100644
index 000000000000..4dd20904e96e
--- /dev/null
+++ b/src/lib/db/migrations/162_remove_hackclub_provider.sql
@@ -0,0 +1,21 @@
+-- 162_remove_hackclub_provider.sql
+-- Hack Club AI provider was removed from OmniRoute at the request of Hack Club's
+-- maintainers (#11118). Clean up any locally stored configuration for it.
+-- Historical request and usage records are intentionally preserved under the
+-- provider identity that existed when they were written.
+
+DELETE FROM provider_connections
+WHERE provider = 'hackclub';
+
+DELETE FROM registered_keys
+WHERE provider = 'hackclub';
+
+DELETE FROM provider_key_limits
+WHERE provider = 'hackclub';
+
+DELETE FROM discovery_results
+WHERE provider_id = 'hackclub';
+
+DELETE FROM key_value
+WHERE namespace = 'customModels'
+ AND key = 'hackclub';
diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts
index 3b02933e1135..fbab6817fd69 100644
--- a/src/lib/db/providers.ts
+++ b/src/lib/db/providers.ts
@@ -627,15 +627,26 @@ export async function createProviderConnection(data: JsonRecord) {
// to no-overrides) keeps the field present on the returned object so the
// UI can tell "field was read, no overrides" apart from "field absent."
if ("quotaWindowThresholds" in connection) {
- connection.quotaWindowThresholds = sanitizeQuotaWindowThresholds(
- connection.quotaWindowThresholds
- );
+ const result = sanitizeQuotaWindowThresholds(connection.quotaWindowThresholds);
+ if (result.rejected.length > 0) {
+ throw new Error(
+ `Refusing to persist quotaWindowThresholds with rejected keys: ${result.rejected.join(", ")}`
+ );
+ }
+ connection.quotaWindowThresholds = result.sanitized;
}
// Same sanitization for rateLimitOverrides — keep in-memory representation
- // in sync with what gets persisted.
+ // in sync with what gets persisted. Reject (don't silently drop) invalid
+ // keys/values so a direct DB writer can't lose operator intent.
if ("rateLimitOverrides" in connection) {
- connection.rateLimitOverrides = sanitizeRateLimitOverrides(connection.rateLimitOverrides);
+ const result = sanitizeRateLimitOverrides(connection.rateLimitOverrides);
+ if (result.rejected.length > 0) {
+ throw new Error(
+ `Refusing to persist rateLimitOverrides with rejected keys: ${result.rejected.join(", ")}`
+ );
+ }
+ connection.rateLimitOverrides = result.sanitized;
}
_insertConnectionRow(db, encryptConnectionFields({ ...connection }));
@@ -849,13 +860,24 @@ export async function updateProviderConnection(id: string, data: JsonRecord) {
// Mirror the sanitization the create path applies — keep the returned
// object in lockstep with what we persist.
if ("quotaWindowThresholds" in merged) {
- const sanitized = sanitizeQuotaWindowThresholds(merged.quotaWindowThresholds);
+ const result = sanitizeQuotaWindowThresholds(merged.quotaWindowThresholds);
+ if (result.rejected.length > 0) {
+ throw new Error(
+ `Refusing to persist quotaWindowThresholds with rejected keys: ${result.rejected.join(", ")}`
+ );
+ }
// For updates we always carry the key forward (even as null) so the read
- // path surfaces the cleared state to callers that just patched it.
- merged.quotaWindowThresholds = sanitized;
+ // path surfaces the cleared state to callers that merged it.
+ merged.quotaWindowThresholds = result.sanitized;
}
if ("rateLimitOverrides" in merged) {
- merged.rateLimitOverrides = sanitizeRateLimitOverrides(merged.rateLimitOverrides);
+ const result = sanitizeRateLimitOverrides(merged.rateLimitOverrides);
+ if (result.rejected.length > 0) {
+ throw new Error(
+ `Refusing to persist rateLimitOverrides with rejected keys: ${result.rejected.join(", ")}`
+ );
+ }
+ merged.rateLimitOverrides = result.sanitized;
}
const existingRecord = toRecord(existing);
diff --git a/src/lib/db/providers/columns.ts b/src/lib/db/providers/columns.ts
index b32f653f7c6c..f06f948cd212 100644
--- a/src/lib/db/providers/columns.ts
+++ b/src/lib/db/providers/columns.ts
@@ -64,20 +64,37 @@ export function normalizeBooleanColumn(value: unknown, fallback: boolean): boole
return fallback;
}
+// Result of sanitizing a per-connection overrides/threshold map. `sanitized`
+// is the cleaned value (or null when it collapses to nothing); `rejected`
+// lists every key that was refused so callers can fail loudly
+// instead of silently dropping the operator's input.
+export type SanitizeResult = {
+ sanitized: Record | null;
+ rejected: string[];
+};
+
// Sanitize the per-connection rate limit overrides map: keep only known
-// fields with valid numeric values. Called once at each write-path boundary.
-export function sanitizeRateLimitOverrides(value: unknown): Record | null {
- if (value === null || value === undefined) return null;
- if (typeof value !== "object" || Array.isArray(value)) return null;
+// fields with valid non-negative integer values. Called once at each
+// write-path boundary. Unknown keys and invalid values go into `rejected`
+// rather than being dropped in silence.
+export function sanitizeRateLimitOverrides(value: unknown): SanitizeResult {
+ if (value === null || value === undefined) return { sanitized: null, rejected: [] };
+ if (typeof value !== "object" || Array.isArray(value)) return { sanitized: null, rejected: [] };
const allowedKeys = new Set(["rpm", "tpm", "tpd", "minTime", "maxConcurrent"]);
+ const rejected: string[] = [];
const map: Record = {};
for (const [key, v] of Object.entries(value as Record)) {
- if (!allowedKeys.has(key)) continue;
+ if (!allowedKeys.has(key)) {
+ rejected.push(key);
+ continue;
+ }
if (typeof v === "number" && Number.isInteger(v) && v >= 0) {
map[key] = v;
+ } else {
+ rejected.push(key);
}
}
- return Object.keys(map).length === 0 ? null : map;
+ return { sanitized: Object.keys(map).length === 0 ? null : map, rejected };
}
// Serialize an already-sanitized map for SQLite TEXT storage.
@@ -91,20 +108,29 @@ export function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" ? (value as JsonRecord) : {};
}
-// Sanitize the per-window threshold map: keep only 0-100 integer values.
-// Called once at each write-path boundary (createProviderConnection +
-// updateProviderConnection) so both the in-memory return and the persisted
-// row share the same shape. Serialization below trusts this output.
-export function sanitizeQuotaWindowThresholds(value: unknown): Record | null {
- if (value === null || value === undefined) return null;
- if (typeof value !== "object" || Array.isArray(value)) return null;
+// Sanitize the per-window threshold map: keep only 0-100 integer values with
+// keys no longer than 64 chars. Called once at each write-path boundary
+// (createProviderConnection + updateProviderConnection) so both the in-memory
+// return and the persisted row share the same shape. Serialization below
+// trusts this output. Invalid keys/values go into `rejected` rather than being
+// dropped in silence.
+export function sanitizeQuotaWindowThresholds(value: unknown): SanitizeResult {
+ if (value === null || value === undefined) return { sanitized: null, rejected: [] };
+ if (typeof value !== "object" || Array.isArray(value)) return { sanitized: null, rejected: [] };
+ const rejected: string[] = [];
const map: Record = {};
for (const [key, v] of Object.entries(value as Record)) {
+ if (key.length > 64) {
+ rejected.push(key);
+ continue;
+ }
if (typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 100) {
map[key] = v;
+ } else {
+ rejected.push(key);
}
}
- return Object.keys(map).length === 0 ? null : map;
+ return { sanitized: Object.keys(map).length === 0 ? null : map, rejected };
}
export function toStringOrNull(value: unknown): string | null {
diff --git a/src/lib/embeddings/service.ts b/src/lib/embeddings/service.ts
index 8daf081bc791..3341de28993b 100644
--- a/src/lib/embeddings/service.ts
+++ b/src/lib/embeddings/service.ts
@@ -329,7 +329,7 @@ export async function createEmbeddingResponse(
const responseHeaders = new Headers(result.headers);
if (result.success) {
- if (credentials) await clearRecoveredProviderState(credentials);
+ if (credentials) await clearRecoveredProviderState(credentials as Record);
responseHeaders.set("Content-Type", "application/json");
const usage = (result.data as { usage?: Record })?.usage ?? null;
const costUsd = usage ? await calculateCost(provider, effectiveModel ?? "", usage) : 0;
diff --git a/src/lib/memory/injection.ts b/src/lib/memory/injection.ts
index 9fdd2bbbf108..d4d8ead7f7c6 100644
--- a/src/lib/memory/injection.ts
+++ b/src/lib/memory/injection.ts
@@ -65,7 +65,11 @@ export function providerSupportsSystemMessage(provider: string | null | undefine
*
* Populated with the Xiaomi MiMo endpoint (provider id `xiaomi-mimo`, registry
* alias `mimo`, serving mimo-v2.5) confirmed live to 400 on a non-first system
- * message. Add other providers here only when they are documented as strict.
+ * message, and the TokenRouter gateway (provider id `tokenrouter`), confirmed
+ * live on 2026-08-22 to reject mid-array system messages — including the
+ * compression notice spliced by purifyHistory() before that splice was fixed to
+ * merge into the leading system message. Add other providers here only when
+ * they are documented as strict.
*
* Self-hosted deployments can extend this list without a source change via
* OMNIROUTE_STRICT_SYSTEM_PROVIDERS (comma-separated provider ids,
@@ -73,7 +77,7 @@ export function providerSupportsSystemMessage(provider: string | null | undefine
* self-hosted Qwen3.5+/3.6 model, whose chat template enforces the same
* single-leading-system-message constraint as xiaomi-mimo.
*/
-const BUILTIN_PROVIDERS_SYSTEM_MUST_BE_FIRST = new Set(["xiaomi-mimo", "mimo"]);
+const BUILTIN_PROVIDERS_SYSTEM_MUST_BE_FIRST = new Set(["xiaomi-mimo", "mimo", "tokenrouter"]);
/**
* Parses OMNIROUTE_STRICT_SYSTEM_PROVIDERS into a normalized id list.
diff --git a/src/lib/modelMetadataRegistry.ts b/src/lib/modelMetadataRegistry.ts
index 3828047b90b9..b7aa2aa21643 100644
--- a/src/lib/modelMetadataRegistry.ts
+++ b/src/lib/modelMetadataRegistry.ts
@@ -130,6 +130,11 @@ function uniqueStrings(values: Array) {
];
}
+export function isGlmFamilyModel(modelId: string, displayName = ""): boolean {
+ const glmFamilyPattern = /(?:^|[/@:_. -])glm(?=$|[-._ /@:](?:z)?\d|\d)/i;
+ return glmFamilyPattern.test(modelId) || glmFamilyPattern.test(displayName);
+}
+
function toQualifiedId(
providerAlias: string | null,
provider: string | null,
@@ -477,11 +482,16 @@ export function enrichCatalogModelEntry(
? declaredEffortTiers
: sourceDeclaresThinking
? undefined
- : extendCodexGpt56EffortValues(
- metadata.provider,
- metadata.model,
- CANONICAL_EFFORT_VALUES
- );
+ : // #10963: GLM-family models never inherit generic OpenAI tiers — an
+ // explicit empty list is authoritative unless a provider-declared
+ // contract exists (handled by declaredEffortTiers above).
+ isGlmFamilyModel(metadata.model, metadata.displayName)
+ ? []
+ : extendCodexGpt56EffortValues(
+ metadata.provider,
+ metadata.model,
+ CANONICAL_EFFORT_VALUES
+ );
const capabilityFields = {
...(typeof metadata.capabilities.vision === "boolean"
? { vision: metadata.capabilities.vision }
@@ -502,7 +512,9 @@ export function enrichCatalogModelEntry(
// #6241: surface thinking support + the canonical effort tiers so the frontend can
// render the effort/thinking toggles. `thinking` is kept for back-compat; `supportsThinking`
// is the explicit flag and `effort_tiers` lists the selectable reasoning levels
- // (only when the model actually supports thinking).
+ // (only when the model actually supports thinking). An explicit empty registry list
+ // is authoritative; GLM models also require a provider-declared contract instead of
+ // inheriting generic OpenAI effort tiers.
...(typeof metadata.capabilities.supportsThinking === "boolean"
? {
thinking: metadata.capabilities.supportsThinking,
diff --git a/src/lib/monitoring/comboHealthAutopilot.ts b/src/lib/monitoring/comboHealthAutopilot.ts
index 71a104948375..24eba32a634c 100644
--- a/src/lib/monitoring/comboHealthAutopilot.ts
+++ b/src/lib/monitoring/comboHealthAutopilot.ts
@@ -16,6 +16,7 @@ import type {
ComboForecastMetrics,
ComboForecastResponse,
ComboForecastRiskLevel,
+ ProviderAutopilotReport,
ComboHealthMetrics,
ComboHealthResponse,
ComboRecord,
@@ -34,6 +35,7 @@ export interface ComboHealthAutopilotOptions {
combos?: ComboRecord[];
healthResponse?: ComboHealthResponse;
forecastResponse?: ComboForecastResponse;
+ providerHealthResponse?: ProviderAutopilotReport;
}
type ProviderIssueView = {
@@ -103,7 +105,12 @@ function actionSet(
case "open_combo_editor":
return action(type, "Open combo editor", target, "/dashboard/combos");
case "run_combo_test":
- return action(type, "Run combo test", target, "/dashboard/combos");
+ return action(
+ type,
+ "Run combo test",
+ target,
+ `/dashboard/combos?test=${encodeURIComponent(target.comboId)}`
+ );
case "open_provider_health_autopilot":
return action(type, "Open provider autopilot", target, "/dashboard/health");
case "review_quota_limits":
@@ -447,7 +454,8 @@ export async function buildComboHealthAutopilotReport(
now: options.now,
combos: combosSnapshot,
}),
- buildProviderHealthAutopilotReport({ includeHealthy: false, includeActions: false }),
+ options.providerHealthResponse ??
+ buildProviderHealthAutopilotReport({ includeHealthy: false, includeActions: false }),
]);
const forecastsByComboId = new Map(forecast.combos.map((entry) => [entry.comboId, entry]));
@@ -470,7 +478,7 @@ export async function buildComboHealthAutopilotReport(
const degradedCount = allCombos.filter((combo) => combo.state === "degraded").length;
const healthyCount = allCombos.filter((combo) => combo.state === "healthy").length;
const issueCount = allCombos.reduce((sum, combo) => sum + combo.issues.length, 0);
- const actionableCount = allCombos.reduce(
+ const suggestionCount = allCombos.reduce(
(sum, combo) =>
sum + combo.issues.reduce((issueSum, issue) => issueSum + issue.actions.length, 0),
0
@@ -487,7 +495,8 @@ export async function buildComboHealthAutopilotReport(
degradedCount,
downCount,
issueCount,
- actionableCount,
+ suggestionCount,
+ actionableCount: suggestionCount,
},
combos,
};
diff --git a/src/lib/providers/staticModels.ts b/src/lib/providers/staticModels.ts
index 06f404177603..a62c90b1109f 100644
--- a/src/lib/providers/staticModels.ts
+++ b/src/lib/providers/staticModels.ts
@@ -99,6 +99,15 @@ const STATIC_MODEL_PROVIDERS: Record Array<{ id: string; name: str
{ id: "google_scholar", name: "Google Scholar" },
{ id: "duckduckgo", name: "DuckDuckGo" },
],
+ "v0-vercel-web": () => [
+ // v0-vercel-web web-cookie codegen provider — no upstream /v1/models endpoint,
+ // no registry `models` and no discovery config, so seed the current v0 lineup
+ // as a static catalog mirroring the v0-vercel API provider (shared.ts) so the
+ // model-import UI serves a list instead of the tail 400 (#10990).
+ { id: "v0-1.0-md", name: "V0 1.0 MD" },
+ { id: "v0-1.5-lg", name: "V0 1.5 LG" },
+ { id: "v0-1.5-md", name: "V0 1.5 MD" },
+ ],
"venice-web": () => [
// Venice.ai web-cookie provider — no upstream /v1/models endpoint, so seed the
// current lineup as a static catalog (#6269). Venice rotates its catalog; keep
diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts
index 7f8180dee009..8148173d1a07 100644
--- a/src/lib/providers/validation.ts
+++ b/src/lib/providers/validation.ts
@@ -108,6 +108,7 @@ import {
validateBytezProvider,
} from "./validation/webCookie";
import { validateAiHordeProvider } from "./validation/aihorde";
+import { validateDifyProvider } from "./validation/dify";
import { validateAdobeFireflyProvider } from "./validation/adobeFirefly";
import {
validateV0VercelProvider,
@@ -230,6 +231,10 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
freebuff: validateFreebuffProvider,
"command-code": validateCommandCodeProvider,
huggingface: validateHuggingFaceProvider,
+ // #11002: Dify serves no OpenAI-compatible route — only POST /v1/chat-messages.
+ // The generic OpenAI-like probe 404s on /v1/models and /v1/chat/completions,
+ // so every real app key was misreported as "endpoint not supported".
+ dify: validateDifyProvider,
// #5422: auth-only probe — Bytez 404s on every chat model until the account adds it to
// its catalog, so the generic chat probe can't validate a fresh key.
bytez: validateBytezProvider,
diff --git a/src/lib/providers/validation/dify.ts b/src/lib/providers/validation/dify.ts
new file mode 100644
index 000000000000..3834af0e93ce
--- /dev/null
+++ b/src/lib/providers/validation/dify.ts
@@ -0,0 +1,86 @@
+/**
+ * Dify key check. Dify (multi-app LLM "LLMOps" platform) does NOT expose an
+ * OpenAI-compatible HTTP API. Its native completion endpoint is
+ * `POST {base}/v1/chat-messages` (body `inputs`/`query`/`response_mode`/`user`
+ * — no `model`/`messages` envelope). There is no `/v1/models` listing, so the
+ * generic OpenAI-like probe (GET /v1/models → POST /v1/chat/completions)
+ * always 404s and every real Dify app key is misreported as
+ * "Provider validation endpoint not supported" (#11002).
+ *
+ * Dify itself returns a clean 401 {"code":"unauthorized"} for a bad app key on
+ * `/v1/chat-messages`, and 200 for a valid key, so a single POST there is the
+ * correct auth probe.
+ */
+import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
+import { normalizeBaseUrl } from "./urlHelpers";
+import { toValidationErrorResult, validationWrite } from "./transport";
+
+/**
+ * Shape a provider/connection base URL into the Dify native completion route.
+ * Accepts the cloud root (`https://api.dify.ai`), a `/v1` root, or a full
+ * `/v1/chat-messages` URL (e.g. a self-hosted instance) and always returns
+ * `{base}/v1/chat-messages`.
+ */
+export function resolveDifyChatMessagesUrl(baseUrl: string) {
+ const normalized = normalizeBaseUrl(baseUrl);
+ if (!normalized) return "";
+ const cleaned = normalized.replace(/\/chat-messages$/, "").replace(/\/v1$/, "");
+ return `${cleaned}/v1/chat-messages`;
+}
+
+/** Pure status→verdict mapping, unit-testable without network. */
+export function difyValidationResultFromStatus(status: number) {
+ if (status === 401 || status === 403) {
+ return { valid: false, error: "Invalid API key" };
+ }
+ if (status >= 200 && status < 300) {
+ return { valid: true, error: null };
+ }
+ return { valid: false, error: `Dify validation failed (${status})` };
+}
+
+export async function validateDifyProvider({
+ apiKey,
+ providerSpecificData = {},
+ fetchImpl = validationWrite,
+}: {
+ apiKey?: unknown;
+ providerSpecificData?: Record;
+ fetchImpl?: typeof validationWrite;
+}) {
+ const key = typeof apiKey === "string" ? apiKey.trim() : "";
+ if (!key) {
+ return { valid: false, error: "API key required" };
+ }
+
+ const specificBase =
+ typeof providerSpecificData?.baseUrl === "string" ? providerSpecificData.baseUrl.trim() : "";
+ const entryBase = (getRegistryEntry("dify")?.baseUrl as string) || "";
+ const probeUrl = resolveDifyChatMessagesUrl(specificBase || entryBase);
+ if (!probeUrl) {
+ return { valid: false, error: "Dify requires a Base URL" };
+ }
+
+ try {
+ const response = await fetchImpl(
+ probeUrl,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${key}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ inputs: {},
+ query: "ping",
+ response_mode: "blocking",
+ user: "omniroute-key-check",
+ }),
+ },
+ false
+ );
+ return difyValidationResultFromStatus(response.status);
+ } catch (error) {
+ return toValidationErrorResult(error);
+ }
+}
\ No newline at end of file
diff --git a/src/lib/providers/validation/openaiFormat.ts b/src/lib/providers/validation/openaiFormat.ts
index 3d6011313458..fa7f2455797c 100644
--- a/src/lib/providers/validation/openaiFormat.ts
+++ b/src/lib/providers/validation/openaiFormat.ts
@@ -1,7 +1,6 @@
// OpenAI/Gemini-format + Bedrock provider key validators (bedrock, openai-like, command-code, gemini-like, openai-compatible).
// Extracted from validation.ts (god-file decomposition) — top-level functions; behavior is
// byte-identical to the original inline defs.
-import { randomUUID } from "node:crypto";
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
import {
discoverBedrockNativeModels,
@@ -196,13 +195,12 @@ export async function validateOpenAILikeProvider({
export async function validateCommandCodeProvider({ apiKey, providerSpecificData = {} }: any) {
const entry = getRegistryEntry("command-code");
const baseUrl = normalizeBaseUrl(entry?.baseUrl || "https://api.commandcode.ai");
- const chatPath = entry?.chatPath || "/alpha/generate";
+ const chatPath = entry?.chatPath || "/provider/v1/chat/completions";
const url = `${baseUrl}${chatPath.startsWith("/") ? chatPath : `/${chatPath}`}`;
const validationModelId =
providerSpecificData?.validationModelId ||
entry?.models?.find((model) => model.id === "deepseek/deepseek-v4-flash")?.id ||
"deepseek/deepseek-v4-flash";
- const { COMMAND_CODE_VERSION } = await import("@omniroute/open-sse/executors/commandCode.ts");
return validateDirectChatProvider({
url,
@@ -210,37 +208,13 @@ export async function validateCommandCodeProvider({ apiKey, providerSpecificData
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
- "x-command-code-version": COMMAND_CODE_VERSION,
- "x-cli-environment": "external",
- "x-project-slug": "pi-cc",
- "x-taste-learning": "false",
- "x-co-flag": "false",
- "x-session-id": randomUUID(),
+ Accept: "text/event-stream",
},
body: {
- config: {
- workingDir: "/workspace",
- date: new Date().toISOString().slice(0, 10),
- environment: "external",
- structure: [],
- isGitRepo: false,
- currentBranch: "",
- mainBranch: "",
- gitStatus: "",
- recentCommits: [],
- },
- memory: "",
- taste: "",
- skills: "",
- permissionMode: "standard",
- params: {
- model: validationModelId,
- messages: [{ role: "user", content: "test" }],
- tools: [],
- system: "",
- max_tokens: 1,
- stream: true,
- },
+ model: validationModelId,
+ messages: [{ role: "user", content: "test" }],
+ stream: true,
+ max_tokens: 1,
},
});
}
diff --git a/src/lib/providers/validation/webProvidersA.ts b/src/lib/providers/validation/webProvidersA.ts
index a1b20f1df9bd..52e9aa4c0ef1 100644
--- a/src/lib/providers/validation/webProvidersA.ts
+++ b/src/lib/providers/validation/webProvidersA.ts
@@ -13,11 +13,14 @@ import {
normalizeSessionCookieHeader,
} from "@/lib/providers/webCookieAuth";
-// kimi-web uses the international `www.kimi.com` Connect-RPC API. The legacy
-// `kimi.moonshot.cn` domain now 307-redirects every non-CN visitor, and even
-// if you bypass the redirect the old `/api/chat` REST endpoint is gone. The
-// SPA exposes a profile probe at `GET /api/user` that returns the user object
-// at the top level when the `Authorization: Bearer ` header is valid.
+// kimi-web uses the international (west-facing) `www.kimi.ai` Connect-RPC API by
+// default. `www.kimi.com` is the China-region endpoint — it serves China users but
+// the China region is not reliably reachable from outside CN, so it is not the
+// default. The legacy `kimi.moonshot.cn` domain now 307-redirects every non-CN
+// visitor, and even if you bypass the redirect the old `/api/chat` REST endpoint is
+// gone. The SPA exposes a profile probe at `GET /api/user` that returns the user
+// object at the top level when the `Authorization: Bearer ` header is
+// valid. Override the endpoint with KIMI_WEB_BASE_URL (opt-in).
export async function validateKimiWebProvider({ apiKey }: any) {
const rawCred = String(apiKey ?? "").trim();
if (!rawCred) {
diff --git a/src/lib/quota/redisQuotaStore.ts b/src/lib/quota/redisQuotaStore.ts
index 7c0e99ceb7ad..d9d17309eda7 100644
--- a/src/lib/quota/redisQuotaStore.ts
+++ b/src/lib/quota/redisQuotaStore.ts
@@ -72,7 +72,7 @@ export function resetRedisClient(): void {
// Key helpers
// ---------------------------------------------------------------------------
-const KEY_PREFIX = "omniroute:quota";
+const KEY_PREFIX = `${process.env.REDIS_KEY_PREFIX?.trim() || "omniroute:"}quota`;
function bucketKey(apiKeyId: string, dimensionKey: string, bucketIndex: number): string {
return `${KEY_PREFIX}:${apiKeyId}:${dimensionKey}:${bucketIndex}`;
diff --git a/src/lib/services/ServiceSupervisor.ts b/src/lib/services/ServiceSupervisor.ts
index 9ec8e44f6ab7..df1011bb456b 100644
--- a/src/lib/services/ServiceSupervisor.ts
+++ b/src/lib/services/ServiceSupervisor.ts
@@ -7,7 +7,12 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { getServiceRow, updateServiceField, setToolStatus } from "@/lib/db/versionManager";
import { RingBuffer } from "./ringBuffer";
import { HealthChecker } from "./healthCheck";
-import { decidePreSpawn, probeBeforeSpawn, resolvePortPid } from "./portProbe";
+import {
+ decidePreSpawn,
+ isAdoptExistingEnabled,
+ probeBeforeSpawn,
+ resolvePortPid,
+} from "./portProbe";
import type { ServiceConfig, ServiceState, ServiceStatus, LogLine, HealthState } from "./types";
const CRASH_FAST_THRESHOLD_MS = 5_000;
@@ -111,7 +116,7 @@ export class ServiceSupervisor extends EventEmitter {
// Opt-in per ServiceConfig so the default spawn path is unchanged.
if (this.config.probeBeforeSpawn) {
const probe = await probeBeforeSpawn(this.config.healthUrl(), this.config.port);
- const decision = decidePreSpawn(probe, this.config.port);
+ const decision = decidePreSpawn(probe, this.config.port, isAdoptExistingEnabled());
if (decision.action === "adopt") {
// Something healthy already serves this port. We didn't spawn it,
diff --git a/src/lib/services/installers/utils.ts b/src/lib/services/installers/utils.ts
index aff9c8c62267..45bdef032f91 100644
--- a/src/lib/services/installers/utils.ts
+++ b/src/lib/services/installers/utils.ts
@@ -145,13 +145,16 @@ export function runNpm(
options: { cwd?: string; timeoutMs?: number; prefix?: string } = {}
): Promise {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
- // On Windows, npm is npm.cmd; on Unix it's npm.
- const npmBin = process.platform === "win32" ? "npm.cmd" : "npm";
+ const isBun = Boolean(process.versions.bun);
+ const npmBin = process.platform === "win32"
+ ? (isBun ? "bun.exe" : "npm.cmd")
+ : (isBun ? "bun" : "npm");
+ const execArgs = isBun && args[0] === "install" ? ["add", ...args.slice(1)] : args;
return new Promise((resolve, reject) => {
execFile(
npmBin,
- args,
+ execArgs,
buildNpmExecOptions(process.platform, {
cwd: options.cwd,
timeoutMs,
diff --git a/src/lib/services/portProbe.ts b/src/lib/services/portProbe.ts
index 81c3d8a84676..2a9fd502bda6 100644
--- a/src/lib/services/portProbe.ts
+++ b/src/lib/services/portProbe.ts
@@ -37,11 +37,30 @@ const PID_RESOLVE_TIMEOUT_MS = 2_000;
*
* Pure — no I/O — so it can be exhaustively unit-tested.
*/
-export function decidePreSpawn(probe: PreSpawnProbe, port: number): PreSpawnDecision {
- // A healthy instance is already serving on the port — adopt it rather than
- // spawn a duplicate that would immediately die with EADDRINUSE.
+export function decidePreSpawn(
+ probe: PreSpawnProbe,
+ port: number,
+ allowAdopt = false
+): PreSpawnDecision {
if (probe.healthy) {
- return { action: "adopt" };
+ // A 2xx on the health path does NOT prove the listener is our service: a
+ // local process can squat the port, answer 200, and get adopted — receiving
+ // the injected service API key and script execution inside the dashboard
+ // origin (GHSA-wg9p-6m2g-4v27). Adopt an already-healthy listener only when
+ // the operator explicitly opts in; otherwise surface the same actionable
+ // error we already use for a held-but-unhealthy port instead of silently
+ // trusting the listener.
+ if (allowAdopt) {
+ return { action: "adopt" };
+ }
+ return {
+ action: "error",
+ message:
+ `Port ${port} is already serving a healthy response, but adopting an ` +
+ `existing listener is disabled by default (a 2xx cannot prove the listener ` +
+ `is this service). Set OMNIROUTE_ADOPT_EXISTING_SERVICE=1 to allow adoption, ` +
+ `or stop the process holding the port and start the service again.`,
+ };
}
// Port is held but nothing healthy answers: an orphaned or unrelated process
// is squatting on it. Surface a clear, actionable error instead of letting
@@ -59,6 +78,17 @@ export function decidePreSpawn(probe: PreSpawnProbe, port: number): PreSpawnDeci
return { action: "spawn" };
}
+/**
+ * Whether the operator opted in to adopting an already-healthy listener on a
+ * service port. Off by default (GHSA-wg9p-6m2g-4v27): a squatter can answer a
+ * 2xx, so auto-adoption is only safe when the operator knows the listener is
+ * genuinely their (externally-managed) instance.
+ */
+export function isAdoptExistingEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
+ const v = env.OMNIROUTE_ADOPT_EXISTING_SERVICE;
+ return v === "1" || v === "true";
+}
+
/** TCP connect check: resolves true when something accepts a connection. */
function isPortInUse(port: number, timeoutMs: number): Promise {
return new Promise((resolve) => {
diff --git a/src/lib/usage/internalUsageCommand.ts b/src/lib/usage/internalUsageCommand.ts
index 257b8f9c1580..37f5f009d1c2 100644
--- a/src/lib/usage/internalUsageCommand.ts
+++ b/src/lib/usage/internalUsageCommand.ts
@@ -13,7 +13,7 @@ const TEXT_PLAIN_HEADERS = { "Content-Type": "text/plain; charset=utf-8" } as co
type JsonRecord = Record;
-interface UsageCommandApiKeyMetadata {
+export interface UsageCommandApiKeyMetadata {
id: string;
name?: string;
allowedConnections?: string[] | null;
@@ -31,7 +31,7 @@ interface ProviderConnectionLike {
quotaWindowThresholds?: Record | null;
}
-interface UsageSnapshot {
+export interface UsageSnapshot {
connectionId: string;
provider: string;
plan: unknown;
@@ -39,7 +39,7 @@ interface UsageSnapshot {
quotaWindowThresholds?: Record | null;
}
-interface UsageCommandSelection {
+export interface UsageCommandSelection {
preferredProvider?: string | null;
preferredConnectionId?: string | null;
}
@@ -258,7 +258,7 @@ function snapshotFromConnection(
};
}
-async function collectUsageSnapshots(
+export async function collectUsageSnapshots(
metadata: UsageCommandApiKeyMetadata,
deps: RequiredDeps
): Promise {
@@ -525,6 +525,55 @@ function appendQuotaBlock(
lines.push(`⏱ reset in ${formatResetIn(getResetAt(match?.quota ?? null), now)}`);
}
+/**
+ * Structured form of the usage command — what {@link buildUsageCommandText}
+ * renders as text, exposed as data for API consumers (the OmniCopilot panel
+ * asks for it via `?format=json`). Text and JSON share the exact same
+ * collectors, so the two can never disagree about a number.
+ *
+ * The key design constraint is the 403 case: a key without `allowUsageCommand`
+ * must reach the client as a *structured* reason, not a bare text error — a
+ * caller rendering a usage panel has to be able to tell "the server does not
+ * know your limits yet" apart from "this key may not ask".
+ */
+/** Discriminated so the caller never reads a data field off a refusal:
+ * `allowed:false` carries only `error`; `allowed:true` carries the data. */
+export type UsageCommandJson =
+ | { allowed: false; error: { message: string } }
+ | {
+ allowed: true;
+ /** Present only when the key opted into per-key usage limits. */
+ personal: unknown | null;
+ /** The selected provider snapshot, or null when nothing is cached. */
+ provider: UsageSnapshot | null;
+ /** Every connection's snapshot, so a panel can render Codex / Claude /
+ * OpenCode side by side instead of only the selected one (#11191). The
+ * single-pick in `provider` is a presentation choice for a terminal; the
+ * collector already gathered all of them. */
+ providers: UsageSnapshot[];
+ };
+
+export async function buildUsageCommandJson(
+ metadata: UsageCommandApiKeyMetadata,
+ deps: InternalUsageCommandDeps = {},
+ selection: UsageCommandSelection = {}
+): Promise {
+ const resolvedDeps = await normalizeDeps(deps);
+ const personal =
+ metadata.usageLimitEnabled === true
+ ? await resolvedDeps.getApiKeyUsageLimitStatus(
+ {
+ ...metadata,
+ preferredProvider: selection.preferredProvider ?? metadata.preferredProvider ?? null,
+ },
+ { now: resolvedDeps.now }
+ )
+ : null;
+ const snapshots = await collectUsageSnapshots(metadata, resolvedDeps);
+ const provider = selectUsageSnapshot(snapshots, selection);
+ return { allowed: true, personal, provider, providers: snapshots };
+}
+
export async function buildUsageCommandText(
metadata: UsageCommandApiKeyMetadata,
deps: InternalUsageCommandDeps = {},
@@ -588,6 +637,17 @@ function inferHttpUsageCommandSelection(request: Request): UsageCommandSelection
}
}
+/** `?format=json` (or `?format=JSON`) — anything else falls back to the text
+ * form, which is the historical contract of this endpoint. */
+function wantsUsageCommandJson(request: Request): boolean {
+ try {
+ const format = new URL(request.url, "http://localhost").searchParams.get("format");
+ return format !== null && format.trim().toLowerCase() === "json";
+ } catch {
+ return false;
+ }
+}
+
function createPlainUsageCommandResponse(text: string, status = 200): Response {
return new Response(text, { status, headers: TEXT_PLAIN_HEADERS });
}
@@ -764,22 +824,45 @@ export async function handleInternalUsageCommandHttpRequest(
): Promise {
try {
const resolvedDeps = await normalizeDeps(deps);
+ const json = wantsUsageCommandJson(request);
const apiKey = extractUsageCommandApiKey(request);
if (!apiKey || !(await resolvedDeps.isValidApiKey(apiKey))) {
+ if (json) {
+ return Response.json(
+ { allowed: false, error: { message: USAGE_COMMAND_AUTH_REQUIRED_MESSAGE } } satisfies UsageCommandJson,
+ { status: 401 }
+ );
+ }
return createPlainUsageCommandResponse(USAGE_COMMAND_AUTH_REQUIRED_MESSAGE, 401);
}
const metadata = await resolvedDeps.getApiKeyMetadata(apiKey);
if (!metadata?.id) {
+ if (json) {
+ return Response.json(
+ { allowed: false, error: { message: USAGE_COMMAND_AUTH_REQUIRED_MESSAGE } } satisfies UsageCommandJson,
+ { status: 401 }
+ );
+ }
return createPlainUsageCommandResponse(USAGE_COMMAND_AUTH_REQUIRED_MESSAGE, 401);
}
if (metadata.allowUsageCommand !== true) {
+ if (json) {
+ return Response.json(
+ { allowed: false, error: { message: USAGE_COMMAND_DISABLED_MESSAGE } } satisfies UsageCommandJson,
+ { status: 403 }
+ );
+ }
return createPlainUsageCommandResponse(USAGE_COMMAND_DISABLED_MESSAGE, 403);
}
+ const selection = inferHttpUsageCommandSelection(request);
+ if (json) {
+ return Response.json(await buildUsageCommandJson(metadata, resolvedDeps, selection));
+ }
return createPlainUsageCommandResponse(
- await buildUsageCommandText(metadata, resolvedDeps, inferHttpUsageCommandSelection(request))
+ await buildUsageCommandText(metadata, resolvedDeps, selection)
);
} catch (err) {
const body = buildErrorBody(500, err instanceof Error ? err.message : String(err));
diff --git a/src/lib/webhooks/eventDescriptions.ts b/src/lib/webhooks/eventDescriptions.ts
index 00ffae98efdb..c0b992a53ea4 100644
--- a/src/lib/webhooks/eventDescriptions.ts
+++ b/src/lib/webhooks/eventDescriptions.ts
@@ -1,11 +1,11 @@
-export type WebhookEvent =
- | "request.completed"
- | "request.failed"
- | "provider.error"
- | "provider.recovered"
- | "quota.exceeded"
- | "combo.switched"
- | "test.ping";
+export type WebhookEvent = "request.completed" | "request.failed" | "quota.exceeded" | "test.ping";
+
+export const WEBHOOK_EVENT_VALUES = [
+ "request.completed",
+ "request.failed",
+ "quota.exceeded",
+ "test.ping",
+] as const;
export interface EventDescription {
label: string;
@@ -38,35 +38,12 @@ export const EVENT_DESCRIPTIONS: Record = {
attempts: 3,
},
},
- "provider.error": {
- label: "Provider Error",
- emoji: "⚠️",
- description: "A provider tripped the circuit breaker due to repeated failures.",
- exampleData: { provider: "openai", model: "gpt-4o", errorCode: 503, consecutiveFailures: 3 },
- },
- "provider.recovered": {
- label: "Provider Recovered",
- emoji: "✅",
- description: "A provider recovered from a circuit-breaker OPEN state.",
- exampleData: { provider: "openai", recoveredAfterMs: 60000 },
- },
"quota.exceeded": {
label: "Quota Exceeded",
emoji: "📊",
description: "A usage threshold (e.g. 95% of quota) was reached.",
exampleData: { quota: "daily_tokens", used: 950000, limit: 1000000, pct: 95 },
},
- "combo.switched": {
- label: "Combo Switched",
- emoji: "🔄",
- description: "Combo routing switched to a different target.",
- exampleData: {
- combo: "auto-fallback",
- fromModel: "gpt-4o",
- toModel: "claude-opus-4-7",
- reason: "provider.error",
- },
- },
"test.ping": {
label: "Test Ping",
emoji: "🏓",
diff --git a/src/lib/webhooks/integrations/discord.ts b/src/lib/webhooks/integrations/discord.ts
index 0b3fdc4199ac..3686e4272752 100644
--- a/src/lib/webhooks/integrations/discord.ts
+++ b/src/lib/webhooks/integrations/discord.ts
@@ -16,10 +16,7 @@ export interface DiscordPayload {
const EVENT_COLORS: Partial> = {
"request.completed": 0x22c55e,
"request.failed": 0xef4444,
- "provider.error": 0xf97316,
- "provider.recovered": 0x22c55e,
"quota.exceeded": 0xeab308,
- "combo.switched": 0x3b82f6,
"test.ping": 0x8b5cf6,
};
diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts
index 9f4e46bed1eb..d8dacf376d7e 100644
--- a/src/server/authz/pipeline.ts
+++ b/src/server/authz/pipeline.ts
@@ -205,6 +205,7 @@ function drainingResponse(requestId: string): NextResponse {
{ status: 503 }
);
response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId);
+ response.headers.set("Retry-After", "5");
return response;
}
diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts
index d59e68836f45..7c61d24545c4 100644
--- a/src/server/authz/routeGuard.ts
+++ b/src/server/authz/routeGuard.ts
@@ -56,6 +56,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [
"/api/jobs", // JobRegistry control (enable/disable/run-now) + run history - runtime job administration, loopback-only (Hard Rules #15 + #17)
"/api/jobs/", // sub-paths: /api/jobs/:id/{runs,enable,disable,run-now} (the bare `/api/jobs` above matches the list route; this matches children)
"/api/oauth/cursor/auto-import", // spawns execFile("which", argv-array-of-one-arg "cursor") to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable. Note: this comment intentionally avoids a literal closing square bracket character — check-openapi-security-tiers.mjs's naive regex parser for this array stops at the first one it finds, silently truncating its view of every entry after this one.
+ "/api/oauth/kiro/auto-import", // reads host-local Kiro credential files (homedir kiro-cli data) — must reach the loopback-only gate, not the PUBLIC /api/oauth/ prefix (GHSA-wgwc-crjm-pmwv, GHSA-gxv4-955v-v6cm). Excluded from PUBLIC in publicApiRoutes.ts.
+ "/api/oauth/raycast/auto-import", // reads host-local Raycast credential files — same loopback-only rationale as the kiro and cursor auto-import routes above.
"/api/skills/collect/", // Skill Collector CLI detection: GET .../detect probes getCliRuntimeStatus() per CLI_TOOL_IDS entry, which spawns a child process to check each tool — RCE-via-tunnel surface (Hard Rules #15 + #17, PR #6294 review).
"/api/discovery/", // Discovery tool (opt-in provider scanner): the scan route makes outbound probes to provider endpoints (SSRF-adjacent) and the whole surface is an admin research tool — strict-loopback only, no manage-scope bypass (NOT in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES). See _tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md.
VNC_ROUTE_PREFIX, // #7892: /api/vnc-session/* spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — RCE-via-tunnel surface (Hard Rules #15 + #17), same CVE class (GHSA-fhh6-4qxv-rpqj).
diff --git a/src/server/cors/origins.ts b/src/server/cors/origins.ts
index dfd0b407c509..a354cccb5e34 100644
--- a/src/server/cors/origins.ts
+++ b/src/server/cors/origins.ts
@@ -144,6 +144,26 @@ export function getCorsStatus(): CorsStatus {
* compression middleware only appends it conditionally, so shared caches can't
* otherwise reliably tell compressed vs uncompressed variants apart.
*/
+function requestCarriesTokenOrPreflight(request: Request): boolean {
+ // Preflight (OPTIONS) never carries the Authorization / x-api-key header, so it
+ // must be allowed through — the actual request that follows is re-evaluated by
+ // this same check and only gets the permissive Origin if it presents a token.
+ if (request.method === "OPTIONS") return true;
+ if (
+ request.headers.get("authorization") ||
+ request.headers.get("x-api-key") ||
+ request.headers.get("x-goog-api-key")
+ ) {
+ return true;
+ }
+ // A dashboard session cookie is a credential too (#5242 browser/Electron
+ // clients). auth_token is HttpOnly + SameSite, so a cross-site attacker page
+ // cannot get it auto-attached — only a truly credential-less request (the
+ // GHSA-7px7 anonymous case on a keyless install) falls through to fail-closed.
+ const cookie = request.headers.get("cookie");
+ return Boolean(cookie && /(?:^|;\s*)auth_token=/.test(cookie));
+}
+
export function applyCorsHeaders(
response: Response,
request: Request,
@@ -151,7 +171,15 @@ export function applyCorsHeaders(
): void {
const requestOrigin = request.headers.get("origin");
let allowed = resolveAllowedOrigin(requestOrigin);
- if (allowed === null && relaxForTokenAuth) {
+ if (allowed === null && relaxForTokenAuth && requestCarriesTokenOrPreflight(request)) {
+ // GHSA-7px7-29v2-m97p: the permissive Origin echo is only safe on the
+ // assumption that these routes are token-authenticated (browsers never
+ // auto-attach Authorization/x-api-key). On a keyless install that assumption
+ // breaks — an anonymous cross-origin page would be echoed its own Origin and
+ // could read the response. Only relax for a request that actually carries a
+ // credential, plus CORS preflights (OPTIONS never carries the header — the
+ // real request that follows is re-checked), so authenticated browser/Electron
+ // clients (#5242) keep working while credential-less cross-origin reads do not.
allowed = requestOrigin && requestOrigin.length > 0 ? requestOrigin : "*";
}
if (allowed !== null) {
diff --git a/src/shared/constants/agentSkills.ts b/src/shared/constants/agentSkills.ts
index d4341f2f8e84..051b57101361 100644
--- a/src/shared/constants/agentSkills.ts
+++ b/src/shared/constants/agentSkills.ts
@@ -203,7 +203,7 @@ export const CURATED_SKILLS: CuratedSkillEntry[] = [
id: "omni-webhooks",
name: "Webhooks",
description:
- "Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries.",
+ "Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries.",
category: "api",
area: "webhooks",
icon: "webhook",
diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts
index 23f9c53b39ce..5c058958e769 100644
--- a/src/shared/constants/providers.ts
+++ b/src/shared/constants/providers.ts
@@ -106,7 +106,6 @@ export const AGGREGATOR_PROVIDER_IDS = new Set([
"empower",
"poe",
"chutes",
- "hackclub",
"freetheai",
"g4f-groq",
"g4f-gemini",
@@ -237,9 +236,7 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean {
const EXPLICIT_OPTIONAL_APIKEY_PROVIDER_IDS = new Set([
"searxng-search",
"firecrawl",
- "pollinations",
"copilot-web",
- "hackclub",
"g4f-groq",
"g4f-gemini",
"g4f-pollinations",
diff --git a/src/shared/constants/providers/apikey/frontier-labs.ts b/src/shared/constants/providers/apikey/frontier-labs.ts
index 45faf2435911..0257a4561674 100644
--- a/src/shared/constants/providers/apikey/frontier-labs.ts
+++ b/src/shared/constants/providers/apikey/frontier-labs.ts
@@ -91,6 +91,11 @@ export const APIKEY_PROVIDERS_FRONTIER = {
hasFree: true,
freeNote:
"Limited free access is available through Blackbox; model availability and account limits apply",
+ subscriptionRisk: true,
+ riskNoticeVariant: "deprecated",
+ deprecated: true,
+ deprecationReason:
+ "api.blackbox.ai returns HTTP 404 on every path variant (sweep 2026-08-21); the public inference surface has moved to the gated enterprise.blackbox.ai/v1 endpoint.",
},
xai: {
id: "xai",
diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts
index 606d6eb4934c..2baf13d3aaed 100644
--- a/src/shared/constants/providers/apikey/gateways.ts
+++ b/src/shared/constants/providers/apikey/gateways.ts
@@ -83,7 +83,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
textIcon: "CC",
website: "https://commandcode.ai/",
authHint:
- "Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint.",
+ "Use a Command Code API key. Requests are sent to Command Code's /provider/v1/chat/completions endpoint.",
apiHint: "Create or copy an API key from Command Code, then paste it here as a Bearer token.",
},
openrouter: {
@@ -639,20 +639,6 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
text: "Dahl auto-generates tokens via https://inference.dahl.global/tokens. No signup needed. Rate limits apply. You can also add your own API key.",
},
},
- uncloseai: {
- id: "uncloseai",
- alias: "unc",
- name: "UncloseAI",
- icon: "auto_awesome",
- color: "#8B5CF6",
- textIcon: "UN",
- website: "https://uncloseai.com",
- hasFree: true,
- freeNote: "Free forever — no signup, no credit card. OpenAI-compatible endpoints.",
- passthroughModels: true,
- authHint:
- "No auth required. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync; verified live model: solidrust/Hermes-3-Llama-3.1-8B-AWQ.",
- },
hackclub: {
id: "hackclub",
alias: "hc",
diff --git a/src/shared/constants/providers/noauth.ts b/src/shared/constants/providers/noauth.ts
index f7a0f1082aac..42d0b3bfbf2a 100644
--- a/src/shared/constants/providers/noauth.ts
+++ b/src/shared/constants/providers/noauth.ts
@@ -175,6 +175,25 @@ export const NOAUTH_PROVIDERS = {
text: "ZCode runs locally through its native app-server. OmniRoute never receives or stores the Z.ai credential.",
},
},
+ uncloseai: {
+ id: "uncloseai",
+ alias: "unc",
+ name: "UncloseAI",
+ icon: "auto_awesome",
+ color: "#8B5CF6",
+ textIcon: "UN",
+ website: "https://uncloseai.com",
+ noAuth: true,
+ hasFree: true,
+ passthroughModels: true,
+ serviceKinds: ["llm"],
+ authHint:
+ "No auth required. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync; verified live model: solidrust/Hermes-3-Llama-3.1-8B-AWQ.",
+ freeNote: "Free forever — no signup, no credit card. OpenAI-compatible endpoints.",
+ notice: {
+ text: "UncloseAI needs no API key. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync.",
+ },
+ },
aihorde: {
id: "aihorde",
alias: "horde",
diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts
index 6d1b08be7d58..65e63ac615d3 100644
--- a/src/shared/constants/providers/web-cookie.ts
+++ b/src/shared/constants/providers/web-cookie.ts
@@ -323,14 +323,9 @@ export const WEB_COOKIE_PROVIDERS = {
icon: "auto_awesome",
color: "#2563EB",
textIcon: "KW",
- // Kimi official-partnership aff link (2026-07) — the "Kimi Coding Plan"
- // tracking link (same origin as the plain www.kimi.com login flow below,
- // so the "Open {host}" credential guide in WebSessionCredentialGuide.tsx /
- // AddApiKeyModal.tsx is unaffected: origin, not path, decides localStorage
- // access). Was `https://www.kimi.com` (no aff attribution).
- website: "https://www.kimi.com/code?aff=omniroute",
+ website: "https://www.kimi.ai",
authHint:
- "Paste access_token from www.kimi.com DevTools → Application → Local Storage. A legacy kimi-auth cookie is also accepted.",
+ "Paste access_token from www.kimi.ai DevTools → Application → Local Storage. A legacy kimi-auth cookie is also accepted.",
subscriptionRisk: true,
riskNoticeVariant: "webCookie",
},
@@ -356,7 +351,7 @@ export const WEB_COOKIE_PROVIDERS = {
icon: "auto_awesome",
color: "#5B21B6",
textIcon: "HL",
- website: "https://hailuo.ai",
+ website: "https://chat.minimax.io",
authHint:
"Open hailuo.ai, log in, then open DevTools → Application → Local Storage → copy the " +
'"_token" value. device_id/uuid fingerprint fields are derived automatically; if ' +
diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts
index 7044089a5909..b34e130acde0 100644
--- a/src/shared/constants/publicApiRoutes.ts
+++ b/src/shared/constants/publicApiRoutes.ts
@@ -71,7 +71,26 @@ function isPublicCloudApiRoute(pathname: string, method: string): boolean {
);
}
+// OAuth "auto-import" routes read host-local credential files (Cursor / Kiro /
+// Raycast tokens). The broad `/api/oauth/` PUBLIC prefix would classify them
+// PUBLIC, which skips the LOCAL_ONLY tier entirely (GHSA-wgwc-crjm-pmwv) and
+// exposes the host credential to a remote caller (GHSA-gxv4-955v-v6cm). Exclude
+// them so they fall through to MANAGEMENT and reach the loopback-only gate.
+const LOCAL_ONLY_OAUTH_IMPORT_ROUTES = [
+ "/api/oauth/cursor/auto-import",
+ "/api/oauth/kiro/auto-import",
+ "/api/oauth/raycast/auto-import",
+];
+
export function isPublicApiRoute(pathname: string, method = "GET"): boolean {
+ if (
+ LOCAL_ONLY_OAUTH_IMPORT_ROUTES.some(
+ (route) => pathname === route || pathname.startsWith(`${route}/`)
+ )
+ ) {
+ return false;
+ }
+
if (isPublicCloudApiRoute(pathname, method)) {
return true;
}
diff --git a/src/shared/middleware/chatBodyAdmission.ts b/src/shared/middleware/chatBodyAdmission.ts
index 1c3c25b904b8..9b20d78e5aea 100644
--- a/src/shared/middleware/chatBodyAdmission.ts
+++ b/src/shared/middleware/chatBodyAdmission.ts
@@ -18,6 +18,7 @@
import { CORS_HEADERS } from "../utils/cors";
import { createHmac } from "crypto";
import v8 from "node:v8";
+import { trackRequest } from "../../lib/gracefulShutdown";
function parsePositiveInt(value: string | undefined, fallback: number): number {
const parsed = Number.parseInt(String(value), 10);
@@ -229,6 +230,7 @@ export class ChatAdmissionController {
tryAcquireHealthyHeadroom(): ChatAdmissionLease | null {
if (this.#activeHealthy >= this.healthyHeadroom) return null;
this.#activeHealthy += 1;
+ const done = trackRequest();
let released = false;
return {
get released() {
@@ -238,6 +240,7 @@ export class ChatAdmissionController {
if (released) return;
released = true;
this.#activeHealthy = Math.max(0, this.#activeHealthy - 1);
+ done();
},
};
}
@@ -264,6 +267,7 @@ export class ChatAdmissionController {
tryAcquireHeavy(): ChatAdmissionLease | null {
if (this.#activeHeavy >= this.maxHeavyInFlight) return null;
this.#activeHeavy += 1;
+ const done = trackRequest();
let released = false;
return {
get released() {
@@ -273,6 +277,7 @@ export class ChatAdmissionController {
if (released) return;
released = true;
this.#activeHeavy = Math.max(0, this.#activeHeavy - 1);
+ done();
this.#dispatchFair();
},
};
diff --git a/src/shared/network/outboundUrlGuard.ts b/src/shared/network/outboundUrlGuard.ts
index e7175da0bc54..45a7ebde7bed 100644
--- a/src/shared/network/outboundUrlGuard.ts
+++ b/src/shared/network/outboundUrlGuard.ts
@@ -1,4 +1,10 @@
-import { isIP } from "node:net";
+import { ipVersion, isPrivateHost, normalizeHost } from "./privateHost";
+
+// #11122: the host classification lives in `./privateHost.ts` because
+// `open-sse/config/providerRegistry.ts` imports it from a module reachable by a browser
+// bundle, and `node:net` cannot be resolved there. Re-exported so every existing caller of
+// `isPrivateHost` from this module keeps working unchanged.
+export { isPrivateHost };
export const PROVIDER_URL_BLOCKED_MESSAGE = "Blocked private or local provider URL";
export const CLOUD_METADATA_BLOCKED_MESSAGE = "Blocked cloud-metadata endpoint";
@@ -29,61 +35,6 @@ export class OutboundUrlGuardError extends Error {
}
}
-function normalizeHost(hostname: string) {
- const normalized = hostname.trim().toLowerCase();
- if (normalized.startsWith("[") && normalized.endsWith("]")) {
- return normalized.slice(1, -1);
- }
- return normalized;
-}
-
-export function isPrivateHost(hostname: string) {
- const normalized = normalizeHost(hostname);
- if (!normalized) return true;
-
- if (
- normalized === "localhost" ||
- normalized === "0.0.0.0" ||
- // `::` is the IPv6 twin of `0.0.0.0`: connecting to it reaches a service bound
- // to the IPv6 loopback, so it has to be refused alongside its IPv4 spelling.
- normalized === "::" ||
- normalized === "127.0.0.1" ||
- normalized === "::1" ||
- normalized.endsWith(".localhost") ||
- normalized.endsWith(".local") ||
- // `.internal` is reserved for private use (ICANN-style) and is the
- // hostname suffix used by GCP/Azure metadata probes
- // (e.g. `metadata.google.internal`).
- normalized.endsWith(".internal") ||
- normalized.startsWith("::ffff:")
- ) {
- return true;
- }
-
- if (isIP(normalized) === 4) {
- const octets = normalized.split(".").map((segment) => parseInt(segment, 10));
- const [a, b] = octets;
-
- if (a === 0 || a === 10 || a === 127) return true;
- if (a === 169 && b === 254) return true;
- if (a === 192 && b === 168) return true;
- if (a === 172 && b >= 16 && b <= 31) return true;
- if (a === 100 && b >= 64 && b <= 127) return true;
- return false;
- }
-
- if (isIP(normalized) === 6) {
- return (
- normalized === "::1" ||
- normalized.startsWith("fc") ||
- normalized.startsWith("fd") ||
- normalized.startsWith("fe80:")
- );
- }
-
- return false;
-}
-
// WHATWG URL serialises an IPv4-mapped IPv6 address as hextets, so
// `http://[::ffff:169.254.169.254]/` reaches these helpers as `::ffff:a9fe:a9fe`.
// Matching the dotted spelling alone therefore misses every mapped address that
@@ -92,7 +43,7 @@ function mappedIpv4Host(hostname: string): string | null {
const normalized = normalizeHost(hostname);
if (!normalized.startsWith("::ffff:")) return null;
const embedded = normalized.slice("::ffff:".length);
- if (isIP(embedded) === 4) return embedded;
+ if (ipVersion(embedded) === 4) return embedded;
const hextets = embedded.split(":");
if (hextets.length !== 2) return null;
const [high, low] = hextets.map((part) =>
@@ -202,3 +153,4 @@ export function parseAndValidateNonMetadataUrl(input: string | URL) {
// opencode.ts) where no `tsconfig.json` is present to resolve the `@/*` path alias. Keeping
// this module free of ANY `@/`-aliased import is what makes it safe to load from the CLI.
// Do not add a `@/`-aliased import here — see docs/security/… (packaging) and #7682.
+// The same rule binds `./privateHost.ts`, which this module re-exports from.
diff --git a/src/shared/network/outboundUrlGuardPolicy.ts b/src/shared/network/outboundUrlGuardPolicy.ts
index cf32a0c612fe..a991647288b3 100644
--- a/src/shared/network/outboundUrlGuardPolicy.ts
+++ b/src/shared/network/outboundUrlGuardPolicy.ts
@@ -56,8 +56,18 @@ export function arePrivateProviderUrlsAllowed() {
return false;
}
+/**
+ * Guard mode for the provider OUTBOUND path (search-provider connection validation, image
+ * generation, remote image fetch, model discovery — anything that does not go through the
+ * chat validation path). Precedence — mirrors `getProviderValidationGuard()` (#9123):
+ * 1. explicit full opt-in (`arePrivateProviderUrlsAllowed`) → "none" (no checks; power users).
+ * 2. local-first default (`areLocalProviderUrlsAllowed`) → "block-metadata" (allow LAN, block IMDS).
+ * 3. otherwise → "public-only" (strict).
+ */
export function getProviderOutboundGuard(): OutboundUrlGuardMode {
- return arePrivateProviderUrlsAllowed() ? "none" : "public-only";
+ if (arePrivateProviderUrlsAllowed()) return "none";
+ if (areLocalProviderUrlsAllowed()) return "block-metadata";
+ return "public-only";
}
/**
diff --git a/src/shared/network/privateHost.ts b/src/shared/network/privateHost.ts
new file mode 100644
index 000000000000..f3120e3d1bfd
--- /dev/null
+++ b/src/shared/network/privateHost.ts
@@ -0,0 +1,103 @@
+// Host classification shared by the outbound URL guard and the provider registry.
+//
+// #11122: `open-sse/config/providerRegistry.ts` needs `isPrivateHost`, and that module is
+// reachable from `ProviderDetailPageClient.tsx`. `outboundUrlGuard.ts` reached for `node:net`'s
+// `isIP`, so importing it from the registry broke the browser bundle with
+// `Could not resolve "node:net"` (caught by tests/unit/media-page-client-browser-bundle.test.ts,
+// which has been red on the release branch since #11122 merged).
+// The classification therefore lives here, on a pure-JS `ipVersion`, with NO platform imports.
+//
+// Two constraints this module MUST keep — both enforced by tests:
+// 1. No `node:*` import: it is bundled for the browser.
+// 2. No `@/`-aliased import: `./outboundUrlGuard.ts` re-exports from here and is loaded by the
+// packaged CLI (`omniroute setup-opencode`), where no tsconfig resolves the alias (#7682).
+
+// Vendored from Node's own `lib/internal/net.js` so `ipVersion` stays verdict-for-verdict
+// identical to `isIP` — a NARROWER match would silently reclassify a private address as public
+// and open the very egress the guard exists to close. `tests/unit/private-host-ip-parity-11122`
+// asserts that parity against `node:net` directly.
+const V4_SEG = "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
+const V4_STR = `(?:${V4_SEG}\\.){3}${V4_SEG}`;
+const V6_SEG = "(?:[0-9a-fA-F]{1,4})";
+
+const IPV4_RE = new RegExp(`^${V4_STR}$`);
+
+const IPV6_RE = new RegExp(
+ "^(?:" +
+ `(?:${V6_SEG}:){7}(?:${V6_SEG}|:)|` +
+ `(?:${V6_SEG}:){6}(?:${V4_STR}|:${V6_SEG}|:)|` +
+ `(?:${V6_SEG}:){5}(?::${V4_STR}|(?::${V6_SEG}){1,2}|:)|` +
+ `(?:${V6_SEG}:){4}(?:(?::${V6_SEG}){0,1}:${V4_STR}|(?::${V6_SEG}){1,3}|:)|` +
+ `(?:${V6_SEG}:){3}(?:(?::${V6_SEG}){0,2}:${V4_STR}|(?::${V6_SEG}){1,4}|:)|` +
+ `(?:${V6_SEG}:){2}(?:(?::${V6_SEG}){0,3}:${V4_STR}|(?::${V6_SEG}){1,5}|:)|` +
+ `(?:${V6_SEG}:){1}(?:(?::${V6_SEG}){0,4}:${V4_STR}|(?::${V6_SEG}){1,6}|:)|` +
+ `(?::(?:(?::${V6_SEG}){0,5}:${V4_STR}|(?::${V6_SEG}){1,7}|:))` +
+ ")(?:%[0-9a-zA-Z-.:]{1,64})?$"
+);
+
+// Longest legal literal is 45 chars (`ffff:…:255.255.255.255`) plus a `%zone`. Every quantifier
+// above is bounded, and this guard keeps the alternation from ever seeing a long hostile string
+// (AGENTS.md → "Regex Security (ReDoS)").
+const MAX_IP_LITERAL_LENGTH = 110;
+
+/** Pure-JS `node:net#isIP`: 4, 6, or 0 when the string is not an IP literal. */
+export function ipVersion(host: string): 0 | 4 | 6 {
+ if (!host || host.length > MAX_IP_LITERAL_LENGTH) return 0;
+ if (IPV4_RE.test(host)) return 4;
+ return IPV6_RE.test(host) ? 6 : 0;
+}
+
+export function normalizeHost(hostname: string) {
+ const normalized = hostname.trim().toLowerCase();
+ if (normalized.startsWith("[") && normalized.endsWith("]")) {
+ return normalized.slice(1, -1);
+ }
+ return normalized;
+}
+
+export function isPrivateHost(hostname: string) {
+ const normalized = normalizeHost(hostname);
+ if (!normalized) return true;
+
+ if (
+ normalized === "localhost" ||
+ normalized === "0.0.0.0" ||
+ // `::` is the IPv6 twin of `0.0.0.0`: connecting to it reaches a service bound
+ // to the IPv6 loopback, so it has to be refused alongside its IPv4 spelling.
+ normalized === "::" ||
+ normalized === "127.0.0.1" ||
+ normalized === "::1" ||
+ normalized.endsWith(".localhost") ||
+ normalized.endsWith(".local") ||
+ // `.internal` is reserved for private use (ICANN-style) and is the
+ // hostname suffix used by GCP/Azure metadata probes
+ // (e.g. `metadata.google.internal`).
+ normalized.endsWith(".internal") ||
+ normalized.startsWith("::ffff:")
+ ) {
+ return true;
+ }
+
+ if (ipVersion(normalized) === 4) {
+ const octets = normalized.split(".").map((segment) => parseInt(segment, 10));
+ const [a, b] = octets;
+
+ if (a === 0 || a === 10 || a === 127) return true;
+ if (a === 169 && b === 254) return true;
+ if (a === 192 && b === 168) return true;
+ if (a === 172 && b >= 16 && b <= 31) return true;
+ if (a === 100 && b >= 64 && b <= 127) return true;
+ return false;
+ }
+
+ if (ipVersion(normalized) === 6) {
+ return (
+ normalized === "::1" ||
+ normalized.startsWith("fc") ||
+ normalized.startsWith("fd") ||
+ normalized.startsWith("fe80:")
+ );
+ }
+
+ return false;
+}
diff --git a/src/shared/services/opencodeConfig.ts b/src/shared/services/opencodeConfig.ts
index a398585dbad6..a91ca5069c5c 100644
--- a/src/shared/services/opencodeConfig.ts
+++ b/src/shared/services/opencodeConfig.ts
@@ -57,10 +57,19 @@ export const buildOpenCodeProviderConfig = ({
? normalizedModels
: [...new Set([normalizedModel, ...OPENCODE_DEFAULT_MODELS].filter(Boolean))];
- const modelsRecord: Record = {};
+ const modelsRecord: Record<
+ string,
+ { name: string; limit: { context: number; output: number } }
+ > = {};
for (const m of uniqueModels) {
if (m) {
- modelsRecord[m] = { name: getModelEntryName(m, normalizedLabels) };
+ modelsRecord[m] = {
+ name: getModelEntryName(m, normalizedLabels),
+ limit: {
+ context: 128_000,
+ output: 8_192,
+ },
+ };
}
}
@@ -75,11 +84,29 @@ export const buildOpenCodeProviderConfig = ({
};
};
+export const buildOpenCodeV2ProviderConfig = (
+ input: OpenCodeConfigInput
+): Record => {
+ const v1Config = buildOpenCodeProviderConfig(input);
+ return {
+ name: v1Config.name,
+ package: "@opencode-ai/ai/providers/openai-compatible",
+ settings: {
+ baseURL: v1Config.options.baseURL,
+ apiKey: v1Config.options.apiKey,
+ },
+ models: v1Config.models,
+ };
+};
+
export const buildOpenCodeConfigDocument = (input: OpenCodeConfigInput) => ({
$schema: "https://opencode.ai/config.json",
provider: {
omniroute: buildOpenCodeProviderConfig(input),
},
+ providers: {
+ omniroute: buildOpenCodeV2ProviderConfig(input),
+ },
});
export const mergeOpenCodeConfig = (
@@ -91,18 +118,18 @@ export const mergeOpenCodeConfig = (
? existingConfig
: {};
- // Same guard as the root above, one level down. Spreading a non-object here
- // does not throw, it splays the value into index keys: an existing
- // `"provider": ["a", "b"]` merged to `{"0": "a", "1": "b", omniroute: ... }`
- // and a string was exploded one character per key. mergeOpenCodeConfigText
- // refuses the same input outright, so the two disagreed on what to do with a
- // malformed config.
const existingProvider = (safeConfig as Record).provider;
const safeProvider =
existingProvider && typeof existingProvider === "object" && !Array.isArray(existingProvider)
? (existingProvider as Record)
: {};
+ const existingProviders = (safeConfig as Record).providers;
+ const safeProviders =
+ existingProviders && typeof existingProviders === "object" && !Array.isArray(existingProviders)
+ ? (existingProviders as Record)
+ : {};
+
return {
...safeConfig,
$schema: safeConfig.$schema || "https://opencode.ai/config.json",
@@ -110,6 +137,10 @@ export const mergeOpenCodeConfig = (
...safeProvider,
omniroute: buildOpenCodeProviderConfig(input),
},
+ providers: {
+ ...safeProviders,
+ omniroute: buildOpenCodeV2ProviderConfig(input),
+ },
};
};
@@ -118,6 +149,7 @@ export const mergeOpenCodeConfigText = (
input: OpenCodeConfigInput
) => {
const providerConfig = buildOpenCodeProviderConfig(input);
+ const v2ProviderConfig = buildOpenCodeV2ProviderConfig(input);
const content = typeof existingText === "string" ? existingText : "";
const trimmedContent = content.trim();
@@ -152,6 +184,11 @@ export const mergeOpenCodeConfigText = (
const providerEdits = modify(nextText, ["provider", "omniroute"], providerConfig, {
formattingOptions: { insertSpaces: true, tabSize: 2 },
});
+ nextText = applyEdits(nextText, providerEdits);
+
+ const v2ProviderEdits = modify(nextText, ["providers", "omniroute"], v2ProviderConfig, {
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
+ });
- return applyEdits(nextText, providerEdits);
+ return applyEdits(nextText, v2ProviderEdits);
};
diff --git a/src/shared/types/utilization.ts b/src/shared/types/utilization.ts
index 36f0d7dabcf3..0a1e9dc900d6 100644
--- a/src/shared/types/utilization.ts
+++ b/src/shared/types/utilization.ts
@@ -260,7 +260,9 @@ export interface ComboAutopilotReport {
degradedCount: number;
downCount: number;
issueCount: number;
- actionableCount: number;
+ suggestionCount: number;
+ /** @deprecated Use suggestionCount instead. Kept as an alias for backward compatibility; remove after 2 releases. */
+ actionableCount?: number;
};
combos: ComboAutopilotCombo[];
}
diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts
index 7df8f1cd1dc3..ebd23c9eadf0 100644
--- a/src/shared/utils/apiKeyPolicy.ts
+++ b/src/shared/utils/apiKeyPolicy.ts
@@ -645,13 +645,37 @@ async function validateRateLimitAndThrottle(context: PolicyContext): Promise {
- // A real bearer key wins; otherwise an authenticated dashboard playground may
- // test a specific key's policy by id (resolved server-side, secret never sent).
- const apiKey = extractApiKey(request) || (await resolvePlaygroundTestKey(request));
+ // A real bearer key wins; then a bare x-api-key/x-goog-api-key that auth
+ // accepted but extractApiKey() gates out; otherwise an authenticated dashboard
+ // playground may test a specific key's policy by id (resolved server-side,
+ // secret never sent).
+ const apiKey =
+ extractApiKey(request) ||
+ extractUngatedClientApiKey(request) ||
+ (await resolvePlaygroundTestKey(request));
// No API key = local/session mode, skip policy checks
if (!apiKey) {
diff --git a/src/shared/utils/m365HarImport.ts b/src/shared/utils/m365HarImport.ts
new file mode 100644
index 000000000000..3dc3c31b0cf4
--- /dev/null
+++ b/src/shared/utils/m365HarImport.ts
@@ -0,0 +1,116 @@
+/**
+ * Pure, transport-free helpers that extract a `copilot-m365-web` credential
+ * (`access_token=...; chathubPath=...`) directly from a DevTools HAR export,
+ * so the "Add connection" / "Edit connection" dialogs can offer a one-click
+ * "Import .har file" button instead of the user hand-extracting the token
+ * from the WebSocket URL (see `HarImportButton.tsx`).
+ *
+ * The token rides in the query string of the BizChat ChatHub WebSocket URL
+ * (`wss://substrate.office.com/m365Copilot/Chathub/@?...
+ * access_token=...`) — the same URL the executor itself connects to (see
+ * `open-sse/executors/copilot-m365-connection.ts`). No network calls here;
+ * everything operates on the HAR text already in the browser.
+ */
+
+/** Prefix identifying the BizChat ChatHub WebSocket request in a HAR entry. */
+export const M365_CHATHUB_WS_PREFIX = "wss://substrate.office.com/m365Copilot/Chathub/";
+
+export type M365HarImportResult =
+ | { ok: true; apiKey: string; chathubPath: string; expiresAt: number | null }
+ | { ok: false; error: string };
+
+interface HarEntryLike {
+ request?: { url?: unknown };
+}
+
+interface HarLike {
+ log?: { entries?: unknown };
+}
+
+/** Decode a JWT payload WITHOUT verification — exp is a display hint only. */
+function decodeJwtExpiry(token: string): number | null {
+ const parts = token.split(".");
+ if (parts.length !== 3) return null;
+ try {
+ const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
+ const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
+ const json = JSON.parse(atob(padded)) as { exp?: unknown };
+ return typeof json.exp === "number" ? json.exp * 1000 : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Extract the `copilot-m365-web` credential from raw HAR file text.
+ *
+ * Scans every request in `log.entries` for the ChatHub WebSocket URL and
+ * uses the LAST match (a HAR may contain several turns of the same session;
+ * the most recent one carries the freshest token). Returns a structured
+ * error — never throws — so callers can render it directly.
+ */
+export function extractM365CredentialFromHar(text: string): M365HarImportResult {
+ let har: HarLike;
+ try {
+ har = JSON.parse(text) as HarLike;
+ } catch {
+ return { ok: false, error: "notJson" };
+ }
+
+ const entries = har.log?.entries;
+ if (!Array.isArray(entries)) {
+ return { ok: false, error: "noEntries" };
+ }
+
+ let matchedUrl: string | null = null;
+ for (const entry of entries as HarEntryLike[]) {
+ const url = entry?.request?.url;
+ if (typeof url === "string" && url.startsWith(M365_CHATHUB_WS_PREFIX)) {
+ matchedUrl = url;
+ }
+ }
+
+ if (!matchedUrl) {
+ return { ok: false, error: "noChathubUrl" };
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(matchedUrl);
+ } catch {
+ return { ok: false, error: "unparsableUrl" };
+ }
+
+ const token = parsed.searchParams.get("access_token");
+ const chathubPath = decodeURIComponent(parsed.pathname.split("/m365Copilot/Chathub/")[1] || "");
+
+ if (!token || !chathubPath) {
+ return { ok: false, error: "missingFields" };
+ }
+
+ return {
+ ok: true,
+ apiKey: `access_token=${token}; chathubPath=${chathubPath}`,
+ chathubPath,
+ expiresAt: decodeJwtExpiry(token),
+ };
+}
+
+export type HarImportExpiryTone = "ok" | "warn" | "bad" | "unknown";
+
+export interface HarImportExpiryStatus {
+ tone: HarImportExpiryTone;
+ minutesRemaining: number | null;
+}
+
+/** Classify a decoded token expiry for the inline status hint. */
+export function describeHarImportExpiry(
+ expiresAt: number | null,
+ now: number = Date.now()
+): HarImportExpiryStatus {
+ if (expiresAt === null) return { tone: "unknown", minutesRemaining: null };
+ const minutesRemaining = Math.round((expiresAt - now) / 60000);
+ if (minutesRemaining <= 0) return { tone: "bad", minutesRemaining };
+ if (minutesRemaining < 15) return { tone: "warn", minutesRemaining };
+ return { tone: "ok", minutesRemaining };
+}
diff --git a/src/shared/utils/noAuthProviders.ts b/src/shared/utils/noAuthProviders.ts
index ac025be9f868..83b3235da7ec 100644
--- a/src/shared/utils/noAuthProviders.ts
+++ b/src/shared/utils/noAuthProviders.ts
@@ -22,8 +22,10 @@ export function isProviderBlockedByIdOrAlias(
): boolean {
const blockedProviderSet = normalizeBlockedProviderSet(blockedProviders);
const provider = getProviderById(providerId) as ProviderWithAlias | undefined;
+ const baseId = providerId.replace(/-search$/, "");
return (
blockedProviderSet.has(providerId) ||
+ blockedProviderSet.has(baseId) ||
(typeof provider?.alias === "string" && blockedProviderSet.has(provider.alias))
);
}
diff --git a/src/shared/utils/nodeRuntimeSupport.ts b/src/shared/utils/nodeRuntimeSupport.ts
index 0ba7e1818563..c6d44af478a6 100644
--- a/src/shared/utils/nodeRuntimeSupport.ts
+++ b/src/shared/utils/nodeRuntimeSupport.ts
@@ -72,6 +72,18 @@ export function getSecureFloorForMajor(major: number): NodeVersionInfo | null {
}
export function getNodeRuntimeSupport(version: string = process.versions.node): NodeRuntimeSupport {
+ if (process.versions.bun) {
+ return {
+ nodeVersion: `bun-${process.versions.bun} (Node.js API ${version})`,
+ nodeCompatible: true,
+ reason: "supported-bun",
+ supportedRange: SUPPORTED_NODE_RANGE + " || Bun >=1.1.0",
+ supportedDisplay: SUPPORTED_NODE_DISPLAY + ", or Bun 1.1+",
+ recommendedVersion: `v${RECOMMENDED_NODE_VERSION}`,
+ minimumSecureVersion: null,
+ };
+ }
+
const parsed = parseNodeVersion(version);
const secureFloor = getSecureFloorForMajor(parsed.major);
const nodeCompatible = secureFloor ? compareNodeVersions(parsed, secureFloor) >= 0 : false;
diff --git a/src/shared/utils/rateLimiter.ts b/src/shared/utils/rateLimiter.ts
index bb268ba5f039..7b36a2173697 100644
--- a/src/shared/utils/rateLimiter.ts
+++ b/src/shared/utils/rateLimiter.ts
@@ -3,6 +3,10 @@ import type Redis from "ioredis";
// Redis is optional. When REDIS_URL is unset, use a process-local fallback
// instead of probing localhost on every API request.
const REDIS_URL = process.env.REDIS_URL?.trim() || "";
+
+// Namespace prefix for all OmniRoute Redis keys. Prevents key collisions when
+// OmniRoute shares a Redis instance with other apps (e.g. on 127.0.0.1:6379).
+const REDIS_KEY_PREFIX = process.env.REDIS_KEY_PREFIX?.trim() || "omniroute:";
if (process.env.NODE_ENV === "production" && !REDIS_URL) {
console.warn("[REDIS] REDIS_URL is not set in production. Using in-memory rate limiting.");
}
@@ -72,6 +76,7 @@ export function getRedisClient(): Promise {
const client = new RedisCtor(REDIS_URL, {
maxRetriesPerRequest: 3,
enableReadyCheck: false,
+ keyPrefix: REDIS_KEY_PREFIX,
retryStrategy(times) {
return Math.min(times * 50, 2000); // Exponential backoff
},
diff --git a/src/shared/validation/helpers.ts b/src/shared/validation/helpers.ts
index 4e486c7287d3..c811d6d4bf02 100644
--- a/src/shared/validation/helpers.ts
+++ b/src/shared/validation/helpers.ts
@@ -4,6 +4,10 @@ import { z } from "zod";
type ValidationErrorDetail = {
field: string;
message: string;
+ // Present for `unrecognized_keys` issues: the unknown key names that were
+ // refused (e.g. a typo'd override key). Surfaced by callers so clients can
+ // tell exactly which keys were rejected.
+ keys?: string[];
};
type ValidationErrorPayload = {
@@ -45,6 +49,9 @@ export function validateBody(
details: issues.map((e) => ({
field: e.path.join("."),
message: e.message,
+ ...(("keys" in e && (e as { keys?: string[] }).keys)
+ ? { keys: (e as { keys: string[] }).keys }
+ : {}),
})),
},
};
diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts
index edeca47e729a..db825e11cc9d 100644
--- a/src/shared/validation/schemas/combo.ts
+++ b/src/shared/validation/schemas/combo.ts
@@ -321,7 +321,7 @@ export const createComboSchema = z
.object({
name: comboNameSchema,
description: z.string().max(2000).optional(),
- models: z.array(comboModelEntry).optional().default([]),
+ models: z.array(comboModelEntry).min(1, "a combo requires at least one model"),
strategy: comboStrategySchema.optional().default("priority"),
config: comboRuntimeConfigSchema.optional(),
allowedProviders: z.array(z.string().trim().min(1).max(200)).max(100).optional(),
@@ -380,8 +380,9 @@ export const updateComboSchema = z
.object({
name: comboNameSchema.optional(),
description: z.string().max(2000).optional().nullable(),
- // Creation may leave `models` empty (`omniroute combo create` drafts one
- // that way); an update may not, or a working combo loses every target.
+ // An update may not remove every model from a combo, or a working combo
+ // loses every target. Creation refuses an empty list too: since the CLI
+ // gained --models (#10954), an empty draft has no remaining legitimate path.
models: z
.array(comboModelEntry)
.min(1, "an update cannot remove every model from a combo")
diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts
index b3272f70fb64..4cfe7afff453 100644
--- a/src/shared/validation/schemas/provider.ts
+++ b/src/shared/validation/schemas/provider.ts
@@ -420,6 +420,25 @@ export const providerNodeValidateSchema = z.object({
modelId: z.string().trim().max(200).optional().or(z.literal("")),
});
+// rate-limit override numeric fields must reject operator intent loss.
+// `z.coerce.number()` silently turns "" into 0 and "60abc" into NaN, which
+// would drop or distort the value instead of rejecting it. Preprocess first so
+// an empty/non-numeric string fails validation (surfaced as a 400), while still
+// coercing legit numeric strings like "60".
+function rateLimitOverrideNumber(max: number) {
+ return z.preprocess(
+ (raw) => {
+ if (typeof raw === "string") {
+ if (raw.trim() === "") return NaN;
+ const parsed = Number(raw);
+ return Number.isNaN(parsed) ? raw : parsed;
+ }
+ return raw;
+ },
+ z.coerce.number().int().min(0).max(max)
+ );
+}
+
export const updateProviderConnectionSchema = z
.object({
name: z.string().max(200).optional(),
@@ -468,17 +487,24 @@ export const updateProviderConnectionSchema = z
projectId: z.union([z.string(), z.null()]).optional(),
// Per-connection rate limit overrides — overrides the global RequestQueueSettings
// for this connection. Set to null to clear all overrides.
+ // Per-connection rate limit overrides — overrides the global
+ // RequestQueueSettings for this connection. Set to null to clear all
+ // overrides. `.strict()` rejects unknown keys (e.g. a typo'd `tmp`) with a
+ // 400 instead of silently stripping them: the operator's intent is
+ // never dropped without an error. `.nullable()` (rather than a
+ // `z.union([z.null(), …])`) keeps the `unrecognized_keys` issue at the top
+ // level so the rejected key name survives into the 400 response.
rateLimitOverrides: z
- .union([
- z.null(),
- z.object({
- rpm: z.coerce.number().int().min(0).max(1_000_000).optional(),
- tpm: z.coerce.number().int().min(0).max(100_000_000).optional(),
- tpd: z.coerce.number().int().min(0).max(10_000_000_000).optional(),
- minTime: z.coerce.number().int().min(0).max(60_000).optional(),
- maxConcurrent: z.coerce.number().int().min(0).max(10_000).optional(),
- }),
- ])
+ .object({
+ rpm: rateLimitOverrideNumber(1_000_000).optional(),
+ tpm: rateLimitOverrideNumber(100_000_000).optional(),
+ tpd: rateLimitOverrideNumber(10_000_000_000).optional(),
+ minTime: rateLimitOverrideNumber(60_000).optional(),
+ maxConcurrent: rateLimitOverrideNumber(10_000).optional(),
+ })
+ .partial()
+ .strict()
+ .nullable()
.optional(),
proxyEnabled: z.boolean().optional(),
perKeyProxyEnabled: z.boolean().optional(),
diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts
index 388e3f73c74f..beefb5fc8f32 100644
--- a/src/shared/validation/settingsSchemas.ts
+++ b/src/shared/validation/settingsSchemas.ts
@@ -259,6 +259,48 @@ export const updateSettingsSchema = z.object({
})
)
.optional(),
+ /**
+ * Operator-declared per-provider error rules. Consulted BEFORE the built-in
+ * `providerRuleRegistry` in open-sse/config/providerErrorRules.ts so an
+ * operator can add a scope/cooldown/reason override for a provider without
+ * editing the catalog. Matches are plain case-insensitive SUBSTRINGS of the
+ * error body (never RegExp) to keep the classification hot path ReDoS-safe.
+ * Bounded to 50 rules total so a misconfigured setting cannot blow up the
+ * matcher.
+ */
+ providerErrorRules: z
+ .record(
+ z.string().trim().min(1).max(100),
+ z.array(
+ z.object({
+ status: z.number().int().min(100).max(599),
+ match: z.string().min(1).max(200),
+ scope: z.enum(["model", "provider", "connection"]),
+ reason: z
+ .enum([
+ "auth_error",
+ "quota_exhausted",
+ "rate_limit_exceeded",
+ "model_capacity",
+ "server_error",
+ "unknown",
+ ])
+ .optional(),
+ cooldownMs: z.number().int().min(0).max(86_400_000).optional(),
+ })
+ )
+ )
+ .optional()
+ .superRefine((value, ctx) => {
+ if (!value) return;
+ const total = Object.values(value).reduce((n, rules) => n + rules.length, 0);
+ if (total > 50) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `providerErrorRules: at most 50 rules total, got ${total}`,
+ });
+ }
+ }),
// #6168: global session-stickiness opt-out (per-combo config overrides this).
disableSessionStickiness: z.boolean().optional(),
/** Keep eligible combo targets close to the provider-side prompt cache. */
diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts
index 3ec6e6c6d6c2..49a29c1b69f4 100644
--- a/src/sse/handlers/chat.ts
+++ b/src/sse/handlers/chat.ts
@@ -1851,7 +1851,7 @@ async function handleSingleModelChat(
modelPinned: runtimeOptions?.modelPinned ?? false,
routingComboId: runtimeOptions?.routingComboId ?? null,
sessionAffinityKey: runtimeOptions.sessionAffinityKey ?? null,
- reasoningTransportFallback: runtimeOptions.reasoningTransportFallback ?? "skip",
+ reasoningTransportFallback: runtimeOptions.reasoningTransportFallback ?? "drop",
managedLease: runtimeOptions.managedLease ?? null,
},
runtimeOptions
diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts
index 36bd4077408a..bcf9a51c62cd 100644
--- a/src/sse/handlers/chatHelpers.ts
+++ b/src/sse/handlers/chatHelpers.ts
@@ -422,7 +422,7 @@ export async function executeChatWithBreaker({
conversationId = null,
modelPinned = false,
routingComboId = null,
- reasoningTransportFallback = "skip",
+ reasoningTransportFallback = "drop",
sessionAffinityKey = null,
managedLease = null,
}: ExecuteChatWithBreakerOptions): Promise {
diff --git a/src/types/databaseSettings.ts b/src/types/databaseSettings.ts
index 6bcc210e5fd2..2b4820e19825 100644
--- a/src/types/databaseSettings.ts
+++ b/src/types/databaseSettings.ts
@@ -44,6 +44,7 @@ export interface DatabaseSettings {
quotaSnapshots: number;
compressionAnalytics: number;
mcpAudit: number;
+ configAudit: number;
a2aEvents: number;
callLogs: number;
usageHistory: number;
@@ -114,6 +115,7 @@ export const DEFAULT_DATABASE_SETTINGS: Omit",
- "Content-Type": "application/json"
- },
- "nonStream": {
- "Authorization": "Bearer ",
- "Content-Type": "application/json"
- },
- "oauth": {
- "Accept": "text/event-stream",
- "Authorization": "Bearer ",
- "Content-Type": "application/json"
- }
- },
- "url": {
- "nonStream": "https://ai.hackclub.com/proxy/v1/chat/completions",
- "stream": "https://ai.hackclub.com/proxy/v1/chat/completions"
- }
- },
"hailuo-web": {
"format": "openai",
"headers": {
@@ -2826,8 +2803,8 @@
}
},
"url": {
- "nonStream": "https://www.hailuo.ai",
- "stream": "https://www.hailuo.ai"
+ "nonStream": "https://chat.minimax.io",
+ "stream": "https://chat.minimax.io"
}
},
"haiper": {
@@ -3364,8 +3341,8 @@
}
},
"url": {
- "nonStream": "https://www.kimi.com",
- "stream": "https://www.kimi.com"
+ "nonStream": "https://www.kimi.ai",
+ "stream": "https://www.kimi.ai"
}
},
"kiro": {
diff --git a/tests/unit/a2a-route-require-api-key.test.ts b/tests/unit/a2a-route-require-api-key.test.ts
new file mode 100644
index 000000000000..4fec1a55c806
--- /dev/null
+++ b/tests/unit/a2a-route-require-api-key.test.ts
@@ -0,0 +1,69 @@
+/**
+ * GHSA-v54m-6rm3-p565 — /a2a sits outside the authz proxy matcher, so it never
+ * saw the REQUIRE_API_KEY posture and accepted every caller when OMNIROUTE_API_KEY
+ * was unset (the default). authenticate() now honors REQUIRE_API_KEY directly.
+ */
+
+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(), "omni-a2a-require-key-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "a2a-require-key-secret";
+process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
+
+const core = await import("../../src/lib/db/core.ts");
+const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
+const route = await import("../../src/app/a2a/route.ts");
+
+const ORIGINAL_REQUIRE = process.env.REQUIRE_API_KEY;
+const ORIGINAL_A2A_KEY = process.env.OMNIROUTE_API_KEY;
+
+test.after(() => {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ if (ORIGINAL_REQUIRE === undefined) delete process.env.REQUIRE_API_KEY;
+ else process.env.REQUIRE_API_KEY = ORIGINAL_REQUIRE;
+ if (ORIGINAL_A2A_KEY === undefined) delete process.env.OMNIROUTE_API_KEY;
+ else process.env.OMNIROUTE_API_KEY = ORIGINAL_A2A_KEY;
+});
+
+function post(key?: string) {
+ return route.POST(
+ new Request("http://localhost/a2a", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ ...(key ? { authorization: `Bearer ${key}` } : {}),
+ },
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: {} }),
+ }) as never
+ );
+}
+
+async function isUnauthorized(res: Response) {
+ const body = (await res.clone().json()) as { error?: { code?: number } };
+ return body.error?.code === -32600;
+}
+
+test("REQUIRE_API_KEY=true rejects an unkeyed /a2a call (GHSA-v54m)", async () => {
+ delete process.env.OMNIROUTE_API_KEY;
+ process.env.REQUIRE_API_KEY = "true";
+ assert.equal(await isUnauthorized(await post()), true, "no key must be rejected");
+
+ const key = await apiKeysDb.createApiKey("a2a-client", "machine-a2a", []);
+ assert.equal(
+ await isUnauthorized(await post(key.key)),
+ false,
+ "a valid key must clear the /a2a auth gate"
+ );
+});
+
+test("keyless local-first default still allows /a2a (posture preserved)", async () => {
+ delete process.env.REQUIRE_API_KEY;
+ delete process.env.OMNIROUTE_API_KEY;
+ assert.equal(await isUnauthorized(await post()), false, "keyless default must not 401");
+});
diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts
index 3711e6217c80..266179ec7a53 100644
--- a/tests/unit/account-fallback-service.test.ts
+++ b/tests/unit/account-fallback-service.test.ts
@@ -453,6 +453,25 @@ test("hasPerModelQuota returns true for GitHub Copilot provider (#1624)", () =>
assert.equal(hasPerModelQuota("github", "gpt-5-mini"), true);
});
+test("hasPerModelQuota honors shared-registry passthrough providers (#11071)", () => {
+ // These declare passthroughModels:true in src/shared/constants/providers/, but are absent
+ // from the open-sse REGISTRY passthrough set and are neither local nor self-hosted — so the
+ // isLocalProvider/isSelfHostedChatProvider branch (#11078) never reaches them. Without the
+ // shared-registry lookup a missing model on one of these cools the WHOLE connection.
+ assert.equal(hasPerModelQuota("novita"), true);
+ assert.equal(hasPerModelQuota("uncloseai"), true);
+ assert.equal(hasPerModelQuota("orcarouter"), true);
+
+ // Already covered by the local/self-hosted branch — asserted so this port cannot regress it.
+ assert.equal(hasPerModelQuota("ollama-local"), true);
+ assert.equal(hasPerModelQuota("lm-studio"), true);
+ assert.equal(hasPerModelQuota("vllm"), true);
+
+ // Neither declared in the shared registry nor local: a failure here is still connection-wide.
+ assert.equal(hasPerModelQuota("openai"), false);
+ assert.equal(hasPerModelQuota("anthropic"), false);
+});
+
test("Codex Spark 429s are scoped away from normal Codex models", () => {
const connectionId = `codex-${Date.now()}`;
clearModelLock("codex", connectionId, "gpt-5.3-codex-spark");
@@ -1659,7 +1678,11 @@ test("#10460: model-unsupported 400 handles various phrasings", async () => {
// Verify connection stays healthy after each iteration
const conn = await providersDb.getProviderConnectionById(id);
assert.ok(!conn.rateLimitedUntil, `"${errorText}" must not rate-limit connection`);
- assert.notStrictEqual(conn.testStatus, "unavailable", `"${errorText}" must not mark unavailable`);
+ assert.notStrictEqual(
+ conn.testStatus,
+ "unavailable",
+ `"${errorText}" must not mark unavailable`
+ );
}
});
@@ -1692,7 +1715,11 @@ test("#10460: non-400 status with model-unsupported text does NOT trigger guard"
"test-model"
);
- assert.strictEqual(result.shouldFallback, true, "non-400 must not be short-circuited by model guard");
+ assert.strictEqual(
+ result.shouldFallback,
+ true,
+ "non-400 must not be short-circuited by model guard"
+ );
// The key assertion: guard returns shouldFallback:false. If we get here with
// shouldFallback:true, the guard did NOT fire (correct behavior).
});
@@ -1723,7 +1750,11 @@ test("#10460: auth-credential 400 text does NOT match model-unsupported guard",
// This text does NOT match MODEL_ACCESS_DENIED_PATTERNS (verified by regex test)
// so it falls through to checkFallbackError which returns shouldFallback:false for generic 400
- assert.strictEqual(result.shouldFallback, false, "auth-credential 400 must not be caught by model guard");
+ assert.strictEqual(
+ result.shouldFallback,
+ false,
+ "auth-credential 400 must not be caught by model guard"
+ );
// The generic 400 path returns cooldownMs:0 — same as the guard, but the
// connection was NOT touched (no rateLimitedUntil set). This distinguishes
// it from the normal fallback path which would set a cooldown.
@@ -1737,7 +1768,11 @@ test("#10460: guard early return does not touch DB (distinguishes from normal pa
// Guard path: model-unsupported 400 → shouldFallback:false, cooldownMs:0, no DB change
const guardResult = await auth.markAccountUnavailable(
- connId, 400, "The requested model is not supported", "github", "test-model"
+ connId,
+ 400,
+ "The requested model is not supported",
+ "github",
+ "test-model"
);
assert.strictEqual(guardResult.shouldFallback, false);
assert.strictEqual(guardResult.cooldownMs, 0);
diff --git a/tests/unit/account-rotation-lot-c.test.ts b/tests/unit/account-rotation-lot-c.test.ts
index d52c0e4bf4dc..0dde306f90a4 100644
--- a/tests/unit/account-rotation-lot-c.test.ts
+++ b/tests/unit/account-rotation-lot-c.test.ts
@@ -11,7 +11,7 @@ test("markCooldown default is transient — no eviction, only backoff", () => {
const a = acct("a");
markCooldown(a); // kind omitted → transient
assert.ok(a.cooldownUntil > Date.now());
- assert.equal((a as any).evictedAt, undefined);
+ assert.equal((a as Record).evictedAt, undefined);
// still picked when others are ready
const state = { nextAccountIdx: 0 };
const picked = pickAccount([a, acct("b")], state);
@@ -25,28 +25,28 @@ test("terminal kind evicts after threshold, pickAccount skips evicted unless all
markCooldown(a, "terminal");
markCooldown(a, "terminal");
markCooldown(a, "terminal");
- assert.ok((a as any).evictedAt != null);
+ assert.ok((a as Record).evictedAt != null);
const state = { nextAccountIdx: 0 };
// b is ready, a evicted → b is picked
- const picked = pickAccount([a, b], state, (x) => isAccountReady(x) && !(x as any).evictedAt);
+ const picked = pickAccount([a, b], state, (x) => isAccountReady(x) && !(x as Record).evictedAt);
assert.equal(picked.fingerprint, "healthy");
// when all evicted, caller still gets an account rather than hanging (preserves :52-58)
- (b as any).evictedAt = Date.now();
- const fallback = pickAccount([a, b], { nextAccountIdx: 0 }, (x) => isAccountReady(x) && !(x as any).evictedAt);
+ (b as Record).evictedAt = Date.now();
+ const fallback = pickAccount([a, b], { nextAccountIdx: 0 }, (x) => isAccountReady(x) && !(x as Record).evictedAt);
assert.ok(fallback.fingerprint === "dead" || fallback.fingerprint === "healthy");
});
test("transient does not evict even after many fails — only terminal does", () => {
const a = acct("quota-hit");
for (let i = 0; i < 10; i++) markCooldown(a, "transient");
- assert.equal((a as any).evictedAt, undefined);
+ assert.equal((a as Record).evictedAt, undefined);
});
test("markSuccess clears eviction and consecutiveFails", () => {
const a = acct("revived");
markCooldown(a, "terminal"); markCooldown(a, "terminal"); markCooldown(a, "terminal");
markSuccess(a);
- assert.equal((a as any).evictedAt, null);
+ assert.equal((a as Record).evictedAt, null);
assert.equal(a.consecutiveFails, 0);
});
@@ -57,5 +57,5 @@ test("cross-executor alias still works — opencode wrapper forwards kind", asyn
assert.ok(mc.length >= 1 && mc.length <= 2);
// Prove it accepts terminal without throw
const tmp = acct("probe");
- assert.doesNotThrow(() => (mc as any)(tmp, "terminal"));
+ assert.doesNotThrow(() => (mc as Record)(tmp, "terminal"));
});
diff --git a/tests/unit/account-rotation.test.ts b/tests/unit/account-rotation.test.ts
index f4864ee2dd84..a682f02a0d8a 100644
--- a/tests/unit/account-rotation.test.ts
+++ b/tests/unit/account-rotation.test.ts
@@ -7,6 +7,8 @@ import {
markSuccess,
maskAccountId,
isNetworkErrorRotatable,
+ isEmptyUpstreamRejection,
+ extractChatcmplId,
type RotatableAccount,
} from "../../open-sse/executors/accountRotation.ts";
@@ -114,3 +116,100 @@ describe("accountRotation", () => {
assert.strictEqual(isNetworkErrorRotatable(withoutProxy), false);
});
});
+
+describe("isEmptyUpstreamRejection", () => {
+ it("matches the observed malformed completion envelope (no error field, empty content, null finish_reason)", () => {
+ const observed =
+ '{"id":"chatcmpl_44fn2g6e7kk","object":"chat.completion","created":1787419957,"model":"muse-spark-1.2-contributor-free","choices":[{"index":0,"message":{"role":"assistant"},"finish_reason":null}]}';
+ assert.strictEqual(isEmptyUpstreamRejection(400, observed), true);
+ });
+
+ it("does not match a non-400 status", () => {
+ const observed =
+ '{"id":"chatcmpl_44fn2g6e7kk","object":"chat.completion","created":1787419957,"model":"muse-spark-1.2-contributor-free","choices":[{"index":0,"message":{"role":"assistant"},"finish_reason":null}]}';
+ assert.strictEqual(isEmptyUpstreamRejection(200, observed), false);
+ assert.strictEqual(isEmptyUpstreamRejection(429, observed), false);
+ assert.strictEqual(isEmptyUpstreamRejection(502, observed), false);
+ });
+
+ it("does not match when an error field is present", () => {
+ const withError = JSON.stringify({
+ error: { message: "bad request", type: "invalid_request_error" },
+ });
+ assert.strictEqual(isEmptyUpstreamRejection(400, withError), false);
+ const emptyError = JSON.stringify({ error: {} });
+ assert.strictEqual(isEmptyUpstreamRejection(400, emptyError), false);
+ });
+
+ it("does not match when content is non-empty or tool_calls present", () => {
+ const nonEmpty = JSON.stringify({
+ choices: [{ message: { role: "assistant", content: "hi" }, finish_reason: "stop" }],
+ });
+ assert.strictEqual(isEmptyUpstreamRejection(400, nonEmpty), false);
+
+ const toolCalls = JSON.stringify({
+ choices: [
+ { message: { role: "assistant", tool_calls: [{ id: "x" }] }, finish_reason: "tool_calls" },
+ ],
+ });
+ assert.strictEqual(isEmptyUpstreamRejection(400, toolCalls), false);
+ });
+
+ it("does not match when content is a non-string non-null value (number, block array)", () => {
+ const numericContent = JSON.stringify({
+ choices: [{ message: { role: "assistant", content: 123 }, finish_reason: null }],
+ });
+ assert.strictEqual(
+ isEmptyUpstreamRejection(400, numericContent),
+ false,
+ "non-string non-null content is not eligible"
+ );
+
+ const reasoningContent = JSON.stringify({
+ choices: [
+ { message: { role: "assistant", reasoning_content: "thinking" }, finish_reason: null },
+ ],
+ });
+ assert.strictEqual(isEmptyUpstreamRejection(400, reasoningContent), false);
+ });
+
+ it("does not match when choices or message are absent", () => {
+ const noChoices = JSON.stringify({ id: "chatcmpl_x", model: "muse" });
+ assert.strictEqual(isEmptyUpstreamRejection(400, noChoices), false);
+ const noMessage = JSON.stringify({ choices: [{ finish_reason: null }] });
+ assert.strictEqual(isEmptyUpstreamRejection(400, noMessage), false);
+ });
+
+ it("does not match when finish_reason is a literal value (not null)", () => {
+ const stopReason = JSON.stringify({
+ choices: [{ message: { role: "assistant" }, finish_reason: "stop" }],
+ });
+ assert.strictEqual(isEmptyUpstreamRejection(400, stopReason), false);
+ });
+
+ it("matches an empty string content (treated as eligible)", () => {
+ const emptyContent = JSON.stringify({
+ choices: [{ message: { role: "assistant", content: "" }, finish_reason: null }],
+ });
+ assert.strictEqual(isEmptyUpstreamRejection(400, emptyContent), true);
+ });
+
+ it("returns false for unparseable JSON rather than throwing", () => {
+ assert.strictEqual(isEmptyUpstreamRejection(400, "not json"), false);
+ assert.strictEqual(isEmptyUpstreamRejection(400, ""), false);
+ });
+});
+
+describe("extractChatcmplId", () => {
+ it("extracts the chatcmpl id from an observed envelope", () => {
+ const observed =
+ '{"id":"chatcmpl_44fn2g6e7kk","object":"chat.completion","created":1787419957,"model":"muse-spark-1.2-contributor-free","choices":[{"index":0,"message":{"role":"assistant"},"finish_reason":null}]}';
+ assert.strictEqual(extractChatcmplId(observed), "chatcmpl_44fn2g6e7kk");
+ });
+
+ it("falls back to 'unknown' when no id is present", () => {
+ assert.strictEqual(extractChatcmplId("{choices:[]}"), "unknown");
+ assert.strictEqual(extractChatcmplId(""), "unknown");
+ assert.strictEqual(extractChatcmplId("not json"), "unknown");
+ });
+});
diff --git a/tests/unit/api-key-policy.test.ts b/tests/unit/api-key-policy.test.ts
index ceda6e7348ea..63cf63be7b25 100644
--- a/tests/unit/api-key-policy.test.ts
+++ b/tests/unit/api-key-policy.test.ts
@@ -96,6 +96,17 @@ function makeAnthropicPolicyRequest(apiKey) {
});
}
+// A bare `x-api-key` with NO anthropic-version header and no claude user-agent:
+// the CLIENT_API auth layer accepts it, but the gated extractApiKey() used by
+// the policy layer used to ignore it, so the key's per-key policy was skipped
+// entirely (GHSA-2phc-xp22-9f56).
+function makeBareXApiKeyPolicyRequest(apiKey) {
+ return new Request("http://localhost/v1/responses", {
+ method: "POST",
+ headers: apiKey ? { "x-api-key": apiKey } : {},
+ });
+}
+
async function readErrorMessage(response) {
const body = (await response.json()) as { error?: { message?: unknown } };
return typeof body.error?.message === "string" ? body.error.message : "";
@@ -457,6 +468,29 @@ test("enforceApiKeyPolicy rejects disabled keys and blocked schedules", async ()
assert.match(await readErrorMessage(blocked.rejection), /Access denied outside allowed hours/);
});
+test("enforceApiKeyPolicy enforces allowedModels for a bare x-api-key (GHSA-2phc-xp22-9f56)", async () => {
+ const restrictedKey = await createKeyWithPolicy({
+ allowedModels: ["openai/gpt-4.1"],
+ });
+ const policy = await loadPolicy("bare-x-api-key");
+
+ // Disallowed model via a bare x-api-key must be rejected, exactly as it is for
+ // a Bearer token — the header used to carry the key must not weaken the policy.
+ const disallowed = await policy.enforceApiKeyPolicy(
+ makeBareXApiKeyPolicyRequest(restrictedKey.key),
+ "anthropic/claude-3-7-sonnet"
+ );
+ assert.equal(disallowed.rejection.status, 403);
+ assert.match(await readErrorMessage(disallowed.rejection), /not allowed/);
+
+ // The allowed model still passes through the same header.
+ const allowed = await policy.enforceApiKeyPolicy(
+ makeBareXApiKeyPolicyRequest(restrictedKey.key),
+ "openai/gpt-4.1"
+ );
+ assert.equal(allowed.rejection, null);
+});
+
test("enforceApiKeyPolicy rejects disallowed models and exhausted budgets", async () => {
const restrictedKey = await createKeyWithPolicy({
allowedModels: ["openai/gpt-4.1"],
diff --git a/tests/unit/authz/oauth-autoimport-local-only.test.ts b/tests/unit/authz/oauth-autoimport-local-only.test.ts
new file mode 100644
index 000000000000..586ad365eb6f
--- /dev/null
+++ b/tests/unit/authz/oauth-autoimport-local-only.test.ts
@@ -0,0 +1,35 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { isPublicApiRoute } from "../../../src/shared/constants/publicApiRoutes.ts";
+import { classifyRoute } from "../../../src/server/authz/classify.ts";
+import { isLocalOnlyPath } from "../../../src/server/authz/routeGuard.ts";
+
+// GHSA-wgwc-crjm-pmwv / GHSA-gxv4-955v-v6cm — the OAuth auto-import routes read
+// host-local credential files. They must NOT be PUBLIC (which skips the LOCAL_ONLY
+// tier); they must classify MANAGEMENT and be loopback-gated.
+
+const AUTO_IMPORT = [
+ "/api/oauth/cursor/auto-import",
+ "/api/oauth/kiro/auto-import",
+ "/api/oauth/raycast/auto-import",
+];
+
+test("OAuth auto-import routes are excluded from PUBLIC classification", () => {
+ for (const p of AUTO_IMPORT) {
+ assert.equal(isPublicApiRoute(p), false, `${p} must not be PUBLIC`);
+ assert.equal(classifyRoute(p, "GET").routeClass, "MANAGEMENT", `${p} must classify MANAGEMENT`);
+ }
+});
+
+test("OAuth auto-import routes are LOCAL_ONLY (loopback-gated)", () => {
+ for (const p of AUTO_IMPORT) {
+ assert.equal(isLocalOnlyPath(p), true, `${p} must be LOCAL_ONLY`);
+ }
+});
+
+test("the rest of /api/oauth/ (callbacks, browser flows) stays PUBLIC", () => {
+ assert.equal(isPublicApiRoute("/api/oauth/cursor/callback"), true);
+ assert.equal(isPublicApiRoute("/api/oauth/codex/authorize"), true);
+ // A sibling that merely shares the prefix must not be swept in.
+ assert.equal(isPublicApiRoute("/api/oauth/cursor/auto-import-status"), true);
+});
diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts
index 34703f270ecf..6f6469e804eb 100644
--- a/tests/unit/authz/pipeline.test.ts
+++ b/tests/unit/authz/pipeline.test.ts
@@ -306,6 +306,7 @@ test("runAuthzPipeline rejects new API requests during shutdown drain", async ()
assert.equal(response.status, 503);
assert.equal(body.error.code, "SERVICE_UNAVAILABLE");
+ assert.equal(response.headers.get("retry-after"), "5");
});
test("runAuthzPipeline rejects rewritten API aliases during shutdown drain", async () => {
@@ -319,6 +320,7 @@ test("runAuthzPipeline rejects rewritten API aliases during shutdown drain", asy
assert.equal(response.status, 503);
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
assert.equal(body.error.code, "SERVICE_UNAVAILABLE");
+ assert.equal(response.headers.get("retry-after"), "5");
});
test("runAuthzPipeline allows dashboard sessions to read model catalog aliases", async () => {
diff --git a/tests/unit/base-executor-ssrf-guard.test.ts b/tests/unit/base-executor-ssrf-guard.test.ts
new file mode 100644
index 000000000000..054bd883b6a3
--- /dev/null
+++ b/tests/unit/base-executor-ssrf-guard.test.ts
@@ -0,0 +1,38 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { DefaultExecutor } from "../../open-sse/executors/default.ts";
+
+// GHSA-4f49-hj64-448x — a persisted, caller-supplied providerSpecificData.baseUrl
+// reaches fetch() on the runtime dispatch path with no SSRF guard. BaseExecutor
+// now mirrors the provider VALIDATION guard before every upstream fetch. In the
+// shipped default (block-metadata) mode the cloud-metadata IMDS pivot is blocked
+// for non-local providers, public upstreams pass, and local / self-hosted
+// providers (vLLM, LM Studio, Ollama, …) stay exempt so loopback/LAN keeps working.
+
+function guardOf(provider: string) {
+ const exec = new DefaultExecutor(provider) as unknown as {
+ assertOutboundUrlAllowed(url: string): void;
+ };
+ return (url: string) => exec.assertOutboundUrlAllowed(url);
+}
+
+test("BaseExecutor blocks cloud-metadata for a non-local provider (GHSA-4f49-hj64-448x)", () => {
+ const guard = guardOf("openai");
+ assert.throws(() => guard("http://169.254.169.254/latest/meta-data/iam/security-credentials/"));
+ // IPv4-mapped IPv6 spelling of the same address (folded out by #10843).
+ assert.throws(() => guard("http://[::ffff:169.254.169.254]/latest/meta-data/"));
+});
+
+test("BaseExecutor allows a public upstream URL for a non-local provider", () => {
+ const guard = guardOf("openai");
+ assert.doesNotThrow(() => guard("https://api.openai.com/v1/chat/completions"));
+});
+
+test("BaseExecutor exempts local / self-hosted providers from the outbound guard", () => {
+ assert.doesNotThrow(() => guardOf("ollama-local")("http://127.0.0.1:11434/v1/chat/completions"));
+ assert.doesNotThrow(() => guardOf("lm-studio")("http://192.168.1.50:1234/v1/chat/completions"));
+});
+
+test("BaseExecutor guard is a no-op for an empty URL", () => {
+ assert.doesNotThrow(() => guardOf("openai")(""));
+});
diff --git a/tests/unit/blackbox-deprecation-probe.test.ts b/tests/unit/blackbox-deprecation-probe.test.ts
new file mode 100644
index 000000000000..77ab2381da78
--- /dev/null
+++ b/tests/unit/blackbox-deprecation-probe.test.ts
@@ -0,0 +1,29 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const blackboxRegistryPath = join(
+ __dirname,
+ "../../open-sse/config/providers/registry/blackbox/index.ts",
+);
+const blackboxMetaPath = join(
+ __dirname,
+ "../../src/shared/constants/providers/apikey/frontier-labs.ts",
+);
+
+test("probe: blackbox runtime registry still points at api.blackbox.ai (bug present)", () => {
+ const src = readFileSync(blackboxRegistryPath, "utf8");
+ assert.match(src, /api\.blackbox\.ai\/v1\/chat\/completions/);
+ assert.match(src, /api\.blackbox\.ai\/v1\/models/);
+});
+
+test("guard: blackbox metadata is marked deprecated because api.blackbox.ai is dead", () => {
+ const meta = readFileSync(blackboxMetaPath, "utf8");
+ const block = meta.split(/\n\s*blackbox:\s*\{/)[1]?.split(/\n\s*\w+:\s*\{/)[0] ?? "";
+ assert.ok(block.includes("deprecated: true"));
+ assert.ok(block.includes('riskNoticeVariant: "deprecated"'));
+ assert.ok(block.includes("deprecationReason"));
+});
\ No newline at end of file
diff --git a/tests/unit/build/optional-transformers-dependency.test.ts b/tests/unit/build/optional-transformers-dependency.test.ts
index 7ad60fb09c4b..cf5997a5f598 100644
--- a/tests/unit/build/optional-transformers-dependency.test.ts
+++ b/tests/unit/build/optional-transformers-dependency.test.ts
@@ -9,40 +9,52 @@ function readJson>(relPath: string): T {
return JSON.parse(readFileSync(join(repoRoot, relPath), "utf8")) as T;
}
-test("@huggingface/transformers is a regular dependency so npm ci never skips it", () => {
- // #9962 deliberately moved @huggingface/transformers out of optionalDependencies:
- // as an optional dep, npm silently skipped the whole subtree on Node 24/26 (old
- // pin dragged onnxruntime-node@1.21.0 whose NAN build no longer compiles), which
- // broke `npm ci`/`next build` with "Can't resolve @huggingface/transformers"
- // (lazy import in src/lib/memory/embedding/transformersLocal.ts). As a regular
- // dep with onnxruntime-node@~1.24.3 (napi prebuilds, no node-gyp) it stays
- // installable and the memory embedding path requires() cleanly.
+test("ONNX chain (@huggingface/transformers + onnxruntime-node) stays optional so Termux/Android installs succeed", () => {
+ // #11095: onnxruntime-node declares os ["win32","darwin","linux"], so while
+ // these lived in `dependencies` every npm install on Android/Termux aborted
+ // with a fatal EBADPLATFORM. As optionalDependencies npm skips only the
+ // unsupported-platform subtree (with a warning) and installs normally
+ // everywhere else. This deliberately reverses the MECHANISM of #9962 while
+ // keeping its goal: #9962's skip happened because the old onnxruntime-node@
+ // 1.21.0 pin built from source (NAN) and failed to compile on Node 24/26;
+ // the current 1.24.3 pin ships napi prebuilds, so on supported platforms the
+ // chain always installs and `npm ci`/`next build` keep resolving it. On
+ // platforms where it IS skipped, both consumers degrade gracefully via lazy/
+ // dynamic imports (asserted below).
const pkg = readJson<{
dependencies?: Record;
optionalDependencies?: Record;
+ overrides?: Record;
}>("package.json");
assert.equal(
pkg.dependencies?.["@huggingface/transformers"],
+ undefined,
+ "transformers must NOT be a hard dependency (fatal EBADPLATFORM on Android)"
+ );
+ assert.equal(
+ pkg.optionalDependencies?.["@huggingface/transformers"],
"^4.2.0",
- "transformers must be a regular dependency (never optional) so npm ci cannot skip it"
+ "transformers must be an optionalDependency"
);
- assert.equal(pkg.optionalDependencies?.["@huggingface/transformers"], undefined);
-});
-
-test("transformers + onnxruntime-node are regular dependencies (not optional)", () => {
- const pkg = readJson<{
- dependencies?: Record;
- optionalDependencies?: Record;
- }>("package.json");
-
assert.equal(
pkg.dependencies?.["onnxruntime-node"],
+ undefined,
+ "onnxruntime-node must NOT be a hard dependency (fatal EBADPLATFORM on Android)"
+ );
+ assert.equal(
+ pkg.optionalDependencies?.["onnxruntime-node"],
+ "1.24.3",
+ "onnxruntime-node must be an optionalDependency pinned in lockstep with the overrides pin"
+ );
+ assert.equal(
+ pkg.overrides?.["onnxruntime-node"],
"1.24.3",
- "onnxruntime-node is a regular dep (napi prebuilds, installable on Node 24/26)"
+ "the overrides pin must stay aligned with @huggingface/transformers' own pin (single-copy invariant)"
);
- assert.equal(pkg.optionalDependencies?.["onnxruntime-node"], undefined);
+});
+test("lockfile marks the whole ONNX chain optional", () => {
const lock = readJson<{
packages: Record<
string,
@@ -51,26 +63,61 @@ test("transformers + onnxruntime-node are regular dependencies (not optional)",
dependencies?: Record;
optionalDependencies?: Record;
}
- >;
+ >;
}>("package-lock.json");
assert.equal(
- lock.packages[""]?.dependencies?.["@huggingface/transformers"],
+ lock.packages[""]?.optionalDependencies?.["@huggingface/transformers"],
"^4.2.0",
- "root lock dependencies must hold transformers as a regular (non-optional) dep"
+ "root lock optionalDependencies must hold transformers"
+ );
+ assert.equal(
+ lock.packages[""]?.optionalDependencies?.["onnxruntime-node"],
+ "1.24.3",
+ "root lock optionalDependencies must hold onnxruntime-node"
);
- // Optional flag is only written `true` for genuinely optional packages;
- // regular deps leave it absent/null. Assert each is NOT optional.
assert.ok(
- !lock.packages["node_modules/@huggingface/transformers"]?.optional,
- "transformers must not be marked optional in the lockfile"
+ lock.packages["node_modules/@huggingface/transformers"]?.optional,
+ "transformers must be marked optional in the lockfile"
);
assert.ok(
- !lock.packages["node_modules/onnxruntime-node"]?.optional,
- "onnxruntime-node must not be marked optional in the lockfile"
+ lock.packages["node_modules/onnxruntime-node"]?.optional,
+ "onnxruntime-node must be marked optional in the lockfile"
);
assert.ok(
- !lock.packages["node_modules/onnxruntime-common"]?.optional,
- "onnxruntime-common must not be marked optional in the lockfile"
+ lock.packages["node_modules/onnxruntime-common"]?.optional,
+ "onnxruntime-common must be marked optional in the lockfile"
+ );
+});
+
+test("every @huggingface/transformers consumer loads it lazily so absent installs degrade gracefully", () => {
+ // If any module ever switches to a STATIC import of the optional chain,
+ // startup crashes on platforms where npm skipped it (Android/Termux).
+ // transformersLocal.ts must keep its lazy await import() (D8/D25);
+ // onnxWorker.ts must keep its runtime-variable dynamicImport indirection.
+
+ const embeddingSrc = readFileSync(
+ join(repoRoot, "src/lib/memory/embedding/transformersLocal.ts"),
+ "utf8"
+ );
+ assert.doesNotMatch(
+ embeddingSrc,
+ /^\s*import\s+(?:[^'"]*?\s+from\s+)?["']@huggingface\/transformers["']/m,
+ "transformersLocal.ts must not statically import @huggingface/transformers"
+ );
+ assert.match(
+ embeddingSrc,
+ /await import\(["']@huggingface\/transformers["']\)/,
+ "transformersLocal.ts must load @huggingface/transformers via await import()"
+ );
+
+ const workerSrc = readFileSync(
+ join(repoRoot, "open-sse/services/compression/engines/llmlingua/onnxWorker.ts"),
+ "utf8"
+ );
+ assert.doesNotMatch(
+ workerSrc,
+ /^\s*import\s+(?:[^'"]*?\s+from\s+)?["']@huggingface\/transformers["']/m,
+ "onnxWorker.ts must not statically import @huggingface/transformers"
);
});
diff --git a/tests/unit/bun-support.test.ts b/tests/unit/bun-support.test.ts
new file mode 100644
index 000000000000..73c0c048f75b
--- /dev/null
+++ b/tests/unit/bun-support.test.ts
@@ -0,0 +1,105 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { getNodeRuntimeSupport } from "../../src/shared/utils/nodeRuntimeSupport.ts";
+import { createSyncDriverFactory } from "../../src/lib/db/adapters/driverFactory.ts";
+
+test("getNodeRuntimeSupport detects Bun as a supported runtime", () => {
+ const originalBun = process.versions.bun;
+ try {
+ (process.versions as Record).bun = "1.1.20";
+ const support = getNodeRuntimeSupport("22.0.0");
+ assert.equal(support.nodeCompatible, true);
+ assert.equal(support.reason, "supported-bun");
+ assert.match(support.nodeVersion, /^bun-1\.1\.20/);
+ assert.match(support.supportedDisplay, /Bun 1\.1\+/);
+ } finally {
+ if (originalBun === undefined) {
+ delete (process.versions as Record).bun;
+ } else {
+ process.versions.bun = originalBun;
+ }
+ }
+});
+
+test("createSyncDriverFactory prefers bun:sqlite built-in driver when running under Bun", () => {
+ const originalBun = process.versions.bun;
+ try {
+ (process.versions as Record).bun = "1.1.20";
+
+ const dummyBunDb = {
+ query: () => ({ run: () => ({ changes: 1, lastInsertRowid: 1 }), get: () => null, all: () => [] }),
+ exec: () => {},
+ close: () => {},
+ };
+
+ const loader = (modName: string) => {
+ if (modName === "bun:sqlite") {
+ return { Database: function DummyBunDatabase() { return dummyBunDb; } };
+ }
+ throw new Error(`Unexpected module ${modName}`);
+ };
+
+ const factory = createSyncDriverFactory(loader, () => true);
+ const adapter = factory(":memory:");
+ assert.ok(adapter, "Adapter should be created via bun:sqlite");
+ assert.equal(adapter.driver, "bun:sqlite");
+ } finally {
+ if (originalBun === undefined) {
+ delete (process.versions as Record).bun;
+ } else {
+ process.versions.bun = originalBun;
+ }
+ }
+});
+
+test("createSyncDriverFactory prefers better-sqlite3 when running under Node", () => {
+ const originalBun = process.versions.bun;
+ try {
+ delete (process.versions as Record).bun;
+
+ const dummyBetterDb = {
+ open: true,
+ name: ":memory:",
+ inTransaction: false,
+ prepare: () => ({ run: () => ({ changes: 1, lastInsertRowid: 1 }) }),
+ exec: () => {},
+ close: () => {},
+ };
+
+ const loader = (modName: string) => {
+ if (modName === "better-sqlite3") {
+ return function DummyBetterSqlite() {
+ return dummyBetterDb;
+ };
+ }
+ throw new Error(`Unexpected module ${modName}`);
+ };
+
+ const factory = createSyncDriverFactory(loader, () => true);
+ const adapter = factory(":memory:");
+ assert.ok(adapter, "Adapter should be created via better-sqlite3");
+ assert.equal(adapter.driver, "better-sqlite3");
+ } finally {
+ if (originalBun === undefined) {
+ delete (process.versions as Record).bun;
+ } else {
+ process.versions.bun = originalBun;
+ }
+ }
+});
+
+test("resolveNextBuildBundlerFlag automatically disables Turbopack and uses Webpack under Bun", async () => {
+ const originalBun = process.versions.bun;
+ try {
+ (process.versions as Record).bun = "1.1.20";
+ const buildIsolated = await import("../../scripts/build/build-next-isolated.mjs");
+ assert.equal(buildIsolated.resolveNextBuildBundlerFlag({}), "--webpack");
+ assert.equal(buildIsolated.resolveNextBuildBundlerFlag({ OMNIROUTE_USE_TURBOPACK: "1" }), "--webpack");
+ } finally {
+ if (originalBun === undefined) {
+ delete (process.versions as Record).bun;
+ } else {
+ process.versions.bun = originalBun;
+ }
+ }
+});
diff --git a/tests/unit/call-logs-row-filter.test.ts b/tests/unit/call-logs-row-filter.test.ts
new file mode 100644
index 000000000000..24090389f6e8
--- /dev/null
+++ b/tests/unit/call-logs-row-filter.test.ts
@@ -0,0 +1,47 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { rowMatchesFilter } from "../../src/app/api/usage/call-logs/route.ts";
+
+test.describe("call-logs rowMatchesFilter unit tests", () => {
+ const baseRow = {
+ id: "log-1",
+ status: 200,
+ model: "openai/gpt-4o",
+ provider: "openai",
+ providerDisplay: "OpenAI Main",
+ account: "Work Account",
+ apiKeyName: "DevKey",
+ comboName: "SmartRouter",
+ correlationId: "corr-12345",
+ path: "/v1/chat/completions",
+ error: null,
+ };
+
+ test("status filter matches ok, error, and explicit status codes", () => {
+ assert.equal(rowMatchesFilter(baseRow, { status: "ok" }), true);
+ assert.equal(rowMatchesFilter(baseRow, { status: "error" }), false);
+ assert.equal(rowMatchesFilter(baseRow, { status: 200 }), true);
+ assert.equal(rowMatchesFilter(baseRow, { status: 500 }), false);
+
+ const errorRow = { ...baseRow, status: 500, error: "Internal Error" };
+ assert.equal(rowMatchesFilter(errorRow, { status: "ok" }), false);
+ assert.equal(rowMatchesFilter(errorRow, { status: "error" }), true);
+ });
+
+ test("provider filter matches provider name and excludes mismatched in-memory rows", () => {
+ assert.equal(rowMatchesFilter(baseRow, { provider: "openai" }), true);
+ assert.equal(rowMatchesFilter(baseRow, { provider: "anthropic" }), false);
+ });
+
+ test("model filter matches model name and excludes mismatched in-memory rows", () => {
+ assert.equal(rowMatchesFilter(baseRow, { model: "gpt-4o" }), true);
+ assert.equal(rowMatchesFilter(baseRow, { model: "claude-3-5-sonnet" }), false);
+ });
+
+ test("search query matches across haystack fields", () => {
+ assert.equal(rowMatchesFilter(baseRow, { search: "SmartRouter" }), true);
+ assert.equal(rowMatchesFilter(baseRow, { search: "DevKey" }), true);
+ assert.equal(rowMatchesFilter(baseRow, { search: "corr-12345" }), true);
+ assert.equal(rowMatchesFilter(baseRow, { search: "non-existent" }), false);
+ });
+});
diff --git a/tests/unit/capture-critical-db-state.test.ts b/tests/unit/capture-critical-db-state.test.ts
index 22f78df3850f..fcf2a017b7e6 100644
--- a/tests/unit/capture-critical-db-state.test.ts
+++ b/tests/unit/capture-critical-db-state.test.ts
@@ -1,24 +1,41 @@
-import test from "node:test";
+import { before, after, 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";
+// Shared across all tests — the module caches DATA_DIR / SQLITE_FILE at load time,
+// so we must create the temp dir and import exactly once.
+type CoreModule = typeof import("../../src/lib/db/core.ts");
let tempDir: string;
let originalDataDir: string | undefined;
+let getDbInstance: CoreModule["getDbInstance"];
+let resetDbInstance: CoreModule["resetDbInstance"];
+let ensureDbInitialized: CoreModule["ensureDbInitialized"];
+let closeDbInstance: CoreModule["closeDbInstance"];
-function setup() {
+before(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-test-"));
originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = tempDir;
-}
-function cleanup() {
+ const core = await import("../../src/lib/db/core.ts");
+ getDbInstance = core.getDbInstance;
+ resetDbInstance = core.resetDbInstance;
+ ensureDbInitialized = core.ensureDbInitialized;
+ closeDbInstance = core.closeDbInstance;
+
+ // Clear any singleton left by a previous test file in the same shard
+ closeDbInstance();
+ // Create a fresh DB in the temp dir (handles async driver initialization)
+ await ensureDbInitialized();
+});
+
+after(() => {
try {
- const { resetDbInstance } = require("../../src/lib/db/core.ts");
resetDbInstance();
} catch {
- // ignore if import fails
+ // ignore
}
if (originalDataDir !== undefined) {
process.env.DATA_DIR = originalDataDir;
@@ -30,120 +47,139 @@ function cleanup() {
} catch {
// ignore cleanup errors
}
-}
+});
test("getDbInstance returns a valid database handle", async () => {
- setup();
- try {
- const { getDbInstance } = await import("../../src/lib/db/core.ts");
- const db = getDbInstance();
-
- assert.ok(db, "db should be defined");
- assert.equal(typeof db.prepare, "function", "db.prepare should be a function");
- assert.equal(typeof db.exec, "function", "db.exec should be a function");
- assert.equal(typeof db.pragma, "function", "db.pragma should be a function");
- assert.equal(db.open !== false, true, "db should be open");
- } finally {
- cleanup();
- }
+ const db = getDbInstance();
+
+ assert.ok(db, "db should be defined");
+ assert.equal(typeof db.prepare, "function", "db.prepare should be a function");
+ assert.equal(typeof db.exec, "function", "db.exec should be a function");
+ assert.equal(typeof db.pragma, "function", "db.pragma should be a function");
+ assert.equal(db.open !== false, true, "db should be open");
});
test("getDbInstance creates tables from SCHEMA_SQL (proves initialization succeeded with captureSucceeded sentinel)", async () => {
- setup();
- try {
- const { getDbInstance } = await import("../../src/lib/db/core.ts");
- const db = getDbInstance();
-
- const tables = db
- .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
- .all() as Array<{ name: string }>;
- const tableNames = new Set(tables.map((t) => t.name));
-
- const expectedTables = [
- "provider_connections",
- "provider_nodes",
- "key_value",
- "combos",
- "api_keys",
- "db_meta",
- "usage_history",
- "call_logs",
- "domain_circuit_breakers",
- "semantic_cache",
- "_omniroute_migrations",
- ];
-
- for (const name of expectedTables) {
- assert.ok(tableNames.has(name), `table "${name}" should exist`);
- }
-
- // The preservedCriticalState sentinel is captureSucceeded: true on fresh DB
- // (no existing file = no corruption path = initialized with default sentinel).
- // Verify this indirectly: the DB is fully functional and migrations ran.
- const migrationCount = db
- .prepare("SELECT COUNT(*) as c FROM _omniroute_migrations")
- .get() as { c: number };
- assert.ok(migrationCount.c >= 1, "at least one migration should be recorded");
- } finally {
- cleanup();
+ const db = getDbInstance();
+
+ const tables = db
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
+ .all() as Array<{ name: string }>;
+ const tableNames = new Set(tables.map((t) => t.name));
+
+ const expectedTables = [
+ "provider_connections",
+ "provider_nodes",
+ "key_value",
+ "combos",
+ "api_keys",
+ "db_meta",
+ "usage_history",
+ "call_logs",
+ "domain_circuit_breakers",
+ "semantic_cache",
+ "_omniroute_migrations",
+ ];
+
+ for (const name of expectedTables) {
+ assert.ok(tableNames.has(name), `table "${name}" should exist`);
}
+
+ // The preservedCriticalState sentinel is captureSucceeded: true on fresh DB
+ // (no existing file = no corruption path = initialized with default sentinel).
+ // Verify this indirectly: the DB is fully functional and migrations ran.
+ const migrationCount = db
+ .prepare("SELECT COUNT(*) as c FROM _omniroute_migrations")
+ .get() as { c: number };
+ assert.ok(migrationCount.c >= 1, "at least one migration should be recorded");
});
test("getDbInstance supports basic CRUD operations after startup", async () => {
- setup();
- try {
- const { getDbInstance } = await import("../../src/lib/db/core.ts");
- const db = getDbInstance();
-
- // Insert into key_value
- db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
- "test_ns",
- "test_key",
- JSON.stringify({ hello: "world" })
- );
-
- const row = db
- .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
- .get("test_ns", "test_key") as { value: string };
- assert.ok(row, "row should exist");
- assert.deepEqual(JSON.parse(row.value), { hello: "world" });
-
- // Update
- db.prepare("UPDATE key_value SET value = ? WHERE namespace = ? AND key = ?").run(
- JSON.stringify({ hello: "updated" }),
- "test_ns",
- "test_key"
- );
- const updated = db
- .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
- .get("test_ns", "test_key") as { value: string };
- assert.deepEqual(JSON.parse(updated.value), { hello: "updated" });
-
- // Delete
- db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run("test_ns", "test_key");
- const deleted = db
- .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
- .get("test_ns", "test_key");
- assert.equal(deleted, undefined, "row should be deleted");
- } finally {
- cleanup();
- }
+ const db = getDbInstance();
+
+ // Insert into key_value
+ db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
+ "test_ns",
+ "test_key",
+ JSON.stringify({ hello: "world" })
+ );
+
+ const row = db
+ .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
+ .get("test_ns", "test_key") as { value: string };
+ assert.ok(row, "row should exist");
+ assert.deepEqual(JSON.parse(row.value), { hello: "world" });
+
+ // Update
+ db.prepare("UPDATE key_value SET value = ? WHERE namespace = ? AND key = ?").run(
+ JSON.stringify({ hello: "updated" }),
+ "test_ns",
+ "test_key"
+ );
+ const updated = db
+ .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
+ .get("test_ns", "test_key") as { value: string };
+ assert.deepEqual(JSON.parse(updated.value), { hello: "updated" });
+
+ // Delete
+ db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run("test_ns", "test_key");
+ const deleted = db
+ .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
+ .get("test_ns", "test_key");
+ assert.equal(deleted, undefined, "row should be deleted");
});
test("getDbInstance returns same singleton on repeated calls", async () => {
- setup();
- try {
- const { getDbInstance } = await import("../../src/lib/db/core.ts");
- const db1 = getDbInstance();
- const db2 = getDbInstance();
- assert.equal(db1, db2, "should return the same singleton instance");
- } finally {
- cleanup();
- }
+ const db1 = getDbInstance();
+ const db2 = getDbInstance();
+ assert.equal(db1, db2, "should return the same singleton instance");
});
-test.skip("resetDbInstance clears the singleton so next call creates a new DB", () => {});
+test("resetDbInstance clears the singleton so next call creates a new DB", async () => {
+ const db1 = getDbInstance();
+
+ // Write a marker row so we can prove the post-reset handle reopens the same
+ // on-disk file through a freshly opened connection (not the cached one).
+ db1.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
+ "reset_ns",
+ "marker",
+ JSON.stringify({ v: 1 })
+ );
+
+ resetDbInstance();
-test.skip("getDbInstance sets WAL journal mode", () => {});
+ // Re-initialize after reset — drivers may need async pre-init (sql.js WASM)
+ await ensureDbInitialized();
+
+ const db2 = getDbInstance();
+ assert.notEqual(db1, db2, "reset must swap the cached singleton for a new handle");
+
+ // The new handle reopens the same DATA_DIR file, so the persisted marker
+ // survives while the object identity does not.
+ const row = db2
+ .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
+ .get("reset_ns", "marker") as { value: string } | undefined;
+ assert.ok(row, "persisted row should survive a singleton reset");
+ assert.deepEqual(JSON.parse(row.value), { v: 1 });
+});
-test.skip("getDbInstance stores schema_version in db_meta", () => {});
+test("getDbInstance sets WAL journal mode", async () => {
+ const db = getDbInstance();
+
+ const mode = db.pragma("journal_mode", { simple: true }) as string;
+ assert.equal(
+ String(mode).toLowerCase(),
+ "wal",
+ "on-disk DB should open in WAL journal mode"
+ );
+});
+
+test("getDbInstance stores schema_version in db_meta", async () => {
+ const db = getDbInstance();
+
+ const row = db
+ .prepare("SELECT value FROM db_meta WHERE key = 'schema_version'")
+ .get() as { value: string } | undefined;
+ assert.ok(row, "db_meta should hold a schema_version row after init");
+ assert.equal(row.value, "1", "schema_version should be seeded to '1'");
+});
diff --git a/tests/unit/chat-body-admission.test.ts b/tests/unit/chat-body-admission.test.ts
index e4707402a159..05afae869c82 100644
--- a/tests/unit/chat-body-admission.test.ts
+++ b/tests/unit/chat-body-admission.test.ts
@@ -16,6 +16,7 @@ const {
resolveSelfLoopBearer,
} = admissionModule;
const { withEarlyStreamKeepalive } = await import("../../open-sse/utils/earlyStreamKeepalive.ts");
+const { getActiveRequestCount } = await import("../../src/lib/gracefulShutdown.ts");
/**
* Save/restore the env-var keys that `resolveSelfLoopBearer` reads so tests can
@@ -49,6 +50,25 @@ function chatRequest(body: string, contentLength: string | null = String(body.le
});
}
+test("heavyweight leases are counted for SIGTERM drain (#11015)", () => {
+ globalThis.__omnirouteShutdown = { init: true, shuttingDown: false, activeRequests: 0 };
+ const controller = new ChatAdmissionController(2);
+ const before = getActiveRequestCount();
+ const lease = controller.tryAcquireHeavy();
+ assert.ok(lease);
+ assert.equal(getActiveRequestCount(), before + 1);
+ const headroom = controller.tryAcquireHealthyHeadroom();
+ assert.ok(headroom);
+ assert.equal(getActiveRequestCount(), before + 2);
+ lease.release();
+ assert.equal(getActiveRequestCount(), before + 1);
+ headroom.release();
+ assert.equal(getActiveRequestCount(), before);
+ lease.release();
+ headroom.release();
+ assert.equal(getActiveRequestCount(), before);
+});
+
test("small known body is admitted without consuming heavyweight capacity", async () => {
const controller = new ChatAdmissionController(1);
const result = await admitChatRequest(chatRequest("{}"), {
diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts
index 08cc564cc4e5..6ecb0487889c 100644
--- a/tests/unit/chatcore-translation-paths.test.ts
+++ b/tests/unit/chatcore-translation-paths.test.ts
@@ -369,7 +369,7 @@ async function invokeChatCore({
onCredentialsRefreshed = null,
onRequestSuccess = null,
sessionAffinityKey = null,
- reasoningTransportFallback = "skip",
+ reasoningTransportFallback = "drop",
managedLease = null,
cachedSettings = null,
}: any = {}) {
@@ -631,7 +631,7 @@ test("chatCore translates a streaming Responses upstream for a Chat client", asy
assert.match(streamed, /"content":"ok"/);
assert.match(streamed, /data: \[DONE\]/);
});
-test("chatCore rejects opaque reasoning for unknown Responses targets unless explicitly enabled", async () => {
+test("chatCore drops opaque reasoning for plaintext Responses targets by default (#10959)", async () => {
const reasoningItems = [
{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" },
{ type: "reasoning", encrypted_content: "" },
@@ -640,7 +640,7 @@ test("chatCore rejects opaque reasoning for unknown Responses targets unless exp
{ id: "fc_call", type: "function_call", call_id: "call_1", name: "search", arguments: "{}" },
];
- const rejected = await invokeChatCore({
+ const dropped = await invokeChatCore({
provider: "openai-compatible-sp-openai",
model: "gpt-5.4",
endpoint: "/v1/responses",
@@ -656,9 +656,12 @@ test("chatCore rejects opaque reasoning for unknown Responses targets unless exp
responseFormat: "openai-responses",
});
- assert.equal(rejected.result.success, false);
- assert.equal(rejected.result.status, 400);
- assert.equal(rejected.calls.length, 0);
+ assert.equal(dropped.result.success, true);
+ assert.equal(dropped.calls.length, 1);
+ assert.deepEqual(
+ dropped.call.body.input.filter((item) => item.type === "reasoning"),
+ [{ type: "reasoning", summary: [{ text: "not self-contained" }] }]
+ );
const enabled = await invokeChatCore({
provider: "openai-compatible-sp-openai",
@@ -682,7 +685,7 @@ test("chatCore rejects opaque reasoning for unknown Responses targets unless exp
assert.deepEqual(
input.filter((item) => item.type === "reasoning"),
[
- { id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" },
+ { id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob", summary: [] }, // summary defaulted by #11110
{ type: "reasoning", summary: [{ text: "not self-contained" }] },
]
);
@@ -693,9 +696,9 @@ test("chatCore rejects opaque reasoning for unknown Responses targets unless exp
assert.equal(input.find((item) => item.type === "function_call")?.id, undefined);
});
-test("chatCore applies Chat reasoning compatibility before stream mode diverges", async () => {
+test("chatCore drops incompatible Chat reasoning before stream mode diverges (#10959)", async () => {
for (const stream of [false, true]) {
- const rejected = await invokeChatCore({
+ const dropped = await invokeChatCore({
provider: "openai-compatible-sp-openai",
model: "gpt-5.4",
endpoint: "/v1/chat/completions",
@@ -728,14 +731,14 @@ test("chatCore applies Chat reasoning compatibility before stream mode diverges"
},
});
- assert.equal(rejected.result.success, false, `stream=${stream}`);
- assert.equal(rejected.result.status, 400, `stream=${stream}`);
- assert.equal(rejected.calls.length, 0, `stream=${stream}`);
+ assert.equal(dropped.result.success, true, `stream=${stream}`);
+ assert.equal(dropped.calls.length, 1, `stream=${stream}`);
+ assert.equal(dropped.call.body.messages[0].reasoning_details, undefined, `stream=${stream}`);
}
});
-test("chatCore can drop incompatible reasoning for an opted-in Combo attempt", async () => {
- const dropped = await invokeChatCore({
+test("chatCore preserves Combo skip behavior for incompatible reasoning", async () => {
+ const skipped = await invokeChatCore({
provider: "openai-compatible-sp-openai",
model: "gpt-5.4",
endpoint: "/v1/responses",
@@ -757,15 +760,12 @@ test("chatCore can drop incompatible reasoning for an opted-in Combo attempt", a
},
responseFormat: "openai-responses",
isCombo: true,
- reasoningTransportFallback: "drop",
+ reasoningTransportFallback: "skip",
});
- assert.equal(dropped.result.success, true);
- assert.equal(dropped.calls.length, 1);
- assert.equal(
- dropped.call.body.input.some((item) => item.type === "reasoning"),
- false
- );
+ assert.equal(skipped.result.success, false);
+ assert.equal(skipped.result.status, 400);
+ assert.equal(skipped.calls.length, 0);
});
test("chatCore carries Chat reasoning_content into official DeepSeek Responses input", async () => {
@@ -800,6 +800,7 @@ test("chatCore carries Chat reasoning_content into official DeepSeek Responses i
assert.deepEqual(call.body.input.slice(0, 3), [
{
type: "reasoning",
+ summary: [], // defaulted on freshly-built reasoning items (#11129)
content: [{ type: "reasoning_text", text: "Inspect before calling the tool" }],
},
{
@@ -866,7 +867,7 @@ test("chatCore replays nonstream DeepSeek Responses reasoning across a Chat tool
assert.equal(second.result.success, true);
assert.deepEqual(
second.call.body.input.find((item) => item.type === "reasoning"),
- { type: "reasoning", content: [{ type: "reasoning_text", text: reasoning }] }
+ { type: "reasoning", content: [{ type: "reasoning_text", text: reasoning }], summary: [] } // summary defaulted by #11129
);
});
@@ -930,7 +931,7 @@ test("chatCore replays streamed DeepSeek Responses reasoning across a Chat tool
assert.equal(second.result.success, true);
assert.deepEqual(
second.call.body.input.find((item) => item.type === "reasoning"),
- { type: "reasoning", content: [{ type: "reasoning_text", text: reasoning }] }
+ { type: "reasoning", content: [{ type: "reasoning_text", text: reasoning }], summary: [] } // summary defaulted by #11129
);
});
@@ -1132,7 +1133,7 @@ test("chatCore automatically preserves provider-generated opaque reasoning for C
assert.equal(result.success, true);
assert.deepEqual(
call.body.input.filter((item) => item.type === "reasoning"),
- [{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob" }]
+ [{ id: "rs_valid", type: "reasoning", encrypted_content: "encrypted-blob", summary: [] }] // summary defaulted by #11110
);
assert.equal(
call.body.input.some((item) => item.type === "item_reference"),
diff --git a/tests/unit/claude-code-tool-casing-identity-echo.test.ts b/tests/unit/claude-code-tool-casing-identity-echo.test.ts
new file mode 100644
index 000000000000..63d1edf3d248
--- /dev/null
+++ b/tests/unit/claude-code-tool-casing-identity-echo.test.ts
@@ -0,0 +1,205 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { restoreClaudeToolName } from "../../open-sse/services/claudeCodeToolRemapper.ts";
+import { openaiToClaudeResponse } from "../../open-sse/translator/response/openai-to-claude.ts";
+import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts";
+import { FORMATS } from "../../open-sse/translator/formats.ts";
+
+interface ClaudeEvent {
+ type: string;
+ index?: number;
+ content_block?: { type: string; id?: string; name?: string; input?: unknown };
+}
+
+type TranslatorState = Record;
+
+function firstToolUse(events: ClaudeEvent[] | null): ClaudeEvent["content_block"] {
+ return events?.find(
+ (e) => e.type === "content_block_start" && e.content_block?.type === "tool_use"
+ )?.content_block;
+}
+
+function openaiToolCallChunk(name: string): { choices: Array> } {
+ return {
+ choices: [
+ {
+ delta: {
+ tool_calls: [{ index: 0, id: "call_echo", function: { name, arguments: "" } }],
+ },
+ },
+ ],
+ };
+}
+
+/**
+ * Claude Code 2.1.x added CronCreate/CronList/CronDelete/ScheduleWakeup/
+ * EnterWorktree. The `/loop` skill schedules via CronCreate; upstream gateways
+ * that emit the lowercased name (and echo it into the toolNameMap alias
+ * channel) previously let `croncreate` reach Claude Code un-restored, which
+ * the CLI rejects with "No such tool available" — killing /loop AND every
+ * other native tool call emitted in lowercase form.
+ */
+describe("Claude Code cron-era tool names survive identity-echo alias maps", () => {
+ const ECHO_MAPS = [
+ ["identity entry croncreate→croncreate", new Map([["croncreate", "croncreate"]])],
+ ["cloak-direction entry CronCreate→croncreate", new Map([["CronCreate", "croncreate"]])],
+ [
+ "identity + unrelated aliases",
+ new Map([
+ ["subdispatch", "SubDispatch"],
+ ["croncreate", "croncreate"],
+ ]),
+ ],
+ ] as const;
+
+ for (const [label, map] of ECHO_MAPS) {
+ it(`restoreClaudeToolName upgrades echoed lowercase cron tools — ${label}`, () => {
+ assert.equal(restoreClaudeToolName("croncreate", map), "CronCreate");
+ assert.equal(restoreClaudeToolName("cronlist", map), "CronList");
+ assert.equal(restoreClaudeToolName("crondelete", map), "CronDelete");
+ assert.equal(restoreClaudeToolName("schedulewakeup", map), "ScheduleWakeup");
+ assert.equal(restoreClaudeToolName("enterworktree", map), "EnterWorktree");
+ assert.equal(restoreClaudeToolName("bash", map), "Bash");
+ assert.equal(restoreClaudeToolName("taskcreate", map), "TaskCreate");
+ assert.equal(restoreClaudeToolName("taskupdate", map), "TaskUpdate");
+ assert.equal(restoreClaudeToolName("tasklist", map), "TaskList");
+ assert.equal(restoreClaudeToolName("taskget", map), "TaskGet");
+ });
+
+ it(`openaiToClaudeResponse emits PascalCase content_block.name — ${label}`, () => {
+ const state: TranslatorState = {
+ toolCalls: new Map(),
+ nextBlockIndex: 0,
+ toolNameMap: map,
+ };
+ const block = firstToolUse(
+ openaiToClaudeResponse(openaiToolCallChunk("croncreate"), state) as ClaudeEvent[]
+ );
+ assert.equal(block?.name, "CronCreate");
+ });
+ }
+
+ it("request-side non-identity alias still beats canonical casing", () => {
+ // A client that actually declared a custom lowercase MCP-style name keeps it.
+ const map = new Map([
+ ["read", "mcp__fs__read"],
+ ["croncreate", "CronCreate"],
+ ]);
+ assert.equal(restoreClaudeToolName("read", map), "mcp__fs__read");
+ });
+
+ it("unknown tools with identity entries are preserved verbatim", () => {
+ const map = new Map([["my_custom_tool", "my_custom_tool"]]);
+ assert.equal(restoreClaudeToolName("my_custom_tool", map), "my_custom_tool");
+ });
+
+ it("canonical echo stays canonical on no-map routes (live repro 2026-08-22)", () => {
+ // Live-tested against glm-5.2 via opencode-go: the request declared
+ // CronCreate/Bash, the gateway echoed them TitleCase, and claude-to-openai
+ // builds no _toolNameMap — the old #7926 REVERSE_MAP fallback downcased
+ // the echo to `croncreate`/`bash`, which Claude Code rejects with
+ // "No such tool available", killing those tools for the whole session.
+ // Every restoreClaudeToolName caller converts toward a Claude-format
+ // client, so blind TitleCase→lowercase downcasing has no legitimate
+ // consumer left: legacy lowercase clients are protected by explicit
+ // alias maps (see test above), not by unmapped downcasing.
+ assert.equal(restoreClaudeToolName("TodoWrite", null), "TodoWrite");
+ assert.equal(restoreClaudeToolName("Read", undefined), "Read");
+ assert.equal(restoreClaudeToolName("WebSearch", null), "WebSearch");
+ });
+});
+
+/**
+ * Live-reproduced 2026-08-22: an `ox-alpha-free` upstream answered the
+ * stream:true /v1/messages request with a non-streaming JSON body; omniroute
+ * converted it via translateNonStreamingResponse, which emitted tool_use.name
+ * verbatim ("bash") — Claude Code rejected it with "No such tool available",
+ * killing Bash/Read/Write/CronCreate for the whole session.
+ */
+describe("translateNonStreamingResponse restores Claude Code tool casing", () => {
+ function openaiJson(name: string) {
+ return {
+ id: "202608221120471ad3e3bd71e24afd",
+ object: "chat.completion",
+ choices: [
+ {
+ index: 0,
+ finish_reason: "tool_calls",
+ message: {
+ role: "assistant",
+ content: null,
+ reasoning_content: "The user wants echo ok",
+ tool_calls: [
+ {
+ id: "call_b90e72ccc16f440c88c4f9e6",
+ type: "function",
+ function: { name, arguments: '{"command":"echo ok"}' },
+ },
+ ],
+ },
+ },
+ ],
+ usage: { prompt_tokens: 246, completion_tokens: 35 },
+ };
+ }
+
+ it("upgrades lowercase native tool names with no alias map (live repro)", () => {
+ const out = translateNonStreamingResponse(
+ openaiJson("bash"),
+ FORMATS.OPENAI,
+ FORMATS.CLAUDE,
+ null
+ );
+ const toolUse = out.content.find((b) => b.type === "tool_use");
+ assert.equal(toolUse.name, "Bash");
+ assert.equal(toolUse.input.command, "echo ok");
+ });
+
+ it("upgrades cron-era tools through identity-echo maps", () => {
+ const out = translateNonStreamingResponse(
+ openaiJson("croncreate"),
+ FORMATS.OPENAI,
+ FORMATS.CLAUDE,
+ new Map([["croncreate", "croncreate"]])
+ );
+ assert.equal(out.content.find((b) => b.type === "tool_use").name, "CronCreate");
+ });
+
+ it("request-side aliases still win over canonical casing", () => {
+ const out = translateNonStreamingResponse(
+ openaiJson("read"),
+ FORMATS.OPENAI,
+ FORMATS.CLAUDE,
+ new Map([["read", "mcp__fs__read"]])
+ );
+ assert.equal(out.content.find((b) => b.type === "tool_use").name, "mcp__fs__read");
+ });
+
+ it("keeps canonical casing the upstream echoed verbatim when no alias map exists (live repro #11085)", () => {
+ // Live-tested on the Claude Code → OpenAI-style upstream route: the request
+ // declares CronCreate/Bash, the gateway echoes them TitleCase, and
+ // claude-to-openai builds no _toolNameMap — the #7926 REVERSE_MAP fallback
+ // must not downcase a canonical name back into "No such tool available".
+ const out = translateNonStreamingResponse(
+ openaiJson("CronCreate"),
+ FORMATS.OPENAI,
+ FORMATS.CLAUDE,
+ null
+ );
+ assert.equal(out.content.find((b) => b.type === "tool_use").name, "CronCreate");
+ assert.equal(restoreClaudeToolName("Bash", null), "Bash");
+ assert.equal(restoreClaudeToolName("WebSearch", null), "WebSearch");
+ assert.equal(restoreClaudeToolName("TaskCreate", new Map()), "TaskCreate");
+ });
+
+ it("declared lowercase form still wins when an explicit alias maps canonical → lowercase", () => {
+ // Legacy OpenCode/XML-style clients declare `bash`; request-side cloak
+ // records { CronCreate→croncreate }-style aliases and restoreClaudeToolName
+ // must keep honoring them even when the upstream echoes the canonical form.
+ assert.equal(
+ restoreClaudeToolName("CronCreate", new Map([["CronCreate", "croncreate"]])),
+ "croncreate"
+ );
+ assert.equal(restoreClaudeToolName("Read", new Map([["Read", "read"]])), "read");
+ });
+});
diff --git a/tests/unit/claude-tool-name-casing-fix.test.ts b/tests/unit/claude-tool-name-casing-fix.test.ts
index c02fd09286e1..5c11d1652bce 100644
--- a/tests/unit/claude-tool-name-casing-fix.test.ts
+++ b/tests/unit/claude-tool-name-casing-fix.test.ts
@@ -50,10 +50,13 @@ describe("Claude Code Tool Name Casing Fixes", () => {
assert.equal(restoreClaudeToolName("exitplanmode"), "ExitPlanMode");
});
- it("restoreClaudeToolName keeps the #7926 TitleCase→lowercase fallback with no map", () => {
- // Clients with no request-side map (XML / OpenCode-style) expect lowercase.
- assert.equal(restoreClaudeToolName("TodoWrite"), "todowrite");
- assert.equal(restoreClaudeToolName("Read"), "read");
+ it("restoreClaudeToolName keeps canonical TitleCase with no map (#11085 live repro)", () => {
+ // Live-tested 2026-08-22: no-map routes (Claude Code → OpenAI-style
+ // upstreams) receive the gateway's TitleCase echo and must keep it —
+ // downcasing made Claude Code reject its own tools. XML/OpenCode-style
+ // lowercase clients are protected by explicit alias maps instead.
+ assert.equal(restoreClaudeToolName("TodoWrite"), "TodoWrite");
+ assert.equal(restoreClaudeToolName("Read"), "Read");
});
it("restoreClaudeToolName prefers toolNameMap over the static map", () => {
@@ -150,9 +153,7 @@ describe("Claude Code Tool Name Casing Fixes", () => {
choices: [
{
delta: {
- tool_calls: [
- { index: 0, id: "call_123", function: { name: "bash", arguments: "" } },
- ],
+ tool_calls: [{ index: 0, id: "call_123", function: { name: "bash", arguments: "" } }],
},
},
],
diff --git a/tests/unit/cli-combo-create-models-10954.test.ts b/tests/unit/cli-combo-create-models-10954.test.ts
index dc53459d6ed5..52fabf8c1690 100644
--- a/tests/unit/cli-combo-create-models-10954.test.ts
+++ b/tests/unit/cli-combo-create-models-10954.test.ts
@@ -90,7 +90,15 @@ test("combo create — parses --models without throwing (Commander option regist
});
await prog.parseAsync(
- ["node", "x", "combo", "create", "my-combo", "--models", "openai/gpt-4o,anthropic/claude-3-opus"],
+ [
+ "node",
+ "x",
+ "combo",
+ "create",
+ "my-combo",
+ "--models",
+ "openai/gpt-4o,anthropic/claude-3-opus",
+ ],
{ from: "node" }
);
@@ -222,3 +230,26 @@ test("combo create (HTTP) — POST /api/combos body carries the parsed models",
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
});
+
+// Regression for the follow-up of #11011: with --models available, creating
+// an empty combo is no longer a legitimate path on either transport.
+test("combo create without any model is refused before reaching a transport", async () => {
+ await withComboEnv(async () => {
+ const errors: string[] = [];
+ const originalError = console.error;
+ console.error = (msg?: unknown) => {
+ errors.push(String(msg));
+ };
+ try {
+ const mod = await import("../../bin/cli/commands/combo.mjs");
+ const rc = await mod.runComboCreateCommand("guard-test");
+ assert.equal(rc, 1);
+ } finally {
+ console.error = originalError;
+ }
+ assert.ok(
+ errors.some((m) => m.includes("--models")),
+ `stderr should name --models, got: ${errors.join(" | ")}`
+ );
+ });
+});
diff --git a/tests/unit/cline-model-format-11099.test.ts b/tests/unit/cline-model-format-11099.test.ts
new file mode 100644
index 000000000000..3e5a87de43c7
--- /dev/null
+++ b/tests/unit/cline-model-format-11099.test.ts
@@ -0,0 +1,39 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { getModelsByProviderId } from "../../open-sse/config/providerModels.ts";
+import { parseClineRecommendedModels } from "../../open-sse/services/clinepassModels.ts";
+
+test("#11099: Cline provider catalog model IDs use valid modelType/model format", () => {
+ const models = getModelsByProviderId("cline");
+ assert.ok(models.length > 0, "cline provider must expose models");
+
+ for (const model of models) {
+ assert.match(
+ model.id,
+ /^[a-z0-9-]+-?[a-z0-9-]*\/[a-z0-9._:-]+$/i,
+ `Model ID '${model.id}' must follow provider/model format`
+ );
+ assert.notEqual(
+ model.id.split("/")[0],
+ "zai",
+ "Model ID must use 'z-ai' instead of invalid 'zai'"
+ );
+ }
+});
+
+test("#11099: parseClineRecommendedModels correctly extracts recommended/free models", () => {
+ const mockPayload = {
+ recommended: [
+ { id: "moonshotai/kimi-k3", name: "kimi-k3" },
+ { id: "x-ai/grok-4.5", name: "grok-4.5" },
+ ],
+ free: [{ id: "deepseek/deepseek-v4-flash", name: "deepseek-v4-flash" }],
+ };
+
+ const parsed = parseClineRecommendedModels(mockPayload);
+ assert.equal(parsed.length, 3);
+ assert.equal(parsed[0].id, "moonshotai/kimi-k3");
+ assert.equal(parsed[1].id, "x-ai/grok-4.5");
+ assert.equal(parsed[2].id, "deepseek/deepseek-v4-flash");
+});
diff --git a/tests/unit/codex-drop-nonstandard-events.test.ts b/tests/unit/codex-drop-nonstandard-events.test.ts
index d2aff5481498..dec24970ca5a 100644
--- a/tests/unit/codex-drop-nonstandard-events.test.ts
+++ b/tests/unit/codex-drop-nonstandard-events.test.ts
@@ -17,6 +17,19 @@ function sseResponse(body: string): Response {
});
}
+function chunkedSseResponse(chunks: string[]): Response {
+ const encoder = new TextEncoder();
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
+ controller.close();
+ },
+ }),
+ { status: 200, headers: { "content-type": "text/event-stream" } }
+ );
+}
+
async function readAll(res: Response): Promise {
return await res.text();
}
@@ -61,10 +74,10 @@ describe("codexDropNonstandardEvents (#11014)", () => {
describe("filterNonstandardCodexSse (#4715)", () => {
it("drops codex.* event blocks but keeps standard response.* events", async () => {
const stream =
- "event: response.created\ndata: {\"type\":\"response.created\"}\n\n" +
+ 'event: response.created\ndata: {"type":"response.created"}\n\n' +
"event: codex.rate_limits\n\n" +
- "event: response.output_text.delta\ndata: {\"delta\":\"hi\"}\n\n" +
- "event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n";
+ 'event: response.output_text.delta\ndata: {"delta":"hi"}\n\n' +
+ 'event: response.completed\ndata: {"type":"response.completed"}\n\n';
const out = await readAll(filterNonstandardCodexSse(sseResponse(stream)));
assert.ok(!out.includes("codex.rate_limits"), "codex.* frame must be stripped");
assert.ok(out.includes("response.created"), "standard events preserved");
@@ -72,18 +85,31 @@ describe("filterNonstandardCodexSse (#4715)", () => {
assert.ok(out.includes("response.completed"), "terminal event preserved");
});
+ it("filters CRLF-framed events split across transport chunks", async () => {
+ const response = chunkedSseResponse([
+ 'event: response.created\r\ndata: {"type":"response.created"}\r\n\r',
+ "\nevent: codex.rate_limits\r\n\r\n",
+ 'event: response.completed\r\ndata: {"type":"response.completed"}\r\n\r\n',
+ ]);
+
+ const out = await readAll(filterNonstandardCodexSse(response));
+
+ assert.ok(!out.includes("codex.rate_limits"), "codex.* frame must be stripped");
+ assert.ok(out.includes("response.created"), "standard events preserved");
+ assert.ok(out.includes("response.completed"), "terminal event preserved");
+ });
+
it("passes through non-SSE responses untouched", async () => {
- const json = new Response("{\"ok\":true}", {
+ const json = new Response('{"ok":true}', {
status: 200,
headers: { "content-type": "application/json" },
});
const out = filterNonstandardCodexSse(json);
- assert.equal(await out.text(), "{\"ok\":true}");
+ assert.equal(await out.text(), '{"ok":true}');
});
it("drops a trailing codex.* block with no double-newline terminator (flush path)", async () => {
- const stream =
- "event: response.created\ndata: {}\n\n" + "event: codex.token_count\ndata: {}";
+ const stream = "event: response.created\ndata: {}\n\n" + "event: codex.token_count\ndata: {}";
const out = await readAll(filterNonstandardCodexSse(sseResponse(stream)));
assert.ok(out.includes("response.created"));
assert.ok(!out.includes("codex.token_count"));
diff --git a/tests/unit/columns-validation.test.ts b/tests/unit/columns-validation.test.ts
new file mode 100644
index 000000000000..41f70cddeb25
--- /dev/null
+++ b/tests/unit/columns-validation.test.ts
@@ -0,0 +1,24 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ sanitizeRateLimitOverrides,
+ sanitizeQuotaWindowThresholds,
+} from "@/lib/db/providers/columns";
+
+test("sanitizeRateLimitOverrides surfaces rejected keys (blocking, not silent)", () => {
+ const r = sanitizeRateLimitOverrides({ rpm: 10, foo: 1, tpm: -1 });
+ assert.deepEqual(r.sanitized, { rpm: 10 });
+ assert.deepEqual(r.rejected.sort(), ["foo", "tpm"]);
+});
+
+test("sanitizeQuotaWindowThresholds surfaces key-too-long and out-of-range", () => {
+ const r = sanitizeQuotaWindowThresholds({ ["a".repeat(65)]: 50, win: 101 });
+ assert.ok(r.rejected.length >= 1);
+ assert.ok(r.rejected.includes("win"));
+});
+
+test("valid input yields no rejected keys", () => {
+ const r = sanitizeRateLimitOverrides({ rpm: 10, tpm: 20 });
+ assert.deepEqual(r.rejected, []);
+ assert.deepEqual(r.sanitized, { rpm: 10, tpm: 20 });
+});
diff --git a/tests/unit/combo-bracket-names.test.ts b/tests/unit/combo-bracket-names.test.ts
index 32844173cc15..0f4d8af0de92 100644
--- a/tests/unit/combo-bracket-names.test.ts
+++ b/tests/unit/combo-bracket-names.test.ts
@@ -30,6 +30,7 @@ test.after(() => {
test("combo schemas accept names with spaces and square brackets", () => {
const createResult = schemas.createComboSchema.safeParse({
name: "Claude [1m]",
+ models: ["anthropic/claude-3-opus"],
});
const updateResult = schemas.updateComboSchema.safeParse({
name: "Claude [1m]",
diff --git a/tests/unit/combo-context-length.test.ts b/tests/unit/combo-context-length.test.ts
index f47539d64ad2..c416b4d8be38 100644
--- a/tests/unit/combo-context-length.test.ts
+++ b/tests/unit/combo-context-length.test.ts
@@ -46,6 +46,7 @@ test.after(async () => {
test("createComboSchema accepts valid context_length", () => {
const result = schemas.createComboSchema.safeParse({
name: "TestCombo",
+ models: ["openai/gpt-4o-mini"],
context_length: 128000,
});
assert.equal(result.success, true);
@@ -54,6 +55,7 @@ test("createComboSchema accepts valid context_length", () => {
test("createComboSchema rejects context_length below minimum (1000)", () => {
const result = schemas.createComboSchema.safeParse({
name: "TestCombo",
+ models: ["openai/gpt-4o-mini"],
context_length: 999,
});
assert.equal(result.success, false);
@@ -62,6 +64,7 @@ test("createComboSchema rejects context_length below minimum (1000)", () => {
test("createComboSchema rejects context_length above maximum (2000000)", () => {
const result = schemas.createComboSchema.safeParse({
name: "TestCombo",
+ models: ["openai/gpt-4o-mini"],
context_length: 2000001,
});
assert.equal(result.success, false);
@@ -70,12 +73,14 @@ test("createComboSchema rejects context_length above maximum (2000000)", () => {
test("createComboSchema accepts context_length at exact boundaries", () => {
const min = schemas.createComboSchema.safeParse({
name: "MinCombo",
+ models: ["openai/gpt-4o-mini"],
context_length: 1000,
});
assert.equal(min.success, true);
const max = schemas.createComboSchema.safeParse({
name: "MaxCombo",
+ models: ["openai/gpt-4o-mini"],
context_length: 2000000,
});
assert.equal(max.success, true);
@@ -84,6 +89,7 @@ test("createComboSchema accepts context_length at exact boundaries", () => {
test("createComboSchema rejects non-integer context_length", () => {
const result = schemas.createComboSchema.safeParse({
name: "TestCombo",
+ models: ["openai/gpt-4o-mini"],
context_length: 128000.5,
});
assert.equal(result.success, false);
@@ -92,6 +98,7 @@ test("createComboSchema rejects non-integer context_length", () => {
test("createComboSchema accepts omitted context_length", () => {
const result = schemas.createComboSchema.safeParse({
name: "TestCombo",
+ models: ["openai/gpt-4o-mini"],
});
assert.equal(result.success, true);
});
diff --git a/tests/unit/combo-empty-models.test.ts b/tests/unit/combo-empty-models.test.ts
index 1486628ec537..95f32813a34c 100644
--- a/tests/unit/combo-empty-models.test.ts
+++ b/tests/unit/combo-empty-models.test.ts
@@ -24,9 +24,9 @@ test("an update cannot remove every model from a combo", () => {
assert.equal(updateComboSchema.safeParse({ name: "renamed" }).success, true);
});
-test("creating a combo with no model stays allowed — the CLI does it on purpose", () => {
- assert.equal(createComboSchema.safeParse({ name: "drafted", models: [] }).success, true);
- assert.equal(createComboSchema.safeParse({ name: "drafted" }).success, true);
+test("creating a combo without a model is refused at the boundary", () => {
+ assert.equal(createComboSchema.safeParse({ name: "drafted", models: [] }).success, false);
+ assert.equal(createComboSchema.safeParse({ name: "drafted" }).success, false);
});
test("the copilot createCombo tool stores targets where the router looks for them", async () => {
diff --git a/tests/unit/combo-health-autopilot-counter.test.ts b/tests/unit/combo-health-autopilot-counter.test.ts
new file mode 100644
index 000000000000..1bd8330753cd
--- /dev/null
+++ b/tests/unit/combo-health-autopilot-counter.test.ts
@@ -0,0 +1,108 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+
+import type {
+ ComboForecastResponse,
+ ComboHealthResponse,
+ ProviderAutopilotReport,
+} from "../../src/shared/types/utilization.ts";
+import { buildComboHealthAutopilotReport } from "../../src/lib/monitoring/comboHealthAutopilot.ts";
+
+function healthResponse(): ComboHealthResponse {
+ return {
+ timeRange: "24h",
+ combos: [
+ {
+ comboId: "c1",
+ comboName: "my-combo",
+ strategy: "fallback",
+ models: [],
+ targetHealth: [
+ {
+ executionKey: "e1",
+ stepId: "s1",
+ model: "m",
+ provider: "p",
+ connectionId: null,
+ label: null,
+ requests: 5,
+ successRate: 90,
+ avgLatencyMs: 100,
+ lastStatus: "error",
+ lastUsedAt: null,
+ quotaRemainingPct: 50,
+ quotaIsExhausted: false,
+ quotaTrend: "stable",
+ quotaScope: "provider",
+ },
+ ],
+ quotaHealth: { providers: [], worstRemainingPct: 100 },
+ usageSkew: { modelDistribution: [], giniCoefficient: 0 },
+ performance: { avgLatencyMs: 100, successRate: 1.0, totalRequests: 10 },
+ },
+ ],
+ };
+}
+
+function forecastResponse(): ComboForecastResponse {
+ return {
+ timeRange: "24h",
+ horizon: "30d",
+ asOf: new Date(0).toISOString(),
+ method: "linear_history",
+ combos: [],
+ };
+}
+
+function providerHealthResponse(): ProviderAutopilotReport {
+ return { providers: [] } as unknown as ProviderAutopilotReport;
+}
+
+function buildOptions() {
+ return {
+ range: "24h" as const,
+ horizon: "30d" as const,
+ healthResponse: healthResponse(),
+ forecastResponse: forecastResponse(),
+ providerHealthResponse: providerHealthResponse(),
+ };
+}
+
+describe("combo health autopilot counter", () => {
+ it("exposes suggestionCount and keeps actionableCount alias", async () => {
+ const report = await buildComboHealthAutopilotReport(buildOptions());
+ assert.equal(typeof report.summary.suggestionCount, "number");
+ assert.equal(report.summary.actionableCount, report.summary.suggestionCount);
+ const expected = report.combos.reduce(
+ (sum, combo) =>
+ sum + combo.issues.reduce((issueSum, issue) => issueSum + issue.actions.length, 0),
+ 0
+ );
+ assert.equal(report.summary.suggestionCount, expected);
+ });
+
+ it("run_combo_test action links the dashboard with the combo id", async () => {
+ const report = await buildComboHealthAutopilotReport(buildOptions());
+ const actions = report.combos.flatMap((combo) => combo.issues.flatMap((i) => i.actions));
+ const runTest = actions.find((a) => a.type === "run_combo_test");
+ assert.ok(runTest, "run_combo_test action should exist");
+ assert.equal(typeof runTest.href, "string");
+ assert.ok(runTest.href?.includes("c1"), "href must carry the combo id");
+ assert.equal(
+ runTest.href?.includes("/api/combos/test?comboId="),
+ false,
+ "href must not target the GET-only API route (405)"
+ );
+ });
+
+ it("keeps every action in manual mode", async () => {
+ const report = await buildComboHealthAutopilotReport(buildOptions());
+ for (const combo of report.combos) {
+ for (const issue of combo.issues) {
+ for (const action of issue.actions) {
+ assert.equal(action.mode, "manual");
+ }
+ }
+ }
+ });
+});
diff --git a/tests/unit/command-code-executor.test.ts b/tests/unit/command-code-executor.test.ts
index af1d317f5aba..e9b305468d40 100644
--- a/tests/unit/command-code-executor.test.ts
+++ b/tests/unit/command-code-executor.test.ts
@@ -8,20 +8,13 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-command-c
process.env.DATA_DIR = TEST_DATA_DIR;
const { REGISTRY, getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts");
-const { CommandCodeExecutor, COMMAND_CODE_VERSION } =
- await import("../../open-sse/executors/commandCode.ts");
+const { CommandCodeExecutor } = await import("../../open-sse/executors/commandCode.ts");
const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts");
-const { createResponsesApiTransformStream } =
- await import("../../open-sse/transformer/responsesTransformer.ts");
const core = await import("../../src/lib/db/core.ts");
const originalFetch = globalThis.fetch;
-type JsonRecord = Record;
-type ResponsesEvent = {
- event: string;
- data: { response: JsonRecord & { usage?: unknown; output?: JsonRecord[] } };
-};
+type FetchCall = { url: string; init: Record; body?: Record };
const PINNED_COMMAND_CODE_MODELS = [
"claude-opus-4-7",
@@ -44,20 +37,7 @@ const PINNED_COMMAND_CODE_MODELS = [
"Qwen/Qwen3.6-Plus",
];
-function commandCodeStream(lines: unknown[], { sse = false } = {}) {
- const text = lines
- .map((line) => {
- const json = JSON.stringify(line);
- return sse ? `data: ${json}\n\n` : `${json}\n`;
- })
- .join("");
- return new Response(text, { status: 200, headers: { "Content-Type": "application/x-ndjson" } });
-}
-
-function toPlainHeaders(headers: Headers | Record) {
- if (headers instanceof Headers) return Object.fromEntries(headers.entries());
- return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, String(value)]));
-}
+const CHAT_URL = "https://api.commandcode.ai/provider/v1/chat/completions";
function parseSsePayloads(sse: string) {
return sse
@@ -68,25 +48,21 @@ function parseSsePayloads(sse: string) {
.map((line) => JSON.parse(line));
}
-async function responsesFromChatSse(sse: string): Promise {
- const input = new ReadableStream({
- start(controller) {
- controller.enqueue(new TextEncoder().encode(sse));
- controller.close();
- },
- });
- const transformed = await new Response(
- input.pipeThrough(createResponsesApiTransformStream(null, 60_000))
- ).text();
-
- return transformed
- .split("\n\n")
- .map((part) => {
- const event = part.match(/^event:\s*(.+)$/m)?.[1];
- const data = part.match(/^data:\s*(.+)$/m)?.[1];
- return event && data ? ({ event, data: JSON.parse(data) } as ResponsesEvent) : null;
- })
- .filter((entry): entry is ResponsesEvent => entry !== null);
+function openAiSse(obj: unknown): string {
+ return `data: ${JSON.stringify(obj)}\n\n`;
+}
+
+function captureFetch(body: Record) {
+ const calls: FetchCall[] = [];
+ globalThis.fetch = async (url, init = {}) => {
+ calls.push({
+ url: String(url),
+ init,
+ body: JSON.parse(String(init.body)),
+ });
+ return new Response(JSON.stringify(body), { status: 200 });
+ };
+ return calls;
}
test.afterEach(() => {
@@ -105,7 +81,9 @@ test("Command Code provider catalog has pinned models and alias lookup", () => {
assert.equal(entry.alias, "cmd");
assert.equal(entry.executor, "command-code");
assert.equal(entry.baseUrl, "https://api.commandcode.ai");
- assert.equal(entry.chatPath, "/alpha/generate");
+ // Chat targets the documented /provider/v1/chat/completions endpoint, NOT the
+ // CLI-only /alpha/generate endpoint (#10265).
+ assert.equal(entry.chatPath, "/provider/v1/chat/completions");
assert.deepEqual(
entry.models.map((model) => model.id),
PINNED_COMMAND_CODE_MODELS
@@ -119,17 +97,10 @@ test("getExecutor returns the specialized Command Code executor", () => {
assert.ok(getExecutor("cmd") instanceof CommandCodeExecutor);
});
-type FetchCall = { url: string; init: Record; body?: unknown };
-
-test("Command Code executor posts wrapped body and required headers to /alpha/generate", async () => {
- const calls: FetchCall[] = [];
- globalThis.fetch = async (url, init = {}) => {
- calls.push({ url: String(url), init });
- return commandCodeStream([{ type: "text-delta", text: "hello" }, { type: "finish" }]);
- };
-
+test("Command Code executor posts a flat OpenAI body + standard headers to /provider/v1/chat/completions (#10265)", async () => {
+ const calls = captureFetch({});
const executor = getExecutor("command-code");
- const { response, url, headers, transformedBody } = await executor.execute({
+ const { response, url, headers } = await executor.execute({
model: "gpt-5.4-mini",
stream: false,
credentials: { apiKey: "cc_test_key" },
@@ -144,41 +115,34 @@ test("Command Code executor posts wrapped body and required headers to /alpha/ge
},
});
- assert.equal(url, "https://api.commandcode.ai/alpha/generate");
+ assert.equal(url, CHAT_URL);
assert.equal(calls.length, 1);
- assert.equal(calls[0].url, "https://api.commandcode.ai/alpha/generate");
+ assert.equal(calls[0].url, CHAT_URL);
assert.equal(calls[0].init.method, "POST");
assert.equal(headers.Authorization, "Bearer cc_test_key");
- assert.equal(headers["x-command-code-version"], COMMAND_CODE_VERSION);
- assert.equal(headers["x-cli-environment"], "external");
- assert.equal(headers["x-project-slug"], "pi-cc");
- assert.equal(headers["x-taste-learning"], "false");
- assert.equal(headers["x-co-flag"], "false");
- assert.equal(typeof headers["x-session-id"], "string");
-
- const posted = JSON.parse(String(calls[0].init.body));
- assert.deepEqual(posted, transformedBody);
- for (const key of ["config", "memory", "taste", "skills", "permissionMode", "params"]) {
- assert.ok(key in posted, `missing ${key}`);
- }
- assert.equal(posted.skills, "");
- assert.equal(posted.params.model, "gpt-5.4-mini");
- assert.equal(posted.params.stream, true);
- assert.equal(posted.params.system, "You are concise.");
- assert.equal(posted.params.messages[0].role, "user");
- assert.equal(posted.params.tools[0].name, "lookup");
-
+ // No CLI-impersonation headers.
+ assert.equal(headers["x-command-code-version"], undefined);
+ assert.equal(headers["x-cli-environment"], undefined);
+ assert.equal(headers["x-project-slug"], undefined);
+
+ const posted = calls[0].body as Record;
+ // No CLI envelope.
+ assert.equal(posted.config, undefined, "CLI envelope config must not be sent");
+ assert.equal(posted.params, undefined, "CLI envelope params wrapper must not be sent");
+ assert.equal(posted.model, "gpt-5.4-mini");
+ assert.equal(posted.stream, false);
+ assert.equal((posted.messages as Array<{ role: string }>)[0].role, "system");
+ const tool = (posted.tools as Array<{ function: { name: string } }>)[0];
+ assert.equal(tool.function.name, "lookup", "tools in OpenAI shape (function.name)");
+ assert.equal(posted.max_tokens, 42);
+
+ // The upstream OpenAI JSON passes through untouched.
const json = await response.json();
- assert.equal(json.choices[0].message.content, "hello");
+ assert.deepEqual(json, {});
});
-test("Command Code executor passes reasoning/thinking fields through to params (#2986 follow-up)", async () => {
- const calls: FetchCall[] = [];
- globalThis.fetch = async (url, init = {}) => {
- calls.push({ url: String(url), init });
- return commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]);
- };
-
+test("Command Code executor passes reasoning/thinking fields through at the top level of the OpenAI body", async () => {
+ const calls = captureFetch({});
await getExecutor("command-code").execute({
model: "deepseek/deepseek-v4-pro",
stream: false,
@@ -189,30 +153,19 @@ test("Command Code executor passes reasoning/thinking fields through to params (
reasoning_effort: "high",
thinking: { type: "enabled" },
effort: "high",
- output_config: { effort: "high" },
extra_body: { enable_thinking: true },
},
});
- const posted = JSON.parse(String(calls[0].init.body));
- assert.equal(posted.params.reasoning_effort, "high");
- assert.deepEqual(posted.params.thinking, { type: "enabled" });
- assert.equal(posted.params.effort, "high");
- assert.deepEqual(posted.params.output_config, { effort: "high" });
- assert.deepEqual(posted.params.extra_body, { enable_thinking: true });
+ const posted = calls[0].body as Record;
+ assert.equal(posted.reasoning_effort, "high");
+ assert.deepEqual(posted.thinking, { type: "enabled" });
+ assert.equal(posted.effort, "high");
+ assert.deepEqual(posted.extra_body, { enable_thinking: true });
});
test("Command Code executor honors body.model rewrite from payload rules", async () => {
- const calls: FetchCall[] = [];
- globalThis.fetch = async (url, init = {}) => {
- calls.push({ url: String(url), init });
- return commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]);
- };
-
- // Simulate a payload-rule rewrite: combo resolves to "deepseek-v4-pro-max"
- // (passed as the execute() model arg), but the payload rule overwrites
- // body.model to "deepseek/deepseek-v4-pro" (the vendor-prefixed form
- // Command Code's API expects).
+ const calls = captureFetch({});
await getExecutor("command-code").execute({
model: "deepseek-v4-pro-max",
stream: false,
@@ -225,20 +178,13 @@ test("Command Code executor honors body.model rewrite from payload rules", async
},
});
- const posted = JSON.parse(String(calls[0].init.body));
- assert.equal(posted.params.model, "deepseek/deepseek-v4-pro");
- assert.equal(posted.params.reasoning_effort, "max");
+ const posted = calls[0].body as Record;
+ assert.equal(posted.model, "deepseek/deepseek-v4-pro");
+ assert.equal(posted.reasoning_effort, "max");
});
test("Command Code executor maps unsupported minimal reasoning_effort to low (upstream 400 regression)", async () => {
- const calls: FetchCall[] = [];
- globalThis.fetch = async (url, init = {}) => {
- calls.push({ url: String(url), init });
- return commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]);
- };
-
- // Live upstream rejection: "Validation error: Invalid option: expected one of
- // \"low\"|\"medium\"|\"high\"|\"xhigh\"|\"max\" at \"params.reasoning_effort\"" —
+ const calls = captureFetch({});
// `minimal` (a Muse Spark catalog tier) must be downgraded to `low` before
// the wire body is built, on BOTH the combo and single-model paths.
await getExecutor("command-code").execute({
@@ -252,20 +198,38 @@ test("Command Code executor maps unsupported minimal reasoning_effort to low (up
},
});
- const posted = JSON.parse(String(calls[0].init.body));
- assert.equal(posted.params.reasoning_effort, "low", "minimal must map to low");
+ const posted = calls[0].body as Record;
+ assert.equal(posted.reasoning_effort, "low", "minimal must map to low");
});
-test("Command Code raw NDJSON stream becomes OpenAI chat SSE chunks", async () => {
- const calls: FetchCall[] = [];
+test("Command Code executor passes the upstream OpenAI SSE stream through untouched", async () => {
+ const sse =
+ openAiSse({
+ id: "c1",
+ object: "chat.completion.chunk",
+ model: "gpt-5.4",
+ choices: [{ index: 0, delta: { role: "assistant" } }],
+ }) +
+ openAiSse({
+ id: "c1",
+ object: "chat.completion.chunk",
+ model: "gpt-5.4",
+ choices: [{ index: 0, delta: { content: "Hello" } }],
+ }) +
+ openAiSse({
+ id: "c1",
+ object: "chat.completion.chunk",
+ model: "gpt-5.4",
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
+ }) +
+ "data: [DONE]\n\n";
+ let capturedStreamFlag: unknown = null;
globalThis.fetch = async (url, init = {}) => {
- calls.push({ url: String(url), init, body: JSON.parse(String(init.body)) });
- return commandCodeStream([
- { type: "text-delta", text: "Hello" },
- { type: "reasoning-delta", text: "thinking" },
- { type: "tool-call", toolCallId: "call_1", toolName: "search", input: { q: "docs" } },
- { type: "finish", finishReason: "tool-calls" },
- ]);
+ capturedStreamFlag = JSON.parse(String(init.body)).stream;
+ return new Response(sse, {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ });
};
const { response } = await getExecutor("command-code").execute({
@@ -275,39 +239,32 @@ test("Command Code raw NDJSON stream becomes OpenAI chat SSE chunks", async () =
body: { messages: [{ role: "user", content: "Hi" }] },
});
- assert.equal(calls[0].body.params.stream, true);
- assert.equal(response.headers.get("Content-Type"), "text/event-stream; charset=utf-8");
- const sse = await response.text();
- assert.match(sse, /data: \[DONE\]/);
- const chunks = parseSsePayloads(sse);
- assert.equal(chunks[0].object, "chat.completion.chunk");
- assert.deepEqual(chunks[0].choices[0].delta, { role: "assistant" });
+ assert.equal(capturedStreamFlag, true, "stream flag forwarded to upstream");
+ const text = await response.text();
+ assert.equal(text, sse, "OpenAI SSE stream passed through byte-for-byte");
+ assert.ok(text.includes("data: [DONE]"));
+ const chunks = parseSsePayloads(text);
+ assert.equal(chunks[0].choices[0].delta.role, "assistant");
assert.equal(chunks[1].choices[0].delta.content, "Hello");
- assert.equal(chunks[2].choices[0].delta.reasoning_content, "thinking");
- assert.equal(chunks[3].choices[0].delta.tool_calls[0].function.name, "search");
- assert.equal(chunks.at(-1).choices[0].finish_reason, "tool_calls");
+ assert.equal(chunks[2].choices[0].finish_reason, "stop");
});
-test("Command Code data: SSE lines aggregate into non-stream ChatCompletion JSON", async () => {
- globalThis.fetch = async () =>
- commandCodeStream(
- [
- { type: "text-delta", text: "Hel" },
- { type: "text-delta", text: "lo" },
- { type: "reasoning-delta", text: "because" },
- { type: "tool-call", id: "call_2", name: "lookup", arguments: { id: 7 } },
- {
- type: "finish",
- finishReason: "max_tokens",
- totalUsage: {
- inputTokens: 3,
- inputTokenDetails: { cacheReadTokens: 2 },
- outputTokens: 5,
- },
- },
- ],
- { sse: true }
- );
+test("Command Code executor passes the upstream OpenAI JSON through untouched (non-stream)", async () => {
+ const upstreamJson = {
+ id: "chatcmpl-1",
+ object: "chat.completion",
+ model: "gpt-5.4-mini",
+ choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
+ usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
+ };
+ let capturedStreamFlag: unknown = null;
+ globalThis.fetch = async (url, init = {}) => {
+ capturedStreamFlag = JSON.parse(String(init.body)).stream;
+ return new Response(JSON.stringify(upstreamJson), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
const { response } = await getExecutor("command-code").execute({
model: "gpt-5.4-mini",
@@ -316,26 +273,12 @@ test("Command Code data: SSE lines aggregate into non-stream ChatCompletion JSON
body: { messages: [{ role: "user", content: "Hi" }] },
});
- assert.equal(response.headers.get("Content-Type"), "application/json");
- const json = await response.json();
- assert.equal(json.object, "chat.completion");
- assert.equal(json.choices[0].message.content, "Hello");
- assert.equal(json.choices[0].message.reasoning_content, "because");
- assert.equal(json.choices[0].message.tool_calls[0].function.arguments, JSON.stringify({ id: 7 }));
- assert.equal(json.choices[0].finish_reason, "length");
- assert.deepEqual(json.usage, {
- prompt_tokens: 3,
- prompt_tokens_details: { cached_tokens: 2 },
- completion_tokens: 5,
- completion_tokens_details: { reasoning_tokens: 0 },
- total_tokens: 8,
- cache_read_input_tokens: 2,
- });
+ assert.equal(capturedStreamFlag, false, "stream flag forwarded as false for non-stream");
+ assert.deepEqual(await response.json(), upstreamJson);
});
-test("Command Code executor surfaces upstream and streamed errors", async () => {
- globalThis.fetch = async () =>
- new Response("bad key", { status: 401, statusText: "Unauthorized" });
+test("Command Code executor surfaces upstream errors", async () => {
+ globalThis.fetch = async () => new Response("bad key", { status: 401, statusText: "Unauthorized" });
const upstreamFailure = await getExecutor("command-code").execute({
model: "gpt-5.4-mini",
stream: false,
@@ -344,360 +287,68 @@ test("Command Code executor surfaces upstream and streamed errors", async () =>
});
assert.equal(upstreamFailure.response.status, 401);
assert.equal(await upstreamFailure.response.text(), "bad key");
-
- globalThis.fetch = async () => commandCodeStream([{ type: "error", error: { message: "boom" } }]);
- await assert.rejects(async () => {
- await getExecutor("command-code").execute({
- model: "gpt-5.4-mini",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: { messages: [{ role: "user", content: "Hi" }] },
- });
- }, /boom/);
});
-test("Command Code executor omits max_tokens when the client does not supply one (GLM-5.x)", async () => {
- const calls: FetchCall[] = [];
- globalThis.fetch = async (url, init = {}) => {
- calls.push({ url: String(url), init, body: JSON.parse(String(init.body)) });
- return commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]);
- };
-
- // No client max_tokens: we must NOT fabricate one. Omitting the field lets
- // Command Code's upstream apply the model's own native default.
+test("Command Code executor omits max_tokens when the client does not supply one", async () => {
+ const calls = captureFetch({});
await getExecutor("command-code").execute({
model: "zai-org/GLM-5.1",
stream: false,
credentials: { apiKey: "cc_test_key" },
body: { messages: [{ role: "user", content: "Hi" }] },
});
- assert.ok(!("max_tokens" in calls[0].body.params));
-});
-
-test("Command Code executor omits max_tokens for DeepSeek v4 when the client does not supply one", async () => {
- const calls: FetchCall[] = [];
- globalThis.fetch = async (url, init = {}) => {
- calls.push({ url: String(url), init, body: JSON.parse(String(init.body)) });
- return commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]);
- };
-
- // Regression: previously the executor invented max_tokens from the registry
- // (384000), which /alpha/generate rejects with a 400
- // "Too big: expected number to be <=200000". With no client value we now omit
- // the field entirely, so the request succeeds and upstream picks the default.
- await getExecutor("command-code").execute({
- model: "deepseek/deepseek-v4-pro",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: { messages: [{ role: "user", content: "Hi" }] },
- });
- assert.ok(!("max_tokens" in calls[0].body.params));
+ const posted = calls[0].body as Record;
+ assert.ok(!("max_tokens" in posted), "must not fabricate max_tokens");
+ assert.ok(!("max_completion_tokens" in posted), "must not fabricate max_completion_tokens");
});
test("Command Code executor clamps an oversized client-supplied max_tokens to the endpoint ceiling", async () => {
- const calls: FetchCall[] = [];
- globalThis.fetch = async (url, init = {}) => {
- calls.push({ url: String(url), init, body: JSON.parse(String(init.body)) });
- return commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]);
- };
-
- // A client asking for more than the 200000 endpoint ceiling is clamped down
- // (not 400'd), mirroring the provider-driven clamp in antigravity.ts.
+ const calls = captureFetch({});
+ // A client asking for more than the 200000 endpoint ceiling is clamped down.
await getExecutor("command-code").execute({
model: "deepseek/deepseek-v4-pro",
stream: false,
credentials: { apiKey: "cc_test_key" },
body: { messages: [{ role: "user", content: "Hi" }], max_tokens: 500000 },
});
- assert.equal(calls[0].body.params.max_tokens, 200000);
+ assert.equal((calls[0].body as Record).max_tokens, 200000);
});
-test("Command Code executor honors a smaller client-provided max_tokens under the per-model cap", async () => {
- const calls: FetchCall[] = [];
- globalThis.fetch = async (url, init = {}) => {
- calls.push({ url: String(url), init, body: JSON.parse(String(init.body)) });
- return commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]);
- };
-
+test("Command Code executor honors a smaller client-provided max_tokens", async () => {
+ const calls = captureFetch({});
await getExecutor("command-code").execute({
model: "zai-org/GLM-5.1",
stream: false,
credentials: { apiKey: "cc_test_key" },
body: { messages: [{ role: "user", content: "Hi" }], max_tokens: 2048 },
});
- assert.equal(calls[0].body.params.max_tokens, 2048);
+ assert.equal((calls[0].body as Record).max_tokens, 2048);
});
-test("Command Code non-stream aggregation throws when the final error event lacks a trailing newline", async () => {
- globalThis.fetch = async () =>
- new Response(
- `${JSON.stringify({ type: "text-delta", text: "Hello" })}\n${JSON.stringify({
- type: "error",
- error: { message: "boom" },
- })}`,
- { status: 200, headers: { "Content-Type": "application/x-ndjson" } }
- );
-
- await assert.rejects(async () => {
- await getExecutor("command-code").execute({
+test("Command Code stream preserves the upstream OpenAI usage chunk (passthrough)", async () => {
+ const sse =
+ openAiSse({
+ id: "c1",
+ object: "chat.completion.chunk",
model: "gpt-5.4-mini",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: { messages: [{ role: "user", content: "Hi" }] },
- });
- }, /boom/);
-});
-
-test("Command Code usage chunk surfaces cache_read and no_cache for the stream pipeline", async () => {
- globalThis.fetch = async () =>
- commandCodeStream([
- { type: "text-delta", text: "Hi" },
- {
- type: "finish",
- finishReason: "stop",
- totalUsage: {
- inputTokens: 10,
- inputTokenDetails: { noCacheTokens: 6, cacheReadTokens: 4 },
- outputTokens: 6,
- },
- },
- ]);
-
- const { response } = await getExecutor("command-code").execute({
- model: "gpt-5.4-mini",
- stream: true,
- credentials: { apiKey: "cc_test_key" },
- body: { messages: [{ role: "user", content: "Hi" }] },
- });
-
- const sse = await response.text();
- const chunks = parseSsePayloads(sse);
- const usageChunk = chunks.find(
- (chunk) => Array.isArray(chunk.choices) && chunk.choices.length === 0
- );
- assert.ok(usageChunk, "expected a usage-only chunk (choices: []) in the stream");
-
- // The usage-only chunk feeds stream.ts's extractUsage, which surfaces
- // cache_read_input_tokens / no_cache_tokens into the [USAGE] line.
- const { extractUsage } = await import("../../open-sse/utils/usageTracking.ts");
- const extracted = extractUsage(usageChunk);
- assert.ok(extracted, "extractUsage should recognize the usage-only chunk");
- assert.equal(extracted.prompt_tokens, 10);
- assert.equal(extracted.completion_tokens, 6);
- assert.equal(extracted.cache_read_input_tokens, 4);
- assert.equal(extracted.no_cache_tokens, 6);
-});
-
-test("Command Code stream emits a usage-only chunk with actual tokens before [DONE]", async () => {
- globalThis.fetch = async () =>
- commandCodeStream([
- { type: "text-delta", text: "Hi" },
- {
- type: "finish",
- finishReason: "stop",
- totalUsage: {
- inputTokens: 10,
- inputTokenDetails: { cacheReadTokens: 4, cacheCreationTokens: 2 },
- outputTokens: 6,
- reasoningTokenDetails: { reasoningTokens: 1 },
- },
- },
- ]);
-
- const { response } = await getExecutor("command-code").execute({
- model: "gpt-5.4-mini",
- stream: true,
- credentials: { apiKey: "cc_test_key" },
- body: { messages: [{ role: "user", content: "Hi" }] },
- });
-
- const sse = await response.text();
- const chunks = parseSsePayloads(sse);
-
- // Find the usage-only chunk: choices must be [] and usage must carry the
- // actual upstream numbers. prompt_tokens = inputTokens (10) — cacheRead 4 is
- // already included in that 10, so it is reported separately, NOT re-added.
- const usageChunk = chunks.find(
- (chunk) => Array.isArray(chunk.choices) && chunk.choices.length === 0
- );
- assert.ok(usageChunk, "expected a usage-only chunk (choices: []) in the stream");
- assert.deepEqual(usageChunk.usage, {
- prompt_tokens: 10,
- prompt_tokens_details: { cached_tokens: 4 },
- completion_tokens: 6,
- completion_tokens_details: { reasoning_tokens: 1 },
- total_tokens: 16,
- cache_read_input_tokens: 4,
- reasoning_tokens: 1,
- });
- // The usage chunk must come before the [DONE] marker.
- assert.match(sse, /"usage":/);
- const doneIndex = sse.indexOf("data: [DONE]");
- const usageIndex = sse.indexOf(`"choices":[]`);
- assert.ok(usageIndex > -1 && usageIndex < doneIndex, "usage chunk must precede [DONE]");
-});
-
-test("Command Code non-stream usage keeps inputTokens as prompt_tokens and reports cache separately", async () => {
- globalThis.fetch = async () =>
- commandCodeStream(
- [
- { type: "text-delta", text: "ok" },
- {
- type: "finish",
- finishReason: "stop",
- totalUsage: {
- inputTokens: 5,
- inputTokenDetails: { noCacheTokens: 2, cacheReadTokens: 3 },
- outputTokens: 2,
- },
- },
- ],
- { sse: true }
- );
-
- const { response } = await getExecutor("command-code").execute({
- model: "gpt-5.4-mini",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: { messages: [{ role: "user", content: "Hi" }] },
- });
-
- const json = await response.json();
- assert.deepEqual(json.usage, {
- prompt_tokens: 5,
- prompt_tokens_details: { cached_tokens: 3 },
- completion_tokens: 2,
- completion_tokens_details: { reasoning_tokens: 0 },
- total_tokens: 7,
- cache_read_input_tokens: 3,
- no_cache_tokens: 2,
- });
-});
-
-test("Command Code preserves finish-step usage through a finish without totalUsage", async () => {
- globalThis.fetch = async () =>
- commandCodeStream([
- { type: "text-delta", text: "Hi" },
- {
- type: "finish-step",
- usage: {
- inputTokens: 7308,
- inputTokenDetails: { noCacheTokens: 27, cacheReadTokens: 7281 },
- outputTokens: 177,
- outputTokenDetails: { textTokens: 12, reasoningTokens: 165 },
- totalTokens: 7485,
- },
- },
- { type: "finish", finishReason: "stop", totalUsage: null },
- ]);
-
- const { response } = await getExecutor("command-code").execute({
- model: "gpt-5.4-mini",
- stream: true,
- credentials: { apiKey: "cc_test_key" },
- body: { messages: [{ role: "user", content: "Hi" }] },
- });
- const sse = await response.text();
- const chunks = parseSsePayloads(sse);
- const usageChunk = chunks.find(
- (chunk) => Array.isArray(chunk.choices) && chunk.choices.length === 0
- );
-
- assert.deepEqual(usageChunk?.usage, {
- prompt_tokens: 7308,
- prompt_tokens_details: { cached_tokens: 7281 },
- completion_tokens: 177,
- completion_tokens_details: { reasoning_tokens: 165 },
- total_tokens: 7485,
- cache_read_input_tokens: 7281,
- no_cache_tokens: 27,
- reasoning_tokens: 165,
- });
-
- const completed = (await responsesFromChatSse(sse)).find(
- (event) => event.event === "response.completed"
- );
- assert.deepEqual(completed?.data.response.usage, {
- input_tokens: 7308,
- input_tokens_details: { cached_tokens: 7281 },
- output_tokens: 177,
- output_tokens_details: { reasoning_tokens: 165 },
- total_tokens: 7485,
- });
-});
-
-test("Command Code accepts OpenAI-style usage aliases with absent optional details", async () => {
- globalThis.fetch = async () =>
- commandCodeStream([
- {
- type: "finish-step",
- usage: {
- prompt_tokens: 11,
- prompt_tokens_details: { cached_tokens: 4 },
- completion_tokens: 5,
- completion_tokens_details: { reasoning_tokens: 2 },
- total_tokens: 16,
- },
+ choices: [{ index: 0, delta: { content: "Hi" } }],
+ }) +
+ openAiSse({
+ id: "c1",
+ object: "chat.completion.chunk",
+ model: "gpt-5.4-mini",
+ choices: [],
+ usage: {
+ prompt_tokens: 10,
+ prompt_tokens_details: { cached_tokens: 4 },
+ completion_tokens: 6,
+ completion_tokens_details: { reasoning_tokens: 1 },
+ total_tokens: 16,
},
- { type: "finish", finishReason: "stop" },
- ]);
-
- const { response } = await getExecutor("command-code").execute({
- model: "gpt-5.4-mini",
- stream: true,
- credentials: { apiKey: "cc_test_key" },
- body: { messages: [{ role: "user", content: "Hi" }] },
- });
- const sse = await response.text();
- const usageChunk = parseSsePayloads(sse).find(
- (chunk) => Array.isArray(chunk.choices) && chunk.choices.length === 0
- );
- assert.deepEqual(usageChunk?.usage, {
- prompt_tokens: 11,
- prompt_tokens_details: { cached_tokens: 4 },
- completion_tokens: 5,
- completion_tokens_details: { reasoning_tokens: 2 },
- total_tokens: 16,
- cache_read_input_tokens: 4,
- reasoning_tokens: 2,
- });
-
- globalThis.fetch = async () =>
- commandCodeStream([
- { type: "finish-step", usage: { inputTokens: 4, outputTokens: 3, totalTokens: 7 } },
- { type: "finish", finishReason: "stop", totalUsage: null },
- ]);
- const fallback = await getExecutor("command-code").execute({
- model: "gpt-5.4-mini",
- stream: true,
- credentials: { apiKey: "cc_test_key" },
- body: { messages: [{ role: "user", content: "Hi" }] },
- });
- const fallbackSse = await fallback.response.text();
- const completed = (await responsesFromChatSse(fallbackSse)).find(
- (event) => event.event === "response.completed"
- );
- assert.deepEqual(completed?.data.response.usage, {
- input_tokens: 4,
- input_tokens_details: { cached_tokens: 0 },
- output_tokens: 3,
- output_tokens_details: { reasoning_tokens: 0 },
- total_tokens: 7,
- });
-});
-
-test("Command Code preserves tool-call streaming while finalizing finish-step usage", async () => {
+ }) +
+ "data: [DONE]\n\n";
globalThis.fetch = async () =>
- commandCodeStream([
- {
- type: "tool-call",
- toolCallId: "call_1",
- toolName: "lookup",
- input: { query: "hello" },
- },
- { type: "finish-step", usage: { inputTokens: 3, outputTokens: 2, totalTokens: 5 } },
- { type: "finish", finishReason: "tool-calls" },
- ]);
+ new Response(sse, { status: 200, headers: { "Content-Type": "text/event-stream" } });
const { response } = await getExecutor("command-code").execute({
model: "gpt-5.4-mini",
@@ -705,25 +356,12 @@ test("Command Code preserves tool-call streaming while finalizing finish-step us
credentials: { apiKey: "cc_test_key" },
body: { messages: [{ role: "user", content: "Hi" }] },
});
- const sse = await response.text();
- assert.ok(
- parseSsePayloads(sse).some(
- (chunk) => chunk.choices?.[0]?.delta?.tool_calls?.[0]?.id === "call_1"
- )
- );
- const completed = (await responsesFromChatSse(sse)).find(
- (event) => event.event === "response.completed"
- );
- assert.equal(
- completed?.data.response.output?.some((item) => item.type === "function_call"),
- true
- );
- assert.deepEqual(completed?.data.response.usage, {
- input_tokens: 3,
- input_tokens_details: { cached_tokens: 0 },
- output_tokens: 2,
- output_tokens_details: { reasoning_tokens: 0 },
- total_tokens: 5,
- });
-});
+ const text = await response.text();
+ // The upstream OpenAI usage chunk passes through unchanged, including the
+ // standard OpenAI usage shape the stream pipeline already understands.
+ assert.ok(text.includes('"prompt_tokens":10'));
+ assert.ok(text.includes('"cached_tokens":4'));
+ assert.ok(text.includes('"reasoning_tokens":1'));
+ assert.ok(text.includes("data: [DONE]"));
+});
\ No newline at end of file
diff --git a/tests/unit/command-code-maxtokens-negative-5166.test.ts b/tests/unit/command-code-maxtokens-negative-5166.test.ts
index e155aa72f05a..2e30975b998f 100644
--- a/tests/unit/command-code-maxtokens-negative-5166.test.ts
+++ b/tests/unit/command-code-maxtokens-negative-5166.test.ts
@@ -34,7 +34,7 @@ test.after(() => {
core.resetDbInstance();
});
-async function captureParams(body: Record): Promise {
+async function captureBody(body: Record): Promise {
const calls: FetchCall[] = [];
globalThis.fetch = async (url: any, init: any = {}) => {
calls.push({ url: String(url), init, body: JSON.parse(String(init.body)) });
@@ -50,27 +50,27 @@ async function captureParams(body: Record): Promise
}
test("Command Code omits max_tokens when the client sends max_tokens: -1 (#5166)", async () => {
- const call = await captureParams({ max_tokens: -1 });
+ const call = await captureBody({ max_tokens: -1 });
assert.ok(
- !("max_tokens" in call.body.params),
- `max_tokens:-1 must be omitted, got params.max_tokens=${call.body.params.max_tokens}`
+ !("max_tokens" in call.body),
+ `max_tokens:-1 must be omitted, got max_tokens=${call.body.max_tokens}`
);
});
test("Command Code omits max_tokens when the client sends max_completion_tokens: -1 (#5166)", async () => {
- const call = await captureParams({ max_completion_tokens: -1 });
+ const call = await captureBody({ max_completion_tokens: -1 });
assert.ok(
- !("max_tokens" in call.body.params),
- `max_completion_tokens:-1 must be omitted, got params.max_tokens=${call.body.params.max_tokens}`
+ !("max_tokens" in call.body),
+ `max_completion_tokens:-1 must be omitted, got max_tokens=${call.body.max_tokens}`
);
});
test("Command Code omits max_tokens when the client sends 0 (#5166)", async () => {
- const call = await captureParams({ max_tokens: 0 });
- assert.ok(!("max_tokens" in call.body.params), "max_tokens:0 must be omitted");
+ const call = await captureBody({ max_tokens: 0 });
+ assert.ok(!("max_tokens" in call.body), "max_tokens:0 must be omitted");
});
test("Command Code still honors a positive client max_tokens after the #5166 fix", async () => {
- const call = await captureParams({ max_tokens: 2048 });
- assert.equal(call.body.params.max_tokens, 2048);
-});
+ const call = await captureBody({ max_tokens: 2048 });
+ assert.equal(call.body.max_tokens, 2048);
+});
\ No newline at end of file
diff --git a/tests/unit/command-code-user-array-5166.test.ts b/tests/unit/command-code-user-array-5166.test.ts
index 01d20a15ff94..a692012ed06b 100644
--- a/tests/unit/command-code-user-array-5166.test.ts
+++ b/tests/unit/command-code-user-array-5166.test.ts
@@ -1,14 +1,13 @@
/**
- * Regression test for #5166 (user-content-array 400 on Command Code / deepseek-v4-pro).
+ * #5166 (user-content-array 400 on Command Code / deepseek-v4-pro) context.
*
- * When a client sends a user message whose `content` is an array of content parts
- * (e.g. [{type:"text",text:"Hello"},{type:"text",text:"World"}]), the raw array
- * must NOT reach the Command Code upstream — it requires user content to be a plain
- * string. The executor must normalise the array to a string before posting.
- *
- * NOTE: this file covers ONLY the user-content-array/400 symptom of #5166.
- * The 0-output-token symptom on mimo-v2.5-pro (reasoning-only models) is tracked
- * separately and is NOT addressed here.
+ * The original regression was that a user message whose `content` was an array of
+ * content parts reached the CLI-only /alpha/generate endpoint, which required
+ * user content to be a plain string. Since #10265 the executor posts to the
+ * documented /provider/v1/chat/completions endpoint, which natively speaks the
+ * OpenAI chat.completions format — array content (text + image_url parts) is
+ * valid there and passes through unchanged. These tests pin that OpenAI-shaped
+ * passthrough.
*/
import test from "node:test";
import assert from "node:assert/strict";
@@ -26,9 +25,8 @@ const core = await import("../../src/lib/db/core.ts");
const originalFetch = globalThis.fetch;
-function commandCodeStream(lines: unknown[]) {
- const text = lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
- return new Response(text, { status: 200, headers: { "Content-Type": "application/x-ndjson" } });
+function okResponse() {
+ return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
}
test.after(() => {
@@ -41,155 +39,100 @@ test.afterEach(() => {
globalThis.fetch = originalFetch;
});
-// ── helpers ────────────────────────────────────────────────────────────────────
+// ── helpers ────────────────────────────────────────────────────────────
type FetchCall = { url: string; init: Record; body: Record };
function captureFetch(response: Response) {
const calls: FetchCall[] = [];
globalThis.fetch = async (url, init: RequestInit = {}) => {
- calls.push({ url: String(url), init: init as Record, body: JSON.parse(String(init.body)) });
+ calls.push({
+ url: String(url),
+ init: init as Record,
+ body: JSON.parse(String(init.body)),
+ });
return response;
};
return calls;
}
-// ── failing tests (before fix, user content is the raw array) ──────────────
-
-test(
- "#5166 user message with multi-part array content is flattened to a string (#5166)",
- async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
- await getExecutor("command-code").execute({
- model: "deepseek/deepseek-v4-pro",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [
- { type: "text", text: "Hello" },
- { type: "text", text: "World" },
- ],
- },
- ],
- },
- });
-
- const posted = calls[0].body;
- const userMsg = (posted.params as Record).messages[0] as Record<
- string,
- unknown
- >;
-
- // Must be a string — never an array — otherwise Command Code's upstream returns 400.
- assert.equal(
- typeof userMsg.content,
- "string",
- `user message content must be a string, got ${typeof userMsg.content}`
- );
- // Joined text parts with "\n"
- assert.equal(userMsg.content, "Hello\nWorld");
- }
-);
-
-test(
- "#5166 user message with single text-part array is flattened to a plain string",
- async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
- await getExecutor("command-code").execute({
- model: "deepseek/deepseek-v4-pro",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [{ type: "text", text: "Hi there" }],
- },
- ],
- },
- });
-
- const posted = calls[0].body;
- const userMsg = (posted.params as Record).messages[0] as Record<
- string,
- unknown
- >;
- assert.equal(typeof userMsg.content, "string");
- assert.equal(userMsg.content, "Hi there");
- }
-);
-
-test(
- "#5166 user message with plain string content passes through unchanged (no regression)",
- async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
- await getExecutor("command-code").execute({
- model: "deepseek/deepseek-v4-pro",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: "Plain string message",
- },
- ],
- },
- });
-
- const posted = calls[0].body;
- const userMsg = (posted.params as Record).messages[0] as Record<
- string,
- unknown
- >;
- assert.equal(typeof userMsg.content, "string");
- assert.equal(userMsg.content, "Plain string message");
- }
-);
+test("#5166 user message with multi-part array content passes through as an OpenAI array", async () => {
+ const calls = captureFetch(okResponse());
+ await getExecutor("command-code").execute({
+ model: "deepseek/deepseek-v4-pro",
+ stream: false,
+ credentials: { apiKey: "cc_test_key" },
+ body: {
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "Hello" },
+ { type: "text", text: "World" },
+ ],
+ },
+ ],
+ },
+ });
+
+ const userMsg = (calls[0].body.messages as Record[])[0];
+ // OpenAI array content is valid on /provider/v1 — forwarded as-is.
+ assert.ok(Array.isArray(userMsg.content), "array content forwarded (no CLI flattening)");
+ const parts = userMsg.content as Record[];
+ assert.equal(parts.length, 2);
+ assert.equal(parts[0].text, "Hello");
+ assert.equal(parts[1].text, "World");
+});
-test(
- "#5166 user message with mixed parts (text + image_url) keeps only text parts",
- async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
+test("#5166 user message with single text-part array passes through", async () => {
+ const calls = captureFetch(okResponse());
+ await getExecutor("command-code").execute({
+ model: "deepseek/deepseek-v4-pro",
+ stream: false,
+ credentials: { apiKey: "cc_test_key" },
+ body: {
+ messages: [{ role: "user", content: [{ type: "text", text: "Hi there" }] }],
+ },
+ });
+ const userMsg = (calls[0].body.messages as Record[])[0];
+ const parts = userMsg.content as Record[];
+ assert.equal(parts.length, 1);
+ assert.equal(parts[0].text, "Hi there");
+});
- await getExecutor("command-code").execute({
- model: "deepseek/deepseek-v4-pro",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [
- { type: "text", text: "Describe this:" },
- { type: "image_url", image_url: { url: "https://example.com/img.png" } },
- ],
- },
- ],
- },
- });
+test("#5166 user message with plain string content passes through unchanged", async () => {
+ const calls = captureFetch(okResponse());
+ await getExecutor("command-code").execute({
+ model: "deepseek/deepseek-v4-pro",
+ stream: false,
+ credentials: { apiKey: "cc_test_key" },
+ body: { messages: [{ role: "user", content: "Plain string message" }] },
+ });
+ const userMsg = (calls[0].body.messages as Record[])[0];
+ assert.equal(userMsg.content, "Plain string message");
+});
- const posted = calls[0].body;
- const userMsg = (posted.params as Record).messages[0] as Record<
- string,
- unknown
- >;
- assert.equal(typeof userMsg.content, "string");
- // Only text parts extracted; image_url part is dropped (not a "text" type)
- assert.equal(userMsg.content, "Describe this:");
- }
-);
+test("#5166 user message with mixed parts (text + image_url) keeps all parts", async () => {
+ const calls = captureFetch(okResponse());
+ await getExecutor("command-code").execute({
+ model: "deepseek/deepseek-v4-pro",
+ stream: false,
+ credentials: { apiKey: "cc_test_key" },
+ body: {
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "Describe this:" },
+ { type: "image_url", image_url: { url: "https://example.com/img.png" } },
+ ],
+ },
+ ],
+ },
+ });
+ const userMsg = (calls[0].body.messages as Record[])[0];
+ const parts = userMsg.content as Record[];
+ assert.equal(parts.length, 2, "text + image both preserved");
+ assert.equal(parts[0].text, "Describe this:");
+ assert.equal(parts[1].type, "image_url");
+});
\ No newline at end of file
diff --git a/tests/unit/command-code-vision.test.ts b/tests/unit/command-code-vision.test.ts
index 80138b570b50..364013975500 100644
--- a/tests/unit/command-code-vision.test.ts
+++ b/tests/unit/command-code-vision.test.ts
@@ -1,9 +1,13 @@
/**
* Vision / multimodal support tests for the Command Code executor.
*
- * Verifies that vision-capable models (MiniMax M3, MiMo V2.5, Kimi K2, Qwen 3.x, GPT-5, Claude 3/4, Fable 5, Gemini 3.x, Stepfun, Fugu, etc.)
- * receive image parts in Command Code CLI format, while text-only
- * models strip images as before (no regression).
+ * Since #10265 the executor posts to the documented /provider/v1/chat/completions
+ * endpoint, which speaks the standard OpenAI chat.completions format. User image
+ * content (OpenAI `image_url` parts and Anthropic Messages-style source blocks)
+ * passes through unchanged — the endpoint natively understands both shapes, so
+ * there is no CLI-specific conversion (and no CLI-wire image stripping) left to
+ * verify. These tests pin that passthrough plus the #10809 wire-model
+ * normalization, which still applies to /provider/v1.
*/
import test from "node:test";
import assert from "node:assert/strict";
@@ -19,9 +23,8 @@ const core = await import("../../src/lib/db/core.ts");
const originalFetch = globalThis.fetch;
-function commandCodeStream(lines: unknown[]) {
- const text = lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
- return new Response(text, { status: 200, headers: { "Content-Type": "application/x-ndjson" } });
+function okResponse() {
+ return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
}
test.after(() => {
@@ -52,44 +55,28 @@ function captureFetch(response: Response) {
}
function userContent(calls: FetchCall[]): unknown {
- return (
- (calls[0].body.params as Record).messages as Record[]
- )[0].content;
+ return (calls[0].body.messages as Record[])[0].content;
}
-// ── wire model normalization (#10809) ────────────────────────────────
-//
-// Command Code's /alpha/generate endpoint serves most models under a
-// vendor-prefixed wire id and defaults an unprefixed id to the `anthropic:`
-// provider (403 "Model/provider not recognized: anthropic:"). A bare id
-// reaches the executor when an operator sets a custom vision model in the
-// Vision Bridge picker (e.g. `command-code/mimo-v2.5`). The executor must
-// normalize to the documented vendor-prefixed wire form.
-
function wireModel(calls: FetchCall[]): string {
- return (calls[0].body.params as Record).model as string;
+ return calls[0].body.model as string;
}
+// ── wire model normalization (#10809) ────────────────────────────────
+
test("#10809: command-code/mimo-v2.5 wire model is normalized to xiaomi/mimo-v2.5", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
+ const calls = captureFetch(okResponse());
await getExecutor("command-code").execute({
model: "command-code/mimo-v2.5",
stream: false,
credentials: { apiKey: "cc_test_key" },
- body: {
- model: "command-code/mimo-v2.5",
- messages: [{ role: "user", content: "hi" }],
- },
+ body: { model: "command-code/mimo-v2.5", messages: [{ role: "user", content: "hi" }] },
});
assert.equal(wireModel(calls), "xiaomi/mimo-v2.5");
});
test("#10809: cmd/mimo-v2.5 (alias prefix) is also normalized", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
+ const calls = captureFetch(okResponse());
await getExecutor("command-code").execute({
model: "cmd/mimo-v2.5",
stream: false,
@@ -100,9 +87,7 @@ test("#10809: cmd/mimo-v2.5 (alias prefix) is also normalized", async () => {
});
test("#10809: already vendor-prefixed wire ids pass through unchanged", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
+ const calls = captureFetch(okResponse());
await getExecutor("command-code").execute({
model: "command-code/deepseek/deepseek-v4-pro",
stream: false,
@@ -115,13 +100,10 @@ test("#10809: already vendor-prefixed wire ids pass through unchanged", async ()
assert.equal(wireModel(calls), "deepseek/deepseek-v4-pro");
});
-// ── vision models: image parts preserved in CC CLI format ─────────────
-
-test("vision model minimax-m3 preserves image_url part as CC CLI {type:image}", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
+// ── image content passthrough (OpenAI /provider/v1 surface) ──────────
+test("image_url parts pass through unchanged (text + image preserved)", async () => {
+ const calls = captureFetch(okResponse());
await getExecutor("command-code").execute({
model: "MiniMaxAI/MiniMax-M3",
stream: false,
@@ -132,451 +114,25 @@ test("vision model minimax-m3 preserves image_url part as CC CLI {type:image}",
role: "user",
content: [
{ type: "text", text: "What's in this?" },
- {
- type: "image_url",
- image_url: { url: "data:image/png;base64,iVBORw0KGgo=" },
- },
- ],
- },
- ],
- },
- });
-
- const content = userContent(calls);
- assert.ok(Array.isArray(content), "vision model user content must be an array");
- const parts = content as Record[];
- assert.equal(parts.length, 2);
-
- // Text part preserved
- assert.equal(parts[0].type, "text");
- assert.equal(parts[0].text, "What's in this?");
-
- // Image part converted to CC CLI format
- assert.equal(parts[1].type, "image");
- assert.equal(parts[1].image, "data:image/png;base64,iVBORw0KGgo=");
-});
-
-test("vision model minimax-m3 preserves image_url with HTTP URL", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
- await getExecutor("command-code").execute({
- model: "minimax-m3",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [
- { type: "text", text: "Describe" },
- {
- type: "image_url",
- image_url: { url: "https://example.com/photo.jpg" },
- },
- ],
- },
- ],
- },
- });
-
- const content = userContent(calls);
- assert.ok(Array.isArray(content));
- const parts = content as Record[];
- assert.equal(parts.length, 2);
- assert.equal(parts[1].type, "image");
- assert.equal(parts[1].image, "https://example.com/photo.jpg");
-});
-
-test("vision model mimo-v2.5 preserves image parts", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
- await getExecutor("command-code").execute({
- model: "mimo-v2.5",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [
- { type: "text", text: "Analyze" },
- {
- type: "image_url",
- image_url: { url: "https://example.com/img.png" },
- },
- ],
- },
- ],
- },
- });
-
- const content = userContent(calls);
- assert.ok(Array.isArray(content));
- const parts = content as Record[];
- assert.equal(parts.length, 2);
- assert.equal(parts[1].type, "image");
-});
-
-test("vision model mimo-v2.5-pro is text-only (no image parts)", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
- await getExecutor("command-code").execute({
- model: "mimo-v2.5-pro",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [
- { type: "text", text: "Hi" },
- {
- type: "image_url",
- image_url: { url: "https://example.com/img.png" },
- },
- ],
- },
- ],
- },
- });
-
- const content = userContent(calls);
- // mimo-v2.5-pro is text-only — content must be flattened to a plain string
- assert.equal(typeof content, "string");
- assert.equal(content, "Hi");
-});
-
-test("vision model mimo-v2-omni preserves image parts", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
- await getExecutor("command-code").execute({
- model: "mimo-v2-omni",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [
- { type: "text", text: "Check" },
- {
- type: "image_url",
- image_url: { url: "data:image/jpeg;base64,/9j/4AAQ=" },
- },
- ],
- },
- ],
- },
- });
-
- const content = userContent(calls);
- assert.ok(Array.isArray(content));
- const parts = content as Record[];
- assert.equal(parts.length, 2);
- assert.equal(parts[1].type, "image");
- assert.equal(parts[1].image, "data:image/jpeg;base64,/9j/4AAQ=");
-});
-
-// ── non-vision models: images still stripped (no regression) ──────────
-
-test("text-only model deepseek-v4-pro strips image_url parts", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
- await getExecutor("command-code").execute({
- model: "deepseek/deepseek-v4-pro",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [
- { type: "text", text: "Hello" },
- {
- type: "image_url",
- image_url: { url: "https://example.com/img.png" },
- },
- ],
- },
- ],
- },
- });
-
- const content = userContent(calls);
- // Non-vision model: content is a plain string, images stripped
- assert.equal(typeof content, "string");
- assert.equal(content, "Hello");
-});
-
-test("text-only model deepseek-v4-flash strips image_url parts (no regression)", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
- await getExecutor("command-code").execute({
- model: "deepseek/deepseek-v4-flash",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [
- { type: "text", text: "Text only" },
- {
- type: "image_url",
- image_url: { url: "data:image/png;base64,AAA=" },
- },
- ],
- },
- ],
- },
- });
-
- const content = userContent(calls);
- assert.equal(typeof content, "string");
- assert.equal(content, "Text only");
-});
-
-// ── edge cases ────────────────────────────────────────────────────────
-
-test("vision model with only image content emits empty text fallback", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
- await getExecutor("command-code").execute({
- model: "minimax-m3",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [
- {
- type: "image_url",
- image_url: { url: "data:image/png;base64,iVBOR=" },
- },
+ { type: "image_url", image_url: { url: "data:image/png;base64,iVBORw0KGgo=" } },
],
},
],
},
});
- const content = userContent(calls);
- assert.ok(Array.isArray(content));
- const parts = content as Record[];
- // Single image part preserved — no empty text injected because
- // the image itself keeps content non-empty.
- assert.equal(parts.length, 1);
- assert.equal(parts[0].type, "image");
- assert.equal(parts[0].image, "data:image/png;base64,iVBOR=");
-});
-
-test("vision model passes plain string content through unchanged", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
- await getExecutor("command-code").execute({
- model: "minimax-m3",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [{ role: "user", content: "Plain string message" }],
- },
- });
-
- const content = userContent(calls);
- assert.equal(typeof content, "string");
- assert.equal(content, "Plain string message");
-});
-
-test("vision model honors body.model rewrite for vision detection", async () => {
- // #5166 scenario: body.model overwrites the execute model arg.
- // Vision detection must use the rewritten model id.
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
- // execute() gets a non-vision combo model, body.model rewrites to a vision model
- await getExecutor("command-code").execute({
- model: "gpt-5.4-mini",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- model: "MiniMaxAI/MiniMax-M3",
- messages: [
- {
- role: "user",
- content: [
- { type: "text", text: "Describe" },
- {
- type: "image_url",
- image_url: { url: "https://example.com/img.png" },
- },
- ],
- },
- ],
- },
- });
-
- const content = userContent(calls);
- // body.model = MiniMax-M3 (vision) → images preserved
- assert.ok(Array.isArray(content));
- const parts = content as Record[];
- assert.equal(parts.length, 2);
- assert.equal(parts[1].type, "image");
-});
-
-test("vision model with multiple image parts preserves all of them", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
- await getExecutor("command-code").execute({
- model: "minimax-m3",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [
- { type: "text", text: "Compare" },
- {
- type: "image_url",
- image_url: { url: "https://example.com/a.jpg" },
- },
- {
- type: "image_url",
- image_url: { url: "https://example.com/b.jpg" },
- },
- ],
- },
- ],
- },
- });
-
- const content = userContent(calls);
- assert.ok(Array.isArray(content));
- const parts = content as Record[];
- assert.equal(parts.length, 3);
- assert.equal(parts[0].type, "text");
- assert.equal(parts[1].type, "image");
- assert.equal(parts[1].image, "https://example.com/a.jpg");
- assert.equal(parts[2].type, "image");
- assert.equal(parts[2].image, "https://example.com/b.jpg");
-});
-
-test("vision model with image_url as plain string (no object wrapper) still works", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
+ const content = userContent(calls) as Record[];
+ assert.equal(content.length, 2);
+ assert.equal(content[0].type, "text");
+ assert.equal(content[1].type, "image_url", "image_url part preserved as-is");
+ assert.equal(
+ (content[1].image_url as { url: string }).url,
+ "data:image/png;base64,iVBORw0KGgo="
);
-
- await getExecutor("command-code").execute({
- model: "minimax-m3",
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [
- { type: "text", text: "Look" },
- {
- type: "image_url",
- image_url: "https://example.com/img.png",
- },
- ],
- },
- ],
- },
- });
-
- const content = userContent(calls);
- assert.ok(Array.isArray(content));
- const parts = content as Record[];
- assert.equal(parts.length, 2);
- assert.equal(parts[1].type, "image");
- assert.equal(parts[1].image, "https://example.com/img.png");
});
-// ── CC vision models (Command Code docs registry) ──────────────────
-
-const VISION_CASES = [
- ["Kimi K2.6", "moonshotai/Kimi-K2.6"],
- ["Kimi K2.7 Code", "moonshotai/Kimi-K2.7-Code"],
- ["Kimi K2.5", "moonshotai/Kimi-K2.5"],
- ["Qwen 3.6 Plus", "Qwen/Qwen3.6-Plus"],
- ["Qwen 3.7 Plus", "Qwen/Qwen3.7-Plus"],
- ["Step 3.7 Flash", "stepfun/Step-3.7-Flash"],
- ["GPT-5.5", "gpt-5.5"],
- ["GPT-5.4", "gpt-5.4"],
- ["GPT-5.3 Codex", "gpt-5.3-codex"],
- ["GPT-5.4 Mini", "gpt-5.4-mini"],
- ["Claude Fable 5", "claude-fable-5"],
- ["Sakana Fugu Ultra", "sakana/fugu-ultra"],
- ["Claude Opus 4.7 (isVisionModelId)", "claude-opus-4-7"],
- ["Claude Sonnet 4.6 (isVisionModelId)", "claude-sonnet-4-6"],
- ["Gemini 3.5 Flash (isVisionModelId)", "google/gemini-3.5-flash"],
-];
-
-for (const [name, model] of VISION_CASES) {
- test(`vision model ${name} preserves image parts`, async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
- await getExecutor("command-code").execute({
- model,
- stream: false,
- credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [
- { type: "text", text: "Check" },
- {
- type: "image_url",
- image_url: { url: "https://example.com/img.png" },
- },
- ],
- },
- ],
- },
- });
- const content = userContent(calls);
- assert.ok(Array.isArray(content), `${name} user content must be an array`);
- const parts = content;
- assert.equal(parts.length, 2);
- assert.equal(parts[1].type, "image");
- });
-}
-
-// ── Anthropic-shaped image blocks (Zoo Code / Claude-Code-compatible clients) ──
-
-test("vision model mimo-v2.5 preserves Anthropic source.base64 image block", async () => {
- // Zoo Code sends Messages-API-shaped content blocks to the OpenAI
- // /v1/chat/completions surface: { type:"image", source:{ base64 } }.
- // The vision-bridge guardrail skips vision-capable models (cmd/xiaomi/mimo-v2.5
- // resolves supportsVision=true via the mimo-v2.5 leaf spec), so the raw block
- // must survive to the executor and be converted to CC CLI { type:"image" }.
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
+test("Anthropic Messages-style source image blocks pass through unchanged", async () => {
+ const calls = captureFetch(okResponse());
await getExecutor("command-code").execute({
model: "xiaomi/mimo-v2.5",
stream: false,
@@ -601,24 +157,15 @@ test("vision model mimo-v2.5 preserves Anthropic source.base64 image block", asy
},
});
- const content = userContent(calls);
- assert.ok(Array.isArray(content), "user content must be an array");
- const parts = content as Record[];
- assert.equal(parts.length, 2, "text + image parts preserved");
- assert.equal(parts[0].type, "text");
- assert.equal(parts[1].type, "image");
- assert.equal(
- parts[1].image,
- "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
- "base64 payload is rebuilt into a CC CLI data URL"
- );
+ const content = userContent(calls) as Record[];
+ assert.equal(content.length, 2, "text + image parts preserved");
+ assert.equal(content[1].type, "image");
+ assert.equal((content[1].source as { type: string }).type, "base64");
+ assert.equal((content[1].source as { data: string }).data, "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==");
});
-test("vision model preserves Anthropic source.url image block", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
+test("Anthropic source.url image block passes through unchanged", async () => {
+ const calls = captureFetch(okResponse());
await getExecutor("command-code").execute({
model: "xiaomi/mimo-v2.5",
stream: false,
@@ -629,31 +176,23 @@ test("vision model preserves Anthropic source.url image block", async () => {
role: "user",
content: [
{ type: "text", text: "Look" },
- {
- type: "image",
- source: { type: "url", url: "https://example.com/img.png" },
- },
+ { type: "image", source: { type: "url", url: "https://example.com/img.png" } },
],
},
],
},
});
- const content = userContent(calls);
- assert.ok(Array.isArray(content));
- const parts = content as Record[];
- assert.equal(parts.length, 2);
- assert.equal(parts[1].type, "image");
- assert.equal(parts[1].image, "https://example.com/img.png");
+ const content = userContent(calls) as Record[];
+ assert.equal(content.length, 2);
+ assert.equal(content[1].type, "image");
+ assert.deepEqual(content[1].source, { type: "url", url: "https://example.com/img.png" });
});
-test("text-only model deepseek-v4-flash strips Anthropic source.base64 image block", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
+test("multiple image parts are all preserved", async () => {
+ const calls = captureFetch(okResponse());
await getExecutor("command-code").execute({
- model: "deepseek/deepseek-v4-flash",
+ model: "minimax-m3",
stream: false,
credentials: { apiKey: "cc_test_key" },
body: {
@@ -661,77 +200,41 @@ test("text-only model deepseek-v4-flash strips Anthropic source.base64 image blo
{
role: "user",
content: [
- { type: "text", text: "Text only" },
- {
- type: "image",
- source: {
- type: "base64",
- media_type: "image/png",
- data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
- },
- },
+ { type: "text", text: "Compare" },
+ { type: "image_url", image_url: { url: "https://example.com/a.jpg" } },
+ { type: "image_url", image_url: { url: "https://example.com/b.jpg" } },
],
},
],
},
});
- const content = userContent(calls);
- // Text-only model: content flattened to plain string, image stripped.
- assert.equal(typeof content, "string");
- assert.equal(content, "Text only");
+ const content = userContent(calls) as Record[];
+ assert.equal(content.length, 3);
+ assert.equal(content[1].type, "image_url");
+ assert.equal(content[2].type, "image_url");
});
-// ── conservative vision family lock (gpt-5.4-mini / gpt-5.3-codex) ─────
-//
-// These two ids stay INSIDE the `/gpt-5/` vision family: both accept image
-// input on the OpenAI API, and there is no verified Command Code backend data
-// marking them text-only. These tests pin that conservative executor behavior
-// so a future "narrow the regex" change cannot silently strip images from models
-// that can see them (the #4071 regression class). The #10703 Vision Bridge
-// candidate-list fix lives in the shared capability resolution
-// (KNOWN_TEXT_ONLY_DESPITE_SYNC), NOT in the executor's wire transform.
-
-test("gpt-5.4-mini keeps image parts (conservative vision family lock)", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
+test("plain string content passes through unchanged", async () => {
+ const calls = captureFetch(okResponse());
await getExecutor("command-code").execute({
- model: "gpt-5.4-mini",
+ model: "minimax-m3",
stream: false,
credentials: { apiKey: "cc_test_key" },
- body: {
- messages: [
- {
- role: "user",
- content: [
- { type: "text", text: "What's in this?" },
- {
- type: "image_url",
- image_url: { url: "https://example.com/img.png" },
- },
- ],
- },
- ],
- },
+ body: { messages: [{ role: "user", content: "Plain string message" }] },
});
const content = userContent(calls);
- assert.ok(Array.isArray(content), "gpt-5.4-mini must be treated as vision-capable");
- const parts = content as Record[];
- assert.equal(parts.length, 2);
- assert.equal(parts[1].type, "image");
- assert.equal(parts[1].image, "https://example.com/img.png");
+ assert.equal(typeof content, "string");
+ assert.equal(content, "Plain string message");
});
-test("gpt-5.3-codex keeps image parts (conservative vision family lock)", async () => {
- const calls = captureFetch(
- commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
- );
-
+test("text-only model still forwards image parts (passthrough, no CLI stripping)", async () => {
+ // The /provider/v1 OpenAI surface accepts image content for any model id; the
+ // executor forwards content untouched, so there is no text-only stripping.
+ const calls = captureFetch(okResponse());
await getExecutor("command-code").execute({
- model: "gpt-5.3-codex",
+ model: "deepseek/deepseek-v4-pro",
stream: false,
credentials: { apiKey: "cc_test_key" },
body: {
@@ -739,21 +242,15 @@ test("gpt-5.3-codex keeps image parts (conservative vision family lock)", async
{
role: "user",
content: [
- { type: "text", text: "Describe" },
- {
- type: "image_url",
- image_url: { url: "https://example.com/img.png" },
- },
+ { type: "text", text: "Hello" },
+ { type: "image_url", image_url: { url: "https://example.com/img.png" } },
],
},
],
},
});
- const content = userContent(calls);
- assert.ok(Array.isArray(content), "gpt-5.3-codex must be treated as vision-capable");
- const parts = content as Record[];
- assert.equal(parts.length, 2);
- assert.equal(parts[1].type, "image");
- assert.equal(parts[1].image, "https://example.com/img.png");
-});
+ const content = userContent(calls) as Record[];
+ assert.equal(content.length, 2, "content array forwarded unchanged");
+ assert.equal(content[1].type, "image_url");
+});
\ No newline at end of file
diff --git a/tests/unit/compression/ccr-non-mcp-full-prompt-loss-7746.test.ts b/tests/unit/compression/ccr-non-mcp-full-prompt-loss-7746.test.ts
index 0f4abd534e64..a646744871c4 100644
--- a/tests/unit/compression/ccr-non-mcp-full-prompt-loss-7746.test.ts
+++ b/tests/unit/compression/ccr-non-mcp-full-prompt-loss-7746.test.ts
@@ -15,6 +15,7 @@ import { describe, it, before } from "node:test";
import assert from "node:assert/strict";
import {
ccrEngine,
+ getCcrStoreStats,
resetCcrStore,
retrieveBlock,
} from "../../../open-sse/services/compression/engines/ccr/index.ts";
@@ -54,39 +55,117 @@ describe("issue #7746 — CCR must not reduce the sole user prompt to a bare, un
});
it("prompt fixture is realistically sized (>= default 600-char minChars)", () => {
- assert.ok(REPORTER_PROMPT.length >= 600, `fixture must be >= 600 chars, got ${REPORTER_PROMPT.length}`);
+ assert.ok(
+ REPORTER_PROMPT.length >= 600,
+ `fixture must be >= 600 chars, got ${REPORTER_PROMPT.length}`
+ );
});
- it("does not leave the model with only the bare CCR marker when no retrieve tool is available", () => {
+ it("non-MCP caller: CCR skips entirely — the sole user prompt passes through verbatim", () => {
resetCcrStore();
const body = makeOpenCodeStyleRequestBody();
const result = ccrEngine.apply(body as Record, { stepConfig: {} });
- assert.equal(result.compressed, true, "CCR compressed the sole user message (reproducing the report)");
-
- const messages = result.body.messages as Array<{ role: string; content: string }>;
- const compressedContent = messages[0].content;
- const isBareMarkerOnly = /^\[CCR retrieve hash=[0-9a-f]{24} chars=\d+\]$/.test(compressedContent);
-
+ // #7746 follow-up (forge review outage, 2026-08-22): the preamble guard was
+ // not enough — a non-MCP caller received "[CCR retrieve hash=...] markers"
+ // it had no tool to resolve (upstream saw 112 of ~3.6K tokens). The engine
+ // now refuses to replace content at all when tools[] lacks
+ // omniroute_ccr_retrieve: compressed=false, message content untouched.
assert.equal(
- isBareMarkerOnly,
+ result.compressed,
false,
- "BUG #7746: CCR replaced the ENTIRE sole user message with nothing but the bare " +
- `[CCR retrieve hash=...] marker, permanently losing the original prompt for any ` +
- `non-MCP caller that cannot resolve the marker. Got: ${JSON.stringify(compressedContent)}`
+ "CCR must not compress for a caller without the retrieve tool"
);
+ assert.equal(result.stats, null, "no stats when the engine is skipped");
+ const messages = result.body.messages as Array<{ role: string; content: string }>;
+ assert.equal(messages[0].role, "user", "message role must stay user");
+ assert.equal(
+ messages[0].content,
+ REPORTER_PROMPT,
+ "sole user prompt must pass through verbatim"
+ );
+ assert.equal(messages.length, 1, "no protocol instruction may be injected for non-MCP callers");
+ // Guard regression check: if callerSupportsCcrRetrieve ever returned true
+ // here, the store would silently accumulate blocks no non-MCP caller can
+ // retrieve. After a skip the store must hold nothing for this principal.
+ assert.equal(getCcrStoreStats().entries, 0, "store must stay empty after a non-MCP skip");
});
- it("the original prompt remains fully retrievable by hash even after the guard applies", () => {
+ // tools:[] and unrelated tools are distinct caller shapes that must all be
+ // treated as non-MCP: an empty array and a foreign tool list both mean the
+ // retrieve tool is unreachable.
+ for (const label of ["empty tools array", "unrelated tools"] as const) {
+ it(`non-MCP caller with ${label}: CCR skips entirely`, () => {
+ resetCcrStore();
+ const tools =
+ label === "empty tools array"
+ ? []
+ : [
+ { type: "function", function: { name: "get_weather" } },
+ { type: "function", function: { name: "web_search" } },
+ ];
+ const body = { ...makeOpenCodeStyleRequestBody(), tools };
+ const result = ccrEngine.apply(body as Record, { stepConfig: {} });
+
+ assert.equal(result.compressed, false, `${label} must not compress`);
+ const messages = result.body.messages as Array<{ role: string; content: string }>;
+ assert.equal(messages[0].content, REPORTER_PROMPT, "prompt passes through verbatim");
+ assert.equal(messages.length, 1, "no protocol instruction injected");
+ });
+ }
+
+ // A malformed body (tools as a non-array, or entries of unexpected shape)
+ // must fail OPEN — no compression, never a throw into the request pipeline.
+ for (const malformed of [
+ { tools: "not-an-array" },
+ { tools: [null, 42, "x"] },
+ { tools: [{}, { type: "function" }] },
+ ]) {
+ it(`malformed tools payload (${JSON.stringify(malformed.tools)}): engine skips without throwing`, () => {
+ resetCcrStore();
+ const body = { ...makeOpenCodeStyleRequestBody(), ...malformed };
+ const result = ccrEngine.apply(body as Record, { stepConfig: {} });
+
+ assert.equal(result.compressed, false, "malformed tools must fail open (skip)");
+ const messages = result.body.messages as Array<{ role: string; content: string }>;
+ assert.equal(messages[0].content, REPORTER_PROMPT, "prompt passes through verbatim");
+ assert.equal(
+ getCcrStoreStats().entries,
+ 0,
+ "store must stay empty after a malformed-tools skip"
+ );
+ });
+ }
+
+ it("MCP-capable caller (tools[] advertises omniroute_ccr_retrieve): replacement still runs and stays retrievable", () => {
resetCcrStore();
- const body = makeOpenCodeStyleRequestBody();
+ const body = {
+ ...makeOpenCodeStyleRequestBody(),
+ tools: [{ type: "function", function: { name: "omniroute_ccr_retrieve" } }],
+ };
const result = ccrEngine.apply(body as Record, { stepConfig: {} });
+ assert.equal(result.compressed, true, "CCR still compresses for MCP-capable callers");
const messages = result.body.messages as Array<{ role: string; content: string }>;
- const compressedContent = messages[0].content;
+ // The protocol instruction is injected as a leading system message, so the
+ // compressed conversation is exactly: [instruction, original user message].
+ assert.equal(messages.length, 2, "instruction + user message");
+ assert.equal(messages[0].role, "system", "instruction is a leading system message");
+ assert.ok(
+ typeof messages[0].content === "string" && messages[0].content.length > 0,
+ "instruction content must be non-empty"
+ );
+ assert.ok(
+ messages[0].content.includes("omniroute_ccr_retrieve"),
+ "instruction must teach the retrieve tool contract"
+ );
+ const compressedContent = messages[1].content;
const match = compressedContent.match(/\[CCR retrieve hash=([0-9a-f]{24}) chars=\d+\]/);
- assert.ok(match, "compressed content must still contain a resolvable CCR marker");
- const hash = match![1];
- assert.equal(retrieveBlock(hash), REPORTER_PROMPT, "original prompt must be stored verbatim and retrievable");
+ assert.ok(match, "compressed content must contain a resolvable CCR marker");
+ assert.equal(
+ retrieveBlock(match![1]),
+ REPORTER_PROMPT,
+ "original prompt must be stored verbatim and retrievable"
+ );
});
});
diff --git a/tests/unit/compression/ccr-retrieval-ramp.test.ts b/tests/unit/compression/ccr-retrieval-ramp.test.ts
index 2b475e69bfb0..6e87f7b78773 100644
--- a/tests/unit/compression/ccr-retrieval-ramp.test.ts
+++ b/tests/unit/compression/ccr-retrieval-ramp.test.ts
@@ -78,7 +78,13 @@ describe("ccrEngine.apply — retrieval-aware compression (H8)", () => {
const block = (len: number) => "x".repeat(len);
const run = (content: string, retrievalRampFactor = 2) =>
ccrEngine.apply(
- { messages: [{ role: "user", content }] },
+ // The retrieve tool is advertised — this suite exercises the compression
+ // path itself (H8 ramp); without the tool declaration the #7746 guard
+ // skips the engine entirely.
+ {
+ messages: [{ role: "user", content }],
+ tools: [{ type: "function", function: { name: "omniroute_ccr_retrieve" } }],
+ },
{ stepConfig: { minChars: BASE, retrievalRampFactor }, principalId: P }
);
diff --git a/tests/unit/compute-connection-default-name-11033.test.ts b/tests/unit/compute-connection-default-name-11033.test.ts
new file mode 100644
index 000000000000..c63a3eacf804
--- /dev/null
+++ b/tests/unit/compute-connection-default-name-11033.test.ts
@@ -0,0 +1,25 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { computeConnectionDefaultName } from "../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/computeConnectionDefaultName.ts";
+
+describe("computeConnectionDefaultName array deduping (#11033)", () => {
+ it("dedupes against existing connection object arrays without overwriting connection 1", () => {
+ const existing = [
+ { name: "main" },
+ { name: "main-2" },
+ { name: "main-3" },
+ { name: "main-4" },
+ ];
+ assert.equal(computeConnectionDefaultName(existing), "main-5");
+ });
+
+ it("handles missing intermediate indices cleanly", () => {
+ const existing = ["main", "main-3", "main-4"];
+ assert.equal(computeConnectionDefaultName(existing), "main-2");
+ });
+
+ it("returns 'main' if main does not exist in existing connections", () => {
+ const existing = ["main-2", "main-3"];
+ assert.equal(computeConnectionDefaultName(existing), "main");
+ });
+});
diff --git a/tests/unit/config-audit-persistence.test.ts b/tests/unit/config-audit-persistence.test.ts
new file mode 100644
index 000000000000..4bad88bc5db4
--- /dev/null
+++ b/tests/unit/config-audit-persistence.test.ts
@@ -0,0 +1,124 @@
+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-config-audit-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
+
+const core = await import("../../src/lib/db/core.ts");
+const cleanup = await import("../../src/lib/db/cleanup.ts");
+const audit = await import("../../src/domain/configAudit.ts");
+
+type CountRow = { c: number };
+
+function resetStorage() {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+}
+
+function countRows(): number {
+ const db = core.getDbInstance();
+ const row = db.prepare("SELECT COUNT(*) AS c FROM config_audit_log").get() as CountRow;
+ return row.c;
+}
+
+function insertOldRow(id: string, daysAgo: number) {
+ const db = core.getDbInstance();
+ const old = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString();
+ db.prepare(
+ `INSERT INTO config_audit_log
+ (id, timestamp, action, target, target_id, target_name, before_json, after_json, diff_json, source, note)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ ).run(
+ id,
+ old,
+ "update",
+ "provider",
+ "p1",
+ "P1",
+ null,
+ null,
+ JSON.stringify({ added: [], removed: [], changed: [], isEmpty: true }),
+ "api",
+ null
+ );
+}
+
+test.beforeEach(() => {
+ resetStorage();
+});
+
+test.after(() => {
+ resetStorage();
+});
+
+test("recordChange persists to SQLite, not memory", () => {
+ const db = core.getDbInstance();
+ const tableRow = db
+ .prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name='config_audit_log'")
+ .get() as CountRow;
+ assert.equal(tableRow.c, 1);
+
+ const e = audit.recordChange("update", "provider", "p1", "My Provider", { a: 1 }, { a: 2 }, "api", null);
+ assert.equal(countRows(), 1);
+
+ const { entries, total } = audit.getAuditLog({ target: "provider" });
+ assert.equal(total, 1);
+ assert.equal(entries[0].id, e.id);
+ assert.deepEqual(entries[0].diff.changed, [{ key: "a", from: 1, to: 2 }]);
+});
+
+test("pagination + filters read from SQLite", () => {
+ audit.recordChange("create", "combo", "c1", "C1", null, { models: ["m1"] }, "dashboard");
+ audit.recordChange("update", "combo", "c1", "C1", { models: ["m1"] }, { models: ["m1", "m2"] }, "api");
+
+ const { entries, total } = audit.getAuditLog({ target: "combo", limit: 1, offset: 0 });
+ assert.equal(total, 2);
+ assert.equal(entries.length, 1);
+});
+
+test("getRollbackState returns the before snapshot", () => {
+ const e = audit.recordChange("update", "policy", "pol1", "Pol", { x: 1 }, { x: 2 }, "api");
+ assert.deepEqual(audit.getRollbackState(e.id), { x: 1 });
+});
+
+test("computeDiff stays pure", () => {
+ const d = audit.computeDiff({ a: 1 }, { a: 2, b: 3 });
+ assert.deepEqual(d.added, ["b"]);
+ assert.deepEqual(d.changed, [{ key: "a", from: 1, to: 2 }]);
+});
+
+test("resetAuditLog clears persisted rows", () => {
+ audit.recordChange("update", "provider", "p1", "P1", { a: 1 }, { a: 2 }, "api");
+ assert.equal(countRows(), 1);
+ audit.resetAuditLog();
+ assert.equal(countRows(), 0);
+});
+
+test("cleanupConfigAudit prunes rows beyond retentionDays", async () => {
+ insertOldRow("audit-old", 40);
+ const r = await cleanup.cleanupConfigAudit(30);
+ assert.equal(r.deleted, 1);
+ assert.equal(countRows(), 0);
+});
+
+test("cleanupConfigAudit keeps recent rows within retention", async () => {
+ insertOldRow("audit-recent", 5);
+ const r = await cleanup.cleanupConfigAudit(30);
+ assert.equal(r.deleted, 0);
+ assert.equal(countRows(), 1);
+});
+
+test("runAutoCleanup includes a configAudit result", async () => {
+ insertOldRow("audit-old-2", 40);
+ const result = await cleanup.runAutoCleanup();
+ assert.ok(result.results.configAudit);
+ assert.equal(typeof result.results.configAudit.deleted, "number");
+ assert.equal(typeof result.results.configAudit.errors, "number");
+ assert.equal(result.results.configAudit.deleted, 1);
+ assert.equal(countRows(), 0);
+});
diff --git a/tests/unit/context-manager-purify-system-first.test.ts b/tests/unit/context-manager-purify-system-first.test.ts
new file mode 100644
index 000000000000..51c658231d0f
--- /dev/null
+++ b/tests/unit/context-manager-purify-system-first.test.ts
@@ -0,0 +1,91 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { compressContext } from "../../open-sse/services/contextManager.ts";
+
+/**
+ * Plan-A root fix for the 2026-08-22 tokenrouter 400s. purifyHistory() used to
+ * splice the `[Context compressed: …]` notice as a SECOND system-role message at
+ * index system.length; strict gateways (TokenRouter, xiaomi-mimo/mimo) reject any
+ * system message at index > 0 with HTTP 400 "System message must be at the
+ * beginning". The notice must now merge into the leading system/developer
+ * message — or prepend a single system message when none exists — so the output
+ * never contains a system role after index 0, for ANY provider.
+ */
+
+function bigTurn(n: number) {
+ return { role: "user", content: `turn ${n}: ${"x".repeat(4_000)}` };
+}
+
+function run(body: Record) {
+ // ~30k tokens of history vs a small target forces Layer-3 purify_history.
+ return compressContext(body, { maxTokens: 5_000, reserveTokens: 0 });
+}
+
+function systemIndices(messages: Array<{ role: string }>) {
+ return messages.map((m, i) => (m.role === "system" ? i : -1)).filter((i) => i >= 0);
+}
+
+test("purify_history merges dropped-notice into existing leading system message", () => {
+ const body = {
+ model: "any-model",
+ messages: [
+ { role: "system", content: "You are a helpful assistant." },
+ ...Array.from({ length: 12 }, (_, i) => bigTurn(i)),
+ ],
+ };
+ const result = run(body);
+ assert.equal(result.compressed, true);
+ const messages = (result.body as { messages: Array> }).messages;
+ assert.deepEqual(systemIndices(messages as Array<{ role: string }>).slice(1), []);
+ const first = messages[0];
+ assert.equal(first.role, "system");
+ const text = String(first.content);
+ assert.match(text, /Context compressed: \d+ earlier messages removed/);
+ assert.match(text, /You are a helpful assistant\./);
+});
+
+test("purify_history prepends a single system notice when no system message exists", () => {
+ const body = {
+ model: "any-model",
+ messages: Array.from({ length: 12 }, (_, i) => bigTurn(i)),
+ };
+ const result = run(body);
+ assert.equal(result.compressed, true);
+ const messages = (result.body as { messages: Array> }).messages;
+ assert.deepEqual(systemIndices(messages as Array<{ role: string }>), [0]);
+ assert.match(String(messages[0].content), /Context compressed: \d+ earlier messages removed/);
+});
+
+test("purify_history merges into leading developer message without adding a second one", () => {
+ const body = {
+ model: "any-model",
+ messages: [
+ { role: "developer", content: "dev instructions" },
+ ...Array.from({ length: 12 }, (_, i) => bigTurn(i)),
+ ],
+ };
+ const result = run(body);
+ assert.equal(result.compressed, true);
+ const messages = (result.body as { messages: Array> }).messages;
+ assert.deepEqual(
+ messages.filter((m) => m.role === "developer").length,
+ 1,
+ "exactly one developer message"
+ );
+ assert.match(String(messages[0].content), /Context compressed: \d+ earlier messages removed/);
+ assert.match(String(messages[0].content), /dev instructions/);
+});
+
+test("no compression means no notice and untouched history", () => {
+ const body = {
+ model: "any-model",
+ messages: [
+ { role: "system", content: "sys" },
+ { role: "user", content: "hi" },
+ ],
+ };
+ const result = run(body);
+ assert.equal(result.compressed, false);
+ const messages = (result.body as { messages: unknown[] }).messages;
+ assert.equal(messages.length, 2);
+});
diff --git a/tests/unit/context-manager.test.ts b/tests/unit/context-manager.test.ts
index 776a3cdc256c..de95d5b6f2d3 100644
--- a/tests/unit/context-manager.test.ts
+++ b/tests/unit/context-manager.test.ts
@@ -75,10 +75,15 @@ for (const modelId of HYPERAGENT_FALLBACK_MODEL_IDS) {
}
test("getTokenLimit: does not force 1M onto non-hyperagent providers serving the same model ids", () => {
- // windsurf declares an explicit per-model contextLength of 200000 for this exact id —
- // a provider-unscoped substring match on "claude-opus-4" would have clobbered it to 1M.
- assert.equal(getTokenLimit("windsurf", "claude-opus-4.7-max"), 200000);
- // bluesminds likewise pins its own claude-opus-4-5 entry to 200000.
+ // windsurf used to pin this exact id at 200000, but its built-in provider entry was
+ // retired (#8228 — replaced by devin-desktop). With no per-provider source left,
+ // #11034 resolves the effort-suffixed variant via its BASE model (`claude-opus-4.7-max`
+ // → `claude-opus-4.7` → canonical `claude-opus-4-7`), whose real catalog window IS 1M.
+ // This is a legitimate base-model resolution, not the forbidden hyperagent default leak:
+ // it comes from the shared model catalog, never from the hyperagent registry scope.
+ assert.equal(getTokenLimit("windsurf", "claude-opus-4.7-max"), 1_000_000);
+ // bluesminds still pins its own claude-opus-4-5 entry to 200000, and that pin must win
+ // over both the name heuristic and any 1M window from sibling providers/catalogs.
assert.equal(getTokenLimit("bluesminds", "claude-opus-4-5"), 200000);
});
diff --git a/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts b/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts
index 98a3548dfe34..971180f19d28 100644
--- a/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts
+++ b/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts
@@ -15,7 +15,10 @@ class MockM365WebSocket {
closed = false;
listeners = new Map();
- constructor(public url: string, public options: unknown) {
+ constructor(
+ public url: string,
+ public options: unknown
+ ) {
MockM365WebSocket.instances.push(this);
queueMicrotask(() => this.emit("open"));
}
@@ -144,30 +147,55 @@ test("#7870: enterprise-tier chat invocation defaults tone to Magic", async () =
assert.equal(invocationArgs.tone, "Magic");
});
-test("#10718: individual (no tier) chat invocation carries the recaptured 2026-08 shape", async () => {
+test("2026-08-21: individual (no tier) chat invocation carries the recaptured shape", async () => {
const invocationArgs = await sendChatInvocation(undefined);
const optionsSets = invocationArgs.optionsSets as string[];
- // The 25-entry consumer/MSA set (enable_msa_user, pdnascan, …) is gone from
- // the wire — the stale set was part of the silently-dropped shape.
+ // The pre-#10718 25-entry consumer/MSA set (enable_msa_user, pdnascan, …) is
+ // still gone from the wire — only the 2026-08-21 34-entry set is current.
assert.ok(!optionsSets.includes("enable_msa_user"));
assert.ok(optionsSets.includes("enable_gg_gpt"));
- // The browser sends tone:"magic" (lowercase) on the individual/EDU surface.
- assert.equal(invocationArgs.tone, "magic");
+ // The individual/consumer surface now sends tone:"Magic" (capitalized) —
+ // the #10718 lowercase "magic" is stale.
+ assert.equal(invocationArgs.tone, "Magic");
assert.deepEqual(invocationArgs.allowedMessageTypes, [
"Chat",
"Suggestion",
+ "InternalSearchQuery",
"Disengaged",
+ "InternalLoaderMessage",
"Progress",
+ "GeneratedCode",
+ "RenderCardRequest",
+ "AdsQuery",
+ "SemanticSerp",
+ "GenerateContentQuery",
+ "GenerateGraphicArt",
+ "SearchQuery",
+ "ConfirmationCard",
+ "AuthError",
+ "DeveloperLogs",
+ "TriggerPlugin",
+ "HintInvocation",
+ "MemoryUpdate",
"EndOfRequest",
- "InternalLoaderMessage",
+ "TriggerConfirmation",
+ "ResumeInvokeAction",
+ "ResumeUserInputRequest",
+ "TriggerUserInputRequest",
+ "EscapeHatch",
+ "TriggerPluginAuth",
+ "ResumePluginAuth",
+ "SideBySide",
+ "ReferencesListComplete",
+ "SwitchRespondingEndpoint",
]);
});
-test("#10718: EDU-tier chat invocation carries the same recaptured shape", async () => {
+test("2026-08-21: EDU-tier chat invocation carries the same recaptured shape", async () => {
const invocationArgs = await sendChatInvocation("edu");
const optionsSets = invocationArgs.optionsSets as string[];
assert.ok(!optionsSets.includes("enable_msa_user"));
- assert.equal(invocationArgs.tone, "magic");
+ assert.equal(invocationArgs.tone, "Magic");
});
test("#8971: enterprise-tier chat invocation must send disconnectBehavior=continue", async () => {
@@ -179,11 +207,11 @@ test("#8971: enterprise-tier chat invocation must send disconnectBehavior=contin
);
});
-test("#8971/#10718: individual (no tier) chat invocation omits disconnectBehavior (not on the 2026-08 wire)", async () => {
+test("2026-08-21: individual (no tier) chat invocation also sends disconnectBehavior=continue (now on every tier)", async () => {
const invocationArgs = await sendChatInvocation(undefined);
assert.equal(
invocationArgs.disconnectBehavior,
- undefined,
- `individual-tier invocation must omit disconnectBehavior; got ${JSON.stringify(invocationArgs.disconnectBehavior)}`
+ "continue",
+ `individual-tier invocation must carry disconnectBehavior="continue"; got ${JSON.stringify(invocationArgs.disconnectBehavior)}`
);
});
diff --git a/tests/unit/copilot-m365-invocation-refresh-10718.test.ts b/tests/unit/copilot-m365-invocation-refresh-10718.test.ts
index f5e602ce3d25..415dc5036be9 100644
--- a/tests/unit/copilot-m365-invocation-refresh-10718.test.ts
+++ b/tests/unit/copilot-m365-invocation-refresh-10718.test.ts
@@ -41,7 +41,7 @@ test("#10718: metricsFrame emits the exact bytes observed in the browser capture
// ── Recaptured invocation shape ────────────────────────────────────────────
-test("#10718: buildChatInvocation matches the recaptured arguments[0] key set", () => {
+test("2026-08-21: buildChatInvocation matches the recaptured arguments[0] key set", () => {
const arg = buildChatInvocation({
text: "Say OK in one word.",
traceId: "11111111-1111-1111-1111-111111111111",
@@ -50,19 +50,25 @@ test("#10718: buildChatInvocation matches the recaptured arguments[0] key set",
conversationId: "44444444-4444-4444-4444-444444444444",
}).arguments[0] as Record;
- // Exact key set from the capture — additions AND omissions are both pinned,
- // because the stale keys are exactly what got the shape dropped.
+ // Exact key set from the 2026-08-21 capture (issue: individual/consumer M365
+ // Copilot calls got only SignalR keepalive pings and no type:1 update at all
+ // — "Stream ended before producing a non-ping SSE event" client-side). The
+ // #10718 shape below is missing exactly the keys this capture added.
assert.deepEqual(Object.keys(arg).sort(), [
"allowedMessageTypes",
"clientCorrelationId",
"clientInfo",
"conversationId",
+ "disconnectBehavior",
+ "extraExtensionParameters",
+ "isSbsSupported",
"isStartOfSession",
"message",
"options",
"optionsSets",
"plugins",
"productThreadType",
+ "renderReferencesBehindEOS",
"sessionId",
"sliceIds",
"source",
@@ -73,13 +79,32 @@ test("#10718: buildChatInvocation matches the recaptured arguments[0] key set",
"traceId",
]);
assert.equal(arg.productThreadType, "Office");
- assert.deepEqual(arg.clientInfo, { clientAppName: "Office", clientPlatform: "mcmcopilot-web" });
+ assert.deepEqual(arg.clientInfo, {
+ clientAppName: "Office",
+ clientPlatform: "mcmcopilot-web",
+ clientEntrypoint: "mcmcopilot-officeweb",
+ clientSessionId: "22222222-2222-2222-2222-222222222222",
+ ProductCategory: "Chat",
+ clientAppType: "Web",
+ productEntryPoint: "ChatPanel",
+ deviceOS: "Windows",
+ deviceType: "Desktop",
+ clientPlatformVersion: "10",
+ });
assert.equal(arg.conversationId, "44444444-4444-4444-4444-444444444444");
assert.equal(arg.toolChoice, null);
- assert.equal(arg.tone, "magic");
+ // The individual/consumer surface now sends "Magic" (capitalized), matching
+ // the enterprise tone literal — the #10718 lowercase "magic" is stale.
+ assert.equal(arg.tone, "Magic");
+ assert.equal(arg.isSbsSupported, true);
+ assert.equal(arg.renderReferencesBehindEOS, true);
+ assert.deepEqual(arg.extraExtensionParameters, {});
+ assert.deepEqual(arg.plugins, [{ Id: "BingWebSearch", Source: "BuiltIn" }]);
+ // Sent on every tier now, not gated to enterprise as #8971 described.
+ assert.equal(arg.disconnectBehavior, "continue");
});
-test("#10718: the message object carries the recaptured rich shape", () => {
+test("2026-08-21: the message object carries the recaptured rich shape", () => {
const arg = buildChatInvocation({
text: "Say OK in one word.",
traceId: "t",
@@ -93,7 +118,9 @@ test("#10718: the message object carries the recaptured rich shape", () => {
"adaptiveCards",
"attachments",
"author",
+ "clientInfo",
"clientPreferences",
+ "connectedFederatedConnections",
"entityAnnotationTypes",
"experienceType",
"inputMethod",
@@ -107,26 +134,41 @@ test("#10718: the message object carries the recaptured rich shape", () => {
assert.equal(message.messageType, "Chat");
assert.equal(message.requestId, "r");
assert.equal(message.experienceType, "Default");
- assert.deepEqual(message.entityAnnotationTypes, ["People", "File", "Event", "Email", "TeamsMessage"]);
+ assert.deepEqual(message.entityAnnotationTypes, [
+ "People",
+ "File",
+ "Event",
+ "Email",
+ "TeamsMessage",
+ ]);
assert.equal(message.attachments, null);
assert.deepEqual(message.locationInfo, { timeZone: "UTC", timeZoneOffset: 0 });
+ assert.deepEqual(message.connectedFederatedConnections, ["dummyId"]);
+ // Same clientInfo object echoed inside message, per the capture.
+ assert.deepEqual(message.clientInfo, arg.clientInfo);
});
-test("#10718: default tier lists are the recaptured 14-entry optionsSets / 6-entry allowedMessageTypes", () => {
+test("2026-08-21: default tier lists are the recaptured 34-entry optionsSets / 30-entry allowedMessageTypes", () => {
const overrides = resolveChatInvocationOverrides(undefined);
- assert.equal(overrides.optionsSets.length, 14);
- assert.equal(overrides.allowedMessageTypes.length, 6);
- assert.equal(overrides.tone, "magic");
- // The pre-#10718 consumer/MSA flags are gone from the wire.
+ assert.equal(overrides.optionsSets.length, 34);
+ assert.equal(overrides.allowedMessageTypes.length, 30);
+ assert.equal(overrides.tone, "Magic");
+ assert.equal(overrides.disconnectBehavior, "continue");
const optionSets = M365_DEFAULT_OPTION_SETS as readonly string[];
const messageTypes = ALLOWED_MESSAGE_TYPES as readonly string[];
- for (const stale of ["enable_msa_user", "pdnascan", "cwc_code_interpreter", "rich_responses"]) {
- assert.ok(!optionSets.includes(stale), `${stale} must not be in the default option sets`);
+ // Entries the 2026-08-21 capture showed that the #10718 lists lacked.
+ for (const present of [
+ "cwc_code_interpreter",
+ "rich_responses",
+ "async_client_interaction",
+ "flux_v3_references",
+ ]) {
+ assert.ok(optionSets.includes(present), `${present} must be in the default option sets`);
}
- for (const stale of ["InternalSearchQuery", "GeneratedCode", "RenderCardRequest", "AdsQuery", "SemanticSerp", "GenerateContentQuery"]) {
- assert.ok(!messageTypes.includes(stale), `${stale} must not be in allowedMessageTypes`);
+ for (const present of ["InternalSearchQuery", "GeneratedCode", "AuthError", "TriggerPlugin"]) {
+ assert.ok(messageTypes.includes(present), `${present} must be in allowedMessageTypes`);
}
- // Entries the capture showed and the old lists lacked.
+ // Entries the #10718 capture showed and this capture still confirms.
assert.ok(optionSets.includes("cwcfluxgptv"));
assert.ok(messageTypes.includes("EndOfRequest"));
});
@@ -134,8 +176,7 @@ test("#10718: default tier lists are the recaptured 14-entry optionsSets / 6-ent
// ── refresh_token helpers ──────────────────────────────────────────────────
function fakeJwt(claims: Record): string {
- const b64 = (value: unknown) =>
- Buffer.from(JSON.stringify(value)).toString("base64url");
+ const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
return `${b64({ alg: "none" })}.${b64(claims)}.sig`;
}
diff --git a/tests/unit/copilot-m365-tool-calls.test.ts b/tests/unit/copilot-m365-tool-calls.test.ts
index 679cdc522428..cf35483cece6 100644
--- a/tests/unit/copilot-m365-tool-calls.test.ts
+++ b/tests/unit/copilot-m365-tool-calls.test.ts
@@ -182,7 +182,9 @@ test("buildChatInvocation defaults stay backward compatible", () => {
requestId: "r",
conversationId: "c",
}).arguments[0] as Record;
- assert.deepEqual(arg.plugins, []);
+ // 2026-08-21 capture (#11069): BingWebSearch BuiltIn plugin is now the
+ // default on all tiers (was [] in the #10718 shape); toolChoice stays null.
+ assert.deepEqual(arg.plugins, [{ Id: "BingWebSearch", Source: "BuiltIn" }]);
assert.equal(arg.toolChoice, null);
assert.equal(arg.customInstructions, undefined);
});
diff --git a/tests/unit/cors/origins.test.ts b/tests/unit/cors/origins.test.ts
index 00950aa09756..7d8ecd42ecee 100644
--- a/tests/unit/cors/origins.test.ts
+++ b/tests/unit/cors/origins.test.ts
@@ -129,12 +129,12 @@ describe("cors/origins.applyCorsHeaders", () => {
assert.match(res.headers.get("Vary") || "", /Origin/);
});
- it("CLIENT_API: echoes arbitrary Origin (+Vary) when no allowlist matches (relaxForTokenAuth)", () => {
+ it("CLIENT_API: echoes arbitrary Origin (+Vary) for a token-carrying request (relaxForTokenAuth)", () => {
// Token-authenticated /v1/* surface (issue #5242): no allowlist, arbitrary
// origin → echo it back so browser/Electron renderers can read the body.
const res = NextResponse.json({ ok: true });
const req = new Request("https://server.example.com/api/v1/models", {
- headers: { Origin: "http://localhost" },
+ headers: { Origin: "http://localhost", Authorization: "Bearer omr_test_key" },
});
applyCorsHeaders(res, req, true);
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "http://localhost");
@@ -143,14 +143,40 @@ describe("cors/origins.applyCorsHeaders", () => {
assert.equal(res.headers.get("Access-Control-Allow-Credentials"), null);
});
- it("CLIENT_API: returns '*' when no Origin header is present (relaxForTokenAuth)", () => {
+ it("CLIENT_API: returns '*' when no Origin header is present for a token-carrying request", () => {
const res = NextResponse.json({ ok: true });
- const req = new Request("https://server.example.com/api/v1/models");
+ const req = new Request("https://server.example.com/api/v1/models", {
+ headers: { "x-api-key": "omr_test_key" },
+ });
applyCorsHeaders(res, req, true);
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "*");
assert.equal(res.headers.get("Access-Control-Allow-Credentials"), null);
});
+ it("CLIENT_API: does NOT echo the Origin for a credential-less cross-origin request (GHSA-7px7)", () => {
+ // A keyless install serves /v1 anonymously; echoing the Origin to a
+ // credential-less cross-origin page would let any visited page drive the
+ // gateway. Only token-carrying requests get the permissive echo.
+ const res = NextResponse.json({ ok: true });
+ const req = new Request("https://server.example.com/api/v1/models", {
+ headers: { Origin: "https://evil.example" },
+ });
+ applyCorsHeaders(res, req, true);
+ assert.equal(res.headers.get("Access-Control-Allow-Origin"), null);
+ });
+
+ it("CLIENT_API: a CORS preflight (OPTIONS) is still allowed through (relaxForTokenAuth)", () => {
+ // Preflight never carries the auth header; blocking it would break the
+ // credentialed request that follows, so OPTIONS keeps the permissive echo.
+ const res = new Response(null, { status: 204 });
+ const req = new Request("https://server.example.com/api/v1/models", {
+ method: "OPTIONS",
+ headers: { Origin: "http://localhost" },
+ });
+ applyCorsHeaders(res, req, true);
+ assert.equal(res.headers.get("Access-Control-Allow-Origin"), "http://localhost");
+ });
+
it("MANAGEMENT: stays fail-closed for arbitrary Origin with no allowlist (relax off)", () => {
const res = NextResponse.json({ ok: true });
const req = new Request("https://server.example.com/api/keys", {
@@ -213,7 +239,11 @@ describe("cors/origins.applyCorsHeaders", () => {
it("CLIENT_API: appends Vary: Accept-Encoding even without an Origin header (#6737)", () => {
const res = NextResponse.json({ ok: true });
- const req = new Request("https://server.example.com/api/v1/models");
+ // Token-carrying request (post-GHSA-7px7 the permissive echo requires a
+ // credential); this test's point is the Vary: Accept-Encoding stamp.
+ const req = new Request("https://server.example.com/api/v1/models", {
+ headers: { "x-api-key": "omr_test_key" },
+ });
applyCorsHeaders(res, req, true);
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "*");
assert.match(res.headers.get("Vary") || "", /Accept-Encoding/);
diff --git a/tests/unit/dashboard/m365-har-import.test.ts b/tests/unit/dashboard/m365-har-import.test.ts
new file mode 100644
index 000000000000..63515c69e737
--- /dev/null
+++ b/tests/unit/dashboard/m365-har-import.test.ts
@@ -0,0 +1,78 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ extractM365CredentialFromHar,
+ describeHarImportExpiry,
+ M365_CHATHUB_WS_PREFIX,
+} from "../../../src/shared/utils/m365HarImport.ts";
+
+function fakeJwt(exp: number): string {
+ const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
+ return `${b64({ alg: "none" })}.${b64({ exp })}.sig`;
+}
+
+function harWithUrl(url: string | null): string {
+ return JSON.stringify({
+ log: { entries: url ? [{ request: { url } }] : [] },
+ });
+}
+
+test("extracts access_token + chathubPath from a matching ChatHub WS entry", () => {
+ const exp = Math.floor(Date.now() / 1000) + 3600;
+ const token = fakeJwt(exp);
+ const url = `${M365_CHATHUB_WS_PREFIX}oid-123%40tenant-456?chatsessionid=abc&access_token=${token}`;
+ const result = extractM365CredentialFromHar(harWithUrl(url));
+
+ assert.equal(result.ok, true);
+ if (!result.ok) return;
+ assert.equal(result.chathubPath, "oid-123@tenant-456");
+ assert.equal(result.apiKey, `access_token=${token}; chathubPath=oid-123@tenant-456`);
+ assert.ok(result.expiresAt !== null && Math.abs(result.expiresAt - exp * 1000) < 1000);
+});
+
+test("picks the LAST matching entry when a HAR has multiple turns", () => {
+ const oldToken = fakeJwt(1000);
+ const freshToken = fakeJwt(9999999999);
+ const har = JSON.stringify({
+ log: {
+ entries: [
+ { request: { url: `${M365_CHATHUB_WS_PREFIX}u%40t?access_token=${oldToken}` } },
+ { request: { url: "https://unrelated.example/" } },
+ { request: { url: `${M365_CHATHUB_WS_PREFIX}u%40t?access_token=${freshToken}` } },
+ ],
+ },
+ });
+ const result = extractM365CredentialFromHar(har);
+ assert.equal(result.ok, true);
+ if (!result.ok) return;
+ assert.ok(result.apiKey.includes(freshToken));
+});
+
+test("returns notJson for malformed input", () => {
+ const result = extractM365CredentialFromHar("not json{{{");
+ assert.deepEqual(result, { ok: false, error: "notJson" });
+});
+
+test("returns noEntries when log.entries is missing/not an array", () => {
+ const result = extractM365CredentialFromHar(JSON.stringify({ log: {} }));
+ assert.deepEqual(result, { ok: false, error: "noEntries" });
+});
+
+test("returns noChathubUrl when no request matches the ChatHub prefix", () => {
+ const result = extractM365CredentialFromHar(harWithUrl("https://example.com/"));
+ assert.deepEqual(result, { ok: false, error: "noChathubUrl" });
+});
+
+test("returns missingFields when the URL matches but lacks access_token or chathubPath", () => {
+ const result = extractM365CredentialFromHar(harWithUrl(`${M365_CHATHUB_WS_PREFIX}`));
+ assert.equal(result.ok, false);
+});
+
+test("describeHarImportExpiry classifies ok/warn/bad/unknown", () => {
+ const now = Date.now();
+ assert.equal(describeHarImportExpiry(now + 30 * 60000, now).tone, "ok");
+ assert.equal(describeHarImportExpiry(now + 5 * 60000, now).tone, "warn");
+ assert.equal(describeHarImportExpiry(now - 60000, now).tone, "bad");
+ assert.equal(describeHarImportExpiry(null, now).tone, "unknown");
+});
diff --git a/tests/unit/db-providers-split.test.ts b/tests/unit/db-providers-split.test.ts
index 220544ba2d4f..284a5193c84d 100644
--- a/tests/unit/db-providers-split.test.ts
+++ b/tests/unit/db-providers-split.test.ts
@@ -52,26 +52,38 @@ describe("providers/columns — normalizeBooleanColumn", () => {
});
describe("providers/columns — sanitizeRateLimitOverrides", () => {
- it("returns null for nullish / non-object / array input", () => {
- assert.equal(sanitizeRateLimitOverrides(null), null);
- assert.equal(sanitizeRateLimitOverrides(undefined), null);
- assert.equal(sanitizeRateLimitOverrides("x"), null);
- assert.equal(sanitizeRateLimitOverrides([1, 2]), null);
+ it("returns {sanitized:null,rejected:[]} for nullish / non-object / array input", () => {
+ assert.deepEqual(sanitizeRateLimitOverrides(null), { sanitized: null, rejected: [] });
+ assert.deepEqual(sanitizeRateLimitOverrides(undefined), { sanitized: null, rejected: [] });
+ assert.deepEqual(sanitizeRateLimitOverrides("x"), { sanitized: null, rejected: [] });
+ assert.deepEqual(sanitizeRateLimitOverrides([1, 2]), { sanitized: null, rejected: [] });
});
- it("keeps only allowed keys with non-negative integers", () => {
- assert.deepEqual(sanitizeRateLimitOverrides({ rpm: 10, bogus: 5, tpm: -1 }), { rpm: 10 });
+ it("keeps only allowed keys with non-negative integers, reports the rest as rejected", () => {
+ assert.deepEqual(sanitizeRateLimitOverrides({ rpm: 10, bogus: 5, tpm: -1 }), {
+ sanitized: { rpm: 10 },
+ rejected: ["bogus", "tpm"],
+ });
});
- it("returns null when nothing valid remains", () => {
- assert.equal(sanitizeRateLimitOverrides({ rpm: 1.5, nope: 3 }), null);
+ it("returns {sanitized:null} when nothing valid remains, with rejected keys", () => {
+ assert.deepEqual(sanitizeRateLimitOverrides({ rpm: 1.5, nope: 3 }), {
+ sanitized: null,
+ rejected: ["rpm", "nope"],
+ });
});
});
describe("providers/columns — sanitizeQuotaWindowThresholds", () => {
- it("keeps only 0-100 integers", () => {
- assert.deepEqual(sanitizeQuotaWindowThresholds({ a: 50, b: 120, c: 0 }), { a: 50, c: 0 });
+ it("keeps only 0-100 integers, reports the rest as rejected", () => {
+ assert.deepEqual(sanitizeQuotaWindowThresholds({ a: 50, b: 120, c: 0 }), {
+ sanitized: { a: 50, c: 0 },
+ rejected: ["b"],
+ });
});
- it("returns null when empty", () => {
- assert.equal(sanitizeQuotaWindowThresholds({ a: 200 }), null);
+ it("returns {sanitized:null} when nothing valid remains, with rejected keys", () => {
+ assert.deepEqual(sanitizeQuotaWindowThresholds({ a: 200 }), {
+ sanitized: null,
+ rejected: ["a"],
+ });
});
});
diff --git a/tests/unit/dify-key-validation-repro.test.ts b/tests/unit/dify-key-validation-repro.test.ts
new file mode 100644
index 000000000000..0bbf4303791d
--- /dev/null
+++ b/tests/unit/dify-key-validation-repro.test.ts
@@ -0,0 +1,100 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { after, before } from "node:test";
+import { createServer, type Server } from "node:http";
+
+import { readFileSync } from "node:fs";
+import { validateProviderApiKey } from "../../src/lib/providers/validation.ts";
+import {
+ difyValidationResultFromStatus,
+ resolveDifyChatMessagesUrl,
+} from "../../src/lib/providers/validation/dify.ts";
+import { difyProvider } from "../../open-sse/config/providers/registry/dify/index.ts";
+
+// #11002 — the `dify` provider is registered with format:"openai", so the generic OpenAI-like
+// validation probe hits GET /v1/models then POST /v1/chat/completions. Dify's native API serves
+// neither — it only exposes POST /v1/chat-messages (401 {"code":"unauthorized"} for a bad key).
+// Every real Dify app key therefore fails validation with the generic
+// "Provider validation endpoint not supported" instead of a clean invalid/valid verdict.
+//
+// The fake upstream below is Dify-faithful: /v1/models and /v1/chat/completions 404, while
+// /v1/chat-messages is the only route and answers 401 for a bad key.
+
+let server: Server;
+let baseUrl = "";
+
+before(async () => {
+ server = createServer((req, res) => {
+ const path = (req.url || "").split("?")[0];
+ if (path === "/v1/models") {
+ res.writeHead(404, { "content-type": "text/plain" });
+ res.end("Not Found");
+ } else if (path === "/v1/chat/completions") {
+ res.writeHead(404, { "content-type": "text/html" });
+ res.end("404 Not Found");
+ } else if (path === "/v1/chat-messages") {
+ res.writeHead(401, { "content-type": "application/json" });
+ res.end(JSON.stringify({ code: "unauthorized", message: "Access token is invalid" }));
+ } else {
+ res.writeHead(404, { "content-type": "text/plain" });
+ res.end("Not Found");
+ }
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("no assigned port");
+ baseUrl = `http://127.0.0.1:${address.port}`;
+});
+
+after(async () => {
+ await new Promise((resolve, reject) =>
+ server.close((err) => (err ? reject(err) : resolve()))
+ );
+});
+
+test("#11002 dify key validation probes /v1/chat-messages and rejects a bad key", async () => {
+ const result = await validateProviderApiKey({
+ provider: "dify",
+ apiKey: "app-test-key",
+ providerSpecificData: { baseUrl },
+ });
+ assert.equal(result.valid, false);
+ assert.equal(result.error, "Invalid API key");
+});
+
+test("#11002 dify status→result maps bad keys and valid keys", () => {
+ assert.deepEqual(difyValidationResultFromStatus(401), {
+ valid: false,
+ error: "Invalid API key",
+ });
+ assert.deepEqual(difyValidationResultFromStatus(403), {
+ valid: false,
+ error: "Invalid API key",
+ });
+ assert.deepEqual(difyValidationResultFromStatus(200), { valid: true, error: null });
+ assert.deepEqual(difyValidationResultFromStatus(500), {
+ valid: false,
+ error: "Dify validation failed (500)",
+ });
+});
+
+test("#11002 resolveDifyChatMessagesUrl always targets /v1/chat-messages", () => {
+ assert.equal(resolveDifyChatMessagesUrl("https://api.dify.ai"), "https://api.dify.ai/v1/chat-messages");
+ assert.equal(
+ resolveDifyChatMessagesUrl("https://selfhosted.example.com/v1"),
+ "https://selfhosted.example.com/v1/chat-messages"
+ );
+ assert.equal(
+ resolveDifyChatMessagesUrl("https://selfhosted.example.com/v1/chat-messages"),
+ "https://selfhosted.example.com/v1/chat-messages"
+ );
+});
+
+test("#11002 dify registry baseUrl is the bare API root, not /chat/completions", () => {
+ assert.equal(difyProvider.baseUrl, "https://api.dify.ai");
+ const src = readFileSync(
+ new URL("../../src/lib/providers/validation.ts", import.meta.url),
+ "utf8"
+ );
+ assert.match(src, /dify:\s*validateDifyProvider/);
+});
\ No newline at end of file
diff --git a/tests/unit/empty-stream-no-content-8649.test.ts b/tests/unit/empty-stream-no-content-8649.test.ts
index e46157fbe174..918ae7c4c065 100644
--- a/tests/unit/empty-stream-no-content-8649.test.ts
+++ b/tests/unit/empty-stream-no-content-8649.test.ts
@@ -252,3 +252,68 @@ test("#8649 buildStreamErrorChunks-shaped error must not be rewritten as empty c
assert.match(text, /AI Model Not Found/);
assert.doesNotMatch(text, /Provider returned empty content/);
});
+
+test("#8649 a Responses compaction-only stream is real output, not empty content", async () => {
+ // Codex remote compaction V2: POST /v1/responses with a compaction_trigger
+ // input item completes with output = [{type:"compaction", encrypted_content}]
+ // and no assistant text. The watcher's content keys do not include
+ // encrypted_content, so the healthy stream was followed by a synthetic
+ // response.failed ("Provider returned empty content") — which strict
+ // Responses clients reject even after response.completed.
+ const text = await runClientStream(
+ [
+ `data: {"type":"response.in_progress"}\n\n`,
+ `event: response.created\ndata: ${JSON.stringify({
+ type: "response.created",
+ response: { id: "resp_cmp", status: "in_progress", output: [] },
+ })}\n\n`,
+ `event: response.completed\ndata: ${JSON.stringify({
+ type: "response.completed",
+ response: {
+ id: "resp_cmp",
+ status: "completed",
+ output: [
+ { id: "cmp_1", type: "compaction", encrypted_content: "gAAAAABencryptedpayload" },
+ ],
+ },
+ })}\n\n`,
+ ],
+ FORMATS.OPENAI_RESPONSES
+ );
+
+ assert.match(text, /"type":"compaction"/);
+ assert.doesNotMatch(
+ text,
+ /Provider returned empty content|response\.failed/,
+ "a completed compaction response must not be followed by a synthetic failure frame"
+ );
+});
+
+test("#8649 an encrypted-reasoning-only stream is still empty content", async () => {
+ // Inverse of the compaction carve-out: an encrypted reasoning item is not
+ // user-visible output. A turn that produces only a reasoning trace and no
+ // message/tool call is the fake-success shape this guard exists to catch.
+ const text = await runClientStream(
+ [
+ `event: response.created\ndata: ${JSON.stringify({
+ type: "response.created",
+ response: { id: "resp_r", status: "in_progress", output: [] },
+ })}\n\n`,
+ `event: response.completed\ndata: ${JSON.stringify({
+ type: "response.completed",
+ response: {
+ id: "resp_r",
+ status: "completed",
+ output: [{ id: "rs_1", type: "reasoning", encrypted_content: "gAAAAABencryptedtrace" }],
+ },
+ })}\n\n`,
+ ],
+ FORMATS.OPENAI_RESPONSES
+ );
+
+ assert.match(
+ text,
+ /response\.failed|Provider returned empty content/,
+ "a reasoning-only turn must keep tripping the empty-content guard"
+ );
+});
diff --git a/tests/unit/executor-command-code.test.ts b/tests/unit/executor-command-code.test.ts
index 06b8a51afdbd..56ee16b72752 100644
--- a/tests/unit/executor-command-code.test.ts
+++ b/tests/unit/executor-command-code.test.ts
@@ -14,11 +14,15 @@ describe("CommandCodeExecutor", () => {
assert.ok(executor);
});
- it("buildUrl returns a string", () => {
+ it("buildUrl targets the documented /provider/v1/chat/completions endpoint (#10265)", () => {
const executor = new mod.CommandCodeExecutor();
const url = executor.buildUrl();
assert.ok(typeof url === "string");
- assert.ok(url.includes("generate") && url.includes("commandcode"));
+ assert.ok(
+ url.includes("/provider/v1/chat/completions"),
+ `expected the documented provider API endpoint, got: ${url}`
+ );
+ assert.ok(url.includes("commandcode"));
});
it("execute throws when no API key", async () => {
@@ -57,7 +61,7 @@ describe("CommandCodeExecutor", () => {
}
});
- it("assistant tool-call conversion always emits a valid required arguments field (#regression input[N] missing required field arguments)", async () => {
+ it("posts a flat OpenAI chat.completions body (no CLI envelope) to /provider/v1/chat/completions (#10265)", async () => {
const calls: Array<{ url: string; init: RequestInit; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
@@ -70,60 +74,42 @@ describe("CommandCodeExecutor", () => {
}) as typeof fetch;
const executor = new mod.CommandCodeExecutor();
- const pairedId = "call_paired";
const body = {
+ model: "gpt-5.4",
messages: [
{ role: "user", content: "hi" },
{
role: "assistant",
content: "",
tool_calls: [
- // Missing arguments entirely -> must still get a valid arguments field
- { id: "call_missing", type: "function", function: { name: "lookup" } },
- // Empty string arguments -> "{}"
- {
- id: "call_empty",
- type: "function",
- function: { name: "lookup", arguments: "" },
- },
- // Valid object arguments -> round-trips as JSON string
- {
- id: pairedId,
- type: "function",
- function: { name: "lookup", arguments: { q: "docs" } },
- },
- // Valid string arguments -> preserved as-is
- {
- id: "call_string",
- type: "function",
- function: { name: "lookup", arguments: '{"q":"string"}' },
- },
- // Invalid JSON string arguments -> defaults to "{}"
- {
- id: "call_invalid",
- type: "function",
- function: { name: "lookup", arguments: "{invalid-json" },
- },
- // Tool call without name -> defaults tool-result toolName to "unknown"
{
- id: "call_unnamed",
+ id: "call_1",
type: "function",
- function: { arguments: { q: "unnamed" } },
+ function: { name: "lookup", arguments: '{"q":"docs"}' },
},
+ // Missing arguments entirely stays missing — passthrough, no CLI
+ // envelope injection of a synthetic `arguments` field.
+ { id: "call_2", type: "function", function: { name: "search" } },
],
},
- { role: "tool", tool_call_id: "call_missing", content: "r1" },
- { role: "tool", tool_call_id: "call_empty", content: "r2" },
- { role: "tool", tool_call_id: pairedId, content: "r3" },
- { role: "tool", tool_call_id: "call_string", content: "r4" },
- { role: "tool", tool_call_id: "call_invalid", content: "r5" },
- { role: "tool", tool_call_id: "call_unnamed", content: "r6" },
+ { role: "tool", tool_call_id: "call_1", content: "r1" },
+ { role: "tool", tool_call_id: "call_2", content: "r2" },
+ ],
+ tools: [
+ {
+ type: "function",
+ function: {
+ name: "lookup",
+ description: "lookup",
+ parameters: { type: "object", properties: { q: { type: "string" } } },
+ },
+ },
],
};
try {
await executor.execute({
- model: "test",
+ model: "gpt-5.4",
body,
stream: false,
credentials: { apiKey: "fake-key" },
@@ -134,186 +120,66 @@ describe("CommandCodeExecutor", () => {
}
assert.equal(calls.length, 1, "exactly one upstream call");
- const sentBody = calls[0].body as {
- params: { messages: Array<{ role: string; content: unknown }> };
- };
- const assistant = sentBody.params.messages.find((m) => m.role === "assistant");
- assert.ok(assistant, "assistant turn present");
- const parts = assistant.content as Array>;
- const toolCalls = parts.filter((p) => p.type === "tool-call");
- assert.equal(toolCalls.length, 6, "all six paired tool calls converted");
-
- for (const call of toolCalls) {
- assert.equal(
- typeof call.arguments,
- "string",
- `tool-call ${String(call.toolCallId)} must carry a string arguments field`
- );
- const parsed = JSON.parse(call.arguments as string);
- assert.equal(typeof parsed, "object");
- assert.ok(!Array.isArray(parsed), "arguments must parse to a JSON object");
- }
-
- const byId = new Map(toolCalls.map((c) => [String(c.toolCallId), c]));
- assert.equal(byId.get("call_missing").arguments, "{}", "missing arguments -> empty object");
- assert.equal(byId.get("call_empty").arguments, "{}", "empty string arguments -> empty object");
- assert.equal(
- byId.get(pairedId).arguments,
- '{"q":"docs"}',
- "object arguments round-trip as JSON string"
- );
- assert.equal(
- byId.get("call_string").arguments,
- '{"q":"string"}',
- "valid string arguments preserved as-is"
- );
- assert.equal(
- byId.get("call_invalid").arguments,
- "{}",
- "invalid JSON string arguments -> empty object"
- );
-
- const toolMsgs = sentBody.params.messages.filter((m) => m.role === "tool");
- assert.equal(toolMsgs.length, 6, "all 6 tool result messages present");
- const resultByName = new Map(
- toolMsgs.map((m) => {
- const p = (m.content as Array>)[0];
- return [String(p.toolCallId), String(p.toolName)];
- })
- );
- assert.equal(resultByName.get("call_missing"), "lookup");
- assert.equal(
- resultByName.get("call_unnamed"),
- "unknown",
- "unnamed call falls back to 'unknown'"
- );
-
- // /alpha/generate also requires `arguments` on tool-result parts; a
- // missing one is rejected with `input[N] missing required field 'arguments'`
- // (the index landing on the tool message). Echo the paired call's
- // normalized arguments.
- const resultById = new Map(
- toolMsgs.map((m) => {
- const p = (m.content as Array>)[0];
- return [String(p.toolCallId), p];
- })
- );
- assert.equal(resultById.size, 6, "each tool result maps to its call id");
- for (const p of resultById.values()) {
- assert.equal(
- typeof p.arguments,
- "string",
- `tool-result ${String(p.toolCallId)} must carry a string arguments field`
- );
- const parsed = JSON.parse(p.arguments as string);
- assert.equal(typeof parsed, "object");
- assert.ok(!Array.isArray(parsed), "tool-result arguments must parse to a JSON object");
- }
- assert.equal(
- resultById.get("call_missing").arguments,
- "{}",
- "tool-result echoes paired call's missing arguments as empty object"
+ assert.ok(
+ calls[0].url.includes("/provider/v1/chat/completions"),
+ `expected documented provider endpoint, got: ${calls[0].url}`
);
- assert.equal(
- resultById.get(pairedId).arguments,
- '{"q":"docs"}',
- "tool-result echoes paired call's object arguments as JSON string"
+ const sent = calls[0].body as Record;
+ // No CLI envelope.
+ assert.equal(sent.config, undefined, "CLI envelope `config` must not be sent");
+ assert.equal(sent.params, undefined, "CLI envelope `params` wrapper must not be sent");
+ assert.equal(sent.model, "gpt-5.4", "flat OpenAI model at top level");
+ assert.equal((sent.messages as Array<{ role: string }>)[0].role, "user");
+ // Assistant tool_calls pass through unchanged (no CLI tool-call/tool-result parts).
+ const assistant = (sent.messages as Array>).find(
+ (m) => m.role === "assistant"
);
- assert.equal(
- resultById.get("call_string").arguments,
- '{"q":"string"}',
- "tool-result echoes paired call's valid string arguments as-is"
- );
- assert.equal(
- resultById.get("call_empty").arguments,
- "{}",
- "tool-result echoes paired call's empty arguments as empty object"
+ assert.ok(assistant, "assistant turn present");
+ const toolCalls = assistant?.tool_calls as Array<{
+ id: string;
+ function: { name: string; arguments?: string };
+ }>;
+ assert.equal(toolCalls.length, 2, "both tool calls pass through untouched");
+ assert.equal(toolCalls[0].function.name, "lookup");
+ assert.equal(toolCalls[0].function.arguments, '{"q":"docs"}');
+ assert.equal(toolCalls[1].function.arguments, undefined, "missing arguments stays missing (no injection)");
+ // Tool role message (OpenAI flat) preserved.
+ const toolMsg = (sent.messages as Array>).find(
+ (m) => m.role === "tool"
);
+ assert.equal(toolMsg?.tool_call_id, "call_1");
assert.equal(
- resultById.get("call_invalid").arguments,
- "{}",
- "tool-result echoes paired call's invalid JSON arguments as empty object"
+ (sent.tools as Array<{ function: { name: string } }>)[0].function.name,
+ "lookup",
+ "tool definitions pass through in OpenAI shape (no rename)"
);
});
- it("COMMAND_CODE_VERSION default constant is 1.15.1", () => {
- assert.equal(mod.COMMAND_CODE_VERSION, "1.15.1");
- });
-
- it("renames tool names colliding with upstream built-ins on the wire and un-renames on the response (#regression input[N] missing required field arguments from a tool_search result)", async () => {
- // Upstream /alpha/generate normalizes tool-call/result parts against its
- // OWN built-in registry for matching names; `tool_search` collides and its
- // result is rejected with `input[N] missing required field 'arguments'`.
- // Verified live: renaming the pair to a non-colliding name passes.
- const body = {
- messages: [
- { role: "user", content: "hi" },
- {
- role: "assistant",
- content: "",
- tool_calls: [
- {
- id: "call_00_AAAAAAAAAAAAAAAAA",
- type: "function",
- function: { name: "tool_search", arguments: '{"query":"x"}' },
- },
- {
- id: "call_01_BBBBBBBBBBBBBBBBB",
- type: "function",
- function: { name: "lookup", arguments: '{"q":"1"}' },
- },
- ],
- },
- { role: "tool", tool_call_id: "call_00_AAAAAAAAAAAAAAAAA", content: "r1" },
- { role: "tool", tool_call_id: "call_01_BBBBBBBBBBBBBBBBB", content: "r2" },
- ],
- tools: [
- {
- type: "function",
- function: {
- name: "tool_search",
- description: "search tools",
- parameters: { type: "object", properties: { query: { type: "string" } } },
- },
- },
- {
- type: "function",
- function: {
- name: "lookup",
- description: "lookup",
- parameters: { type: "object", properties: { q: { type: "string" } } },
- },
- },
- ],
- };
-
- const calls: Array<{ url: string; init: RequestInit; body: unknown }> = [];
+ it("passes through the upstream OpenAI response and drops CLI-impersonation headers (#10265)", async () => {
+ const calls: Array<{ url: string; init: RequestInit }> = [];
const originalFetch = globalThis.fetch;
- // execute() makes a single upstream fetch; capture the wire request and
- // return a stream with a tool-call event using the renamed wire name.
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
- calls.push({
- url: String(url),
- init: init || {},
- body: JSON.parse(String((init as RequestInit | undefined)?.body)),
- });
- const streamBody =
- 'data: {"type":"tool-call","toolCallId":"c1","toolName":"omniroute_tool_search","input":{"query":"x"}}\n\n' +
- 'data: {"type":"finish","finishReason":"tool_use"}\n\n' +
+ calls.push({ url: String(url), init: init || {} });
+ const chunk =
+ 'data: {"id":"c1","object":"chat.completion.chunk","model":"gpt-5.4",' +
+ '"choices":[{"index":0,"delta":{"content":"hi"}}]}\n\n' +
+ 'data: {"id":"c1","object":"chat.completion.chunk","model":"gpt-5.4",' +
+ '"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":' +
+ '{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}\n\n' +
"data: [DONE]\n\n";
- return new Response(streamBody, {
+ return new Response(chunk, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}) as typeof fetch;
const executor = new mod.CommandCodeExecutor();
- let result: { response: Response } | null = null;
+ let result: { response: Response; headers: Record } | null = null;
try {
result = await executor.execute({
- model: "test",
- body,
- stream: false,
+ model: "gpt-5.4",
+ body: { messages: [{ role: "user", content: "hi" }] },
+ stream: true,
credentials: { apiKey: "fake-key" },
signal: null,
});
@@ -321,46 +187,17 @@ describe("CommandCodeExecutor", () => {
globalThis.fetch = originalFetch;
}
- const sentBody = calls[0].body as {
- params: {
- messages: Array<{ role: string; content: unknown }>;
- tools: Array<{ name: string }>;
- };
- };
- const toolDefNames = sentBody.params.tools.map((t) => t.name);
- assert.ok(
- toolDefNames.includes("omniroute_tool_search"),
- "colliding tool def renamed on the wire"
- );
- assert.ok(toolDefNames.includes("lookup"), "non-colliding tool def untouched");
-
- const assistant = sentBody.params.messages.find((m) => m.role === "assistant");
- const toolCallParts = (assistant?.content as Array>).filter(
- (p) => p.type === "tool-call"
- );
- const toolSearchCall = toolCallParts.find((p) => p.toolName === "omniroute_tool_search");
- assert.ok(toolSearchCall, "assistant tool-call part renamed on the wire");
- const lookupCall = toolCallParts.find((p) => p.toolName === "lookup");
- assert.ok(lookupCall, "non-colliding tool-call part untouched");
-
- const toolMsgs = sentBody.params.messages.filter((m) => m.role === "tool");
- const toolSearchResult = toolMsgs.find(
- (m) => (m.content as Array>)[0]?.toolName === "omniroute_tool_search"
- );
- assert.ok(toolSearchResult, "tool-result part renamed on the wire");
-
- // Response path: upstream emits the renamed wire name; the client must get
- // its original name back.
- assert.ok(result, "execute returned a response");
- const json = (await result.response.json()) as {
- choices: Array<{ message: { tool_calls?: Array<{ function: { name: string } }> } }>;
- };
- const toolCalls = json.choices[0].message.tool_calls ?? [];
- assert.equal(toolCalls.length, 1, "one tool call translated");
- assert.equal(
- toolCalls[0].function.name,
- "tool_search",
- "renamed wire name un-renamed for the client"
- );
+ assert.ok(result, "execute returned a result");
+ const headers = result.headers;
+ assert.equal(headers["x-command-code-version"], undefined, "CLI-impersonation header dropped");
+ assert.equal(headers["x-cli-environment"], undefined, "CLI-impersonation header dropped");
+ assert.equal(headers.Authorization, "Bearer fake-key");
+
+ // The upstream OpenAI SSE passes through untouched (no CLI re-parsing).
+ const text = await result.response.text();
+ assert.ok(text.includes("chat.completion.chunk"), "OpenAI-format SSE passed through");
+ assert.ok(text.includes('"content":"hi"'), "delta content preserved");
+ assert.ok(text.includes("[DONE]"), "stream terminator preserved");
+ assert.ok(text.includes('"prompt_tokens":2'), "OpenAI usage block passed through");
});
-});
+});
\ No newline at end of file
diff --git a/tests/unit/executor-kimi-web.test.ts b/tests/unit/executor-kimi-web.test.ts
index 85b01957dc74..43f894e63a5f 100644
--- a/tests/unit/executor-kimi-web.test.ts
+++ b/tests/unit/executor-kimi-web.test.ts
@@ -1,7 +1,7 @@
-// Tests for the international Kimi web executor (www.kimi.com Connect-RPC API).
+// Tests for the international Kimi web executor (www.kimi.ai Connect-RPC API).
//
// Previously this provider targeted kimi.moonshot.cn; that domain now redirects
-// every non-CN visitor to www.kimi.com, which uses a Connect-RPC streaming API.
+// every non-CN visitor to www.kimi.ai, which uses a Connect-RPC streaming API.
// These tests pin the parser behavior of the Connect envelope framing and the
// JSON event-delta extractor.
@@ -31,7 +31,7 @@ describe("KimiWebExecutor", () => {
assert.match(body.error.code, /HTTP_400|400/);
});
- it("execute targets www.kimi.com (not kimi.moonshot.cn)", async () => {
+ it("execute targets www.kimi.ai (not kimi.moonshot.cn)", async () => {
const executor = new mod.KimiWebExecutor();
let capturedUrl = "";
const originalFetch = globalThis.fetch;
diff --git a/tests/unit/gamification/leaderboard-limit-validation.test.ts b/tests/unit/gamification/leaderboard-limit-validation.test.ts
new file mode 100644
index 000000000000..b574e9b438a5
--- /dev/null
+++ b/tests/unit/gamification/leaderboard-limit-validation.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, before, after } from "node:test";
+import assert from "node:assert/strict";
+
+import { getTopN, LEADERBOARD_MAX_LIMIT } from "../../../src/lib/db/gamification";
+import { getDbInstance } from "../../../src/lib/db/core";
+
+// Regression for the unvalidated `?limit` that reached the SQLite LIMIT bind on
+// the leaderboard endpoints. In SQLite a negative LIMIT means "no limit", so a
+// caller passing limit=-1 would read the entire leaderboard; a non-integer would
+// throw a datatype mismatch. getTopN must clamp the bind as a backstop.
+describe("getTopN limit/offset clamping", () => {
+ const scope = "global";
+ const keys: string[] = [];
+
+ before(() => {
+ const db = getDbInstance();
+ for (let i = 0; i < 5; i++) {
+ const k = `test-lb-${Date.now()}-${i}`;
+ keys.push(k);
+ db
+ .prepare(
+ "INSERT OR REPLACE INTO leaderboard (api_key_id, scope, score, updated_at) VALUES (?, ?, ?, ?)"
+ )
+ .run(k, scope, 100 - i, new Date().toISOString());
+ }
+ });
+
+ after(() => {
+ const db = getDbInstance();
+ for (const k of keys) {
+ db.prepare("DELETE FROM leaderboard WHERE api_key_id = ?").run(k);
+ }
+ });
+
+ it("returns at most the requested number of rows", () => {
+ assert.equal(getTopN(scope, 2).length, 2);
+ });
+
+ it("treats a negative limit as empty, never as unbounded", () => {
+ // Pre-fix this returned every row (SQLite LIMIT -1 == no limit).
+ assert.equal(getTopN(scope, -1).length, 0);
+ assert.equal(getTopN(scope, -100).length, 0);
+ });
+
+ it("treats a non-integer limit as empty instead of throwing", () => {
+ assert.equal(getTopN(scope, Number.NaN).length, 0);
+ });
+
+ it("caps the limit at LEADERBOARD_MAX_LIMIT", () => {
+ const rows = getTopN(scope, LEADERBOARD_MAX_LIMIT + 5000);
+ assert.ok(rows.length <= LEADERBOARD_MAX_LIMIT);
+ });
+
+ it("never binds a negative offset", () => {
+ // Would throw or behave oddly if a negative offset reached SQLite.
+ assert.doesNotThrow(() => getTopN(scope, 2, -10));
+ });
+});
diff --git a/tests/unit/gemini-to-claude-tool-name-case-9008.test.ts b/tests/unit/gemini-to-claude-tool-name-case-9008.test.ts
index 798e130bd6c4..99d655335ebf 100644
--- a/tests/unit/gemini-to-claude-tool-name-case-9008.test.ts
+++ b/tests/unit/gemini-to-claude-tool-name-case-9008.test.ts
@@ -18,8 +18,7 @@ const { claudeToGeminiRequest } =
await import("../../open-sse/translator/request/claude-to-gemini.ts");
const { openaiToGeminiRequest } =
await import("../../open-sse/translator/request/openai-to-gemini.ts");
-const { restoreClaudeToolName } =
- await import("../../open-sse/services/claudeCodeToolRemapper.ts");
+const { restoreClaudeToolName } = await import("../../open-sse/services/claudeCodeToolRemapper.ts");
function toolUseName(events: Array> | null): string | undefined {
const start = (events || []).find(
@@ -27,9 +26,7 @@ function toolUseName(events: Array> | null): string | un
e.type === "content_block_start" &&
(e.content_block as Record | undefined)?.type === "tool_use"
);
- return (start?.content_block as Record | undefined)?.name as
- | string
- | undefined;
+ return (start?.content_block as Record | undefined)?.name as string | undefined;
}
test("#9008 restoreClaudeToolName: preserves PascalCase from the request map", () => {
@@ -50,9 +47,13 @@ test("#9008 restoreClaudeToolName: maps lowercased upstream names back to declar
assert.equal(restoreClaudeToolName("websearch", map), "WebSearch");
});
-test("#9008 restoreClaudeToolName: still lowercases TitleCase when no request map (#7926)", () => {
- assert.equal(restoreClaudeToolName("Bash", null), "bash");
- assert.equal(restoreClaudeToolName("Read", undefined), "read");
+test("#9008 restoreClaudeToolName: keeps canonical TitleCase when no request map (#11085 live repro)", () => {
+ // Live-tested 2026-08-22 (glm via opencode-go → /v1/messages): the gateway
+ // echoed Bash/Read TitleCase and claude-to-openai ships no _toolNameMap;
+ // downcasing here made Claude Code reject its own tools. Legacy lowercase
+ // clients are protected by explicit alias maps instead of blind downcasing.
+ assert.equal(restoreClaudeToolName("Bash", null), "Bash");
+ assert.equal(restoreClaudeToolName("Read", undefined), "Read");
});
test("#9008 Gemini → Claude: PascalCase tool_use survives when upstream echoes TitleCase", () => {
diff --git a/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts b/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts
index d14c136e7b3a..5d927de02a8d 100644
--- a/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts
+++ b/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
-// GLM-5.3 support (released 2026-08-14, https://z.ai/blog/glm-5.3).
+// GLM-5.3 support (released 2026-08-14, https://docs.z.ai/guides/llm/glm-5.3).
//
// Upstream ships ONE model id (`glm-5.3`) — effort is a request parameter
// (`reasoning_effort`: low|high|max, default max) on the coding chat/completions
@@ -12,14 +12,15 @@ import assert from "node:assert/strict";
// beta header), the 5.3 tiers use the documented `reasoning_effort` param on the
// OpenAI coding transport.
//
-// Spec caveat: Z.ai has not yet published the default context window — 1M is
-// mirrored from GLM-5.2 (same base model) per operator decision; correct when
-// the official spec lands.
+// Z.AI documents a 1M context window and 128K maximum output.
-const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts");
+const { getRegistryEntry, REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
const { GlmExecutor } = await import("../../open-sse/executors/glm.ts");
const { MODEL_SPECS } = await import("../../src/shared/constants/modelSpecs.ts");
const { GLM_PRICING } = await import("../../src/shared/constants/pricing/shared-tiers.ts");
+const metadataRegistry = await import("../../src/lib/modelMetadataRegistry.ts");
+const { shouldExposeSyncedEffortVariants, SYNCED_EFFORT_SKIP_PROVIDERS } =
+ await import("../../open-sse/utils/syncedEffortVariants.ts");
const GLM_5_3_IDS = ["glm-5.3", "glm-5.3-high", "glm-5.3-low"] as const;
@@ -38,6 +39,87 @@ function modelIds(provider: string): string[] {
return (entry.models ?? []).map((m) => m.id);
}
+test("shared GLM providers keep their dedicated aliases instead of synthesizing another layer", () => {
+ for (const provider of ["glm", "glm-cn", "glmt"]) {
+ assert.ok(SYNCED_EFFORT_SKIP_PROVIDERS.has(provider), provider);
+ assert.equal(
+ shouldExposeSyncedEffortVariants({
+ id: `${provider}/glm-5.3`,
+ owned_by: provider,
+ capabilities: { effort_tiers: ["low", "high", "max"] },
+ }),
+ false,
+ provider
+ );
+ }
+ assert.equal(SYNCED_EFFORT_SKIP_PROVIDERS.has("zcode"), false);
+});
+
+test("GLM family detection covers numeric, Z1, and bare provider model ids", () => {
+ for (const modelId of [
+ "hf:zai-org/GLM-5.2",
+ "THUDM/GLM-Z1-32B-0414",
+ "THUDM/GLM-Z1-9B-0414",
+ "glm",
+ ]) {
+ assert.equal(metadataRegistry.isGlmFamilyModel(modelId), true, modelId);
+ }
+ assert.equal(metadataRegistry.isGlmFamilyModel("llama-3.3"), false);
+});
+
+test("catalog suppresses inferred tiers for every GLM registry entry without a provider contract", () => {
+ let audited = 0;
+ for (const [provider, entry] of Object.entries(REGISTRY)) {
+ for (const model of entry.models ?? []) {
+ if (!metadataRegistry.isGlmFamilyModel(model.id, model.name)) continue;
+ audited += 1;
+ const enriched = metadataRegistry.enrichCatalogModelEntry({
+ id: `${provider}/${model.id}`,
+ object: "model",
+ owned_by: provider,
+ root: model.id,
+ }) as Record;
+ const capabilities = enriched.capabilities as Record;
+ if (capabilities.supportsThinking === true) {
+ assert.deepEqual(
+ capabilities.effort_tiers,
+ model.supportedThinkingEfforts ?? [],
+ `${provider}/${model.id}`
+ );
+ } else {
+ assert.equal("effort_tiers" in capabilities, false, `${provider}/${model.id}`);
+ }
+ }
+ }
+ assert.ok(audited > 0);
+});
+
+test("catalog exposes only GLM effort tiers that each provider can route", () => {
+ const routedTiers = new Map([
+ ["glm-5.3", ["low", "high", "max"]],
+ ["glm-5.3-high", ["high"]],
+ ["glm-5.3-low", ["low"]],
+ ["glm-5.2", ["high", "max"]],
+ ["glm-5.2-high", ["high"]],
+ ["glm-5.2-max", ["max"]],
+ ]);
+
+ for (const provider of ["glm", "glm-cn", "glmt", "zcode"]) {
+ for (const model of getRegistryEntry(provider)!.models ?? []) {
+ const enriched = metadataRegistry.enrichCatalogModelEntry({
+ id: `${provider}/${model.id}`,
+ object: "model",
+ owned_by: provider,
+ root: model.id,
+ }) as Record;
+ const capabilities = enriched.capabilities as Record;
+ const expected = provider === "zcode" ? [] : (routedTiers.get(model.id) ?? []);
+ assert.equal(capabilities.supportsThinking, true, `${provider}/${model.id}`);
+ assert.deepEqual(capabilities.effort_tiers, expected, `${provider}/${model.id}`);
+ }
+ }
+});
+
for (const provider of ["glm", "glm-cn", "glmt"]) {
test(`${provider} advertises the GLM-5.3 base model and effort tiers (GLM_SHARED_MODELS)`, () => {
const ids = modelIds(provider);
diff --git a/tests/unit/http-status-unprocessable-entity.test.ts b/tests/unit/http-status-unprocessable-entity.test.ts
new file mode 100644
index 000000000000..636e0ebe37f1
--- /dev/null
+++ b/tests/unit/http-status-unprocessable-entity.test.ts
@@ -0,0 +1,7 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { HTTP_STATUS } from "../../open-sse/config/constants.ts";
+
+test("HTTP_STATUS declares UNPROCESSABLE_ENTITY as 422", () => {
+ assert.equal(HTTP_STATUS.UNPROCESSABLE_ENTITY, 422);
+});
diff --git a/tests/unit/is-local-provider-11091.test.ts b/tests/unit/is-local-provider-11091.test.ts
new file mode 100644
index 000000000000..e5adc57b15a6
--- /dev/null
+++ b/tests/unit/is-local-provider-11091.test.ts
@@ -0,0 +1,38 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { isLocalProvider } from "../../open-sse/config/providerRegistry.ts";
+
+test("isLocalProvider detects RFC1918, CGNAT/Tailscale, and mDNS private hosts", () => {
+ // Local / loopback
+ assert.equal(isLocalProvider("http://localhost:11434/v1"), true);
+ assert.equal(isLocalProvider("http://127.0.0.1:11434/v1"), true);
+
+ // Docker 172.16/12
+ assert.equal(isLocalProvider("http://172.18.0.2:11434/v1"), true);
+
+ // RFC1918 LAN hosts (Issue #11091)
+ assert.equal(isLocalProvider("http://192.168.1.50:11434/v1"), true);
+ assert.equal(isLocalProvider("http://10.0.0.5:11434/v1"), true);
+
+ // Tailscale / CGNAT (100.64/10)
+ assert.equal(isLocalProvider("http://100.64.1.2:11434/v1"), true);
+
+ // Link-local (169.254/16)
+ assert.equal(isLocalProvider("http://169.254.1.1:11434/v1"), true);
+
+ // mDNS / private suffixes
+ assert.equal(isLocalProvider("http://studio.local:11434/v1"), true);
+ assert.equal(isLocalProvider("http://mybox.internal:11434/v1"), true);
+
+ // Public hosts (should be false)
+ assert.equal(isLocalProvider("https://api.openai.com/v1"), false);
+ assert.equal(isLocalProvider("https://api.anthropic.com/v1"), false);
+ assert.equal(isLocalProvider("http://8.8.8.8:8080/v1"), false);
+
+ // Fails open on missing or unparseable input (Issue #11091 review finding)
+ assert.equal(isLocalProvider(null), false);
+ assert.equal(isLocalProvider(undefined), false);
+ assert.equal(isLocalProvider(""), false);
+ assert.equal(isLocalProvider("not a url"), false);
+ assert.equal(isLocalProvider("file:///models"), false);
+});
diff --git a/tests/unit/kimi-partner-aff-links.test.ts b/tests/unit/kimi-partner-aff-links.test.ts
index 2592f03fa223..2db3c56636a0 100644
--- a/tests/unit/kimi-partner-aff-links.test.ts
+++ b/tests/unit/kimi-partner-aff-links.test.ts
@@ -7,9 +7,8 @@ import test from "node:test";
import assert from "node:assert/strict";
const providers = await import("../../src/shared/constants/providers.ts");
-const featuredProviders = await import(
- "../../src/app/(dashboard)/dashboard/providers/featuredProviders.ts"
-);
+const featuredProviders =
+ await import("../../src/app/(dashboard)/dashboard/providers/featuredProviders.ts");
const KIMI_CODING_AFF_URL = "https://www.kimi.com/code?aff=omniroute";
const KIMI_PLATFORM_AFF_URL = "https://platform.kimi.ai?aff=omniroute";
@@ -33,11 +32,11 @@ test("kimi-coding (Kimi Code CLI) top-of-page link: the Kimi Coding Plan aff lin
assert.equal(kimiCoding.website, KIMI_CODING_AFF_URL);
});
-test("kimi-web (Kimi Web) top-of-page link: the Kimi Coding Plan aff link (was the bare kimi.com domain)", () => {
+test("kimi-web (Kimi Web) top-of-page link: points to www.kimi.ai", () => {
const kimiWeb = providers.WEB_COOKIE_PROVIDERS["kimi-web"];
assert.ok(kimiWeb, "kimi-web must still exist in the web-cookie catalog");
assert.equal(kimiWeb.name, "Kimi Web", "display name is unchanged by the rename");
- assert.equal(kimiWeb.website, KIMI_CODING_AFF_URL);
+ assert.equal(kimiWeb.website, "https://www.kimi.ai");
});
test("kimi-coding-apikey (hidden, folds into kimi-coding card) also carries the aff link", () => {
@@ -69,8 +68,6 @@ test("no visible Kimi provider website field still points at the unattributed pl
test("runtime endpoints are untouched by the rename/aff-link changes (moonshot API base URL still api.moonshot.ai)", async () => {
// Guard against the aff-link change ever leaking into a runtime executor
// config — website is a UI navigation field only, never a fetch target.
- const registry = await import(
- "../../open-sse/config/providers/registry/moonshot/index.ts"
- );
+ const registry = await import("../../open-sse/config/providers/registry/moonshot/index.ts");
assert.equal(registry.moonshotProvider.baseUrl, "https://api.moonshot.ai/v1/chat/completions");
});
diff --git a/tests/unit/learned-reasoning-effort-caps.test.ts b/tests/unit/learned-reasoning-effort-caps.test.ts
new file mode 100644
index 000000000000..5d69a342c38f
--- /dev/null
+++ b/tests/unit/learned-reasoning-effort-caps.test.ts
@@ -0,0 +1,126 @@
+import { test, after, beforeEach } from "node:test";
+import assert from "node:assert/strict";
+import {
+ REASONING_EFFORT_ORDER,
+ parseReasoningEffortEnum,
+ recordLearnedReasoningEffort,
+ getLearnedReasoningEffort,
+ __test_resetLearnedReasoningEffortCaps,
+} from "../../open-sse/services/learnedReasoningEffortCaps.ts";
+
+beforeEach(() => {
+ __test_resetLearnedReasoningEffortCaps();
+});
+
+after(() => {
+ __test_resetLearnedReasoningEffortCaps();
+});
+
+// ── REASONING_EFFORT_ORDER ──────────────────────────────────────────────────
+
+test("REASONING_EFFORT_ORDER is none < minimal < low < medium < high < xhigh < max", () => {
+ assert.deepEqual(REASONING_EFFORT_ORDER, [
+ "none",
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
+ ]);
+});
+
+// ── parseReasoningEffortEnum ────────────────────────────────────────────────
+
+test("parseReasoningEffortEnum extracts the real OVH 422 enum (backtick-quoted)", () => {
+ const err =
+ "Failed to deserialize the JSON body into the target type: reasoning_effort: " +
+ "unknown variant `xhigh`, expected one of `none`, `high`, `medium`, `low`, `minimal`";
+ assert.deepEqual(parseReasoningEffortEnum(err), ["none", "high", "medium", "low", "minimal"]);
+});
+
+test("parseReasoningEffortEnum extracts a bare comma/and-joined enum with annotations", () => {
+ const err =
+ "Unexpected reasoning effort high. Supported types are xhigh (default), medium, and low.";
+ assert.deepEqual(parseReasoningEffortEnum(err), ["xhigh", "medium", "low"]);
+});
+
+test("parseReasoningEffortEnum drops unrecognized tokens", () => {
+ const err = "expected one of `none`, `turbo`, `high`";
+ assert.deepEqual(parseReasoningEffortEnum(err), ["none", "high"]);
+});
+
+test("parseReasoningEffortEnum returns null for unrelated error text", () => {
+ assert.equal(parseReasoningEffortEnum("connection refused"), null);
+ assert.equal(parseReasoningEffortEnum(""), null);
+ assert.equal(parseReasoningEffortEnum(null), null);
+ assert.equal(parseReasoningEffortEnum(undefined), null);
+});
+
+test("parseReasoningEffortEnum returns null when the list has no recognized token", () => {
+ assert.equal(parseReasoningEffortEnum("expected one of `foo`, `bar`"), null);
+});
+
+// ── recordLearnedReasoningEffort / getLearnedReasoningEffort ───────────────
+
+test("records the highest recognized value from the accepted list", () => {
+ const learned = recordLearnedReasoningEffort("ovh", "qwen3-coder-30b-a3b-instruct", [
+ "none",
+ "high",
+ "medium",
+ "low",
+ "minimal",
+ ]);
+ assert.equal(learned, "high");
+ assert.equal(getLearnedReasoningEffort("ovh", "qwen3-coder-30b-a3b-instruct"), "high");
+});
+
+test("returns null and stores nothing when acceptedValues has no recognized token", () => {
+ const learned = recordLearnedReasoningEffort("acme", "model-x", ["foo", "bar"]);
+ assert.equal(learned, null);
+ assert.equal(getLearnedReasoningEffort("acme", "model-x"), null);
+});
+
+test("monotonic decrease: a later, higher accepted-list never ratchets the cap back up", () => {
+ recordLearnedReasoningEffort("acme", "model-x", ["none", "low", "medium"]);
+ const learned = recordLearnedReasoningEffort("acme", "model-x", [
+ "none",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ ]);
+ assert.equal(learned, "medium");
+ assert.equal(getLearnedReasoningEffort("acme", "model-x"), "medium");
+});
+
+test("a later, lower accepted-list does ratchet the cap down", () => {
+ recordLearnedReasoningEffort("acme", "model-x", ["none", "low", "medium", "high"]);
+ const learned = recordLearnedReasoningEffort("acme", "model-x", ["none", "low"]);
+ assert.equal(learned, "low");
+ assert.equal(getLearnedReasoningEffort("acme", "model-x"), "low");
+});
+
+test("getLearnedReasoningEffort returns null for unknown provider+model", () => {
+ assert.equal(getLearnedReasoningEffort("acme", "unknown-model"), null);
+});
+
+test("getLearnedReasoningEffort is keyed case-insensitively on provider+model", () => {
+ recordLearnedReasoningEffort("OVH", "Qwen3-Coder-30B", ["none", "high"]);
+ assert.equal(getLearnedReasoningEffort("ovh", "qwen3-coder-30b"), "high");
+ assert.equal(getLearnedReasoningEffort("OVH", "QWEN3-CODER-30B"), "high");
+});
+
+test("different providers for the same model id have independent caps", () => {
+ recordLearnedReasoningEffort("ovh", "shared-model", ["none", "high"]);
+ assert.equal(getLearnedReasoningEffort("openrouter", "shared-model"), null);
+});
+
+test("handles empty/null provider or model gracefully", () => {
+ assert.equal(getLearnedReasoningEffort("", "m"), null);
+ assert.equal(getLearnedReasoningEffort("p", ""), null);
+ assert.equal(getLearnedReasoningEffort(null, "m"), null);
+ assert.equal(getLearnedReasoningEffort("p", null), null);
+ assert.equal(recordLearnedReasoningEffort("", "m", ["high"]), null);
+ assert.equal(recordLearnedReasoningEffort("p", "", ["high"]), null);
+});
diff --git a/tests/unit/local-redis-status.test.ts b/tests/unit/local-redis-status.test.ts
new file mode 100644
index 000000000000..956930664230
--- /dev/null
+++ b/tests/unit/local-redis-status.test.ts
@@ -0,0 +1,38 @@
+/**
+ * tests/unit/local-redis-status.test.ts
+ *
+ * Coverage for src/app/api/local/redis/status/route.ts:
+ * - The status endpoint must report OmniRoute as "connected" whenever the
+ * native REDIS_URL is reachable — not only when a Docker/Podman container
+ * is present. This is the production path used by this instance
+ * (redis on 127.0.0.1:6379, no container).
+ *
+ * Verified at the source-contract level (the route imports Next.js + the route
+ * guard, which is heavy to import in the native runner and would make the test
+ * environment-dependent on a live container runtime).
+ */
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const STATUS_SRC = path.resolve(__dirname, "../../src/app/api/local/redis/status/route.ts");
+const src = fs.readFileSync(STATUS_SRC, "utf8");
+
+test("redis status: reports running via REDIS_URL reachability, not only Docker", () => {
+ assert.ok(src.includes("parseRedisUrl"), "status route must parse REDIS_URL");
+ assert.ok(
+ src.includes("redisUrlReachable"),
+ "status route must probe REDIS_URL reachability"
+ );
+ assert.ok(
+ src.includes("redisUrlConfigured"),
+ "status route must report whether REDIS_URL is configured"
+ );
+ assert.ok(
+ src.includes("const running = container.running || redisUrlReachable;"),
+ "status route must treat a reachable native REDIS_URL as a connected state"
+ );
+});
diff --git a/tests/unit/local-rerank-logging.test.ts b/tests/unit/local-rerank-logging.test.ts
new file mode 100644
index 000000000000..3c6ec6279458
--- /dev/null
+++ b/tests/unit/local-rerank-logging.test.ts
@@ -0,0 +1,215 @@
+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-rerank-test-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const { invalidateDbCache } = await import("../../src/lib/db/readCache.ts");
+const { createProviderNode, createProviderConnection } =
+ await import("../../src/lib/db/providers.ts");
+const { getCallLogs, getCallLogById, waitForCallLogSaves } =
+ await import("../../src/lib/usage/callLogs.ts");
+const { POST } = await import("../../src/app/api/v1/rerank/route.ts");
+
+interface RerankSuccessResponse {
+ results: Array<{ index: number; relevance_score: number }>;
+}
+
+interface CallLogRow {
+ id: string;
+ model: string;
+ provider: string;
+ status: number;
+ error?: string;
+ connectionId?: string;
+}
+
+test.describe("Local rerank provider logging and fallback", () => {
+ const originalFetch = globalThis.fetch;
+
+ test.after(() => {
+ globalThis.fetch = originalFetch;
+ core.resetDbInstance();
+ try {
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ } catch {
+ // ignore
+ }
+ });
+
+ test("successfully logs local rerank calls and attaches metadata headers", async () => {
+ const now = new Date().toISOString();
+ await createProviderNode({
+ id: "vram",
+ name: "vram",
+ type: "openai",
+ prefix: "vram",
+ baseUrl: "http://127.0.0.1:8000/v1",
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ await createProviderConnection({
+ id: "conn-vram-1",
+ provider: "vram",
+ authType: "apikey",
+ name: "vram-local",
+ apiKey: "test-token",
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ invalidateDbCache("nodes");
+ invalidateDbCache("connections");
+
+ globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) => {
+ assert.equal(String(url), "http://127.0.0.1:8000/v1/rerank");
+ const parsedBody = JSON.parse(String(init?.body || "{}"));
+ assert.equal(parsedBody.model, "BAAI/bge-reranker-v2-m3");
+ assert.equal(parsedBody.query, "test query");
+ assert.deepEqual(parsedBody.documents, ["doc1", "doc2"]);
+
+ return new Response(
+ JSON.stringify({
+ results: [
+ { index: 0, relevance_score: 0.95 },
+ { index: 1, relevance_score: 0.2 },
+ ],
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ };
+
+ const req = new Request("http://localhost:20128/api/v1/rerank", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: "vram/BAAI/bge-reranker-v2-m3",
+ query: "test query",
+ documents: ["doc1", "doc2"],
+ }),
+ });
+
+ const res = await POST(req, {} as Record);
+ assert.equal(res.status, 200);
+ assert.equal(res.headers.get("x-omniroute-provider"), "vram");
+ assert.equal(res.headers.get("x-omniroute-model"), "BAAI/bge-reranker-v2-m3");
+
+ const json = (await res.json()) as RerankSuccessResponse;
+ assert.equal(json.results.length, 2);
+
+ await waitForCallLogSaves(15000);
+
+ const logs = (await getCallLogs({ limit: 10 })) as unknown as CallLogRow[];
+ const logEntry = logs.find((l) => l.model === "vram/BAAI/bge-reranker-v2-m3");
+ assert.ok(logEntry, "Expected call log entry for local rerank");
+ assert.equal(logEntry.provider, "vram");
+ assert.equal(logEntry.status, 200);
+
+ const detail = await getCallLogById(logEntry.id);
+ assert.deepEqual(detail?.requestBody, {
+ model: "vram/BAAI/bge-reranker-v2-m3",
+ query: "test query",
+ documents: ["doc1", "doc2"],
+ });
+ assert.deepEqual(detail?.responseBody, {
+ results: [
+ { index: 0, relevance_score: 0.95 },
+ { index: 1, relevance_score: 0.2 },
+ ],
+ });
+ });
+
+ test("falls back from /v1/rerank to /rerank when local provider returns 404", async () => {
+ const now = new Date().toISOString();
+ await createProviderNode({
+ id: "infinity",
+ name: "infinity",
+ type: "openai",
+ prefix: "infinity",
+ baseUrl: "http://127.0.0.1:7997",
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ await createProviderConnection({
+ id: "conn-infinity-1",
+ provider: "infinity",
+ authType: "apikey",
+ name: "infinity-local",
+ apiKey: "test-token",
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ invalidateDbCache("nodes");
+ invalidateDbCache("connections");
+
+ const urlsAttempted: string[] = [];
+ globalThis.fetch = async (url: string | URL | Request) => {
+ urlsAttempted.push(String(url));
+ if (String(url).endsWith("/v1/rerank")) {
+ return new Response("Not Found", { status: 404 });
+ }
+ return new Response(
+ JSON.stringify({
+ results: [{ index: 0, relevance_score: 0.99 }],
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ };
+
+ const req = new Request("http://localhost:20128/api/v1/rerank", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: "infinity/bge-reranker-large",
+ query: "search",
+ documents: ["doc1"],
+ }),
+ });
+
+ const res = await POST(req, {} as Record);
+ assert.equal(res.status, 200);
+ assert.deepEqual(urlsAttempted, [
+ "http://127.0.0.1:7997/v1/rerank",
+ "http://127.0.0.1:7997/rerank",
+ ]);
+ });
+
+ test("records error call log when local provider returns 500", async () => {
+ globalThis.fetch = async () => {
+ return new Response(JSON.stringify({ detail: "Local backend failure" }), {
+ status: 500,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+
+ const req = new Request("http://localhost:20128/api/v1/rerank", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: "vram/BAAI/bge-reranker-v2-m3",
+ query: "test query",
+ documents: ["doc1"],
+ }),
+ });
+
+ const res = await POST(req, {} as Record);
+ assert.equal(res.status, 500);
+
+ await waitForCallLogSaves(15000);
+
+ const logs = (await getCallLogs({ limit: 10 })) as unknown as CallLogRow[];
+ const logEntry = logs.find(
+ (l) => l.model === "vram/BAAI/bge-reranker-v2-m3" && l.status === 500
+ );
+ assert.ok(logEntry, "Expected 500 call log entry for local rerank failure");
+ assert.equal(logEntry.provider, "vram");
+ assert.equal(logEntry.error, "Local backend failure");
+ });
+});
diff --git a/tests/unit/m365-bizchat-frames-4042.test.ts b/tests/unit/m365-bizchat-frames-4042.test.ts
index cd67a0e42bb7..f89fae406e50 100644
--- a/tests/unit/m365-bizchat-frames-4042.test.ts
+++ b/tests/unit/m365-bizchat-frames-4042.test.ts
@@ -131,7 +131,22 @@ test("buildChatInvocation produces a type:4 chat invocation carrying the user te
assert.equal(arg.sessionId, "session-id");
assert.equal(arg.conversationId, "conversation-id");
assert.equal(arg.productThreadType, "Office");
- assert.deepEqual(arg.clientInfo, { clientAppName: "Office", clientPlatform: "mcmcopilot-web" });
+ // 2026-08-21 capture (#11069): clientInfo gained 8 keys (clientEntrypoint,
+ // clientSessionId, ProductCategory, clientAppType, productEntryPoint,
+ // deviceOS, deviceType, clientPlatformVersion). The #10718 base shape only
+ // carried clientAppName + clientPlatform.
+ assert.deepEqual(arg.clientInfo, {
+ clientAppName: "Office",
+ clientPlatform: "mcmcopilot-web",
+ clientEntrypoint: "mcmcopilot-officeweb",
+ clientSessionId: "session-id",
+ ProductCategory: "Chat",
+ clientAppType: "Web",
+ productEntryPoint: "ChatPanel",
+ deviceOS: "Windows",
+ deviceType: "Desktop",
+ clientPlatformVersion: "10",
+ });
assert.equal(arg.isStartOfSession, true);
assert.ok(Array.isArray(arg.optionsSets));
assert.ok((arg.optionsSets as string[]).includes("enable_gg_gpt"));
diff --git a/tests/unit/m365-tone-model-variants.test.ts b/tests/unit/m365-tone-model-variants.test.ts
index 8860c17cdc70..5152dd84314c 100644
--- a/tests/unit/m365-tone-model-variants.test.ts
+++ b/tests/unit/m365-tone-model-variants.test.ts
@@ -30,10 +30,11 @@ test("model-driven tone overrides the tier default; bare id keeps the tier tone"
const enterprise = resolveChatInvocationOverrides("enterprise");
const individual = resolveChatInvocationOverrides(undefined);
- // enterprise tier default tone is Magic; individual/EDU sends "magic" (#10718
- // recapture — the old "" default was part of the silently-dropped shape)
+ // enterprise tier default tone is Magic; individual/EDU now also sends
+ // "Magic" (capitalized) — the 2026-08-21 capture (#11069) showed lowercase
+ // "magic" is part of the shape that gets silently dropped (ping-only socket).
assert.equal(enterprise.tone, "Magic");
- assert.equal(individual.tone, "magic");
+ assert.equal(individual.tone, "Magic");
// precedence: resolveToneForModel(model) ?? overrides.tone (mirrors the executor wiring)
const toneFor = (model: string | undefined, tierTone: string) =>
@@ -43,9 +44,9 @@ test("model-driven tone overrides the tier default; bare id keeps the tier tone"
assert.equal(toneFor("copilot-m365-claude-opus", enterprise.tone), "Claude_Opus");
assert.equal(toneFor("copilot-m365-claude-opus", individual.tone), "Claude_Opus");
- // the bare id keeps whatever the tier resolved
+ // the bare id keeps whatever the tier resolved (both "Magic" post-#11069)
assert.equal(toneFor("copilot-m365", enterprise.tone), "Magic");
- assert.equal(toneFor("copilot-m365", individual.tone), "magic");
+ assert.equal(toneFor("copilot-m365", individual.tone), "Magic");
});
test("registry exposes the bare id (first) plus every tone variant", () => {
diff --git a/tests/unit/mcp-memory-tools-strategy.test.ts b/tests/unit/mcp-memory-tools-strategy.test.ts
index a0db42866312..ba740797caa8 100644
--- a/tests/unit/mcp-memory-tools-strategy.test.ts
+++ b/tests/unit/mcp-memory-tools-strategy.test.ts
@@ -187,3 +187,37 @@ test("omniroute_memory_search: hardcoded fallback config has retrievalStrategy=e
"fallback from catch path must use retrievalStrategy=exact"
);
});
+
+// ── IDOR: the authenticated caller's principal must win over a caller-supplied
+// apiKeyId (GHSA-cpv3-xr7r-xf8q). With a resolvable caller (here: OMNIROUTE_API_KEY
+// on the stdio path → "env-key"), omniroute_memory_add must store under the
+// caller, NOT under the arbitrary apiKeyId in the tool arguments.
+test("omniroute_memory_add: caller principal wins over a spoofed apiKeyId (GHSA-cpv3)", async () => {
+ const db = core.getDbInstance();
+ const prevEnvKey = process.env.OMNIROUTE_API_KEY;
+ process.env.OMNIROUTE_API_KEY = "test-mcp-caller-key";
+ try {
+ const { memoryTools } = await import("../../open-sse/mcp-server/tools/memoryTools.ts");
+ const result = await memoryTools.omniroute_memory_add.handler({
+ apiKeyId: "victim-b",
+ type: "factual",
+ key: "idor-k1",
+ content: "owned-by-caller",
+ });
+ assert.equal(result.success, true, "add must succeed");
+
+ const rows = db
+ .prepare("SELECT api_key_id FROM memories WHERE key = 'idor-k1'")
+ .all() as Array<{ api_key_id: string }>;
+ assert.equal(rows.length, 1, "exactly one memory row expected");
+ assert.equal(
+ rows[0].api_key_id,
+ "env-key",
+ "memory must be stored under the resolved caller (env-key), not the spoofed apiKeyId"
+ );
+ assert.notEqual(rows[0].api_key_id, "victim-b", "must NOT store under the caller-supplied id");
+ } finally {
+ if (prevEnvKey === undefined) delete process.env.OMNIROUTE_API_KEY;
+ else process.env.OMNIROUTE_API_KEY = prevEnvKey;
+ }
+});
diff --git a/tests/unit/memory-system-first-6135.test.ts b/tests/unit/memory-system-first-6135.test.ts
index 7104007ae641..339f83792cf0 100644
--- a/tests/unit/memory-system-first-6135.test.ts
+++ b/tests/unit/memory-system-first-6135.test.ts
@@ -49,6 +49,11 @@ describe("injectMemory system-must-be-first (#6135)", () => {
it("flags xiaomi-mimo (and alias mimo) as system-must-be-first", () => {
assert.equal(systemMessageMustBeFirst("xiaomi-mimo"), true);
assert.equal(systemMessageMustBeFirst("mimo"), true);
+ // tokenrouter: confirmed live 2026-08-22 — mid-array system message
+ // (e.g. the purifyHistory compression notice) -> HTTP 400
+ // "System message must be at the beginning".
+ assert.equal(systemMessageMustBeFirst("tokenrouter"), true);
+ assert.equal(systemMessageMustBeFirst("TokenRouter"), true); // case-insensitive
// default: unlisted providers keep current (non-first-constrained) behavior
assert.equal(systemMessageMustBeFirst("anthropic"), false);
assert.equal(systemMessageMustBeFirst(null), false);
diff --git a/tests/unit/monitoring-health-public-view.test.ts b/tests/unit/monitoring-health-public-view.test.ts
new file mode 100644
index 000000000000..0f8f7b220928
--- /dev/null
+++ b/tests/unit/monitoring-health-public-view.test.ts
@@ -0,0 +1,48 @@
+/**
+ * GHSA-mvf8-qc78-5mxm — GET /api/monitoring/health returned host-fingerprinting
+ * detail (version, node version, pid, memory, provider config) to anonymous
+ * callers. It now serves only the liveness verdict to non-management callers.
+ */
+
+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 type { NextRequest } from "next/server";
+import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-health-view-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const route = await import("../../src/app/api/monitoring/health/route.ts");
+
+test.after(() => {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+});
+
+test("anonymous health GET is reduced to liveness only (GHSA-mvf8)", async () => {
+ const res = await route.GET(new Request("http://localhost/api/monitoring/health") as never);
+ const body = (await res.json()) as Record;
+ assert.ok("status" in body, "liveness status must be present for probes");
+ // No host fingerprinting for an anonymous caller.
+ const keys = Object.keys(body);
+ const allowed = new Set(["status", "setupComplete"]);
+ for (const k of keys) {
+ assert.ok(allowed.has(k), `anonymous health view leaked field: ${k}`);
+ }
+});
+
+test("management session sees the full health payload", async () => {
+ const sessionReq = (await makeManagementSessionRequest(
+ "http://localhost/api/monitoring/health"
+ )) as unknown as NextRequest;
+ const res = await route.GET(sessionReq as never);
+ const body = (await res.json()) as Record;
+ assert.ok(
+ Object.keys(body).length > 2,
+ "a management caller must still receive the detailed payload"
+ );
+});
diff --git a/tests/unit/ninerouter-embed-port-6205.test.ts b/tests/unit/ninerouter-embed-port-6205.test.ts
index be967d2f535f..3306bc557a08 100644
--- a/tests/unit/ninerouter-embed-port-6205.test.ts
+++ b/tests/unit/ninerouter-embed-port-6205.test.ts
@@ -70,11 +70,19 @@ describe("#6205 A — embed panel root no longer 404s", () => {
// ─── SUB-BUG B: pre-spawn port/health decision ───────────────────────────────
describe("#6205 B — pre-spawn port probe avoids raw EADDRINUSE", () => {
- it("adopts a healthy existing instance (no spawn)", () => {
- const decision = decidePreSpawn({ healthy: true, portInUse: true }, 20130);
+ it("adopts a healthy existing instance when adoption is opted in (no spawn)", () => {
+ const decision = decidePreSpawn({ healthy: true, portInUse: true }, 20130, true);
assert.equal(decision.action, "adopt");
});
+ it("does NOT adopt a healthy listener by default — a 2xx cannot prove identity (GHSA-wg9p-6m2g-4v27)", () => {
+ const decision = decidePreSpawn({ healthy: true, portInUse: true }, 20130);
+ assert.equal(decision.action, "error");
+ assert.match(decision.message, /adopt/i);
+ assert.match(decision.message, /OMNIROUTE_ADOPT_EXISTING_SERVICE/);
+ assert.ok(!decision.message.includes("at /"), "must not leak a stack trace");
+ });
+
it("returns a clear error object (not a throw) when the port is held but unhealthy", () => {
let decision;
assert.doesNotThrow(() => {
@@ -92,9 +100,10 @@ describe("#6205 B — pre-spawn port probe avoids raw EADDRINUSE", () => {
assert.equal(decision.action, "spawn");
});
- it("adopts a healthy instance even if the TCP probe missed it", () => {
- // Health is authoritative: a 2xx means a real instance is serving.
- const decision = decidePreSpawn({ healthy: true, portInUse: false }, 20130);
+ it("adopts a healthy instance (opted in) even if the TCP probe missed it", () => {
+ // With adoption opted in, health is authoritative: a 2xx means a real
+ // instance is serving even when the TCP connect probe raced and missed it.
+ const decision = decidePreSpawn({ healthy: true, portInUse: false }, 20130, true);
assert.equal(decision.action, "adopt");
});
});
diff --git a/tests/unit/oauth-device-code-region-ssrf.test.ts b/tests/unit/oauth-device-code-region-ssrf.test.ts
new file mode 100644
index 000000000000..bc57659f7998
--- /dev/null
+++ b/tests/unit/oauth-device-code-region-ssrf.test.ts
@@ -0,0 +1,54 @@
+/**
+ * GHSA-7x63-xvp5-w2jc — the kiro / amazon-q device-code action interpolates a
+ * caller-supplied `region` into the AWS OIDC endpoint URLs that requestDeviceCode()
+ * fetches. An attacker-shaped region (userinfo / fragment) re-points the outbound
+ * host (SSRF → cloud metadata). The route must reject a non-canonical region with
+ * a 400 before any outbound fetch.
+ */
+
+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 type { NextRequest } from "next/server";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-oauth-region-ssrf-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const route = await import("../../src/app/api/oauth/[provider]/[action]/route.ts");
+
+test.after(() => {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+});
+
+async function deviceCode(provider: string, region: string) {
+ const url =
+ `http://localhost/api/oauth/${provider}/device-code` +
+ `?startUrl=${encodeURIComponent("https://d-1234567890.awsapps.com/start")}` +
+ `®ion=${encodeURIComponent(region)}`;
+ return route.GET(new Request(url) as unknown as NextRequest, {
+ params: Promise.resolve({ provider, action: "device-code" }),
+ });
+}
+
+test("kiro device-code rejects a non-canonical region before any outbound fetch (GHSA-7x63)", async () => {
+ for (const bad of [
+ "evil.com",
+ "169.254.169.254",
+ "us-east-1@169.254.169.254",
+ "us-east-1#.amazonaws.com@evil.com",
+ "us-east-1/../..",
+ "US-EAST-1", // uppercase is not the canonical shape
+ ]) {
+ const res = await deviceCode("kiro", bad);
+ assert.equal(res.status, 400, `region "${bad}" must be rejected with 400`);
+ }
+});
+
+test("amazon-q device-code also validates region", async () => {
+ const res = await deviceCode("amazon-q", "evil.com:1@169.254.169.254");
+ assert.equal(res.status, 400);
+});
diff --git a/tests/unit/oauth-device-flow-11164.test.ts b/tests/unit/oauth-device-flow-11164.test.ts
new file mode 100644
index 000000000000..48cdb23b6406
--- /dev/null
+++ b/tests/unit/oauth-device-flow-11164.test.ts
@@ -0,0 +1,38 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+test("device code normalization handles camelCase, snake_case, and authUrl without returning undefined", () => {
+ const cases = [
+ {
+ input: { userCode: "ABCD-1234", verificationUri: "https://auth.example.com" },
+ expectedCode: "ABCD-1234",
+ expectedUri: "https://auth.example.com",
+ },
+ {
+ input: { user_code: "EFGH-5678", verification_uri: "https://auth.example.com/device" },
+ expectedCode: "EFGH-5678",
+ expectedUri: "https://auth.example.com/device",
+ },
+ {
+ input: { authUrl: "https://studio.example.com/auth" },
+ expectedCode: "",
+ expectedUri: "https://studio.example.com/auth",
+ },
+ ];
+
+ for (const c of cases) {
+ const userCode = c.input.userCode ?? c.input.user_code ?? "";
+ const verificationUri =
+ c.input.verificationUriComplete ??
+ c.input.verification_uri_complete ??
+ c.input.verificationUri ??
+ c.input.verification_uri ??
+ c.input.authUrl ??
+ c.input.url ??
+ "";
+
+ assert.equal(userCode, c.expectedCode);
+ assert.equal(verificationUri, c.expectedUri);
+ assert.notEqual(verificationUri, "undefined");
+ }
+});
diff --git a/tests/unit/oauth-import-manage-scope.test.ts b/tests/unit/oauth-import-manage-scope.test.ts
new file mode 100644
index 000000000000..070515c02a9f
--- /dev/null
+++ b/tests/unit/oauth-import-manage-scope.test.ts
@@ -0,0 +1,75 @@
+/**
+ * GHSA-mg76-rhpx-gvw3 / GHSA-gxv4-955v-v6cm — OAuth import / auto-import routes
+ * create or read provider credentials. They were guarded only by isAuthenticated(),
+ * which (because /api/oauth/ is PUBLIC-classified) accepts ANY valid client API key.
+ * They must now require MANAGEMENT scope.
+ */
+
+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(), "omni-oauth-import-manage-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "oauth-import-manage-secret";
+process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
+
+const core = await import("../../src/lib/db/core.ts");
+const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
+const settingsDb = await import("../../src/lib/db/settings.ts");
+const codexImportToken = await import("../../src/app/api/oauth/codex/import-token/route.ts");
+const cursorAutoImport = await import("../../src/app/api/oauth/cursor/auto-import/route.ts");
+
+test.before(async () => {
+ process.env.JWT_SECRET = "oauth-import-manage-jwt";
+ process.env.INITIAL_PASSWORD = "oauth-import-manage-pass";
+ await settingsDb.updateSettings({ requireLogin: true });
+});
+
+test.after(() => {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ delete process.env.JWT_SECRET;
+ delete process.env.INITIAL_PASSWORD;
+});
+
+function post(route: { POST: (r: Request) => Promise }, key?: string) {
+ return route.POST(
+ new Request("http://localhost/api/oauth/codex/import-token", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ ...(key ? { authorization: `Bearer ${key}` } : {}),
+ },
+ body: JSON.stringify({ accessToken: "x", name: "poc" }),
+ })
+ );
+}
+
+function get(route: { GET: (r: Request) => Promise }, key?: string) {
+ return route.GET(
+ new Request("http://localhost/api/oauth/cursor/auto-import", {
+ headers: key ? { authorization: `Bearer ${key}` } : {},
+ })
+ );
+}
+
+test("codex/import-token: non-manage key → 403, no key → 401, manage key passes the auth gate (GHSA-mg76)", async () => {
+ const nonManage = await apiKeysDb.createApiKey("client", "machine-client", []);
+ const manage = await apiKeysDb.createApiKey("admin", "machine-admin", ["manage"]);
+
+ assert.equal((await post(codexImportToken, nonManage.key)).status, 403, "non-manage key rejected");
+ assert.equal((await post(codexImportToken)).status, 401, "no credential rejected");
+
+ const withManage = await post(codexImportToken, manage.key);
+ assert.notEqual(withManage.status, 401, "manage key must clear the auth gate");
+ assert.notEqual(withManage.status, 403, "manage key must clear the auth gate");
+});
+
+test("cursor/auto-import: a non-manage key cannot read the host's Cursor token (GHSA-gxv4)", async () => {
+ const nonManage = await apiKeysDb.createApiKey("client2", "machine-client2", []);
+ assert.equal((await get(cursorAutoImport, nonManage.key)).status, 403, "non-manage key rejected");
+ assert.equal((await get(cursorAutoImport)).status, 401, "no credential rejected");
+});
diff --git a/tests/unit/obsidian-webdav-route.test.ts b/tests/unit/obsidian-webdav-route.test.ts
index cfc839a60701..cfb93634923e 100644
--- a/tests/unit/obsidian-webdav-route.test.ts
+++ b/tests/unit/obsidian-webdav-route.test.ts
@@ -114,7 +114,46 @@ test("POST with a valid temp dir → returns { username, password }, GET shows e
const getBody = (await getRes.json()) as Record;
assert.equal(getBody.webdavEnabled, true);
assert.ok(typeof getBody.webdavUsername === "string" && (getBody.webdavUsername as string).length > 0);
- assert.ok(typeof getBody.webdavPassword === "string" && (getBody.webdavPassword as string).length > 0);
+ // Anonymous GET (this request carries no management credential): the plaintext
+ // password is masked (GHSA-62vw), but the set/unset flag still reflects state.
+ assert.equal(getBody.webdavPassword, null);
+ assert.equal(getBody.webdavPasswordSet, true);
+ } finally {
+ fs.rmSync(vaultDir, { recursive: true, force: true });
+ }
+});
+
+test("GET masks the WebDAV password for anonymous callers but reveals it to a management session (GHSA-62vw)", async () => {
+ const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault-62vw-"));
+ try {
+ // Enable WebDAV so there is a stored password to leak.
+ const enableRes = await route.POST(
+ makeRequest("http://localhost/api/settings/obsidian/webdav", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ vaultPath: vaultDir }),
+ })
+ );
+ assert.equal(enableRes.status, 200);
+
+ // Anonymous (open-mode) caller: password masked, flag still set.
+ const anonBody = (await (await route.GET(
+ makeRequest("http://localhost/api/settings/obsidian/webdav")
+ )).json()) as Record;
+ assert.equal(anonBody.webdavEnabled, true);
+ assert.equal(anonBody.webdavPassword, null, "anonymous caller must not receive the plaintext password");
+ assert.equal(anonBody.webdavPasswordSet, true);
+
+ // Genuine management session: the operator's reveal-password view still works.
+ const sessionReq = (await makeManagementSessionRequest(
+ "http://localhost/api/settings/obsidian/webdav"
+ )) as unknown as NextRequest;
+ const sessionBody = (await (await route.GET(sessionReq)).json()) as Record;
+ assert.ok(
+ typeof sessionBody.webdavPassword === "string" &&
+ (sessionBody.webdavPassword as string).length > 0,
+ "a management session must still receive the plaintext password"
+ );
} finally {
fs.rmSync(vaultDir, { recursive: true, force: true });
}
diff --git a/tests/unit/ollama-404-model-lockout-11071.test.ts b/tests/unit/ollama-404-model-lockout-11071.test.ts
new file mode 100644
index 000000000000..f58c29c07b97
--- /dev/null
+++ b/tests/unit/ollama-404-model-lockout-11071.test.ts
@@ -0,0 +1,66 @@
+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-ollama-404-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const providersDb = await import("../../src/lib/db/providers.ts");
+const auth = await import("../../src/sse/services/auth.ts");
+const { hasPerModelQuota, isModelLocked } = await import("../../open-sse/services/accountFallback.ts");
+
+async function resetStorage() {
+ 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("hasPerModelQuota returns true for ollama-local and ollama providers", () => {
+ assert.equal(hasPerModelQuota("ollama-local"), true);
+ assert.equal(hasPerModelQuota("ollama"), true);
+});
+
+test("markAccountUnavailable locks only the missing model on a 404 from ollama-local", async () => {
+ await resetStorage();
+
+ const connection = await providersDb.createProviderConnection({
+ provider: "ollama-local",
+ authType: "none",
+ baseUrl: "http://127.0.0.1:11434/v1",
+ isActive: true,
+ });
+
+ const result = await auth.markAccountUnavailable(
+ connection.id,
+ 404,
+ "model 'model-b' not found",
+ "ollama-local",
+ "model-b"
+ );
+
+ assert.equal(result.shouldFallback, true);
+
+ // The missing model must be locked
+ assert.equal(isModelLocked("ollama-local", connection.id, "model-b"), true);
+
+ // The connection in DB must remain active / not marked unavailable for sibling models
+ const connInDb = await providersDb.getProviderConnectionById(connection.id);
+ assert.notEqual(connInDb?.testStatus, "unavailable", "connection should not be marked unavailable connection-wide on a 404 model-not-found error");
+
+ // getProviderCredentials must still serve sibling models
+ const selectedForSibling = await auth.getProviderCredentials(
+ "ollama-local",
+ null,
+ null,
+ "model-a"
+ );
+ assert.ok(selectedForSibling && !("allExpired" in selectedForSibling), "sibling model-a must still be selected on the same connection");
+});
diff --git a/tests/unit/opencode-empty-rejection-rotation.test.ts b/tests/unit/opencode-empty-rejection-rotation.test.ts
new file mode 100644
index 000000000000..784a4b83a747
--- /dev/null
+++ b/tests/unit/opencode-empty-rejection-rotation.test.ts
@@ -0,0 +1,416 @@
+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, ProviderCredentials } from "../../open-sse/executors/base.ts";
+import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts";
+import {
+ isEmptyUpstreamRejection,
+ extractChatcmplId,
+} from "../../open-sse/executors/accountRotation.ts";
+
+/**
+ * Empty-upstream-rejection rotation (#design opencode-empty-rejection-rotation).
+ *
+ * An upstream 400 whose body carries no usable completion (the observed malformed
+ * envelope: `choices[0].message` with no error field, no real content,
+ * `finish_reason: null`) must be rotated/retried instead of propagated as a fatal
+ * success — that was killing subagent sessions. These tests pin the wiring:
+ *
+ * 1. A 400 empty rejection rotates to the next account (and its proxy).
+ * 2. The retry budget is bounded: +1 attempt for a single account, exactly N
+ * for an N-account all-empty run (propagate the last 400, never loop forever).
+ * 3. A 400 carrying a real error field (or non-empty content) still propagates
+ * immediately — no cooldown, no success, no rotation.
+ * 4. The 200/success path is never cloned or read (anti-bufferisation).
+ *
+ * The dispatch layer is mocked by stubbing globalThis.fetch (exactly what the
+ * #4954 proxy integration test does). Three throwaway TCP listeners stand in for
+ * the per-account proxies so runWithProxyContext's reachability probe passes.
+ */
+
+const log: ExecutorLog = { debug() {}, info() {}, warn() {}, error() {} };
+
+const ACCOUNT_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+const ACCOUNT_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+const ACCOUNT_C = "cccccccccccccccccccccccccccccccc";
+
+const EMPTY_BODY =
+ '{"id":"chatcmpl_44fn2g6e7kk","object":"chat.completion","created":1787419957,"model":"muse-spark-1.2-contributor-free","choices":[{"index":0,"message":{"role":"assistant"},"finish_reason":null}]}';
+const ERROR_BODY = JSON.stringify({
+ error: { message: "bad request", type: "invalid_request_error" },
+});
+
+let serverA: net.Server;
+let serverB: net.Server;
+let serverC: net.Server;
+let portA = 0;
+let portB = 0;
+let portC = 0;
+
+function listen(server: net.Server): Promise {
+ return new Promise((resolve) => {
+ server.listen(0, "127.0.0.1", () => {
+ resolve((server.address() as net.AddressInfo).port);
+ });
+ });
+}
+
+before(async () => {
+ serverA = net.createServer((s) => s.destroy());
+ serverB = net.createServer((s) => s.destroy());
+ serverC = net.createServer((s) => s.destroy());
+ portA = await listen(serverA);
+ portB = await listen(serverB);
+ portC = await listen(serverC);
+});
+
+after(() => {
+ serverA?.close();
+ serverB?.close();
+ serverC?.close();
+});
+
+function portFor(fp: string): number {
+ if (fp === ACCOUNT_A) return portA;
+ if (fp === ACCOUNT_B) return portB;
+ return portC;
+}
+
+/** `fingerprints` accounts; `proxied` is the subset that get a dedicated proxy
+ * (defaults to all). A proxy-less account shares the default egress. */
+function credentialsFor(
+ fingerprints: string[],
+ proxied: string[] = [...fingerprints]
+): ProviderCredentials {
+ return {
+ apiKey: null,
+ accessToken: null,
+ connectionId: "noauth",
+ providerSpecificData: {
+ fingerprints,
+ ...(proxied.length > 0 && {
+ accountProxies: proxied.map((fp) => ({
+ fingerprint: fp,
+ proxy: { type: "http", host: "127.0.0.1", port: portFor(fp) },
+ })),
+ }),
+ },
+ };
+}
+
+/** A Response subclass that counts clone() so we can assert the executor never
+ * buffers a 200/streaming response. Note: `clone()` returns a plain Response, so
+ * only `clone()` is reliably counted (a read on the clone hits the native
+ * method, not this override) — counting clones is the meaningful invariant. */
+class SpyResponse extends Response {
+ static clones = 0;
+ clone(): Response {
+ SpyResponse.clones++;
+ return super.clone();
+ }
+}
+
+interface PlanStep {
+ status: number;
+ body?: string;
+ throw?: Error;
+}
+
+describe("OpencodeExecutor empty-rejection rotation", () => {
+ let originalFetch: typeof globalThis.fetch;
+ let observed: Array<{ source: string; host: string | null; port: string | null }>;
+ const GUARD_FLAG = "NETWORK_ROTATION_SHARED_EGRESS_GUARD";
+ let savedGuardFlag: string | undefined;
+
+ beforeEach(() => {
+ originalFetch = globalThis.fetch;
+ observed = [];
+ SpyResponse.clones = 0;
+ savedGuardFlag = process.env[GUARD_FLAG];
+ delete process.env[GUARD_FLAG];
+ });
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ if (savedGuardFlag === undefined) delete process.env[GUARD_FLAG];
+ else process.env[GUARD_FLAG] = savedGuardFlag;
+ });
+
+ function installFetch(plan: PlanStep[]) {
+ let call = 0;
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ 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,
+ });
+ const step = plan[Math.min(call, plan.length - 1)];
+ call++;
+ if (step.throw) throw step.throw;
+ return new SpyResponse(step.body ?? JSON.stringify({ ok: step.status === 200 }), {
+ status: step.status,
+ headers: { "Content-Type": "application/json" },
+ });
+ }) as typeof globalThis.fetch;
+ }
+
+ /**
+ * Launches the executor. Asserts the predicate itself behaves (regression guard
+ * for the design's signature — the wiring tests below depend on it).
+ */
+ it("predicate matches the observed envelope and rejects real errors", () => {
+ assert.strictEqual(isEmptyUpstreamRejection(400, EMPTY_BODY), true);
+ assert.strictEqual(isEmptyUpstreamRejection(200, EMPTY_BODY), false);
+ assert.strictEqual(isEmptyUpstreamRejection(400, ERROR_BODY), false);
+ assert.strictEqual(extractChatcmplId(EMPTY_BODY), "chatcmpl_44fn2g6e7kk");
+ });
+
+ it("rotates to the next account on an empty 400 rejection (loop)", async () => {
+ const exec = new OpencodeExecutor("opencode-zen");
+ installFetch([{ status: 400, body: EMPTY_BODY }, { status: 200 }]);
+
+ const result = await exec.execute({
+ model: "deepseek-v4-flash-free",
+ body: { messages: [{ role: "user", content: "hi" }], stream: false },
+ stream: false,
+ signal: null,
+ credentials: credentialsFor([ACCOUNT_A, ACCOUNT_B]),
+ log,
+ });
+
+ assert.strictEqual(
+ (result as { response: Response }).response.status,
+ 200,
+ "must rotate past the empty 400"
+ );
+ assert.ok(observed.length >= 2, "should have dispatched on a second account");
+ assert.ok(
+ observed.some((o) => o.port === String(portA)),
+ "first attempt on account A"
+ );
+ assert.ok(
+ observed.some((o) => o.port === String(portB)),
+ "rotated attempt on account B"
+ );
+ });
+
+ it("caps an all-empty N-account run at N attempts and propagates the last 400 intact", async () => {
+ const exec = new OpencodeExecutor("opencode-zen");
+ installFetch([
+ { status: 400, body: EMPTY_BODY },
+ { status: 400, body: EMPTY_BODY },
+ { status: 400, body: EMPTY_BODY },
+ { status: 200 },
+ ]);
+
+ const result = await exec.execute({
+ model: "deepseek-v4-flash-free",
+ body: { messages: [{ role: "user", content: "hi" }], stream: false },
+ stream: false,
+ signal: null,
+ credentials: credentialsFor([ACCOUNT_A, ACCOUNT_B, ACCOUNT_C]),
+ log,
+ });
+
+ assert.strictEqual(
+ (result as { response: Response }).response.status,
+ 400,
+ "must propagate the last empty 400"
+ );
+ assert.strictEqual(observed.length, 3, "must NOT exceed N attempts (no infinite loop)");
+ assert.ok(SpyResponse.clones >= 1, "the empty 400 path must read the body to classify it");
+ const propagated = await (result as { response: Response }).response.clone().text();
+ assert.strictEqual(propagated, EMPTY_BODY, "propagated 400 body must stay intact");
+ for (const p of observed) {
+ assert.strictEqual(p.source, "context", "every dispatch must egress through a proxy context");
+ }
+ });
+
+ it("retries the same proxied account once when it is the only account", async () => {
+ const exec = new OpencodeExecutor("opencode-zen");
+ installFetch([
+ { status: 400, body: EMPTY_BODY },
+ { status: 400, body: EMPTY_BODY },
+ ]);
+
+ const result = await exec.execute({
+ model: "deepseek-v4-flash-free",
+ body: { messages: [{ role: "user", content: "hi" }], stream: false },
+ stream: false,
+ signal: null,
+ credentials: credentialsFor([ACCOUNT_A]),
+ log,
+ });
+
+ assert.strictEqual((result as { response: Response }).response.status, 400);
+ assert.strictEqual(observed.length, 2, "exactly one bounded retry on the sole account");
+ assert.ok(
+ observed.every((o) => o.port === String(portA)),
+ "both attempts egress through the single account's proxy"
+ );
+ });
+
+ it("coexists with 429 rotation and 200 success in the same request", async () => {
+ const exec = new OpencodeExecutor("opencode-zen");
+ installFetch([{ status: 429 }, { status: 400, body: EMPTY_BODY }, { status: 200 }]);
+
+ const result = await exec.execute({
+ model: "deepseek-v4-flash-free",
+ body: { messages: [{ role: "user", content: "hi" }], stream: false },
+ stream: false,
+ signal: null,
+ credentials: credentialsFor([ACCOUNT_A, ACCOUNT_B, ACCOUNT_C]),
+ log,
+ });
+
+ assert.strictEqual(
+ (result as { response: Response }).response.status,
+ 200,
+ "final response should succeed"
+ );
+ assert.strictEqual(observed.length, 3, "429 + empty-400 + success across three accounts");
+ assert.ok(
+ observed.some((o) => o.port === String(portA)),
+ "account A (429)"
+ );
+ assert.ok(
+ observed.some((o) => o.port === String(portB)),
+ "account B (empty 400)"
+ );
+ assert.ok(
+ observed.some((o) => o.port === String(portC)),
+ "account C (200)"
+ );
+ });
+
+ it("propagates a 400 carrying an error field immediately (no rotation)", async () => {
+ const exec = new OpencodeExecutor("opencode-zen");
+ installFetch([{ status: 400, body: ERROR_BODY }]);
+
+ const result = await exec.execute({
+ model: "deepseek-v4-flash-free",
+ body: { messages: [{ role: "user", content: "hi" }], stream: false },
+ stream: false,
+ signal: null,
+ credentials: credentialsFor([ACCOUNT_A, ACCOUNT_B]),
+ log,
+ });
+
+ assert.strictEqual(
+ (result as { response: Response }).response.status,
+ 400,
+ "real error 400 must propagate"
+ );
+ assert.strictEqual(observed.length, 1, "must NOT rotate on a genuine error 400");
+ });
+
+ it("never clones or reads the body of a 200 via the loop", async () => {
+ const exec = new OpencodeExecutor("opencode-zen");
+ installFetch([{ status: 200 }, { status: 200 }, { status: 200 }]);
+
+ const result = await exec.execute({
+ model: "deepseek-v4-flash-free",
+ body: { messages: [{ role: "user", content: "hi" }], stream: false },
+ stream: false,
+ signal: null,
+ credentials: credentialsFor([ACCOUNT_A, ACCOUNT_B, ACCOUNT_C]),
+ log,
+ });
+
+ assert.strictEqual((result as { response: Response }).response.status, 200);
+ assert.strictEqual(SpyResponse.clones, 0, "loop 200 must never be cloned");
+ });
+
+ it("retries once via the fast path when a direct account answers an empty 400", async () => {
+ const exec = new OpencodeExecutor("opencode-zen");
+ installFetch([
+ { status: 400, body: EMPTY_BODY },
+ { status: 400, body: EMPTY_BODY },
+ ]);
+
+ const result = await exec.execute({
+ model: "deepseek-v4-flash-free",
+ body: { messages: [{ role: "user", content: "hi" }], stream: false },
+ stream: false,
+ signal: null,
+ credentials: credentialsFor([ACCOUNT_A], []),
+ log,
+ });
+
+ assert.strictEqual((result as { response: Response }).response.status, 400);
+ assert.strictEqual(observed.length, 2, "fast path must retry the direct account exactly once");
+ });
+
+ it("propagates the second 400 intact when the fast path retries and empty-rejects again", async () => {
+ const exec = new OpencodeExecutor("opencode-zen");
+ installFetch([
+ { status: 400, body: EMPTY_BODY },
+ { status: 400, body: EMPTY_BODY },
+ ]);
+
+ const result = await exec.execute({
+ model: "deepseek-v4-flash-free",
+ body: { messages: [{ role: "user", content: "hi" }], stream: false },
+ stream: false,
+ signal: null,
+ credentials: credentialsFor([ACCOUNT_A], []),
+ log,
+ });
+
+ assert.strictEqual((result as { response: Response }).response.status, 400);
+ const propagated = await (result as { response: Response }).response.clone().text();
+ assert.strictEqual(propagated, EMPTY_BODY, "second rejection propagates with intact body");
+ assert.strictEqual(observed.length, 2, "exactly one retry, no loop");
+ });
+
+ it("never clones or reads the body of a 200 via the fast path", async () => {
+ const exec = new OpencodeExecutor("opencode-zen");
+ installFetch([{ status: 200 }]);
+
+ const result = await exec.execute({
+ model: "deepseek-v4-flash-free",
+ body: { messages: [{ role: "user", content: "hi" }], stream: false },
+ stream: false,
+ signal: null,
+ credentials: credentialsFor([ACCOUNT_A], []),
+ log,
+ });
+
+ assert.strictEqual((result as { response: Response }).response.status, 200);
+ assert.strictEqual(SpyResponse.clones, 0, "fast path 200 must never be cloned");
+ });
+
+ it("rotates to a proxied account after a proxy-less account empty-rejects (shared-egress guard on by default)", async () => {
+ const exec = new OpencodeExecutor("opencode-zen");
+ installFetch([{ status: 400, body: EMPTY_BODY }, { status: 200 }]);
+
+ const result = await exec.execute({
+ model: "deepseek-v4-flash-free",
+ body: { messages: [{ role: "user", content: "hi" }], stream: false },
+ stream: false,
+ signal: null,
+ // A proxy-less, B proxied: B must still be tried and succeed.
+ credentials: credentialsFor([ACCOUNT_A, ACCOUNT_B], [ACCOUNT_B]),
+ log,
+ });
+
+ assert.strictEqual(
+ (result as { response: { status: number } }).response.status,
+ 200,
+ "the proxied account (B) must still be tried and must succeed"
+ );
+ assert.strictEqual(observed.length, 2, "exactly one empty rejection (A) then one success (B)");
+ assert.ok(
+ observed.some((o) => o.source === "direct"),
+ "first dispatch on the proxy-less account"
+ );
+ assert.ok(
+ observed.some((o) => o.port === String(portB)),
+ "rotated dispatch on the proxied account"
+ );
+ });
+});
diff --git a/tests/unit/opencode-limit-output-10940.test.ts b/tests/unit/opencode-limit-output-10940.test.ts
index 653002ebe82e..317ae1f5a94e 100644
--- a/tests/unit/opencode-limit-output-10940.test.ts
+++ b/tests/unit/opencode-limit-output-10940.test.ts
@@ -54,8 +54,8 @@ describe("opencode config generator — limit.output always emitted (#10940)", (
`entry.limit.output must be a number, got ${JSON.stringify(entry.limit?.output)}`
);
assert.ok(entry.limit.output > 0, "entry.limit.output must be a positive number");
- // context stays unknown — we must NOT fabricate it.
- assert.strictEqual(entry.limit.context, undefined);
+ // context defaults to 128k when unknown to satisfy OpenCode's required limit.context schema (#11035).
+ assert.strictEqual(entry.limit.context, 128_000);
} finally {
stub.restore();
}
diff --git a/tests/unit/opencode-target-format-alias-11045.test.ts b/tests/unit/opencode-target-format-alias-11045.test.ts
new file mode 100644
index 000000000000..69caa31b29d8
--- /dev/null
+++ b/tests/unit/opencode-target-format-alias-11045.test.ts
@@ -0,0 +1,44 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ OpencodeExecutor,
+ resolveOpencodeTargetFormat,
+} from "../../open-sse/executors/opencode.ts";
+
+// oc/muse-spark-1.2-contributor-free returns HTTP 400 with an empty assistant
+// message right after `targetFormat:"openai-responses"` was added to its registry
+// entry (#10874). Root cause: `PROVIDER_MODELS` is keyed by the provider's public
+// ALIAS ("oc"), not its raw registry id ("opencode") — resolving the target format
+// with the raw id missed the registry entry entirely, so `buildUrl()` kept picking
+// `/chat/completions` while chatCore's own (correctly aliased) request translation
+// had already switched the request BODY to the Responses API shape (`input:[...]`).
+// Upstream received a Responses-shaped body at the Chat Completions endpoint and
+// returned a degenerate empty response.
+
+test("resolveOpencodeTargetFormat resolves the registry entry via the provider alias, not the raw id", () => {
+ assert.equal(
+ resolveOpencodeTargetFormat("opencode", "muse-spark-1.2-contributor-free"),
+ "openai-responses"
+ );
+ assert.equal(resolveOpencodeTargetFormat("opencode", "muse-spark-1.2"), "openai-responses");
+});
+
+test("resolveOpencodeTargetFormat falls back to 'openai' for a model with no registry targetFormat", () => {
+ assert.equal(resolveOpencodeTargetFormat("opencode", "hy3-free"), "openai");
+});
+
+test("resolveOpencodeTargetFormat falls back to 'openai' for an unknown provider (no alias, no direct match)", () => {
+ assert.equal(resolveOpencodeTargetFormat("not-a-real-provider", "muse-spark-1.2"), "openai");
+});
+
+test("OpencodeExecutor.buildUrl uses /responses once _requestFormat is resolved through the alias (live-bug repro)", () => {
+ const executor = new OpencodeExecutor("opencode");
+ // Mirrors the first line of execute() — see #11045.
+ (executor as unknown as { _requestFormat: string })._requestFormat = resolveOpencodeTargetFormat(
+ "opencode",
+ "muse-spark-1.2-contributor-free"
+ );
+ const url = executor.buildUrl("muse-spark-1.2-contributor-free", true);
+ assert.ok(url.endsWith("/responses"), `expected URL to end with /responses, got: ${url}`);
+});
diff --git a/tests/unit/opencode-v2-config-11070.test.ts b/tests/unit/opencode-v2-config-11070.test.ts
new file mode 100644
index 000000000000..71bd8a953781
--- /dev/null
+++ b/tests/unit/opencode-v2-config-11070.test.ts
@@ -0,0 +1,40 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const opencodeConfig = await import("../../src/shared/services/opencodeConfig.ts");
+
+test("buildOpenCodeConfigDocument includes both V1 (provider) and V2 (providers) definitions", () => {
+ const doc = opencodeConfig.buildOpenCodeConfigDocument({
+ baseUrl: "http://localhost:20128/v1",
+ apiKey: "{env:OMNIROUTE_API_KEY}",
+ models: ["auto/best-coding"],
+ });
+
+ assert.ok(doc.provider?.omniroute, "V1 provider.omniroute must be present");
+ assert.equal(doc.provider.omniroute.npm, "@ai-sdk/openai-compatible");
+ assert.equal(doc.provider.omniroute.options.baseURL, "http://localhost:20128/v1");
+
+ assert.ok(doc.providers?.omniroute, "V2 providers.omniroute must be present");
+ assert.equal(doc.providers.omniroute.package, "@opencode-ai/ai/providers/openai-compatible");
+ assert.equal(doc.providers.omniroute.settings.baseURL, "http://localhost:20128/v1");
+ assert.equal(doc.providers.omniroute.settings.apiKey, "{env:OMNIROUTE_API_KEY}");
+ assert.ok(doc.providers.omniroute.models["auto/best-coding"].limit, "V2 model limit must be present");
+});
+
+test("mergeOpenCodeConfig preserves existing properties and updates both provider and providers", () => {
+ const existing = {
+ $schema: "https://opencode.ai/config.json",
+ customField: "keep-me",
+ };
+
+ const merged = opencodeConfig.mergeOpenCodeConfig(existing, {
+ baseUrl: "http://localhost:20128/v1",
+ apiKey: "sk_test_key",
+ models: ["auto/best-coding"],
+ });
+
+ assert.equal(merged.customField, "keep-me");
+ assert.ok(merged.provider?.omniroute);
+ assert.ok(merged.providers?.omniroute);
+ assert.equal(merged.providers.omniroute.settings.apiKey, "sk_test_key");
+});
diff --git a/tests/unit/opencode-zen-go-shared-models.test.ts b/tests/unit/opencode-zen-go-shared-models.test.ts
new file mode 100644
index 000000000000..c1fb49e97d5f
--- /dev/null
+++ b/tests/unit/opencode-zen-go-shared-models.test.ts
@@ -0,0 +1,39 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../../open-sse/config/opencodeZenGoSharedModels.ts";
+import { opencode_zenProvider } from "../../open-sse/config/providers/registry/opencode/zen/index.ts";
+import { opencode_goProvider } from "../../open-sse/config/providers/registry/opencode/go/index.ts";
+
+test("every OPENCODE_ZEN_GO_SHARED_MODELS entry is present, unmodified, exactly once in opencode-zen", () => {
+ for (const shared of OPENCODE_ZEN_GO_SHARED_MODELS) {
+ const matches = opencode_zenProvider.models.filter((m) => m.id === shared.id);
+ assert.equal(matches.length, 1, `${shared.id} must appear exactly once in opencode-zen`);
+ assert.deepEqual(matches[0], shared, `${shared.id} must match the shared definition exactly`);
+ }
+});
+
+test("every OPENCODE_ZEN_GO_SHARED_MODELS entry is present, unmodified, exactly once in opencode-go", () => {
+ for (const shared of OPENCODE_ZEN_GO_SHARED_MODELS) {
+ const matches = opencode_goProvider.models.filter((m) => m.id === shared.id);
+ assert.equal(matches.length, 1, `${shared.id} must appear exactly once in opencode-go`);
+ assert.deepEqual(matches[0], shared, `${shared.id} must match the shared definition exactly`);
+ }
+});
+
+test("OPENCODE_ZEN_GO_SHARED_MODELS is frozen (no accidental cross-registry mutation)", () => {
+ assert.ok(Object.isFrozen(OPENCODE_ZEN_GO_SHARED_MODELS));
+});
+
+test("referenced non-shared model ids remain present", () => {
+ const goIds = new Set(opencode_goProvider.models.map((m) => m.id));
+ const zenIds = new Set(opencode_zenProvider.models.map((m) => m.id));
+ for (const id of ["minimax-m3", "glm-5.1"]) {
+ assert.ok(goIds.has(id) || zenIds.has(id), `expected ${id} in go or zen`);
+ }
+});
+
+test("models[0] is the intended dashboard default", () => {
+ assert.equal(opencode_goProvider.models[0].id, "glm-5.2");
+ assert.equal(opencode_zenProvider.models[0].id, "big-pickle");
+});
diff --git a/tests/unit/opencode-zen-muse-spark-targetformat-11046.test.ts b/tests/unit/opencode-zen-muse-spark-targetformat-11046.test.ts
new file mode 100644
index 000000000000..300cebfb555c
--- /dev/null
+++ b/tests/unit/opencode-zen-muse-spark-targetformat-11046.test.ts
@@ -0,0 +1,43 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { opencode_zenProvider } from "../../open-sse/config/providers/registry/opencode/zen/index.ts";
+import { opencode_goProvider } from "../../open-sse/config/providers/registry/opencode/go/index.ts";
+
+// opencode-zen and opencode-go both serve the same upstream family
+// (opencode.ai/zen/*) as the opencode provider for Muse Spark, but neither
+// got the targetFormat:"openai-responses" declaration that #10874 added to
+// the opencode provider's entries — so requests routed to either still hit
+// /chat/completions and the upstream returns an empty message.
+test("muse-spark-1.2 and muse-spark-1.2-contributor-free route to the Responses API on opencode-zen too", () => {
+ for (const id of ["muse-spark-1.2", "muse-spark-1.2-contributor-free"]) {
+ const model = opencode_zenProvider.models.find((m) => m.id === id);
+ assert.ok(model, `${id} should be registered in the opencode-zen provider`);
+ assert.equal(
+ model?.targetFormat,
+ "openai-responses",
+ `${id} must target the Responses API, not the default chat/completions pass-through`
+ );
+ assert.equal(model?.supportsReasoning, true);
+ }
+});
+
+test("all muse-spark-1.2-contributor effort-tier entries route to the Responses API on opencode-go too", () => {
+ const ids = [
+ "muse-spark-1.2-contributor",
+ "muse-spark-1.2-contributor-minimal",
+ "muse-spark-1.2-contributor-low",
+ "muse-spark-1.2-contributor-medium",
+ "muse-spark-1.2-contributor-high",
+ "muse-spark-1.2-contributor-xhigh",
+ ];
+ for (const id of ids) {
+ const model = opencode_goProvider.models.find((m) => m.id === id);
+ assert.ok(model, `${id} should be registered in the opencode-go provider`);
+ assert.equal(
+ model?.targetFormat,
+ "openai-responses",
+ `${id} must target the Responses API, not the default chat/completions pass-through`
+ );
+ }
+});
diff --git a/tests/unit/outbound-url-guard-local-flag.test.ts b/tests/unit/outbound-url-guard-local-flag.test.ts
new file mode 100644
index 000000000000..f754acd60a86
--- /dev/null
+++ b/tests/unit/outbound-url-guard-local-flag.test.ts
@@ -0,0 +1,49 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+const ENV_KEYS = [
+ "OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS",
+ "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS",
+ "OUTBOUND_SSRF_GUARD_ENABLED",
+];
+
+function withEnv(overrides: Record, fn: () => void) {
+ const saved: Record = {};
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
+ for (const k of ENV_KEYS) delete process.env[k];
+ Object.assign(process.env, overrides);
+ try {
+ fn();
+ } finally {
+ for (const k of ENV_KEYS) {
+ if (saved[k] === undefined) delete process.env[k];
+ else process.env[k] = saved[k];
+ }
+ }
+}
+
+test("#9123: setting ONLY OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS=true should relax the guard used for search-provider outbound calls (currently does not)", async () => {
+ const { areLocalProviderUrlsAllowed, getProviderOutboundGuard } = await import(
+ "../../src/shared/network/outboundUrlGuardPolicy.ts"
+ );
+
+ withEnv({ OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS: "true" }, () => {
+ assert.equal(areLocalProviderUrlsAllowed(), true);
+ assert.notEqual(
+ getProviderOutboundGuard(),
+ "public-only",
+ "BUG #9123: OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS should relax getProviderOutboundGuard() " +
+ "(used for search-provider outbound calls) the same way it already relaxes " +
+ "getProviderValidationGuard() for regular chat providers — it currently has no effect."
+ );
+ });
+});
+
+test("#9123 control: OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true DOES relax the same guard", async () => {
+ const { getProviderOutboundGuard } = await import(
+ "../../src/shared/network/outboundUrlGuardPolicy.ts"
+ );
+ withEnv({ OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS: "true" }, () => {
+ assert.equal(getProviderOutboundGuard(), "none");
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/perplexity-discovery-filter.test.ts b/tests/unit/perplexity-discovery-filter.test.ts
new file mode 100644
index 000000000000..853364674651
--- /dev/null
+++ b/tests/unit/perplexity-discovery-filter.test.ts
@@ -0,0 +1,63 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { PROVIDER_MODELS_CONFIG } from "../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts";
+
+// Regression guard for #11060 — Perplexity's /v1/models endpoint lists the
+// Agent API catalog (vendor-prefixed ids like "anthropic/claude-fable-5"), but
+// chat requests always go to the classic /chat/completions endpoint, which only
+// accepts the Sonar family. Without a PROVIDER_MODELS_CONFIG entry, generic
+// model import pulled those agent-style ids into the connection's chat model
+// list and every routed request failed with 400 "Invalid model". The discovery
+// entry must exist and its parseResponse must keep only Sonar-family ids.
+
+test("perplexity has a discovery entry in PROVIDER_MODELS_CONFIG", () => {
+ const cfg = PROVIDER_MODELS_CONFIG.perplexity;
+ assert.ok(cfg, "expected a perplexity entry in PROVIDER_MODELS_CONFIG");
+ assert.equal(cfg.method, "GET");
+ assert.equal(cfg.url, "https://api.perplexity.ai/v1/models");
+ assert.equal(typeof cfg.parseResponse, "function");
+});
+
+test("perplexity parseResponse keeps only the Sonar family (#11060)", () => {
+ const cfg = PROVIDER_MODELS_CONFIG.perplexity;
+ const models = cfg.parseResponse({
+ object: "list",
+ data: [
+ { id: "anthropic/claude-fable-5", object: "model", owned_by: "anthropic" },
+ { id: "sonar-pro", object: "model", owned_by: "perplexity" },
+ { id: "sonar", object: "model", owned_by: "perplexity" },
+ ],
+ }) as Array<{ id: string }>;
+
+ assert.deepEqual(
+ models.map((model) => model.id),
+ ["sonar-pro", "sonar"]
+ );
+});
+
+test("perplexity parseResponse keeps every Sonar variant and drops non-Sonar ids", () => {
+ const cfg = PROVIDER_MODELS_CONFIG.perplexity;
+ const models = cfg.parseResponse({
+ data: [
+ { id: "sonar-deep-research" },
+ { id: "sonar-reasoning-pro" },
+ { id: "sonar-pro" },
+ { id: "sonar" },
+ { id: "openai/gpt-5" },
+ { id: "sonarish" },
+ ],
+ }) as Array<{ id: string }>;
+
+ assert.deepEqual(
+ models.map((model) => model.id),
+ ["sonar-deep-research", "sonar-reasoning-pro", "sonar-pro", "sonar"]
+ );
+});
+
+test("perplexity parseResponse tolerates empty and malformed payloads", () => {
+ const cfg = PROVIDER_MODELS_CONFIG.perplexity;
+ assert.deepEqual(cfg.parseResponse({ data: [] }), []);
+ assert.deepEqual(cfg.parseResponse(undefined), []);
+ assert.deepEqual(cfg.parseResponse({}), []);
+});
diff --git a/tests/unit/pollinations-api-key-required-11096.test.ts b/tests/unit/pollinations-api-key-required-11096.test.ts
new file mode 100644
index 000000000000..8b69fb833121
--- /dev/null
+++ b/tests/unit/pollinations-api-key-required-11096.test.ts
@@ -0,0 +1,11 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { providerAllowsOptionalApiKey } from "../../src/shared/constants/providers.js";
+
+test("pollinations provider requires an API key and does not allow optional API key", () => {
+ assert.equal(
+ providerAllowsOptionalApiKey("pollinations"),
+ false,
+ "pollinations must require an API key because anonymous completions are no longer supported"
+ );
+});
diff --git a/tests/unit/private-host-ip-parity-11122.test.ts b/tests/unit/private-host-ip-parity-11122.test.ts
new file mode 100644
index 000000000000..3fb4f1049012
--- /dev/null
+++ b/tests/unit/private-host-ip-parity-11122.test.ts
@@ -0,0 +1,135 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { isIP } from "node:net";
+import { fileURLToPath } from "node:url";
+
+import { build } from "esbuild";
+
+import { ipVersion, isPrivateHost } from "../../src/shared/network/privateHost.ts";
+
+// #11122 — that PR pointed `isLocalProvider()` at `isPrivateHost`, imported from
+// `outboundUrlGuard.ts` (which imports `node:net`). `open-sse/config/providerRegistry.ts` is in
+// the `ProviderDetailPageClient.tsx` graph, so the browser bundle broke and
+// media-page-client-browser-bundle.test.ts went red on release/v3.8.50. `isPrivateHost` moved
+// here to fix it; two things must hold for that move to be safe:
+// 1. `ipVersion` agrees with `node:net#isIP` on every input — a NARROWER match would classify
+// a private address as public and open the egress the guard exists to close.
+// 2. The module stays bundleable for the browser (no `node:*`, no `@/` alias).
+
+const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
+
+const LITERALS = [
+ // IPv4 — valid
+ "0.0.0.0",
+ "127.0.0.1",
+ "10.0.0.1",
+ "100.64.0.1",
+ "169.254.169.254",
+ "172.16.0.1",
+ "172.31.255.254",
+ "192.168.1.50",
+ "8.8.8.8",
+ "255.255.255.255",
+ // IPv4 — invalid spellings node rejects
+ "010.1.1.1",
+ "1.2.3.4.5",
+ "1.2.3",
+ "256.1.1.1",
+ "1.2.3.-1",
+ "1.2.3.4 ",
+ " 1.2.3.4",
+ "1.2.3.04",
+ // IPv6 — valid
+ "::",
+ "::1",
+ "fd00::1",
+ "fe80::1",
+ "fc00::abcd",
+ "2001:db8::1",
+ "2001:0db8:0000:0000:0000:0000:0000:0001",
+ "::ffff:192.168.1.1",
+ "::ffff:a9fe:a9fe",
+ "64:ff9b::8.8.8.8",
+ "fe80::1%eth0",
+ "fe80::1%25",
+ // IPv6 — invalid
+ ":::",
+ "2001:db8::1::2",
+ "fe80::1%",
+ "gggg::1",
+ "2001:db8:::1",
+ // not IP literals at all
+ "",
+ "localhost",
+ "studio.local",
+ "api.openai.com",
+ "0x7f.1",
+ "2130706433",
+ "..",
+ "999",
+];
+
+test("ipVersion matches node:net#isIP across IP literals and near-misses", () => {
+ for (const host of LITERALS) {
+ assert.equal(
+ ipVersion(host),
+ isIP(host),
+ `ipVersion disagreed with isIP for ${JSON.stringify(host)}`
+ );
+ }
+});
+
+test("ipVersion matches node:net#isIP across generated IPv4 permutations", () => {
+ const segments = ["0", "00", "01", "9", "10", "099", "127", "192", "255", "256", "300", ""];
+ for (const a of segments) {
+ for (const b of segments) {
+ const host = `${a}.${b}.${a}.${b}`;
+ assert.equal(ipVersion(host), isIP(host), `ipVersion disagreed with isIP for ${host}`);
+ }
+ }
+});
+
+test("ipVersion matches node:net#isIP across generated IPv6 permutations", () => {
+ const groups = ["", "0", "1", "abcd", "ffff", "fffff", "xyz"];
+ for (const g of groups) {
+ for (const host of [`${g}::1`, `::${g}`, `${g}:${g}::${g}`, `2001:db8::${g}`, `[${g}::1]`]) {
+ assert.equal(ipVersion(host), isIP(host), `ipVersion disagreed with isIP for ${host}`);
+ }
+ }
+});
+
+test("an over-long input is rejected rather than fed to the alternation", () => {
+ // The length guard is the ReDoS bound (AGENTS.md → "Regex Security"). Node agrees: no legal
+ // literal is this long, so the fast path costs no accuracy.
+ const long = `${"f".repeat(200)}::1`;
+ assert.equal(ipVersion(long), 0);
+ assert.equal(isIP(long), 0);
+});
+
+test("isPrivateHost keeps its verdicts after the move", () => {
+ for (const host of ["", "localhost", "127.0.0.1", "::1", "[::1]", "10.1.2.3", "192.168.0.15"]) {
+ assert.equal(isPrivateHost(host), true, `expected private: ${JSON.stringify(host)}`);
+ }
+ for (const host of ["api.openai.com", "8.8.8.8", "172.32.0.1", "2001:db8::1"]) {
+ assert.equal(isPrivateHost(host), false, `expected public: ${host}`);
+ }
+});
+
+test("privateHost stays browser-bundle safe", async () => {
+ // The direct guard for the regression: providerRegistry -> privateHost is in the
+ // ProviderDetailPageClient graph, so a `node:*` import here breaks the dashboard build.
+ await assert.doesNotReject(
+ build({
+ absWorkingDir: REPO_ROOT,
+ entryPoints: [
+ fileURLToPath(new URL("../../src/shared/network/privateHost.ts", import.meta.url)),
+ ],
+ bundle: true,
+ format: "esm",
+ logLevel: "silent",
+ platform: "browser",
+ tsconfig: "tsconfig.json",
+ write: false,
+ })
+ );
+});
diff --git a/tests/unit/provider-connections-quota-threshold.test.ts b/tests/unit/provider-connections-quota-threshold.test.ts
index 5caaeea36993..e7812557e994 100644
--- a/tests/unit/provider-connections-quota-threshold.test.ts
+++ b/tests/unit/provider-connections-quota-threshold.test.ts
@@ -106,18 +106,36 @@ test("updateProviderConnection with explicit null clears the column entirely", a
assert.ok(reread.quotaWindowThresholds === null || reread.quotaWindowThresholds === undefined);
});
-test("DB serializer drops out-of-range values silently", async () => {
- // The DB module sanitizes the map on the way in; values outside 0-100 or
- // non-integers are pruned. This is a defense in depth — the Zod schema
- // already rejects them at the API boundary, but the DB shouldn't trust.
- const created = await providersDb.createProviderConnection({
- provider: "codex",
- authType: "apikey",
- name: "Codex Sanitize",
- apiKey: "sk-san",
- quotaWindowThresholds: { window5h: 95, bogus: 999, fractional: 1.5 },
- });
- assert.deepEqual(created.quotaWindowThresholds, { window5h: 95 });
+test("DB serializer refuses out-of-range / invalid values instead of dropping silently", async () => {
+ // the DB module must refuse the write (throw) rather than
+ // silently prune invalid keys/values on the way in, so operator intent is
+ // never lost without an error. The Zod schema already rejects at the API
+ // boundary; this is defense in depth for direct DB writers (seed/scripts).
+ await assert.rejects(
+ () =>
+ providersDb.createProviderConnection({
+ provider: "codex",
+ authType: "apikey",
+ name: "Codex Sanitize",
+ apiKey: "sk-san",
+ quotaWindowThresholds: { window5h: 95, bogus: 999, fractional: 1.5 },
+ }),
+ /rejected keys/
+ );
+});
+
+test("DB serializer refuses unknown rate-limit override keys instead of dropping silently", async () => {
+ await assert.rejects(
+ () =>
+ providersDb.createProviderConnection({
+ provider: "codex",
+ authType: "apikey",
+ name: "Codex Sanitize RLO",
+ apiKey: "sk-san-2",
+ rateLimitOverrides: { rpm: 10, bogus: 999, tpm: -1 },
+ }),
+ /rejected keys/
+ );
});
test("updateProviderConnectionSchema accepts a valid window map", () => {
diff --git a/tests/unit/provider-error-rules-operator.test.ts b/tests/unit/provider-error-rules-operator.test.ts
new file mode 100644
index 000000000000..6f8c9e52ef81
--- /dev/null
+++ b/tests/unit/provider-error-rules-operator.test.ts
@@ -0,0 +1,135 @@
+import { describe, it, beforeEach } from "node:test";
+import assert from "node:assert/strict";
+import {
+ getProviderErrorRuleMatch,
+ setOperatorProviderErrorRules,
+ resolveRuleMatchBody,
+ honorsRuleLockScope,
+ type OperatorProviderErrorRule,
+} from "../../open-sse/config/providerErrorRules.ts";
+
+describe("operator error rules", () => {
+ beforeEach(() => {
+ // Isolate each test from the settings-backed cache.
+ setOperatorProviderErrorRules(undefined);
+ });
+
+ it("operator rule overrides the catalog registry for a provider", () => {
+ const op: Record = {
+ nvidia: [{ status: 404, match: "Not found for account", scope: "model", cooldownMs: 1000 }],
+ };
+ const m = getProviderErrorRuleMatch("nvidia", 404, null, "Not found for account id 123", op);
+ assert.ok(m, "operator rule should match");
+ assert.equal(m.scope, "model");
+ assert.equal(m.cooldownMs, 1000);
+ });
+
+ it("operator rule wins even when a catalog rule would also match", () => {
+ const op: Record = {
+ openrouter: [{ status: 402, match: "credits exhausted", scope: "model" }],
+ };
+ const m = getProviderErrorRuleMatch("openrouter", 402, null, "credits exhausted on key", op);
+ assert.ok(m);
+ // Catalog rule for openrouter/402 uses scope "connection"; the operator
+ // override must take precedence.
+ assert.equal(m.scope, "model");
+ });
+
+ it("operator can reclassify a 401 before the global permanent rule", () => {
+ const op: Record = {
+ acme: [
+ { status: 401, match: "transient quota", scope: "connection", reason: "quota_exhausted" },
+ ],
+ };
+ const m = getProviderErrorRuleMatch("acme", 401, null, "transient quota — retry shortly", op);
+ assert.ok(m);
+ assert.equal(m.scope, "connection");
+ assert.equal(m.reason, "quota_exhausted");
+ });
+
+ it("unknown provider with no operator rule returns null (no throw)", () => {
+ const m = getProviderErrorRuleMatch("unknown-provider", 402, null, "anything");
+ assert.equal(m, null);
+ });
+
+ it("substring match is case-insensitive", () => {
+ const op: Record = {
+ nvidia: [{ status: 404, match: "NOT FOUND", scope: "model" }],
+ };
+ const m = getProviderErrorRuleMatch("nvidia", 404, null, "Body says Not Found Here", op);
+ assert.ok(m);
+ assert.equal(m.scope, "model");
+ });
+
+ it("status must match before the substring is considered", () => {
+ const op: Record = {
+ nvidia: [{ status: 404, match: "not found", scope: "model" }],
+ };
+ // 500 with the same body text must NOT match a 404 rule.
+ const m = getProviderErrorRuleMatch("nvidia", 500, null, "not found for account", op);
+ assert.equal(m, null);
+ });
+
+ it("without an operator override the catalog registry is intact", () => {
+ const m = getProviderErrorRuleMatch("openrouter", 402, null, "credits exhausted on key");
+ assert.ok(m);
+ assert.equal(m.scope, "connection");
+ assert.equal(m.cooldownMs, 2 * 60 * 1000);
+ });
+
+ it("reads the settings-backed cache via setOperatorProviderErrorRules", () => {
+ setOperatorProviderErrorRules({
+ nvidia: [{ status: 404, match: "Not found", scope: "model" }],
+ });
+ const m = getProviderErrorRuleMatch("nvidia", 404, null, "Not found for account");
+ assert.ok(m);
+ assert.equal(m.scope, "model");
+ // Provider key lookup is case-insensitive.
+ const m2 = getProviderErrorRuleMatch("NVIDIA", 404, null, "Not found here");
+ assert.ok(m2);
+ assert.equal(m2.scope, "model");
+ });
+
+ // Regression coverage for #11104's original gap: an operator rule for any
+ // provider outside the built-in FULL_TEXT_RULE_PROVIDERS/
+ // HONORS_RULE_LOCK_SCOPE_PROVIDERS allowlists was silently text-blind (only
+ // {code,type} reached the matcher) and had its declared scope dropped by the
+ // persistence layer. Declaring an operator rule for a provider must be
+ // sufficient by itself — no separate allowlist entry required.
+ describe("operator rule bypasses the built-in allowlists", () => {
+ it("resolveRuleMatchBody hands the full error text once an operator rule exists for the provider", () => {
+ setOperatorProviderErrorRules({
+ acme: [{ status: 404, match: "model withdrawn", scope: "model" }],
+ });
+ const body = resolveRuleMatchBody("acme", { code: "not_found" }, "Model withdrawn upstream");
+ assert.equal(body, "Model withdrawn upstream");
+ });
+
+ it("resolveRuleMatchBody keeps returning the structured error for a provider with no operator rule", () => {
+ const body = resolveRuleMatchBody("acme", { code: "not_found" }, "Model withdrawn upstream");
+ assert.deepEqual(body, { code: "not_found" });
+ });
+
+ it("honorsRuleLockScope is true once an operator rule exists for the provider", () => {
+ assert.equal(honorsRuleLockScope("acme"), false);
+ setOperatorProviderErrorRules({
+ acme: [{ status: 404, match: "model withdrawn", scope: "model" }],
+ });
+ assert.equal(honorsRuleLockScope("acme"), true);
+ });
+
+ it("an operator rule for a non-allowlisted provider matches on raw body text end to end", () => {
+ setOperatorProviderErrorRules({
+ acme: [{ status: 404, match: "model withdrawn", scope: "model" }],
+ });
+ const body = resolveRuleMatchBody(
+ "acme",
+ { code: "not_found" },
+ "Error: model withdrawn upstream"
+ );
+ const m = getProviderErrorRuleMatch("acme", 404, null, body);
+ assert.ok(m, "operator rule should match once resolveRuleMatchBody hands it the raw text");
+ assert.equal(m.scope, "model");
+ });
+ });
+});
diff --git a/tests/unit/provider-models-target-format-scoping.test.ts b/tests/unit/provider-models-target-format-scoping.test.ts
index b899eb960a8e..106fd7a020ec 100644
--- a/tests/unit/provider-models-target-format-scoping.test.ts
+++ b/tests/unit/provider-models-target-format-scoping.test.ts
@@ -8,12 +8,12 @@ import { resolveChatCoreTargetFormat } from "../../open-sse/handlers/chatCore/ta
// ghe-copilot catalog. getModelTargetFormat falls back to getGlobalModel() when
// the provider's own catalog lacks the model id, importing the DECLARING
// provider's endpoint semantics into every other provider serving the same id.
-// command-code's chat-shaped /alpha/generate executor then received a
+// command-code's chat-shaped executor then received a
// Responses-format body (input, not messages) and shipped `messages: []`
// upstream — upstream rejected with "Invalid prompt: messages must not be empty"
// (502). Model-level targetFormat is provider-scoped: it must not leak.
test("model-level targetFormat does not leak across provider catalogs", () => {
- // command-code serves gpt-5.6-luna over its chat-shaped /alpha/generate endpoint
+ // command-code serves gpt-5.6-luna over its chat-shaped provider endpoint
assert.equal(getModelTargetFormat("cmd", "gpt-5.6-luna"), null);
// raw provider id form behaves identically (alias resolution)
assert.equal(getModelTargetFormat("command-code", "gpt-5.6-luna"), null);
diff --git a/tests/unit/provider-route-schemas.test.ts b/tests/unit/provider-route-schemas.test.ts
index ca61c4f28d0c..30b5d4bd8c26 100644
--- a/tests/unit/provider-route-schemas.test.ts
+++ b/tests/unit/provider-route-schemas.test.ts
@@ -5,17 +5,19 @@ const { createProviderSchema, providersBatchTestSchema } =
await import("../../src/shared/validation/schemas.ts");
const { providerAllowsOptionalApiKey } = await import("../../src/shared/constants/providers.ts");
-test("Pollinations is treated as a keyless-capable provider", () => {
- assert.equal(providerAllowsOptionalApiKey("pollinations"), true);
+// #11117: Pollinations no longer serves anonymous requests (401 without a key),
+// so it left EXPLICIT_OPTIONAL_APIKEY_PROVIDER_IDS — key is now required.
+test("Pollinations requires an API key", () => {
+ assert.equal(providerAllowsOptionalApiKey("pollinations"), false);
});
-test("createProviderSchema allows Pollinations without apiKey", () => {
+test("createProviderSchema rejects Pollinations without apiKey", () => {
const result = createProviderSchema.safeParse({
provider: "pollinations",
name: "Pollinations",
});
- assert.equal(result.success, true);
+ assert.equal(result.success, false);
});
test("providersBatchTestSchema accepts cloud-agent batch mode", () => {
diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts
index ce172cd05ffd..cebf699d171a 100644
--- a/tests/unit/provider-validation-specialty.test.ts
+++ b/tests/unit/provider-validation-specialty.test.ts
@@ -16,7 +16,6 @@ const { __setTlsFetchOverrideForTesting: __setPplxTlsFetchOverride } =
const { __setTlsFetchOverrideForTesting: __setGrokTlsFetchOverride } =
await import("../../open-sse/services/grokTlsClient.ts");
-const { COMMAND_CODE_VERSION } = await import("../../open-sse/executors/commandCode.ts");
const originalFetch = globalThis.fetch;
@@ -216,11 +215,11 @@ test("specialty provider validators cover Deepgram, AssemblyAI, ElevenLabs and I
test("validateCommandCodeProvider ignores caller baseUrl and chatPath overrides", async () => {
globalThis.fetch = async (url, init = {}) => {
- assert.equal(String(url), "https://api.commandcode.ai/alpha/generate");
+ assert.equal(String(url), "https://api.commandcode.ai/provider/v1/chat/completions");
const headers = init.headers as Record;
assert.equal(headers.Authorization, "Bearer cc-key");
const body = JSON.parse(String(init.body));
- assert.equal(body.params.model, "command-code-validation-model");
+ assert.equal(body.model, "command-code-validation-model");
return new Response(JSON.stringify({ ok: true }), { status: 200 });
};
@@ -239,7 +238,7 @@ test("validateCommandCodeProvider ignores caller baseUrl and chatPath overrides"
test("validateCommandCodeProvider defaults probe model to DeepSeek flash", async () => {
globalThis.fetch = async (_url, init = {}) => {
const body = JSON.parse(String(init.body));
- assert.equal(body.params.model, "deepseek/deepseek-v4-flash");
+ assert.equal(body.model, "deepseek/deepseek-v4-flash");
return new Response("", { status: 400 });
};
@@ -2285,7 +2284,7 @@ test("specialty validator rejects invalid Runway credentials", async () => {
assert.equal(runway.error, "Invalid API key");
});
-test("validateCommandCodeProvider sends Command Code probe URL, headers, and wrapper body", async () => {
+test("validateCommandCodeProvider sends Command Code probe URL, headers, and flat OpenAI body", async () => {
const calls: Array<{
url: string;
method?: string;
@@ -2309,22 +2308,21 @@ test("validateCommandCodeProvider sends Command Code probe URL, headers, and wra
assert.deepEqual(result, { valid: true, error: null });
assert.equal(calls.length, 1);
- assert.equal(calls[0].url, "https://api.commandcode.ai/alpha/generate");
+ // Probe targets the documented /provider/v1/chat/completions endpoint, not
+ // the CLI-only /alpha/generate (#10265).
+ assert.equal(calls[0].url, "https://api.commandcode.ai/provider/v1/chat/completions");
assert.equal(calls[0].method, "POST");
assert.equal(calls[0].headers.Authorization, "Bearer cc_test_key");
assert.equal(calls[0].headers["Content-Type"], "application/json");
- assert.equal(calls[0].headers["x-command-code-version"], COMMAND_CODE_VERSION);
- assert.equal(calls[0].headers["x-cli-environment"], "external");
- assert.equal(calls[0].headers["x-project-slug"], "pi-cc");
- assert.equal(calls[0].headers["x-taste-learning"], "false");
- assert.equal(calls[0].headers["x-co-flag"], "false");
- assert.equal(typeof calls[0].headers["x-session-id"], "string");
- assert.equal(calls[0].body.config.environment, "external");
- assert.equal(calls[0].body.permissionMode, "standard");
- assert.equal(calls[0].body.skills, "");
- assert.equal(calls[0].body.params.model, "gpt-5.4-mini");
- assert.equal(calls[0].body.params.stream, true);
- assert.equal(calls[0].body.params.max_tokens, 1);
+ // No CLI-impersonation headers.
+ assert.equal(calls[0].headers["x-command-code-version"], undefined);
+ assert.equal(calls[0].headers["x-cli-environment"], undefined);
+ assert.equal(calls[0].headers["x-project-slug"], undefined);
+ // Flat OpenAI chat.completions body (no CLI wrapper).
+ assert.equal(calls[0].body.params, undefined, "CLI envelope params wrapper must not be sent");
+ assert.equal(calls[0].body.model, "gpt-5.4-mini");
+ assert.equal(calls[0].body.stream, true);
+ assert.equal(calls[0].body.max_tokens, 1);
});
for (const status of [400, 422, 429]) {
diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts
index 9276da220604..3eabac24bb96 100644
--- a/tests/unit/providers-constants-split.test.ts
+++ b/tests/unit/providers-constants-split.test.ts
@@ -24,7 +24,8 @@
// (base-reds round 3, #9985) are both included in that measurement; Cursor API (specialty-media,
// #10729) brings it to 229; Token Kiosk (gateways, #10722) — merged in the same
// merge-train batch — independently bumped the gateways family too, landing at 231; Freebuff
-// (gateways, #10531) brings it to 232.
+// (gateways, #10531) brings it to 232. #8864 moves uncloseai (gateways family) into
+// NOAUTH_PROVIDERS, dropping the APIKEY_PROVIDERS count to 231.
import { test } from "node:test";
import assert from "node:assert/strict";
@@ -53,12 +54,12 @@ test("barrel still exports every catalog + key helpers", () => {
}
});
-test("APIKEY_PROVIDERS merges the 6 family files into 232 entries (no loss / no dup)", async () => {
+test("APIKEY_PROVIDERS merges the 6 family files into 231 entries (no loss / no dup)", async () => {
const keys = Object.keys((P as Record).APIKEY_PROVIDERS);
- assert.equal(keys.length, 232);
- assert.equal(new Set(keys).size, 232, "duplicate keys after spread-merge");
+ assert.equal(keys.length, 231);
+ assert.equal(new Set(keys).size, 231, "duplicate keys after spread-merge");
// the merged object's entry-count equals the sum of the 6 semantic family files; families are a
- // strict partition (every provider in exactly one), so the sum must be exactly 232.
+ // strict partition (every provider in exactly one), so the sum must be exactly 231.
const families: [string, string][] = [
["gateways", "APIKEY_PROVIDERS_GATEWAYS"],
["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"],
@@ -78,7 +79,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 232 entries (no loss / no
seen.add(k);
}
}
- assert.equal(famTotal, 232, "families must partition all 232 providers");
+ assert.equal(famTotal, 231, "families must partition all 231 providers");
});
test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {
diff --git a/tests/unit/providers-patch-400.test.ts b/tests/unit/providers-patch-400.test.ts
new file mode 100644
index 000000000000..8ec1cbdf3bc8
--- /dev/null
+++ b/tests/unit/providers-patch-400.test.ts
@@ -0,0 +1,42 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { updateProviderConnectionSchema } from "@/shared/validation/schemas/provider";
+
+test("PATCH rateLimitOverrides {rpm:\"60\"} coerces to a valid number", () => {
+ const r = updateProviderConnectionSchema.safeParse({ rateLimitOverrides: { rpm: "60" } });
+ assert.equal(r.success, true);
+});
+
+test("unknown key in rateLimitOverrides is rejected (no silent drop)", () => {
+ const r = updateProviderConnectionSchema.safeParse({ rateLimitOverrides: { rpm: 10, foo: 1 } });
+ assert.equal(r.success, false);
+ const flaggedFoo = r.error!.issues.some(
+ (i) => i.path.includes("foo") || (i as { keys?: string[] }).keys?.includes("foo") || i.message.includes("foo")
+ );
+ assert.ok(
+ flaggedFoo,
+ `expected an issue flagging "foo", got: ${JSON.stringify(r.error!.issues)}`
+ );
+});
+
+test("quotaWindowThresholds key longer than 64 chars is rejected", () => {
+ const r = updateProviderConnectionSchema.safeParse({
+ quotaWindowThresholds: { ["a".repeat(65)]: 50 },
+ });
+ assert.equal(r.success, false);
+});
+
+test("empty string rate limit value is rejected (coerce \"\"→0 trap)", () => {
+ const r = updateProviderConnectionSchema.safeParse({ rateLimitOverrides: { rpm: "" } });
+ assert.equal(r.success, false);
+});
+
+test("non-numeric rate limit value is rejected", () => {
+ const r = updateProviderConnectionSchema.safeParse({ rateLimitOverrides: { rpm: "60abc" } });
+ assert.equal(r.success, false);
+});
+
+test("quotaWindowThresholds value outside 0-100 is rejected", () => {
+ const r = updateProviderConnectionSchema.safeParse({ quotaWindowThresholds: { win: 101 } });
+ assert.equal(r.success, false);
+});
diff --git a/tests/unit/providers-uncloseai-noauth.test.ts b/tests/unit/providers-uncloseai-noauth.test.ts
new file mode 100644
index 000000000000..f43aa302a481
--- /dev/null
+++ b/tests/unit/providers-uncloseai-noauth.test.ts
@@ -0,0 +1,21 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { NOAUTH_PROVIDERS, providerAllowsOptionalApiKey } from "@/shared/constants/providers";
+
+test("uncloseai should be treated as a no-auth provider", () => {
+ const isNoAuthRegistered = Object.prototype.hasOwnProperty.call(NOAUTH_PROVIDERS, "uncloseai");
+ const allowsOptionalKey = providerAllowsOptionalApiKey("uncloseai");
+
+ assert.equal(
+ isNoAuthRegistered || allowsOptionalKey,
+ true,
+ "uncloseai must be registered in NOAUTH_PROVIDERS or allow an optional API key " +
+ "so the dashboard doesn't force users to enter a key for a no-auth provider"
+ );
+ assert.equal(
+ isNoAuthRegistered,
+ true,
+ "uncloseai must be registered in NOAUTH_PROVIDERS (not just allow an optional key) " +
+ "so the dashboard renders the NoAuthProviderControls flow"
+ );
+});
diff --git a/tests/unit/quota-redis-store.test.ts b/tests/unit/quota-redis-store.test.ts
index 3b9d5ded4c03..5a5ae936dc88 100644
--- a/tests/unit/quota-redis-store.test.ts
+++ b/tests/unit/quota-redis-store.test.ts
@@ -20,6 +20,9 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-redis-store-"));
process.env.DATA_DIR = TEST_DATA_DIR;
@@ -282,3 +285,16 @@ test("redisQuotaStore: resetRedisQuotaStore resets the store singleton", async (
// After reset, a new instance is created
assert.ok(store2, "Should create new instance after reset");
});
+
+test("redis namespace prefix: quota store KEY_PREFIX derives from REDIS_KEY_PREFIX", () => {
+ const quotaSrc = fs.readFileSync(
+ path.resolve(__dirname, "../../src/lib/quota/redisQuotaStore.ts"),
+ "utf8"
+ );
+ assert.ok(
+ quotaSrc.includes('process.env.REDIS_KEY_PREFIX?.trim() || "omniroute:"') &&
+ quotaSrc.includes("const KEY_PREFIX = `") &&
+ quotaSrc.includes("quota`"),
+ "redisQuotaStore KEY_PREFIX must derive from REDIS_KEY_PREFIX env, defaulting to omniroute:quota"
+ );
+});
diff --git a/tests/unit/rate-limiter-redis-optional.test.ts b/tests/unit/rate-limiter-redis-optional.test.ts
index edbce9b53d7d..8f23f3eafeee 100644
--- a/tests/unit/rate-limiter-redis-optional.test.ts
+++ b/tests/unit/rate-limiter-redis-optional.test.ts
@@ -41,3 +41,14 @@ test("#2357 checkRateLimit falls back when REDIS_URL is unset", () => {
"checkRateLimit must route to the in-memory fallback when Redis is disabled"
);
});
+
+test("redis namespace prefix: rate limiter + auth cache keys are namespaced", () => {
+ assert.ok(
+ src.includes('process.env.REDIS_KEY_PREFIX?.trim() || "omniroute:"'),
+ "rateLimiter must read REDIS_KEY_PREFIX with an omniroute: default"
+ );
+ assert.ok(
+ src.includes("keyPrefix: REDIS_KEY_PREFIX"),
+ "rateLimiter must pass the prefix as the ioredis keyPrefix so all keys are namespaced"
+ );
+});
diff --git a/tests/unit/reasoning-cache.test.ts b/tests/unit/reasoning-cache.test.ts
index e13dfec7a95e..db538a803140 100644
--- a/tests/unit/reasoning-cache.test.ts
+++ b/tests/unit/reasoning-cache.test.ts
@@ -664,6 +664,7 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
{
type: "reasoning",
content: [{ type: "reasoning_text", text: "Cached Chat continuation reasoning" }],
+ summary: [],
}
);
});
@@ -751,47 +752,70 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
assert.equal(lookupReasoning(callId), "Authentic provider reasoning");
});
- it("should never cache Responses summaries or opaque plaintext companions", () => {
- for (const [suffix, reasoningItem] of [
- [
- "summary",
- {
- type: "reasoning",
- summary: [{ type: "summary_text", text: "Display-only summary" }],
- },
- ],
- [
- "mixed",
- {
- type: "reasoning",
- encrypted_content: "opaque-provider-state",
- content: [{ type: "reasoning_text", text: "Unsafe plaintext companion" }],
- summary: [{ type: "summary_text", text: "Display-only mixed summary" }],
- },
- ],
- ] as const) {
- clearReasoningCacheAll();
- const callId = `call_nonstream_${suffix}_reasoning`;
- const translated = translateNonStreamingResponse(
- {
- object: "response",
- model: "deepseek-v4-flash",
- output: [
- reasoningItem,
- { type: "function_call", call_id: callId, name: "read_file", arguments: "{}" },
- ],
- },
- FORMATS.OPENAI_RESPONSES,
- FORMATS.OPENAI
- ) as { choices?: Array<{ message?: Record }> };
- const message = translated.choices?.[0]?.message;
-
- assert.ok(message);
- assert.equal(message.reasoning_content, undefined);
- assert.ok(Array.isArray(message.reasoning_summary));
- assert.equal(cacheReasoningFromAssistantMessage(message, "deepseek", "deepseek-v4-flash"), 0);
- assert.equal(lookupReasoning(callId), null);
- }
+ it("preserves plaintext reasoning from a mixed plaintext + encrypted_content item (#10949)", () => {
+ clearReasoningCacheAll();
+ const callId = "call_nonstream_mixed_reasoning";
+ const translated = translateNonStreamingResponse(
+ {
+ object: "response",
+ model: "deepseek-v4-flash",
+ output: [
+ {
+ type: "reasoning",
+ content: [
+ {
+ type: "reasoning_text",
+ text: "Let me start by reading the directory to understand the structure of the corpus.",
+ },
+ ],
+ encrypted_content: "",
+ summary: [],
+ },
+ { type: "function_call", call_id: callId, name: "read_file", arguments: "{}" },
+ ],
+ },
+ FORMATS.OPENAI_RESPONSES,
+ FORMATS.OPENAI
+ ) as { choices?: Array<{ message?: Record }> };
+ const message = translated.choices?.[0]?.message;
+
+ assert.ok(message);
+ assert.equal(
+ message.reasoning_content,
+ "Let me start by reading the directory to understand the structure of the corpus."
+ );
+ assert.equal(cacheReasoningFromAssistantMessage(message, "deepseek", "deepseek-v4-flash"), 1);
+ assert.equal(
+ lookupReasoning(callId),
+ "Let me start by reading the directory to understand the structure of the corpus."
+ );
+ });
+
+ it("should never cache summary-only Responses reasoning", () => {
+ clearReasoningCacheAll();
+ const callId = "call_nonstream_summary_reasoning";
+ const translated = translateNonStreamingResponse(
+ {
+ object: "response",
+ model: "deepseek-v4-flash",
+ output: [
+ {
+ type: "reasoning",
+ summary: [{ type: "summary_text", text: "Display-only summary" }],
+ },
+ { type: "function_call", call_id: callId, name: "read_file", arguments: "{}" },
+ ],
+ },
+ FORMATS.OPENAI_RESPONSES,
+ FORMATS.OPENAI
+ ) as { choices?: Array<{ message?: Record }> };
+ const message = translated.choices?.[0]?.message;
+
+ assert.ok(message);
+ assert.equal(message.reasoning_content, undefined);
+ assert.ok(Array.isArray(message.reasoning_summary));
+ assert.equal(cacheReasoningFromAssistantMessage(message, "deepseek", "deepseek-v4-flash"), 0);
+ assert.equal(lookupReasoning(callId), null);
});
it("should preserve client-provided reasoning content", () => {
diff --git a/tests/unit/reasoning-effort-clamp-and-retry.test.ts b/tests/unit/reasoning-effort-clamp-and-retry.test.ts
new file mode 100644
index 000000000000..a97ac16df095
--- /dev/null
+++ b/tests/unit/reasoning-effort-clamp-and-retry.test.ts
@@ -0,0 +1,110 @@
+import { test, after, beforeEach } from "node:test";
+import assert from "node:assert/strict";
+import { BaseExecutor } from "../../open-sse/executors/base.ts";
+import {
+ getLearnedReasoningEffort,
+ recordLearnedReasoningEffort,
+ __test_resetLearnedReasoningEffortCaps,
+} from "../../open-sse/services/learnedReasoningEffortCaps.ts";
+
+const OVH_422_BODY = JSON.stringify({
+ error: {
+ message:
+ "Failed to deserialize the JSON body into the target type: reasoning_effort: " +
+ "unknown variant `xhigh`, expected one of `none`, `high`, `medium`, `low`, `minimal`",
+ },
+});
+
+// Passthrough executor: returns the body unchanged so we assert on exactly what
+// base.ts sends upstream.
+class SimpleExecutor extends BaseExecutor {
+ constructor() {
+ super("openai-compatible-chat-eaff6869", {
+ baseUrls: ["https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/chat/completions"],
+ });
+ }
+ async transformRequest(_model: string, body: Record) {
+ return { ...body };
+ }
+}
+
+beforeEach(() => {
+ __test_resetLearnedReasoningEffortCaps();
+});
+
+after(() => {
+ __test_resetLearnedReasoningEffortCaps();
+});
+
+test("422 'unknown variant xhigh, expected one of ...' clamps reasoning_effort and retries once", async () => {
+ const executor = new SimpleExecutor();
+ const originalFetch = globalThis.fetch;
+ const capturedBodies: Record[] = [];
+
+ globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => {
+ const body = JSON.parse(String(init.body));
+ capturedBodies.push(body);
+ if (capturedBodies.length === 1) {
+ return new Response(OVH_422_BODY, {
+ status: 422,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ return new Response(JSON.stringify({ ok: true }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+
+ try {
+ const result = await executor.execute({
+ model: "qwen3-coder-30b-a3b-instruct",
+ body: { reasoning_effort: "xhigh" },
+ stream: false,
+ credentials: {},
+ });
+ assert.equal(capturedBodies.length, 2);
+ assert.equal(capturedBodies[0].reasoning_effort, "xhigh");
+ assert.equal(capturedBodies[1].reasoning_effort, "high");
+ assert.equal(
+ getLearnedReasoningEffort("openai-compatible-chat-eaff6869", "qwen3-coder-30b-a3b-instruct"),
+ "high"
+ );
+ assert.equal(result.response.status, 200);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test("a second request for the same provider+model sends the learned value on the first try", async () => {
+ const executor = new SimpleExecutor();
+ const originalFetch = globalThis.fetch;
+ const capturedBodies: Record[] = [];
+
+ globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => {
+ const body = JSON.parse(String(init.body));
+ capturedBodies.push(body);
+ return new Response(JSON.stringify({ ok: true }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+
+ try {
+ recordLearnedReasoningEffort(
+ "openai-compatible-chat-eaff6869",
+ "qwen3-coder-30b-a3b-instruct",
+ ["none", "high", "medium", "low", "minimal"]
+ );
+ await executor.execute({
+ model: "qwen3-coder-30b-a3b-instruct",
+ body: { reasoning_effort: "xhigh" },
+ stream: false,
+ credentials: {},
+ });
+ assert.equal(capturedBodies.length, 1);
+ assert.equal(capturedBodies[0].reasoning_effort, "high");
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
diff --git a/tests/unit/reasoning-effort-learned-capability.test.ts b/tests/unit/reasoning-effort-learned-capability.test.ts
new file mode 100644
index 000000000000..210a451341e5
--- /dev/null
+++ b/tests/unit/reasoning-effort-learned-capability.test.ts
@@ -0,0 +1,91 @@
+import { test, after, beforeEach } from "node:test";
+import assert from "node:assert/strict";
+import { sanitizeReasoningEffortForProvider } from "../../open-sse/executors/base/reasoningEffort.ts";
+import {
+ recordLearnedReasoningEffort,
+ __test_resetLearnedReasoningEffortCaps,
+} from "../../open-sse/services/learnedReasoningEffortCaps.ts";
+
+beforeEach(() => {
+ __test_resetLearnedReasoningEffortCaps();
+});
+
+after(() => {
+ __test_resetLearnedReasoningEffortCaps();
+});
+
+test("unregistered/custom provider+model: no learned cap yet sends xhigh unchanged", () => {
+ const body = { reasoning_effort: "xhigh" };
+ const result = sanitizeReasoningEffortForProvider(
+ body,
+ "openai-compatible-chat-eaff6869",
+ "qwen3-coder-30b-a3b-instruct"
+ ) as { reasoning_effort: string };
+ assert.equal(result.reasoning_effort, "xhigh");
+});
+
+test("unregistered/custom provider+model: a learned cap clamps xhigh down to it", () => {
+ recordLearnedReasoningEffort("openai-compatible-chat-eaff6869", "qwen3-coder-30b-a3b-instruct", [
+ "none",
+ "high",
+ "medium",
+ "low",
+ "minimal",
+ ]);
+ const body = { reasoning_effort: "xhigh" };
+ const result = sanitizeReasoningEffortForProvider(
+ body,
+ "openai-compatible-chat-eaff6869",
+ "qwen3-coder-30b-a3b-instruct"
+ ) as { reasoning_effort: string };
+ assert.equal(result.reasoning_effort, "high");
+});
+
+test("learned cap only clamps when the requested effort is above it", () => {
+ recordLearnedReasoningEffort("acme", "model-x", ["none", "low", "medium"]);
+ const body = { reasoning_effort: "low" };
+ const result = sanitizeReasoningEffortForProvider(body, "acme", "model-x") as {
+ reasoning_effort: string;
+ };
+ assert.equal(result.reasoning_effort, "low");
+});
+
+test("registry says supportsXHighEffort:false (and no supportsMax path) with a learned cap below 'high': uses the learned cap, not the hardcoded 'high'", () => {
+ // claude-haiku-4-5 is registered with supportsXHighEffort:false
+ // (open-sse/config/providers/registry/claude/index.ts) and its family is
+ // excluded from supportsClaudeMaxEffort (CLAUDE_MAX_EFFORT_UNSUPPORTED_FAMILY_PATTERNS
+ // in providerModels.ts), so it reaches the hardcoded-"high" line today —
+ // a real registry-covered case. Teach a lower cap and confirm it wins.
+ recordLearnedReasoningEffort("claude", "claude-haiku-4-5-20251001", ["none", "low", "medium"]);
+ const body = { reasoning_effort: "xhigh" };
+ const result = sanitizeReasoningEffortForProvider(
+ body,
+ "claude",
+ "claude-haiku-4-5-20251001"
+ ) as {
+ reasoning_effort: string;
+ };
+ assert.equal(result.reasoning_effort, "medium");
+});
+
+test("registry says supportsXHighEffort:false with no learned cap: falls back to hardcoded 'high' (unchanged behavior)", () => {
+ const body = { reasoning_effort: "xhigh" };
+ const result = sanitizeReasoningEffortForProvider(
+ body,
+ "claude",
+ "claude-haiku-4-5-20251001"
+ ) as {
+ reasoning_effort: string;
+ };
+ assert.equal(result.reasoning_effort, "high");
+});
+
+test("deepseek's non-ordinal max<->xhigh translation is untouched by the learned-cap catch-all", () => {
+ recordLearnedReasoningEffort("deepseek", "deepseek-v4", ["none", "low"]);
+ const body = { reasoning_effort: "xhigh" };
+ const result = sanitizeReasoningEffortForProvider(body, "deepseek", "deepseek-v4") as {
+ reasoning_effort: string;
+ };
+ // deepseek's special case returns early — xhigh -> max, never reaches the catch-all.
+ assert.equal(result.reasoning_effort, "max");
+});
diff --git a/tests/unit/reasoning-input-policy-summary-11108.test.ts b/tests/unit/reasoning-input-policy-summary-11108.test.ts
new file mode 100644
index 000000000000..4317225dfb2b
--- /dev/null
+++ b/tests/unit/reasoning-input-policy-summary-11108.test.ts
@@ -0,0 +1,145 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+const { applyReasoningInputPolicy } =
+ await import("../../open-sse/services/reasoningInputPolicy.ts");
+
+test("#11108 applyReasoningInputPolicy defaults summary on a kept opaque reasoning item", () => {
+ const body: Record = {
+ input: [
+ {
+ type: "reasoning",
+ id: "rs_example",
+ encrypted_content: "opaque-blob",
+ },
+ ],
+ };
+
+ applyReasoningInputPolicy(body, "responses", {
+ provider: "opencode",
+ preserveEncryptedReasoning: true,
+ });
+
+ const input = body.input as Record[];
+ assert.equal(input.length, 1);
+ assert.deepEqual(input[0].summary, []);
+});
+
+test("#11108 applyReasoningInputPolicy preserves an existing summary on a kept reasoning item", () => {
+ const body: Record = {
+ input: [
+ {
+ type: "reasoning",
+ id: "rs_example",
+ encrypted_content: "opaque-blob",
+ summary: [{ type: "summary_text", text: "Planning." }],
+ },
+ ],
+ };
+
+ applyReasoningInputPolicy(body, "responses", {
+ provider: "opencode",
+ preserveEncryptedReasoning: true,
+ });
+
+ const input = body.input as Record[];
+ assert.deepEqual(input[0].summary, [{ type: "summary_text", text: "Planning." }]);
+});
+
+test("#11108 applyReasoningInputPolicy defaults summary on an opaque item surviving incompatible-drop", () => {
+ // Mixed item (plaintext + opaque) on an opaque-only transport is incompatible;
+ // dropIncompatibleResponsesReasoning() strips the plaintext content but keeps
+ // the opaque item alive — it must still get a default `summary`.
+ const body: Record = {
+ input: [
+ {
+ type: "reasoning",
+ id: "rs_mixed",
+ content: [{ type: "reasoning_text", text: "inspect first" }],
+ encrypted_content: "opaque-blob",
+ },
+ ],
+ };
+
+ const result = applyReasoningInputPolicy(body, "responses", {
+ provider: "codex",
+ onIncompatibleReasoning: "drop",
+ });
+
+ assert.equal(result.incompatibleReasoning, false);
+ const input = body.input as Record[];
+ assert.equal(input.length, 1);
+ assert.equal(input[0].content, undefined);
+ assert.equal(input[0].encrypted_content, "opaque-blob");
+ assert.deepEqual(input[0].summary, []);
+});
+
+test("#11108 applyReasoningInputPolicy strips a non-string id on a kept opaque reasoning item", () => {
+ // Same gap class as the summary fix above: opencode/zen also omits `id`
+ // entirely (surfaced by the client as `id: null`) on opaque-only reasoning
+ // items instead of a `rs_...` string. Replaying that shape verbatim trips
+ // strict Responses-API validators with "Expected 'id' to be a string."
+ const body: Record = {
+ input: [
+ {
+ type: "reasoning",
+ id: null,
+ encrypted_content: "opaque-blob",
+ },
+ ],
+ };
+
+ applyReasoningInputPolicy(body, "responses", {
+ provider: "opencode",
+ preserveEncryptedReasoning: true,
+ });
+
+ const input = body.input as Record[];
+ assert.equal(input.length, 1);
+ assert.equal("id" in input[0], false);
+});
+
+test("#11108 applyReasoningInputPolicy strips a non-string id on a non-reasoning item (function_call)", () => {
+ // Same gap class, generic branch: any non-"reasoning" input item (function_call,
+ // message, ...) only stripped `id` when it was already a valid string, so a
+ // malformed `id` (e.g. `null`, mirroring the opencode/zen omission pattern)
+ // on a function_call item survived replay untouched.
+ const body: Record = {
+ input: [
+ {
+ type: "function_call",
+ id: null,
+ call_id: "call_abc",
+ name: "bash",
+ arguments: "{}",
+ },
+ ],
+ };
+
+ applyReasoningInputPolicy(body, "responses", { provider: "opencode" });
+
+ const input = body.input as Record[];
+ assert.equal(input.length, 1);
+ assert.equal("id" in input[0], false);
+ assert.equal(input[0].call_id, "call_abc");
+});
+
+test("#11108 applyReasoningInputPolicy preserves a valid string id on a kept opaque reasoning item", () => {
+ const body: Record = {
+ input: [
+ {
+ type: "reasoning",
+ id: "rs_example",
+ encrypted_content: "opaque-blob",
+ },
+ ],
+ };
+
+ applyReasoningInputPolicy(body, "responses", {
+ provider: "opencode",
+ preserveEncryptedReasoning: true,
+ });
+
+ const input = body.input as Record[];
+ assert.equal(input[0].id, "rs_example");
+});
diff --git a/tests/unit/remove-hackclub-11118.test.ts b/tests/unit/remove-hackclub-11118.test.ts
new file mode 100644
index 000000000000..9dc39fe1b32d
--- /dev/null
+++ b/tests/unit/remove-hackclub-11118.test.ts
@@ -0,0 +1,7 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { REGISTRY } from "../../open-sse/config/providers/index.ts";
+
+test("hackclub provider is removed from REGISTRY", () => {
+ assert.equal("hackclub" in REGISTRY, false);
+});
diff --git a/tests/unit/repro-10990-v0-vercel-web-static-models.test.ts b/tests/unit/repro-10990-v0-vercel-web-static-models.test.ts
new file mode 100644
index 000000000000..e8de722b905f
--- /dev/null
+++ b/tests/unit/repro-10990-v0-vercel-web-static-models.test.ts
@@ -0,0 +1,25 @@
+/**
+ * #10990 — v0-vercel-web (a web-cookie codegen provider) shipped a web-cookie
+ * executor but no registry `models`, no discovery config, and no static-catalog
+ * entry, so its "Import from /models" fell through to the models route's tail 400
+ * ("Provider v0-vercel-web does not support models listing"). Adding a
+ * `v0-vercel-web` entry to STATIC_MODEL_PROVIDERS gives the models route a local
+ * catalog to serve (same class as the #6269 venice-web / #7820 amazon-q fix).
+ *
+ * Kept as a standalone unit against the pure `getStaticModelsForProvider` resolver
+ * rather than extending the frozen `provider-models-route.test.ts` god-file.
+ */
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { getStaticModelsForProvider } from "../../src/lib/providers/staticModels.ts";
+
+test("#10990 v0-vercel-web resolves a non-empty static local catalog", () => {
+ const models = getStaticModelsForProvider("v0-vercel-web");
+ assert.ok(models && models.length > 0, "v0-vercel-web should expose a static catalog");
+ const ids = models.map((m) => m.id);
+ assert.ok(
+ ids.includes("v0-1.5-lg"),
+ `expected v0-1.5-lg in [${ids.join(", ")}]`
+ );
+});
\ No newline at end of file
diff --git a/tests/unit/responses-handler.test.ts b/tests/unit/responses-handler.test.ts
index 7afbe12d318b..3b4d4b7cdec0 100644
--- a/tests/unit/responses-handler.test.ts
+++ b/tests/unit/responses-handler.test.ts
@@ -10,7 +10,6 @@ process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { handleResponsesCore } = await import("../../open-sse/handlers/responsesHandler.ts");
-const { COMMAND_CODE_VERSION } = await import("../../open-sse/executors/commandCode.ts");
const originalFetch = globalThis.fetch;
@@ -354,26 +353,31 @@ test("handleResponsesCore transforms Command Code executor SSE through Responses
input: "hello command code",
},
responseFactory() {
+ // /provider/v1/chat/completions returns standard OpenAI SSE (#10265).
+ const chunk = (delta: Record) =>
+ `data: ${JSON.stringify({
+ id: "c1",
+ object: "chat.completion.chunk",
+ model: "gpt-5.4-mini",
+ choices: [{ index: 0, delta }],
+ })}\n\n`;
return new Response(
[
- `data: ${JSON.stringify({ type: "text-delta", text: "command" })}`,
- "",
- `data: ${JSON.stringify({ type: "reasoning-delta", text: "thinking" })}`,
- "",
- `data: ${JSON.stringify({ type: "finish", finishReason: "stop" })}`,
- "",
- ].join("\n"),
- { status: 200, headers: { "Content-Type": "application/x-ndjson" } }
+ chunk({ role: "assistant" }),
+ chunk({ content: "command" }),
+ chunk({}),
+ ].join("") + "data: [DONE]\n\n",
+ { status: 200, headers: { "Content-Type": "text/event-stream" } }
);
},
});
assert.equal(result.success, true);
- assert.equal(call.url, "https://api.commandcode.ai/alpha/generate");
+ assert.equal(call.url, "https://api.commandcode.ai/provider/v1/chat/completions");
assert.equal(call.headers.Authorization, "Bearer cc_test_key");
- assert.equal(call.headers["x-command-code-version"], COMMAND_CODE_VERSION);
- assert.equal(call.body.params.model, "gpt-5.4-mini");
- assert.equal(call.body.params.stream, true);
+ assert.equal(call.headers["x-command-code-version"], undefined);
+ assert.equal(call.body.model, "gpt-5.4-mini");
+ assert.equal(call.body.stream, true);
const sse = await result.response.text();
assert.match(sse, /event: response\.created/);
diff --git a/tests/unit/responses-input-sanitizer-name.test.ts b/tests/unit/responses-input-sanitizer-name.test.ts
index 7ac09604c7bc..4eec2d6c10e0 100644
--- a/tests/unit/responses-input-sanitizer-name.test.ts
+++ b/tests/unit/responses-input-sanitizer-name.test.ts
@@ -73,6 +73,23 @@ test("keeps valid server reasoning item ids", () => {
assert.equal(result[0].id, "rs_123");
});
+test("strips a non-string reasoning item id instead of passing it through (#11108)", () => {
+ // Same gap class fixed in reasoningInputPolicy.ts: some upstreams (e.g.
+ // opencode/zen) send `id: null` instead of omitting it. The previous
+ // `typeof record.id !== "string"` guard returned the record unchanged in
+ // that case, letting a malformed id reach a strict Responses-API upstream.
+ const items = [
+ {
+ id: null,
+ type: "reasoning",
+ summary: [{ type: "summary_text", text: "cached reasoning" }],
+ },
+ ];
+ const result = sanitizeResponsesInputItems(items) as Array>;
+ assert.equal("id" in result[0], false);
+ assert.equal(result[0].type, "reasoning");
+});
+
test("normalizes user image_url content parts to input_image", () => {
const items = [
{
diff --git a/tests/unit/responses-parallel-tool-calls-index.test.ts b/tests/unit/responses-parallel-tool-calls-index.test.ts
new file mode 100644
index 000000000000..f825f8700bac
--- /dev/null
+++ b/tests/unit/responses-parallel-tool-calls-index.test.ts
@@ -0,0 +1,294 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const { openaiResponsesToOpenAIResponse } =
+ await import("../../open-sse/translator/response/openai-responses.ts");
+
+// Issue: 2+ `function_call` items opened (response.output_item.added) before any of
+// them closes (response.output_item.done) — a genuine parallel tool-call dispatch —
+// causes `state.toolCallIndex` (only incremented in the `.done` handler) to stay at 0
+// for every "added" header chunk. Clients that key their tool-call accumulator by
+// `delta.tool_calls[].index` (e.g. opencode's github-copilot chat-language-model
+// stream parser) then see the *first* `.done` argument chunk at index 1/2 with no
+// prior header and no `id`, and throw "Expected 'id' to be a string."
+test("Responses -> OpenAI: parallel function_call items get distinct index+id on the added header", () => {
+ const state = {};
+
+ const added0 = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.added",
+ item: { type: "function_call", call_id: "call_0", name: "task" },
+ },
+ state
+ );
+ const added1 = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.added",
+ item: { type: "function_call", call_id: "call_1", name: "task" },
+ },
+ state
+ );
+ const added2 = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.added",
+ item: { type: "function_call", call_id: "call_2", name: "task" },
+ },
+ state
+ );
+
+ const headers = [added0, added1, added2].map((r) => r.choices[0].delta.tool_calls[0]);
+
+ assert.deepEqual(
+ headers.map((h) => h.index),
+ [0, 1, 2],
+ "each parallel tool call must get its own header index, not all 0"
+ );
+ assert.deepEqual(
+ headers.map((h) => h.id),
+ ["call_0", "call_1", "call_2"]
+ );
+
+ const done0 = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.done",
+ item: { type: "function_call", call_id: "call_0", name: "task", arguments: '{"i":0}' },
+ },
+ state
+ );
+ const done1 = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.done",
+ item: { type: "function_call", call_id: "call_1", name: "task", arguments: '{"i":1}' },
+ },
+ state
+ );
+ const done2 = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.done",
+ item: { type: "function_call", call_id: "call_2", name: "task", arguments: '{"i":2}' },
+ },
+ state
+ );
+
+ assert.deepEqual(
+ [done0, done1, done2].map((r) => r.choices[0].delta.tool_calls[0].index),
+ [0, 1, 2],
+ "argument chunks must reuse the SAME index assigned at .added time for each call_id"
+ );
+});
+
+test("Responses -> OpenAI: parallel calls closed out of order keep their own index", () => {
+ const state = {};
+
+ for (const callId of ["call_a", "call_b", "call_c"]) {
+ openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.added",
+ item: { type: "function_call", call_id: callId, name: "task" },
+ },
+ state
+ );
+ }
+
+ // Close in reverse order: c, then a, then b.
+ const doneC = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.done",
+ item: { type: "function_call", call_id: "call_c", name: "task", arguments: "{}" },
+ },
+ state
+ );
+ const doneA = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.done",
+ item: { type: "function_call", call_id: "call_a", name: "task", arguments: "{}" },
+ },
+ state
+ );
+ const doneB = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.done",
+ item: { type: "function_call", call_id: "call_b", name: "task", arguments: "{}" },
+ },
+ state
+ );
+
+ assert.equal(doneC.choices[0].delta.tool_calls[0].index, 2);
+ assert.equal(doneA.choices[0].delta.tool_calls[0].index, 0);
+ assert.equal(doneB.choices[0].delta.tool_calls[0].index, 1);
+});
+
+test("Responses -> OpenAI: argument deltas interleaved across 2 parallel calls do not get glued together", () => {
+ const state = {};
+
+ openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.added",
+ item: { type: "function_call", call_id: "call_x", name: "Read", id: "fc_call_x" },
+ },
+ state
+ );
+ openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.added",
+ item: { type: "function_call", call_id: "call_y", name: "Read", id: "fc_call_y" },
+ },
+ state
+ );
+
+ // Interleave argument deltas by item_id — x, y, x, y — before either closes.
+ openaiResponsesToOpenAIResponse(
+ { type: "response.function_call_arguments.delta", item_id: "fc_call_x", delta: '{"filePath"' },
+ state
+ );
+ openaiResponsesToOpenAIResponse(
+ { type: "response.function_call_arguments.delta", item_id: "fc_call_y", delta: '{"filePath"' },
+ state
+ );
+ openaiResponsesToOpenAIResponse(
+ { type: "response.function_call_arguments.delta", item_id: "fc_call_x", delta: ':"/a.txt"}' },
+ state
+ );
+ openaiResponsesToOpenAIResponse(
+ { type: "response.function_call_arguments.delta", item_id: "fc_call_y", delta: ':"/b.txt"}' },
+ state
+ );
+
+ const doneX = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.done",
+ item: { type: "function_call", call_id: "call_x", name: "Read" },
+ },
+ state
+ );
+ const doneY = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.done",
+ item: { type: "function_call", call_id: "call_y", name: "Read" },
+ },
+ state
+ );
+
+ assert.equal(doneX.choices[0].delta.tool_calls[0].function.arguments, '{"filePath":"/a.txt"}');
+ assert.equal(doneY.choices[0].delta.tool_calls[0].function.arguments, '{"filePath":"/b.txt"}');
+});
+
+test("Responses -> OpenAI: a deferred (nameless) call that never resolves a name never consumes an index", () => {
+ const state = {};
+
+ openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.added",
+ item: { type: "function_call", call_id: "call_deferred", name: "" },
+ },
+ state
+ );
+ const done = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.done",
+ item: { type: "function_call", call_id: "call_deferred", name: " " },
+ },
+ state
+ );
+
+ assert.equal(done, null);
+ assert.equal(state.toolCallIndex, 0);
+});
+
+test("Responses -> OpenAI: argument deltas interleaved across 2 parallel calls resolve by output_index when the upstream omits item_id", () => {
+ const state = {};
+
+ openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.added",
+ output_index: 0,
+ item: { type: "function_call", call_id: "call_p", name: "Read" },
+ },
+ state
+ );
+ openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.added",
+ output_index: 1,
+ item: { type: "function_call", call_id: "call_q", name: "Read" },
+ },
+ state
+ );
+
+ // No item_id on any of these deltas — only output_index, which the Responses API
+ // guarantees on every streamed event regardless of whether item_id is also sent.
+ openaiResponsesToOpenAIResponse(
+ { type: "response.function_call_arguments.delta", output_index: 0, delta: '{"filePath"' },
+ state
+ );
+ openaiResponsesToOpenAIResponse(
+ { type: "response.function_call_arguments.delta", output_index: 1, delta: '{"filePath"' },
+ state
+ );
+ openaiResponsesToOpenAIResponse(
+ { type: "response.function_call_arguments.delta", output_index: 0, delta: ':"/p.txt"}' },
+ state
+ );
+ openaiResponsesToOpenAIResponse(
+ { type: "response.function_call_arguments.delta", output_index: 1, delta: ':"/q.txt"}' },
+ state
+ );
+
+ const doneP = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.done",
+ item: { type: "function_call", call_id: "call_p", name: "Read" },
+ },
+ state
+ );
+ const doneQ = openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.done",
+ item: { type: "function_call", call_id: "call_q", name: "Read" },
+ },
+ state
+ );
+
+ assert.equal(doneP.choices[0].delta.tool_calls[0].function.arguments, '{"filePath":"/p.txt"}');
+ assert.equal(doneQ.choices[0].delta.tool_calls[0].function.arguments, '{"filePath":"/q.txt"}');
+});
+
+test("Responses -> OpenAI: 2 parallel Agent calls still open at stream end each get their own flush chunk", () => {
+ const state = {};
+
+ openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.added",
+ item: { type: "function_call", call_id: "call_agent0", name: "Agent" },
+ },
+ state
+ );
+ openaiResponsesToOpenAIResponse(
+ { type: "response.function_call_arguments.delta", output_index: 0, delta: '{"task":"a"}' },
+ state
+ );
+ openaiResponsesToOpenAIResponse(
+ {
+ type: "response.output_item.added",
+ item: { type: "function_call", call_id: "call_agent1", name: "Agent" },
+ },
+ state
+ );
+ openaiResponsesToOpenAIResponse(
+ { type: "response.function_call_arguments.delta", output_index: 1, delta: '{"task":"b"}' },
+ state
+ );
+
+ // Stream ends (chunk === null) before either call's output_item.done arrives.
+ const flushed = openaiResponsesToOpenAIResponse(null, state);
+
+ assert.ok(Array.isArray(flushed));
+ const argChunks = flushed.filter((c) => c.choices[0].delta.tool_calls);
+ assert.deepEqual(
+ argChunks.map((c) => c.choices[0].delta.tool_calls[0].index).sort(),
+ [0, 1],
+ "each still-open parallel call must get its own flush chunk, at its own index"
+ );
+ const finalChunk = flushed[flushed.length - 1];
+ assert.equal(finalChunk.choices[0].finish_reason, "tool_calls");
+});
diff --git a/tests/unit/search-blocked-providers-11100.test.ts b/tests/unit/search-blocked-providers-11100.test.ts
new file mode 100644
index 000000000000..1c5c775e86ec
--- /dev/null
+++ b/tests/unit/search-blocked-providers-11100.test.ts
@@ -0,0 +1,11 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { getAllSearchProviders } from "../../open-sse/config/searchRegistry.ts";
+
+test("getAllSearchProviders filters out blocked providers", () => {
+ const all = getAllSearchProviders();
+ assert.ok(all.some((p) => p.id === "serper-search"));
+
+ const filtered = getAllSearchProviders(["serper-search"]);
+ assert.equal(filtered.some((p) => p.id === "serper-search"), false);
+});
diff --git a/tests/unit/services/ServiceSupervisor.test.ts b/tests/unit/services/ServiceSupervisor.test.ts
index fe0d6d82bc48..011395733adb 100644
--- a/tests/unit/services/ServiceSupervisor.test.ts
+++ b/tests/unit/services/ServiceSupervisor.test.ts
@@ -244,8 +244,16 @@ test("#6205: probeBeforeSpawn adopts a healthy existing instance (no spawn)", as
// healthy, running service as untrustworthy/stale. This asserts the resolved
// pid on adoption matches the real process actually holding the port.
test("adopted service resolves and records the real pid of the process holding the port", async () => {
- const healthServer = startHealthServer(29996);
- const cfg = { ...tickConfig("test-adopt", 29996), probeBeforeSpawn: true };
+ // Use a distinct port from the other probeBeforeSpawn adoption test above.
+ // Both originally shared 29996, and Node's undici fetch() keep-alive pool
+ // (used by isHealthy() in portProbe.ts) caches a socket keyed only by
+ // host:port, so the second test's fetch could be replayed over a stale
+ // connection from the first test's health server instance, failing the
+ // probe and flipping the adoption into a spurious "error" state. A separate
+ // port keeps each probe isolated from the other test's pooled connection
+ // (#10523).
+ const healthServer = startHealthServer(29995);
+ const cfg = { ...tickConfig("test-adopt", 29995), probeBeforeSpawn: true };
const sup = new ServiceSupervisor(cfg);
try {
diff --git a/tests/unit/stream-continuation-wiring.test.ts b/tests/unit/stream-continuation-wiring.test.ts
index 2f246e325d4b..36a6f445d1fc 100644
--- a/tests/unit/stream-continuation-wiring.test.ts
+++ b/tests/unit/stream-continuation-wiring.test.ts
@@ -47,9 +47,15 @@ async function collectText(stream: ReadableStream): Promise
const ROLE = 'data: {"choices":[{"delta":{"role":"assistant"}}]}\n\n';
const content = (s: string) => `data: {"choices":[{"delta":{"content":${JSON.stringify(s)}}}]}\n\n`;
+const reasoning = (s: string) =>
+ `data: {"choices":[{"delta":{"reasoning_content":${JSON.stringify(s)}}}]}\n\n`;
+const finishStopNoContent = 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n';
+const finishLengthNoContent = 'data: {"choices":[{"delta":{},"finish_reason":"length"}]}\n\n';
+
test("mid-stream continuation: stitches the suffix after a silent post-commit truncation", async () => {
- // Commits on chunk 1, emits "Hello wor", then ends WITHOUT a terminal marker (silent cut).
- const initial = streamFrom([ROLE, content("Hello wor")]);
+ // Commits on chunk 1, emits "Hello there world", then ends WITHOUT a terminal marker
+ // (silent cut).
+ const initial = streamFrom([ROLE, content("Hello there world")]);
let finalizeCount = 0;
let continueArg = "";
@@ -60,15 +66,22 @@ test("mid-stream continuation: stitches the suffix after a silent post-commit tr
now: steppingClock(),
continueStream: async (soFar: string) => {
continueArg = soFar;
- // The model re-emits a small overlap ("wor") which must be trimmed away.
- return streamFrom([ROLE, content("world!"), "data: [DONE]\n\n"]);
+ // The model re-emits only a partial tail of what was already sent ("there world",
+ // 11 chars — above the 8-char threshold, but NOT the full emitted text, unlike a
+ // full-string overlap this stays a discriminating test of trimContinuationOverlap's
+ // partial-tail trim, not just its "accept everything" path) before continuing.
+ return streamFrom([ROLE, content("there world, nice to meet you!"), "data: [DONE]\n\n"]);
},
});
const out = await collectText(stream);
const scan = scanOpenAiSseText(out);
- assert.equal(continueArg, "Hello wor", "continuation is prefilled with the text already sent");
- assert.equal(scan.text, "Hello world!", "client sees the full answer, overlap trimmed, exactly once");
+ assert.equal(continueArg, "Hello there world", "continuation is prefilled with the text already sent");
+ assert.equal(
+ scan.text,
+ "Hello there world, nice to meet you!",
+ "client sees the full answer, partial overlap trimmed, exactly once"
+ );
assert.equal(scan.terminal, true, "the recovered stream ends with a terminal marker");
assert.equal(finalizeCount, 1, "finalize runs exactly once");
});
@@ -80,7 +93,7 @@ test("mid-stream continuation: recovers a post-commit transport error too", asyn
const stream = createRecoverableStream(initial, async () => null, {
finalize: () => {},
now: steppingClock(),
- continueStream: async () => streamFrom([content("answer done."), "data: [DONE]\n\n"]),
+ continueStream: async () => streamFrom([content("Partial answer done."), "data: [DONE]\n\n"]),
});
const scan = scanOpenAiSseText(await collectText(stream));
assert.equal(scan.text, "Partial answer done.");
@@ -115,3 +128,167 @@ test("tool-call in flight is never continued (would corrupt tool JSON)", async (
await collectText(stream);
assert.equal(continued, false, "continuation must NOT fire once a tool call has started streaming");
});
+
+test("mid-stream continuation: a zero-overlap restart is rejected, never concatenated raw", async () => {
+ // Truncates silently after real, non-empty text — canContinue() fires.
+ const initial = streamFrom([ROLE, content("Tous les faits sont reunis")]);
+ let continuations = 0;
+ const stream = createRecoverableStream(initial, async () => null, {
+ finalize: () => {},
+ now: steppingClock(),
+ maxContinuations: 1,
+ continueStream: async () => {
+ continuations += 1;
+ // The model ignores the assistant prefill and restarts on an unrelated sentence —
+ // zero characters of overlap with what was already emitted.
+ return streamFrom([
+ content("Je complete le design - derniere verification"),
+ "data: [DONE]\n\n",
+ ]);
+ },
+ });
+ const out = await collectText(stream);
+ const scan = scanOpenAiSseText(out);
+ assert.equal(
+ scan.text,
+ "Tous les faits sont reunis",
+ "the unrelated restart must never be appended to the already-emitted text"
+ );
+ assert.equal(scan.terminal, true, "closes cleanly instead of leaving the client hanging");
+ assert.equal(continuations, 1, "bounded by maxContinuations — does not loop forever");
+});
+
+test("mid-stream continuation: a nonzero overlap below the threshold is rejected too", async () => {
+ // Genuine 4-character overlap ("pret"), well under the 8-char threshold — this is the
+ // false-negative case a naive `overlapChars === 0` check would miss (a restart that
+ // happens to share a short accidental fragment with the emitted tail): must still be
+ // treated as a suspected restart, not accepted as a genuine resume.
+ const initial = streamFrom([ROLE, content("Le design est pret")]);
+ let continuations = 0;
+ const stream = createRecoverableStream(initial, async () => null, {
+ finalize: () => {},
+ now: steppingClock(),
+ maxContinuations: 1,
+ continueStream: async () => {
+ continuations += 1;
+ // Shares only "pret" (4 chars) with the emitted tail, then diverges completely.
+ return streamFrom([content("pret a partir de zero"), "data: [DONE]\n\n"]);
+ },
+ });
+ const scan = scanOpenAiSseText(await collectText(stream));
+ assert.equal(
+ scan.text,
+ "Le design est pret",
+ "a below-threshold (but nonzero) overlap must not be accepted as a real resume"
+ );
+ assert.equal(continuations, 1);
+});
+
+test("mid-stream continuation: a real overlap at or above the threshold is still stitched correctly", async () => {
+ // Regression guard: the existing happy path (first test in this file, whose updated
+ // fixture re-emits the 11-char partial tail "there world") still passes below — this test
+ // adds an overlap AT the threshold boundary to prove Task 3's new check does not fire when
+ // it shouldn't.
+ const initial = streamFrom([ROLE, content("The answer to this question")]);
+ const stream = createRecoverableStream(initial, async () => null, {
+ finalize: () => {},
+ now: steppingClock(),
+ continueStream: async () =>
+ // "question" (8 chars) overlaps the tail of emittedText exactly at the threshold.
+ streamFrom([content("question is forty-two."), "data: [DONE]\n\n"]),
+ });
+ const scan = scanOpenAiSseText(await collectText(stream));
+ assert.equal(
+ scan.text,
+ "The answer to this question is forty-two.",
+ "an overlap meeting the threshold is trimmed and stitched, not rejected"
+ );
+});
+
+test("mid-stream continuation: a clean stop with reasoning-only output (no answer) triggers a continuation", async () => {
+ const initial = streamFrom([
+ ROLE,
+ reasoning("the model thinks through the problem here..."),
+ finishStopNoContent,
+ ]);
+ let continueArg = "__unset__";
+ const stream = createRecoverableStream(initial, async () => null, {
+ finalize: () => {},
+ now: steppingClock(),
+ continueStream: async (soFar: string) => {
+ continueArg = soFar;
+ return streamFrom([content("Here is the actual answer."), "data: [DONE]\n\n"]);
+ },
+ });
+ const out = await collectText(stream);
+ const scan = scanOpenAiSseText(out);
+ assert.equal(continueArg, "", "nothing usable was emitted — the re-request has an empty prefill");
+ assert.equal(
+ scan.text,
+ "Here is the actual answer.",
+ "the client gets a real answer instead of silence"
+ );
+ assert.equal(scan.terminal, true);
+});
+
+test("mid-stream continuation: a clean stop with truly empty output (no text, no reasoning) is left unchanged", async () => {
+ const initial = streamFrom([ROLE, finishStopNoContent]);
+ let continued = false;
+ const stream = createRecoverableStream(initial, async () => null, {
+ finalize: () => {},
+ now: steppingClock(),
+ continueStream: async () => {
+ continued = true;
+ return streamFrom([content("nope"), "data: [DONE]\n\n"]);
+ },
+ });
+ await collectText(stream);
+ assert.equal(
+ continued,
+ false,
+ "no reasoning trace means there is nothing to act on — do not guess"
+ );
+});
+
+test("mid-stream continuation: finish_reason 'length' with reasoning-only output does NOT trigger a continuation", async () => {
+ // Regression guard for a blocker found in cross-review: widening the gate to any
+ // terminal marker (instead of the literal finish_reason "stop") would wrongly spend a
+ // continuation attempt on a token-limit cutoff, which is out of this fix's scope.
+ const initial = streamFrom([
+ ROLE,
+ reasoning("the model was still thinking when it hit the token limit..."),
+ finishLengthNoContent,
+ ]);
+ let continued = false;
+ const stream = createRecoverableStream(initial, async () => null, {
+ finalize: () => {},
+ now: steppingClock(),
+ continueStream: async () => {
+ continued = true;
+ return streamFrom([content("nope"), "data: [DONE]\n\n"]);
+ },
+ });
+ await collectText(stream);
+ assert.equal(continued, false, "finish_reason 'length' is out of scope for this fix");
+});
+
+test("mid-stream continuation: real content alongside reasoning at a clean stop is left unchanged (non-regression)", async () => {
+ const initial = streamFrom([
+ ROLE,
+ reasoning("thinking..."),
+ content("The real answer."),
+ finishStopNoContent,
+ ]);
+ let continued = false;
+ const stream = createRecoverableStream(initial, async () => null, {
+ finalize: () => {},
+ now: steppingClock(),
+ continueStream: async () => {
+ continued = true;
+ return streamFrom([content("nope"), "data: [DONE]\n\n"]);
+ },
+ });
+ const scan = scanOpenAiSseText(await collectText(stream));
+ assert.equal(continued, false, "real content was delivered — nothing to recover");
+ assert.equal(scan.text, "The real answer.");
+});
diff --git a/tests/unit/stream-continuation.test.ts b/tests/unit/stream-continuation.test.ts
index caffe936df9d..8a1da37ce9d6 100644
--- a/tests/unit/stream-continuation.test.ts
+++ b/tests/unit/stream-continuation.test.ts
@@ -21,6 +21,30 @@ test("scanOpenAiSseText accumulates content deltas and flags an OpenAI-compat st
assert.equal(r.terminal, false);
});
+test("scanOpenAiSseText accumulates reasoning_content deltas separately from content", () => {
+ const sse =
+ 'data: {"choices":[{"delta":{"role":"assistant"}}]}\n\n' +
+ 'data: {"choices":[{"delta":{"reasoning_content":"thinking..."}}]}\n\n' +
+ 'data: {"choices":[{"delta":{"reasoning_content":" more"}}]}\n\n';
+ const r = scanOpenAiSseText(sse);
+ assert.equal(r.reasoningText, "thinking... more");
+ assert.equal(r.text, "", "reasoning_content must never leak into the visible text field");
+ assert.equal(r.parsedOpenAi, true);
+});
+
+test("scanOpenAiSseText captures the literal finish_reason value", () => {
+ const stop = scanOpenAiSseText('data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n');
+ assert.equal(stop.finishReason, "stop");
+
+ const length = scanOpenAiSseText(
+ 'data: {"choices":[{"delta":{"content":"x"},"finish_reason":"length"}]}\n\n'
+ );
+ assert.equal(length.finishReason, "length");
+
+ const none = scanOpenAiSseText('data: {"choices":[{"delta":{"content":"x"}}]}\n\n');
+ assert.equal(none.finishReason, null, "no finish_reason seen means null, not a guessed default");
+});
+
test("scanOpenAiSseText detects the terminal [DONE] marker", () => {
const r = scanOpenAiSseText('data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n');
assert.equal(r.text, "hi");
@@ -65,6 +89,15 @@ test("makeContinuationBody refuses bodies without a messages array or empty text
assert.equal(makeContinuationBody(null as never, "t"), null);
});
+test("makeContinuationBody accepts an empty prefill by re-sending the messages unchanged", () => {
+ const body = { model: "x", stream: true, messages: [{ role: "user", content: "hi" }] };
+ const out = makeContinuationBody(body, "");
+ assert.ok(out, "an empty prefill must still produce a re-request body, not null");
+ assert.equal(out!.messages.length, 1, "no empty assistant turn is appended");
+ assert.deepEqual(out!.messages[0], { role: "user", content: "hi" });
+ assert.equal(out!.stream, true);
+});
+
// ── trimContinuationOverlap ───────────────────────────────────────────────────
test("trimContinuationOverlap removes a duplicated seam so the join is append-only", () => {
diff --git a/tests/unit/stream-handler.test.ts b/tests/unit/stream-handler.test.ts
index d19a13f351e4..8c19c088021b 100644
--- a/tests/unit/stream-handler.test.ts
+++ b/tests/unit/stream-handler.test.ts
@@ -167,6 +167,41 @@ test("createDisconnectAwareStream treats cancel after Responses completed as suc
assert.equal(disconnectHandled, false);
});
+test("createDisconnectAwareStream recognizes a large Responses compaction completion", async () => {
+ let errorHandled = false;
+ const completed = `event: response.completed\ndata: ${JSON.stringify({
+ type: "response.completed",
+ response: {
+ status: "completed",
+ output: [{ type: "compaction", encrypted_content: "x".repeat(5000) }],
+ },
+ })}\n\n`;
+ const transformStream = {
+ readable: new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode(completed));
+ controller.close();
+ },
+ }),
+ writable: createNoopAbortWritable(),
+ };
+
+ const stream = createDisconnectAwareStream(
+ transformStream,
+ createStreamController({
+ clientResponseFormat: FORMATS.OPENAI_RESPONSES,
+ onError() {
+ errorHandled = true;
+ },
+ })
+ );
+ const text = await readStreamText(stream);
+
+ assert.equal(text, completed);
+ assert.equal(errorHandled, false);
+ assert.doesNotMatch(text, /response\.failed/);
+});
+
test("createDisconnectAwareStream: Gemini 503 high-demand error becomes SSE error chunk with message preserved", async () => {
const geminiMsg =
"[503]: This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.";
diff --git a/tests/unit/stream-payload-collector.test.ts b/tests/unit/stream-payload-collector.test.ts
index 610d0861a0d2..63b96c5eaf60 100644
--- a/tests/unit/stream-payload-collector.test.ts
+++ b/tests/unit/stream-payload-collector.test.ts
@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
const collector = await import("../../open-sse/utils/streamPayloadCollector.ts");
+import { splitConcatenatedToolCallArguments } from "../../open-sse/utils/streamPayloadCollector.ts";
test("compactStructuredStreamPayload returns null for null input", () => {
assert.equal(collector.compactStructuredStreamPayload(null), null);
@@ -42,7 +43,7 @@ test("buildStreamSummaryFromEvents handles empty array", () => {
});
test("buildStreamSummaryFromEvents handles single event", () => {
- const events = [{ data: { choices: [{ delta: { content: "hello" } }] } }];
+ const events = [{ index: 0, data: { choices: [{ delta: { content: "hello" } }] } }];
const result = collector.buildStreamSummaryFromEvents(events) as any;
assert.ok(result !== null);
assert.ok(typeof result === "object");
@@ -50,8 +51,8 @@ test("buildStreamSummaryFromEvents handles single event", () => {
test("buildStreamSummaryFromEvents handles multiple events", () => {
const events = [
- { data: { choices: [{ delta: { content: "hello" } }] } },
- { data: { choices: [{ delta: { content: " world" } }] } },
+ { index: 0, data: { choices: [{ delta: { content: "hello" } }] } },
+ { index: 1, data: { choices: [{ delta: { content: " world" } }] } },
];
const result = collector.buildStreamSummaryFromEvents(events) as any;
assert.ok(result !== null);
@@ -218,6 +219,106 @@ test("buildStreamSummaryFromEvents keeps two genuinely different interleaved too
assert.equal(toolCalls[1].function.arguments, '{"path":"b"}');
});
+// opencode/muse-spark-1.2-contributor-free (zen provider): the upstream SSE stream
+// never varies `index`/`id` for a 2nd/3rd tool_call of the SAME name in one turn —
+// every delta lands on the same accumulator key, so 3 distinct `task` calls
+// concatenate into a single malformed `arguments` string containing 3 back-to-back
+// JSON objects (the model emits the whole 3rd call already glued to the first two
+// in one delta — no true streaming needed to trigger it).
+test("buildStreamSummaryFromEvents splits a tool_call whose arguments are multiple concatenated JSON objects under the same id/index (muse-spark SSE index bug)", () => {
+ const glued =
+ '{"description":"Subagent OK 1","prompt":"Reply only \\"OK\\". Nothing else.","subagent_type":"general"}' +
+ '{"description":"Subagent OK 2","prompt":"Reply only \\"OK\\". Nothing else.","subagent_type":"general"}' +
+ '{"description":"Subagent OK 3","prompt":"Reply only \\"OK\\". Nothing else.","subagent_type":"general"}';
+
+ const events = [
+ toolCallEvent({
+ role: "assistant",
+ tool_calls: [
+ {
+ index: 0,
+ id: "call_task_glued",
+ type: "function",
+ function: { name: "task", arguments: glued },
+ },
+ ],
+ }),
+ toolCallEvent({}, "tool_calls"),
+ ];
+
+ const summary = collector.buildStreamSummaryFromEvents(
+ events,
+ "openai",
+ "opencode/muse-spark-1.2-contributor-free"
+ ) as ToolCallSummary;
+ const toolCalls = summary.choices[0].message.tool_calls;
+
+ assert.equal(
+ toolCalls.length,
+ 3,
+ `expected 3 split tool_calls, got ${toolCalls.length}: ${JSON.stringify(toolCalls)}`
+ );
+ for (const [i, tc] of toolCalls.entries()) {
+ assert.equal(tc.function.name, "task");
+ const parsed = JSON.parse(tc.function.arguments);
+ assert.equal(parsed.description, `Subagent OK ${i + 1}`);
+ }
+});
+
+test("buildStreamSummaryFromEvents leaves a single valid JSON arguments string untouched (no false-positive split)", () => {
+ const events = [
+ toolCallEvent({
+ role: "assistant",
+ tool_calls: [
+ {
+ index: 0,
+ id: "call_single",
+ type: "function",
+ function: { name: "write", arguments: '{"path":"a.txt","content":"{}"}' },
+ },
+ ],
+ }),
+ toolCallEvent({}, "tool_calls"),
+ ];
+
+ const summary = collector.buildStreamSummaryFromEvents(
+ events,
+ "openai",
+ "deepseek-v4-flash-free"
+ ) as ToolCallSummary;
+ const toolCalls = summary.choices[0].message.tool_calls;
+
+ assert.equal(toolCalls.length, 1);
+ assert.equal(toolCalls[0].function.arguments, '{"path":"a.txt","content":"{}"}');
+});
+
+test("buildStreamSummaryFromEvents leaves genuinely malformed (non-concatenated) JSON arguments untouched (no worse than before)", () => {
+ const events = [
+ toolCallEvent({
+ role: "assistant",
+ tool_calls: [
+ {
+ index: 0,
+ id: "call_broken",
+ type: "function",
+ function: { name: "write", arguments: '{"path":"a.txt", "content": tr' },
+ },
+ ],
+ }),
+ toolCallEvent({}, "tool_calls"),
+ ];
+
+ const summary = collector.buildStreamSummaryFromEvents(
+ events,
+ "openai",
+ "deepseek-v4-flash-free"
+ ) as ToolCallSummary;
+ const toolCalls = summary.choices[0].message.tool_calls;
+
+ assert.equal(toolCalls.length, 1);
+ assert.equal(toolCalls[0].function.arguments, '{"path":"a.txt", "content": tr');
+});
+
type OpenAIStreamSummary = {
choices: Array<{
finish_reason: string;
@@ -313,3 +414,33 @@ test("#9315: getSummary() returns undefined when no format was configured (unaff
c.push({ choices: [{ index: 0, delta: { content: "hi" } }] });
assert.equal(c.getSummary(), undefined);
});
+
+test("splitConcatenatedToolCallArguments — two back-to-back JSON objects", () => {
+ const a = JSON.stringify({ tool: "x", args: "1" });
+ const b = JSON.stringify({ tool: "y", args: "2" });
+ const out = splitConcatenatedToolCallArguments(a + b);
+ assert.deepEqual(out, [a, b]); // >=2 valid values -> split (array of parts)
+});
+
+test("splitConcatenatedToolCallArguments — nested object + escaped quotes stay single JSON", () => {
+ const a = JSON.stringify({ a: 'he said "hi"', b: { c: 1 } });
+ const single = a; // a is ONE valid JSON object -> no split
+ const out = splitConcatenatedToolCallArguments(single);
+ assert.equal(out, null); // single valid JSON -> untouched (null)
+});
+
+test("splitConcatenatedToolCallArguments — braces/quotes inside strings exercise escaped scanner", () => {
+ // Two valid JSON values whose string bodies contain braces and escaped quotes.
+ // Concatenated they reach the inString/escaped state machine (not the JSON.parse
+ // fast path), so this covers the case the owner asked about.
+ const a = JSON.stringify({ cmd: 'echo "}{" ; x' });
+ const b = JSON.stringify({ cmd: "{[not json]}" });
+ const out = splitConcatenatedToolCallArguments(a + b);
+ assert.deepEqual(out, [a, b]); // >=2 valid values -> split into parts
+});
+
+test("splitConcatenatedToolCallArguments — top-level array is single value", () => {
+ const arr = JSON.stringify([{ tool: "x" }, { tool: "y" }]);
+ const out = splitConcatenatedToolCallArguments(arr);
+ assert.equal(out, null); // one value boundary (array) -> not split
+});
diff --git a/tests/unit/stream-recovery-toolcall.test.ts b/tests/unit/stream-recovery-toolcall.test.ts
new file mode 100644
index 000000000000..7f3a76534332
--- /dev/null
+++ b/tests/unit/stream-recovery-toolcall.test.ts
@@ -0,0 +1,178 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import {
+ createRecoverableStream,
+ TruncatedStreamError,
+ scanOpenAiSseText,
+} from "../../open-sse/services/streamRecovery.ts";
+
+const enc = new TextEncoder();
+
+// Deliver the SSE chunk on the first read, then error on the second read so the
+// holdback window has committed (post-commit truncation) before the cut.
+function makeStream(sse: string): ReadableStream {
+ let n = 0;
+ return new ReadableStream({
+ pull(c) {
+ n += 1;
+ if (n === 1) {
+ c.enqueue(enc.encode(sse));
+ return;
+ }
+ c.error(new TruncatedStreamError());
+ },
+ });
+}
+
+// A clock that jumps past HOLDBACK_MS on the second read so the very first pushed
+// chunk commits the holdback window immediately (post-commit truncation path).
+function jumpingClock(): () => number {
+ let t = 0;
+ return () => (t += 1000);
+}
+
+describe("scanOpenAiSseText: terminal vs in-flight tool call", () => {
+ it("tool_calls without finish_reason → inFlight true, terminal false", () => {
+ const sse =
+ 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","function":{"name":"lookup"}}]}}]}\n\n';
+ const r = scanOpenAiSseText(sse);
+ assert.equal(r.sawToolCall, true);
+ assert.equal(r.sawToolCallInFlight, true);
+ assert.equal(r.terminal, false);
+ });
+
+ it("complete tool_calls + finish_reason + [DONE] → terminal true, inFlight false", () => {
+ const sse =
+ 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","function":{"name":"lookup","arguments":"{}"}}]}}]}\n' +
+ 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n' +
+ "data: [DONE]\n\n";
+ const r = scanOpenAiSseText(sse);
+ assert.equal(r.sawToolCall, true);
+ assert.equal(r.terminal, true);
+ assert.equal(r.sawToolCallInFlight, false);
+ });
+
+ it("plain text → no tool call", () => {
+ const sse = 'data: {"choices":[{"index":0,"delta":{"content":"hello"}}]}\n\n';
+ const r = scanOpenAiSseText(sse);
+ assert.equal(r.sawToolCall, false);
+ assert.equal(r.sawToolCallInFlight, false);
+ assert.equal(r.terminal, false);
+ });
+
+ it("complete tool_calls WITHOUT [DONE] → terminal false, inFlight false (the actual fix)", () => {
+ // This is the case the original plan promised to unblock: the tool call itself is
+ // done (finish_reason: "tool_calls"), but the overall stream/turn has not sent its
+ // own terminal marker yet — a truncation right here is recoverable.
+ const sse =
+ 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","function":{"name":"lookup","arguments":"{}"}}]}}]}\n' +
+ 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n';
+ const r = scanOpenAiSseText(sse);
+ assert.equal(r.sawToolCall, true);
+ assert.equal(r.sawToolCallInFlight, false);
+ assert.equal(r.terminal, false);
+ });
+});
+
+describe("stream recovery does not duplicate an in-flight tool call", () => {
+ it("truncation with an in-flight tool call → no continuation", async () => {
+ let continued = false;
+ const sse =
+ 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"c1","function":{"name":"f"}}]}}]}\n\n';
+ const wrapped = createRecoverableStream(makeStream(sse), async () => null, {
+ finalize: () => {},
+ now: jumpingClock(),
+ continueStream: async () => {
+ continued = true;
+ return null;
+ },
+ });
+ const reader = wrapped.getReader();
+ try {
+ for (;;) {
+ const r = await reader.read();
+ if (r.done) break;
+ }
+ } catch {
+ // the in-flight tool call makes the stream close without continuing
+ }
+ assert.equal(continued, false);
+ });
+
+ it("truncation right after a completed tool call → continuation attempted (the real 91% gain)", async () => {
+ // Text was emitted, THEN the tool call completed (finish_reason: "tool_calls"), THEN
+ // the connection drops before a [DONE]/other terminal marker. Before this fix, the
+ // blunt `emittedToolCall` guard blocked recovery here even though the call itself is
+ // done and only trailing prose was lost — this is the exact case the plan promised
+ // to unblock and the pre-fix table proved was a no-op.
+ let continued = false;
+ const sse =
+ 'data: {"choices":[{"index":0,"delta":{"content":"Let me check that. "}}]}\n' +
+ 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"c1","function":{"name":"f","arguments":"{}"}}]}}]}\n' +
+ 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n';
+ const wrapped = createRecoverableStream(makeStream(sse), async () => null, {
+ finalize: () => {},
+ now: jumpingClock(),
+ continueStream: async () => {
+ continued = true;
+ return null;
+ },
+ });
+ const reader = wrapped.getReader();
+ try {
+ for (;;) {
+ const r = await reader.read();
+ if (r.done) break;
+ }
+ } catch {
+ // no-op
+ }
+ assert.equal(continued, true);
+ });
+
+ it("truncation of plain text → continuation attempted", async () => {
+ let continued = false;
+ const sse = 'data: {"choices":[{"index":0,"delta":{"content":"hello "}}]}\n\n';
+ const wrapped = createRecoverableStream(makeStream(sse), async () => null, {
+ finalize: () => {},
+ now: jumpingClock(),
+ continueStream: async () => {
+ continued = true;
+ return null;
+ },
+ });
+ const reader = wrapped.getReader();
+ try {
+ for (;;) {
+ const r = await reader.read();
+ if (r.done) break;
+ }
+ } catch {
+ // no-op
+ }
+ assert.equal(continued, true);
+ });
+
+ it("naive removal of the tool-call guard would duplicate a partial tool call", () => {
+ // The blunt `sawToolCall` flag is true for BOTH a complete tool call and a
+ // partial (in-flight) one. The new `sawToolCallInFlight` flag is the only
+ // signal that tells them apart: a naive guard keyed on `sawToolCall` would
+ // block the complete call AND let the partial one through to the
+ // continuation, where trimContinuationOverlap (text-only) cannot de-duplicate
+ // the replayed tool_calls arguments.
+ const ssePartial =
+ 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","function":{"name":"lookup","arguments":"{\\"q\\""}}]}}]}\n\n';
+ const sseFull =
+ 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","function":{"name":"lookup","arguments":"{\\"q\\":\\"x\\"}"}}]}}]}\n' +
+ 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n';
+ const scanPartial = scanOpenAiSseText(ssePartial);
+ const scanFull = scanOpenAiSseText(sseFull);
+ // The blunt flag cannot distinguish them.
+ assert.equal(scanPartial.sawToolCall, true);
+ assert.equal(scanFull.sawToolCall, true);
+ // The in-flight flag can — and that is what keeps canContinue false only for
+ // the partial tool call, so the continuation never replays it.
+ assert.equal(scanPartial.sawToolCallInFlight, true);
+ assert.equal(scanFull.sawToolCallInFlight, false);
+ });
+});
diff --git a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts
index cab5e9c37f53..8b3059236dd1 100644
--- a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts
+++ b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts
@@ -8,7 +8,7 @@ import { omitEncryptedReasoningForLog } from "../../src/lib/logPayloads.ts";
// Responses reasoning replay is target-scoped. Plaintext DeepSeek state and
// provider-generated opaque state are never interchangeable.
-test("unknown Responses targets reject opaque reasoning and ignore display summaries", () => {
+test("unknown Responses targets drop opaque reasoning and preserve display summaries (#10959)", () => {
const body: Record = {
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
@@ -24,11 +24,14 @@ test("unknown Responses targets reject opaque reasoning and ignore display summa
],
};
- const originalInput = structuredClone(body.input);
const result = applyReasoningInputPolicy(body, "responses");
- assert.equal(result.incompatibleReasoning, true);
- assert.deepEqual(body.input, originalInput, "rejection must not mutate the request");
+ assert.equal(result.incompatibleReasoning, false);
+ assert.deepEqual(body.input, [
+ { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
+ { type: "reasoning", summary: [{ text: "display only" }] },
+ { type: "function_call", name: "search", arguments: "{}", call_id: "call_1" },
+ ]);
});
test("unannotated targets preserve plaintext Responses reasoning without synthetic IDs", () => {
@@ -132,7 +135,7 @@ test("Chat drop removes opaque state while preserving plaintext and summary deta
]);
});
-test("DeepSeek rejects plaintext reasoning carrying opaque provider state", () => {
+test("DeepSeek projects plaintext reasoning carrying opaque provider state onto the plaintext transport (#10949)", () => {
for (const opaqueField of ["signature", "format"] as const) {
const body: Record = {
input: [
@@ -148,13 +151,11 @@ test("DeepSeek rejects plaintext reasoning carrying opaque provider state", () =
const result = applyReasoningInputPolicy(body, "responses", { provider: "deepseek" });
- assert.equal(result.incompatibleReasoning, true, opaqueField);
+ assert.equal(result.incompatibleReasoning, false, opaqueField);
assert.deepEqual(body.input, [
{
- id: "rs_mixed123",
type: "reasoning",
content: [{ type: "reasoning_text", text: "untrusted companion" }],
- [opaqueField]: "provider-state",
},
{ type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] },
]);
@@ -199,6 +200,49 @@ test("drop fallback removes only the incompatible active transport and preserves
assert.equal(opaqueReasoning.encrypted_content, "provider-state");
});
+test("mixed plaintext + opaque reasoning follows the target transport instead of rejecting (#10949)", () => {
+ const mixedReasoning = {
+ id: "rs_mixed",
+ type: "reasoning",
+ content: [{ type: "reasoning_text", text: "inspect first" }],
+ encrypted_content: "provider-state",
+ summary: [{ type: "summary_text", text: "display only" }],
+ };
+
+ // Plaintext target (deepseek): keep the portable plaintext, strip opaque state.
+ const toPlaintext: Record = {
+ input: [structuredClone(mixedReasoning)],
+ };
+ const plaintextResult = applyReasoningInputPolicy(toPlaintext, "responses", {
+ provider: "deepseek",
+ });
+ assert.equal(plaintextResult.incompatibleReasoning, false);
+ assert.deepEqual(toPlaintext.input, [
+ {
+ type: "reasoning",
+ content: [{ type: "reasoning_text", text: "inspect first" }],
+ summary: [{ type: "summary_text", text: "display only" }],
+ },
+ ]);
+
+ // Opaque target (openai): keep the provider state, strip the plaintext.
+ const toOpaque: Record = {
+ input: [structuredClone(mixedReasoning)],
+ };
+ const opaqueResult = applyReasoningInputPolicy(toOpaque, "responses", {
+ provider: "openai",
+ });
+ assert.equal(opaqueResult.incompatibleReasoning, false);
+ assert.deepEqual(toOpaque.input, [
+ {
+ id: "rs_mixed",
+ type: "reasoning",
+ encrypted_content: "provider-state",
+ summary: [{ type: "summary_text", text: "display only" }],
+ },
+ ]);
+});
+
test("drop fallback preserves reasoning when its transport is compatible", () => {
const body: Record = {
input: [
@@ -274,7 +318,11 @@ test("explicit custom target opt-in remains an opaque transport override", () =>
const result = applyReasoningInputPolicy(body, "responses", { preserveEncryptedReasoning: true });
assert.equal(result.incompatibleReasoning, false);
- assert.deepEqual(body.input, [{ type: "reasoning", encrypted_content: "encrypted-blob" }]);
+ // #11108: a kept opaque item defaults `summary` when the source omitted it —
+ // some upstreams reject `input[]` reasoning items missing the field entirely.
+ assert.deepEqual(body.input, [
+ { type: "reasoning", encrypted_content: "encrypted-blob", summary: [] },
+ ]);
});
test("preserved opaque reasoning remains redacted from log copies", () => {
diff --git a/tests/unit/t40-opencode-cli-tools-integration.test.ts b/tests/unit/t40-opencode-cli-tools-integration.test.ts
index 7a7c305bacd2..c1da020ff905 100644
--- a/tests/unit/t40-opencode-cli-tools-integration.test.ts
+++ b/tests/unit/t40-opencode-cli-tools-integration.test.ts
@@ -158,7 +158,13 @@ test("T40: OpenCode merge preserves unrelated config and updates only provider.o
github: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
});
assert.deepEqual(mergedConfig.provider.omniroute.models, {
- "cx/gpt-5.6-sol": { name: "GPT-5.6 Sol" },
+ "cx/gpt-5.6-sol": {
+ name: "GPT-5.6 Sol",
+ limit: {
+ context: 128_000,
+ output: 8_192,
+ },
+ },
});
});
diff --git a/tests/unit/terminal-status-origin.test.ts b/tests/unit/terminal-status-origin.test.ts
index f6de47eb6c1e..a4aacf767fc5 100644
--- a/tests/unit/terminal-status-origin.test.ts
+++ b/tests/unit/terminal-status-origin.test.ts
@@ -11,11 +11,11 @@ const { writeTerminalStatus } = await import("../../src/shared/utils/terminalSta
test.after(() => { core.resetDbInstance(); fs.rmSync(DIR, {recursive:true, force:true}); });
-function row(id: string){ return (core.getDbInstance() as any).prepare("SELECT is_active, test_status FROM provider_connections WHERE id=?").get(id); }
+function row(id: string){ return (core.getDbInstance() as unknown as Record).prepare("SELECT is_active, test_status FROM provider_connections WHERE id=?").get(id); }
test("probe-origin writeTerminalStatus records error but never deactivates", async () => {
- const conn = await createProviderConnection({ provider:"openai", authType:"apikey", name:"t11", apiKey:"sk-t11", isActive:true, testStatus:"active" } as any);
- const id = String((conn as any).id);
+ const conn = await createProviderConnection({ provider:"openai", authType:"apikey", name:"t11", apiKey:"sk-t11", isActive:true, testStatus:"active" } as unknown as Record);
+ const id = String((conn as unknown as Record).id);
await runAsProbe(async () => {
await writeTerminalStatus(id, { testStatus:"banned", isActive:false, lastError:"probe 403", errorCode:"403", lastErrorType:"FORBIDDEN" }, "probe");
});
@@ -25,8 +25,8 @@ test("probe-origin writeTerminalStatus records error but never deactivates", asy
});
test("production writeTerminalStatus deactivates on terminal", async () => {
- const conn = await createProviderConnection({ provider:"openai", authType:"apikey", name:"t11b", apiKey:"sk-t11b", isActive:true, testStatus:"active" } as any);
- const id = String((conn as any).id);
+ const conn = await createProviderConnection({ provider:"openai", authType:"apikey", name:"t11b", apiKey:"sk-t11b", isActive:true, testStatus:"active" } as unknown as Record